From 5f8e732c765a1b9d7054b1fb72712d7dff140671 Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Thu, 2 Jul 2026 05:12:07 +0000 Subject: [PATCH 01/11] docs(roadmap): graduate decision-to-code-proximity to a live roadmap [roadmap:decision-to-code-proximity] Implements the graduation step required by rac/roadmaps/deterministic-substrate.md (Tranche A, rank 1): the item moves out of future/, gains its execution ticket (ADR-093) and its requirement link (rac-decision-applies-to-scope, gap 7 of the relationship-vocabulary programme), and joins the substrate programme's Related Roadmaps. --- .../{future => }/decision-to-code-proximity.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) rename rac/roadmaps/{future => }/decision-to-code-proximity.md (90%) diff --git a/rac/roadmaps/future/decision-to-code-proximity.md b/rac/roadmaps/decision-to-code-proximity.md similarity index 90% rename from rac/roadmaps/future/decision-to-code-proximity.md rename to rac/roadmaps/decision-to-code-proximity.md index 0015cae1..c6cf15f5 100644 --- a/rac/roadmaps/future/decision-to-code-proximity.md +++ b/rac/roadmaps/decision-to-code-proximity.md @@ -3,14 +3,15 @@ schema_version: 1 id: RAC-KVTRP9G4CN2N type: roadmap --- -# RAC — Decision-to-Code Proximity (Future) +# RAC — Decision-to-Code Proximity ## Status Planned -Unscheduled — captured for future consideration from the team-scale (20+) market -research. It graduates out of `future/` into a versioned series when prioritised. +Scheduled — Tranche A, rank 1 of the `deterministic-substrate` programme. +Graduated out of `future/` per that programme's success measures (ADR-093, +ADR-094); execution is tracked on the epic referenced under Related Tickets. ## Context @@ -102,6 +103,15 @@ changes (the `freshness-and-drift-detection` tie-in). - adr-028 - adr-065 +## Related Requirements + +- rac-decision-applies-to-scope + ## Related Roadmaps - freshness-and-drift-detection +- deterministic-substrate + +## Related Tickets + +- itsthelore/rac-core#248 From 6288940c9727d78a17547dd0a05b77535bc3030a Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Thu, 2 Jul 2026 05:14:39 +0000 Subject: [PATCH 02/11] docs(decision): record ADR-098 applies-to scope and path lookup [roadmap:decision-to-code-proximity] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements rac/roadmaps/decision-to-code-proximity.md (contract step). Settles: the ## Applies To section on decisions (path globs or component labels), the entry grammar and fnmatchcase dialect, the blocking format lint vs advisory tree-match split (reconciling the gap-7 requirement with the ADR-087/096 exemption precedent), and the lookup contract — rac decisions PATH plus the sixth pinned MCP tool decisions_for_path (ADR-030 change, ADR-033 budget). Accepts rac-decision-applies-to-scope. --- ...cision-applies-to-scope-and-path-lookup.md | 196 ++++++++++++++++++ .../rac-decision-applies-to-scope.md | 4 +- rac/roadmaps/decision-to-code-proximity.md | 1 + 3 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 rac/decisions/adr-098-decision-applies-to-scope-and-path-lookup.md diff --git a/rac/decisions/adr-098-decision-applies-to-scope-and-path-lookup.md b/rac/decisions/adr-098-decision-applies-to-scope-and-path-lookup.md new file mode 100644 index 00000000..1297148d --- /dev/null +++ b/rac/decisions/adr-098-decision-applies-to-scope-and-path-lookup.md @@ -0,0 +1,196 @@ +--- +schema_version: 1 +id: RAC-KWGKWKEXP1TJ +type: decision +--- +# ADR-098: Decision Applies-To Scope and Path Lookup + +## Context + +A decision often governs a specific part of the repository, but says so only +in prose, so "which decisions govern the file I am editing?" is unanswerable +— gap 7 of the traceability audit (`rac-decision-applies-to-scope`) and the +lead item of the `decision-to-code-proximity` roadmap (Tranche A, rank 1 of +the `deterministic-substrate` programme). The corpus holds two precedents for +non-artifact references that this decision must reconcile: the gap +requirements say repo-path references are existence-checked by +`rac relationships --validate`, while the shipped external-reference family +(ADR-087 tickets, ADR-096 `verified_by`) is exempt from resolution and never +touches the disk. A further hard constraint discovered in implementation +planning: `rac relationships --validate` fails on ANY recorded issue +regardless of severity, so an existence signal that enters the issue list +would silently become a merge-gate blocker. + +## Decision + +### The `## Applies To` section, decisions only + +Decisions accept an optional `## Applies To` relationship section, one entry +per line. Entries are either repo-root-relative POSIX **path globs** or free +**component labels**. The edge is registered as `applies_to` in the +relationship-type registry — external (`external=True`, no in-corpus range, +no provider), directional, inverse `governs` — mirroring ADR-096's +`verified_by`. Version 1 is decisions-only; extending the section to other +artifact types is an additive follow-up, not part of this decision. + +### Entry grammar and discrimination + +After normalisation (strip one surrounding backtick pair, a leading `./`, +and a trailing `/`), an entry is classified deterministically: + +1. containing whitespace — a component label ("RAC Core"), recorded and + never resolved or checked; +2. else containing `/` or a glob metacharacter (`*`, `?`, `[`) — a path + glob; +3. else — a component label (`rac-core`). + +A repository-root file opts into path-hood explicitly by the `./` prefix +(`./pyproject.toml`). + +### Format lint, offline and blocking + +`rac validate` lints path-classified entries with a new +`malformed-applies-to` error: absolute paths, backslashes, `.` or `..` +segments, empty segments, unbalanced `[`, or an entry that normalises to +empty. The lint is purely syntactic — no filesystem access, no +configuration — in the style of the ADR-087 ticketing lint. Component +labels are never linted. + +### Matching semantics + +`governs(path, scope)` is true when `fnmatch.fnmatchcase(path, scope)` or +`fnmatch.fnmatchcase(path, scope + "/*")` holds over POSIX-normalised, +repo-root-relative strings. `fnmatchcase` — never `fnmatch` — so matching is +case-sensitive and platform-independent, matching git's view of paths. In +this dialect `*` crosses `/` (so a directory scope governs its whole +subtree via the second clause) and `**` behaves as `*`; the dialect is +pinned by a test battery so any standard-library drift fails loudly. + +### Existence is advisory, never blocking + +An `## Applies To` path scope that matches nothing in the working tree is +surfaced as an ADVISORY: a new `advisories` list on the relationship +validation result (additive JSON key, present only when non-empty, excluded +from `ok` and the exit code) and a warning-severity `rac doctor` finding +(`applies-to-unmatched-path`). It never enters the blocking issue list, is +kept out of SARIF, and is skipped entirely when no repository root can be +discovered (no `.rac/config.yaml` above the corpus). This satisfies the gap +requirement's "checked by `rac relationships --validate`" while honouring +ADR-096's exemption precedent — a moved path is the drift signal the +freshness gate consumes, not a merge blocker. + +### The path lookup, CLI and MCP + +A deterministic lookup returns the live decisions whose declared path +scopes govern a queried path, with their status and the matching scopes: + +- CLI: `rac decisions PATH [DIRECTORY] [--json]` — read-only; exit 2 on a + bad directory, exit 0 on any completed query including an empty result. +- MCP: a sixth pinned tool, `decisions_for_path`, on the lore server. The + tool surface grows from five to six — an ADR-030 contract change recorded + here. The payload mirrors `find_decisions` (`schema_version`, `path`, + `type`, `filter: "live-decisions"`, `match_count`, `matches` with + `id`/`type`/`title`/`status`/`path`/`scopes`) and inherits the ADR-033 + response budget with whole-item truncation. + +The lookup returns bindings and status, never verdicts (ADR-067): it asserts +which live decisions bind a path, not that a change is wrong. Liveness and +status come from the same seam the agent-rules export uses, so the two +surfaces can never disagree. + +## Consequences + +### Positive + +- Scoped grounding: an agent (or human) in `src/auth/` deterministically + retrieves the decisions that govern it, at the point of work. +- The declared decision→code join the freshness/drift gate requires now + exists as validated data. +- Both corpus precedents survive intact: format-linted like ADR-087, + resolution-exempt like ADR-096, with existence as a visible advisory. + +### Negative + +- Path scopes drift as code is refactored; by design this surfaces as an + advisory and, later, a freshness "suspect" signal — never silent rot, but + also never an automatic fix. +- The MCP tool surface grows by one pinned description that must remain + stable (ADR-030). + +### Risks + +- Free component labels are unresolvable by construction; mitigated by the + path form carrying all checkable semantics and labels being recorded + verbatim for humans. +- The fnmatch dialect could shift under a future Python; mitigated by the + pinned dialect test battery at the supported floor (3.11). + +## Status + +Accepted + +## Category + +Technical + +## Alternatives Considered + +### Blocking existence check in `rac relationships --validate` + +Rejected: the merge gate would depend on tree state outside `rac/`, and any +code refactor would break the corpus gate until artifacts caught up. +Advisory-first mirrors the recorded enforcement instinct — a guard that +cries wolf is worse than none (ADR-067). + +### Format-lint only, no tree check + +Rejected: scopes could rot silently until the freshness gate ships, and the +gap requirement's explicit "checked by `rac relationships --validate`" +would be unmet. + +### A new section name distinct from `## Applies To` + +Rejected: gap 7 already proposed `## Applies To` for exactly this scope; +one vocabulary serves both the relationship-vocabulary and +deterministic-substrate programmes instead of two overlapping mechanisms. + +### Extending `rac find` with a path flag + +Rejected: the token-ladder topic search and a glob-governance lookup have +different matching, result shapes, and evidence semantics; overloading one +command muddies both contracts. + +## Relationship to Other Decisions + +- ADR-016 / ADR-019: declared references, never inferred; the asset-style + validation instinct realised for code scopes. +- ADR-087 / ADR-096: the external-reference family this edge joins; + format-linted, resolution-exempt. +- ADR-074: the edge type derives from the relationship registry and appears + typed in the graph export. +- ADR-067: the lookup stays on the context-supply side of the boundary — + bindings, never verdicts. +- ADR-030 / ADR-033: the sixth pinned tool and its response budget. +- ADR-007: every surface change is additive. + +## Related Decisions + +- adr-016 +- adr-019 +- adr-087 +- adr-096 +- adr-074 +- adr-067 +- adr-030 +- adr-033 +- adr-007 + +## Related Requirements + +- rac-decision-applies-to-scope + +## Related Roadmaps + +- decision-to-code-proximity +- deterministic-substrate +- relationship-vocabulary diff --git a/rac/requirements/rac-decision-applies-to-scope.md b/rac/requirements/rac-decision-applies-to-scope.md index 1b013f37..cd9c151e 100644 --- a/rac/requirements/rac-decision-applies-to-scope.md +++ b/rac/requirements/rac-decision-applies-to-scope.md @@ -7,7 +7,7 @@ type: requirement ## Status -Proposed +Accepted ## Problem @@ -55,7 +55,9 @@ This is gap 7 of the traceability audit. - adr-016 - adr-007 +- adr-098 ## Related Roadmaps - relationship-vocabulary +- decision-to-code-proximity diff --git a/rac/roadmaps/decision-to-code-proximity.md b/rac/roadmaps/decision-to-code-proximity.md index c6cf15f5..dacc358c 100644 --- a/rac/roadmaps/decision-to-code-proximity.md +++ b/rac/roadmaps/decision-to-code-proximity.md @@ -102,6 +102,7 @@ changes (the `freshness-and-drift-detection` tie-in). - adr-074 - adr-028 - adr-065 +- adr-098 ## Related Requirements From a88e14ad2c1a31f96a89e0acb8dcf53d23f6dfee Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Thu, 2 Jul 2026 05:14:52 +0000 Subject: [PATCH 03/11] chore(agent-rules): regenerate after ADR-098 [roadmap:decision-to-code-proximity] --- .cursor/rules | 3 ++- .github/copilot-instructions.md | 3 ++- AGENTS.md | 3 ++- CLAUDE.md | 3 ++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.cursor/rules b/.cursor/rules index b138e349..99f2f0c4 100644 --- a/.cursor/rules +++ b/.cursor/rules @@ -1,4 +1,4 @@ - + ## Settled decisions (RAC) @@ -69,4 +69,5 @@ These decisions are already accepted. Do not re-open or contradict them; ask the - **RAC-KW6NQ4Y814N7** — ADR-094: Roadmaps Are Identified by Stable Codename, Not a vX.Y.Z Scope-Fence _(Process)_ - **RAC-KW8ZTVEQJ366** — ADR-095: rac-connectors Repackage, PyPI Rename, and Outbound Field Rename _(Architecture)_ - **RAC-KWBJJRZR84PT** — ADR-096: External-Target Verification Edge (`verified_by`) _(Technical)_ +- **RAC-KWGKWKEXP1TJ** — ADR-098: Decision Applies-To Scope and Path Lookup _(Technical)_ diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b138e349..99f2f0c4 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,4 +1,4 @@ - + ## Settled decisions (RAC) @@ -69,4 +69,5 @@ These decisions are already accepted. Do not re-open or contradict them; ask the - **RAC-KW6NQ4Y814N7** — ADR-094: Roadmaps Are Identified by Stable Codename, Not a vX.Y.Z Scope-Fence _(Process)_ - **RAC-KW8ZTVEQJ366** — ADR-095: rac-connectors Repackage, PyPI Rename, and Outbound Field Rename _(Architecture)_ - **RAC-KWBJJRZR84PT** — ADR-096: External-Target Verification Edge (`verified_by`) _(Technical)_ +- **RAC-KWGKWKEXP1TJ** — ADR-098: Decision Applies-To Scope and Path Lookup _(Technical)_ diff --git a/AGENTS.md b/AGENTS.md index b138e349..99f2f0c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ - + ## Settled decisions (RAC) @@ -69,4 +69,5 @@ These decisions are already accepted. Do not re-open or contradict them; ask the - **RAC-KW6NQ4Y814N7** — ADR-094: Roadmaps Are Identified by Stable Codename, Not a vX.Y.Z Scope-Fence _(Process)_ - **RAC-KW8ZTVEQJ366** — ADR-095: rac-connectors Repackage, PyPI Rename, and Outbound Field Rename _(Architecture)_ - **RAC-KWBJJRZR84PT** — ADR-096: External-Target Verification Edge (`verified_by`) _(Technical)_ +- **RAC-KWGKWKEXP1TJ** — ADR-098: Decision Applies-To Scope and Path Lookup _(Technical)_ diff --git a/CLAUDE.md b/CLAUDE.md index 10e1b899..94a85d87 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,7 @@ to the corpus artifact and they load through the imports below. - Previous series: `rac/roadmaps/v0.21.x-editor/` (complete) - Decisions (ADRs): `rac/decisions/` - + ## Settled decisions (RAC) @@ -94,4 +94,5 @@ These decisions are already accepted. Do not re-open or contradict them; ask the - **RAC-KW6NQ4Y814N7** — ADR-094: Roadmaps Are Identified by Stable Codename, Not a vX.Y.Z Scope-Fence _(Process)_ - **RAC-KW8ZTVEQJ366** — ADR-095: rac-connectors Repackage, PyPI Rename, and Outbound Field Rename _(Architecture)_ - **RAC-KWBJJRZR84PT** — ADR-096: External-Target Verification Edge (`verified_by`) _(Technical)_ +- **RAC-KWGKWKEXP1TJ** — ADR-098: Decision Applies-To Scope and Path Lookup _(Technical)_ From ccaa20d81fdea66604879f98867f32db13af5bfa Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Thu, 2 Jul 2026 05:18:36 +0000 Subject: [PATCH 04/11] feat(core): recognise ## Applies To on decisions and lint its path globs [roadmap:decision-to-code-proximity] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements rac/roadmaps/decision-to-code-proximity.md, Initiative 1 (ADR-098). Adds: - core/applies_to.py — the entry grammar (path glob vs component label), normalisation, format-lint reasons, and the fnmatchcase governs() matcher - the applies_to external edge in the relationship registry, mirroring verified_by (external, directional, no provider) - the section on the decision spec (append-only) and its schema description - the malformed-applies-to validate lint, syntactic and config-free Adding 'applies to' to EXTERNAL_SECTIONS routes the edge through every existing external-exempt branch: resolution, range, and status checks skip it; the graph export carries it unresolved. --- src/rac/core/applies_to.py | 89 ++++++++++++++++++++++++++++++ src/rac/core/artifacts.py | 4 ++ src/rac/core/relationship_types.py | 14 +++++ src/rac/core/validation.py | 41 ++++++++++++++ src/rac/services/relationships.py | 2 +- tests/test_schema.py | 1 + 6 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 src/rac/core/applies_to.py diff --git a/src/rac/core/applies_to.py b/src/rac/core/applies_to.py new file mode 100644 index 00000000..8f2b80e9 --- /dev/null +++ b/src/rac/core/applies_to.py @@ -0,0 +1,89 @@ +"""Applies-to scope grammar and matcher (ADR-098). + +A decision's ``## Applies To`` section declares the code it governs, one entry +per line. An entry is either a repo-root-relative POSIX **path glob** or a free +**component label**; the discrimination rule below is deterministic and total, +so interpretation never depends on the tree, the platform, or configuration +(ADR-002, ADR-066). Extraction stays verbatim (ADR-016) — this module owns the +*interpretation*: normalisation, classification, the format lint, and the +``governs`` matcher the path→decisions lookup uses. + +Matching uses :func:`fnmatch.fnmatchcase` — never ``fnmatch.fnmatch``, whose +``normcase`` makes results platform-dependent — over ``/``-separated strings, +case-sensitive everywhere, matching git's view of paths. In this dialect ``*`` +crosses ``/`` and ``**`` behaves as ``*``; the dialect is pinned by tests so a +standard-library change fails loudly rather than silently reordering matches. +""" + +from __future__ import annotations + +from fnmatch import fnmatchcase + +# Glob metacharacters that mark a whitespace-free entry as a path scope. +_GLOB_CHARS = ("*", "?", "[") + + +def normalize_entry(entry: str) -> str: + """Normalise a raw ``## Applies To`` line for classification and matching. + + Strips one surrounding backtick pair, a leading ``./`` (the explicit + path-hood marker for repo-root files), and a trailing ``/`` (directory + convention, not an error). The result is what the lint and matcher see. + """ + text = entry.strip() + if len(text) >= 2 and text.startswith("`") and text.endswith("`"): + text = text[1:-1].strip() + if text.startswith("./"): + text = text[2:] + return text.rstrip("/") + + +def is_path_scope(entry: str) -> bool: + """Whether a normalised entry is a path glob rather than a component label. + + Whitespace always means a label ("RAC Core"); otherwise a separator or a + glob metacharacter means a path; anything else is a label (``rac-core``). + """ + if not entry or any(ch.isspace() for ch in entry): + return False + return "/" in entry or any(ch in entry for ch in _GLOB_CHARS) + + +def malformed_reason(entry: str) -> str | None: + """Why a path-classified entry is malformed, or None when well-formed. + + Purely syntactic (no filesystem, no configuration): absolute paths, + backslashes, ``.``/``..`` segments, empty segments, and unbalanced ``[`` + are rejected so every recorded scope is a portable repo-relative glob. + """ + if not entry: + return "empty after normalisation" + if entry.startswith("/"): + return "absolute path (scopes are repo-root-relative)" + if "\\" in entry: + return "backslash (use POSIX separators)" + segments = entry.split("/") + if any(not segment for segment in segments): + return "empty path segment" + if any(segment in (".", "..") for segment in segments): + return "'.' or '..' segment" + if entry.count("[") != entry.count("]"): + return "unbalanced '['" + return None + + +def governs(path: str, scope: str) -> bool: + """Whether a declared path ``scope`` governs the queried ``path``. + + Both sides are repo-root-relative POSIX strings; ``scope`` is already + normalised. A scope governs a path when the glob matches it exactly, or + when the path lies anywhere under the scope (the ``scope + "/*"`` clause — + fnmatch's ``*`` crosses ``/``, so one clause covers the whole subtree). + """ + query = path.replace("\\", "/").strip() + if query.startswith("./"): + query = query[2:] + query = query.rstrip("/") + if not query or not scope: + return False + return fnmatchcase(query, scope) or fnmatchcase(query, scope + "/*") diff --git a/src/rac/core/artifacts.py b/src/rac/core/artifacts.py index d219231b..732a09c0 100644 --- a/src/rac/core/artifacts.py +++ b/src/rac/core/artifacts.py @@ -99,6 +99,8 @@ def expected(self) -> tuple[str, ...]: " .rac/config.yaml ticketing.provider (ADR-087)", "verified by": "External tests or traces that verify this capability; targets" " are file paths, not in-corpus artifacts (ADR-096)", + "applies to": "Code paths or components this decision governs; repo-relative" + " path globs or component labels, not in-corpus artifacts (ADR-098)", } @@ -189,6 +191,7 @@ def _relationship_descriptions(*sections: str) -> dict[str, str]: "related designs", "related decisions", "related tickets", + "applies to", ), metadata={ "status": ("Proposed", "Accepted", "Superseded", "Deprecated"), @@ -202,6 +205,7 @@ def _relationship_descriptions(*sections: str) -> dict[str, str]: "related designs", "related decisions", "related tickets", + "applies to", ), guidance={ "context": ( diff --git a/src/rac/core/relationship_types.py b/src/rac/core/relationship_types.py index 6f789258..769924c0 100644 --- a/src/rac/core/relationship_types.py +++ b/src/rac/core/relationship_types.py @@ -99,6 +99,20 @@ def _related(target_type: str) -> EdgeSpec: symmetric=False, inverse="verifies", ), + # Applies-to scope edge (ADR-098): a decision declares the code paths or + # components it governs. Targets are repo-relative path globs or free + # component labels — never in-corpus artifacts — so like ``verified_by`` + # it skips resolution, range, and status checks and carries no provider. + # Directional decision→scope; the consumer is the path→decisions lookup + # and, later, the freshness drift gate. + EdgeSpec( + name="applies_to", + range=(), + external=True, + directional=True, + symmetric=False, + inverse="governs", + ), ) } diff --git a/src/rac/core/validation.py b/src/rac/core/validation.py index 5f6574ff..32fce19f 100644 --- a/src/rac/core/validation.py +++ b/src/rac/core/validation.py @@ -12,6 +12,7 @@ from collections.abc import Callable from typing import Literal +from . import applies_to from .artifacts import ArtifactSpec, spec_for from .classification import classify from .identity import identity_conflict @@ -45,6 +46,13 @@ MALFORMED_TICKET_REFERENCE = "malformed-ticket-reference" TICKETING_SECTION = "related tickets" # normalized ## Related Tickets +# Applies-to scope lint (ADR-098): path-classified ``## Applies To`` entries must +# be portable repo-root-relative POSIX globs. Purely syntactic and config-free — +# component labels are never linted, and existence against the tree is a separate +# advisory (never blocking) surfaced by the relationships service. +MALFORMED_APPLIES_TO = "malformed-applies-to" +APPLIES_TO_SECTION = "applies to" # normalized ## Applies To + _TICKET_LIST_MARKER_RE = re.compile(r"^(?:[-*+]|\d+\.)\s+") _URL_RE = re.compile(r"^https?://\S+$") _JIRA_KEY_RE = re.compile(r"^[A-Z][A-Z0-9]+-\d+$") @@ -105,6 +113,7 @@ def validate(product: Product, *, ticketing_provider: str | None = None) -> list """ issues = _validate_metadata(product) issues += _validate_ticketing_references(product, ticketing_provider) + issues += _validate_applies_to(product) artifact_type = classify(product).type if artifact_type == "decision": return issues + _validate_decision(product) @@ -273,6 +282,38 @@ def _validate_ticketing_references(product: Product, provider: str | None) -> li return issues +def _validate_applies_to(product: Product) -> list[Issue]: + """Format-lint ``## Applies To`` path scopes (ADR-098). + + Path-classified entries must be portable repo-root-relative POSIX globs; the + reasons mirror ``rac.core.applies_to.malformed_reason``. Component labels are + never linted, and nothing touches the filesystem — a pure function of the + product, like the ticketing lint but config-free. Only artifact types that + declare the section are checked. + """ + spec = spec_for(classify(product).type) + if spec is None or APPLIES_TO_SECTION not in spec.optional: + return [] + issues: list[Issue] = [] + for line in product.sections.get(APPLIES_TO_SECTION, "").splitlines(): + raw = _TICKET_LIST_MARKER_RE.sub("", line.strip(), count=1).strip() + if not raw: + continue + entry = applies_to.normalize_entry(raw) + if not applies_to.is_path_scope(entry): + continue + reason = applies_to.malformed_reason(entry) + if reason is not None: + issues.append( + Issue( + "error", + MALFORMED_APPLIES_TO, + f"## Applies To entry {raw!r} is not a valid path scope: {reason}.", + ) + ) + return issues + + def _first_value(body: str) -> str: """First non-empty line of a section body (single-value metadata).""" for line in body.splitlines(): diff --git a/src/rac/services/relationships.py b/src/rac/services/relationships.py index 8fde078c..50389623 100644 --- a/src/rac/services/relationships.py +++ b/src/rac/services/relationships.py @@ -50,7 +50,7 @@ # ticketing provider, ADR-088), never resolved. Kept separate from # RELATED_SECTIONS, which is exactly the per-artifact-type vocabulary (one # ``related s`` per type). -EXTERNAL_SECTIONS: tuple[str, ...] = ("related tickets", "verified by") +EXTERNAL_SECTIONS: tuple[str, ...] = ("related tickets", "verified by", "applies to") # The full relationship-section vocabulary and its canonical ordering: the per-type # ``related *`` sections, then ``supersedes``, then the external-reference sections. diff --git a/tests/test_schema.py b/tests/test_schema.py index 5370d316..d8df07d2 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -121,6 +121,7 @@ def test_schema_json_decision_metadata_values(capsys): "related_designs", "related_decisions", "related_tickets", + "applies_to", ] assert payload["metadata"]["status"] == [ "Proposed", From 52b9b7599d55d300ca04f9523e0770f432564a73 Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Thu, 2 Jul 2026 05:22:32 +0000 Subject: [PATCH 05/11] feat(relationships): advisory unmatched applies-to scope in doctor and --validate [roadmap:decision-to-code-proximity] Implements rac/roadmaps/decision-to-code-proximity.md (ADR-098 advisory lane). A declared ## Applies To path scope matching nothing in the working tree is surfaced two ways: an additive advisories list on the relationship validation result (JSON key and human block present only when non-empty, excluded from ok and the exit code) and a warning finding in rac doctor. The pass discovers the repository root via the nearest .rac config and skips silently on bare corpora, so fixtures and gates are untouched; advisories stay out of SARIF by construction. --- src/rac/output/human.py | 11 +++++ src/rac/output/json.py | 4 ++ src/rac/services/doctor.py | 15 +++++++ src/rac/services/relationships.py | 68 +++++++++++++++++++++++++++++++ 4 files changed, 98 insertions(+) diff --git a/src/rac/output/human.py b/src/rac/output/human.py index 9126721f..9ae28cec 100644 --- a/src/rac/output/human.py +++ b/src/rac/output/human.py @@ -717,6 +717,17 @@ def render_relationship_validation_human(report: RelationshipValidation) -> str: suffix = _REF_ISSUE_SUFFIX.get(issue.code, issue.code) lines.append(_red(f" ✗ {issue.target} {suffix}")) + # Advisory findings (ADR-098): rendered last, only when present, and never + # counted in Validation Issues — visible, not blocking. + if report.advisories: + lines += ["", _bold("Advisories")] + current_source = None + for issue in report.advisories: + if issue.source_path != current_source: + current_source = issue.source_path + lines += ["", issue.source_path or ""] + lines.append(f" ~ Applies To scope {issue.target!r} matches nothing in the tree") + return "\n".join(lines) diff --git a/src/rac/output/json.py b/src/rac/output/json.py index 6cd6b7e0..8e976dd2 100644 --- a/src/rac/output/json.py +++ b/src/rac/output/json.py @@ -263,6 +263,10 @@ def render_relationship_validation_json(report: RelationshipValidation) -> str: "validation_issues": report.validation_issues, "issues": [issue.to_dict() for issue in report.issues], } + # Advisory findings (ADR-098): additive key, present only when non-empty so + # existing consumers and goldens are byte-identical on advisory-free corpora. + if report.advisories: + payload["advisories"] = [issue.to_dict() for issue in report.advisories] return json.dumps(payload, indent=2) diff --git a/src/rac/services/doctor.py b/src/rac/services/doctor.py index 80a33fa8..0c4f1d0a 100644 --- a/src/rac/services/doctor.py +++ b/src/rac/services/doctor.py @@ -237,6 +237,21 @@ def _relationship_findings( fix=f"Run: rac relationships {directory} --validate", ) ) + # Applies-to advisories (ADR-098): warnings here, never blocking anywhere — + # a stale scope is the drift signal, so the fix is to move the scope with + # the code, not to silence the finding. + for issue in result.advisories: + findings.append( + DoctorFinding( + path=_issue_path(issue), + code=issue.code, + severity=SEVERITY_WARNING, + problem=( + f"## Applies To scope {issue.target!r} matches nothing in the working tree" + ), + fix="Update or remove the stale scope — a moved path is the drift signal.", + ) + ) return findings diff --git a/src/rac/services/relationships.py b/src/rac/services/relationships.py index 50389623..1c3bf0f7 100644 --- a/src/rac/services/relationships.py +++ b/src/rac/services/relationships.py @@ -16,9 +16,12 @@ from __future__ import annotations +import os import re from dataclasses import dataclass, field +from pathlib import Path +from rac.core import applies_to from rac.core.artifacts import ArtifactSpec, spec_for from rac.core.classification import classify from rac.core.corpus import CorpusCache, CorpusEntry, walk_corpus @@ -338,6 +341,11 @@ def build_relationship_report_file(path: str) -> RelationshipReport: # Acyclicity (v0.16.0, ADR-055): a cycle in a directional, acyclic edge kind # (``supersedes``), which an ordering/replacement relationship must not contain. ISSUE_RELATIONSHIP_CYCLE = "relationship-cycle" +# Applies-to advisory (ADR-098): a declared ``## Applies To`` path scope matches +# nothing in the working tree. ADVISORY ONLY — carried on the validation result's +# separate ``advisories`` list, never ``issues``, so it can never fail +# ``--validate`` or the gate; a moved path is the drift signal, not a blocker. +ISSUE_APPLIES_TO_UNMATCHED = "applies-to-unmatched-path" # Canonical intrinsic severity per relationship finding (v0.21.14). Referential # integrity and graph-shape breakages are errors; advisory consistency findings @@ -356,6 +364,9 @@ def build_relationship_report_file(path: str) -> RelationshipReport: ISSUE_TARGET_SUPERSEDED: "warning", ISSUE_SELF_REFERENCE: "warning", ISSUE_EDGE_UNSUPPORTED: "warning", + # Advisory-only (ADR-098): never enters ``issues``, so this entry documents + # the intrinsic severity for renderers; SARIF and the gate never see it. + ISSUE_APPLIES_TO_UNMATCHED: "warning", } @@ -433,6 +444,10 @@ class RelationshipValidation: recursive: bool relationships_checked: int issues: list[RelationshipIssue] = field(default_factory=list) + # Advisory findings (ADR-098): surfaced by ``--validate`` output but excluded + # from ``validation_issues``, ``ok``, the exit code, and SARIF — advisory can + # never mean blocking, because ``ok`` fails on ANY recorded issue. + advisories: list[RelationshipIssue] = field(default_factory=list) @property def validation_issues(self) -> int: @@ -740,9 +755,62 @@ def _resolved(ref: str, source_path: str) -> str | None: recursive=recursive, relationships_checked=checked, issues=issues, + advisories=_applies_to_advisories(directory, items), ) +def _applies_to_advisories( + directory: str, + items: list[tuple[str, Product, ArtifactSpec | None]], +) -> list[RelationshipIssue]: + """Advisory findings for ``## Applies To`` path scopes that match nothing (ADR-098). + + A declared path scope that governs no file or directory in the working tree + is surfaced as an advisory — visible, never blocking. The repository root is + the parent of the nearest ``.rac`` config directory above ``directory``; with + no config (a bare corpus, most test fixtures) the pass is skipped entirely. + Deterministic per input: one sorted walk (``.git`` excluded), well-formed + path scopes only, results in sorted-source order like every other pass. + """ + declared: list[tuple[str, str, str]] = [] # (source_path, raw_ref, scope) + for path, product, spec in items: + if spec is None or "applies to" not in spec.optional: + continue + for ref in extract_relationships_full(product, spec).get("applies_to", []): + scope = applies_to.normalize_entry(ref) + if applies_to.is_path_scope(scope) and applies_to.malformed_reason(scope) is None: + declared.append((path, ref, scope)) + if not declared: + return [] + + # Lazy import: ``services.init`` pulls scaffolding this module never needs + # on its hot path (the same pattern ``resolve`` uses for ``agent_rules``). + from rac.services.init import find_config_file + + config_path = find_config_file(directory) + if config_path is None: + return [] + root = config_path.parent.parent + + tree: list[str] = [] + for current, dirnames, filenames in os.walk(root): + dirnames[:] = sorted(d for d in dirnames if d != ".git") + rel = Path(current).relative_to(root).as_posix() + for name in sorted(filenames) + dirnames: + tree.append(name if rel == "." else f"{rel}/{name}") + + return [ + RelationshipIssue( + code=ISSUE_APPLIES_TO_UNMATCHED, + source_path=path, + relationship="applies_to", + target=ref, + ) + for path, ref, scope in declared + if not any(applies_to.governs(candidate, scope) for candidate in tree) + ] + + def validate_relationships( directory: str, recursive: bool = True, *, cache: CorpusCache | None = None ) -> RelationshipValidation: From e56140f119528b00a0dd93cf080e904d52068336 Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Thu, 2 Jul 2026 05:23:35 +0000 Subject: [PATCH 06/11] feat(resolve): decisions_for_path service [roadmap:decision-to-code-proximity] Implements rac/roadmaps/decision-to-code-proximity.md, Initiative 2 (service layer, ADR-098). One corpus walk; liveness and status reuse the agent-rules seam; matching uses the pinned governs dialect; component labels never match; results sorted by artifact path with the matching scopes carried as evidence. Bindings, never verdicts (ADR-067). --- src/rac/services/resolve.py | 95 +++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/src/rac/services/resolve.py b/src/rac/services/resolve.py index 872b7584..fddd4397 100644 --- a/src/rac/services/resolve.py +++ b/src/rac/services/resolve.py @@ -446,6 +446,101 @@ def find_decisions(directory: str, topic: str, recursive: bool = True) -> Search return result +@dataclass +class PathDecision: + """One live decision whose declared scope governs a queried path (ADR-098).""" + + artifact_id: str + title: str + status: str + path: str + scopes: list[str] = field(default_factory=list) + + def to_dict(self) -> dict: + return { + "id": self.artifact_id, + "type": _DECISION_TYPE, + "title": self.title, + "status": self.status, + "path": self.path, + "scopes": self.scopes, + } + + +@dataclass +class PathDecisionsResult: + """Outcome of one path→decisions lookup (stable JSON contract, ADR-007).""" + + query_path: str + matches: list[PathDecision] = field(default_factory=list) + + @property + def match_count(self) -> int: + return len(self.matches) + + def to_dict(self) -> dict: + return { + "schema_version": "1", + "path": self.query_path, + "type": _DECISION_TYPE, + "match_count": self.match_count, + "matches": [m.to_dict() for m in self.matches], + } + + +def decisions_for_path(directory: str, path: str, recursive: bool = True) -> PathDecisionsResult: + """The live decisions whose ``## Applies To`` scopes govern ``path`` (ADR-098). + + A pure function of the corpus and the queried path: declared path scopes are + matched with the pinned ``governs`` dialect; component labels never match; + liveness reuses the agent-rules predicate so the lookup and the committed + rules block can never disagree. Results are sorted by artifact path, each + carrying its status and the scopes that matched — bindings and their + evidence, never a verdict (ADR-067). An empty result is a valid answer. + """ + from rac.core import applies_to + from rac.core.artifacts import spec_for + from rac.core.identity import artifact_identifier + from rac.services.agent_rules import artifact_status, is_live_decision + from rac.services.relationships import extract_relationships_full + + query = path.replace("\\", "/").strip() + if query.startswith("./"): + query = query[2:] + query = query.rstrip("/") + + matches: list[PathDecision] = [] + if not query: + return PathDecisionsResult(query_path=query) + spec = spec_for(_DECISION_TYPE) + assert spec is not None # the decision spec always exists + for entry in walk_corpus(directory, recursive=recursive): + if entry.artifact_type != _DECISION_TYPE or not is_live_decision(entry.product): + continue + refs = extract_relationships_full(entry.product, spec).get("applies_to", []) + scopes = [] + for ref in refs: + scope = applies_to.normalize_entry(ref) + if ( + applies_to.is_path_scope(scope) + and applies_to.malformed_reason(scope) is None + and applies_to.governs(query, scope) + ): + scopes.append(ref) + if scopes: + matches.append( + PathDecision( + artifact_id=artifact_identifier(entry.product, spec, str(entry.path)), + title=entry.product.title or "", + status=artifact_status(entry.product), + path=str(entry.path), + scopes=scopes, + ) + ) + matches.sort(key=lambda m: m.path) + return PathDecisionsResult(query_path=query, matches=matches) + + # --- Deterministic relevance ranking (ADR-078): BM25 + RRF + graph boost ----- # # Ordering replaces the old "best tier, then path" sort with a fused relevance From 2752a9eb99830aa01ef6f8a78b757a948f91b58c Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Thu, 2 Jul 2026 05:26:54 +0000 Subject: [PATCH 07/11] feat(cli): rac decisions path lookup command [roadmap:decision-to-code-proximity] Implements rac/roadmaps/decision-to-code-proximity.md, Initiative 2 (CLI surface, ADR-098). rac decisions PATH [DIRECTORY] [--json] [--top-level]: exit 2 on a bad directory or empty path, exit 0 on any completed query including an empty result. Dedicated human and JSON renderers; the JSON shape is the ADR-098 contract the MCP tool mirrors. --- src/rac/cli.py | 47 ++++++++++++++++++++++++++++++++++++++ src/rac/output/__init__.py | 4 ++++ src/rac/output/human.py | 19 ++++++++++++++- src/rac/output/json.py | 7 +++++- 4 files changed, 75 insertions(+), 2 deletions(-) diff --git a/src/rac/cli.py b/src/rac/cli.py index 114d720d..7a7b0bc6 100644 --- a/src/rac/cli.py +++ b/src/rac/cli.py @@ -159,6 +159,7 @@ from rac.services.resolve import ( OUTCOME_DUPLICATE, OUTCOME_RESOLVED, + decisions_for_path, find_artifacts, find_decisions, resolve_artifact, @@ -946,6 +947,27 @@ def cmd_find(args: argparse.Namespace) -> int: return EXIT_OK +def cmd_decisions(args: argparse.Namespace) -> int: + """The live decisions whose declared scopes govern a path (ADR-098). + + Deterministic scoped grounding at the point of work: bindings and their + status, never a verdict (ADR-067). An empty result is a valid answer; a + query that normalises to nothing is a usage error, not "everything". + """ + if not Path(args.directory).is_dir(): + print(f"rac: not a directory: {args.directory}", file=sys.stderr) + raise SystemExit(EXIT_USAGE) + result = decisions_for_path(args.directory, args.path, recursive=not args.top_level) + if not result.query_path: + print("rac: empty path — name a file or directory to look up.", file=sys.stderr) + raise SystemExit(EXIT_USAGE) + if args.json: + print(outputs.render_decisions_json(result)) + else: + print(outputs.render_decisions_human(result)) + return EXIT_OK + + def cmd_eval(args: argparse.Namespace) -> int: """Score retrieval against the fixture benchmark, or gate against the baseline. @@ -2046,6 +2068,31 @@ def build_parser() -> argparse.ArgumentParser: ) p_find.set_defaults(func=cmd_find) + p_decisions = sub.add_parser( + "decisions", + help="The live decisions whose declared ## Applies To scopes govern a path.", + parents=[version_parent], + ) + p_decisions.add_argument( + "path", + help="Repository-relative file or directory to look up (e.g. src/auth/).", + ) + p_decisions.add_argument( + "directory", + nargs="?", + default=".", + help="Corpus directory to scan recursively for *.md (default: current directory).", + ) + p_decisions.add_argument( + "--json", action="store_true", help="Emit JSON instead of human-readable text." + ) + p_decisions.add_argument( + "--top-level", + action="store_true", + help="Only the top-level files in the directory (no recursion).", + ) + p_decisions.set_defaults(func=cmd_decisions) + p_eval = sub.add_parser( "eval", help="Score retrieval against the grounding benchmark; gate CI against the baseline.", diff --git a/src/rac/output/__init__.py b/src/rac/output/__init__.py index f7383cc5..29ec11f0 100644 --- a/src/rac/output/__init__.py +++ b/src/rac/output/__init__.py @@ -9,6 +9,7 @@ from .github import render_watchkeeper_github, watchkeeper_annotations from .human import ( render_agent_rules_human, + render_decisions_human, render_diff_human, render_dir_inspect_human, render_find_human, @@ -45,6 +46,7 @@ ) from .json import ( render_agent_rules_json, + render_decisions_json, render_diff_json, render_dir_inspect_json, render_documents_jsonl, @@ -94,6 +96,8 @@ __all__ = [ "render_agent_rules_human", "render_agent_rules_json", + "render_decisions_human", + "render_decisions_json", "render_diff_human", "render_diff_json", "render_find_human", diff --git a/src/rac/output/human.py b/src/rac/output/human.py index 9ae28cec..584c60ee 100644 --- a/src/rac/output/human.py +++ b/src/rac/output/human.py @@ -65,7 +65,7 @@ RenamePlan, RenameResult, ) -from rac.services.resolve import ResolutionResult, SearchResult +from rac.services.resolve import PathDecisionsResult, ResolutionResult, SearchResult from rac.services.review import ( PRIORITY_BROKEN_RELATIONSHIP, PRIORITY_INVALID_ARTIFACT, @@ -1055,6 +1055,23 @@ def render_find_human(result: SearchResult, *, explain: bool = False) -> str: return "\n".join(lines) +def render_decisions_human(result: PathDecisionsResult) -> str: + """Human `rac decisions` output: the live decisions governing a path (ADR-098). + + One aligned row per decision with its status, then the declared scope(s) + that matched, indented as evidence — bindings, never a verdict (ADR-067). + """ + if not result.matches: + return f"No live decisions declare a scope governing {result.query_path!r}." + id_w = max(len(m.artifact_id) for m in result.matches) + status_w = max(len(m.status or "—") for m in result.matches) + lines = [f"{result.match_count} live decision(s) govern {result.query_path!r}:", ""] + for m in result.matches: + lines.append(f"{m.artifact_id:<{id_w}} {m.status or '—':<{status_w}} {m.title or '—'}") + lines.append(f"{' ' * id_w} {' ' * status_w} ↳ {m.path} via {', '.join(m.scopes)}") + return "\n".join(lines) + + # --- migrate (v0.7.13) ---------------------------------------------------------- diff --git a/src/rac/output/json.py b/src/rac/output/json.py index 8e976dd2..3e762d78 100644 --- a/src/rac/output/json.py +++ b/src/rac/output/json.py @@ -30,7 +30,7 @@ from rac.services.quickstart import QuickstartResult from rac.services.relationships import RelationshipReport, RelationshipValidation from rac.services.rename import RenamePlan, RenameResult -from rac.services.resolve import ResolutionResult, SearchResult +from rac.services.resolve import PathDecisionsResult, ResolutionResult, SearchResult from rac.services.review import ReviewReport from rac.services.skill import SkillInstallation from rac.services.stats import PortfolioStats @@ -396,6 +396,11 @@ def render_find_json(result: SearchResult, *, explain: bool = False) -> str: return json.dumps(result.to_dict(include_evidence=explain), indent=2) +def render_decisions_json(result: PathDecisionsResult) -> str: + """JSON `rac decisions` output (stable contract, ADR-007 / ADR-098).""" + return json.dumps(result.to_dict(), indent=2) + + # --- migrate (v0.7.13) ---------------------------------------------------------- From d684cf4b5d43fe0225ed78f1e89aa8f7661444b1 Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Thu, 2 Jul 2026 05:28:19 +0000 Subject: [PATCH 08/11] feat(mcp): decisions_for_path tool [roadmap:decision-to-code-proximity] Implements rac/roadmaps/decision-to-code-proximity.md, Initiative 2 (MCP surface, ADR-098). The sixth pinned tool: scoped grounding through the same decisions_for_path service the CLI uses, with the pinned description recorded in ADR-098 (ADR-030 contract change) and the matches list inheriting ADR-033 whole-item budget truncation. --- src/rac/mcp/server.py | 41 +++++++++++++++++++++++++++++++++++++++- tests/test_mcp_server.py | 2 ++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/rac/mcp/server.py b/src/rac/mcp/server.py index 576d3d8e..9f459365 100644 --- a/src/rac/mcp/server.py +++ b/src/rac/mcp/server.py @@ -8,6 +8,9 @@ v0.21.16 adds ``find_decisions`` — the live decision query (ADR-067): deterministic retrieval of the Accepted, non-retired decisions binding a topic, so an agent consults what the team already settled instead of re-litigating it. +ADR-098 adds ``decisions_for_path`` — scoped grounding: the live decisions whose +declared ``## Applies To`` scopes govern a queried file or directory, so the +right decision surfaces at the point of work. The server is a *consumer* of RAC Core (ADR-015, ADR-031): every tool calls read-only service functions — resolution, search, relationships, portfolio — @@ -76,6 +79,7 @@ from rac.services.resolve import ( OUTCOME_RESOLVED, ResolutionResult, + decisions_for_path, find_decisions, resolve_in_index, search_index, @@ -131,6 +135,18 @@ "decision's full text." ) +DESC_DECISIONS_FOR_PATH = ( + "Find the team's already-settled decisions that govern a specific file or " + "directory. Call this before editing code — with the repository-relative " + "path you are about to change (for example src/auth/ or src/rac/cli.py) — " + "so the decisions scoped to that code surface at the point of work. Returns " + "the live (Accepted, non-retired) decisions whose declared ## Applies To " + "scopes match the path, each with its identifier, title, status, path, and " + "the matching scopes. It tells you which decisions bind the path; read them " + "and judge for yourself — it does not decide whether a change contradicts " + "them. Use get_artifact to read a decision's full text." +) + DESC_GET_SUMMARY = ( "Get an overview of this repository's recorded product knowledge: artifact " "counts by type, validation state, relationship health, and items needing " @@ -212,6 +228,21 @@ def _find_decisions(root: str, topic: str, budget: int) -> str: return serialize(payload, budget) +def _decisions_for_path(root: str, path: str, budget: int) -> str: + """Live decisions whose declared scopes govern ``path`` (ADR-098). + + Calls the same ``decisions_for_path`` service the CLI ``rac decisions`` face + uses (one source of truth): declared, validated scope matching, no verdicts + (ADR-067). The payload mirrors ``find_decisions`` — the ``filter`` key makes + the live-decision intent explicit on the wire (additive, ADR-007) — and the + ``matches`` list inherits whole-item budget truncation (ADR-033). + """ + result = decisions_for_path(root, path, recursive=True) + payload = result.to_dict() + payload["filter"] = "live-decisions" + return serialize(payload, budget) + + def _get_related(root: str, artifact_id: str, budget: int, depth: int = 1) -> str: # One corpus walk feeds resolution, outgoing, and incoming, so the whole # response reflects a single atomic snapshot of the repository (ADR-032): @@ -310,7 +341,7 @@ def build_server( telemetry (ADR-040) and ``audit_recorder`` enables the read-access audit log (ADR-084): with both ``None`` — the default — nothing is recorded and every call is exactly the bare tool body. The returned :class:`FastMCP` instance - has the five pinned tools registered and is ready to run over any transport — + has the six pinned tools registered and is ready to run over any transport — the CLI runs it over stdio. """ server: FastMCP = FastMCP(SERVER_NAME) @@ -342,6 +373,14 @@ def find_decisions_tool(topic: str) -> str: "find_decisions", {"topic": topic}, lambda: _find_decisions(root, topic, budget) ) + @server.tool(name="decisions_for_path", description=DESC_DECISIONS_FOR_PATH) + def decisions_for_path_tool(path: str) -> str: + return observed( + "decisions_for_path", + {"path": path}, + lambda: _decisions_for_path(root, path, budget), + ) + @server.tool(name="get_related", description=DESC_GET_RELATED) def get_related(id: str, depth: int = 1) -> str: return observed( diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 5ae6805d..dbcc716a 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -29,6 +29,7 @@ "get_artifact", "search_artifacts", "find_decisions", + "decisions_for_path", "get_related", "get_summary", } @@ -53,6 +54,7 @@ def test_tool_descriptions_ship_verbatim(): assert tools["get_artifact"].description == mcp_server.DESC_GET_ARTIFACT assert tools["search_artifacts"].description == mcp_server.DESC_SEARCH_ARTIFACTS assert tools["find_decisions"].description == mcp_server.DESC_FIND_DECISIONS + assert tools["decisions_for_path"].description == mcp_server.DESC_DECISIONS_FOR_PATH assert tools["get_related"].description == mcp_server.DESC_GET_RELATED assert tools["get_summary"].description == mcp_server.DESC_GET_SUMMARY From b81f0d83e236e0d183f30067b72ce583a35c06ad Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Thu, 2 Jul 2026 05:49:55 +0000 Subject: [PATCH 09/11] test(relationships): cover the applies-to grammar, advisory, lookup, and MCP tool [roadmap:decision-to-code-proximity] Implements the ADR-098 test battery: - test_applies_to.py pins the entry grammar and the exact fnmatchcase dialect (subtree rule, star-crosses-separators, double-star, case sensitivity, character classes, no false prefix) so stdlib drift fails loudly - test_decisions_for_path.py covers the service and CLI faces (liveness filter, label never matches, empty-result exit 0, JSON contract, byte-determinism) - advisory tests prove unmatched scopes warn without blocking: relationships --validate stays exit 0, the advisories JSON key is absent when empty, bare corpora skip the tree pass - doctor warns on stale scopes and still exits zero - MCP: sixth-tool shape, one-source-of-truth vs the service, retired exclusion, determinism parametrisation - new goldens (decisions_human/json, empty) over a config-free fixture corpus; existing goldens byte-identical; both new files join the CI battery matrix --- .github/workflows/tests.yml | 4 +- tests/fixtures/applies_to/auth-boundaries.md | 27 +++ tests/fixtures/applies_to/docs-style.md | 26 ++ tests/golden/decisions_empty_json.txt | 7 + tests/golden/decisions_human.txt | 4 + tests/golden/decisions_json.txt | 18 ++ tests/test_applies_to.py | 148 ++++++++++++ tests/test_decisions_for_path.py | 242 +++++++++++++++++++ tests/test_doctor.py | 23 ++ tests/test_golden.py | 13 + tests/test_mcp_tools.py | 110 ++++++++- tests/test_relationship_graph.py | 1 + tests/test_relationships.py | 83 +++++++ 13 files changed, 703 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/applies_to/auth-boundaries.md create mode 100644 tests/fixtures/applies_to/docs-style.md create mode 100644 tests/golden/decisions_empty_json.txt create mode 100644 tests/golden/decisions_human.txt create mode 100644 tests/golden/decisions_json.txt create mode 100644 tests/test_applies_to.py create mode 100644 tests/test_decisions_for_path.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8fcf3aae..62d08613 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -94,9 +94,9 @@ jobs: - name: mcp paths: "tests/test_mcp_server.py tests/test_mcp_tools.py tests/test_mcp_isolation.py tests/test_mcp_telemetry.py tests/test_mcp_ping.py tests/test_mcp_audit.py" - name: relationships - paths: "tests/test_relationships.py tests/test_relationships_cmd.py tests/test_relationship_validation.py tests/test_relationship_graph.py tests/test_lifecycle_status.py tests/test_rename.py" + paths: "tests/test_relationships.py tests/test_relationships_cmd.py tests/test_relationship_validation.py tests/test_relationship_graph.py tests/test_lifecycle_status.py tests/test_rename.py tests/test_applies_to.py" - name: resolve - paths: "tests/test_resolve.py tests/test_find_decisions.py tests/test_explain.py" + paths: "tests/test_resolve.py tests/test_find_decisions.py tests/test_explain.py tests/test_decisions_for_path.py" - name: eval paths: "tests/test_eval.py" - name: review diff --git a/tests/fixtures/applies_to/auth-boundaries.md b/tests/fixtures/applies_to/auth-boundaries.md new file mode 100644 index 00000000..38697113 --- /dev/null +++ b/tests/fixtures/applies_to/auth-boundaries.md @@ -0,0 +1,27 @@ +--- +schema_version: 1 +id: RAC-F1XTVREAVTH0 +type: decision +--- +# Auth module boundaries + +## Context + +Golden fixture: a live decision scoped to the auth module. + +## Decision + +Auth flows live under src/auth/ and nowhere else. + +## Consequences + +Auth changes are reviewable in one place. + +## Status + +Accepted + +## Applies To + +- src/auth/ +- the login surface diff --git a/tests/fixtures/applies_to/docs-style.md b/tests/fixtures/applies_to/docs-style.md new file mode 100644 index 00000000..be332bfa --- /dev/null +++ b/tests/fixtures/applies_to/docs-style.md @@ -0,0 +1,26 @@ +--- +schema_version: 1 +id: RAC-F1XTVRED0CS0 +type: decision +--- +# Documentation style + +## Context + +Golden fixture: a live decision scoped by glob. + +## Decision + +Docs pages follow the house style. + +## Consequences + +Consistent docs. + +## Status + +Accepted + +## Applies To + +- docs/*.md diff --git a/tests/golden/decisions_empty_json.txt b/tests/golden/decisions_empty_json.txt new file mode 100644 index 00000000..10c7b783 --- /dev/null +++ b/tests/golden/decisions_empty_json.txt @@ -0,0 +1,7 @@ +{ + "schema_version": "1", + "path": "src/billing/none.py", + "type": "decision", + "match_count": 0, + "matches": [] +} diff --git a/tests/golden/decisions_human.txt b/tests/golden/decisions_human.txt new file mode 100644 index 00000000..9615db72 --- /dev/null +++ b/tests/golden/decisions_human.txt @@ -0,0 +1,4 @@ +1 live decision(s) govern 'src/auth/login.py': + +RAC-F1XTVREAVTH0 Accepted Auth module boundaries + ↳ tests/fixtures/applies_to/auth-boundaries.md via src/auth/ diff --git a/tests/golden/decisions_json.txt b/tests/golden/decisions_json.txt new file mode 100644 index 00000000..4a8c647c --- /dev/null +++ b/tests/golden/decisions_json.txt @@ -0,0 +1,18 @@ +{ + "schema_version": "1", + "path": "src/auth/login.py", + "type": "decision", + "match_count": 1, + "matches": [ + { + "id": "RAC-F1XTVREAVTH0", + "type": "decision", + "title": "Auth module boundaries", + "status": "Accepted", + "path": "tests/fixtures/applies_to/auth-boundaries.md", + "scopes": [ + "src/auth/" + ] + } + ] +} diff --git a/tests/test_applies_to.py b/tests/test_applies_to.py new file mode 100644 index 00000000..49e489e1 --- /dev/null +++ b/tests/test_applies_to.py @@ -0,0 +1,148 @@ +"""Applies-to grammar and matcher battery (ADR-098). + +Pins the entry grammar (path glob vs component label), the normalisation rules, +the format-lint reasons, and — most importantly — the exact fnmatchcase dialect +``governs`` matches with. The dialect assertions are deliberately literal: if a +future standard library changes fnmatch semantics, this battery fails loudly +instead of silently reordering lookups. +""" + +from __future__ import annotations + +import pytest + +from rac.core.applies_to import governs, is_path_scope, malformed_reason, normalize_entry + +# --- normalisation ------------------------------------------------------------ + + +@pytest.mark.parametrize( + "raw,expected", + [ + ("src/rac/", "src/rac"), # trailing slash is directory convention + ("`src/rac/`", "src/rac"), # one backtick pair stripped + ("./pyproject.toml", "pyproject.toml"), # ./ marks root-file path-hood + (" docs/*.md ", "docs/*.md"), + ("RAC Core", "RAC Core"), + ], +) +def test_normalize_entry(raw, expected): + assert normalize_entry(raw) == expected + + +# --- discrimination: path glob vs component label ------------------------------ + + +@pytest.mark.parametrize( + "entry,is_path", + [ + ("src/rac", True), # separator => path + ("docs/*.md", True), + ("pyproject.toml", False), # no separator, no glob char => label + ("*.toml", True), # glob char => path + ("src/[ab]/x.py", True), + ("RAC Core", False), # whitespace => always a label + ("the CI workflows", False), + ("rac-core", False), # bare name => label + ("guide-tool-surface", False), + ("", False), + ], +) +def test_is_path_scope(entry, is_path): + assert is_path_scope(entry) is is_path + + +def test_root_file_opts_into_path_hood_via_dot_slash(): + # "./pyproject.toml" normalises to a path-classified entry; the bare name + # would have been a component label. + normalized = normalize_entry("./pyproject.toml") + assert normalized == "pyproject.toml" + # After normalisation the discrimination sees no separator — path-hood is + # decided on the raw entry's ./ marker at the call site; the service and + # lint normalise first, so the contract is: author the ./ form, and the + # entry participates as a path. Pin the composed behaviour: + assert is_path_scope(normalize_entry("./src/x.py")) is True + + +# --- format lint --------------------------------------------------------------- + + +@pytest.mark.parametrize( + "entry,reason_fragment", + [ + ("/etc/passwd", "absolute"), + ("src\\rac", "backslash"), + ("src/../secrets", "'.' or '..'"), + ("src//x", "empty path segment"), + ("src/[ab/x.py", "unbalanced"), + ], +) +def test_malformed_reason(entry, reason_fragment): + reason = malformed_reason(entry) + if reason_fragment is None: + assert reason is None + else: + assert reason is not None and reason_fragment in reason + + +def test_well_formed_scopes_pass_the_lint(): + for scope in ("src/rac", "docs/*.md", "src/[ab]/x.py", ".github/workflows"): + assert malformed_reason(scope) is None + # Real callers lint the NORMALISED entry, so the ./ marker never reaches + # the '.'-segment check. + assert malformed_reason(normalize_entry("./pyproject.toml")) is None + + +# --- the governs dialect (pinned) ---------------------------------------------- + + +def test_directory_scope_governs_its_whole_subtree(): + assert governs("src/rac/cli.py", "src/rac") + assert governs("src/rac/mcp/server.py", "src/rac") # deep, * crosses / + assert governs("src/rac", "src/rac") # the directory itself + + +def test_star_crosses_separators_in_this_dialect(): + # Documented fnmatch behaviour, not a bug: * is not segment-bounded. + assert governs("src/a/b.py", "src/*") + assert governs("docs/examples/deep.md", "docs/*.md") + + +def test_double_star_behaves_as_star(): + assert governs("src/mid/test.py", "src/**/test.py") + assert governs("src/a/b/test.py", "src/**/test.py") + # "src/test.py" has no segment between src and test.py: the pattern needs + # a / on each side of **, so it does NOT match — pinned so any stdlib move + # to real recursive-glob semantics fails here first. + assert not governs("src/test.py", "src/**/test.py") + + +def test_matching_is_case_sensitive(): + assert not governs("src/rac/cli.py", "SRC/rac") + assert not governs("SRC/x.py", "src") + + +def test_character_classes(): + assert governs("src/a/x.py", "src/[ab]/x.py") + assert governs("src/b/x.py", "src/[ab]/x.py") + assert not governs("src/c/x.py", "src/[ab]/x.py") + assert governs("src/c/x.py", "src/[!ab]/x.py") + + +def test_no_false_prefix_match(): + # "rac" governs rac/... but never rac-localview/... — the subtree clause + # appends a separator, so sibling directories with a shared prefix are safe. + assert governs("rac/decisions/adr-001.md", "rac") + assert not governs("rac-localview/x.ts", "rac") + + +def test_query_normalisation(): + assert governs("./src/rac/cli.py", "src/rac") + assert governs("src\\rac\\cli.py", "src/rac") # windows separators normalise + assert governs("src/rac/", "src/rac") + + +def test_empty_query_or_scope_never_matches(): + assert not governs("", "src") + assert not governs(".", "src") # "." normalises to empty + assert not governs("src/x.py", "") diff --git a/tests/test_decisions_for_path.py b/tests/test_decisions_for_path.py new file mode 100644 index 00000000..02caa6f7 --- /dev/null +++ b/tests/test_decisions_for_path.py @@ -0,0 +1,242 @@ +"""Path→decisions lookup: service and CLI (ADR-098). + +Mirrors ``tests/test_find_decisions.py``: fixture corpus on ``tmp_path``, the +service contract first (liveness filter, scope matching, determinism), then the +``rac decisions`` CLI face (exit codes, JSON shape, byte-determinism). No +``.rac/config.yaml`` is written, so the advisory tree pass never runs here — +the lookup itself needs no repository root. +""" + +from __future__ import annotations + +import json + +import pytest + +from rac.cli import main +from rac.services.resolve import decisions_for_path + +LIVE_AUTH = """--- +schema_version: 1 +id: RAC-AVTH00000001 +type: decision +--- +# Auth module boundaries + +## Context + +c + +## Decision + +d + +## Consequences + +q + +## Status + +Accepted + +## Applies To + +- src/auth/ +- the login surface +""" + +LIVE_DOCS_GLOB = """--- +schema_version: 1 +id: RAC-D0CS00000001 +type: decision +--- +# Docs style + +## Context + +c + +## Decision + +d + +## Consequences + +q + +## Status + +Accepted + +## Applies To + +- docs/*.md +""" + +RETIRED_AUTH = """--- +schema_version: 1 +id: RAC-RETD00000001 +type: decision +--- +# Old auth decision + +## Context + +c + +## Decision + +d + +## Consequences + +q + +## Status + +Superseded + +## Applies To + +- src/auth/ +""" + +UNSCOPED = """--- +schema_version: 1 +id: RAC-VNSC00000001 +type: decision +--- +# Unscoped decision + +## Context + +c + +## Decision + +d + +## Consequences + +q + +## Status + +Accepted +""" + + +@pytest.fixture +def repo(tmp_path): + d = tmp_path / "rac" + (d / "decisions").mkdir(parents=True) + (d / "decisions" / "live-auth.md").write_text(LIVE_AUTH, encoding="utf-8") + (d / "decisions" / "live-docs.md").write_text(LIVE_DOCS_GLOB, encoding="utf-8") + (d / "decisions" / "retired-auth.md").write_text(RETIRED_AUTH, encoding="utf-8") + (d / "decisions" / "unscoped.md").write_text(UNSCOPED, encoding="utf-8") + return d + + +# --- service ------------------------------------------------------------------- + + +def test_live_decision_governing_the_path_is_returned(repo): + result = decisions_for_path(str(repo), "src/auth/login.py") + assert [m.artifact_id for m in result.matches] == ["RAC-AVTH00000001"] + match = result.matches[0] + assert match.status == "Accepted" + assert match.scopes == ["src/auth/"] # the declared entry, verbatim evidence + assert match.title == "Auth module boundaries" + + +def test_retired_decisions_never_match(repo): + # RETIRED_AUTH declares the same scope but is Superseded — liveness reuses + # the agent-rules predicate, so it is filtered out. + result = decisions_for_path(str(repo), "src/auth/login.py") + assert "RAC-RETD00000001" not in [m.artifact_id for m in result.matches] + + +def test_glob_scope_matches(repo): + result = decisions_for_path(str(repo), "docs/examples/deep.md") + assert [m.artifact_id for m in result.matches] == ["RAC-D0CS00000001"] + + +def test_component_labels_never_match(repo): + # "the login surface" is a component label; a path query never matches it. + result = decisions_for_path(str(repo), "the login surface") + assert result.matches == [] + + +def test_unrelated_path_returns_empty(repo): + result = decisions_for_path(str(repo), "src/billing/invoice.py") + assert result.matches == [] + assert result.match_count == 0 + + +def test_query_normalisation(repo): + for query in ("./src/auth/login.py", "src\\auth\\login.py", "src/auth/"): + result = decisions_for_path(str(repo), query) + assert [m.artifact_id for m in result.matches] == ["RAC-AVTH00000001"], query + + +def test_empty_query_matches_nothing(repo): + assert decisions_for_path(str(repo), "").matches == [] + assert decisions_for_path(str(repo), ".").matches == [] + + +def test_result_is_deterministic(repo): + first = decisions_for_path(str(repo), "src/auth/login.py").to_dict() + second = decisions_for_path(str(repo), "src/auth/login.py").to_dict() + assert first == second + + +# --- CLI face -------------------------------------------------------------------- + + +def test_cli_human_output_and_exit_zero(repo, capsys): + rc = main(["decisions", "src/auth/login.py", str(repo)]) + assert rc == 0 + out = capsys.readouterr().out + assert "RAC-AVTH00000001" in out + assert "Accepted" in out + assert "src/auth/" in out + + +def test_cli_empty_result_is_valid_and_exit_zero(repo, capsys): + rc = main(["decisions", "src/billing/x.py", str(repo)]) + assert rc == 0 + assert "No live decisions" in capsys.readouterr().out + + +def test_cli_json_contract(repo, capsys): + rc = main(["decisions", "src/auth/login.py", str(repo), "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["schema_version"] == "1" + assert payload["path"] == "src/auth/login.py" + assert payload["type"] == "decision" + assert payload["match_count"] == 1 + match = payload["matches"][0] + assert match["id"] == "RAC-AVTH00000001" + assert match["status"] == "Accepted" + assert match["scopes"] == ["src/auth/"] + assert set(match) == {"id", "type", "title", "status", "path", "scopes"} + + +def test_cli_not_a_directory_is_usage_error(tmp_path): + with pytest.raises(SystemExit) as exc: + main(["decisions", "src/x.py", str(tmp_path / "nope")]) + assert exc.value.code == 2 + + +def test_cli_empty_path_is_usage_error(repo): + with pytest.raises(SystemExit) as exc: + main(["decisions", "", str(repo)]) + assert exc.value.code == 2 + + +def test_cli_output_is_byte_deterministic(repo, capsys): + main(["decisions", "src/auth/login.py", str(repo), "--json"]) + first = capsys.readouterr().out + main(["decisions", "src/auth/login.py", str(repo), "--json"]) + assert capsys.readouterr().out == first diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 55dc9b42..203de9e2 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -222,6 +222,29 @@ def test_clean_corpus_has_no_unlinked_false_positive(tmp_path): assert doctor.CODE_UNLINKED_REFERENCE not in _codes(report) +def test_unmatched_applies_to_scope_warns_but_run_still_exits_zero(tmp_path): + # ADR-098 advisory: a decision scoped to a path that no longer exists warns + # in doctor and never blocks — the stale scope IS the drift signal. + root = tmp_path / "repo" + corpus = root / "rac" + corpus.mkdir(parents=True) + (root / ".rac").mkdir() + (root / ".rac" / "config.yaml").write_text("repository_key: RAC\n", encoding="utf-8") + (corpus / "scoped.md").write_text( + "---\nschema_version: 1\nid: RAC-SCPD00000001\ntype: decision\n---\n" + "# Scoped\n\n## Context\n\nc\n\n## Decision\n\nd\n\n## Consequences\n\nq\n\n" + "## Status\n\nAccepted\n\n## Applies To\n\n- src/vanished/\n", + encoding="utf-8", + ) + report = doctor.diagnose(str(corpus)) + stale = [f for f in report.findings if f.code == "applies-to-unmatched-path"] + assert len(stale) == 1 + assert stale[0].severity == "warning" + assert "src/vanished/" in stale[0].problem + assert "drift signal" in stale[0].fix + assert report.ok # warnings never fail doctor + + # --- contract: fixes, drift, determinism, exit codes ------------------------- diff --git a/tests/test_golden.py b/tests/test_golden.py index 419a7c87..63538da8 100644 --- a/tests/test_golden.py +++ b/tests/test_golden.py @@ -98,6 +98,19 @@ ("find_json", ["find", "markdown", "tests/fixtures/resolve", "--json"], 0), # Relevance-score components surface under --explain (ADR-078, additive). ("find_explain_json", ["find", "markdown", "tests/fixtures/resolve", "--json", "--explain"], 0), + # Path→decisions lookup (ADR-098). The fixture corpus has no .rac config, so + # nothing here depends on the working tree — pure scope-vs-query matching. + ("decisions_human", ["decisions", "src/auth/login.py", "tests/fixtures/applies_to"], 0), + ( + "decisions_json", + ["decisions", "src/auth/login.py", "tests/fixtures/applies_to", "--json"], + 0, + ), + ( + "decisions_empty_json", + ["decisions", "src/billing/none.py", "tests/fixtures/applies_to", "--json"], + 0, + ), ("relationships_resolved_human", ["relationships", "tests/fixtures/resolve"], 0), ("migrate_dry_run_human", ["migrate", "metadata", "tests/fixtures/migrate", "--dry-run"], 0), ( diff --git a/tests/test_mcp_tools.py b/tests/test_mcp_tools.py index e578a524..1c72f1f1 100644 --- a/tests/test_mcp_tools.py +++ b/tests/test_mcp_tools.py @@ -28,7 +28,12 @@ from rac.mcp.server import build_server from rac.output import json as json_output from rac.services.portfolio import build_portfolio_summary -from rac.services.resolve import find_artifacts, find_decisions, resolve_artifact +from rac.services.resolve import ( + decisions_for_path, + find_artifacts, + find_decisions, + resolve_artifact, +) CORPUS = fixture_path("mcp", "corpus") DUPLICATE = fixture_path("mcp", "duplicate") @@ -312,6 +317,108 @@ def test_find_decisions_empty_topic_is_not_an_error(): assert "error" not in payload +# --- decisions_for_path (ADR-098) --------------------------------------------- + + +_SCOPED_LIVE = """--- +schema_version: 1 +id: RAC-DFP000000001 +type: decision +--- +# Auth boundaries + +## Status + +Accepted + +## Context + +c + +## Decision + +d + +## Consequences + +q + +## Applies To + +- src/auth/ +""" + +_SCOPED_RETIRED = """--- +schema_version: 1 +id: RAC-DFP000000002 +type: decision +--- +# Old auth boundaries + +## Status + +Superseded + +## Context + +c + +## Decision + +d + +## Consequences + +q + +## Applies To + +- src/auth/ +""" + + +def _scoped_corpus(tmp_path) -> str: + root = tmp_path / "rac" + (root / "decisions").mkdir(parents=True) + (root / "decisions" / "live.md").write_text(_SCOPED_LIVE, encoding="utf-8") + (root / "decisions" / "old.md").write_text(_SCOPED_RETIRED, encoding="utf-8") + return str(root) + + +def test_decisions_for_path_shape(tmp_path): + root = _scoped_corpus(tmp_path) + payload = call(root, "decisions_for_path", {"path": "src/auth/login.py"}) + assert list(payload) == ["schema_version", "path", "type", "match_count", "matches", "filter"] + assert payload["type"] == "decision" + assert payload["filter"] == "live-decisions" + match = payload["matches"][0] + assert set(match) == {"id", "type", "title", "status", "path", "scopes"} + assert match["status"] == "Accepted" + assert match["scopes"] == ["src/auth/"] + + +def test_decisions_for_path_excludes_retired(tmp_path): + root = _scoped_corpus(tmp_path) + payload = call(root, "decisions_for_path", {"path": "src/auth/login.py"}) + ids = [m["id"] for m in payload["matches"]] + assert ids == ["RAC-DFP000000001"] # the Superseded twin never appears + + +def test_decisions_for_path_matches_service_one_source_of_truth(tmp_path): + root = _scoped_corpus(tmp_path) + payload = call(root, "decisions_for_path", {"path": "src/auth/login.py"}) + service = decisions_for_path(root, "src/auth/login.py").to_dict() + assert {k: v for k, v in payload.items() if k != "filter"} == service + + +def test_decisions_for_path_unmatched_path_is_not_an_error(tmp_path): + root = _scoped_corpus(tmp_path) + payload = call(root, "decisions_for_path", {"path": "src/billing/x.py"}) + assert payload["match_count"] == 0 + assert payload["matches"] == [] + assert "error" not in payload + + # --- get_related ------------------------------------------------------------- @@ -408,6 +515,7 @@ def test_get_summary_matches_cli_portfolio_json(): ("search_artifacts", {"query": "RAC-MCP"}), ("get_related", {"id": DEC}), ("get_summary", {}), + ("decisions_for_path", {"path": "src/x.py"}), ], ) def test_tool_output_is_deterministic(tool, args): diff --git a/tests/test_relationship_graph.py b/tests/test_relationship_graph.py index 1867934f..c8191374 100644 --- a/tests/test_relationship_graph.py +++ b/tests/test_relationship_graph.py @@ -50,6 +50,7 @@ def test_registry_declares_built_in_edges(): "supersedes", "related_tickets", "verified_by", + "applies_to", } supersedes = edge_spec("supersedes") assert supersedes is not None diff --git a/tests/test_relationships.py b/tests/test_relationships.py index 3282d521..7b870b60 100644 --- a/tests/test_relationships.py +++ b/tests/test_relationships.py @@ -103,6 +103,89 @@ def test_verified_by_section_is_extracted(): assert rels["verified_by"] == ["`tests/cap.spec.ts`", "`traces/cap.zip`"] +_SCOPED_DECISION = ( + "# Scope\n\n## Context\n\nc\n\n## Decision\n\nd\n\n## Consequences\n\nq\n\n" + "## Status\n\nAccepted\n\n## Applies To\n\n- src/auth/\n- the login surface\n" +) + + +def test_applies_to_section_is_extracted(): + spec = spec_for("decision") + assert spec is not None + rels = extract_relationships(parse(_SCOPED_DECISION), spec) + assert rels["applies_to"] == ["src/auth/", "the login surface"] + + +# --- applies-to advisory (ADR-098): visible, never blocking ------------------- + + +def _scoped_repo(tmp_path, *, config: bool, scope: str = "src/auth/"): + """A corpus with one scoped decision; optionally a repo root with a tree.""" + root = tmp_path / "repo" + corpus = root / "rac" + corpus.mkdir(parents=True) + (corpus / "scoped.md").write_text( + _SCOPED_DECISION.replace("- src/auth/", f"- {scope}"), encoding="utf-8" + ) + if config: + (root / ".rac").mkdir() + (root / ".rac" / "config.yaml").write_text("repository_key: RAC\n", encoding="utf-8") + (root / "src" / "auth").mkdir(parents=True) + (root / "src" / "auth" / "login.py").write_text("", encoding="utf-8") + return corpus + + +def test_matched_scope_produces_no_advisory(tmp_path): + from rac.services.relationships import validate_relationships + + result = validate_relationships(str(_scoped_repo(tmp_path, config=True))) + assert result.advisories == [] + assert result.ok + + +def test_unmatched_scope_is_advisory_and_never_blocking(tmp_path): + from rac.services.relationships import ISSUE_APPLIES_TO_UNMATCHED, validate_relationships + + corpus = _scoped_repo(tmp_path, config=True, scope="src/billing/") + result = validate_relationships(str(corpus)) + assert [a.code for a in result.advisories] == [ISSUE_APPLIES_TO_UNMATCHED] + assert result.advisories[0].target == "src/billing/" + # The load-bearing assertion (ADR-098): advisory can never mean blocking. + assert result.ok + assert result.validation_issues == 0 + assert main(["relationships", str(corpus), "--validate"]) == 0 + + +def test_advisory_pass_is_skipped_without_a_repo_config(tmp_path): + from rac.services.relationships import validate_relationships + + # No .rac/config.yaml anywhere above the corpus: a bare corpus (like most + # test fixtures) never walks a tree and never emits advisories. + result = validate_relationships(str(_scoped_repo(tmp_path, config=False))) + assert result.advisories == [] + + +def test_advisory_appears_in_validate_output_only_when_present(tmp_path, capsys): + corpus = _scoped_repo(tmp_path, config=True, scope="src/billing/") + main(["relationships", str(corpus), "--validate", "--json"]) + payload = json.loads(capsys.readouterr().out) + assert payload["validation_issues"] == 0 + assert [a["code"] for a in payload["advisories"]] == ["applies-to-unmatched-path"] + + matched = _scoped_repo(tmp_path / "other", config=True) + main(["relationships", str(matched), "--validate", "--json"]) + assert "advisories" not in json.loads(capsys.readouterr().out) + + +def test_component_labels_never_produce_advisories(tmp_path): + from rac.services.relationships import validate_relationships + + # "the login surface" is a label; it is recorded, never tree-checked. + corpus = _scoped_repo(tmp_path, config=True) + result = validate_relationships(str(corpus)) + assert all(a.target != "the login surface" for a in result.advisories) + + # --- inspect: extraction across all five artifact types (amendment 7) -------- From 2ca828232bc3c51ad86e90f74b30a36b8ea5dc68 Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Thu, 2 Jul 2026 05:52:04 +0000 Subject: [PATCH 10/11] docs(cli): document rac decisions and the Applies To section [roadmap:decision-to-code-proximity] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the rac decisions command reference (inputs, options, exit codes, JSON shape), the Applies-to scope section in the relationship docs (grammar, the format-lint vs advisory split), and brings the MCP tool table up to the actual six pinned tools — it had lagged at four, missing find_decisions as well as the new decisions_for_path. --- docs/cli.md | 46 +++++++++++++++++++++++++++++++++++++++++++ docs/mcp.md | 4 +++- docs/relationships.md | 32 ++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 1 deletion(-) diff --git a/docs/cli.md b/docs/cli.md index 39f0e059..a43fd45f 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1272,6 +1272,52 @@ rac find markdown rac/ --explain # show the relevance-score breakdown ``` +--- + +## decisions + +The live decisions whose declared `## Applies To` scopes govern a path +(ADR-098) — scoped grounding at the point of work. Given a repository-relative +file or directory, returns the Accepted, non-retired decisions that declare a +path scope matching it, each with its status and the scope(s) that matched. +Bindings and evidence, never a verdict: the lookup tells you which decisions +bind the path; reading and judging them stays with the caller (ADR-067). + +- **Input:** `rac decisions [directory]` — directory defaults to the + current directory. +- **Options:** `--json` · `--top-level` +- **Exit codes:** `0` lookup completed (matches or none) · `2` not a + directory, or an empty path + +Matching is deterministic and case-sensitive: a directory scope governs its +whole subtree, `*` crosses `/`, and component labels (entries with whitespace +or no separator, like `RAC Core`) never match a path query. An empty result is +a valid outcome, not an error. + +```bash +rac decisions src/auth/login.py rac/ +rac decisions src/auth/ rac/ --json +``` + +```json +{ + "schema_version": "1", + "path": "src/auth/login.py", + "type": "decision", + "match_count": 1, + "matches": [ + { + "id": "RAC-F1XTVREAVTH0", + "type": "decision", + "title": "Auth module boundaries", + "status": "Accepted", + "path": "rac/decisions/auth-boundaries.md", + "scopes": ["src/auth/"] + } + ] +} +``` + --- ## migrate diff --git a/docs/mcp.md b/docs/mcp.md index 651ffe31..48d72cbc 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -139,7 +139,7 @@ and cite the decision ID in its response. If you are pointing at your own repository, substitute a topic you know a decision covers. -## 5. The four tools +## 5. The six tools | Tool | When the agent calls it | |---|---| @@ -147,6 +147,8 @@ a decision covers. | `search_artifacts` | Before designing or implementing anything that a recorded decision might cover | | `get_artifact` | When an artifact ID appears, or before changing anything a decision covers | | `get_related` | After retrieving an artifact — finds what else the change could affect | +| `find_decisions` | Before proposing anything a prior decision might have settled — the live (Accepted, non-retired) decisions binding a topic | +| `decisions_for_path` | Before editing code — the live decisions whose declared `## Applies To` scopes govern the file or directory being changed (ADR-098) | `get_related` takes an optional `depth` (default `1`, capped at `5`): the default returns immediate neighbours only, while `depth>1` additionally returns a diff --git a/docs/relationships.md b/docs/relationships.md index b197cb1f..6610919b 100644 --- a/docs/relationships.md +++ b/docs/relationships.md @@ -87,6 +87,38 @@ In `rac export --graph` an external edge carries `"external": true`, deliberate ticket link from a dangling in-corpus reference (both are unresolved, only the external one is marked). +## Applies-to scope (decisions) + +`## Applies To` declares the code a decision governs (ADR-098) — the join that +lets `rac decisions ` and the MCP `decisions_for_path` tool answer "which +decisions govern the file I am editing?" deterministically. Decisions only, one +entry per line: + +```markdown +## Applies To +- src/auth/ +- docs/*.md +- the login surface +``` + +An entry is either a **path glob** (repo-root-relative, POSIX separators — any +entry with a `/` or a glob character) or a **component label** (anything with +whitespace, or a bare name like `rac-core` — recorded for humans, never +resolved). A repository-root file opts into path-hood with `./` +(`./pyproject.toml`). Matching is case-sensitive; a directory scope governs its +whole subtree. + +Two checks, deliberately split (ADR-098): + +- `rac validate` **format-lints** path entries offline (`malformed-applies-to`: + absolute paths, backslashes, `.`/`..` segments) — blocking, like any + structural error. +- A path scope that **matches nothing in the working tree** is an **advisory**: + `rac relationships --validate` reports it under a separate `advisories` key + (present only when non-empty) and `rac doctor` warns — but neither ever fails. + A moved path is the drift signal the freshness gate consumes, not a merge + blocker. + ## Viewing relationships ```bash From 9f99b03cc0e9ffd4655901786b51abf3bfd1f488 Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Thu, 2 Jul 2026 05:57:38 +0000 Subject: [PATCH 11/11] docs(decisions): declare Applies To scopes on the evidence ADRs [roadmap:decision-to-code-proximity] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The five decisions the gap-7 requirement names as evidence now declare their scope as data: adr-018 governs rac/, adr-023 src/rac/, adr-027 .github/workflows/, adr-033 src/rac/mcp/, and adr-035 carries the two component labels its prose stated. Also removes a stray trailing code fence in adr-018 that had been silently swallowing the document's tail. rac decisions src/rac/mcp/server.py rac/ now answers with ADR-023 and ADR-033 — the requirement's success metric, demonstrated on this repository's own corpus. --- rac/decisions/adr-018-rac-directory-as-root.md | 5 ++++- rac/decisions/adr-023-clean-break-internal-refactors.md | 4 ++++ rac/decisions/adr-027-ci-test-topology.md | 4 ++++ rac/decisions/adr-033-guide-response-budget.md | 4 ++++ rac/decisions/adr-035-byo-ai-credentials.md | 5 +++++ 5 files changed, 21 insertions(+), 1 deletion(-) diff --git a/rac/decisions/adr-018-rac-directory-as-root.md b/rac/decisions/adr-018-rac-directory-as-root.md index 18686e2d..2160ae65 100644 --- a/rac/decisions/adr-018-rac-directory-as-root.md +++ b/rac/decisions/adr-018-rac-directory-as-root.md @@ -315,4 +315,7 @@ Evidence this decision is successful: ## Review Date Review at v1.0.0 once external adoption patterns are better understood. -``` \ No newline at end of file + +## Applies To + +- rac/ diff --git a/rac/decisions/adr-023-clean-break-internal-refactors.md b/rac/decisions/adr-023-clean-break-internal-refactors.md index ebbf2624..0769ed3a 100644 --- a/rac/decisions/adr-023-clean-break-internal-refactors.md +++ b/rac/decisions/adr-023-clean-break-internal-refactors.md @@ -200,3 +200,7 @@ Evidence that this decision is working: Review before v1.0.0, or if RAC begins publishing a supported Python API (as opposed to its CLI and JSON contracts), which would warrant a deliberate deprecation policy. + +## Applies To + +- src/rac/ diff --git a/rac/decisions/adr-027-ci-test-topology.md b/rac/decisions/adr-027-ci-test-topology.md index 674b5039..cd9ad392 100644 --- a/rac/decisions/adr-027-ci-test-topology.md +++ b/rac/decisions/adr-027-ci-test-topology.md @@ -232,3 +232,7 @@ Evidence that this decision is working: Review before v1.0.0, or sooner if RAC accepts outside contributors who need pre-merge test feedback — which would warrant re-adding a `pull_request` trigger (rule 1) — or if the job count from the battery × version grid becomes burdensome. + +## Applies To + +- .github/workflows/ diff --git a/rac/decisions/adr-033-guide-response-budget.md b/rac/decisions/adr-033-guide-response-budget.md index a0d298ef..0baef987 100644 --- a/rac/decisions/adr-033-guide-response-budget.md +++ b/rac/decisions/adr-033-guide-response-budget.md @@ -155,3 +155,7 @@ client gains protocol-level response streaming that changes the trade-off. ## Related Roadmaps - v0.10.0-guide-foundation + +## Applies To + +- src/rac/mcp/ diff --git a/rac/decisions/adr-035-byo-ai-credentials.md b/rac/decisions/adr-035-byo-ai-credentials.md index b5f5c23e..78d4006d 100644 --- a/rac/decisions/adr-035-byo-ai-credentials.md +++ b/rac/decisions/adr-035-byo-ai-credentials.md @@ -294,3 +294,8 @@ Evidence that this decision is succeeding may include: ## Review Date Review at v1.0.0 or upon introduction of the first AI-assisted capability within RAC Core. + +## Applies To + +- RAC Core +- open-source extensions