diff --git a/.abcd/config/reading-presets.json b/.abcd/config/reading-presets.json index 0f30bd998..82ec8d02e 100644 --- a/.abcd/config/reading-presets.json +++ b/.abcd/config/reading-presets.json @@ -60,10 +60,10 @@ "test" ], "window": { - "tokens_est": 1440000, - "measured_tokens_est": 1421126, - "measured_bytes": 5471337, - "measured_at": "d37b3f04964399db5a214460357b7bdad23f74c0" + "tokens_est": 1460000, + "measured_tokens_est": 1441769, + "measured_bytes": 5550812, + "measured_at": "a586d62ff35df482f45a93989466d3a4d1b230e4" } }, "entailment": { @@ -133,9 +133,9 @@ ], "window": { "tokens_est": 420000, - "measured_tokens_est": 406587, - "measured_bytes": 1565362, - "measured_at": "d37b3f04964399db5a214460357b7bdad23f74c0" + "measured_tokens_est": 411876, + "measured_bytes": 1585724, + "measured_at": "a586d62ff35df482f45a93989466d3a4d1b230e4" } }, "comparative": { @@ -216,10 +216,10 @@ "test" ], "window": { - "tokens_est": 1450000, - "measured_tokens_est": 1430162, - "measured_bytes": 5506125, - "measured_at": "d37b3f04964399db5a214460357b7bdad23f74c0" + "tokens_est": 1470000, + "measured_tokens_est": 1450805, + "measured_bytes": 5585600, + "measured_at": "a586d62ff35df482f45a93989466d3a4d1b230e4" } } } diff --git a/.abcd/development/brief/02-constraints/03-invariants.md b/.abcd/development/brief/02-constraints/03-invariants.md index 97e5e3443..2d3398265 100644 --- a/.abcd/development/brief/02-constraints/03-invariants.md +++ b/.abcd/development/brief/02-constraints/03-invariants.md @@ -48,6 +48,6 @@ The following are non-negotiable invariants — any architectural choice that vi 18. **Shape claims in the brief's surface chapters are derived, never hand-authored** — every flag and sub-verb the chapters under `04-surfaces/` state comes from the command tree, through one generated appendix at the end of each chapter under `04-surfaces/`, composed from the walk that builds the compatibility snapshot, with one exception: a chapter's `## Sub-verbs` table (adr-40 §6) and its standard note are hand-written, and `surface_coverage` checks them against the snapshot in both directions. A chapter whose command the tree does not register carries the same block saying there is no shipped surface. The prose above the appendix states no flag the tree registers and no sub-verb written as an invocation, and says why a surface exists, what it refuses and which trade was made. The appendix carries flags and sub-verbs only: exit codes, output fields and behavioural claims stay prose and review-grain until the binary records them where a generator can read. Per [adr-2609231028044006](../../decisions/adrs/2609231028044006-surface-chapter-shape-claims-are-derived-never-hand-authored.md), delivering itd-147. Held by `TestSurfaceAppendicesMatchCommandTree` and `TestSurfaceChapterProseStatesNoShape` (`internal/surface/cli/brief_appendix_test.go`), which run in `go test ./...`. `surface_coverage` stays the row-level presence check over the surfaces index and each chapter's sub-verb table, and says so in every finding. The invariant holds those chapters only: a shape claim elsewhere in the record is not checked by it. How a chapter is added is in [`04-surfaces/README.md` § The generated appendix](../04-surfaces/README.md#the-generated-appendix). -19. **A machine takes an issue alone only when its fields say it needs no decision** — an unattended drain takes an open issue only when no record in its `blocked_by` is still open, its category is in the fixable set (`bug`, `documentation`, `drift`, `inconsistency`, `tech-debt`, `ux`), its severity is `nitpick` or `minor`, and it carries a `remedy:` other than `none (filed automatically)`, the value an automatic filer writes when it has no fix (every new issue carries a remedy; a record filed before that carries none and is listed as ineligible); `security` is always a person's, and every other open issue is handed back, listed as ineligible or skipped by the rule that excluded it, each with exactly one disposition. A host judgement over the remedy may only hand an issue back, never let one through, and the classification is re-derived every run rather than written onto the issue. The drain refuses to start without the record of the rule. Per [adr-2609291342092738](../../decisions/adrs/2609291342092738-a-drain-takes-an-issue-alone-only-when-its-fields-say-it.md), from itd-82 decision 4; the rule is `eligibility` in `internal/core/capture/eligible.go`, reached through `capture.PlanDrain`, and `TestTheEligibilityRuleIsRecordedAndAccepted` holds the record the binary names to an accepted record this invariant cites. +19. **A machine takes an issue alone only when its fields say it needs no decision** — an unattended drain takes an open issue only under the drained repository's own rule, read from an accepted decision record in its own store carrying the four `drain_` fields, and refuses a repository without one, or with a partial, malformed or ambiguous one, never falling back to a looser or a stricter rule. Under that rule it takes an issue only when no record in its `blocked_by` is still open, its category and severity are ones the rule takes, it carries a `remedy:` other than `none (filed automatically)`, the value an automatic filer writes when it has no fix, and its remedy does not open "Waits on" nor is its deferral live at the current anchor tag (every new issue carries a remedy; a record filed before that carries none and is listed as ineligible); every other open issue is handed back, listed as ineligible or skipped by the rule that excluded it, each with exactly one disposition. abcd's strict baseline takes the fixable set (`bug`, `documentation`, `drift`, `inconsistency`, `tech-debt`, `ux`) at `nitpick` or `minor` and hands every `security` issue to a person; a repository's record may narrow it, and may loosen it to `major`, `critical` or `security`, and every floor loosened is named by the dry run and at the start. A host judgement over the remedy may only hand an issue back, never let one through, and the classification is re-derived every run rather than written onto the issue. abcd's own repository states the baseline in [adr-2609291342092738](../../decisions/adrs/2609291342092738-a-drain-takes-an-issue-alone-only-when-its-fields-say-it.md), from itd-82 decision 4 and the product thinker's rulings BX2 and H11; the record is read by `drainrule.Load` in `internal/core/drainrule/drainrule.go`, the rule is `eligibility` in `internal/core/capture/eligible.go`, reached through `capture.PlanDrain`, and `TestAbcdsOwnDrainRuleIsTheStrictBaseline` holds abcd's own record to the baseline and to this citation. 20. **A workflow asserts a person's authorship only inside a declared bound, and a machine never becomes an identity on the commit** — a bot-opened dependency bump is re-authored only when its author and the run's actor are a bot the repository's declaration names, its branch lives in the repository under the row's prefix, it carries one commit by that bot, and that commit only modifies files the row names at the directory the bot's own configuration declares; anything else is left alone with the failed clause named. The re-authored commit carries the owner the declaration names as author AND committer; the GitHub App that pushes it is never either, so the attribution gate stays unchanged and judges it like any other commit. The bound judges which files change, never their content, and that residual is accepted on the record. The App's credentials are Dependabot secrets, and while the owner or either secret is missing an in-bound bump is refused by name — the workflow never pushes with its own token and never keeps the bot as author. Per [adr-2609292116133348](../../decisions/adrs/2609292116133348-a-dependency-bump-inside-the-bound-is-re-authored-as-the.md), on iss-2609221820487644 and itd-2609221842494980; the bound is `scripts/dependency-reauthor.sh`, byte-exact with the template `abcd launch scaffold` lays, held by the tests in `internal/core/launch/scaffold/dependency_reauthor_test.go`. diff --git a/.abcd/development/brief/04-surfaces/01-ahoy.md b/.abcd/development/brief/04-surfaces/01-ahoy.md index a3696148d..4dc933709 100644 --- a/.abcd/development/brief/04-surfaces/01-ahoy.md +++ b/.abcd/development/brief/04-surfaces/01-ahoy.md @@ -119,6 +119,17 @@ the API host explicitly, so an ambient host variable cannot send the write to an endpoint the origin never named, and the call goes through the caller's own authenticated identity: abcd never holds a token. +That identity is the GitHub CLI's, so a missing `gh` is met with the +explain-then-install mode (itd-63): after the first three gates and before the +read, the verb explains `gh` from the tool registry and offers to install it, +running the registry's step only on a yes typed at a terminal. The pre-given +yes answers the settings change and never the install of a program, and a +piped answer is not a person's answer, so both decline the offer; the verb then +refuses, its notes carrying the explanation and the command. A failed or +unverified install refuses the same way, before any request leaves the +machine. The read never offers the install, because looking is never acting; +it names the apply as the verb that does. + ### The provider setup The setup takes the provider's name, its base URL, its first allowlist (every @@ -476,6 +487,7 @@ about, one question per category present, never one per item. | `dependency` | a tool a capability uses and cannot find: gitleaks, optional over the native secret scanner and required where the repository armed it in `.abcd/config/gitleaks.json` | the category approval reaches the step; each tool is then explained from the tool registry (what it is, optional or required here, what works without it, the exact install step, what the install does) and its install step runs only on a per-tool yes — typed at a terminal, or relayed by a host as a flag naming the tool — never under the approve-everything flag, a piped answer or CI; a no is reported as what the capability continues on | | `status-line` | the offer of abcd's status line in the host harness | an advisory offer asked after its own question, written only on an answered consent; never under the approve-everything flag, and reported as optional work it skipped | | `oracle-routing` | the offer of abcd's proposed model-tier routing table (itd-2609170822093401): the machine's `~/.abcd/oracle-routing.json`, then, as a separate question, the repository's `.abcd/config/oracle-routing.json` | the proposal rendered as a table (agent, tier, fan-out) and each file written only on its own answered consent, the machine one owner-only; never under the approve-everything flag, and reported as optional work it skipped; a decline records nothing, so the next install offers again; uninstall leaves both files | +| `drain-rule` | the offer of the repository's drain eligibility record (ruling BX2, itd-82): abcd's strict baseline as an accepted decision record carrying the four `drain_` fields, minted through the decision store's seam | the rule stated in one question and the record written only on a consent answered at a terminal; never under the approve-everything flag and never off a terminal, where neither its category nor the offer is asked (so a piped answer stream keeps its order), and reported as optional work it skipped; a decline records nothing, so the next install offers again; raised only while no accepted record states the rule, so a record stating it badly is never offered a second; only ever the baseline, never a loosened rule | | `user-state` | the registry entry, re-founding, stale or duplicate entries | guided; never auto-edit user-scope state, report extras read-only | **The artefact kind is a gap until it is declared** (itd-2609150819432059). A diff --git a/.abcd/development/brief/04-surfaces/04-launch.md b/.abcd/development/brief/04-surfaces/04-launch.md index c7b6d225c..07fbcd7ef 100644 --- a/.abcd/development/brief/04-surfaces/04-launch.md +++ b/.abcd/development/brief/04-surfaces/04-launch.md @@ -675,7 +675,13 @@ the parity diff and the deep smoke tier where the run made them, and every planned intent that names a release it must land by, under *Targeted, not shipped* (itd-2609212103572513): the preview, the cut's emit and its ingest list the same intents in their human and machine-readable output too, and none of -them refuses on one. +them refuses on one. The ingest moves every target the cut passes (`next`, or a +tag at or below the derived version) to `next`, whatever the following release +is numbered (the product thinker's ruling BS1 of 2026-09-29), rewriting the +record in the write that rolls the changelog and naming the move in one line +under the dated section's notice (`changelog.TargetMoveNote`), which the site's +release stamp passes over because the line names intents the release did not +ship. A refused cut writes its report too, and the refusal names where it landed. The preview's JSON carries `report_path`, the cut's `preflight_report`. A detector fails the build if any non-test Go source under `internal/` so much as names the diff --git a/.abcd/development/brief/04-surfaces/05-intent.md b/.abcd/development/brief/04-surfaces/05-intent.md index 23ce3a1cd..f4df66089 100644 --- a/.abcd/development/brief/04-surfaces/05-intent.md +++ b/.abcd/development/brief/04-surfaces/05-intent.md @@ -70,7 +70,7 @@ judgement no verb makes. - **ID format:** `itd-N` (unpadded — e.g., `itd-1`, `itd-15`). Mirrors the spec store's `spc-N` format. Filenames: `itd-N-.md`. Lexical-vs-numeric sort handled at the tool layer (`internal/core/lint`, registries) rather than via filename padding. - **The low IDs (itd-1..itd-7) reflect an early one-time rebase to ordering signal.** Intents created since are capture-stable, picking up at itd-27+. ID number is *not* an execution-order guarantee — the canonical build order is the phase plan at [`roadmap/phases/`](../../roadmap/phases/README.md). -- **An intent carries one release field, and only while planned: `target_release`.** A planned intent may name the release it must land by, `vX.Y.Z` or `next`, written by the target sub-verb or by planning with a target ([adr-2609212115255771](../../decisions/adrs/2609212115255771-phases-and-milestones-are-retired-sequencing-is-dependencies.md) decision 3, itd-2609212103572513). The launch preview and the cut list every targeted intent still in `planned/`, in their human and machine-readable output and in the pre-flight report, and neither refuses on one. Every move out of `planned/` drops the line — the spec close, the bundle close and the supersession — and record-lint's `record_schema` rule refuses it on a shipped or superseded intent, and refuses a value that is neither shape. +- **An intent carries one release field, and only while planned: `target_release`.** A planned intent may name the release it must land by, `vX.Y.Z` or `next`, written by the target sub-verb or by planning with a target ([adr-2609212115255771](../../decisions/adrs/2609212115255771-phases-and-milestones-are-retired-sequencing-is-dependencies.md) decision 3, itd-2609212103572513). The launch preview and the cut list every targeted intent still in `planned/`, in their human and machine-readable output and in the pre-flight report, and neither refuses on one. The cut moves every target it passes — `next`, or a tag at or below the release it cuts — to `next`, whatever the following release is numbered (the product thinker's ruling BS1 of 2026-09-29), in the same write as the dated changelog section, which names the move in one line under its notice. Every move out of `planned/` drops the line — the spec close, the bundle close and the supersession — and record-lint's `record_schema` rule refuses it on a shipped or superseded intent, and refuses a value that is neither shape. ### Intent kinds (per [`01-product/03-mental-model.md`](../01-product/03-mental-model.md)) @@ -175,7 +175,7 @@ its own condition rather than one still waiting on it. ### Lifecycle - **The low IDs (itd-1..itd-7) reflect an early one-time rebase to ordering signal.** Intents created since are capture-stable, picking up at itd-27+. ID number is *not* an execution-order guarantee — the canonical build order is the phase plan at [`roadmap/phases/`](../../roadmap/phases/README.md). -- **An intent carries one release field, and only while planned: `target_release`.** A planned intent may name the release it must land by, `vX.Y.Z` or `next`, written by the target sub-verb or by planning with a target ([adr-2609212115255771](../../decisions/adrs/2609212115255771-phases-and-milestones-are-retired-sequencing-is-dependencies.md) decision 3, itd-2609212103572513). The launch preview and the cut list every targeted intent still in `planned/`, in their human and machine-readable output and in the pre-flight report, and neither refuses on one. Every move out of `planned/` drops the line — the spec close, the bundle close and the supersession — and record-lint's `record_schema` rule refuses it on a shipped or superseded intent, and refuses a value that is neither shape. +- **An intent carries one release field, and only while planned: `target_release`.** A planned intent may name the release it must land by, `vX.Y.Z` or `next`, written by the target sub-verb or by planning with a target ([adr-2609212115255771](../../decisions/adrs/2609212115255771-phases-and-milestones-are-retired-sequencing-is-dependencies.md) decision 3, itd-2609212103572513). The launch preview and the cut list every targeted intent still in `planned/`, in their human and machine-readable output and in the pre-flight report, and neither refuses on one. The cut moves every target it passes — `next`, or a tag at or below the release it cuts — to `next`, whatever the following release is numbered (the product thinker's ruling BS1 of 2026-09-29), in the same write as the dated changelog section, which names the move in one line under its notice. Every move out of `planned/` drops the line — the spec close, the bundle close and the supersession — and record-lint's `record_schema` rule refuses it on a shipped or superseded intent, and refuses a value that is neither shape. - **Lifecycle (automated, not user-managed):** ``` @@ -319,7 +319,7 @@ Later phase — intent-auditor (shape-classification role) scans the corpus | Audit ingest (a verdict JSON path) | Ingests a host-delegated intent-fidelity verdict JSON, validated fail-closed against the schema and the parked review request, and writes its per-criterion verdict and its disposition of each scope condition the intent carries into the shipped intent's `## Audit Notes`, making it the first writer into the scope-condition disposition surface (or quarantines a bad payload, which records every condition `untested`). The machine-readable result says what the ingest recorded — the verdict, a quarantine, or nothing — and carries the acceptance rollup and the disposition split only beside a recorded verdict; a quarantine states the conditions it recorded untested under a name of its own, so its result never reads as a rollup. A second ingest for the same receipt is a no-op when its payload renders to the block on the record, replaces that block in place when it renders differently, and is refused with nothing written when it does not validate. A verdict whose rendered prose cites a record id that names no record is refused, naming the id, with nothing written, wherever the repository's record-lint gates prose citations in the intent store. Each block closes on its own closing line, so prose written below it survives a replacement, and only a marker on a live line of `## Audit Notes` is review state: one in a fenced block or an HTML comment is an example. | (no move; updates `## Audit Notes`) | | Condition disposition (one shipped intent id, optionally one condition id) | **The second writer into the scope-condition disposition surface.** With the intent alone it is read-only: every scope condition the intent carries, with its standing disposition and the block that disposition came from, or `untested (no block)`; the machine-readable form carries the whole history and the fold. With a condition identity it writes one disposition against a **shipped** intent — `survived`, `narrowed`, `falsified` or `untested` — joined to what occasioned it: a reading item at any position, or a delivered intent in `shipped/` whose delivery changed the condition's standing. It appends one dated block to `## Audit Notes`, beside the fidelity verdict's blocks and in the same bullet shape. A condition's standing is its latest reading-occasioned block where it has one, and otherwise its latest verdict block: a verdict overrides a reading-occasioned block only where its rationale names that block's occasion, wherever the two sit in the section; the verdict ingest reports what it leaves standing, and a re-ingest for the same receipt that names the occasion replaces the ingested verdict. Refused, with nothing written: an intent not in `shipped/` (naming its bucket), an identity the intent does not carry or carries twice, a value outside the four, grounds below the substance floor, `narrowed` without a narrowing or a narrowing on any other value, an occasion that does not resolve, and the intent itself as its own occasion. Grounds and narrowing are redacted before the write. When a reading item's `constraint_in_play` cites a different condition's identity, the mismatch is reported and never refused: the reading names the tension and the researcher marks the condition. The block sits under the heading every reading's assembler withholds, so no disposition reaches a reading. | (no move; appends to `## Audit Notes`) | | Consistency (the whole corpus, or one intent id) | **Role 2 — cross-document fidelity** (itd-48). Assembles the corpus — every brief page, and every intent outside `superseded/` reduced to its title, press release, scope, decisions and rule — into one input under the local tier, and writes the request beside it: the five judgement classes (terminology drift, premise contradictions, scope leakage, sequencing impossibilities, naming conflicts), the rubric, the findings shape rendered from the structure the ingest decodes, the host-computed provenance pair the audit's request carries, and the commit the tree stood at. With an intent id the pass is that intent against the rest of the corpus, and every finding must have an end in it; a superseded or unknown intent is refused. The judgement rides the host: the intent-auditor's Role 2 reads the corpus and returns findings, each naming exactly two ends, quoted. The receipt is deterministic over the scope and the corpus, so a re-emit over an unchanged corpus reuses it. | (no move; writes the request and the corpus to the local tier) | -| Consistency ingest (a findings JSON path) | Validates the returned findings fail-closed before anything is written: the request was issued here, the corpus has not moved since (the receipt is recomputed), the provenance pair is the one issued, every class and severity is in its set, each end's path is a corpus document whose text holds the end's quote (twelve characters at least), and no finding repeats another. A finding it would file whose text cites a record id that names no record is refused too, naming the finding and the id, wherever the repository's record-lint gates prose citations in the issue ledger — every finding is checked before the first is filed, so nothing is written. Then it files one capture per finding — an `inconsistency` from an `agent-finding`, found during the pass that names the report, located at its first end, with the report as its evidence — unless an open record already quotes either end and names its document, in which case the finding is linked to that record rather than filed twice; and it writes a dated report on the reviews shelf naming, in its `review_of_commit` pin, the commit the pass read — marked `dirty: true`, with the uncommitted corpus paths named, when the emit or the ingest's own second reading of the tree against that commit finds a corpus document edited, untracked or deleted relative to it — the union of the two, so the mark is never lost to an edited request or a commit made since the emit — since the pass reads the working tree (itd-28's dirty-tree policy: mark, do not block) — then the receipt and every finding with both ends quoted and located and the record it was filed as or linked to. A second run the same day takes the next free suffix; the same findings ingested again are a no-op naming the report. Neither half writes the brief or an intent. | (no move; writes the report and the ledger) | +| Consistency ingest (a findings JSON path) | Validates the returned findings fail-closed before anything is written: the request was issued here, the corpus has not moved since (the receipt is recomputed), the provenance pair is the one issued, every class and severity is in its set, each end's path is a corpus document whose text holds the end's quote (twelve characters at least), and no finding repeats another. A finding it would file whose text cites a record id that names no record is refused too, naming the finding and the id, wherever the repository's record-lint gates prose citations in the issue ledger — every finding is checked before the first is filed, so nothing is written. Then it files one capture per finding — an `inconsistency` from an `agent-finding`, found during the pass that names the report, located at its first end, with the report as its evidence — unless an open record already quotes either end and names its document, in which case the finding is linked to that record rather than filed twice; a finding it files runs capture's filing-time match on its summary and explanation, never against a record the same pass filed, and carries a `duplicates:` or `refines:` link naming each likely double; and it writes a dated report on the reviews shelf naming, in its `review_of_commit` pin, the commit the pass read — marked `dirty: true`, with the uncommitted corpus paths named, when the emit or the ingest's own second reading of the tree against that commit finds a corpus document edited, untracked or deleted relative to it — the union of the two, so the mark is never lost to an edited request or a commit made since the emit — since the pass reads the working tree (itd-28's dirty-tree policy: mark, do not block) — then the receipt and every finding with both ends quoted and located and the record it was filed as or linked to. A second run the same day takes the next free suffix; the same findings ingested again are a no-op naming the report. Neither half writes the brief or an intent. | (no move; writes the report and the ledger) | | `/abcd:intent shape []` | **Role 3 — kind classification.** Examines whether an intent's declared `kind` (the noun) still fits the corpus. Surfaces *suggested* reclassifications across three live types: `kind_change`, `bundle`, `supersession`. **Bare** scans the corpus; **with ``** checks one intent. Pairs with the reclassify step (action verb that commits a `shape` finding). On-demand only per spc-29 (predecessor store; a later phase); findings land in `.abcd/.work.local/logs/audit/shape-/report.{json,md}`. Concurrency via `flock(2)` on `.abcd/coordination/shape.lock` (see § 7). Scheduled / continuous invocation is a deferred follow-up. | (stays) | | Reclassify (one intent id, its new kind) | **Late reclassification** (itd-34). A kind change — standalone ↔ bundle-member, joining a bundle another record already names — on a draft or planned record rewrites the kind (and the bundle, set or cleared) in place. A supersession, naming the successor (an intent `itd-M`, or an ADR `adr-M` when a decision redecided the question) and a reason, moves the file to `superseded/` with `superseded_by`, `kind_at_supersession` and the supersession note, and appends the record to the successor's `supersedes` in the same write; superseding one member of a bundle of two leaves the other a bundle-member whose history says the bundle now has one member. Every change appends a `reclassification_history` entry; the reason is one line, redacted. Refused with nothing written: a shipped intent's kind change (the remedy for a rule found after the fact is a discipline that supersedes it), any move into disciplines/, a planned member leaving its bundle's shared spec, a missing or superseded successor, and a held record. The result names every path moved and written. | `→ superseded/` for a supersession; otherwise no move | | Hold (one intent id and a reason) | Holds a draft or planned intent: writes `held: ""` — the reason is required, single-line and redacted through the store's scanner before the write, and the JSON reports `redacted` like the other write verbs. Planning and closing a spec refuse a held record before anything moves, naming the reason and the unhold that lifts it; `abcd ` reports the hold as the next move. Refused on a record already held (naming the standing reason — an updated reason is an unhold then a hold) and on a shipped, superseded or discipline record. The `record_provenance` lint rule reports a `held` value in a shape the verb never writes; a legal hand-typed line is byte-identical to the write and is not reported. | (no move; writes `held`) | diff --git a/.abcd/development/brief/04-surfaces/06-capture.md b/.abcd/development/brief/04-surfaces/06-capture.md index 1a771de60..59525ac4a 100644 --- a/.abcd/development/brief/04-surfaces/06-capture.md +++ b/.abcd/development/brief/04-surfaces/06-capture.md @@ -118,6 +118,19 @@ it and removes it by deleting its line, which leaves an ordinary record. The match proposes no `reverses` and no `supersedes`: the itd-84 discipline keeps a reversal advisory and human. +The same match runs on the ledger's two unattended writers, through the same +core and configuration: a promoted inbox report is compared by its own title +and prose, and each finding the consistency pass files by its summary and +explanation. Neither compares the lines every record it files carries (the +inbox's provenance, the pass's evidence line), on which two unrelated records +would match, and the consistency pass never compares a record the same pass +filed. The reading ingest runs it on every stored finding +([`23-reading.md`](23-reading.md)), and promoting an accepted reading item +matches the draft it mints on the item's pattern and body, since the pattern +alone is too short to compare, and links the draft as a quoted-text create is +linked (ruling DQ2b, +[adr-2609300821558671](../../decisions/adrs/2609300821558671-a-reading-finding-is-matched-against-the-record-when-it-is.md)). + One flag belongs to one category: the lapse-instant flag carries the RFC 3339 instant a recorded discipline gave way, for the `lapse` category, and it has no default. @@ -201,8 +214,10 @@ keyed reading record. Once an item already carries a standing answer, a new one must cite it as superseded: that is the only exit from a hold, and what makes the standing disposition the one no sibling supersedes. An item the researcher recognises as one that has come round before says so as a recurrence, -naming the earlier items it recurs from; that is a recorded recognition, never a -join a machine derived. Two hold-shaping flags are reserved and dormant, and a +naming the earlier items it recurs from; that is the researcher's confirmed +recognition. The machine's proposal of the same thing is the `duplicates:` or +`refines:` link the reading ingest writes onto the item +([adr-2609300821558671](../../decisions/adrs/2609300821558671-a-reading-finding-is-matched-against-the-record-when-it-is.md)). Two hold-shaping flags are reserved and dormant, and a populated value is refused until activation is ruled. **At the widening position the order is fixed: characterise first, admit diff --git a/.abcd/development/brief/04-surfaces/08-abcd.md b/.abcd/development/brief/04-surfaces/08-abcd.md index 32ca9fb06..72273e8a1 100644 --- a/.abcd/development/brief/04-surfaces/08-abcd.md +++ b/.abcd/development/brief/04-surfaces/08-abcd.md @@ -232,23 +232,32 @@ by the pick's one rule (`intent.PickLess`), the readiest first and the oldest among equals. The head is the first of them the pick would start: one that passes the build's record-only pre-start checks, read through the one statement of them the build runs too (`intent.StartChecksIn`: no open question, -the claim sections answered, no hold, no unshipped blocker, a step left to +the claim sections answered, no hold, no unsettled blocker, a step left to build), and not one the state file shows in a lane, as the pick passes over an -intent with a run in progress. The build's peers check is not run: the block -reads no other checkout, so an intent a peer holds can still be the head. The +intent with a run in progress. Nor is it one another checkout holds: the +board pays the build's peers check (`loop.StatusPeers`, the check build next +runs; ruling CC1 of 2026-09-29), read once and only when a head is in reach, +so the head is always the intent the pick would choose. A peer the listing +names and cannot read holds every record for the head as for the pick, so +neither names one. The site's Status page reads no other checkout: another +checkout's holdings are this machine's state, never a published page's. The block's `order` field names that order (`pick`). The text render is a `status:` heading with the three counts, then `Now:` and `Next:`, one line per intent: its id, its title, and in brackets its lane state -or `next up`; then `Later: N intents`, Later as a count alone (ruling BV1 of +or `next up`, then `target ` when the intent names the release it must +land by (itd-2609212103572513 criterion 4); then `Later: N intents`, Later as a count alone (ruling BV1 of 2026-09-29), its rows left to the JSON and the site's Status page. The JSON carries a `status` object with `now`, `next` and `later` in full, each row `id`, `title`, `bucket`, and -`next_up`, `lane` (`run`, `lane`, `stage`, `awaiting`) or `failing_checks` when -they apply, and `order`. The block is present in a repository abcd manages and +`next_up`, `lane` (`run`, `lane`, `stage`, `awaiting`), `failing_checks` or +`target_release` (a planned intent's target, `next` or `vX.Y.Z`) when they +apply, and `order`. The block is present in a repository abcd manages and absent elsewhere, and a record that cannot be read omits it with the reason on stderr. The read is `internal/core/statusblock`, the one the site's Status -page renders too ([`22-site.md`](22-site.md#the-page-set)); the state file is -read through the implement loop (`loop.StatusLanes`). +page renders too ([`22-site.md`](22-site.md#the-page-set)); the state file and +the peers are read through the implement loop (`loop.StatusLanes`, +`loop.StatusPeers`), and a fault reading the peers omits the block with the +reason on stderr, as it refuses build next. ## The board itself is not built diff --git a/.abcd/development/brief/04-surfaces/17-guard.md b/.abcd/development/brief/04-surfaces/17-guard.md index bda67ad7b..03ba96b8a 100644 --- a/.abcd/development/brief/04-surfaces/17-guard.md +++ b/.abcd/development/brief/04-surfaces/17-guard.md @@ -82,9 +82,12 @@ the safe successor, recalled by the commands the registry names (`rm`, work injects those rules before the agent acts, so a host without hook support is still taught the safe form and a host with hooks is taught it before the guard would have to refuse. An entry added to the registry is taught and enforced from -the same release, with no second edit. The domain is built from the bundled -registry, not from a repo's `.abcd/guard.json`; how the domain is recalled, -overridden and silenced is the rules loader's +the same release, with no second edit. The registry taught is the one the guard +enforces in the repository: an entry the repository adds in its +`.abcd/guard.json` is taught by the same generator as the bundled ones, its +rule marked `(repo)` after its entry id, and a guard file the guard refuses is +named on stderr and never taught. How the domain is recalled, overridden and +silenced is the rules loader's ([`05-internals/03-configuration.md`](../05-internals/03-configuration.md)). ## The question gate @@ -341,16 +344,61 @@ variables its text holds, and a name runs on into the letters a list or a sequence places after it (`{$HOME,x}`, `$HOME/{.*,}`, `$HO{ME,}`, `$HO{M..M}E`); an expansion whose operator can leave the value as it is reads as the variable itself — a default, an assignment or an error message -(`${HOME:-x}`), a trim or a pattern replacement (`${HOME%/}`, `${HOME#x}`, -`${HOME/x/y}`), a substring, a case change, and a subscript read to its -matching `]` with any text after it (`${HOME[x[0]]}`, `${HOME[0]]}`, which the -bash 3.2 of macOS prints as the value); and an alternative, which prints its +(`${HOME:-x}`), where the colon forms of a single parameter never print an +empty value (`${1:-dist}/` is `${1}/` or `dist/`), while those of `$@` and +`$*` test the parameter count and can (`${@:-x}/` is `/` after +`set -- "" ""`), and an empty word prints the empty text +(`${X:-}/` is also `/`), a trim or a pattern replacement (`${HOME%/}`, `${HOME#x}`, +`${HOME/x/y}`), any substring of a variable, which also reads as the root +and as nothing (`${X:1}`, `${PWD:0:1}`), while a slice of the positional +parameters or a part of one reads as those parameters (`"${@:2}"` and +`"${1:2}"` as `"$2"`), a case change or a transform (`${X^}`, `${X@P}`), and +a subscript read to its matching `]` with any text after it (`${HOME[x[0]]}`, +`${HOME[0]]}`, which the bash 3.2 of macOS prints as the value), the last +three also as nothing (`${A[0]}/` is also `/`); and an alternative, which prints its word or nothing, reads as that word as written (`${X:+$HOME}`, `${X:+/}`, `${X:+$HOME/*}`), including one the bash 3.2 of macOS reads at the first operator after a subscript (`${X[0]]:+$HOME}`). Unquoted, the alternative's word is split on whitespace and a substitution in it that prints nothing drops out, as bash splits and drops them (`${X:+$HOME }`, -`${X:+$(true)$HOME}`). A trim that leaves the path above the home +`${X:+$(true)$HOME}`). A word written as an ANSI-C or a locale string reads +as the text it decodes to (`${X:-$'\x2f'}`, `${X:-$"/"}`); a positional, a +special or an indirect parameter takes the same operators (`${1:-/}`, +`${#:+/}`, `${!X:-/}`, the last read as every target past its operator, +since the variable it names is not in the line); a parameter that can print +nothing at the top of a fresh shell — `$!` before any job runs in the +background, `$@`, `$*` and a positional one with no argument, `$_` after +`x=`, and `$-` under dash — also reads as the text beside it (`$!/`, +`"${1}"/` and `/$!` are `/`), and `$!` in a pattern as text of any length +(`${PWD%%$!*}` is `${PWD%%*}`); a replacement's pattern is +read both where bash 3.2 ends it and where bash 5 does, at a quoted `/` +(`${X/"/"*/$HOME}`); and on a line that names IFS — in any word or +anywhere in its text (`: $((IFS=1))`), or through an assignment target +that holds an expansion — an unquoted default's or +alternative's word, and an unquoted home, reads as every target, since the +fields bash splits it into rest on that IFS (`IFS=x; rm -rf ${U:-x/x}`, +`IFS=Uv; rm -rf $HOME/x`). bash sets the variable a target's value names, +so the rule reads whether a target holds an expansion (`$name`, `${…}` +with its case changes and transforms, `$(…)`, a backtick substitution), +never which bytes are written beside it: `(( ${a}${b} = 1 ))` names IFS +with a=I and b=FS. A target is the word before an assignment operator +(`=` or a compound one, spaced or not) or beside a `++` or `--`, read in +the raw text of each layer, so it is found in every position bash assigns +through one: an assignment word's name, a declaration's or `env`'s operand, +an eval'd string, every arithmetic context (`(( ))`, `$(( ))`, `$[ ]`, a +`for (( ))` header, a subscript, a substring offset, `[[ -eq ]]`), a string +an arithmetic context later reads (`let "$x = 1"`, an integer variable's +value), and `${!x:=1}`, which sets the name x holds. A subscript is not +the name (`a[$i]=x` names `a`); its body is arithmetic and read as such. +The builtins that take a name as an operand (`read "$x"`, +`printf -v "$x" 1`, `mapfile`, `getopts`, `wait -p`) are read over their +words, and so is a nameref's declaration (`declare -n r=$x`, +`local -n r`), whose value is the name a later plain assignment sets. The +rule stays off a test's comparison (`[ $a = b ]`), a word-leading `--` +(`git log --$fmt`) and the `--` that ends options, and over-reads on the +refusing side: `IFS=x rm -rf ${U:-x/x}`, and `read -p "$prompt" f;`, +`echo "$k = $v";` or `[ ! $a = b ];` before `rm -rf ${U:-x/x}`. A run of `/` written before the home names the +home (`/$HOME`). A trim that leaves the path above the home (`${HOME%/*}`) blocks as the home does. Each target is also compared as a path with its redundant separators taken out, since the kernel reads a run of slashes as one, a `.` segment as the directory itself and the root as its own @@ -411,7 +459,11 @@ prints the root (`${PWD:0:1}`), which warns as `$PWD` does; one behind a wrapper table does not name; a REST path an entry names by its root segment when the host serves that API under a prefix; an IFS the shell already holds when the line starts, or gains during the line -through a name the guard does not read (`declare $(echo I)FS=x`, a sourced file), +through a name the guard does not read (a sourced file, a nameref set before +the line, or an operator the line does not write: a value built from +expansions or a command's output that an arithmetic context evaluates, as in +`x=$(cmd); : $((x))`, or a decrement written as its own word in such a +string, as in `n="1 + --$x"` for an integer `n`), since every line is read from the default IFS; a pid list a kill reads through a variable or a file, or from a `ps | grep` chain; a payload inside a non-shell interpreter such as `python -c`, which is diff --git a/.abcd/development/brief/04-surfaces/21-update.md b/.abcd/development/brief/04-surfaces/21-update.md index 3a9a0c35e..8d6f292f0 100644 --- a/.abcd/development/brief/04-surfaces/21-update.md +++ b/.abcd/development/brief/04-surfaces/21-update.md @@ -103,6 +103,32 @@ as somebody else's binary the next time an install shape is judged. The re-stamp runs on an already-current outcome too, and it does nothing at all where no record names that path. +## An update says so once + +Every swap of the abcd binary announces itself once, when it completes, in one +wording: `abcd updated from to ` (`update.UpdatedFormat`, the ruling +CJ1b of 2026-09-29). `abcd update` opens its receipt with that line, and the +plugin bootstrap (`hooks/bootstrap.sh`) opens its success notice with it when +the release it installs replaces an earlier one, and records the replaced +release as `previous_tag` in the `binary-meta` it writes at the swap (the ruling +CJ1). A first install, a copy out of the cache at the same release and the +per-session fast path replace no release and print no such line. The session +start check does not compare versions and shows nothing about an update, with +one exception: the bootstrap salvage that the per-prompt, per-command and +pre-compaction hooks run discards its output, so a swap made there adds +`transition_unseen=yes` to the `binary-meta` it writes, and the next session +start shows the line once. Once is a claim per release: the session check +creates `cache/update-shown-` in the plugin data directory, or +`.update-shown-` in the plugin root when the bootstrap ran in its degraded +per-root mode and wrote the root's `.binary-meta`, with one exclusive create +(`O_CREATE|O_EXCL`). Of any number of sessions starting together, the one whose +create succeeds shows the line; a claim path that already exists, or a create +that fails for any other reason, shows nothing, so the line never repeats. That +empty claim file is the session check's single write, and claims for earlier +releases stay where they are. A directory failing the shape check every reader +of the data directory applies, or a tag outside the release-tag alphabet, shows +nothing and writes nothing. + ## The receipt Three terminal outcomes ship, and the receipt's `action` field names which one @@ -110,7 +136,7 @@ happened: | `action` | What it means | |---|---| -| `swapped` | the file was replaced, and the render reads `updated : -> ` | +| `swapped` | the file was replaced, and the render opens `abcd updated from to `, with the path on the line below | | `already-current` | the target's digest already equals the release's, so the binary is left untouched | | `refused` | a dispatch or ownership refusal, naming its shape and its remedy | diff --git a/.abcd/development/brief/04-surfaces/22-site.md b/.abcd/development/brief/04-surfaces/22-site.md index 3a105ddba..a21dc77de 100644 --- a/.abcd/development/brief/04-surfaces/22-site.md +++ b/.abcd/development/brief/04-surfaces/22-site.md @@ -61,7 +61,11 @@ no remote change attempted, unless the run is told to replace it. **The forge.** Two deployment environments, one for the render and one for the deploy, each admitting only the default branch and release tags, created -through the forge's API as the person running the verb. The default branch is +through the forge's API as the person running the verb. A missing `gh` is +offered for install on the same terms as the remote apply's: explained, and +installed only on a yes typed at a terminal, never on the pre-given yes; a +declined offer leaves the environments uncreated, with the command in the +notes. The default branch is the one the forge names for the repository, and it is also the branch the workflow gates on; only when the forge cannot answer does the checkout's own stand in, and the report's notes say so. An environment that @@ -102,11 +106,14 @@ The status page is the record health page, `/record/health/`. It opens with the Now / Next / Later block the bare `abcd` board carries ([`08-abcd.md`](08-abcd.md)), rendered from the same read (`internal/core/statusblock`): three panels, each row an intent's id linked to -its record page, its title, and what places it there, with Next and the head +its record page, its title, what places it there, and the release it targets +when it names one (the `status.target` label), with Next and the head in the pick order the board reads them in. The site build reads the implement loop's state file for Now's lane rows through the reader its front door hands it, the loop's own, and a build with no state file, as a release -build has, shows Now as the head alone. +build has, shows Now as the head alone. The site build hands in no peers +check, so its head, unlike the board's, does not pass over an intent another +checkout on the building machine holds. The documentation tree under `/docs/` is not among these pages: the docs build writes it beside them. The composition declaration's `docs` block says it is diff --git a/.abcd/development/brief/04-surfaces/23-reading.md b/.abcd/development/brief/04-surfaces/23-reading.md index 57eb47e33..127c72a93 100644 --- a/.abcd/development/brief/04-surfaces/23-reading.md +++ b/.abcd/development/brief/04-surfaces/23-reading.md @@ -275,6 +275,20 @@ that run's own commit marker: A refused run reports the orphans it left in place instead of sweeping them: the sweep is a delete in the committed tier, and a refused run never reaches one. +**Every stored finding is matched against the record** (ruling DQ2b, +[adr-2609300821558671](../../decisions/adrs/2609300821558671-a-reading-finding-is-matched-against-the-record-when-it-is.md)). +The ingest runs capture's filing-time match on each item as it lands, with the +same threshold, link cap and configuration (see +[`06-capture.md`](06-capture.md)): the item's pattern and body are compared +with the open and resolved issues, the intents and every earlier reading item, +and never with another item of the same ingest or with the envelope every item +of a run shares. A likely repeat is written onto the reading record as +`duplicates:` or `refines:`, and the ingest shows each item's match, printed and +as `matches` in the JSON. A link is a proposal the researcher keeps or deletes; +the confirmed form of a recurrence stays the disposition's `recurs` citation. +The match never refuses the ingest: a short finding, an unread record set or a +refused configuration files the item unlinked and says why. + ## What this surface does not claim It never runs a reading. It produces the input a reading would be given; diff --git a/.abcd/development/brief/04-surfaces/30-inbox.md b/.abcd/development/brief/04-surfaces/30-inbox.md index 32468c0e6..58f2816f6 100644 --- a/.abcd/development/brief/04-surfaces/30-inbox.md +++ b/.abcd/development/brief/04-surfaces/30-inbox.md @@ -68,6 +68,14 @@ test holds the constant to the checkout the tests run in. A shallow clone, whose first commit is not the root, is refused rather than guessed at; a fork shares the root commit and promotes. +The capture runs capture's filing-time match (itd-2609212137116617) on the +report's own title and prose, and writes a `duplicates:` or `refines:` link +naming each likely double, exactly as a capture filed by hand does. The +provenance lines every promoted report carries (the sender's key, the inbox, +the kind, the version, the surface, the evidence line) are not compared: two +unrelated reports would otherwise match on them alone. The match never refuses +the promotion, and the output lists what it found. + The capture carries: - the report's severity and category, and `source: managed-repo`; diff --git a/.abcd/development/brief/04-surfaces/34-build.md b/.abcd/development/brief/04-surfaces/34-build.md index 952a3c845..482e8c5ff 100644 --- a/.abcd/development/brief/04-surfaces/34-build.md +++ b/.abcd/development/brief/04-surfaces/34-build.md @@ -62,15 +62,20 @@ No run is created until every check passes, and each is a read (criteria 1 and 2 ask what the record meant. - **hold** — the record carries no `held:`, well formed or not (iss-2609200830076665). -- **blocked** — nothing the record names in `blocked_by` is unshipped: an - intent outside `shipped/`, or one this checkout's store does not hold, blocks - it (itd-2609211116005482). A blocker in `superseded/` is followed along its - `superseded_by` to the intent that replaced it, transitively, and the record - waits on that replacement: it blocks exactly when the last intent of the - chain has not shipped (ruling BZ2 of 2026-09-29). A chain that loops, names a - record this checkout does not hold, stops at a superseded record naming no - successor, or ends at a decision (`adr-N`) rather than an intent blocks, and - the reason names the chain. +- **blocked** — nothing the record names in `blocked_by` is unsettled: an + intent in `shipped/` or `disciplines/` is settled (ruling CF2 of 2026-09-30: + a discipline is a standing rule, not work that ships), and an intent anywhere + else, or one this checkout's store does not hold, blocks it + (itd-2609211116005482). A blocker in `superseded/` is followed along its + `superseded_by` to the record that replaced it, transitively, and the record + waits on that replacement: it blocks exactly when the last record of the + chain is unsettled (ruling BZ2 of 2026-09-29). A chain ending at a decision + (`adr-N`) is settled when that ADR's status is `accepted` (ruling CF1 of + 2026-09-30), read through the record-id resolver both ADR id vintages route + by; a decision in any other status, or one this checkout does not hold, + blocks. A chain that loops, names an intent this checkout does not hold, or + stops at a superseded record naming no successor blocks too. A refusal names + the chain; a settled chain is named in the passing row. - **steps** — the open spec's `## Steps`, read through the spec store's own reader, parses and leaves at least one step unlanded. A spec listing no steps is one step, the whole spec. diff --git a/.abcd/development/brief/04-surfaces/35-drain.md b/.abcd/development/brief/04-surfaces/35-drain.md index 695596df9..31104ab73 100644 --- a/.abcd/development/brief/04-surfaces/35-drain.md +++ b/.abcd/development/brief/04-surfaces/35-drain.md @@ -24,20 +24,69 @@ issue, is not built, so the bare verb refuses to start and says so. ## The rule -The rule is a recorded decision, +Which issues a machine may take alone is the drained repository's own decision +(the product thinker's ruling BX2 of 2026-09-29: "the PROJECT MUST HOLD the +eligibility decision in its own record (e.g. added at setup); drain refuses there +until it does"). The rule is read from an accepted decision record in the +repository's own store, `.abcd/development/decisions/adrs/`, whose frontmatter +carries four fields: + +```yaml +drain_categories: [tech-debt, documentation, inconsistency, drift, bug, ux] +drain_severities: [nitpick, minor] +drain_security: handback +drain_remedy: required +``` + +Those values are abcd's strict baseline, bundled in the binary as the measure a +repository's record is judged against. abcd's own repository states exactly the +baseline in [adr-2609291342092738](../../decisions/adrs/2609291342092738-a-drain-takes-an-issue-alone-only-when-its-fields-say-it.md), -and invariant 19 in -[`02-constraints/03-invariants.md`](../02-constraints/03-invariants.md). It reads -the record's fields and nothing else: nothing open in `blocked_by`, a category -in the fixable set, severity `nitpick` or `minor`, and a `remedy:` other than -`none (filed automatically)`, the value an automatic filer writes when it has -no fix. Every open -issue receives exactly one disposition, from the first rule that excludes it: -skipped when blocked, handed back for `security`, for a category outside the -fixable set or for a severity above `minor`, ineligible without a remedy or -with the automatic filers' value until a person writes one, and -unreadable when the ledger reader refuses the record. A record written before -the field existed reads its `suggested_fix:` as its remedy. +held there by invariant 19 in +[`02-constraints/03-invariants.md`](../02-constraints/03-invariants.md) and a test. +The setup verb offers the baseline to a repository without a record and +writes it only on the person's yes (see [`01-ahoy.md`](01-ahoy.md)). + +A repository's record may narrow the fixable set and the severities. It may +loosen abcd's floors (ruling H11 of 2026-09-29: "MAY LOOSEN abcd's floors (a +project may let drain take major/critical and security issues)"): listing +`major` or `critical` in `drain_severities`, or setting `drain_security: take`. +It may not widen `drain_categories` past the fixable set, since the other +categories are decisions by kind, and `drain_remedy` has the one value +`required`, since the remedy is the brief a lane works from. A record that +misses a field, misspells one, states one twice, lists a value the field does +not take, or writes a list as anything but an inline `[a, b]` refuses; so does a +store with two accepted records carrying the fields. None of these falls back +to the baseline or to a looser rule. + +The rule reads each issue's fields and nothing else: nothing open in +`blocked_by`, a category the rule takes, a severity it takes, and a `remedy:` +other than `none (filed automatically)`, the value an automatic filer writes +when it has no fix. Every open issue receives exactly one disposition, from the +first rule that excludes it: skipped when blocked; handed back for `security` +(unless the record takes it), for a category outside the rule's set, for a +severity outside it, for a remedy that waits on a ruling, and for a deferral +that is live; ineligible without a remedy or with the automatic filers' value until +a person writes one; and unreadable when the ledger reader refuses the record. A +record written before the field existed reads its `suggested_fix:` as its remedy. + +Two hand-backs hold whatever the repository's record says, because each marks a +decision a person still owes. A remedy that opens "Waits on" as words, followed +by a blank, a colon or nothing (the shape a remedy takes when its fix waits on an +unanswered ruling, compared case-folded), is +handed back under `waits-on-ruling`: taking it would make the ruling. A record +whose `deferred_after` names the checkout's current anchor tag, the newest +release tag, is handed back under `deferred`: a person carried it past this +release. A deferral past an earlier tag has lapsed and holds nothing back. A +record carrying both is named for the ruling, which says which decision is +owed. +The deferral verb writes a deferral only onto a `major` or `critical` record, but +a hand-written one on a lighter record is read the same way. The release tags are +read only when an open record carries a deferral, and not knowing whether a +deferral is live never lets its record through: a failure to read the tags +refuses the dry run, and a checkout holding no release tag (a shallow clone +fetches none) marks the anchor unknown and hands back every record carrying a +deferral, the dry run naming the missing tags and `git fetch --tags`. The fields are the rule because a model's judgement of its own ambiguity is unreliable, and the failure runs one way: a machine that decides a thing needs @@ -47,20 +96,63 @@ and the dry run says so beside every eligible issue. ## The order -Eligible issues are taken by category, `tech-debt`, `documentation`, -`inconsistency`, `drift`, `bug`, `ux` (clean-ups and text first, on the evidence -that they merge most often), then `nitpick` before `minor`, then oldest first. -The dry run states the rule and lists the eligible issues first in that order, -then every other open issue by id. +Eligible issues are taken by category in the order `tech-debt`, +`documentation`, `inconsistency`, `drift`, `bug`, `ux` (clean-ups and text +first, on the evidence that they merge most often), then `security` when the +record takes it; then by severity, `nitpick` before `minor` before `major` +before `critical`, as far as the record takes them; then oldest first. The +record's lists are sets: the order is abcd's. The dry run states the order and +lists the eligible issues first in it, then every other open issue by id. + +## Loud loosening + +Every floor a repository's record loosens is named, measured against the +baseline, in this order: `severity major`, `severity critical`, `security`. The +dry run's text prints a `LOOSENED` block under the rule's record, or one line +saying the rule loosens none of abcd's floors; the machine-readable payload carries the list as +`loosened` beside the record's own values under `rule`; both modes also print a +warning on stderr naming each loosened floor; and the start's refusal names +them. + +## The trust boundary + +The eligibility record is a file the drained repository authors, and it decides +what an unattended agent may change there. The threat is a contributor's pull +request that loosens it, for instance adding `major` or `drain_security: take`, +so that a later drain takes issues a person would have decided. What guards it: + +- The record is committed history in the decision store, reviewed like code, and + a change to it is a change to a decision record, which a reviewer reads as a + trust change. +- A loosening is loud: the dry run, its stderr and the start name every floor + the record loosens, so a loosened rule is never applied unseen. +- abcd's own repository keeps the strict baseline, and a test fails when its + record loosens anything or stops being the record the invariant cites. +- The reader never falls back: a missing, partial, ambiguous or malformed record + refuses. A record stating any frontmatter key twice is malformed, because the + line scanner keeps the first value and a YAML reader the last, so + `status: accepted` then `status: superseded` would read as two decisions; so + is a record whose frontmatter `id` disagrees with the id its file name gives + it, which would put another record's name on its rule. +- The store is read inside the checkout and each record through the capped + trust-boundary reader, so a store that is a symlink leaving the checkout, a + record that is a symlink at all, and a record past the ledger's size cap are + refused rather than followed or read whole. +- The two person-owed hand-backs, a remedy waiting on a ruling and a live + deferral, hold whatever the record says. ## What it refuses -The bare verb refuses to start, exit 2, with nothing read or written: the lane -it would hand each issue to does not exist. The start check also refuses naming -the decision record it needs when the rule has none; the binary names the rule's -record, and a test holds that name to an accepted record here. A checkout that -cannot be resolved, or a ledger holding one id in two status folders, is refused -as every capture verb refuses it. +The dry run and the bare verb both refuse, exit 2 with nothing written, when +the repository holds no accepted record of the rule, naming how to add one +(the setup verb's offer, or the four fields on an accepted record); when a record +names the fields but is proposed or superseded, the refusal names it. They +refuse a malformed record, naming the record and the field, two accepted +records, naming both, and a store or record that cannot be read safely. With the rule, the bare verb still refuses to start: the +lane it would hand each issue to does not exist, and the refusal names the +rule's record and every floor it loosens. A checkout that cannot be resolved, +or a ledger holding one id in two status folders, is refused as every capture +verb refuses it. ## Where this sits diff --git a/.abcd/development/brief/05-internals/03-configuration.md b/.abcd/development/brief/05-internals/03-configuration.md index c5c450680..fc80a061c 100644 --- a/.abcd/development/brief/05-internals/03-configuration.md +++ b/.abcd/development/brief/05-internals/03-configuration.md @@ -128,9 +128,16 @@ rather than skipped: route the person set up on their own machine may spend their paid key (the product thinker's ruling AA(b) of 2026-09-29), so a repository's `.abcd/config.json` pointing a role or a judgement type at such a provider is - refused, naming the route, `~/.abcd/config.json` as where to set it, and the - repository's file as where to remove it, since the repository's route wins - per name over the machine's. A + skipped, with one diagnostic on stderr naming the route, `~/.abcd/config.json` + as where to set it, and the repository's file as where to remove it (the + technical facilitator's ruling CD2 of 2026-09-29). The rest of the + configuration loads, so every other route and every command that reads it + keeps working, and the machine's own route to that name, if it has one, + applies in its place. Every front door that reads the configuration says + each skipped route: `abcd ahoy connect` and `abcd ahoy credential` on + stderr, the `abcd ahoy --providers` board among its lines, and the bare + `abcd ahoy` board as the optional gap `oracle_api.route_skipped`. A route the denylist matches is refused whichever + provider it names. A provider holds a key when its block names `key`, judged from the block and never by reading the credential store. A repository's route to a provider whose block names no key (a local server) is admitted and wins over the @@ -557,8 +564,11 @@ repository restates in its own words, so a replacement there is not reported. **One bundled domain is generated.** `SHELL` is the teaching plane of the shell-hazard guard (itd-103, spc-16 "Two planes, one registry"): its rules and -recall keywords are built at start-up from the same bundled hazard registry -`abcd guard` enforces, never written in the bundled `rules.json`. Each registry +recall keywords are built from the same hazard registry `abcd guard` enforces, +never written in the bundled `rules.json`. The bundled set carries it built from +the bundled registry; every load rebuilds it, by the same generator, from the +registry the guard enforces in the repository, which is the bundled entries +merged with the repository's own `.abcd/guard.json` (ruling CK1). Each registry entry becomes one rule — whether the guard refuses or warns, the entry id, the command it matches, the plain-language why, and the safe successor — in entry-id order. The recall keywords are the command heads the registry matches (`rm`, @@ -570,12 +580,23 @@ domain with no second edit, and a test fails the build if the domain and the registry ever part. To every other contract it is an ordinary bundled domain: a user or repo layer overrides it per field, `dormant` silences it, `*SHELL` activates it, the kill switch suppresses it, and dedup and provenance treat it -like any other. Its injected block costs about 2k tokens, one rule per registry -entry, paid once per session per signature: dedup never injects it again while -its rules are unchanged. It is built from the bundled registry only: a repo's -`.abcd/guard.json` changes what the guard refuses there, and the two features -keep independent switches, so a repo that wants its own entries taught states -them in its `rules.json`. +like any other. Its injected block costs about 2k tokens for the bundled +registry, one rule per registry entry, and each entry a repository adds or +rewords in its `.abcd/guard.json` adds its own rule, about a hundred tokens at +the length of a bundled lesson; the block is paid once per session per +signature, so dedup never injects it again while its rules are unchanged, and +an edit to the guard file re-injects it once. A rule whose words are the +repository's — an entry the file adds, or a bundled entry whose tier, pattern, +why or successor it changes — carries `(repo)` after its entry id, so whose +words an agent is taught is never invisible; a fixture-only change teaches +the bundled words and is not marked. A `.abcd/guard.json` the guard refuses +(unreadable, invalid, or an uncommitted edit that weakens it) is refused here +too and never skipped in silence: `SHELL` teaches the registry the guard falls +back to, none of the refused entries, and the load names the file and the +reason on stderr, from `abcd rules` and from the hook on every prompt, while +every other domain loads as usual. The switches stay independent: the guard +file decides what is refused, and `rules.json` overrides, silences or kills the +teaching of it. ## The prompt router's output diff --git a/.abcd/development/decisions/adrs/2609221009491186-a-provider-adapter-serves-only-the-models-it-lists-under-a.md b/.abcd/development/decisions/adrs/2609221009491186-a-provider-adapter-serves-only-the-models-it-lists-under-a.md index 54d179b0e..224be3212 100644 --- a/.abcd/development/decisions/adrs/2609221009491186-a-provider-adapter-serves-only-the-models-it-lists-under-a.md +++ b/.abcd/development/decisions/adrs/2609221009491186-a-provider-adapter-serves-only-the-models-it-lists-under-a.md @@ -81,8 +81,11 @@ We will make every provider adapter default-deny by model. repository from spending it too. Only a route the person set up on their own machine may use their paid key, so a role or a judgement type the repository's configuration points at a provider whose block names a key is - refused when the configuration is read, naming `~/.abcd/config.json` as where - to set it. A repository's route to a provider that holds no key (a local + skipped when the configuration is read, with a diagnostic naming + `~/.abcd/config.json` as where to set it; the rest of the configuration + loads, and the machine's own route to that name applies in its place (the + technical facilitator's ruling CD2 of 2026-09-29 chose the skip over + refusing the whole configuration). A repository's route to a provider that holds no key (a local server) and a `--route` the person types are unaffected. This reverses the route half of itd-2609081951381895 Decision 8, which said a route "may sit in either layer"; that decision is amended in the same change. The diff --git a/.abcd/development/decisions/adrs/2609291342092738-a-drain-takes-an-issue-alone-only-when-its-fields-say-it.md b/.abcd/development/decisions/adrs/2609291342092738-a-drain-takes-an-issue-alone-only-when-its-fields-say-it.md index 052233090..3aba94dab 100644 --- a/.abcd/development/decisions/adrs/2609291342092738-a-drain-takes-an-issue-alone-only-when-its-fields-say-it.md +++ b/.abcd/development/decisions/adrs/2609291342092738-a-drain-takes-an-issue-alone-only-when-its-fields-say-it.md @@ -8,6 +8,10 @@ superseded_by: null related_intents: [itd-82, itd-2609201916151817] related_rfcs: [] related_adrs: [adr-25, adr-27] +drain_categories: [tech-debt, documentation, inconsistency, drift, bug, ux] +drain_severities: [nitpick, minor] +drain_security: handback +drain_remedy: required --- # ADR-2609291342092738: A drain takes an issue alone only when its fields say it needs no decision @@ -61,10 +65,31 @@ no decision, and route every other open issue by the rule that excluded it. 5. **The classification is re-derived every run** and recorded with each disposition in the run's summary; nothing is written onto the issue for it (itd-82 decision 8). -6. **The drain refuses to start without this record.** The binary names this - record as the rule it applies (`capture.EligibilityRecord`), and a test - holds that name to an accepted record here, cited by the brief's - invariants. +6. **The drain refuses to start without this record.** The rule is read from + the drained repository's own decision store, never from the binary: the + four `drain_` fields in this record's frontmatter state it + (`drain_categories`, `drain_severities`, `drain_security`, + `drain_remedy`), and a repository whose store holds no accepted record + carrying them is refused by the dry run and the start alike, naming how to + add one (the product thinker's ruling BX2 of 2026-09-29, verbatim: "the + PROJECT MUST HOLD the eligibility decision in its own record (e.g. added + at setup); drain refuses there until it does"). This repository states + abcd's strict baseline, which the binary bundles as the measure a + loosening is named against, and a test holds this record to it. +7. **Another project may loosen the floors, loudly.** A project's own record + may list `major` or `critical` among its severities, or set + `drain_security: take`, and every floor it loosens is named by the dry run + and at the start (ruling H11 of 2026-09-29, verbatim: "MAY LOOSEN abcd's + floors (a project may let drain take major/critical and security issues). + NOTE for the lane: make a loosened floor loud (drain --dry-run and the + drain start name every floor the project loosened), and keep abcd's own + repository at the stricter default."). It may narrow the fixable set but + never widen it, and it cannot drop the remedy. A partial or malformed + record refuses rather than falling back to either rule. +8. **A record waiting on a person is a person's.** Whatever the record says, + an issue whose remedy opens "Waits on" (a fix that waits on an unanswered + ruling) and an issue whose deferral past the current anchor tag is live are + handed back, each naming its rule. ## Alternatives Considered @@ -90,6 +115,8 @@ no decision, and route every other open issue by the rule that excluded it. - The issue-keyed lane of `itd-2609201916151817` (decision 10) reads the same rule as the check before it starts, so the two cannot disagree about which issue is eligible. -- Owed and out of this record: whether `major` may ever be let through (an - opt-in flag is an open question on itd-82), and where a hand-back flag - lives. +- The eligibility record is a file the drained repository authors, deciding + what an unattended agent may do there. A contributor's pull request that + loosens it is a trust change: it is committed history, reviewed like code, + and a loosened floor is named on every dry run and start. +- Owed and out of this record: where a hand-back flag lives. diff --git a/.abcd/development/decisions/adrs/2609300821558671-a-reading-finding-is-matched-against-the-record-when-it-is.md b/.abcd/development/decisions/adrs/2609300821558671-a-reading-finding-is-matched-against-the-record-when-it-is.md new file mode 100644 index 000000000..bf04b3974 --- /dev/null +++ b/.abcd/development/decisions/adrs/2609300821558671-a-reading-finding-is-matched-against-the-record-when-it-is.md @@ -0,0 +1,105 @@ +--- +id: adr-2609300821558671 +slug: a-reading-finding-is-matched-against-the-record-when-it-is +status: accepted +date: 2026-09-30 +supersedes: null +superseded_by: null +reverses: [itd-180] +related_intents: [itd-180, itd-2609212137116617] +related_rfcs: [] +related_adrs: [] +--- + +# ADR-2609300821558671: A reading finding is matched against the record when it is stored, reversing itd-180's warm-work-only rule + +Typed links: `reverses` [itd-180](../../intents/shipped/itd-180-a-cold-reading-s-findings-land-as-reading-records-and-the-re.md) +(its ruling that recurrence matching is warm work: run-scoped identifiers join +nothing mechanically); `builds_on` +[itd-2609212137116617](../../intents/shipped/itd-2609212137116617-a-new-capture-or-draft-is-matched-against-the-record-before.md) +(the filing-time match this extends to the reading family). + +## Context + +The filing-time match (itd-2609212137116617) compares a new issue or intent +with the record before it is written and links a likely double as +`duplicates:` or `refines:`. Ruling DQ2 of 2026-09-29 asked for the check on +every route that files a record, the reading ingest among them +(iss-2609281911024185). The inbox promote and the consistency pass took the +match without difficulty, because both file issues through `capture`. + +The reading ingest does not. It writes reading records (`rdi-N`), a separate +family with a closed field list and no link key, and shipped itd-180 ruled +that spotting a recurrence is the researcher's warm work: run-scoped +identifiers join nothing mechanically, the researcher recognises a recurrence +against the ledger, and the disposition's `recurs` citation is the recorded +form of that recognition. A link the matcher wrote at ingest would be exactly +the mechanical join itd-180 forbade, so the route went back to the person as +DQ2b. + +The person ruled on 2026-09-30, verbatim: "reading findings and repeats: ALL +THREE — (a) store a 'same as / builds on' link on the stored reading finding +(this REVERSES itd-180's ruling that spotting a recurrence is the researcher's +warm work, never a mechanical join; record it as a typed 'reverses' decision +against itd-180), (b) at storing time also SHOW the likely repeats, and (c) +check again at the promote step." + +## Decision + +We will match every reading finding against the record when it is stored, +write its likely repeats onto it, show them, and match again when an accepted +item is promoted. + +1. **The link is stored on the finding.** A reading record carries the two + typed links the filing-time match writes: `duplicates:` ("same as") and + `refines:` ("builds on"), each a list of `iss-N`, `itd-N` or `rdi-N`. The + reading schema's allow-list gains exactly those two keys. +2. **The match is capture's.** `reading ingest` runs the one canonical match + (`internal/core/record/match`) with capture's threshold, link cap, + minimum-term floor and configuration. The candidates are the open and + resolved issues, the intents, and every reading item already in the ledger, + so a finding a later reading returns again is linked to the item that first + carried it. The compared text is the finding's own words: the pattern and + the position's body, never the envelope every item of a run shares. The + items one ingest stores are never candidates for each other. +3. **The repeats are shown.** The ingest prints each stored item's match and + carries it in `--json` as `matches`, through the renderer the capture verb + uses. +4. **The promote step matches again.** `capture promote `, which mints + an intent draft, compares the item's finding with the record a capture is + compared with and writes the links onto the draft as a capture writes them. +5. **Every link is a proposal.** As on an issue, a person confirms a link by + leaving it and removes it by deleting the line. The match never refuses a + write, never proposes `reverses` or `supersedes`, and never names a + disposition state. No disposition state means "already covered"; the + researcher's `recurs` citation on a disposition stays the confirmed form of + a recurrence, and the stored link is the machine's proposal of one. + +## Alternatives Considered + +- **Report the likely repeats at ingest and write no link** (the lane's + recommendation in DQ2b). It kept itd-180 whole, but left the proposal + nowhere durable: a repeat seen once in a terminal is lost to every later + reader of the ledger. Rejected by the person. +- **Match only at the promote step.** The one point where a reading item + becomes a record the match already covers, so no schema change was needed. + It misses every finding that is never promoted, which is most of them. + Kept as (c), not as the whole answer. +- **Keep recurrence entirely warm** (itd-180 as shipped). Rejected: the person + ruled that the machine proposes a repeat when the finding is stored. +- **Store, show and re-check (chosen).** All three, as ruled. + +## Consequences + +- itd-180's ruling "recurrence matching is warm work" is reversed and its text + says so, citing this record. The assembler still never hands a reading the + ledger's links: a candidate projection carries two named fields only, and + the dispositions stay excluded. +- A reading record may carry `duplicates:` and `refines:`; the writer's + validator and the committed-tree gate accept them, and the gate resolves + each named id. +- `reading ingest` and `capture promote ` read the ledger, the intent + store and the reading store under their locks. An unreadable candidate set + files the item unlinked and says why. +- The brief's capture and reading chapters, and the capture and reading + command pages, state the match on this route. diff --git a/.abcd/development/decisions/adrs/README.md b/.abcd/development/decisions/adrs/README.md index 780d51b2e..af3447e80 100644 --- a/.abcd/development/decisions/adrs/README.md +++ b/.abcd/development/decisions/adrs/README.md @@ -187,3 +187,4 @@ The intent lint (a Go implementation) extends to verify these reciprocally. | [adr-2609292012006845](2609292012006845-now-next-and-later-list-an-intent-in-a-lane-under-now-only.md) | Now, Next and Later list an intent in a lane under Now only; phases stay retired (supersedes adr-2609212115255771) | accepted | 2026-09-29 | | [adr-2609292116133348](2609292116133348-a-dependency-bump-inside-the-bound-is-re-authored-as-the.md) | A dependency bump inside the bound is re-authored as the owner and only pushed by an App | accepted | 2026-09-29 | | [adr-2609300107513982](2609300107513982-a-provider-adapter-s-allowlist-alone-decides-which-models-it.md) | A provider adapter's allowlist alone decides which models it serves, and abcd bundles no vendor denylist (supersedes adr-2609221009491186; ruling H9 of 2026-09-29) | accepted | 2026-09-30 | +| [adr-2609300821558671](2609300821558671-a-reading-finding-is-matched-against-the-record-when-it-is.md) | A reading finding is matched against the record when it is stored, reversing itd-180's warm-work-only rule | accepted | 2026-09-30 | diff --git a/.abcd/development/intents/planned/itd-2609081951381895-abcd-ships-an-openai-compatible-api-oracle-adapter-the-first.md b/.abcd/development/intents/planned/itd-2609081951381895-abcd-ships-an-openai-compatible-api-oracle-adapter-the-first.md index 799f5e7f8..92dfacaa2 100644 --- a/.abcd/development/intents/planned/itd-2609081951381895-abcd-ships-an-openai-compatible-api-oracle-adapter-the-first.md +++ b/.abcd/development/intents/planned/itd-2609081951381895-abcd-ships-an-openai-compatible-api-oracle-adapter-the-first.md @@ -65,7 +65,7 @@ Taken in the implementing lane (autonomous run A, 2026-09-26), within the ruling 5. **The external and keychain homes are deferred to the credential store (2026-09-26).** The key is read through the interim credential source, `internal/core/credential` (`~/.abcd/credentials.json`, mode 0600, owner-only, no symlink), which reads one home. This lane builds the write for that home alone, the abcd-only one. The environment-variable-or-external-tool home and the platform keychain (the `security` command on macOS, the secret service on Linux) are built by itd-2609221017023290, the credential store, which replaces the source's backing and not its interface; until it lands, `abcd ahoy connect` refuses either home naming itd-2609221017023290, before any call and any write, and the explanation names all three homes with the keychain recommended in prose. A fourth answer, `none`, sets up a local server that takes no key. 6. **The walkthrough is a verb the person runs, with the key on stdin (2026-09-26).** `ahoy` explains the adapter as an optional gap and on `abcd ahoy --providers`, and the setup is `abcd ahoy connect `, rather than a question the install pass asks: the install prompter echoes every answer into its transcript, a host's question tool would put the key in an agent's context, a flag would leave it in the process listing and the shell history, and a terminal would echo it as it is typed. Declining is not running it, and changes nothing. 7. **Verify, then write (2026-09-26).** The verification call is made with the key in memory before anything is written, and a failed verification writes nothing, so a wrong key or an unlisted model never leaves a half-configured provider behind. -8. **A provider block sits on the machine alone (2026-09-26), and so does a route to a provider that holds a key (amended 2026-09-29).** `oracle.api.` names the address a key is sent to, so a repository's `.abcd/config.json` declaring it is refused; a checkout must never be able to aim the person's key at a server of its choosing. Denylist extensions (`oracle.denylist`) may sit in either layer. A route (`oracle.roles.`, `oracle.judgements.`) to a provider that holds a key sits on the machine alone: only a route the person set up on their own machine may spend their paid key, so a repository's route to such a provider is refused when the configuration is read, naming the route and `~/.abcd/config.json` as where to set it. A provider holds a key when its block names a `key` credential; that is judged from the block alone, never by reading the credential store, so no secret is read to decide it and a block that names a key counts as keyed before its key is stored. A repository's route to a provider whose block names no key (a local server) is admitted and wins per name, a `--route` the person types is unaffected, and a route naming a provider this machine has not configured stays on the host with a diagnostic. **Amendment, 2026-09-29:** the route half of this decision as ruled on 2026-09-26 said routes "may sit in either layer"; the product thinker's ruling AA(b) of 2026-09-29 reverses that for a provider that holds a key, and this text is the decision as it now stands (adr-2609221009491186 carries the consequence). +8. **A provider block sits on the machine alone (2026-09-26), and so does a route to a provider that holds a key (amended 2026-09-29).** `oracle.api.` names the address a key is sent to, so a repository's `.abcd/config.json` declaring it is refused; a checkout must never be able to aim the person's key at a server of its choosing. Denylist extensions (`oracle.denylist`) may sit in either layer. A route (`oracle.roles.`, `oracle.judgements.`) to a provider that holds a key sits on the machine alone: only a route the person set up on their own machine may spend their paid key, so a repository's route to such a provider is skipped when the configuration is read, with a diagnostic naming the route and `~/.abcd/config.json` as where to set it, and the rest of the configuration loads, the machine's own route to that name applying in its place (the technical facilitator's ruling CD2 of 2026-09-29 chose the skip over refusing the whole configuration; a route the denylist matches is still refused). A provider holds a key when its block names a `key` credential; that is judged from the block alone, never by reading the credential store, so no secret is read to decide it and a block that names a key counts as keyed before its key is stored. A repository's route to a provider whose block names no key (a local server) is admitted and wins per name, a `--route` the person types is unaffected, and a route naming a provider this machine has not configured stays on the host with a diagnostic. **Amendment, 2026-09-29:** the route half of this decision as ruled on 2026-09-26 said routes "may sit in either layer"; the product thinker's ruling AA(b) of 2026-09-29 reverses that for a provider that holds a key, and this text is the decision as it now stands (adr-2609221009491186 carries the consequence). 9. **A provider claims no tier (2026-09-26).** A provider is reached by a role or a judgement type pointed at `/`, or by a `--route` naming it, never by a tier alone, so `Connections.Serves` answers false for every tier. The bundled denylist is `anthropic/*`, the minimum ruled, and a reported model it matches discards the answer. **Amendment, 2026-09-30:** no denylist is bundled (ruling H9, adr-2609300107513982); a reported model an `oracle.denylist` entry the configuration writes matches discards the answer. ## Open Questions diff --git a/.abcd/development/intents/planned/itd-2609211116005482-abcd-build-next-picks-the-readiest-planned-intent-itself-wri.md b/.abcd/development/intents/planned/itd-2609211116005482-abcd-build-next-picks-the-readiest-planned-intent-itself-wri.md index 61da4b25f..90f73668b 100644 --- a/.abcd/development/intents/planned/itd-2609211116005482-abcd-build-next-picks-the-readiest-planned-intent-itself-wri.md +++ b/.abcd/development/intents/planned/itd-2609211116005482-abcd-build-next-picks-the-readiest-planned-intent-itself-wri.md @@ -70,7 +70,7 @@ Ruled by the product thinker on 2026-09-21, in the interview that filed this dra 6. **The reason is the lane's first commit** on the lane branch (ruled 2026-09-21 on the design review's finding): a record-only commit the pick step makes at the branch base before the implementer starts, so it reaches `main` with the work and stays on the branch if the lane is discarded; the receipt verifier counts the implementer's commits from after it. 8. **The ordering is declared a heuristic** with the evidence in Why This Matters; it is revised on the record, never silently. 7. **Equal weights at first**, declared as the bundled default and revisable after ten picks; age breaks ties and is not scored. -9. **A superseded blocker waits on its replacement** (amended 2026-09-29 by ruling BZ2 of 2026-09-29, DECISIONS.md entry landing with lane recRulings; the ruling supersedes the reading this record carried until then, that every blocker outside `shipped/`, a superseded one included, blocks). The blocked check follows a blocker's `superseded_by` to the intent that replaced it, transitively, and the intent is a candidate only when the last intent of that chain has shipped. A chain that loops, ends at a record this checkout does not hold or at a decision (`adr-N`), or stops at a superseded record naming no successor blocks, and the refusal names the chain. +9. **A superseded blocker waits on its replacement** (amended 2026-09-29 by ruling BZ2 of 2026-09-29, DECISIONS.md entry landing with lane recRulings; the ruling supersedes the reading this record carried until then, that every blocker outside `shipped/`, a superseded one included, blocks). The blocked check follows a blocker's `superseded_by` to the intent that replaced it, transitively, and the intent is a candidate only when the last intent of that chain has shipped. A chain that loops, ends at a record this checkout does not hold or at a decision (`adr-N`), or stops at a superseded record naming no successor blocks, and the refusal names the chain. **Amendment, 2026-09-30:** the product thinker's rulings CF1 and CF2 of 2026-09-30 (DECISIONS.md, the entry recording the eighteen rulings of that day) settle two of those ends, as lane cfSettled built them in `followBlocker` (`internal/core/intent/startcheck.go`): a chain ending at a decision (`adr-N`) is settled when that ADR's status is `accepted` (CF1), and refuses naming the decision when it carries another status or no status, or when this checkout's decision store does not hold it; and a blocker, or the last intent of its chain, that sits in `disciplines/` is settled as a standing rule (CF2), as a shipped one is. A chain that loops, ends at an intent this checkout does not hold, or stops at a superseded record naming no successor still blocks, and a settled chain names the record that settled it. ## Open Questions diff --git a/.abcd/development/intents/shipped/itd-103-abcd-teaches-repo-agents-the-shell-commands-they-must-never.md b/.abcd/development/intents/shipped/itd-103-abcd-teaches-repo-agents-the-shell-commands-they-must-never.md index 0684839d9..6d6611fbe 100644 --- a/.abcd/development/intents/shipped/itd-103-abcd-teaches-repo-agents-the-shell-commands-they-must-never.md +++ b/.abcd/development/intents/shipped/itd-103-abcd-teaches-repo-agents-the-shell-commands-they-must-never.md @@ -93,3 +93,5 @@ Gap audit: evidence: internal/core/rules/rules.go:401 — "no guard/safety/hazard domain is registered — the only `guard` here is the stemming short-token guard" Changed on 2026-09-29 by the product thinker's ruling J10 of that day (the DECISIONS.md entry landing with this change), recorded as iss-151: the teaching plane the headline promises, missing from the delivery reviewed above, is built. The rules loader bundles a `SHELL` domain generated from the same bundled hazard registry the guard reads, one rule per entry (the command, whether the guard refuses or warns, the why and the safe successor) recalled by the registry's command heads, so a prompt about shell-heavy work is taught the safe form before a command runs, on a host without hooks as on one with them. The criteria above stand as shipped; none of them concerned the teaching plane, and no ADR's text changes. + +Changed on 2026-09-30 by the product thinker's ruling CK1 of 2026-09-29 (the DECISIONS.md entry landing with this change), recorded as iss-2609300756163382: the `SHELL` domain teaches the registry the guard enforces in the repository, not the bundled registry alone. An entry a repository adds in its `.abcd/guard.json` is taught by the same generator as the bundled ones, its rule marked `(repo)` after its entry id, and a guard file the guard refuses is named on every load and never taught. The criteria above stand as shipped, and no ADR's text changes. diff --git a/.abcd/development/intents/shipped/itd-111-a-stale-abcd-never-answers-silently-every-surface-that-runs.md b/.abcd/development/intents/shipped/itd-111-a-stale-abcd-never-answers-silently-every-surface-that-runs.md index 22fdb2fb4..bce16d05a 100644 --- a/.abcd/development/intents/shipped/itd-111-a-stale-abcd-never-answers-silently-every-surface-that-runs.md +++ b/.abcd/development/intents/shipped/itd-111-a-stale-abcd-never-answers-silently-every-surface-that-runs.md @@ -149,6 +149,14 @@ None stated. - Given provisioning has fetched a new pinned binary (itd-105/108's job), when the next session starts, then the session reports the version transition performed. + _Amended 2026-09-30 by the product thinker's rulings CJ1 and CJ1b of + 2026-09-29 (recorded in `.abcd/work/DECISIONS.md` under 2026-09-30): the + transition is reported once, by the process that performed the swap, when + it completes: the bootstrap's success notice and `abcd update`'s receipt + open with `abcd updated from X to Y`, and the session start shows it only + for a swap made by a hook that discards its output, once. The per-repo + setup_version comparison that first met this criterion is removed + (iss-2609291942520919)._ - Given a binary whose vintage cannot be determined (unstamped build, dirty `vcs.modified` rebuild), when staleness is evaluated, then the state is reported as unknown — never as fresh — and `abcd ahoy install` run through diff --git a/.abcd/development/intents/shipped/itd-180-a-cold-reading-s-findings-land-as-reading-records-and-the-re.md b/.abcd/development/intents/shipped/itd-180-a-cold-reading-s-findings-land-as-reading-records-and-the-re.md index 4b290d2e6..257620003 100644 --- a/.abcd/development/intents/shipped/itd-180-a-cold-reading-s-findings-land-as-reading-records-and-the-re.md +++ b/.abcd/development/intents/shipped/itd-180-a-cold-reading-s-findings-land-as-reading-records-and-the-re.md @@ -64,14 +64,22 @@ register and the iss-2608220750029991 triage-route seed. (exit condition required; availability at the widening position still open). The grounds field is `disposition_grounds`, required on every state except `held`; what it must contain varies by state, enforced by - lint rather than by four fields. Free text, not enumerations. Nothing - meaning "already covered" exists in any position; an undispositioned - item is reported as outstanding, not named as a state. The disposition + lint rather than by four fields. Free text, not enumerations. No + disposition state means "already covered" in any position; an + undispositioned item is reported as outstanding, not named as a state. + The likely repeat the filing-time match writes onto a stored item + (`duplicates:` / `refines:`, below) is a proposal on the item, never a + state of its answer. The disposition record reads the envelope's position to validate its own state — a coupling the schema carries and the lint checks — and the admitted-against-declined count at the widening position is the ownership evidence, queryable without reading prose. -- The recurrence link, on the warm side: a disposition may cite prior +- The recurrence link, in two halves ([adr-2609300821558671](../../decisions/adrs/2609300821558671-a-reading-finding-is-matched-against-the-record-when-it-is.md), + ruling DQ2b of 2026-09-30). The mechanical half: `reading ingest` + matches each stored finding against the issues, the intents and every + earlier reading item with the filing-time match, writes a likely repeat + onto the item as `duplicates:` or `refines:`, and shows it; the promote + step matches the minted draft again. The warm half: a disposition may cite prior item identifiers (`recurs`), and a re-acceptance or re-rejection made against evidence of persistence carries that citation — the stronger record recurrence-is-signal describes, and the answer to the @@ -124,11 +132,14 @@ register and the iss-2608220750029991 triage-route seed. surprise entry and the disposition are different acts and must be distinguishable records; reusing the issue states collapses them and misdescribes all three. -- **Recurrence matching is warm work (per the closing-run ruling):** run-scoped identifiers - join nothing mechanically; the researcher recognises a recurrence - against the ledger, and the recognition is itself a disposition - judgement — the `recurs` citation in scope is that recognition's - recorded form. +- **Recurrence matching is mechanical and confirmed by the researcher + (ruling DQ2b, 2026-09-30, + [adr-2609300821558671](../../decisions/adrs/2609300821558671-a-reading-finding-is-matched-against-the-record-when-it-is.md), + which reverses the closing-run ruling that it is warm work only):** the + filing-time match links a stored finding to its likely repeats, among + them the earlier reading items, and the link is a proposal a person + keeps or deletes; the researcher's recognition of a recurrence is still + a disposition judgement, and the `recurs` citation is its recorded form. - **Where an accepted item goes (per the acceptance-routing ruling):** acceptance is one record; the action is a separate admission and build, joined by the item identifier (forward on `promoted_to` (historical), back in `origin` with diff --git a/.abcd/development/intents/planned/itd-2609212103572513-an-intent-names-the-release-it-must-land-by-and-the-cut-says.md b/.abcd/development/intents/shipped/itd-2609212103572513-an-intent-names-the-release-it-must-land-by-and-the-cut-says.md similarity index 77% rename from .abcd/development/intents/planned/itd-2609212103572513-an-intent-names-the-release-it-must-land-by-and-the-cut-says.md rename to .abcd/development/intents/shipped/itd-2609212103572513-an-intent-names-the-release-it-must-land-by-and-the-cut-says.md index 5b997c5e8..4ea923160 100644 --- a/.abcd/development/intents/planned/itd-2609212103572513-an-intent-names-the-release-it-must-land-by-and-the-cut-says.md +++ b/.abcd/development/intents/shipped/itd-2609212103572513-an-intent-names-the-release-it-must-land-by-and-the-cut-says.md @@ -38,7 +38,7 @@ None stated. - **The field**: `target_release: vX.Y.Z` (or `next`) on a planned intent, validated as a version, written by `intent plan --target` or `intent target `; the record lint refuses it on a shipped or superseded intent. - **The report**: `launch --dry-run` and the cut list every targeted intent not yet shipped, in text, in the receipt and in `--json`; the cut proceeds. -- **The move**: at the cut, each unshipped target is rewritten to the next version in the change that rolls the changelog, and the changelog names the move. +- **The move**: at the cut, each unshipped target the cut passes becomes `next` (whatever the following release is numbered) in the change that rolls the changelog, and the changelog names the move. - **The board**: the status block marks a targeted intent with its target in Next and Later. ## What's Out of Scope @@ -54,6 +54,7 @@ Ruled by the product thinker on 2026-09-21, in the interview that filed and plan 1. Report, never refuse (adr-2609212115255771, decision 3). 2. The target moves forward at the cut, so the field never goes stale. 3. The field is optional and lives on the intent alone. +4. A target the cut passes becomes `next`, whatever the following release is numbered, never a version number: the product thinker's ruling BS1 of 2026-09-29 (the dated entry of that day in `.abcd/work/DECISIONS.md`). The version a cut derives is the one it cuts, so a number written at the cut would name a release already out, which decision 2 exists to prevent. A target the cut passes is `next` (it named this release) or a tag at or below the one cut; a tag above it is still ahead and stays. Criterion 3 is worded to the ruling. ## Open Questions @@ -63,12 +64,14 @@ _None open._ - **Given** a planned intent, **when** `intent target v0.11.0` runs, **then** the record carries `target_release: v0.11.0`, and the same on a shipped or superseded intent is refused by the verb and by the lint. - **Given** a targeted intent still planned, **when** `launch --dry-run` or the cut runs, **then** it is listed as targeted and unshipped in text, the receipt and `--json`, and the cut proceeds. -- **Given** the cut is written, **when** the changelog is rolled, **then** each unshipped target is rewritten to the next version in the same change and the changelog names the move. +- **Given** the cut is written, **when** the changelog is rolled, **then** each unshipped target the cut passes becomes `next` (whatever the following release is numbered) in the same change, and the changelog names the move. - **Given** the status block, **when** a targeted intent is listed, **then** its row shows the target. ## Audit Notes -_Empty. Populated by intent-auditor when intent moves to shipped/._ + +Fidelity review OWED (receipt rcp-47e25ab4498e). + ## Grounds diff --git a/.abcd/development/intents/shipped/itd-63-setup-wizard-explains-installs.md b/.abcd/development/intents/shipped/itd-63-setup-wizard-explains-installs.md index d8180ad1c..6d6dd581e 100644 --- a/.abcd/development/intents/shipped/itd-63-setup-wizard-explains-installs.md +++ b/.abcd/development/intents/shipped/itd-63-setup-wizard-explains-installs.md @@ -141,6 +141,8 @@ Gap audit: evidence: .abcd/development/intents/drafts/itd-62-pluggable-safety-gate.md:1 — "drafts/" +Criterion 2 read on 2026-09-30 for gh, recorded as iss-2609281911024838 under the product thinker's DQ3 ruling of 2026-09-29 (offer to install gh on an explicit yes) and ruling H10: the install half covers gh as well as gitleaks. `ahoy remote apply` and `site setup` explain a missing gh from the registry and offer the registry's step, and for gh the explicit yes is one typed at a terminal: `--yes`, a piped answer and a run with no terminal each decline, and the refusal carries the command. The read, `ahoy --remote`, writes nothing and never offers the install; it names the apply. `--install-tool` still names gitleaks alone. TestRemoteApplyOffersGhAndRunsTheStepOnYes, TestRemoteApplyGhDeclinedRunsNothingAndShowsTheStep, TestSetupOffersAMissingGh and TestTerminalToolConfirmAsksOnlyAPersonAtATerminal pin it. The criterion text above stands as shipped. + ### Linkage note (spc-83.5) Ships as one of FOUR intents sharing spec diff --git a/.abcd/development/release/surface.json b/.abcd/development/release/surface.json index e3d744e52..8bbf2cbc6 100644 --- a/.abcd/development/release/surface.json +++ b/.abcd/development/release/surface.json @@ -1103,7 +1103,7 @@ "hidden": false, "group": "agents", "block": "agents", - "sentence": "Sort the open issues by the drain's field rule, eligible first in drain order: Writes nothing; refuses to start without --dry-run, as the run is not built.", + "sentence": "Sort open issues by this repository's own drain rule, naming each loosened floor: Writes nothing; refuses without the rule's record, or without --dry-run.", "flags": [ { "name": "dry-run", diff --git a/.abcd/development/specs/open/spc-2609212138243443-an-intent-names-the-release-it-must-land-by-and-the-cut-says.md b/.abcd/development/specs/closed/spc-2609212138243443-an-intent-names-the-release-it-must-land-by-and-the-cut-says.md similarity index 85% rename from .abcd/development/specs/open/spc-2609212138243443-an-intent-names-the-release-it-must-land-by-and-the-cut-says.md rename to .abcd/development/specs/closed/spc-2609212138243443-an-intent-names-the-release-it-must-land-by-and-the-cut-says.md index 496c2e865..62c42952a 100644 --- a/.abcd/development/specs/open/spc-2609212138243443-an-intent-names-the-release-it-must-land-by-and-the-cut-says.md +++ b/.abcd/development/specs/closed/spc-2609212138243443-an-intent-names-the-release-it-must-land-by-and-the-cut-says.md @@ -15,7 +15,7 @@ The design record for itd-2609212103572513: the `target_release` field, its verb 1. **The field and verbs**: `intent target` and `plan --target` in `internal/core/intent`, version-validated through the release package's parser; lint row in `record_schema` (criterion 1). 2. **The report**: `launch.DryRun` and `launch.Ship` read planned intents with a target and list those not in `shipped/` (criterion 2). -3. **The move**: the ship path rewrites each listed target to the derived next version in the receipts commit and appends a changelog line (criterion 3). +3. **The move**: the ship path rewrites each listed target the cut passes to `next` (ruling BS1 of 2026-09-29, the intent's decision 4) in the write that rolls the changelog, and the dated section names the move in one line (criterion 3). 4. **The board**: the status block reads the field (criterion 4). ## Out of scope diff --git a/.abcd/work/DECISIONS.md b/.abcd/work/DECISIONS.md index a8a388dc7..a0c345074 100644 --- a/.abcd/work/DECISIONS.md +++ b/.abcd/work/DECISIONS.md @@ -2603,3 +2603,10 @@ together (the script's header says why there is no escape hatch). - 2026-09-30 — BU1 and BT1 are applied under H6, the person's approval of v0.12.0 as a breaking release in the human-step interview ("next release: APPROVED as BREAKING v0.12.0 (lane-stage rename per BU1 + old command spellings removed per BT1), with a migration note in the release notes", 2026-09-29), by autonomous run A's lane breakingStages. The implement loop's lane stages are `stage`: the run state file's lanes and record lines, the `implement step` and `implement receipt` results (`performed` became `performed_stage`, `step` became `stage`), every loop refusal (`refusal.stage`) and the status board's lane; the spec's `spec_step`, `step_title` and `pending` keep "step", and the verb keeps its name, `implement step` performing one stage. The state file is schema version 4, and a file of versions 1 to 3 is migrated on read, never rewritten by the read: each `step` is carried over to `stage` in memory and the run's next mutation writes version 4, as the loop already did for versions 1 and 2, while such a file that already says `stage` is refused. Migration was chosen over refusing the old file so a run in progress survives the upgrade (the lane's technical ruling). The one-release stubs itd-2609212130136102 left are removed in the same cut: `ahoy dry-run`, `ahoy identity-check`, `version` (with `--check`), `docs lint` and `site check` are unknown commands, and bare `identity` and bare `ahoy remote` list their sub-verbs; the CLI's moved-spelling machinery goes with them while the release snapshot keeps its `moved_to` field, which the snapshots the guardrail diffs against still carry. The upgrade guide `docs/how-to/upgrade-to-v0.12.0.md` names every renamed field and removed spelling with its replacement. BU1 ruled on the build loop only, so the shared run's `implement check` vocabulary, which also says "step", is left as it is and captured as iss-2609292359485570 for a ruling. Resolves iss-2609291313276243 and iss-2609251324599468; criterion 5 of itd-2609212103565953 is met, and its spec stays open for criterion 2. - 2026-09-30 — The technical facilitator's ruling H9 of 2026-09-29 is applied (recorded by lane denyRetire of autonomous run A; the answer is kept verbatim in the local-tier file `.abcd/.work.local/scratch/reports/rulings-answered-2609-29-b.md`, dated 2026-09-29, and restated here because that file is not committed): the bundled `anthropic/*` vendor denylist is retired and a provider's allowlist alone decides which models it serves. adr-2609300107513982 supersedes adr-2609221009491186, revising its decision 2 and carrying decisions 1, 3, 4 and 5 forward word for word; itd-2609081951381895's criterion 3 and spc-2609221011153746 are amended to the allowlist-alone reading, with the old wording in the intent's Audit Notes (iss-2609300110451242). This answers the follow-up the 2026-09-29 entry above left to the technical facilitator under AA(a), whether the bundled entry stays as a backstop: it does not. Ruling Y (added 2026-09-26, from lane apiadapter: may a person remove a bundled entry on their own machine) closes as moot, since no bundled entry remains to remove. The detail H9 left to the lane, whether `oracle.denylist` survives as an optional repository or machine extension, is decided as keep: the setting already existed in both layers, and removing the bundled list leaves it working with no added code, which is the ruling's condition. - 2026-09-30 — The person's ruling CM1 of 2026-09-29 ("'step' in implement check: RENAME it to 'stage' too, in the same breaking v0.12.0"; kept verbatim in the local-tier file `.abcd/.work.local/scratch/reports/rulings-answered-2609-29-b.md` and restated here because that file is not committed) is applied by autonomous run A's integration lane integ24b1, answering iss-2609292359485570: `implement check` calls what a session asks about a stage, so its verdict's JSON field and its refusal line's field are `stage` (were `step`), its text says `may take the stage`, an operand outside lane, release, review, audit and land is refused as an unknown stage, and its help, sentence, command page and brief chapter say stage. The operands keep their spellings. The upgrade guide `docs/how-to/upgrade-to-v0.12.0.md` lists both renamed fields. The 'point' spelling the capture's remedy offered was not chosen: CM1 names 'stage'. +- 2026-09-30 — Ruling CK1 of the product thinker (2026-09-29), applied (lane teachRepoGuard, autonomous run A; Refs: iss-2609300756163382): the `SHELL` domain teaches a repository's own guard entries, generated the same way as the bundled registry (the ruling, verbatim: "YES, generated the same way as the bundled registry"). This replaces the consequence the entry for ruling J10 above recorded, that the domain is built from the bundled registry only. Every rules load rebuilds `SHELL`, before any `rules.json` layer lands on it, from the registry `abcd guard` enforces in the repository (`guard.LoadRepo`: the bundled entries merged with `.abcd/guard.json`), through the same generator. Taken by the lane rather than the ruling: a lesson whose words are the repository's (an entry the file adds, or a bundled entry whose tier, pattern, why or successor it changes) carries `(repo)` after its entry id, so provenance stays visible while the domain itself stays bundled to every other contract; a guard file the guard refuses is refused here too, `SHELL` teaching the registry the guard falls back to and a load note naming the file and the reason on stderr from `abcd rules` and the hook, rather than failing the whole rule set (which would silence `PII` and `COMMITTING` over one broken guard file); the guard file's `disabled` switch does not silence the teaching, which keeps its own switches in `rules.json` (spc-16, "Config home"). +- 2026-09-30 — The person answered eighteen rulings of autonomous run A on 2026-09-30 (CF1, CF2, CG1, CI1, CI2, CJ1, CK1, CL1, CM1, DR1, DR2, DR4, DR5, DR6, DQ1a, DQ1b, DQ2, DQ3), relayed by the run's interviewer abcd-23 [b1e81b] and recorded by lane recRulingsDR for orchestrator abcd-8d [5bfdfb]. The answers are kept in the local-tier file `.abcd/.work.local/scratch/reports/rulings-answered-2609-29-b.md`, lines 13 to 30, and are restated here because that file is not committed. SETTLED BLOCKERS: a blocker replaced by an accepted ADR counts as settled, so the waiting intent may start (CF1); a blocker reclassified as a discipline counts as settled too (CF2). Neither is built at this entry's base: the blocked pre-start check still refuses both shapes, and iss-2609300751191426 carries the change. AS BUILT: the App token for the dependency re-authoring workflow is minted in the shell, an openssl-signed JWT exchanged with curl and revoked on exit, with no new action (CG1); when the prompt router cannot evaluate a prompt, its envelope carries the error and leaves the active-domain list out, so the client keeps what it had (CI1); a domain that returns to the active set is re-injected on every host, one that appends to a transcript included, not only on a client that snapshots (CI2); `abcd update` shows progress only when stderr is a terminal, and saved output stays clean (DR4). BUILD: the installer (`hooks/bootstrap.sh`) writes the previous release tag into binary-meta when it swaps a binary, the session hook stays read-only, and that tag replaces the setup_version comparison of the itd-111 update notice (CJ1); the SHELL domain also teaches a repository's own guard entries, generated the same way as the bundled registry's (CK1); `step` in `abcd implement check` is renamed `stage` too, in the same breaking v0.12.0 (CM1); the fix rounds a run spends before it hands a lane back are a per-run setting beside `--pace`, for example `--fix-rounds`, default 3 (DR1); the dependency-bump intent itd-2609221842494980 ships now, with the first live bump recorded as owed until the person has created the App and its secrets (DR2); only a self-contained agent is sent to a paid provider by default, so the cold-reading positions may go while a file-reading agent such as the intent auditor is refused with the reason, and a central override, a machine-level settings list the person keeps that no agent or repository can change, may name providers that take bundled-context requests for file-reading agents (DR5); a run works in parallel, building several pieces and running reviewers at once up to its agent limit, which is new build-loop work that makes the pacing intent's criterion 6 testable (DR6); an undecided fidelity audit reopens the work, sending the feature back to a lane, and never closes like a pass (DQ1a, iss-2608290820473197); the audit moves before the merge, as part of the build loop's last lane (per ruling AI), and until that loop is ready it stays after the merge and reopens on an undecided or failed verdict (DQ1b, iss-2608290822140563); the filing-time duplicate check covers every route, the promoted inbox report, the consistency pass and the reading ingest, in a lane after remedyRequired (DQ2, iss-2609281911024185); `abcd ahoy` offers to install gh on an explicit yes (DQ3, iss-2609281911024838). KEEP: a promoted inbox report's own remedy is kept but not usable: the sender's suggestion stays in the issue's text for reference, the remedy field is `none (filed automatically)`, and a drain skips the record until a person writes the fix, so the conservative default recorded above on 2026-09-30 stands and is not widened (CL1). The person added to CL1 that when a person comes to write the real fix for such an issue, the agent offers a state-of-the-art research pass first (principle `prefer-sota`), which `commands/capture.md` says under the `capture remedy` verb. The three rulings of 2026-09-29 owed to this ledger by earlier sessions of the run, the ceilings of five and of eight sub-agents and the remedies ruling, already have their entries above, so this entry adds none. +- 2026-09-30 — A reading finding is matched against the record when it is stored, and itd-180's ruling that recurrence matching is warm work is reversed (the person's ruling DQ2b of 2026-09-30, recorded as adr-2609300821558671, which carries the typed `reverses` link to itd-180; applied by lane filingReading of autonomous run A; Refs: iss-2609281911024185). DQ2b, verbatim: "reading findings and repeats: ALL THREE — (a) store a 'same as / builds on' link on the stored reading finding (this REVERSES itd-180's ruling that spotting a recurrence is the researcher's warm work, never a mechanical join; record it as a typed 'reverses' decision against itd-180), (b) at storing time also SHOW the likely repeats, and (c) check again at the promote step." As built: a reading record may carry `duplicates:` and `refines:` naming an `iss-N`, `itd-N` or `rdi-N`; `reading ingest` runs capture's one filing-time match on each finding's pattern and body (never the envelope), against the open and resolved issues, the intents and every earlier reading item, never against another item of the same ingest, writes the links and prints the match (and carries it as `matches` in `--json`); `capture promote ` matches the draft it mints on the item's finding and links it as a capture is linked. Earlier reading items are candidates on this route only, because a recurrence is a finding a later reading returns again; a capture's candidate set is unchanged. +- 2026-09-30 — Rulings CJ1 and CJ1b of the product thinker (2026-09-29), applied (lane installerMeta2, autonomous run A; Refs: iss-2609291942520919): an update of the abcd binary is announced once, by whatever swapped it, when the swap completes. CJ1, verbatim: "the INSTALLER (bootstrap.sh) writes the previous release tag into binary-meta when it swaps a binary; the session hook stays read-only. Follow-up: it REPLACES the setup_version comparison." CJ1b, verbatim: "ONCE ONLY. Preferred mechanism (the person's note): couple the notice to the install/update process itself — the installer shows 'updated from X to Y' when it concludes, so it is naturally linked to the installation and the session check need neither show it nor write anything. Only IF that is not possible (e.g. the swap happens where no one sees its output), the session check may write one small 'shown' marker as a single exception to its read-only rule." As built: `hooks/bootstrap.sh` records `previous_tag` in the `binary-meta` it writes at a cache or per-root swap and opens its success notice with `abcd updated from X to Y` (`update.UpdatedFormat`, the one wording, which `abcd update` also opens its receipt with); the session-start setup_version comparison (`ahoy.VersionTransition`) is removed, and ahoy's own `version.upgrade` gap stays. The exception applies: the bootstrap salvage in the UserPromptSubmit, PreToolUse and PreCompact hooks discards its output (`hooks/hooks.json`), so those runs pass `--unseen`, record `transition_unseen=yes`, and the next session start shows the line once and writes `cache/update-shown` in the plugin data directory, its single write. itd-111 criterion 6 is amended to match. +- 2026-09-30 — The drain reads the drained repository's own eligibility record, which may loosen abcd's floors loudly, and it hands back every record still waiting on a person (the product thinker's rulings BX2 and H11 of 2026-09-29, applied by lane drainOwnRule of autonomous run A; partial of itd-82, whose spec stays open for the host judgement, the lane, the hand-back writes and the pace). BX2, verbatim: "the PROJECT MUST HOLD the eligibility decision in its own record (e.g. added at setup); drain refuses there until it does." H11, verbatim: "MAY LOOSEN abcd's floors (a project may let drain take major/critical and security issues). NOTE for the lane: make a loosened floor loud (drain --dry-run and the drain start name every floor the project loosened), and keep abcd's own repository at the stricter default." As built: the record is the one accepted decision record in the repository's `.abcd/development/decisions/adrs/` whose frontmatter carries `drain_categories` (an inline list, a subset of the fixable set), `drain_severities` (an inline list of severities), `drain_security` (`handback` or `take`) and `drain_remedy` (`required`, its only value, since the remedy is the brief a lane works from); abcd's own adr-2609291342092738 carries the strict baseline, which the binary also bundles as the measure a loosening is named against, and a test fails if abcd's record loosens anything. A repository without such a record, with one that is only proposed or superseded, with two accepted, or with one that misses, misspells, repeats or mis-values a field, is refused by `drain --dry-run` and bare `drain` alike, exit 2 and nothing written, never falling back to the baseline or a looser rule; widening the categories past the fixable set is refused as a decision by kind, which H11 does not name. Every loosened floor (`severity major`, `severity critical`, `security`) is named in the dry run's text, on stderr in both output modes, in `--json` as `loosened`, and in the start's refusal. `ahoy install` offers the baseline as an accepted record, written through the decision store's mint only on an answered yes; `--yes` skips it and reports `drain_rule.offered` under `optional_skipped`, as the routing offers are. The gap the remedy lanes found (50 of 54 dry-run-eligible records waiting on a ruling) is closed by BOTH hand-backs, each its own rule: a remedy opening "Waits on" (compared case-folded) is handed back as `waits-on-ruling`, because taking it would make the ruling the remedy waits on; and a record whose `deferred_after` names the current anchor tag is handed back as `deferred`, because a person carried it past this release and the waiver is that person's decision for the cycle. Both hold whatever the repository's record says. `capture defer` writes a deferral only onto a `major` or `critical` record, which H11 now lets a record take, but this ledger also carries hand-written deferrals on minor records (62 of the 231 open records on this branch are handed back as `deferred`), and the rule holds them back the same way. They are asked after the category and severity hand-backs, whose fix a ruling or a lapse would not change, and the ruling before the deferral, because it names which decision is owed; a record carrying both waits on both. The release tags are read only when an open record carries a deferral, and a failure to read them refuses the plan rather than letting a live deferral through. The threat is stated in the drain brief chapter: the record is a repository-authored file deciding what an unattended agent may do, so a contributor's pull request can loosen it; what guards it is that the record is committed history reviewed like code, a loosening is loud on every run, abcd's own repository keeps the baseline under a test, and the store is read inside the checkout so a symlink leaving it is refused. +- 2026-09-30 — Correcting three points of the entry above after its review (lane fix-drainOwnRule of autonomous run A). The drain-rule offer of `ahoy install` is asked only of a person at a terminal, the itd-131 precedent the git identity question set, rather than behind a named opt-in flag: off a terminal neither its category question nor the offer is asked, so a piped answer stream keeps the order it had before the offer existed and a scripted yes never writes the record, and the run reports `drain_rule.offered` under `optional_skipped` naming the terminal as the way to be asked. The terminal gate was chosen over a `--drain-rule` flag because the record decides what an unattended agent may do, which a scripted answer is not a person's yes to, and a flag would hide the offer from the person at a terminal it is for. A checkout holding no release tag (a shallow clone fetches none) marks the anchor unknown rather than reading every deferral as lapsed: every record carrying a deferral is handed back as `deferred`, naming the missing tags and `git fetch --tags`, which keeps the rest of the dry run readable where refusing the whole plan would not. The rule's reader refuses, as malformed, a record that states any frontmatter key twice (not only a `drain_` key) and one whose frontmatter `id` disagrees with its file name, and reads each record through the capped trust-boundary reader, so a record that is a symlink or past the size cap refuses; every refusal of the rule exits 2 on the dry run as on the bare verb. +- 2026-09-30 — Correcting one name in the entry above that applies rulings CJ1 and CJ1b (lane installerMeta2, autonomous run A; Refs: iss-2609291942520919): the session start's single write is not `cache/update-shown` but `cache/update-shown-`, one claim per release, named for the new tag and written beside the cache's `binary-meta`; in the degraded per-root mode it is `.update-shown-` beside the plugin root's own `.binary-meta` (`updateShownPrefix` in `internal/core/ahoy/unseen_update.go`, as the fix round of that lane built it). The ruling and the rest of the entry stand. diff --git a/.abcd/work/issues/open/iss-2609281911024185-itd-2609212137116617-s-press-release-says-a-new-issue-is.md b/.abcd/work/issues/resolved/iss-2609281911024185-itd-2609212137116617-s-press-release-says-a-new-issue-is.md similarity index 73% rename from .abcd/work/issues/open/iss-2609281911024185-itd-2609212137116617-s-press-release-says-a-new-issue-is.md rename to .abcd/work/issues/resolved/iss-2609281911024185-itd-2609212137116617-s-press-release-says-a-new-issue-is.md index 5553ac7ec..b45362444 100644 --- a/.abcd/work/issues/open/iss-2609281911024185-itd-2609212137116617-s-press-release-says-a-new-issue-is.md +++ b/.abcd/work/issues/resolved/iss-2609281911024185-itd-2609212137116617-s-press-release-says-a-new-issue-is.md @@ -12,6 +12,10 @@ found_at: "internal/core/report/inbox.go" deferred_after: "v0.11.1" deferral_reason: "a lane of its own, or a ruling: routing inbox promote, IngestConsistency and IngestReading through the filing-time match threads the layered match config through the report package and moves the two ingests behind matchAndLink, more than an hour with tests; the alternative, narrowing itd-2609212137116617's press release to the two verbs, is the product thinker's text to change." remedy: "Route every ledger writer through the filing-time match: inbox promote passes the layered match config on CaptureRequest.Match (internal/core/report/inbox.go captureRequest), and IngestConsistency and IngestReading write through matchAndLink (internal/core/capture/workflow.go), proven by one test per writer that files a near-double and finds the duplicates or refines link written." +resolution: "The filing-time match runs on every route that files a record: inbox promote and the consistency pass (1029ff5b3), and the reading ingest plus the promote of a reading item, per ruling DQ2b (adr-2609300821558671)." +impact: additive +resolved_by: + commit: "b796ac79c" --- itd-2609212137116617's press release says a new issue is matched against the record at filing, but only the capture verb and the quoted-text intent create run the filing-time match. The ledger's other writers file without it: inbox promote builds a capture.CaptureRequest with no Match (internal/core/report/inbox.go captureRequest), and IngestConsistency and IngestReading (internal/core/capture/consistency.go, reading.go) write issue records outside matchAndLink (internal/core/capture/workflow.go). The unattended paths the intent's Grounds name as the reason for the match are exactly the ones that skip it, so a double promoted from a peer report or filed by a consistency pass is never linked at filing. Either these writers pass the layered match config through CaptureRequest.Match, or the record narrows its claim to the two verbs. @@ -19,3 +23,11 @@ itd-2609212137116617's press release says a new issue is matched against the rec ## Remedy grounds (2026-09-29) This keeps itd-2609212137116617's shipped promise instead of narrowing it, and the unattended writers are the ones the intent's Grounds name as the reason for the match. Rejected: narrowing the press release to the two verbs, which is the product thinker's text and would leave the doubles unlinked. + +## Progress (2026-09-30) + +Two of the three routes landed on branch feat/filing-duplicate-every-route: inbox promote and the consistency ingest now run the filing-time match through CaptureRequest.Match, on the report's own title and prose and on the finding's summary and explanation. The reading-ingest route waits on ruling DQ2b, so this record stays open. + +## Grounds + +- pursued: a finding filed through any of the three routes that doubles an open record carries a duplicates: or refines: link naming it; a double filed through one of them with no link would show this wrong diff --git a/.abcd/work/issues/open/iss-2609281911024838-itd-63-criterion-2-diverges-for-gh-the-explain-then-install.md b/.abcd/work/issues/resolved/iss-2609281911024838-itd-63-criterion-2-diverges-for-gh-the-explain-then-install.md similarity index 70% rename from .abcd/work/issues/open/iss-2609281911024838-itd-63-criterion-2-diverges-for-gh-the-explain-then-install.md rename to .abcd/work/issues/resolved/iss-2609281911024838-itd-63-criterion-2-diverges-for-gh-the-explain-then-install.md index 6d5bfc978..0e61ab4d7 100644 --- a/.abcd/work/issues/open/iss-2609281911024838-itd-63-criterion-2-diverges-for-gh-the-explain-then-install.md +++ b/.abcd/work/issues/resolved/iss-2609281911024838-itd-63-criterion-2-diverges-for-gh-the-explain-then-install.md @@ -12,6 +12,10 @@ found_at: "internal/core/ahoy/detect.go" deferred_after: "v0.11.1" deferral_reason: "ruling owed to the product thinker: should abcd offer to install GitHub's command-line tool itself, on an explicit yes, when ahoy remote or site setup needs it, or does itd-63 say the install half covers gitleaks only? The first is a lane: both verbs route their missing-gh refusal through tools.Install with the host-relayed yes." remedy: "Waits on the itd-63 gh ruling: if offered: ahoy remote and site setup route a missing gh through tools.Install with the host-relayed yes (gh offered only when a verb that needs it runs, by the registry's Homebrew step) and installToolNames accepts gh, proven by tests that nothing installs without the yes and that the yes runs the install; if narrowed: record the narrowing in the H10 shape, never rewriting criterion 2: this issue resolved by the lane's commit plus an Audit Notes line on itd-63 saying the install half covers gitleaks only." +resolution: "Resolved on the DQ3 ruling (offer to install gh on an explicit yes): ahoy remote apply and site setup explain a missing gh and offer the registry's Homebrew step through ahoy.OfferGH. The step runs only on a yes typed at a terminal. --yes, a piped answer and a run with no terminal decline, and the refusal carries the command. A failed or unverified install refuses before any request. ahoy --remote never installs and names the apply instead. --install-tool still names gitleaks alone. itd-63 gains the criterion-2 Audit Notes line under ruling H10." +impact: additive +resolved_by: + commit: "e9a393675" --- itd-63 criterion 2 diverges for gh: the explain-then-install mode's install half is offered for gitleaks alone. ahoy.DependencyTools is {gitleaks} (internal/core/ahoy/detect.go), so ahoy install never puts gh to the question and --install-tool gh is refused by installToolNames (internal/surface/cli/cli.go), while ahoy remote and site setup refuse a missing gh with tools.Missing (internal/core/ahoy/remote.go) and show the Homebrew step the person must then run by hand. A required tool the registry knows how to install is explained but never installed on a yes, so for gh the criterion's 'the install runs only on an explicit yes' never arises. Either a gh dependency gap joins DependencyTools (offered only when a verb that needs gh is in play), or the record states that the mode installs gitleaks only. @@ -19,3 +23,7 @@ itd-63 criterion 2 diverges for gh: the explain-then-install mode's install half ## Remedy grounds (2026-09-29) SOTA check: the gh project names Homebrew as its recommended macOS route (https://github.com/cli/cli/blob/trunk/docs/install_macos.md, read 2026-09-29), which the registry already explains; mise refuses to act on a project's config until the person trusts it (https://mise.jdx.dev/cli/trust.html, read 2026-09-29), the same consent-before-install shape as the host-relayed yes. Rejected for fit: adopting mise or devbox as the installer, a new dependency the repository has not signed off. + +## Grounds + +- pursued: a person at a terminal who meets a missing gh at ahoy remote apply or site setup is asked, and on yes gh is installed and the verb goes on. TestRemoteApplyOffersGhAndRunsTheStepOnYes and TestSetupOffersAMissingGh show it, and TestAhoyRemoteApplyNeverInstallsGhOnAScriptedYes shows a scripted yes installs nothing. It is shown wrong if a --yes or piped run ever reaches the install step, or a yes at a terminal still leaves the verb refusing with gh uninstalled. diff --git a/.abcd/work/issues/open/iss-2609290426544292-rm-rf-root-or-home-reads-a-default-expansion-by-its-variable.md b/.abcd/work/issues/resolved/iss-2609290426544292-rm-rf-root-or-home-reads-a-default-expansion-by-its-variable.md similarity index 69% rename from .abcd/work/issues/open/iss-2609290426544292-rm-rf-root-or-home-reads-a-default-expansion-by-its-variable.md rename to .abcd/work/issues/resolved/iss-2609290426544292-rm-rf-root-or-home-reads-a-default-expansion-by-its-variable.md index dfdf244c8..54bc2e555 100644 --- a/.abcd/work/issues/open/iss-2609290426544292-rm-rf-root-or-home-reads-a-default-expansion-by-its-variable.md +++ b/.abcd/work/issues/resolved/iss-2609290426544292-rm-rf-root-or-home-reads-a-default-expansion-by-its-variable.md @@ -9,8 +9,13 @@ found_during: "autonomous run 2026-09-23" origin: researcher-authored production_mode: hand-written found_at: "internal/core/guard/unknown.go" +remedy: "Make segment.spelled a set of the texts a word can print (varSite.texts, spellWritten), read a default's and an alternative's word through it (spellWord, the alternative also printing nothing), a substring's leading /, and a * replacement's string, pair every text through spellPayload, and bound depth and size so past either the word refuses, test first." deferred_after: v0.11.1 deferral_reason: "Reading a default's word needs a written spelling that holds more than one text (the variable's value or the default's word), which changes segment.spelled from one string per word to a set and the payload pairing that copies it (spellPayload); owed: that representation, then the default word, deep alternatives and a substring's root read through it, test first." +resolution: "rm-rf-root-or-home reads a word's written spelling as the set of texts it can print: a default's and an assignment's word (also after a subscript), an alternative's word or nothing, a substring's leading slash and a star replacement's string, followed 8 deep and 16 texts wide, past which the word refuses." +impact: fix +resolved_by: + commit: "62d1ab56f5e573564bc51ca37ffc67a6ae45ffe4" --- rm-rf-root-or-home reads a default expansion by its variable only: rm -rf ${DIR:-$HOME} and rm -rf ${DIR:-/} delete the home or the root when DIR is unset and allow, because a word's written spelling holds one text and the default's own word is the other value it can print. An alternative nested more than three deep (${X:+${X:+${X:+${X:+$HOME}}}}) and ${PWD:0:1}, which prints the root and warns as $PWD, are the same class. Named in 17-guard.md's residuals. @@ -22,3 +27,7 @@ Deferred past v0.11.1: Reading a default's word needs a written spelling that ho ## Evidence 2026-09-29: a default after a subscript The class includes a default the bash 3.2 of macOS reads at the first operator after a subscript's `]`. With X unset, bash 3.2 and /bin/sh print the word for `${X[0]]-$HOME}`, `${X[0]]:-$HOME}`, `${X[0]]=$HOME}`, `${X[0]]:=$HOME}` and `${X[0]]x-$HOME}` (the home), and for `${X[0]]-/}` (the root); bash 5 refuses each as a bad substitution. With X set each prints X's value. The guard reads the subscript's operator (unknown.go subscriptOperators) and spells a `-` or `=` there as the variable, as it spells `${X:-$HOME}`, so each allows. The pin `${X[0]]-$HOME}` in homeresiduals_test.go is this residual, not a claim that the form stays off the home. The same owed representation, a spelling that holds both texts, reads them. + +## Grounds + +- pursued: rm -rf ${DIR:-$HOME}, ${DIR:-/}, ${X[0]]-$HOME}, a four-deep alternative and ${PWD:0:1} block bare and in sh -c and bash -c while ${DIR:-./build} allows; a default, alternative or substring form that prints the root or home and still allows would show it wrong. diff --git a/.abcd/work/issues/open/iss-2609291731336469-the-transcript-store-refuses-every-history-verb-reads.md b/.abcd/work/issues/resolved/iss-2609291731336469-the-transcript-store-refuses-every-history-verb-reads.md similarity index 81% rename from .abcd/work/issues/open/iss-2609291731336469-the-transcript-store-refuses-every-history-verb-reads.md rename to .abcd/work/issues/resolved/iss-2609291731336469-the-transcript-store-refuses-every-history-verb-reads.md index 00a7f1ef3..b049a0897 100644 --- a/.abcd/work/issues/open/iss-2609291731336469-the-transcript-store-refuses-every-history-verb-reads.md +++ b/.abcd/work/issues/resolved/iss-2609291731336469-the-transcript-store-refuses-every-history-verb-reads.md @@ -12,6 +12,10 @@ found_at: "internal/core/history/location.go" remedy: "Waits on ruling CB1: in Resolve (internal/core/history/location.go): if (a) the refusal of every verb stands, word it per verb ('refusing to read or write transcripts in it') so history list no longer reports a write; if (b), a read skips the mode narrowing with a Note and only a write refuses. Prove the answer with a test that stubs the records leaf's owner lookup to a foreign uid and asserts list, show and capture each get the ruled outcome." deferred_after: "v0.11.1" deferral_reason: "owes ruling CB1 (rulings-owed 2026-09-25): whether a foreign-owned records leaf keeps refusing every verb, or a read skips the narrowing with a Note and only a write refuses; the choice is a trust-boundary ruling for the product thinker, not a lane's call, and no working setup is lost meanwhile (the owner's writes into a root-owned 0o700 leaf already failed)." +resolution: "Ruling CB1: every verb still refuses a records leaf another account owns; the refusal now names the owning uid (and account name where it resolves) and says the store is another account's, with no write wording on read verbs." +impact: fix +resolved_by: + commit: "3674db3a4" --- The transcript store refuses every history verb, reads included, when its records leaf is owned by another uid, and says so with write wording: Resolve's owner check (internal/core/history/location.go:223) reports 'not owned by this account; refusing to write transcripts into it' even for history list. The realistic hit is a root container over a bind-mounted checkout with the local store declared, where reads and hook capture both refuse. Precedent runs both ways: rules/root.go and fsutil.go refuse foreign-owned reads, home.go admits a root-owned home. Fix-later shape from the review: on a foreign owner, skip the narrowing with a Note on a read and refuse only a write. @@ -21,3 +25,7 @@ The transcript store refuses every history verb, reads included, when its record - Why: CB1's two shapes, each with its change; the wording fault is real under both, so (a) still carries a fix. The ruling is unanswered and none is picked. - Sources (consulted 2026-09-29): git's safe.directory 'will refuse to even parse a Git config of a repository owned by someone else', reads included, and admits another owner only through an explicit declaration or the SUDO_UID case (https://raw.githubusercontent.com/git/git/master/Documentation/config/safe.adoc). That precedent supports (a) for data a later session reads back as context, and it is the evidence the ruling should weigh against (b)'s bind-mount convenience. - Rejected: admitting a root-owned leaf silently, which would read records any process with root could have planted. + +## Grounds + +- pursued: history list, show and capture over a foreign-owned records leaf each refuse with a message naming the owner and free of write wording (TestForeignOwnedRecordsLeafRefusalNamesTheOwner); a read verb that succeeded there, or any refusal mentioning a write, would show it wrong diff --git a/.abcd/work/issues/open/iss-2609291942520919-itd-111-acceptance-criterion-6-after-provisioning-fetched-a.md b/.abcd/work/issues/resolved/iss-2609291942520919-itd-111-acceptance-criterion-6-after-provisioning-fetched-a.md similarity index 65% rename from .abcd/work/issues/open/iss-2609291942520919-itd-111-acceptance-criterion-6-after-provisioning-fetched-a.md rename to .abcd/work/issues/resolved/iss-2609291942520919-itd-111-acceptance-criterion-6-after-provisioning-fetched-a.md index f76114c54..ac2fa329a 100644 --- a/.abcd/work/issues/open/iss-2609291942520919-itd-111-acceptance-criterion-6-after-provisioning-fetched-a.md +++ b/.abcd/work/issues/resolved/iss-2609291942520919-itd-111-acceptance-criterion-6-after-provisioning-fetched-a.md @@ -8,8 +8,10 @@ source: "review-followup" found_during: "autonomous run A resumed 2026-09-25: fidelity audit itd-111" origin: researcher-authored production_mode: hand-written -deferred_after: "v0.11.1" -deferral_reason: "Honouring the plugin-cache reference spc-22 names needs a design choice the record does not settle, so nothing is built yet. Three questions are open: which process records the last-reported version (the session-start hook, which itd-111 design decision 1 says never writes, or the bootstrap that fetches the binary); where the record lives when the plugin data directory comes from the environment, which the cache attestation (GHSA-4q78-ccfv-f374) treats as untrusted; and whether it replaces or sits beside the per-repo setup_version comparison that ships today. Owed: a ruling on who writes the record and where." +resolution: "The installer records the replaced release as previous_tag in binary-meta at each swap (CJ1) and opens its success notice with 'abcd updated from X to Y' once, when the swap completes (CJ1b); abcd update opens its receipt with the same shared line. The session-start setup_version comparison is removed. The one swap nobody sees (the hook salvage runs with discarded output) is shown once at the next session start behind a single 'update-shown' marker in the data dir. itd-111 criterion 6 is amended to match." +impact: fix +resolved_by: + commit: "42f050361" --- itd-111 acceptance criterion 6 (after provisioning fetched a new pinned binary, the next session reports the version transition performed) is delivered as a per-repo comparison: ahoy.VersionTransition (internal/core/ahoy/vintage.go:220-234) compares core.Version against meta.setup_version in the repo's .abcd/config.json, written by ahoy install, and reports no transition when either side is a dev build. spc-22 stated the reference would be recorded beside the plugin-cache metadata. So a repo never set up by ahoy install, or a dogfood dev build, sees no transition report after provisioning fetched a new binary, which is the scenario the criterion describes. Either the plugin-cache record the spec named is the reference to consult, or the narrowing is recorded on the criterion. @@ -17,3 +19,7 @@ itd-111 acceptance criterion 6 (after provisioning fetched a new pinned binary, ## Deferral 2026-09-29 Deferred past v0.11.1: Honouring the plugin-cache reference spc-22 names needs a design choice the record does not settle, so nothing is built yet. Three questions are open: which process records the last-reported version (the session-start hook, which itd-111 design decision 1 says never writes, or the bootstrap that fetches the binary); where the record lives when the plugin data directory comes from the environment, which the cache attestation (GHSA-4q78-ccfv-f374) treats as untrusted; and whether it replaces or sits beside the per-repo setup_version comparison that ships today. Owed: a ruling on who writes the record and where. + +## Grounds + +- pursued: after a release swap exactly one reader-visible line names the old and new release (the bootstrap notice's first line, the update receipt's first line, or, for a discarded-output salvage, the next session start) and no later session repeats it; a second session printing the line, or a swap that records no previous_tag, would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609292320015665-rm-rf-root-or-home-reads-a-trimmed-expansion-as-its-variable.md b/.abcd/work/issues/resolved/iss-2609292320015665-rm-rf-root-or-home-reads-a-trimmed-expansion-as-its-variable.md new file mode 100644 index 000000000..bfb5e12e3 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609292320015665-rm-rf-root-or-home-reads-a-trimmed-expansion-as-its-variable.md @@ -0,0 +1,23 @@ +--- +schema_version: 1 +id: "iss-2609292320015665" +slug: "rm-rf-root-or-home-reads-a-trimmed-expansion-as-its-variable" +severity: "major" +category: "security" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/guard/unknown.go" +remedy: "In spellParameterAt, read a trim's pattern by its shape (readPattern): a suffix trim whose pattern can take any length (a *, unknown text or an extglob group) and whose first element past any run of * is a glob or unknown text ($, ${, $(, a backtick), and a prefix trim whose last such element is, spell as the variable, / and nothing; keep a trim whose anchored element is literal text, or whose pattern matches a fixed width, as the variable alone, and pin ${DIR%/}, ${f%.txt}, ${p##*/}, ${p%/*}, ${p#$HOME/} and ${X%?} as allowed, test first; verified on /bin/bash 3.2, /bin/sh and /bin/dash." +resolution: "readPattern reads a trim's pattern shape; a suffix trim whose pattern can take any length and begins (past its *s) with a glob or unknown text, and a prefix trim that ends so, spell as the variable, / and nothing, so rm -rf ${X%${X#?}} and ${X%%[!/]*} block while ${DIR%/}, ${f%.txt}, ${p##*/} and ${p%/*} stay allowed" +impact: fix +resolved_by: + commit: "d82049fb7e8be1e429a8eb91297119aa171e9901" +--- + +rm-rf-root-or-home reads a trimmed expansion as its variable alone, but a suffix trim whose pattern is unknown text or begins with a glob can leave only the leading slash of an absolute path: with X=/a/b, bash 3.2 prints / for ${X%${X#?}} and ${X%%[!/]*}, so rm -rf ${X%${X#?}} deletes the root and allows. Found while fixing iss-2609290426544292, whose written spelling now holds a set of texts; a trim can add / to that set, but reading every trim as the root would refuse the everyday ${DIR%/} and ${f%.*}, so the rule needs the pattern's shape. + +## Grounds + +- pursued: every block form in TestTrimsThatCanLeaveTheRootTheWrittenCompareReads blocks bare and through bash -c and sh -c, each verified to print / on /bin/bash 3.2, /bin/sh and /bin/dash; a trim printing / that the shape rule reads as literal text at its anchored end, other than for one particular value, would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609300009506126-rm-rf-root-or-home-reads-an-expansion-that-prints-nothing-as-its-variable.md b/.abcd/work/issues/resolved/iss-2609300009506126-rm-rf-root-or-home-reads-an-expansion-that-prints-nothing-as-its-variable.md new file mode 100644 index 000000000..05bf9b255 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609300009506126-rm-rf-root-or-home-reads-an-expansion-that-prints-nothing-as-its-variable.md @@ -0,0 +1,23 @@ +--- +schema_version: 1 +id: "iss-2609300009506126" +slug: "rm-rf-root-or-home-reads-an-expansion-that-prints-nothing-as-its-variable" +severity: "major" +category: "security" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/guard/unknown.go" +remedy: "In spellParameterAt, add the empty text to a longest trim (%% or ##) whose pattern can match a whole absolute path (it can take any length, begins with *, a glob, unknown text or a literal /, and ends with *, a glob or unknown text), to a trim whose anchored end is a glob or unknown text, to a substring, and to a trim, replacement or substring after a subscript (bash 3.2 prints nothing there for a scalar); verified on /bin/bash 3.2, /bin/sh and /bin/dash; pin ${X%%*} alone, ${p%/*} and ${X%%.*}/ as allowed, test first." +resolution: "a longest trim whose pattern can match the whole path, a substring, and a trim, replacement or substring after a subscript now also spell as nothing, so rm -rf ${X%%*}/ and ${X[0]%zzz}/ block while the expansion alone stays allowed" +impact: fix +resolved_by: + commit: "d82049fb7e8be1e429a8eb91297119aa171e9901" +--- + +rm-rf-root-or-home reads an expansion that prints nothing whatever the value is as its variable, so the text around it is never read as the whole word: with X=/a/b, bash 3.2, /bin/sh and dash print nothing for ${X%%*}, ${X##*}, ${X%%/*} and ${X:0:0}, so rm -rf ${X%%*}/ deletes the root and rm -rf $HOME/${X%%*} the home, and both allow. bash 3.2, the /bin/bash and /bin/sh of macOS, also prints nothing for a trim, a replacement or a substring after a scalar's subscript (${X[0]%zzz}/ is /). Found while fixing iss-2609292320015665, the trim that leaves only the root; an alternative's empty text was added by iss-2609290426544292, and these are its siblings. + +## Grounds + +- pursued: TestExpansionsThatPrintNothingTheWrittenCompareReads blocks each form bash 3.2 and /bin/sh print as the root or the home and allows the expansion standing alone; a structurally empty expansion whose neighbour text still reads only as the variable would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609300009581165-rm-rf-root-or-home-reads-a-replacement-that-can-take-the-whole-value-as-its-variable.md b/.abcd/work/issues/resolved/iss-2609300009581165-rm-rf-root-or-home-reads-a-replacement-that-can-take-the-whole-value-as-its-variable.md new file mode 100644 index 000000000..163d98de9 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609300009581165-rm-rf-root-or-home-reads-a-replacement-that-can-take-the-whole-value-as-its-variable.md @@ -0,0 +1,23 @@ +--- +schema_version: 1 +id: "iss-2609300009581165" +slug: "rm-rf-root-or-home-reads-a-replacement-that-can-take-the-whole-value-as-its-variable" +severity: "major" +category: "security" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/guard/unknown.go" +remedy: "In spellParameterAt, read a replacement's pattern by the same shape as a trim's: where it can take any length, begins with *, a glob, unknown text or a literal / and ends with *, a glob or unknown text, add its string's texts; where it also begins (past its leading *) with a glob or unknown text and is not anchored with /#, add / before each; keep ${X/foo/$HOME}, ${DIR/#\\~/$HOME} and ${name//[^a-z]/} allowed; verified on /bin/bash 3.2 and /bin/sh (dash has no replacement), test first." +resolution: "replacementTexts reads a replacement's pattern by the trim's shape rule: one that can match the whole path adds its string, one that can match all of it after the leading slash adds / and its string, so rm -rf ${X/?*/$HOME} and ${X/${X#?}} block while ${X/foo/$HOME} and ${DIR/#\\~/$HOME} stay allowed" +impact: fix +resolved_by: + commit: "d82049fb7e8be1e429a8eb91297119aa171e9901" +--- + +rm-rf-root-or-home reads a pattern replacement as its variable unless its pattern is only *, but a replacement whose pattern can match the whole of an absolute path prints its string in its place, and one whose pattern can match all of the path after its leading slash prints / and its string: with X=/a/b, bash 3.2 and /bin/sh print the home for ${X/\/*/$HOME}, ${X/?*/$HOME} and ${X/$X/~}, and / for ${X/${X#?}} and ${X//[!\/]*/}, and rm -rf of each allows. Found while fixing iss-2609292320015665; iss-2609290426544292 read only the *-only pattern and left the rest alone to keep ${DIR/#\~/$HOME} allowed. + +## Grounds + +- pursued: TestReplacementsThatCanTakeTheWholeValueTheWrittenCompareReads blocks each form bash 3.2 and /bin/sh print as the root or the home and keeps the everyday replacements allowed; a replacement whose whole-value pattern still reads as the variable alone would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609300057304812-the-guard-allows-rm-rf-x-x-and-x-x2f-which-bash-3-2-bin-sh.md b/.abcd/work/issues/resolved/iss-2609300057304812-the-guard-allows-rm-rf-x-x-and-x-x2f-which-bash-3-2-bin-sh.md new file mode 100644 index 000000000..d672dd847 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609300057304812-the-guard-allows-rm-rf-x-x-and-x-x2f-which-bash-3-2-bin-sh.md @@ -0,0 +1,23 @@ +--- +schema_version: 1 +id: "iss-2609300057304812" +slug: "the-guard-allows-rm-rf-x-x-and-x-x2f-which-bash-3-2-bin-sh" +severity: "major" +category: "security" +source: "impl-review" +found_during: "autonomous run 2026-09-23" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/guard/unknown.go" +remedy: "spellWord decodes an ANSI-C string with readAnsiCQuote, reads a locale string as the double-quoted string it holds, spells $! as its number or nothing, and reads any other $ that opens no expansion as the literal $ bash prints; grounds: bash 3.2, /bin/sh and bash 5.3 print / for each form under printf, and dash prints $/ (never the root)." +resolution: "spellWord decodes ANSI-C and locale strings, spells $! as its number or nothing, and reads a $ that opens nothing as text; TestQuotedDefaultWordsTheWrittenCompareReads" +impact: fix +resolved_by: + commit: "598f47756" +--- + +The guard allows rm -rf ${X:-$'/'}, ${X:-$"/"} and ${X:-$'\x2f'}, which bash 3.2, /bin/sh and bash 5.3 all print as / with X unset: spellWord reads a $ that opens no name as a word it cannot read and returns nil, so a default or alternative word written as an ANSI-C or a locale string is never read (review-guardSet MAJOR-1). The same reading misses ${X:-$!/}, which is / with no background job. + +## Grounds + +- pursued: rm -rf ${X:-$'/'}, ${X:-$"/"} and ${X:-$'\x2f'} block bare and as payloads; a default word spelled with another quoting or escape that decodes to / and still allows would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609300057311045-on-bash-5-the-guard-allows-rm-rf-x-home-and-x-home-which.md b/.abcd/work/issues/resolved/iss-2609300057311045-on-bash-5-the-guard-allows-rm-rf-x-home-and-x-home-which.md new file mode 100644 index 000000000..807526de8 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609300057311045-on-bash-5-the-guard-allows-rm-rf-x-home-and-x-home-which.md @@ -0,0 +1,23 @@ +--- +schema_version: 1 +id: "iss-2609300057311045" +slug: "on-bash-5-the-guard-allows-rm-rf-x-home-and-x-home-which" +severity: "major" +category: "security" +source: "impl-review" +found_during: "autonomous run 2026-09-23" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/guard/unknown.go" +remedy: "replacementTexts reads the pattern both ways (ending at a quoted / as bash 3.2 does, and past it as bash 5 does) and unions the texts; readPattern reads $\" outside double quotes as the double-quoted string it opens; grounds: printf under /bin/bash 3.2.57 and bash 5.3.9 on the reviewed forms." +resolution: "replacementTexts unions the bash 3.2 and bash 5 pattern boundaries, and readPattern reads $\" as a double-quoted string; TestQuotedSlashReplacementsTheWrittenCompareReads" +impact: fix +resolved_by: + commit: "598f47756" +--- + +On bash 5 the guard allows rm -rf ${X/"/"*/$HOME} and ${X//"/"*/$HOME}, which bash 5.3 prints as the home: readPattern ends a replacement pattern at a quoted /, as bash 3.2 does, while bash 5 keeps a quoted / in the pattern (review-guardSet MAJOR-3). The same reader takes $"" in a pattern for a literal $, so ${X%%$""*}/ reads as not whole and is allowed though every shell prints /. + +## Grounds + +- pursued: ${X/"/"*/$HOME} and ${X%%$""*}/ block while ${X/[/]*/$HOME} and ./${X/"/"/_} stay allowed; a bash 5 pattern boundary other than a quoted slash that still allows would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609300057318410-the-guard-allows-ifs-x-rm-rf-u-x-x-and-u-x-x-which-every.md b/.abcd/work/issues/resolved/iss-2609300057318410-the-guard-allows-ifs-x-rm-rf-u-x-x-and-u-x-x-which-every.md new file mode 100644 index 000000000..98a602157 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609300057318410-the-guard-allows-ifs-x-rm-rf-u-x-x-and-u-x-x-which-every.md @@ -0,0 +1,23 @@ +--- +schema_version: 1 +id: "iss-2609300057318410" +slug: "the-guard-allows-ifs-x-rm-rf-u-x-x-and-u-x-x-which-every" +severity: "major" +category: "security" +source: "impl-review" +found_during: "autonomous run 2026-09-23" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/guard/unknown.go" +remedy: "On a line that names IFS in any word at any payload layer, an unquoted expansion whose text the guard reads (a default or alternative word, a trim or replacement text, or HOME or PWD) reads as a capped spelling, which refuses rm alone; a variable of unknown value is not capped, so while IFS= read -r d; do rm -rf $d; done stays allowed. Capping is smaller than modelling which assignment reaches which expansion, as splitAfterIFS already rules; grounds: printf under bash 3.2, /bin/sh, dash and bash 5.3." +resolution: "a line that names IFS caps every unquoted default, alternative, trim or replacement word and an unquoted HOME or PWD; TestDefaultWordsSplitOnANamedIFSTheWrittenCompareReads" +impact: fix +resolved_by: + commit: "598f47756" +--- + +The guard allows IFS=x; rm -rf ${U:-x/x} (and ${U:+x/x}), which every shell splits on the assigned IFS into "" and /, so rm deletes the root; spellWord splits an unquoted default or alternative word on whitespace only (review-guardSet MAJOR-2). IFS=Uv; rm -rf $HOME/x splits the home into / the same way. + +## Grounds + +- pursued: IFS=x; rm -rf ${U:-x/x} and IFS=Uv; rm -rf $HOME/x block, eval layers included; an IFS set through a name the guard cannot read (declare $(echo I)FS=x) stays the documented residual, and a spelled IFS assignment that still allows would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609300057326778-the-guard-allows-rm-rf-x-and-x-which-bash-3-2-and-bin-sh.md b/.abcd/work/issues/resolved/iss-2609300057326778-the-guard-allows-rm-rf-x-and-x-which-bash-3-2-and-bin-sh.md new file mode 100644 index 000000000..264d31511 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609300057326778-the-guard-allows-rm-rf-x-and-x-which-bash-3-2-and-bin-sh.md @@ -0,0 +1,23 @@ +--- +schema_version: 1 +id: "iss-2609300057326778" +slug: "the-guard-allows-rm-rf-x-and-x-which-bash-3-2-and-bin-sh" +severity: "major" +category: "security" +source: "impl-review" +found_during: "autonomous run 2026-09-23" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/guard/unknown.go" +remedy: "spellParameterAt reads the parameter name as bash does (paramNameEnd): a name, a positional digit run, or one special byte, after an optional indirection !, and applies the operators to each; an indirection value past an operator reads as capped, since the variable it names is not in the line; grounds: printf under bash 3.2, /bin/sh, dash and bash 5.3." +resolution: "spellParameterAt reads indirect, positional and special parameters through paramNameEnd; TestIndirectAndSpecialDefaultsTheWrittenCompareReads" +impact: fix +resolved_by: + commit: "598f47756" +--- + +The guard allows rm -rf ${!X:-/} and ${!X-/}, which bash 3.2 and /bin/sh print as / with X unset: spellParameterAt returns the expansion as written whenever the body does not begin with a name byte (review-guardSet MAJOR-4). The same test drops every positional and special parameter, so ${1:-/}, ${@:-/}, ${!:-/} and ${#:+/} are allowed though every shell prints /. + +## Grounds + +- pursued: ${!X:-/}, ${1:-/}, ${@:-/}, ${!:-/} and ${#:+/} block while ${!X}, ${!X*}, ${#X} and ${1:-dist} stay allowed; a parameter spelling bash reads with operators that still allows would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609300057462186-the-guard-allows-rm-rf-home-and-rm-rf-home-which-delete-the.md b/.abcd/work/issues/resolved/iss-2609300057462186-the-guard-allows-rm-rf-home-and-rm-rf-home-which-delete-the.md new file mode 100644 index 000000000..8d2b05a83 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609300057462186-the-guard-allows-rm-rf-home-and-rm-rf-home-which-delete-the.md @@ -0,0 +1,23 @@ +--- +schema_version: 1 +id: "iss-2609300057462186" +slug: "the-guard-allows-rm-rf-home-and-rm-rf-home-which-delete-the" +severity: "major" +category: "security" +source: "impl-review" +found_during: "autonomous run 2026-09-23" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/guard/match.go" +remedy: "argValueMatches also compares a field whose leading run of / stands before $HOME, ${HOME}, $PWD or ${PWD} with that run taken out, since each of those values is an absolute path and a leading / before an absolute path names the same directory; grounds: POSIX pathname resolution reads a run of slashes as one." +resolution: "argValueMatches reads a run of / before $HOME or $PWD as that directory; TestSeparatorsBeforeTheHomeTheWrittenCompareReads" +impact: fix +resolved_by: + commit: "598f47756" +--- + +The guard allows rm -rf /$HOME and rm -rf /${HOME}, which delete the home: the arg_values compare cleans a run of separators inside a path (//*, $HOME//) but not one written before a home spelling, so /$HOME names none of the values. Found in the fix-guardSet sibling sweep; outside the four review findings. + +## Grounds + +- pursued: rm -rf /$HOME and //${HOME}/ block while /$HOME/build and /$HOMEDIR stay allowed; another prefix that names the home and still allows would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609300057467536-the-guard-allows-rm-rf-and-rm-rf-which-bash-3-2-bin-sh-dash.md b/.abcd/work/issues/resolved/iss-2609300057467536-the-guard-allows-rm-rf-and-rm-rf-which-bash-3-2-bin-sh-dash.md new file mode 100644 index 000000000..b18493555 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609300057467536-the-guard-allows-rm-rf-and-rm-rf-which-bash-3-2-bin-sh-dash.md @@ -0,0 +1,23 @@ +--- +schema_version: 1 +id: "iss-2609300057467536" +slug: "the-guard-allows-rm-rf-and-rm-rf-which-bash-3-2-bin-sh-dash" +severity: "major" +category: "security" +source: "impl-review" +found_during: "autonomous run 2026-09-23" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/guard/tokenize.go" +remedy: "Spell $! in segment.spelled as its number and as nothing (the texts $! and empty), for the arg_values compare alone, leaving the token as it is so kill $! and every other reading are unchanged; grounds: printf of $!/ under bash 3.2, /bin/sh, dash and bash 5.3 prints / in a fresh shell." +resolution: "rm -rf $!/ and its siblings ($@/, $*/, $1/, $_/, $-/, braced and quoted, and $! in a trim pattern) now read as the root: the written spelling holds the empty text beside a parameter that can print nothing, while kill $! and every other reading keep the token." +impact: fix +resolved_by: + commit: "5e3fec20c" +--- + +The guard allows rm -rf $!/ and rm -rf "$!"/, which bash 3.2, /bin/sh, dash and bash 5.3 print as / when no background job has run: the tokenizer reads $! as a number that stays the text it is (simpleParamEnd), so the word is the literal $!/ and names nothing, though $! is empty until a job runs in the background. Found in the fix-guardSet sibling sweep; outside unknown.go. + +## Grounds + +- pursued: every word a parameter that can print nothing leaves as / (or ~, $HOME) blocks as rm-rf-root-or-home, and the 1099-line corpus keeps its verdicts; a shell that prints the empty reading as anything but the text beside it, or an everyday $!/$@ idiom that now blocks, would show it wrong. diff --git a/.abcd/work/issues/resolved/iss-2609300651115290-rm-rf-root-or-home-is-bypassed-by-an-ifs-named-through-an.md b/.abcd/work/issues/resolved/iss-2609300651115290-rm-rf-root-or-home-is-bypassed-by-an-ifs-named-through-an.md new file mode 100644 index 000000000..60b5af57e --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609300651115290-rm-rf-root-or-home-is-bypassed-by-an-ifs-named-through-an.md @@ -0,0 +1,23 @@ +--- +schema_version: 1 +id: "iss-2609300651115290" +slug: "rm-rf-root-or-home-is-bypassed-by-an-ifs-named-through-an" +severity: "major" +category: "security" +source: "review-followup" +found_during: "autonomous run 2026-09-23" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/guard/payload.go" +remedy: "Count as naming IFS any declaration, read, mapfile, getopts or let word, any printf -v or wait -p target, and any assignment-shaped word whose name holds an expansion's mark, plus an arithmetic expression that names IFS or assigns through an expansion; fail closed. Grounds: bash 3.2, /bin/sh, dash and bash 5.3 split ${U:-x/x} into an empty field and / after each form." +resolution: "namesIFS counts a declaration, read, mapfile, getopts or let word, a printf -v or wait -p target, and an assignment-shaped word whose assigned name holds an expansion's mark, and the tokenizer raises segment.arithmeticAssigns for an arithmetic body that names IFS or assigns through an expansion; TestIFSNamedThroughAMarkTheWrittenCompareReads." +impact: fix +resolved_by: + commit: "3c68b005c" +--- + +rm-rf-root-or-home is bypassed by an IFS named through an expansion: with I=I, `export ${I}FS=x; rm -rf ${U:-x/x}` allows and hands rm "" and / on bash 3.2, /bin/sh, dash and bash 5.3, and so do `eval "I${F:-F}S=x"`, `declare|typeset|readonly|local ${I}FS=x`, `read -r ${I}FS`, `printf -v ${I}FS x` and `: $((IFS=1))`. namesIFS matched the literal text IFS only, and the tokenizer steps over arithmetic. + +## Grounds + +- pursued: every form the review named, and the arithmetic, let, getopts, mapfile and wait -p siblings, now blocks while IFS= read -r f and IFS=, read -ra arr with a quoted operand stay allowed; a name built some way the guard still does not read (a sourced file, a nameref set before the line) would show it wrong, and 17-guard.md names those as residuals. diff --git a/.abcd/work/issues/resolved/iss-2609300651122268-rm-rf-root-or-home-over-blocks-a-positional-slice-rm-rf-2-1.md b/.abcd/work/issues/resolved/iss-2609300651122268-rm-rf-root-or-home-over-blocks-a-positional-slice-rm-rf-2-1.md new file mode 100644 index 000000000..e8e3fc2c4 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609300651122268-rm-rf-root-or-home-over-blocks-a-positional-slice-rm-rf-2-1.md @@ -0,0 +1,23 @@ +--- +schema_version: 1 +id: "iss-2609300651122268" +slug: "rm-rf-root-or-home-over-blocks-a-positional-slice-rm-rf-2-1" +severity: "minor" +category: "bug" +source: "review-followup" +found_during: "autonomous run 2026-09-23" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/guard/unknown.go" +remedy: "Read a positional or special parameter's slice or part (${@:2}, ${*:2}, ${1:2}) as the parameters it prints, as \"$2\" is read, rather than as a variable's substring that can be the root. Grounds: bash 3.2, /bin/sh and bash 5.3 print the later arguments for \"${@:2}\"; dash has no slice." +resolution: "spellParameterAt reads a positional or special parameter's slice or part as the parameters' value and nothing, as \"$2\" is read; TestPositionalSlicesReadAsTheParameters." +impact: fix +resolved_by: + commit: "9e5fb2e3c" +--- + +rm-rf-root-or-home over-blocks a positional slice: `rm -rf "${@:2}"`, `"${@:1}"`, `${@:2}`, `"${*:2}"` and `"${1:2}"` block, though each prints what the parameters hold, as `rm -rf "$2"` (allowed) does. The substring reading, which adds the root, reached positional and special parameters. + +## Grounds + +- pursued: "${@:2}", "${@:1}", ${@:2}, "${*:2}" and "${1:2}" allow as "$2" does, and "${@:2}"/ blocks as "$@"/ does; a slice that prints the root where the parameters do not would show it wrong. diff --git a/.abcd/work/issues/resolved/iss-2609300651127327-rm-rf-root-or-home-over-blocks-an-everyday-clean-line-rm-rf.md b/.abcd/work/issues/resolved/iss-2609300651127327-rm-rf-root-or-home-over-blocks-an-everyday-clean-line-rm-rf.md new file mode 100644 index 000000000..654cb9652 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609300651127327-rm-rf-root-or-home-over-blocks-an-everyday-clean-line-rm-rf.md @@ -0,0 +1,23 @@ +--- +schema_version: 1 +id: "iss-2609300651127327" +slug: "rm-rf-root-or-home-over-blocks-an-everyday-clean-line-rm-rf" +severity: "major" +category: "bug" +source: "review-followup" +found_during: "autonomous run 2026-09-23" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/guard/unknown.go" +remedy: "Read a colon default, assignment or error message (${1:-w}, ${1:=w}, ${1:?}) without the empty text an emptyable parameter adds, and keep it under the colonless forms, where a set but empty parameter prints its empty value. Grounds: bash 3.2, /bin/sh, dash and bash 5.3 print dist/ for ${1:-dist}/ with no argument or an empty one." +resolution: "spellParameterAt reads the colon default, assignment and error message from the value without the empty text an emptyable parameter adds, and keeps it under the colonless forms; TestColonDefaultsOfAnEmptyParameterTheWrittenCompareReads." +impact: fix +resolved_by: + commit: "8b6ddeb18" +--- + +rm-rf-root-or-home over-blocks an everyday clean line: `rm -rf "${1:-build}"/*`, `rm -rf ${1:-dist}/`, `rm -rf "${1:-dist}/"*` and `rm -rf ${@:-x}/` block, and `rm -rf ./${1:-dist}` warns, though no shell prints the empty value under `:-`. The empty text an emptyable parameter can print was kept under the colon operators. + +## Grounds + +- pursued: rm -rf "${1:-build}"/*, ${1:-dist}/, "${1:-dist}/"* and ${@:-x}/ allow again and ./${1:-dist} no longer warns, while ${1:-/}, ${1-}/ and ${1-dist}/ block; a shell that prints the empty value under :- would show it wrong, and bash 3.2, /bin/sh, dash and bash 5.3 do not. diff --git a/.abcd/work/issues/resolved/iss-2609300651133651-rm-rf-root-or-home-allows-expansions-that-print-nothing.md b/.abcd/work/issues/resolved/iss-2609300651133651-rm-rf-root-or-home-allows-expansions-that-print-nothing.md new file mode 100644 index 000000000..9c7a416f3 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609300651133651-rm-rf-root-or-home-allows-expansions-that-print-nothing.md @@ -0,0 +1,23 @@ +--- +schema_version: 1 +id: "iss-2609300651133651" +slug: "rm-rf-root-or-home-allows-expansions-that-print-nothing" +severity: "major" +category: "security" +source: "review-followup" +found_during: "autonomous run 2026-09-23" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/guard/unknown.go" +remedy: "Read an empty default word as the empty text, and a subscript alone, a case change and an @ transform as the value or nothing; drop the empty text wherever ifsSplits reads a site, since it splits into no field. Grounds: bash 3.2, /bin/sh and bash 5.3 print / for ${X:-}/, ${X-}/, ${A[0]}/ and ${A[@]}/ with X and A unset, and bash 5.3 for ${X^}/ and ${X@P}/ with X empty." +resolution: "spellWord reads an empty word as the empty text, a subscript alone, a case change and an @ transform read as the value or nothing, and ifsSplits drops the empty text of every site; TestExpansionsThatCanPrintNothingTheWrittenCompareReads." +impact: fix +resolved_by: + commit: "97f1d7a31" +--- + +rm-rf-root-or-home allows expansions that print nothing beside a root: `rm -rf ${X:-}/`, `${X-}/`, `$HOME${X:-}`, `${A[0]}/`, `${A[@]}/`, and bash 5 `${X^}/` and `${X@P}/` hand rm / or the home, while `${1:-}/` blocks. spellWord read an empty word as no text, and the subscript, case and @ operators returned the value alone. + +## Grounds + +- pursued: ${X:-}/, ${X-}/, $HOME${X:-}, ${A[0]}/, ${A[@]}/, ${X^}/ and ${X@P}/ block, while "${files[@]}", "${TMPDIR:-}/abcd-x" and an IFS-named line with ${f:-} stay allowed; the documented residual $X/ (a variable's own empty value) still allows, and 17-guard.md says why. diff --git a/.abcd/work/issues/resolved/iss-2609300711394491-the-drain-rule-offer-shifts-a-piped-ahoy-install-answer.md b/.abcd/work/issues/resolved/iss-2609300711394491-the-drain-rule-offer-shifts-a-piped-ahoy-install-answer.md new file mode 100644 index 000000000..efeee2d69 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609300711394491-the-drain-rule-offer-shifts-a-piped-ahoy-install-answer.md @@ -0,0 +1,23 @@ +--- +schema_version: 1 +id: "iss-2609300711394491" +slug: "the-drain-rule-offer-shifts-a-piped-ahoy-install-answer" +severity: "major" +category: "bug" +source: "review-followup" +found_during: "autonomous run 2026-09-23" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/ahoy/drain_rule.go" +remedy: "Ask the drain-rule category and offer only when the prompter is at a terminal (the itd-131 TerminalPrompter precedent the git identity step set), neither asking nor counting it declined off one, and list drain_rule.offered under optional_skipped naming the terminal as the way to be asked; test that a piped stream of the pre-change answer count gets the pre-change questions and writes no record." +resolution: "Resolved: the drain-rule category and offer are asked only at a terminal; off one they are neither asked nor counted declined and drain_rule.offered is listed under optional_skipped. TestAPipedInstallStreamIsNotShiftedByTheDrainRuleOffer." +impact: fix +resolved_by: + commit: "6683d62c8" +--- + +The drain-rule offer shifts a piped ahoy install answer stream: the drain-rule category question and the offer itself are inserted mid-order, and the stdin prompter reads one line per question with no terminal gate, so in every adopter repository a scripted stream hands the answer meant for a later question (user-state, the identity pin) to the drain rule, can write an accepted drain eligibility record the script never asked for, and leaves the later question reading EOF. + +## Grounds + +- pursued: a piped stream of the pre-change answer count gets the same questions in the same order and writes no record; a drain-rule question put to a pipe would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609300711394709-abcd-drain-lets-a-live-deferral-through-when-the-checkout.md b/.abcd/work/issues/resolved/iss-2609300711394709-abcd-drain-lets-a-live-deferral-through-when-the-checkout.md new file mode 100644 index 000000000..86c5ce60e --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609300711394709-abcd-drain-lets-a-live-deferral-through-when-the-checkout.md @@ -0,0 +1,23 @@ +--- +schema_version: 1 +id: "iss-2609300711394709" +slug: "abcd-drain-lets-a-live-deferral-through-when-the-checkout" +severity: "major" +category: "security" +source: "review-followup" +found_during: "autonomous run 2026-09-23" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/capture/eligible.go" +remedy: "When an open record carries a deferral and no release tag is found, mark the anchor unknown and hand back every record carrying a deferral under the deferred rule, naming the missing tags and git fetch --tags, with the dry run saying the anchor is unknown; keep the refusal on a failed tag read; test on a --depth 1 --no-tags clone." +resolution: "Resolved: with no release tag in the checkout the anchor is marked unknown and every record carrying a deferral is handed back as deferred, naming git fetch --tags; TestADeferralIsHandedBackWhenTheCheckoutHoldsNoReleaseTag on a --depth 1 --no-tags clone." +impact: fix +resolved_by: + commit: "788f0a550" +--- + +abcd drain lets a live deferral through when the checkout holds no release tag: liveDeferralAnchor returns an empty anchor when no tag is found, so every deferred_after reads as lapsed and a record a person carried past this release is eligible. A shallow or tagless clone (fetch-depth 1 fetches no tags) is the unattended drain's likely checkout, and the comment above the function promised the opposite. + +## Grounds + +- pursued: a tagless clone hands back every deferred record and the dry run says the anchor is unknown; a deferred record reported eligible on a tagless clone would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609300711402515-the-drain-eligibility-reader-admits-a-candidate-record-that.md b/.abcd/work/issues/resolved/iss-2609300711402515-the-drain-eligibility-reader-admits-a-candidate-record-that.md new file mode 100644 index 000000000..910adaea3 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609300711402515-the-drain-eligibility-reader-admits-a-candidate-record-that.md @@ -0,0 +1,23 @@ +--- +schema_version: 1 +id: "iss-2609300711402515" +slug: "the-drain-eligibility-reader-admits-a-candidate-record-that" +severity: "major" +category: "security" +source: "review-followup" +found_during: "autonomous run 2026-09-23" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/drainrule/drainrule.go" +remedy: "Refuse, as malformed, any candidate record that states any top-level key twice (Duplicates already returns them) whatever its status reads as, and one whose frontmatter id disagrees with the id its file name gives it; read each record through fsutil.ReadGuardedInRoot with issueschema.RecordReadLimit so a link or an oversized record refuses; map every refusal of the rule load to exit 2 on the dry run." +resolution: "Resolved: any duplicated top-level key and a frontmatter id its file name contradicts refuse as malformed; each record is read through fsutil.ReadGuardedInRoot under the record cap; every rule refusal exits 2 on the dry run. Tests in drainrule and cli drain_surface_test." +impact: fix +resolved_by: + commit: "788f0a550" +--- + +The drain eligibility reader admits a candidate record that states a non-drain key twice: drainrule.Load narrowed frontmatter.Duplicates to drain_ keys, so status: accepted followed by status: superseded loads and applies on the line scanner's first-wins reading, while any YAML reader (last wins) calls the record superseded. The same reader read every store record uncapped (root.ReadFile) and trusted a frontmatter id that disagrees with the file name. + +## Grounds + +- pursued: status accepted then superseded, a mismatched id, a linked record and a record past 1 MiB each refuse the load; any of them yielding a rule would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609300726419415-the-colon-test-of-w-w-w-and-is-taken-on-the-parameter-count.md b/.abcd/work/issues/resolved/iss-2609300726419415-the-colon-test-of-w-w-w-and-is-taken-on-the-parameter-count.md new file mode 100644 index 000000000..d1b52bb7b --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609300726419415-the-colon-test-of-w-w-w-and-is-taken-on-the-parameter-count.md @@ -0,0 +1,23 @@ +--- +schema_version: 1 +id: "iss-2609300726419415" +slug: "the-colon-test-of-w-w-w-and-is-taken-on-the-parameter-count" +severity: "major" +category: "bug" +source: "review-followup" +found_during: "autonomous run 2026-09-23" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/guard/unknown.go" +remedy: "Apply the colon forms' set reading (no empty value) only to a single positional or special parameter; for @ and * keep the value with its empty text, so ${@:-x}/ blocks and ${1:-dist}/ stays allowed. Grounds: the bash manual, Shell Parameter Expansion, and the four shells' printf output with set -- \"\" \"\"." +resolution: "The colon forms of @ and * keep the empty value, since their colon test is on the parameter count; the set reading applies to a single positional or special parameter only." +impact: fix +resolved_by: + commit: "ff4016a84" +--- + +The colon test of ${@:-w}, ${*:-w}, ${@:=w} and ${@:?} is taken on the parameter count, not on a joined value, but the guard read them as a single empty parameter does (the empty value never printed), a regression from iss-2609300651127327. With set -- "" "" (a script called as clean.sh "" "") bash 3.2, /bin/sh, dash and bash 5.3 hand rm the home for rm -rf $HOME${@:-x} and $HOME${*:?}, and / or /* for rm -rf ${@:-x}/*, ${*:-x}/ and ${@:?}/; all were allowed. + +## Grounds + +- pursued: ${@:-x}/, ${@:-x}/*, ${*:-x}/, ${@:?}/, ${*:=x}/, $HOME${@:-x} and $HOME${*:?} block bare, in bash -c and in sh -c, while ${1:-dist}/ stays allowed; a shell that printed dist/ for ${@:-x}/ after set -- "" "" would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609300726507446-structural-closure-of-iss-2609300651115290-s-class-an-ifs.md b/.abcd/work/issues/resolved/iss-2609300726507446-structural-closure-of-iss-2609300651115290-s-class-an-ifs.md new file mode 100644 index 000000000..17ff4ba16 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609300726507446-structural-closure-of-iss-2609300651115290-s-class-an-ifs.md @@ -0,0 +1,23 @@ +--- +schema_version: 1 +id: "iss-2609300726507446" +slug: "structural-closure-of-iss-2609300651115290-s-class-an-ifs" +severity: "major" +category: "security" +source: "review-followup" +found_during: "autonomous run 2026-09-23" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/guard/payload.go" +remedy: "Read the IFS of a line as unknown (cap its unquoted default, alternative, trim and replacement words and unquoted HOME/PWD) when any layer's text holds the name IFS or a name built from an expansion, read lexically over the raw text whatever the context: an expansion beside an I, F or S byte, an expansion an assignment operator follows or ++ precedes, or an expansion standing whole as the operand of a builtin that assigns the names it is handed. Every reported form is one of these shapes whatever context holds it, so the rule is their superset; it replaces the per-context readings (markedAssignment, arithmeticNamesIFS and its three call sites). Grounds: the bash manual (Shell Arithmetic evaluates a variable's value as an expression; subscripts, $[ ], (( )), for (( )), [[ -eq ]], substring offsets and integer attributes are arithmetic) and the shells' printf output." +resolution: "A line's IFS reads as unknown when the raw text of any layer builds a name from an expansion (beside an I, F or S byte, an assignment target, or a whole assigning-builtin operand), whatever the context; the per-context readings are removed." +impact: fix +resolved_by: + commit: "84ad332c5" +--- + +Structural closure of iss-2609300651115290's class: an IFS assigned through a name built from an expansion still passed the guard in contexts the per-context reading never reached. I=I; : $[${I}FS=1], a[${I}FS=1]=x, : ${a[${I}FS=1]}, declare -i n; n=${I}FS=1, (( ${I}FS++ )), : ${X:${I}FS=1} and x=${I}FS=1; : $((x)) each set IFS in bash 3.2, /bin/sh and bash 5.3, which then hand rm "" and / for rm -rf ${U:-1/1}; all were allowed. Rounds 1 and 3 each listed the contexts that can set IFS and each re-verify found another, so the fix is one fail-closed rule, not another context. + +## Grounds + +- pursued: every re-verify form of all three rounds blocks, the testdata and fix corpus (1415 lines) changes only the probe line ${@:-x}/, and IFS= read -r f, n=$((n+1)), a[$i]=x and export PATH=$HOME/bin:$PATH stay allowed; a context that assigns a name built with I, F or S written beside an expansion and still allows would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609300751191426-rulings-cf1-and-cf2-of-2026-09-30-are-not-built-the-blocked.md b/.abcd/work/issues/resolved/iss-2609300751191426-rulings-cf1-and-cf2-of-2026-09-30-are-not-built-the-blocked.md new file mode 100644 index 000000000..5a98ff6d9 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609300751191426-rulings-cf1-and-cf2-of-2026-09-30-are-not-built-the-blocked.md @@ -0,0 +1,23 @@ +--- +schema_version: 1 +id: "iss-2609300751191426" +slug: "rulings-cf1-and-cf2-of-2026-09-30-are-not-built-the-blocked" +severity: "minor" +category: "drift" +source: "agent-finding" +found_during: "autonomous run A resumed 2026-09-25 (lane recRulingsDR, 2026-09-30)" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/intent/startcheck.go" +remedy: "In internal/core/intent/startcheck.go, end a supersession chain at an ADR successor by reading that ADR's status: status accepted settles the edge (the row passes and names the chain, the ADR marked accepted), while any other status, or an ADR this checkout does not hold, still refuses and names it; and let startBlockedRow treat a final record in disciplines/ as settled beside shipped/. Update the function comments, brief 04-surfaces/34-build.md and commands/build.md in the same change. Prove it by turning the 'adr successor' case of TestStartBlockedRowFollowsASupersededBlockerToItsReplacement into a pass for an accepted ADR and a refusal for a proposed one, plus a discipline-successor case (grounds: the rulings themselves; the fix depends on no outside practice)." +resolution: "Built rulings CF1 and CF2: the blocked pre-start check settles a supersession chain ending at an accepted ADR (read through the record-id resolver, both id vintages) and a final record in disciplines/; a decision in any other status or absent still refuses naming it. The board's next up follows through the shared StartChecksIn seam." +impact: fix +resolved_by: + commit: "b6f27a400" +--- + +Rulings CF1 and CF2 of 2026-09-30 are not built: the blocked pre-start check still refuses a blocker whose supersession chain ends at an accepted ADR, and a blocker reclassified as a discipline. The person ruled, through the run's interviewer on 2026-09-30: 'CF1 blocker replaced by an accepted ADR: COUNTS AS SETTLED (the waiting intent may start)' and 'CF2 blocker reclassified as a discipline: COUNTS AS SETTLED (the waiting intent may start)'. At base 85d0bb8eb, followBlocker in internal/core/intent/startcheck.go stops an ADR successor with the problem 'adr-N is not an intent: a decision replaced the blocker, and nothing on the record says that settles the edge', and startBlockedRow counts only a final record in shipped/ as settled, so a chain ending in disciplines/ blocks its dependants for ever. Lane supersededBlocker asked both questions rather than building them; no live edge hits either shape today (itd-72 is superseded by adr-37 and blocks nothing), so nothing is refused wrongly yet. + +## Grounds + +- pursued: we expect an intent blocked by a record superseded by an accepted ADR, or by one in disciplines/, to pass the blocked check and head the board, and one superseded by a proposed ADR to stay refused; shown wrong if TestStartBlockedRowFollowsASupersededBlockerToItsReplacement or TestTheHeadTakesAnIntentWhoseBlockerASettledRecordReplaced fails diff --git a/.abcd/work/issues/resolved/iss-2609300756163382-the-shell-rules-domain-teaches-only-the-bundled-hazard.md b/.abcd/work/issues/resolved/iss-2609300756163382-the-shell-rules-domain-teaches-only-the-bundled-hazard.md new file mode 100644 index 000000000..f9401d117 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609300756163382-the-shell-rules-domain-teaches-only-the-bundled-hazard.md @@ -0,0 +1,23 @@ +--- +schema_version: 1 +id: "iss-2609300756163382" +slug: "the-shell-rules-domain-teaches-only-the-bundled-hazard" +severity: "minor" +category: "inconsistency" +source: "user-observation" +found_during: "autonomous run A resumed 2026-09-25 (lane teachRepoGuard, ruling CK1; question raised by lane teachPlane)" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/rules/shell.go" +remedy: "Rebuild SHELL on every rules load from the registry the guard enforces in the repository (guard.LoadRepo: the bundled entries merged with .abcd/guard.json), through the same generator (Lessons/RecallTerms), marking every lesson whose words are the repository's with (repo) after its entry id so provenance is visible (GHSA-22f8-qf5r-gjgq); a guard.json the guard refuses is refused here too, loudly (a load note on stderr naming the file and reason) and never taught, while SHELL teaches the registry the guard falls back to. Grounds: ruling CK1 verbatim, and J10's single-source rule (the registry is the one source)." +resolution: "SHELL is rebuilt on every rules load from the registry the guard enforces in the repository, through the same generator; a repository's own entries are taught marked (repo), and a refused guard.json is named on stderr and never taught." +impact: additive +resolved_by: + commit: "ab7382da9" +--- + +The SHELL rules domain teaches only the bundled hazard registry: an entry a repository adds in its own .abcd/guard.json is refused by the guard but never taught before shell work, so the teaching plane and the execution plane of itd-103 part in exactly the repositories that extended the registry. Ruling CK1 (2026-09-29, the product thinker): teach a repository's own guard entries in the SHELL domain, generated the same way as the bundled registry. + +## Grounds + +- pursued: a repository's own .abcd/guard.json entry appears in abcd rules shell and the hook's SHELL block marked (repo), recalled by its command head, while an invalid one is named on stderr and absent (TestShellDomainTeachesTheRepositorysOwnGuardEntries, TestShellDomainRefusesAnInvalidRepoGuardEntryLoudly, TestRulesTeachesTheRepositorysOwnGuardEntries); a guard.json entry the guard enforces that SHELL does not teach, or an invalid one taught or dropped with no note, would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609300805090515-the-provider-configuration-s-diagnostics-oracle-apiconfig.md b/.abcd/work/issues/resolved/iss-2609300805090515-the-provider-configuration-s-diagnostics-oracle-apiconfig.md new file mode 100644 index 000000000..f90a2beef --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609300805090515-the-provider-configuration-s-diagnostics-oracle-apiconfig.md @@ -0,0 +1,23 @@ +--- +schema_version: 1 +id: "iss-2609300805090515" +slug: "the-provider-configuration-s-diagnostics-oracle-apiconfig" +severity: "minor" +category: "ux" +source: "agent-finding" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/oracle/connect.go" +remedy: "Carry the configuration read's diagnostics out of oracle.Connect on its result (a field the JSON omits, or a named diagnostics member) and print each on stderr in the ahoy connect front door, the way ahoy credential does; give detectProviderAdapter a non-required gap naming each skipped route. Grounds: the APIConfig doc comment already says the diagnostics are for a front door to print on stderr, and ruling CD2 asks for the skip to be said; a test per door with a repository route to a keyed provider shows it." +resolution: "ahoy connect and ahoy credential now say each skipped route on stderr through one shared printer, and the bare ahoy board carries the optional gap oracle_api.route_skipped naming each" +impact: fix +resolved_by: + commit: "92f084500" +--- + +The provider configuration's diagnostics (oracle.APIConfig.Diagnostics: a role outside the roster, a route to an unconfigured provider, and a repository's route to a keyed provider skipped under ruling CD2) reach the person only through 'abcd ahoy --providers' (its board) and 'abcd ahoy credential' bare (stderr). 'abcd ahoy connect' (oracle.Connect loads the configuration and drops them) and the bare 'abcd ahoy' provider-adapter gap (detectProviderAdapter) say nothing, so a skipped route is silent there. + +## Grounds + +- pursued: a repository route to a keyed provider is named on stderr by ahoy connect (text and JSON) and ahoy credential , and as a route_skipped gap on the bare board; a front door that reads the configuration and stays silent would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609300812525892-round-5-of-iss-2609300651115290-s-class-a-regression-the.md b/.abcd/work/issues/resolved/iss-2609300812525892-round-5-of-iss-2609300651115290-s-class-a-regression-the.md new file mode 100644 index 000000000..bc5cabf83 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609300812525892-round-5-of-iss-2609300651115290-s-class-a-regression-the.md @@ -0,0 +1,23 @@ +--- +schema_version: 1 +id: "iss-2609300812525892" +slug: "round-5-of-iss-2609300651115290-s-class-a-regression-the" +severity: "major" +category: "security" +source: "review-followup" +found_during: "autonomous run 2026-09-23" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/guard/payload.go" +remedy: "Replace the byte trigger with one target rule: a line where any assignment target holds an expansion ($name, ${...} with transforms, $(...), backticks) reads its IFS as unknown, capped as a literal IFS assignment is; a target is the word before =/op= (spaced or not, save a test's lone = after [, test, &&) or beside ++/--, its name part without a subscript, plus the naming builtins' operands and a nameref declaration. Grounds: printf stand-in probes on bash 3.2 and 5.3 print [][/] for each form." +resolution: "Fixed by 2d7ac0ad2: targetsAnExpansion reads whether any assignment target holds an expansion, in every position bash assigns through an operator, and namesIFS reads a nameref's declaration; the byte trigger is removed." +impact: fix +resolved_by: + commit: "2d7ac0ad2" +--- + +Round 5 of iss-2609300651115290's class, a regression the round-4 fix (84ad332c5) opened: the IFS rule keyed on the bytes written beside an expansion, but bash sets the variable an assignment target's VALUE names, so a target written as expansions alone names IFS with no I, F or S byte (reverify4-guardSet finding 1). a=I; b=FS; (( ${a}${b} = 1 )) and x=$a$b; (( $x = 1 )) set IFS to 1 on bash 3.2 and 5.3, and an unquoted default delete after them then hands rm the root; 282908797 blocked both, b993017e8 allows them. Siblings: (( ${x^^} = 1 )), (( ${x@P} = 1 )), a spaced operator in a string arithmetic reads (declare -i n; n="$y = 1"), a substring offset, ${!x:=1}, and a nameref (declare -n r=$x; r=1), which no round read. + +## Grounds + +- pursued: every pinned form (expansion-only arithmetic targets, case change and transform values, let/declare/printf -v/read/export operands, spaced string operators, substring offset, indirect default, nameref) blocks in TestAnAssignmentTargetHoldingAnExpansionTheWrittenCompareReads while n=$((n+1)), a[$i]=x, [ $a = b ] and git log --$fmt stay allowed and the 1099-line corpus verdicts are unchanged; a target written with an expansion that still sets IFS unread would show it wrong. diff --git a/.abcd/work/issues/resolved/iss-2609300939590291-once-only-breaks-under-concurrent-session-starts-ahoy.md b/.abcd/work/issues/resolved/iss-2609300939590291-once-only-breaks-under-concurrent-session-starts-ahoy.md new file mode 100644 index 000000000..6f2d22813 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609300939590291-once-only-breaks-under-concurrent-session-starts-ahoy.md @@ -0,0 +1,23 @@ +--- +schema_version: 1 +id: "iss-2609300939590291" +slug: "once-only-breaks-under-concurrent-session-starts-ahoy" +severity: "minor" +category: "security" +source: "review-followup" +found_during: "autonomous run 2026-09-23" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/ahoy/unseen_update.go" +remedy: "Claim each release with one exclusive create, os.OpenFile(cache/update-shown-, O_WRONLY|O_CREATE|O_EXCL, 0o600), after the dataDirHazard and ReleaseTagShape checks; an existing path or any create error shows nothing (fail closed). O_EXCL is atomic per POSIX open(2), so exactly one concurrent caller wins; a 16-goroutine test holds it." +resolution: "The session check claims each release's unseen update with one exclusive create (O_CREATE|O_EXCL) of cache/update-shown-, or .update-shown- in the plugin root in the per-root mode; an existing path or any create error shows nothing, so exactly one of any number of concurrent session starts shows the line." +impact: fix +resolved_by: + commit: "4fb3575b8" +--- + +Once-only breaks under concurrent session starts: ahoy.TakeUnseenUpdate read the cache/update-shown marker, then wrote it with a create-temp-and-rename, a read-then-write with no claim, so N sessions starting together in one plugin root (an autonomous run does this within a second) each saw no marker and each showed 'abcd updated from X to Y'; a 16-goroutine probe on one seeded cache showed the line 16 times, breaking the ruling CJ1b's once-only. + +## Grounds + +- pursued: 16 goroutines calling TakeUnseenUpdate on one seeded cache show the line exactly once (TestTakeUnseenUpdateShowsOnceUnderConcurrentSessions, 16 before the fix); a second show of one release from any interleaving, or a claim created through a link, would show it wrong. diff --git a/.abcd/work/issues/resolved/iss-2609301046113575-a-comment-in-the-guard-s-ifs-split-test.md b/.abcd/work/issues/resolved/iss-2609301046113575-a-comment-in-the-guard-s-ifs-split-test.md new file mode 100644 index 000000000..dd55791c4 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609301046113575-a-comment-in-the-guard-s-ifs-split-test.md @@ -0,0 +1,23 @@ +--- +schema_version: 1 +id: "iss-2609301046113575" +slug: "a-comment-in-the-guard-s-ifs-split-test" +severity: "minor" +category: "documentation" +source: "impl-review" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/guard/guardset_test.go" +remedy: "Say what the example needs without the path: a macOS home under the Users directory whose account name holds a v, so the U and the v of an IFS of Uv split the home down to a bare root; abcd lint's privacy-hygiene rule (repolint rule_privacy.go, genericHomeRe) is the check that shows it gone." +resolution: "The comment names no absolute home path; abcd lint reports 0 errors at this commit." +impact: internal +resolved_by: + commit: "718fd8243fbb60a818e02519aa64c43f323b0655" +--- + +A comment in the guard's IFS-split test (TestDefaultWordsSplitOnANamedIFSTheWrittenCompareReads, internal/core/guard/guardset_test.go) names a real macOS home directory, the developer account's own absolute path, as its example HOME, so abcd lint's privacy-hygiene rule fails the tree it lands in; it arrived with lane drainTrim and was found by integration 24b-2 before it reached main. + +## Grounds + +- pursued: abcd lint's privacy-hygiene rule passes on the tree; a committed file naming a /Users/ path again would show it wrong. diff --git a/AGENTS.md b/AGENTS.md index 8145c8e82..c84374477 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,8 +79,9 @@ trust rule for load experiments: one owned process group killed together through a re-checked handle and never by pattern, clean proven by what is running, and explicit consent with a cap below the core count on a live development machine. `SHELL` is the teaching half of the shell-hazard guard: it is generated from the -same bundled hazard registry `abcd guard` enforces, one rule per entry (the -command, why it is dangerous, and what to run instead), and recalls on the +same hazard registry `abcd guard` enforces in the repository, one rule per entry +(the command, why it is dangerous, and what to run instead; a rule from the +repository's own `.abcd/guard.json` is marked `(repo)`), and recalls on the commands the registry names (`rm`, `git push`, `pkill`, …) and on shell work in general, so an agent is taught the safe form before a host with hooks would refuse the command and a host without hooks still teaches it. diff --git a/commands/abcd.md b/commands/abcd.md index e2ec8faef..0eb462bc5 100644 --- a/commands/abcd.md +++ b/commands/abcd.md @@ -84,14 +84,17 @@ intent a build run has in a lane (each row's `lane` names the run, the lane, its next stage and the role it waits on), then the intent marked `next_up`; Next is every planned intent the readiness gate reports READY; Later is every planned intent the gate refuses, its `failing_checks` named, then the drafts. -An intent in a lane is listed under Now only, never also under Next or Later. +A planned intent that names the release it must land by carries it as +`target_release` on its row, in any list, and its text line shows `target +` in the brackets. An intent in a lane is listed under Now only, never also under Next or Later. Next and the `next_up` intent are read in `abcd build next`'s pick order (`order` is `pick`): the readiest first by the pick's score, the oldest among equals, and the head passes over an intent that `abcd build next` refuses from the record alone (an open question, an -unanswered claim section, a hold, an unshipped blocker, no step left to build) -or that is already in a lane. The head does not consult other checkouts, so an -intent a peer holds can still be marked `next_up`. Relay Now first: it is what is being built and +unanswered claim section, a hold, an unsettled blocker, no step left to build) +or that is already in a lane, or that another checkout holds (build next's +peers check), so `next_up` is always the intent `abcd build next` would pick. +Relay Now first: it is what is being built and what comes next. The block is computed each time and nothing stores it. ## Record-id dispatch diff --git a/commands/ahoy.md b/commands/ahoy.md index a52cecb74..d235886ce 100644 --- a/commands/ahoy.md +++ b/commands/ahoy.md @@ -147,8 +147,9 @@ category present — often several — and every line after the last one you sup reads end-of-input and DECLINES. `yes` is the reliable form because it never runs out; a single `printf 'y\n'` answers the first question only and silently declines the rest. The questions come in a fixed order (dependency, -safe-autocreate, config-change, status-line, oracle-routing, user-state, plugin-owned), so a -scripted stream of specific answers lines up with them. Each answer is echoed back, so the +safe-autocreate, config-change, status-line, oracle-routing, drain-rule, user-state, plugin-owned), so a +scripted stream of specific answers lines up with them. The drain-rule question +is asked only at a terminal, so a piped stream never meets it. Each answer is echoed back, so the transcript shows what was asked and what it was answered — read it back rather than assuming. Under `set -o pipefail` the pipeline reports 141: `yes` takes SIGPIPE when abcd stops reading, by design — judge the run by abcd's own output @@ -184,9 +185,12 @@ that must not block and must not prompt, close stdin or pre-answer everything: `--yes` approves every resolvable category but never adopts the optional git-identity pin, because the pin records whatever git identity is currently configured, never wires the status line (below), because that rewrites a -harness-wide setting, and never accepts a model-tier routing table (below), -because a table decides which model every delegated step asks for. When the result carries `optional_skipped`, report it and -offer the `yes |` form above as the way to apply it. +harness-wide setting, never accepts a model-tier routing table (below), +because a table decides which model every delegated step asks for, and never +adds the drain eligibility record (below), because the record decides what an +unattended agent may change in the repository. When the result carries `optional_skipped`, report it and +offer the `yes |` form above as the way to apply it, except `drain_rule.offered`, +which only a person at a terminal is asked. **The git identity question is a person's alone.** When the author or committer a commit would carry diverges from the identity pin, or is a machine identity @@ -298,6 +302,27 @@ from. With no provider configured every step still runs through the harness, which is asked for the tier. `ahoy uninstall` leaves both files, because they are the user's configuration. +**The drain eligibility record offer.** `abcd drain` takes an open issue alone +only under a rule the repository records for itself, and refuses to run until an +accepted decision record in `.abcd/development/decisions/adrs/` states it in +four frontmatter fields (`drain_categories`, `drain_severities`, +`drain_security`, `drain_remedy`). While no accepted record carries them, the +install states abcd's strict baseline in one question (take an issue only when +its category is `tech-debt`, `documentation`, `inconsistency`, `drift`, `bug` or +`ux`, its severity is `nitpick` or `minor`, it carries a remedy and nothing open +blocks it; every security, major and critical issue is a person's); consent +mints it through the decision store's own seam as an accepted record, which is +committed with the repository. Relay the user's answer; never answer it for the +user. Declining writes nothing and records nothing, so the next install offers +again. It is asked only at a terminal, as the git identity question is: off one +(a pipe, a routine, CI) neither its category nor the offer is asked, so a +scripted answer stream keeps its order and a scripted yes never writes the +record; that run, and a `--yes` run, report `drain_rule.offered` under +`optional_skipped`. The offer only ever writes the baseline: +loosening a floor is an edit a person makes to the record, and `abcd drain` +names every floor loosened. A repository whose record states the rule badly is +not offered a second one; `abcd drain` names what is wrong with the one it has. + `--attribution` is its own approval and works on an already-installed repo (the step the adopt phase runs it in). It opts the repo into the committed `prepare-commit-msg` prompt, @@ -398,10 +423,15 @@ would send this verb's authenticated write to a machine the origin URL never named. The call goes through the GitHub CLI (`gh`), so the write is made by the user's -own authenticated identity and abcd never holds a token; if `gh` is absent the -verb refuses, and the refusal carries the tool registry's explanation of `gh`: -what it is, that these verbs require it, the exact install step and what that -install does. Relay it; the step is the user's to run. It is idempotent — a repository already in the desired +own authenticated identity and abcd never holds a token. If `gh` is absent, the +verb explains it from the tool registry (what it is, that these verbs require +it, the exact install step and what that install does) and offers to install +it, running the step only on a yes typed at a terminal. `--yes` never answers +that offer, and neither does a piped answer, so through this page the offer is +declined: the verb refuses, and its notes carry the explanation and the command. +Relay them; the install is the user's to run, by hand or by running the verb at +a terminal. `ahoy --remote` never offers the install, since it writes nothing, +and names this verb as the one that does. It is idempotent — a repository already in the desired state takes no write, and a re-run rewrites nothing in the tree — and it stops at the first failed step rather than attempting one that cannot succeed. Relay `status`, the resolved `repo`, every `change`, and every `note`: a note is a @@ -431,6 +461,10 @@ configured provider changes no step until provider dispatch lands. The bare board names the same adapter as an optional gap (`oracle_api.none_configured`) while none is configured, and a configuration the adapter refuses as `oracle_api.config_refused`, naming the file and the key. +A route the configuration read skips (a repository's route to a provider that +holds a key, a route to a provider this machine has not configured, or a role +outside the roster) is the optional gap `oracle_api.route_skipped`, its +`detail` one line per skipped route; relay each line. Declining is not running `connect`, and it changes nothing. The setup is `abcd ahoy connect --base-url --model @@ -458,7 +492,9 @@ would be echoed. **Never ask the person for the key and never pass it yourself**: it would enter this conversation. Give them the command to run in their own shell, with the key piped in from a file or a variable they hold, and relay the result — `verified` (the provider, the model asked for, the model -it reported and the credential's name), each `wrote` path, and `dispatch`. +it reported and the credential's name), each `wrote` path, and `dispatch`. A +route the configuration read skips is named on stderr, in the text and the JSON +form alike, and the setup stands: relay that line too. ## `credential` — the credential store's walkthrough @@ -471,7 +507,10 @@ Every external credential abcd holds lives in one store, in the home the person chooses once per credential. Bare, the sub-verb lists each credential an adapter reads (`hosting.cloudflare` for the site setup, each configured provider's key) with its `state` (`set`, `not set`, or a refusal) and `home`; -never a value. With a name it explains that credential and writes nothing: +never a value. A route the configuration read skips (a repository's route to +a provider that holds a key) is named on stderr and the listing goes on: relay +that line too, as with a name that is a provider's credential, whose read of +the configuration names it the same way. With a name it explains that credential and writes nothing: relay `unlocks`, `without_it`, then `homes_prose` verbatim (it recommends the platform keychain in the prose; never present one home as the marked option), then the `homes` and the `setup` command for each. diff --git a/commands/build.md b/commands/build.md index 51e365655..7c2962b32 100644 --- a/commands/build.md +++ b/commands/build.md @@ -49,13 +49,16 @@ pass: - `claim_sections` — the `## Mechanism` prompt is answered (or the section absent) and the scope conditions are recorded. - `hold` — the intent carries no `held:`. -- `blocked` — nothing the intent names in `blocked_by` is unshipped (an intent - not in `shipped/`, or one this checkout does not hold, blocks it). A - superseded blocker is followed along `superseded_by` to the intent that - replaced it, transitively, and blocks only while that replacement is - unshipped; a chain that loops, ends at a record this checkout does not hold - or at a decision (`adr-N`), or stops at a superseded record naming no - successor blocks, naming the chain. +- `blocked` — nothing the intent names in `blocked_by` is unsettled. An intent + in `shipped/` or `disciplines/` is settled; one anywhere else, or one this + checkout does not hold, blocks it. A superseded blocker is followed along + `superseded_by` to the record that replaced it, transitively, and blocks only + while that replacement is unsettled: an intent settles as above, and a + decision (`adr-N`) settles when its status is `accepted`. A decision in any + other status or missing from this checkout blocks, as does a chain that + loops, ends at an intent this checkout does not hold, or stops at a + superseded record naming no successor; the reason names the chain, and a + settled chain is named in the passing row. - `steps` — the spec's `## Steps` reads, and at least one step is not landed. - `peers` — no peer holds the intent: no sibling worktree or local branch holds it in another bucket, and no session other than `--session` holds a live diff --git a/commands/capture.md b/commands/capture.md index 96363b10a..446d3a683 100644 --- a/commands/capture.md +++ b/commands/capture.md @@ -504,6 +504,9 @@ replacement is never silent. Report `redacted` whenever it is non-zero. The record stays in `open/`. Refused with exit 2 and nothing written: an empty text, `none (filed automatically)` in any case (it would leave the record as the drain already skips it), a malformed or unknown id, and a record that is not open. +When a person comes to write the real fix for a record filed with `none (filed +automatically)`, offer to run a state-of-the-art research pass first (principle +`prefer-sota`) before they write it, and let them decline. ## Answer a reading item @@ -544,13 +547,16 @@ hand, until exactly one does. The standing disposition of an item is the one no sibling supersedes, and the superseded record stays in place, because a hold that vanished when it was answered would take its own exit condition with it. `--recurs` cites prior item ids — the -recorded form of a warm recognition that something has come back, never a -mechanical join and never a state of its own. +researcher's confirmed recognition that something has come back, never a state +of its own. The machine's proposal of a repeat is the `duplicates:` or +`refines:` link `reading ingest` writes onto the item; a recurrence the +researcher confirms is cited here. `--hold-frame-location` and `--hold-moscow` are **reserved and dormant**: the grammars are stated and a populated value is refused until activation is ruled. -Nothing means "already covered" — an item nobody has answered is reported as -outstanding by `abcd lint`, never named as a state. +No state means "already covered": an item nobody has answered is reported as +outstanding by `abcd lint`, never named as a state, and a stored link to a +likely repeat is a proposal on the item, not an answer to it. **At the widening position, characterise first and admit second.** No disposition in any state (`accepted`, `declined` or `held`) and no admission is @@ -739,6 +745,14 @@ intent. Its Press Release seed names no item ("Seeded by promotion from a readin item"): that section is projected to a later reading, and no reading sees another's output. The item's own text stays in the reading record. +Promoting a reading item matches the draft it mints against the record, as a +capture is matched: the item's pattern and body are compared with the open and +resolved issues and the intents, and each likely double is written onto the +draft as `duplicates:` or `refines:`. The JSON carries it as `match`, and the +plain rendering prints each link written, or why nothing was compared. Relay the +match; a person keeps a link or deletes its line. Link mode mints nothing and +matches nothing. + For a reading item the JSON's `issue_status` carries the **standing disposition's state** (`accepted`), not a status folder: that family's status signal is the keyed disposition, and it has no folder to name. diff --git a/commands/drain.md b/commands/drain.md index 0648f154e..463ece1a1 100644 --- a/commands/drain.md +++ b/commands/drain.md @@ -1,6 +1,6 @@ --- name: drain -description: "Sort the open issues by the drain's field rule, eligible first in drain order: Writes nothing; refuses to start without --dry-run, as the run is not built." +description: "Sort open issues by this repository's own drain rule, naming each loosened floor: Writes nothing; refuses without the rule's record, or without --dry-run." block: agents --- @@ -19,29 +19,48 @@ Run: ## The rule +Which issues a drain may take alone is **this repository's own decision**: an +accepted decision record in `.abcd/development/decisions/adrs/` whose +frontmatter carries four fields. abcd's strict baseline, which +`abcd ahoy install` offers to write, is: + +```yaml +drain_categories: [tech-debt, documentation, inconsistency, drift, bug, ux] +drain_severities: [nitpick, minor] +drain_security: handback +drain_remedy: required +``` + +A repository's record may narrow those lists, and may **loosen abcd's floors**: +list `major` or `critical` in `drain_severities`, or set `drain_security: take`. +It cannot widen the categories past that fixable set, and `drain_remedy` is +always `required`. + An open issue is **eligible** when all of these hold, read from its fields alone: - no record in its `blocked_by` is still open; -- its category is in the fixable set: `tech-debt`, `documentation`, - `inconsistency`, `drift`, `bug`, `ux`; -- its severity is `nitpick` or `minor`; +- its category is one the rule takes; +- its severity is one the rule takes; - it carries a `remedy:` (`abcd capture --remedy ""`, which every new issue carries, or `abcd capture remedy ""` onto an open one; a record carrying only the older `suggested_fix:` reads that as its remedy), - and the remedy is not `none (filed automatically)`, the value abcd's - automatic filers write when they have no fix. + the remedy is not `none (filed automatically)`, the value abcd's automatic + filers write when they have no fix, and it does not open "Waits on"; +- it carries no deferral that is live at the checkout's newest release tag, and + none at all when the checkout holds no release tag. -The rule is a recorded decision, and the payload's `record` names it. The -rules are asked in a fixed order, and the first that excludes an issue decides -its one disposition: +The rules are asked in a fixed order, and the first that excludes an issue +decides its one disposition: | `outcome` | `rule` | Meaning | | --- | --- | --- | | `skipped` | `blocked` | an open record blocks it; `blockers` names them | -| `handback` | `security` | category `security` is always a person's | -| `handback` | `category` | a category outside the fixable set (`process`, `observation`, `architectural-insight`, `future-work-seed`, `lapse`) | -| `handback` | `severity` | severity `major` or `critical` | +| `handback` | `security` | category `security`, a person's unless the rule sets `drain_security: take` | +| `handback` | `category` | a category the rule does not take | +| `handback` | `severity` | a severity the rule does not take (`major` and `critical` under the baseline) | +| `handback` | `waits-on-ruling` | its remedy opens "Waits on": the fix waits on a ruling a person has not given | +| `handback` | `deferred` | its `deferred_after` names the current anchor tag: a person carried it past this release; or the checkout holds no release tag (a shallow clone fetches none), so whether any deferral is live is unknown and the reason names `git fetch --tags` | | `ineligible` | `remedy` | no remedy (a record filed before the remedy was required), or `none (filed automatically)` from an automatic filer; ineligible until a person writes one with `abcd capture remedy`, which the reason names | | `unreadable` | `unreadable` | the ledger reader refuses the record; the reason names why | | `eligible` | `fields` | every field rule passes | @@ -53,31 +72,58 @@ a dry run, and it can only ever hand an issue back. ## The order Eligible issues come first, in the order a drain takes them: by category -`tech-debt`, `documentation`, `inconsistency`, `drift`, `bug`, `ux`; then -`nitpick` before `minor`; then oldest first. The payload's `order` states the -rule. Every other open issue follows, by id. +`tech-debt`, `documentation`, `inconsistency`, `drift`, `bug`, `ux`, then +`security` when the rule takes it; then by severity, `nitpick` first; then +oldest first. The payload's `order` states the rule. Every other open issue +follows, by id. -## The payload +## Loosened floors + +When the repository's record loosens a floor, the text output prints a +`LOOSENED` block naming each one (`severity major`, `severity critical`, +`security`), stderr carries a warning naming them in both modes, and the +payload's `loosened` lists them. **Tell the user every loosened floor first, +before anything else in the plan**: a loosened rule lets a drain take issues +abcd's baseline hands to a person, and the record that loosened it is a change +a person should have reviewed. -`dry_run` is `true`; `record` is the decision record the rule is stated in; -`order` is the ordering rule; `dispositions` holds one entry per open issue -(`id`, `path`, `severity`, `category`, `outcome`, `rule`, `reason`, and -`blockers` when skipped); `counts` totals them by outcome; `ledger` names the -checkout and branch read. +## The payload -Tell the user the counts, then the eligible issues in order, then the others -grouped by outcome with their reasons. For an `ineligible` issue, say that a -person writing a remedy with `abcd capture remedy ""` is what makes -it a candidate, and name the ones an automatic filer wrote apart, since their -reason says so. Do not act on the list: a -hand-back is a person's decision. +`dry_run` is `true`; `record` is the repository's decision record the rule is +read from; `rule` is that record's rule (`record`, `path`, `categories`, +`severities`, `security`, `remedy`, `loosened`); `loosened` lists every floor +it loosens (empty when none); `anchor` is the release tag a live deferral names, +present when an open record carries a deferral; `anchor_unknown` is `true` when +an open record carries a deferral and the checkout holds no release tag; `order` is the ordering rule; +`dispositions` holds one entry per open issue (`id`, `path`, `severity`, +`category`, `outcome`, `rule`, `reason`, and `blockers` when skipped); `counts` +totals them by outcome; `ledger` names the checkout and branch read. + +Tell the user any loosened floors, then the counts, then the eligible issues in +order, then the others grouped by outcome with their reasons. For an +`ineligible` issue, say that a person writing a remedy with +`abcd capture remedy ""` is what makes it a candidate, and name the +ones an automatic filer wrote apart, since their reason says so. For a +`waits-on-ruling` or `deferred` hand-back, say which ruling or release it waits +on. Do not act on the list: a hand-back is a person's decision. ## Refusals -- Without `--dry-run` the verb refuses to start (exit 2, nothing read or - written): the issue-keyed lane a drain hands each issue to is not built. The - refusal names what is missing and points at the dry run. It would also refuse - naming the decision record it needs, were the rule unrecorded. +- Without the repository's own record of the rule, the dry run and the bare + verb refuse (exit 2, nothing written), naming how to add it: run + `abcd ahoy install` at a terminal and accept the offer, or give an accepted + decision record the four `drain_` fields. A record carrying the fields but + proposed or superseded is named. Relay this; do not write the record for the + user. +- A malformed record (a field missing or misspelt, any frontmatter key stated + twice, an `id` its file name does not give it, or a value the field does not + take) refuses, naming the record and the field; two accepted records carrying + the fields refuse, naming both. A decision store or record that cannot be read + safely (a symlink, or a record past the size cap) refuses. Every one of these + exits 2 with nothing written, on the dry run and the bare verb alike. +- Without `--dry-run` the verb refuses to start (exit 2, nothing written): the + issue-keyed lane a drain hands each issue to is not built. The refusal names + the rule's record, every floor it loosens, and the dry run. - Outside a checkout, or on a ledger holding one id in two status folders, it refuses (exit 2) as every capture verb does. diff --git a/commands/guard.md b/commands/guard.md index 45160b429..94f1622ca 100644 --- a/commands/guard.md +++ b/commands/guard.md @@ -133,11 +133,12 @@ UNGUARDED warning naming the file, so the state cannot pass unnoticed. **Never write `.abcd/guard.json` on your own initiative.** Disabling or retiering a hazard is the user's decision to make and to review. -The bundled hazards are also taught before any command runs: the rules loader's -`SHELL` domain is generated from them, one rule per entry, and is injected when a -prompt is about shell work (`abcd rules shell` renders it). It is built from the -bundled registry only, so an entry a repo adds in `.abcd/guard.json` is enforced -here without being taught there. +The hazards are also taught before any command runs: the rules loader's `SHELL` +domain is generated from the registry this guard enforces, one rule per entry, +and is injected when a prompt is about shell work (`abcd rules shell` renders +it). An entry a repo adds in `.abcd/guard.json` is taught there as well, its +rule marked `(repo)`; a guard file this guard refuses is named on stderr and +not taught. ### What this guard is @@ -254,7 +255,10 @@ builds them: as the command in command position, as operands after it, and at every payload layer the guard follows, so a document whose own text is `$(cat <<'F' … F)` is read too. Text written in the word is not split, as bash does not split it, and an assignment's value is not split either. On a command -line where any other command names IFS (`IFS=x;`, `export IFS=x`), an unquoted +line where any other command names IFS (`IFS=x;`, `export IFS=x`, or an +assignment target that holds an expansion anywhere in the line's text, such as +`export ${I}FS=x`, `(( ${a}${b} = 1 ))` or `printf -v "$x" 1`, since bash sets the +variable the target's value names), an unquoted fixed output is a **block** (`ifs-split-unread`): the guard splits on the default IFS only, and refuses rather than work out which assignment reaches which expansion. A prefix assignment (`IFS=x $(…)`) does not reach its own command's @@ -396,7 +400,9 @@ runs, or `pkill` or `killall` as the program a variable names (`$P make`) — because reading each would refuse the ordinary commands a variable carries a value for, an IFS the shell already holds when the line starts or gains during the line through a name the guard does not read -(`declare $(echo I)FS=x`, a sourced file; every line is read from the default IFS), a hazard inside a non-shell interpreter's payload (`python -c`, +(a sourced file, a nameref set before the line, or an operator the line does +not write, such as a command's output an arithmetic context evaluates; every +line is read from the default IFS), a hazard inside a non-shell interpreter's payload (`python -c`, `perl -e`) — one opaque token the tokenizer cannot read, today a silent allow, not a warn (a warn for it is a recorded design target, not yet implemented), or a dangerous form no entry describes. Nor does an allow see what a lone substitution diff --git a/commands/inbox.md b/commands/inbox.md index c214a4883..ec9ed652c 100644 --- a/commands/inbox.md +++ b/commands/inbox.md @@ -64,8 +64,12 @@ It files a capture through the capture verb's own path and redactor, with repository" in place of the sender's name, and the report id as its evidence. A record id the report names is the sender's, so it is written as one word (`iss12`) and cites nothing in abcd's record. -Tell the user the `capture` id and its `path`, and relay `redacted` or -`redaction_degraded` when present. The report is kept, marked promoted. A +The capture runs the capture verb's filing-time match on the report's own +title and prose, never on the provenance lines every promoted report carries, +and writes a `duplicates:` or `refines:` link naming each likely double. +Tell the user the `capture` id and its `path`, relay `redacted` or +`redaction_degraded` when present, and relay `match`: each link written, for a +person to confirm by leaving it or remove by deleting its line. The report is kept, marked promoted. A refusal exits 2 and writes nothing: a promotion outside a checkout of abcd, an unreadable report, one already promoted (the refusal names its capture), an id with no report, a capture the ledger refuses (the report still waits), or a diff --git a/commands/intent.md b/commands/intent.md index 79dfd9cd5..5ec71021c 100644 --- a/commands/intent.md +++ b/commands/intent.md @@ -735,7 +735,10 @@ intent only: a bundle is planned without one and each member targeted after); Refused with nothing written: a draft, a shipped, superseded or discipline record, and a value that is neither shape. A target is a report, never a gate: `launch --dry-run` and the release cut (`launch ship`, `abcd changelog`) list -every targeted intent still planned, and neither refuses on one. Closing the +every targeted intent still planned, and neither refuses on one. The cut moves +every target it passes — `next`, or a tag at or below the release it cuts — to +`next`, whatever the following release is numbered, in the same write as the +changelog, and the dated section names the move. Closing the spec that ships the intent drops the line, as superseding it does, and record-lint's `record_schema` rule refuses a `target_release` left on a shipped or superseded intent. @@ -1081,7 +1084,10 @@ A payload that validates is written in two places. Each finding is filed as one issue (`inconsistency`, from an `agent-finding`, located at its first end, with the report as its evidence) — unless an open record already quotes either end and names its document, in which case it is linked to that record and nothing -is filed. And one dated report lands on the reviews shelf, +is filed. A finding it files runs the capture verb's filing-time match on its +summary and explanation, never against a record the same pass filed, and +carries a `duplicates:` or `refines:` link naming each likely double; each row +reports it as `match`. And one dated report lands on the reviews shelf, `.abcd/work/reviews/-consistency[-]/00-summary.md`, pinned to the commit the pass read, listing every finding with both ends quoted and located and the record it was filed as or linked to; a second run the same day takes diff --git a/commands/launch.md b/commands/launch.md index cb535baf4..975e792ad 100644 --- a/commands/launch.md +++ b/commands/launch.md @@ -18,8 +18,9 @@ Two flows over the abcd binary, kept apart on purpose: `.abcd/.work.local/logs/launch/`. - **ship** — the release cut: derive the version from what shipped, compose the changelog prose and the release page, write them. It writes the dated section - of `CHANGELOG.md`, the release page `RELEASE.md`, and the outgoing page's copy - under `.abcd/development/releases/`; in a repository that publishes a versioned + of `CHANGELOG.md`, the release page `RELEASE.md`, the outgoing page's copy + under `.abcd/development/releases/`, and the `target_release` line of every + planned intent whose target the cut passed, moved to `next`; in a repository that publishes a versioned plugin it also pins the release's plugin archive in `.claude-plugin/marketplace.json` (refreshing the surface snapshot beside it). It **never publishes**. @@ -386,7 +387,8 @@ The cut also lists every planned intent that names a release it must land by (`targets`, one `targeted:` line each in the render, and `targets_error` when the intent store could not be read): targeted and not shipped. The list never refuses the cut and never changes the exit code; relay it with the report, and -the ingest in step 3 reports the same list beside what it wrote. +the ingest in step 3 reports the same list beside what it wrote, and moves +each target the cut passes to `next` (below). The emit render ends with the **receipts protocol**, a numbered checklist the binary composes from the committed `release.yml`: commit the roll, run each @@ -627,6 +629,16 @@ footer), and the page against the repository's persona registry On success it writes, in this order, only after every check has passed: +0. **the moved targets** — every planned intent whose `target_release` the cut + passes (`next`, which named this release, or a tag at or below the derived + one) targets `next` — the following release, whatever version it derives — + and its record is rewritten in the same write when it named a tag. A target + past the cut stays. The dated section names every move in one line under + its notice, ahead of the first change-type heading: `Targeted and not + shipped in this release, so each targets the next release (next): itd-N + (targeted vX.Y.Z), …`. A record whose target changed since the cut read it + stops the cut. The report prints one `moved:` line per intent and the JSON + carries `moved_targets` (`id`, `path`, `from`). 1. **the archive** — the outgoing `RELEASE.md` moves to `.abcd/development/releases/.md`, the version read from its own heading. It never overwrites: an archive page already standing there stops diff --git a/commands/reading.md b/commands/reading.md index 46dbaf8ad..2fa83f81f 100644 --- a/commands/reading.md +++ b/commands/reading.md @@ -430,7 +430,18 @@ records. The ids a sweep removed are reported however the invocation ends. One ingest runs at a time in a checkout: a second waits, and reports contention rather than sweeping the first one's records away. -Report from the JSON: `run_id`, `records`, `refused_items`, +**Each stored finding is matched against the record.** The ingest compares each +item's pattern and body with the open and resolved issues, the intents and +every earlier reading item, never with another item of the same run, and writes +a likely repeat onto the reading record as `duplicates:` ("same as") or +`refines:` ("builds on"). The JSON's `matches` lists, per record, the links +written, the matches past the cap, and the near misses with their scores; the +plain rendering prints them under each record id. The match is a lexical +heuristic and never refuses the ingest. Relay the likely repeats: the +researcher keeps a link or deletes its line, and cites a confirmed recurrence +with `capture disposition --recurs`. + +Report from the JSON: `run_id`, `records`, `matches`, `refused_items`, `cleared_stages`, `rolled_back_records`, `pending_stages`, and `run_record` — or, on a refusal that recorded one, `refusal_record`. A refusal renders the JSON whenever it has one of these to disclose, so read it on exit 2 as well. diff --git a/commands/site.md b/commands/site.md index 04d2336a9..e67375e8a 100644 --- a/commands/site.md +++ b/commands/site.md @@ -107,7 +107,10 @@ sets up the site of a repository abcd manages, in three stages, and emits writes is `refused`, the whole run writes nothing, and `--confirm` replaces it. - `environments` — the forge's `site-render` and `site` deployment environments, each admitting only the default branch and tags `v*`, created - through `gh` as you. The default branch is the one the forge names; when the + through `gh` as you. When `gh` is missing, setup explains it and offers to + install it, running the step only on a yes typed at a terminal; under + `--yes` or a piped answer the offer is declined, the environments are not + created, and `notes` carries the command to run. The default branch is the one the forge names; when the forge cannot answer, the checkout's branch stands in and `notes` says so. An existing environment is never rewritten (the forge's write would replace its required reviewers): one on named rules and no rule beyond those two gains the rules it lacks, and one that admits more, through diff --git a/commands/update.md b/commands/update.md index 43439081c..6107662be 100644 --- a/commands/update.md +++ b/commands/update.md @@ -10,11 +10,18 @@ block: people Complete a chosen update of the PATH-installed binary. The verb's documented meaning IS the fetch: it resolves the latest release (or takes an explicit tag), verifies the platform binary against the same release's -`checksums.txt`, and swaps the PATH copy atomically, printing a receipt with -the origin, tag, digest, and old→new versions. abcd never checks for or +`checksums.txt`, and swaps the PATH copy atomically, printing a receipt that +opens with `abcd updated from to ` and names the path, origin and +digest. abcd never checks for or applies updates on its own — this verb is the only command that reaches the release origin, and only when invoked. +The same line announces a swap the plugin bootstrap makes. When a hook that +discards its output made the swap, the next session start shows the line once, +and claims that release with an empty file: `update-shown-` in the plugin +data directory's `cache/`, or `.update-shown-` in the plugin root when there +is no data directory. That claim is the only thing the session start writes. + **Only asking.** When the user wants to know whether a newer release exists without taking it, run the check, which fetches the latest release's tag once and swaps nothing: diff --git a/docs/how-to/install.md b/docs/how-to/install.md index 6e59f961f..f2354ea04 100644 --- a/docs/how-to/install.md +++ b/docs/how-to/install.md @@ -170,6 +170,12 @@ scrolled away and you would rather not go looking for the directory, the [install](#cli) one-liner below needs no plugin root at all and gets you to the same place. +When a plugin update brings a newer release, the bootstrap's success notice +opens with `abcd updated from to `, once, in the session that +installs it; `abcd update` opens its receipt with the same line. If the new +release was installed by a hook that shows no output, the next session start +prints that line instead, once. + For a stronger root of trust than same-origin checksums, build from source — `go build ./cmd/abcd` — and place the binary in the plugin root and on your `PATH` yourself. A binary placed there by hand takes the same no-network fast diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index da2ea73fe..f5bafab0a 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -133,7 +133,7 @@ Enable GitHub secret scanning and push protection on this repository: Writes bot **Flags:** ``` - --yes confirm the remote change without being asked; without it an unanswered run declines and changes nothing + --yes confirm the remote change without being asked (never the install of a missing gh); without it an unanswered run declines and changes nothing ``` #### `abcd ahoy uninstall` @@ -229,7 +229,7 @@ Start the loop that takes one READY intent to delivered: Writes the run's state Start the implement loop for one intent, or resume the run already in progress for it. A new run's checks run first, and every one must pass: the intent is READY (planned, criteria written, its spec linked and written), asks no -open question, has no unanswered claim section, is not held, names no unshipped intent +open question, has no unanswered claim section, is not held, names no unsettled blocker in `blocked_by`, its spec leaves a step to build, and no peer holds it (no sibling worktree or local branch holds it in another bucket, and no session holds a live claim on it; a peer or claim that cannot be read counts as holding it). A refusal names the @@ -289,7 +289,7 @@ Pick the readiest planned intent and start its run: Writes the run's state and t Pick the readiest planned intent, write down why, and start its run. The candidates are the planned intents that pass every check `abcd build ` runs -(READY, no open question, no unanswered claim section, not held, no unshipped intent in +(READY, no open question, no unanswered claim section, not held, no unsettled blocker in `blocked_by`, a step left to build, no peer holding it), less one this checkout already has a run in progress for. Each is scored from its record, three parts at equal weight, each 0 to 100: criteria clarity (the share of its acceptance criteria in Given-When-Then form), a @@ -396,7 +396,7 @@ Answer one reading item with a disposition record: Writes the record keyed to th --grounds string disposition_grounds: why this answer (free text; required on every state except held) --hold-frame-location string RESERVED (dormant): the frame element a hold sits at; a populated value is refused until activation is ruled --hold-moscow string RESERVED (dormant): must | should | could | wont; a populated value is refused until activation is ruled - --recurs string comma-separated prior rdi-ids this item recurs from — the recorded form of a warm recognition, never a mechanical join + --recurs string comma-separated prior rdi-ids this item recurs from — the researcher's confirmed recognition; the ingest's duplicates/refines link is only a proposal --state string the answer: accepted | rejected | declined | held (availability varies by the item's position) --supersedes string the standing dsp-N this answer replaces; required once an item already carries one ``` @@ -917,26 +917,31 @@ This is the only abcd verb that reaches the network on behalf of documentation. ### `abcd drain` -Sort the open issues by the drain's field rule, eligible first in drain order: Writes nothing; refuses to start without --dry-run, as the run is not built. +Sort open issues by this repository's own drain rule, naming each loosened floor: Writes nothing; refuses without the rule's record, or without --dry-run. **Usage:** `abcd drain [flags]` Work the open issue ledger unattended: fix the issues that need no decision, and -hand the rest back by kind. The rule for which issues need no decision is a -recorded decision, and it reads the record's fields alone: nothing open in -blocked_by; a category in the fixable set (tech-debt, documentation, -inconsistency, drift, bug, ux); severity nitpick or minor; and a remedy: field. -A security issue is always a person's. Every other open issue is handed back, -listed as ineligible, or skipped naming its blocker, by the rule that excluded it. +hand the rest back by kind. Which issues need no decision is this repository's own +recorded decision: an accepted decision record whose frontmatter carries the four +fields drain_categories, drain_severities, drain_security and drain_remedy. The +rule reads the record's fields alone: nothing open in blocked_by; a category the +rule takes; a severity it takes; and a remedy: field. abcd's strict baseline takes +tech-debt, documentation, inconsistency, drift, bug and ux at nitpick or minor, and +hands every security issue to a person. A repository's record may loosen those +floors (major, critical, security), and every floor it loosens is named. An issue +whose remedy opens "Waits on", or whose deferral past the current release tag is +live, is always handed back. Every other open issue is handed back, listed as +ineligible, or skipped naming its blocker, by the rule that excluded it. --dry-run shows every open issue's disposition, the eligible ones first in the -order a drain takes them (category tech-debt, documentation, inconsistency, -drift, bug, ux; then nitpick before minor; then oldest first), and writes -nothing. The host judgement over each eligible remedy does not run in a dry +order a drain takes them (by category, then severity, then oldest first), and +writes nothing. The host judgement over each eligible remedy does not run in a dry run; it can only ever hand an issue back. -The run itself is not built: without --dry-run the verb refuses to start, and -exits 2 with nothing read or written. +Without the repository's record, the dry run and the run both refuse (exit 2), +naming how to add it; `abcd ahoy install` offers it. The run itself is not built: +without --dry-run the verb refuses to start, and exits 2 with nothing written. **Flags:** @@ -2566,6 +2571,11 @@ run never happened; where the marker is there the run stands and only the stage refused run reports the orphans it left in place, and the ids a sweep removed are reported as rolled_back_records on every exit, including a failing one. +Every stored finding is matched against the record as a capture is: its pattern and body are +compared with the open and resolved issues, the intents and every earlier reading item, never +with another item of the same run, and a likely repeat is written onto the reading record as a +duplicates: or refines: link and shown, printed and as matches in --json. + **Flags:** ``` @@ -2641,10 +2651,14 @@ is named on stderr, with the file that set the list, here and on every hook prompt. To keep an entry, restate it in the list, or leave the field out to inherit the bundled list. -SHELL is generated from the bundled shell-hazard registry that "abcd guard" -enforces: one rule per registry entry, naming the command, why it is dangerous -and what to run instead, recalled by the commands the registry names. It -teaches before shell work what the guard refuses at the moment a command runs. +SHELL is generated from the shell-hazard registry that "abcd guard" enforces +in this repository, the bundled entries and the repository's own +.abcd/guard.json entries alike: one rule per registry entry, naming the +command, why it is dangerous and what to run instead, recalled by the commands +the registry names. A rule in the repository's words is marked "(repo)" after +its entry id. A guard.json the guard refuses is named on stderr and not taught; +SHELL then teaches the registry the guard enforces in its place. It teaches +before shell work what the guard refuses at the moment a command runs. Read-only. ### `abcd scribe` @@ -2776,7 +2790,7 @@ Take the website from this checkout to a live address: Writes its files, and the --confirm replace a workflow or host configuration that differs from what setup writes --domain string custom domain to route to the host when the composition names none --name string host name when the composition names none (default: the repository's name) - --yes confirm the forge and host changes without being asked; without it an unanswered run declines them + --yes confirm the forge and host changes without being asked (never the install of a missing gh); without it an unanswered run declines them ``` ### `abcd source` diff --git a/hooks/bootstrap.sh b/hooks/bootstrap.sh index e12aef7e0..af0bb3ff8 100755 --- a/hooks/bootstrap.sh +++ b/hooks/bootstrap.sh @@ -15,6 +15,14 @@ set -u +# --unseen is passed by the salvage runs in the per-prompt, per-command and +# pre-compaction hooks (hooks/hooks.json), which discard everything this script +# prints. A release swap made there reports "abcd updated from X to Y" to no one, +# so it says so in the record instead (transition_unseen=yes), and the next +# session start shows the line once (the ruling CJ1b's single exception). +output_unseen='' +[ "${1:-}" = '--unseen' ] && output_unseen=yes + plugin_root="${CLAUDE_PLUGIN_ROOT:-}" [ -n "$plugin_root" ] || exit 0 @@ -577,6 +585,19 @@ if [ -n "$cache_mode" ] && [ -f "$cache_binary" ]; then [ -n "$cached_sha" ] || cached_tag='' fi +# prev_tag is the release this run replaces, read before anything is written: +# the cache's record in cache mode, the root's own record in the per-root mode +# (it outlives a binary removed from beside it). A fresh root with no record +# replaces nothing, and a first install is not an update. The swap records it +# as previous_tag (the ruling CJ1) and, when it differs from the release +# installed, leads the success notice with the update line (CJ1b). +prev_tag='' +if [ -n "$cache_mode" ]; then + [ -f "$cache_binary" ] && prev_tag=$(meta_field "$cache_meta" release_tag) +else + prev_tag=$(meta_field "$plugin_root/.binary-meta" release_tag) +fi + resolved_tag='' if command -v curl >/dev/null 2>&1; then redirect=$(curl -q -fsS --proto '=https' --proto-redir '=https' --max-time 15 -o /dev/null -w '%{redirect_url}' "$releases_url/latest" 2>/dev/null) || redirect='' @@ -656,6 +677,8 @@ path_note='' from_note='' stamp_note='' attest_note='' +update_lead='' +update_record='' if [ -n "$use_cache" ]; then release_tag="$cached_tag" @@ -676,6 +699,20 @@ else refuse "the latest release tag could not be resolved, so the download cannot be pinned to a single release — there may be no network; $ignored_env" release_tag="$resolved_tag" + # The swap below replaces prev_tag's release, so its record names it + # (CJ1), and the success notice leads with the one line the swap owes its + # reader (CJ1b) in the wording update.UpdatedFormat holds: first, because + # only the first line of a hook's stderr reaches the transcript (iss-208). + # A re-download of the same release is no update and says nothing of one. + # A run whose output nobody reads records that too, for the session check. + if [ -n "$prev_tag" ]; then + update_record=$(printf 'previous_tag=%s\n' "$prev_tag") + if [ "$prev_tag" != "$release_tag" ]; then + update_lead="$(printf 'abcd updated from %s to %s' "$prev_tag" "$release_tag"). " + [ -z "$output_unseen" ] || update_record=$(printf '%s\ntransition_unseen=yes' "$update_record") + fi + fi + # 6. Download into the mode's temp dir — the data dir in cache mode (same # filesystem as the cache, so publishing into it is a rename), the plugin # root otherwise (same filesystem as the install, ditto). @@ -744,6 +781,7 @@ else printf 'release_sha=%s\n' "$release_sha" printf 'binary_sha256=%s\n' "$binary_sha256" printf 'fetched_at=%s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" + [ -z "$update_record" ] || printf '%s\n' "$update_record" } > "$tmp/binary-meta" 2>/dev/null chmod 0755 "$tmp/$asset" 2>/dev/null || refuse "the downloaded $asset cannot be made executable" @@ -987,6 +1025,7 @@ else printf 'release_sha=%s\n' "$release_sha" printf 'binary_sha256=%s\n' "$binary_sha256" printf 'fetched_at=%s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" + [ -z "$update_record" ] || printf '%s\n' "$update_record" } > "$tmp/binary-meta" 2>/dev/null && mv -f "$tmp/binary-meta" "$meta_path" 2>/dev/null || meta_note=' (the .binary-meta provenance record could not be written, so version-skew reporting stays silent for this plugin root)' @@ -1037,5 +1076,5 @@ fi # # The path is wrapped in SINGLE quotes (binary_quoted, defined at the top) for # the reason given there: this string is printed to be pasted into a shell. -notice "$(printf 'abcd bootstrap: installed the checksum-verified abcd binary (release %s) into the plugin root, so the abcd hooks are live for this session.%s%s%s%s%s%s%s%s For the abcd command in your own terminal, run this once — the path is absolute because abcd is not on your PATH yet, which is exactly what the command fixes: %s ahoy install' \ - "$release_tag" "$from_note" "$stale_note" "$path_note" "$meta_note" "$cache_note" "$stamp_note" "$attest_note" "$degrade_note" "$binary_quoted")" +notice "$(printf '%sabcd bootstrap: installed the checksum-verified abcd binary (release %s) into the plugin root, so the abcd hooks are live for this session.%s%s%s%s%s%s%s%s For the abcd command in your own terminal, run this once — the path is absolute because abcd is not on your PATH yet, which is exactly what the command fixes: %s ahoy install' \ + "$update_lead" "$release_tag" "$from_note" "$stale_note" "$path_note" "$meta_note" "$cache_note" "$stamp_note" "$attest_note" "$degrade_note" "$binary_quoted")" diff --git a/hooks/hooks.json b/hooks/hooks.json index 84f44637d..95266a80b 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -7,7 +7,7 @@ "type": "command", "timeout": 120, "statusMessage": "abcd: checking the plugin binary; a first run downloads it, so this can take a while", - "command": "r=\"${CLAUDE_PLUGIN_ROOT:-}\"; [ -n \"$r\" ] || exit 0; if [ ! -x \"$r/abcd\" ] && [ -x \"$r/hooks/bootstrap.sh\" ] && [ -z \"$(find \"$r/.bootstrap.attempt\" -maxdepth 0 -mmin -10 2>/dev/null)\" ]; then : > \"$r/.bootstrap.attempt\" 2>/dev/null || true; \"$r/hooks/bootstrap.sh\" >/dev/null 2>&1 /dev/null); if [ -n \"$c\" ]; then y=\"\"; case \"$c\" in /*) dd=${c%/*}; [ -n \"$dd\" ] || dd=/; d=$(cd -P \"$dd\" 2>/dev/null && pwd -P); if [ -z \"$d\" ]; then y=\"its directory could not be resolved\"; else q=$(pwd -P); case \"$d/\" in \"$q\"/*) y=\"it lives inside the working tree\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ld \"$d\" 2>/dev/null)\" in ????????w*) y=\"its directory is world-writable\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ldL \"$c\" 2>/dev/null)\" in ????????w*) y=\"the binary itself is world-writable\" ;; esac; fi ;; *) y=\"it did not resolve to an absolute path\" ;; esac; if [ -z \"$y\" ]; then o=\"\"; w=\"\"; e=\"${HOME:-}/.abcd/path-entry\"; if [ -n \"${HOME:-}\" ] && [ -f \"$e\" ]; then if [ -L \"${HOME}/.abcd\" ]; then w=2; elif [ -n \"$(find \"$e\" -maxdepth 0 -type f -user \"$(id -un 2>/dev/null)\" ! -perm -0020 ! -perm -0002 2>/dev/null)\" ]; then while IFS= read -r ln || [ -n \"$ln\" ]; do case \"$ln\" in path=*) if [ \"${ln#path=}\" = \"$c\" ]; then o=1; fi ;; esac; done < \"$e\"; else w=1; fi; fi; if [ \"$w\" = 2 ]; then y=\"~/.abcd is a symlink, so its path-entry record is not read (replace the link with a real directory)\"; elif [ -n \"$w\" ]; then y=\"its ~/.abcd/path-entry record is not owned by you or is writable by others\"; else [ -n \"$o\" ] || y=\"~/.abcd/path-entry does not record it as the abcd installed here\"; fi; fi; if [ -n \"$y\" ]; then p=$(printf '%s' \"$c\" | tr -d '\\000-\\037\\177'); printf '%s\\n' \"abcd: ignoring the abcd found on PATH at $p because $y \u2014 a hook runs only the abcd recorded in ~/.abcd/path-entry by the documented install, from an ordinary user directory such as ~/.local/bin that neither the project nor another local user can replace; re-run the install per https://github.com/intentdriven/abcd#install to record it\" >&2; else g=\"$c\"; fi; fi; fi; if [ -n \"$g\" ]; then exec \"$g\" hook prompt-router; fi; printf '%s\\n' \"abcd: the plugin binary is missing and could not be provisioned, so the rules loader is inactive for this prompt — hooks/bootstrap.sh installs it when the session has network access, or install per https://github.com/intentdriven/abcd#install\" >&2; exit 1" + "command": "r=\"${CLAUDE_PLUGIN_ROOT:-}\"; [ -n \"$r\" ] || exit 0; if [ ! -x \"$r/abcd\" ] && [ -x \"$r/hooks/bootstrap.sh\" ] && [ -z \"$(find \"$r/.bootstrap.attempt\" -maxdepth 0 -mmin -10 2>/dev/null)\" ]; then : > \"$r/.bootstrap.attempt\" 2>/dev/null || true; \"$r/hooks/bootstrap.sh\" --unseen >/dev/null 2>&1 /dev/null); if [ -n \"$c\" ]; then y=\"\"; case \"$c\" in /*) dd=${c%/*}; [ -n \"$dd\" ] || dd=/; d=$(cd -P \"$dd\" 2>/dev/null && pwd -P); if [ -z \"$d\" ]; then y=\"its directory could not be resolved\"; else q=$(pwd -P); case \"$d/\" in \"$q\"/*) y=\"it lives inside the working tree\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ld \"$d\" 2>/dev/null)\" in ????????w*) y=\"its directory is world-writable\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ldL \"$c\" 2>/dev/null)\" in ????????w*) y=\"the binary itself is world-writable\" ;; esac; fi ;; *) y=\"it did not resolve to an absolute path\" ;; esac; if [ -z \"$y\" ]; then o=\"\"; w=\"\"; e=\"${HOME:-}/.abcd/path-entry\"; if [ -n \"${HOME:-}\" ] && [ -f \"$e\" ]; then if [ -L \"${HOME}/.abcd\" ]; then w=2; elif [ -n \"$(find \"$e\" -maxdepth 0 -type f -user \"$(id -un 2>/dev/null)\" ! -perm -0020 ! -perm -0002 2>/dev/null)\" ]; then while IFS= read -r ln || [ -n \"$ln\" ]; do case \"$ln\" in path=*) if [ \"${ln#path=}\" = \"$c\" ]; then o=1; fi ;; esac; done < \"$e\"; else w=1; fi; fi; if [ \"$w\" = 2 ]; then y=\"~/.abcd is a symlink, so its path-entry record is not read (replace the link with a real directory)\"; elif [ -n \"$w\" ]; then y=\"its ~/.abcd/path-entry record is not owned by you or is writable by others\"; else [ -n \"$o\" ] || y=\"~/.abcd/path-entry does not record it as the abcd installed here\"; fi; fi; if [ -n \"$y\" ]; then p=$(printf '%s' \"$c\" | tr -d '\\000-\\037\\177'); printf '%s\\n' \"abcd: ignoring the abcd found on PATH at $p because $y \u2014 a hook runs only the abcd recorded in ~/.abcd/path-entry by the documented install, from an ordinary user directory such as ~/.local/bin that neither the project nor another local user can replace; re-run the install per https://github.com/intentdriven/abcd#install to record it\" >&2; else g=\"$c\"; fi; fi; fi; if [ -n \"$g\" ]; then exec \"$g\" hook prompt-router; fi; printf '%s\\n' \"abcd: the plugin binary is missing and could not be provisioned, so the rules loader is inactive for this prompt — hooks/bootstrap.sh installs it when the session has network access, or install per https://github.com/intentdriven/abcd#install\" >&2; exit 1" } ] } @@ -31,7 +31,7 @@ { "type": "command", "statusMessage": "abcd: checking the plugin binary; a first run downloads it, so this can take a while", - "command": "r=\"${CLAUDE_PLUGIN_ROOT:-}\"; [ -n \"$r\" ] || exit 0; i=$(cat); case \"$i\" in *'\"tool_name\":\"AskUserQuestion\"'*|*'\"tool_name\": \"AskUserQuestion\"'*) k=\"questions through AskUserQuestion run UNGUARDED\" ;; *) k=\"shell commands run UNGUARDED\" ;; esac; if [ ! -x \"$r/abcd\" ] && [ -x \"$r/hooks/bootstrap.sh\" ] && [ -z \"$(find \"$r/.bootstrap.attempt\" -maxdepth 0 -mmin -10 2>/dev/null)\" ]; then : > \"$r/.bootstrap.attempt\" 2>/dev/null || true; \"$r/hooks/bootstrap.sh\" >/dev/null 2>&1 /dev/null); if [ -n \"$c\" ]; then y=\"\"; case \"$c\" in /*) dd=${c%/*}; [ -n \"$dd\" ] || dd=/; d=$(cd -P \"$dd\" 2>/dev/null && pwd -P); if [ -z \"$d\" ]; then y=\"its directory could not be resolved\"; else q=$(pwd -P); case \"$d/\" in \"$q\"/*) y=\"it lives inside the working tree\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ld \"$d\" 2>/dev/null)\" in ????????w*) y=\"its directory is world-writable\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ldL \"$c\" 2>/dev/null)\" in ????????w*) y=\"the binary itself is world-writable\" ;; esac; fi ;; *) y=\"it did not resolve to an absolute path\" ;; esac; if [ -z \"$y\" ]; then o=\"\"; w=\"\"; e=\"${HOME:-}/.abcd/path-entry\"; if [ -n \"${HOME:-}\" ] && [ -f \"$e\" ]; then if [ -L \"${HOME}/.abcd\" ]; then w=2; elif [ -n \"$(find \"$e\" -maxdepth 0 -type f -user \"$(id -un 2>/dev/null)\" ! -perm -0020 ! -perm -0002 2>/dev/null)\" ]; then while IFS= read -r ln || [ -n \"$ln\" ]; do case \"$ln\" in path=*) if [ \"${ln#path=}\" = \"$c\" ]; then o=1; fi ;; esac; done < \"$e\"; else w=1; fi; fi; if [ \"$w\" = 2 ]; then y=\"~/.abcd is a symlink, so its path-entry record is not read (replace the link with a real directory)\"; elif [ -n \"$w\" ]; then y=\"its ~/.abcd/path-entry record is not owned by you or is writable by others\"; else [ -n \"$o\" ] || y=\"~/.abcd/path-entry does not record it as the abcd installed here\"; fi; fi; if [ -n \"$y\" ]; then p=$(printf '%s' \"$c\" | tr -d '\\000-\\037\\177'); printf '%s\\n' \"abcd: ignoring the abcd found on PATH at $p because $y \u2014 a hook runs only the abcd recorded in ~/.abcd/path-entry by the documented install, from an ordinary user directory such as ~/.local/bin that neither the project nor another local user can replace; re-run the install per https://github.com/intentdriven/abcd#install to record it\" >&2; else g=\"$c\"; fi; fi; fi; if [ -n \"$g\" ]; then printf '%s' \"$i\" | \"$g\" guard hook; s=$?; [ $s -eq 0 ] || [ $s -eq 1 ] || [ $s -eq 2 ] || { echo \"abcd guard: FAILED TO RUN (exit $s) — $k in this session; run 'abcd ahoy' to see guard health\" >&2; exit 1; }; exit $s; fi; printf '%s\\n' \"abcd guard: the plugin binary is missing, so $k until it is provisioned — install per https://github.com/intentdriven/abcd#install\" >&2; exit 1" + "command": "r=\"${CLAUDE_PLUGIN_ROOT:-}\"; [ -n \"$r\" ] || exit 0; i=$(cat); case \"$i\" in *'\"tool_name\":\"AskUserQuestion\"'*|*'\"tool_name\": \"AskUserQuestion\"'*) k=\"questions through AskUserQuestion run UNGUARDED\" ;; *) k=\"shell commands run UNGUARDED\" ;; esac; if [ ! -x \"$r/abcd\" ] && [ -x \"$r/hooks/bootstrap.sh\" ] && [ -z \"$(find \"$r/.bootstrap.attempt\" -maxdepth 0 -mmin -10 2>/dev/null)\" ]; then : > \"$r/.bootstrap.attempt\" 2>/dev/null || true; \"$r/hooks/bootstrap.sh\" --unseen >/dev/null 2>&1 /dev/null); if [ -n \"$c\" ]; then y=\"\"; case \"$c\" in /*) dd=${c%/*}; [ -n \"$dd\" ] || dd=/; d=$(cd -P \"$dd\" 2>/dev/null && pwd -P); if [ -z \"$d\" ]; then y=\"its directory could not be resolved\"; else q=$(pwd -P); case \"$d/\" in \"$q\"/*) y=\"it lives inside the working tree\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ld \"$d\" 2>/dev/null)\" in ????????w*) y=\"its directory is world-writable\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ldL \"$c\" 2>/dev/null)\" in ????????w*) y=\"the binary itself is world-writable\" ;; esac; fi ;; *) y=\"it did not resolve to an absolute path\" ;; esac; if [ -z \"$y\" ]; then o=\"\"; w=\"\"; e=\"${HOME:-}/.abcd/path-entry\"; if [ -n \"${HOME:-}\" ] && [ -f \"$e\" ]; then if [ -L \"${HOME}/.abcd\" ]; then w=2; elif [ -n \"$(find \"$e\" -maxdepth 0 -type f -user \"$(id -un 2>/dev/null)\" ! -perm -0020 ! -perm -0002 2>/dev/null)\" ]; then while IFS= read -r ln || [ -n \"$ln\" ]; do case \"$ln\" in path=*) if [ \"${ln#path=}\" = \"$c\" ]; then o=1; fi ;; esac; done < \"$e\"; else w=1; fi; fi; if [ \"$w\" = 2 ]; then y=\"~/.abcd is a symlink, so its path-entry record is not read (replace the link with a real directory)\"; elif [ -n \"$w\" ]; then y=\"its ~/.abcd/path-entry record is not owned by you or is writable by others\"; else [ -n \"$o\" ] || y=\"~/.abcd/path-entry does not record it as the abcd installed here\"; fi; fi; if [ -n \"$y\" ]; then p=$(printf '%s' \"$c\" | tr -d '\\000-\\037\\177'); printf '%s\\n' \"abcd: ignoring the abcd found on PATH at $p because $y \u2014 a hook runs only the abcd recorded in ~/.abcd/path-entry by the documented install, from an ordinary user directory such as ~/.local/bin that neither the project nor another local user can replace; re-run the install per https://github.com/intentdriven/abcd#install to record it\" >&2; else g=\"$c\"; fi; fi; fi; if [ -n \"$g\" ]; then printf '%s' \"$i\" | \"$g\" guard hook; s=$?; [ $s -eq 0 ] || [ $s -eq 1 ] || [ $s -eq 2 ] || { echo \"abcd guard: FAILED TO RUN (exit $s) — $k in this session; run 'abcd ahoy' to see guard health\" >&2; exit 1; }; exit $s; fi; printf '%s\\n' \"abcd guard: the plugin binary is missing, so $k until it is provisioned — install per https://github.com/intentdriven/abcd#install\" >&2; exit 1" } ] } @@ -42,7 +42,7 @@ { "type": "command", "statusMessage": "abcd: checking the plugin binary; a first run downloads it, so this can take a while", - "command": "r=\"${CLAUDE_PLUGIN_ROOT:-}\"; [ -n \"$r\" ] || exit 0; if [ ! -x \"$r/abcd\" ] && [ -x \"$r/hooks/bootstrap.sh\" ] && [ -z \"$(find \"$r/.bootstrap.attempt\" -maxdepth 0 -mmin -10 2>/dev/null)\" ]; then : > \"$r/.bootstrap.attempt\" 2>/dev/null || true; \"$r/hooks/bootstrap.sh\" >/dev/null 2>&1 /dev/null); if [ -n \"$c\" ]; then y=\"\"; case \"$c\" in /*) dd=${c%/*}; [ -n \"$dd\" ] || dd=/; d=$(cd -P \"$dd\" 2>/dev/null && pwd -P); if [ -z \"$d\" ]; then y=\"its directory could not be resolved\"; else q=$(pwd -P); case \"$d/\" in \"$q\"/*) y=\"it lives inside the working tree\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ld \"$d\" 2>/dev/null)\" in ????????w*) y=\"its directory is world-writable\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ldL \"$c\" 2>/dev/null)\" in ????????w*) y=\"the binary itself is world-writable\" ;; esac; fi ;; *) y=\"it did not resolve to an absolute path\" ;; esac; if [ -z \"$y\" ]; then o=\"\"; w=\"\"; e=\"${HOME:-}/.abcd/path-entry\"; if [ -n \"${HOME:-}\" ] && [ -f \"$e\" ]; then if [ -L \"${HOME}/.abcd\" ]; then w=2; elif [ -n \"$(find \"$e\" -maxdepth 0 -type f -user \"$(id -un 2>/dev/null)\" ! -perm -0020 ! -perm -0002 2>/dev/null)\" ]; then while IFS= read -r ln || [ -n \"$ln\" ]; do case \"$ln\" in path=*) if [ \"${ln#path=}\" = \"$c\" ]; then o=1; fi ;; esac; done < \"$e\"; else w=1; fi; fi; if [ \"$w\" = 2 ]; then y=\"~/.abcd is a symlink, so its path-entry record is not read (replace the link with a real directory)\"; elif [ -n \"$w\" ]; then y=\"its ~/.abcd/path-entry record is not owned by you or is writable by others\"; else [ -n \"$o\" ] || y=\"~/.abcd/path-entry does not record it as the abcd installed here\"; fi; fi; if [ -n \"$y\" ]; then p=$(printf '%s' \"$c\" | tr -d '\\000-\\037\\177'); printf '%s\\n' \"abcd: ignoring the abcd found on PATH at $p because $y \u2014 a hook runs only the abcd recorded in ~/.abcd/path-entry by the documented install, from an ordinary user directory such as ~/.local/bin that neither the project nor another local user can replace; re-run the install per https://github.com/intentdriven/abcd#install to record it\" >&2; else g=\"$c\"; fi; fi; fi; if [ -n \"$g\" ]; then exec \"$g\" hook prompt-router-reset; fi; printf '%s\\n' \"abcd: the plugin binary is missing, so rules will not re-inject after compaction — install per https://github.com/intentdriven/abcd#install\" >&2; exit 1" + "command": "r=\"${CLAUDE_PLUGIN_ROOT:-}\"; [ -n \"$r\" ] || exit 0; if [ ! -x \"$r/abcd\" ] && [ -x \"$r/hooks/bootstrap.sh\" ] && [ -z \"$(find \"$r/.bootstrap.attempt\" -maxdepth 0 -mmin -10 2>/dev/null)\" ]; then : > \"$r/.bootstrap.attempt\" 2>/dev/null || true; \"$r/hooks/bootstrap.sh\" --unseen >/dev/null 2>&1 /dev/null); if [ -n \"$c\" ]; then y=\"\"; case \"$c\" in /*) dd=${c%/*}; [ -n \"$dd\" ] || dd=/; d=$(cd -P \"$dd\" 2>/dev/null && pwd -P); if [ -z \"$d\" ]; then y=\"its directory could not be resolved\"; else q=$(pwd -P); case \"$d/\" in \"$q\"/*) y=\"it lives inside the working tree\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ld \"$d\" 2>/dev/null)\" in ????????w*) y=\"its directory is world-writable\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ldL \"$c\" 2>/dev/null)\" in ????????w*) y=\"the binary itself is world-writable\" ;; esac; fi ;; *) y=\"it did not resolve to an absolute path\" ;; esac; if [ -z \"$y\" ]; then o=\"\"; w=\"\"; e=\"${HOME:-}/.abcd/path-entry\"; if [ -n \"${HOME:-}\" ] && [ -f \"$e\" ]; then if [ -L \"${HOME}/.abcd\" ]; then w=2; elif [ -n \"$(find \"$e\" -maxdepth 0 -type f -user \"$(id -un 2>/dev/null)\" ! -perm -0020 ! -perm -0002 2>/dev/null)\" ]; then while IFS= read -r ln || [ -n \"$ln\" ]; do case \"$ln\" in path=*) if [ \"${ln#path=}\" = \"$c\" ]; then o=1; fi ;; esac; done < \"$e\"; else w=1; fi; fi; if [ \"$w\" = 2 ]; then y=\"~/.abcd is a symlink, so its path-entry record is not read (replace the link with a real directory)\"; elif [ -n \"$w\" ]; then y=\"its ~/.abcd/path-entry record is not owned by you or is writable by others\"; else [ -n \"$o\" ] || y=\"~/.abcd/path-entry does not record it as the abcd installed here\"; fi; fi; if [ -n \"$y\" ]; then p=$(printf '%s' \"$c\" | tr -d '\\000-\\037\\177'); printf '%s\\n' \"abcd: ignoring the abcd found on PATH at $p because $y \u2014 a hook runs only the abcd recorded in ~/.abcd/path-entry by the documented install, from an ordinary user directory such as ~/.local/bin that neither the project nor another local user can replace; re-run the install per https://github.com/intentdriven/abcd#install to record it\" >&2; else g=\"$c\"; fi; fi; fi; if [ -n \"$g\" ]; then exec \"$g\" hook prompt-router-reset; fi; printf '%s\\n' \"abcd: the plugin binary is missing, so rules will not re-inject after compaction — install per https://github.com/intentdriven/abcd#install\" >&2; exit 1" } ] } diff --git a/internal/core/ahoy/ahoy.go b/internal/core/ahoy/ahoy.go index 4773e5374..f0437468c 100644 --- a/internal/core/ahoy/ahoy.go +++ b/internal/core/ahoy/ahoy.go @@ -56,6 +56,10 @@ const ( // --yes: a routing table decides which model every delegated step asks // for, so only an answered prompt accepts one. OracleRouting GapCategory = "oracle-routing" + // DrainRule (drain_rule.go) covers adding the repository's drain + // eligibility record. Its gap is advisory and never written under --yes: + // the record decides what an unattended agent may do in the repository, so + // only an answered prompt adds one. ) // Gap is one detected discrepancy between desired and actual state. diff --git a/internal/core/ahoy/apply.go b/internal/core/ahoy/apply.go index 69652a7e5..cfd90ec18 100644 --- a/internal/core/ahoy/apply.go +++ b/internal/core/ahoy/apply.go @@ -10,6 +10,7 @@ import ( "path/filepath" "github.com/intentdriven/abcd/internal/fsutil" + "slices" "sort" "strings" "time" @@ -119,7 +120,7 @@ func install(cwd string, opts InstallOptions, p Prompter) (InstallResult, error) modeForced := modeWouldChange(opts, det, binTargetPath) if len(actionable(det.Gaps)) == 0 && - !(!opts.Yes && len(optionalPending(det.Gaps)) > 0) && + !(!opts.Yes && len(optionalAskable(det.Gaps, p)) > 0) && !overridesWouldChange(abs, opts.ValueOverrides) && !attributionWouldChange(abs, opts) && !modeForced { @@ -136,12 +137,12 @@ func install(cwd string, opts InstallOptions, p Prompter) (InstallResult, error) return InstallResult{ Status: "partial", Notes: malformedConfigNotes(cfgErr, opts.ValueOverrides), - OptionalSkipped: optionalSkipped(opts, det.Gaps), + OptionalSkipped: optionalSkipped(opts, det.Gaps, p), }, nil } return InstallResult{ Status: "already_up_to_date", - OptionalSkipped: optionalSkipped(opts, det.Gaps), + OptionalSkipped: optionalSkipped(opts, det.Gaps, p), }, nil } @@ -186,6 +187,8 @@ func install(cwd string, opts InstallOptions, p Prompter) (InstallResult, error) ac.stepStatusLine() // After the status line, the order the consent questions are asked in. ac.stepOracleRouting() + // After the routing offers: the last of the repository consent questions. + ac.stepDrainRule() ac.stepRules() ac.stepVersionStamp() // Before the pin: an identity mended here is the one the pin then records. @@ -221,7 +224,7 @@ func install(cwd string, opts InstallOptions, p Prompter) (InstallResult, error) Remaining: remaining, DeclinedCategories: declined, Notes: ac.notes, - OptionalSkipped: optionalSkipped(opts, final.Gaps), + OptionalSkipped: optionalSkipped(opts, final.Gaps, p), writeKinds: ac.writeKinds, }, nil } @@ -1780,20 +1783,35 @@ const credentialAtRestGapID = "history.credential_at_rest" // optionalGapIDs are the advisory gaps install closes only against an answered // prompt, never under --yes: the identity pin (see stepIdentityPin), the -// status-line offer (see stepStatusLine) and the two model-tier routing offers -// (see stepOracleRouting). In the order they are reported. -var optionalGapIDs = []string{OptionalPinGapID, StatusLineOfferGapID, OracleRoutingMachineGapID, OracleRoutingRepoGapID} - -// optionalSkipped lists the optional gaps a --yes run left un-applied. --yes -// approves every resolvable category but never adopts the identity pin or -// wires the status line, so the skip is deliberate — and therefore has to be -// reported rather than left ambient (iss-166). Outside --yes each is offered -// as a confirmation, so nothing is skipped silently and the list stays empty. -func optionalSkipped(opts InstallOptions, gaps []Gap) []string { - if !opts.Yes { +// status-line offer (see stepStatusLine), the two model-tier routing offers +// (see stepOracleRouting) and the drain eligibility record (see +// stepDrainRule). In the order they are reported. +var optionalGapIDs = []string{OptionalPinGapID, StatusLineOfferGapID, OracleRoutingMachineGapID, OracleRoutingRepoGapID, DrainRuleOfferGapID} + +// optionalSkipped lists the optional gaps a run left un-applied without asking. +// --yes approves every resolvable category but never adopts the identity pin +// or wires the status line, so the skip is deliberate — and therefore has to +// be reported rather than left ambient (iss-166). Outside --yes each is offered +// as a confirmation, except the drain eligibility record off a terminal (see +// stepDrainRule), so that one is listed and nothing is skipped silently. +func optionalSkipped(opts InstallOptions, gaps []Gap, p Prompter) []string { + if opts.Yes { + return optionalPending(gaps) + } + if atTerminal(p) || !gapIDSet(gaps)[DrainRuleOfferGapID] { return nil } - return optionalPending(gaps) + return []string{DrainRuleOfferGapID} +} + +// optionalAskable is optionalPending less the offers that will not be asked of +// p: the drain eligibility record is offered only to a person at a terminal. +func optionalAskable(gaps []Gap, p Prompter) []string { + pending := optionalPending(gaps) + if atTerminal(p) { + return pending + } + return slices.DeleteFunc(pending, func(id string) bool { return id == DrainRuleOfferGapID }) } // optionalPending reports which of the optional gaps are the remaining work. @@ -1857,6 +1875,7 @@ var categoryPromptOrder = []GapCategory{ ConfigChange, StatusLine, OracleRouting, + DrainRule, UserState, PluginOwned, } @@ -1910,6 +1929,13 @@ func resolveApproval(gaps []Gap, opts InstallOptions, p Prompter) (map[GapCatego approved[c] = true } default: + // The drain eligibility record is offered only to a person at a + // terminal (see stepDrainRule): off one its category is neither asked + // nor counted as declined, so a piped answer stream keeps the order it + // had before the offer existed. + if !atTerminal(p) { + delete(present, DrainRule) + } for _, c := range presentInPromptOrder(present) { if c == Dependency && opts.ApproveDependency { continue // answered by the named tool; approved below diff --git a/internal/core/ahoy/defaults/claude-md-marker-block.md b/internal/core/ahoy/defaults/claude-md-marker-block.md index 0f09777e6..8327ab194 100644 --- a/internal/core/ahoy/defaults/claude-md-marker-block.md +++ b/internal/core/ahoy/defaults/claude-md-marker-block.md @@ -76,8 +76,9 @@ trust rule for load experiments: one owned process group killed together through a re-checked handle and never by pattern, clean proven by what is running, and explicit consent with a cap below the core count on a live development machine. `SHELL` is the teaching half of the shell-hazard guard: it is generated from the -same bundled hazard registry `abcd guard` enforces, one rule per entry (the -command, why it is dangerous, and what to run instead), and recalls on the +same hazard registry `abcd guard` enforces in the repository, one rule per entry +(the command, why it is dangerous, and what to run instead; a rule from the +repository's own `.abcd/guard.json` is marked `(repo)`), and recalls on the commands the registry names (`rm`, `git push`, `pkill`, …) and on shell work in general, so an agent is taught the safe form before a host with hooks would refuse the command and a host without hooks still teaches it. diff --git a/internal/core/ahoy/detect.go b/internal/core/ahoy/detect.go index 42a010106..bf809a179 100644 --- a/internal/core/ahoy/detect.go +++ b/internal/core/ahoy/detect.go @@ -105,6 +105,7 @@ func Detect(cwd string) (DetectionResult, error) { gaps = append(gaps, detectPathSymlink(abs, pluginRoot, pluginOK)...) gaps = append(gaps, detectStatusLine(harness)...) gaps = append(gaps, detectOracleRouting(abs)...) + gaps = append(gaps, detectDrainRule(abs)...) gaps = append(gaps, detectProviderAdapter(abs)...) gaps = append(gaps, detectHookManifest(pluginRoot, pluginOK)...) gaps = append(gaps, detectVersion(abs)...) diff --git a/internal/core/ahoy/drain_rule.go b/internal/core/ahoy/drain_rule.go new file mode 100644 index 000000000..7f2380667 --- /dev/null +++ b/internal/core/ahoy/drain_rule.go @@ -0,0 +1,104 @@ +package ahoy + +// The drain eligibility record `ahoy install` offers (the product thinker's +// ruling BX2 of 2026-09-29, verbatim: "the PROJECT MUST HOLD the eligibility +// decision in its own record (e.g. added at setup); drain refuses there until +// it does"). `abcd drain` reads which open issues it may take alone from an +// accepted decision record in the repository's own store (core/drainrule), and +// refuses a repository without one. Setup is where one is added: +// +// - detection raises an optional repository gap while the store holds no +// accepted record carrying the drain fields; a record that states the rule +// badly is not offered a second one, since the drain names what is wrong +// with the record it has; +// - the offer states abcd's strict baseline and, on the person's yes, mints +// it through the decision store's own seam as an accepted record, which is +// committed with the repository and decides for everyone who drains it. +// +// It is asked only of a person at a terminal (the itd-131 precedent the git +// identity step set): --yes approves the category but never writes the record, +// and off a terminal (a pipe, a routine, CI) neither the category nor the offer +// is asked, because the record decides what an unattended agent may do in this +// repository and a scripted yes is not a person's. Adding the question to a +// piped answer stream would also shift every later answer onto the wrong +// question. Either way it is reported under optional_skipped. A decline writes +// nothing and records nothing, so the next install offers again. The offer +// only ever writes the baseline; loosening a floor is an edit a person makes +// to the record. + +import ( + "errors" + "path/filepath" + "strings" + + "github.com/intentdriven/abcd/internal/core/decide" + "github.com/intentdriven/abcd/internal/core/drainrule" +) + +// DrainRule covers adding the repository's drain eligibility record. +const DrainRule GapCategory = "drain-rule" + +// DrainRuleOfferGapID is the offer of the drain eligibility record. +const DrainRuleOfferGapID = "drain_rule.offered" + +// drainRuleQuestionTail ends the offer, so a scripted answer stream and the +// transcript can tell it apart. +const drainRuleQuestionTail = "Add this rule to this repository's decision records?" + +// detectDrainRule raises the offer while the repository holds no record of the +// rule. +func detectDrainRule(cwd string) []Gap { + if _, err := drainrule.Load(cwd); !errors.Is(err, drainrule.ErrUnrecorded) { + return nil + } + return []Gap{{ + ID: DrainRuleOfferGapID, Category: DrainRule, Scope: "repo", + Title: "no drain eligibility record in this repository", + Detail: "abcd drain takes an open issue alone only under this repository's own recorded rule, and refuses " + + "to run until an accepted decision record states it.", + FixHint: "ahoy install offers abcd's strict baseline as an accepted decision record and writes it only on " + + "consent; --yes never accepts it. Or add the four drain_ fields to an accepted record by hand.", + Required: false, Resolvable: true, + }} +} + +// stepDrainRule makes the offer. It never runs under --yes, and never off a +// terminal. +func (a *applyCtx) stepDrainRule() { + if a.autoYes || !atTerminal(a.prompter) || !a.approved[DrainRule] || !a.has(DrainRuleOfferGapID) { + return + } + if !a.prompter.Confirm(drainRuleQuestion()) { + return + } + // Re-read at the moment of writing: a record that appeared while the + // question was open is the repository's rule, and a second is never added. + if _, err := drainrule.Load(a.cwd); !errors.Is(err, drainrule.ErrUnrecorded) { + a.refuse("the drain eligibility record was not written: the repository's decision store changed while the question was open, and is left as it is.") + return + } + d, err := decide.CreateStated(a.cwd, decide.Stated{ + Title: drainrule.ProposalTitle, + Frontmatter: drainrule.ProposalFrontmatter(), + Body: drainrule.ProposalBody(), + }) + if err != nil { + a.refuse("could not write the drain eligibility record (" + errText(err) + "); nothing was written.") + return + } + a.note(writeDrainRule, filepath.Join(a.cwd, filepath.FromSlash(d.Path))) +} + +// drainRuleQuestion is the reason, the rule and the question: core never +// prints, so the rule travels as the text of the confirm. +func drainRuleQuestion() string { + b := drainrule.Baseline() + return "abcd drain works the open issue ledger unattended, and takes an issue alone only under a rule this " + + "repository records for itself; until it does, the drain refuses to run here. abcd's strict baseline: " + + "take an issue only when its category is one of " + strings.Join(b.Categories, ", ") + + ", its severity is " + strings.Join(b.Severities, " or ") + + ", it carries a remedy, and nothing open blocks it; every security issue, and every major or critical " + + "one, is a person's. Accepting writes this rule as an accepted decision record under " + + drainrule.ADRsRelDir + "/, committed with the repository, where it can be edited; loosening it is " + + "named on every drain. Declining writes nothing. " + drainRuleQuestionTail +} diff --git a/internal/core/ahoy/drain_rule_test.go b/internal/core/ahoy/drain_rule_test.go new file mode 100644 index 000000000..541c93fcc --- /dev/null +++ b/internal/core/ahoy/drain_rule_test.go @@ -0,0 +1,231 @@ +package ahoy + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/drainrule" +) + +// The setup offer of the drain eligibility record (ruling BX2: "the PROJECT +// MUST HOLD the eligibility decision in its own record (e.g. added at setup); +// drain refuses there until it does"). The offer writes abcd's strict baseline +// as an accepted decision record, and only on the person's own yes. + +// terminalScripted is a scriptedPrompter a person answers at a terminal, the +// only prompter the drain rule offer is put to. +type terminalScripted struct{ *scriptedPrompter } + +func (terminalScripted) AtTerminal() bool { return true } + +// drainRulePrompter approves every category question and answers the drain +// rule offer as told, at a terminal; every other offer is declined. +func drainRulePrompter(yes bool) terminalScripted { + return terminalScripted{&scriptedPrompter{confirm: func(q string) bool { + switch { + case strings.HasPrefix(q, "Apply "): + return true + case strings.Contains(q, drainRuleQuestionTail): + return yes + } + return false + }}} +} + +// TestDrainRuleOfferWritesTheBaselineOnYes: a managed repository without the +// record is offered it, the question states the rule, and a yes writes an +// accepted record that the drain's own reader reads as the strict baseline, +// after which the offer is not made again. +func TestDrainRuleOfferWritesTheBaselineOnYes(t *testing.T) { + setupHermetic(t) + repo := installedRepo(t) + det, err := Detect(repo) + if err != nil { + t.Fatal(err) + } + if !hasGap(det.Gaps, DrainRuleOfferGapID) { + t.Fatalf("a repository without the record is not offered it: gaps %v", det.Gaps) + } + + p := drainRulePrompter(true) + res, err := Install(repo, InstallOptions{}, p) + if err != nil { + t.Fatal(err) + } + asked := false + for _, q := range p.asked { + if strings.Contains(q, drainRuleQuestionTail) { + asked = true + for _, want := range []string{"nitpick", "minor", "security", "remedy", "abcd drain"} { + if !strings.Contains(q, want) { + t.Errorf("the offer does not state %q:\n%s", want, q) + } + } + } + } + if !asked { + t.Fatalf("the drain rule was not offered; asked %q", p.asked) + } + r, err := drainrule.Load(repo) + if err != nil { + t.Fatalf("the written record does not load: %v (writes %v, notes %v)", err, res.Writes, res.Notes) + } + if len(r.Loosened) != 0 || r.Security != drainrule.SecurityHandBack || strings.Join(r.Severities, ",") != "nitpick,minor" { + t.Errorf("the offer wrote a rule that is not the baseline: %+v", r) + } + raw, err := os.ReadFile(filepath.Join(repo, filepath.FromSlash(r.Path))) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), "\nstatus: accepted\n") || !strings.Contains(string(raw), "## Decision") { + t.Errorf("the record is not an accepted decision record:\n%s", raw) + } + if !containsString(res.Writes, r.Path) { + t.Errorf("the write is not reported: %v", res.Writes) + } + det, _ = Detect(repo) + if hasGap(det.Gaps, DrainRuleOfferGapID) { + t.Error("a repository holding the record is offered it again") + } +} + +// TestDrainRuleOfferDeclineWritesNothing: a no writes nothing and is not +// persisted, so the next install offers again. +func TestDrainRuleOfferDeclineWritesNothing(t *testing.T) { + setupHermetic(t) + repo := installedRepo(t) + if _, err := Install(repo, InstallOptions{}, drainRulePrompter(false)); err != nil { + t.Fatal(err) + } + if _, err := drainrule.Load(repo); !errors.Is(err, drainrule.ErrUnrecorded) { + t.Fatalf("a declined offer left a rule: %v", err) + } + det, _ := Detect(repo) + if !hasGap(det.Gaps, DrainRuleOfferGapID) { + t.Error("a decline was persisted; the offer must stand for the next install") + } +} + +// TestDrainRuleOfferYesFlagSkipsAndSaysSo: --yes approves the category but +// never writes the record, which decides what an unattended agent may do, and +// names it under optional_skipped, as the other consent offers are. +func TestDrainRuleOfferYesFlagSkipsAndSaysSo(t *testing.T) { + setupHermetic(t) + repo := t.TempDir() + if err := os.Mkdir(filepath.Join(repo, ".git"), 0o755); err != nil { + t.Fatal(err) + } + res, err := Install(repo, installOpts(), RefusingPrompter{}) + if err != nil { + t.Fatal(err) + } + if !containsString(res.OptionalSkipped, DrainRuleOfferGapID) { + t.Errorf("optional_skipped = %v, want it to name %s", res.OptionalSkipped, DrainRuleOfferGapID) + } + if _, err := drainrule.Load(repo); !errors.Is(err, drainrule.ErrUnrecorded) { + t.Errorf("--yes wrote a drain rule: %v", err) + } +} + +// TestDrainRuleOfferLeavesAMalformedRecordAlone: a repository that states a +// rule, however badly, is not offered a second one; the drain names what is +// wrong with the record it has. +func TestDrainRuleOfferLeavesAMalformedRecordAlone(t *testing.T) { + setupHermetic(t) + repo := installedRepo(t) + dir := filepath.Join(repo, filepath.FromSlash(drainrule.ADRsRelDir)) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + body := "---\nid: adr-7\nstatus: accepted\ndrain_categories: [bug]\n---\n" + if err := os.WriteFile(filepath.Join(dir, "0007-rule.md"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + det, err := Detect(repo) + if err != nil { + t.Fatal(err) + } + if hasGap(det.Gaps, DrainRuleOfferGapID) { + t.Error("a repository stating a malformed rule is offered a second record") + } +} + +// pipedPrompter is a scripted answer stream off a pipe: each confirm takes the +// next answer, and past the end reads EOF, a no, as the CLI's stdin prompter +// does. It is not a TerminalPrompter, so no person is at a terminal. +type pipedPrompter struct { + answers []bool + asked []string +} + +func (p *pipedPrompter) Confirm(q string) bool { + p.asked = append(p.asked, q) + if len(p.asked) > len(p.answers) { + return false + } + return p.answers[len(p.asked)-1] +} + +func (p *pipedPrompter) Prompt(_ string, _ []string, def string) string { return def } + +// TestAPipedInstallStreamIsNotShiftedByTheDrainRuleOffer: a scripted `ahoy +// install` answers its questions positionally, so a question added to the +// sequence hands every later answer to the wrong question, and a yes meant for +// another question would write the drain rule, which decides what an unattended +// agent may change. Off a terminal the offer is never asked (the itd-131 +// precedent): a stream of the answer count a repository holding the record is +// asked gets the same questions in the same order, the last of them still gets +// its answer, no record is written, and optional_skipped names the offer. +func TestAPipedInstallStreamIsNotShiftedByTheDrainRuleOffer(t *testing.T) { + var control []string + t.Run("control: the repository holds the record", func(t *testing.T) { + setupHermetic(t) + repo := installedRepo(t) + dir := filepath.Join(repo, filepath.FromSlash(drainrule.ADRsRelDir)) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + body := "---\nid: adr-2609300000000009\nstatus: accepted\n" + drainrule.ProposalFrontmatter() + "---\n" + if err := os.WriteFile(filepath.Join(dir, "2609300000000009-drain-rule.md"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + p := &pipedPrompter{answers: make([]bool, 64)} + for i := range p.answers { + p.answers[i] = true + } + if _, err := Install(repo, InstallOptions{}, p); err != nil { + t.Fatal(err) + } + control = append(control, p.asked...) + }) + if len(control) == 0 { + t.Fatal("the control install asked nothing; the stream has nothing to shift") + } + setupHermetic(t) + repo := installedRepo(t) + p := &pipedPrompter{answers: make([]bool, len(control))} + for i := range p.answers { + p.answers[i] = true + } + res, err := Install(repo, InstallOptions{}, p) + if err != nil { + t.Fatal(err) + } + if strings.Join(p.asked, "\n") != strings.Join(control, "\n") { + t.Errorf("the piped stream was shifted:\n got %q\nwant %q", p.asked, control) + } + for _, q := range p.asked { + if strings.Contains(q, string(DrainRule)) || strings.Contains(q, drainRuleQuestionTail) { + t.Errorf("a pipe was asked the drain rule: %q", q) + } + } + if _, err := drainrule.Load(repo); !errors.Is(err, drainrule.ErrUnrecorded) { + t.Errorf("a piped stream wrote a drain rule: %v", err) + } + if !containsString(res.OptionalSkipped, DrainRuleOfferGapID) { + t.Errorf("optional_skipped = %v, want it to name %s", res.OptionalSkipped, DrainRuleOfferGapID) + } +} diff --git a/internal/core/ahoy/install_summary.go b/internal/core/ahoy/install_summary.go index c23d529a5..f0995813e 100644 --- a/internal/core/ahoy/install_summary.go +++ b/internal/core/ahoy/install_summary.go @@ -33,6 +33,7 @@ const ( writeCommandEntry writeKind = "command-entry" writeStatusLine writeKind = "status-line" writeRouting writeKind = "routing" + writeDrainRule writeKind = "drain-rule" writeRules writeKind = "rules" writeIdentityPin writeKind = "identity-pin" writeGitIdentity writeKind = "git-identity" @@ -45,7 +46,7 @@ var allWriteKinds = []writeKind{ writeSettings, writeGitignore, writeLocalTier, writeNameGuard, writePrivateNames, writeDocsCheck, writeAttributionHook, writeRules, writeConventionsBlock, writeConventionsBlockRemoved, writeGitIdentity, writeIdentityPin, writeArtefactKind, writeCommandEntry, writeSessionStore, - writeStatusLine, writeRouting, + writeStatusLine, writeRouting, writeDrainRule, } // writeKindHelp is the plain-language explanation of each kind of write. @@ -135,6 +136,11 @@ var writeKindHelp = map[writeKind]SummaryItem{ Why: "Larger models cost more; the table keeps the expensive ones for the steps that need them.", Action: "Nothing. Edit the saved table if you want a different split.", }, + writeDrainRule: { + What: "Recorded which open issues abcd drain may fix without asking you, as a decision record in this repository.", + Why: "abcd drain refuses to run in a repository until it records that rule; this one is abcd's strict default.", + Action: "Commit the record. Edit its drain_ fields to change the rule; abcd drain names any loosening.", + }, } // unexplainedWriteHelp is what a write reaches the person as when it carries no @@ -185,6 +191,11 @@ var declinedCategoryHelp = map[GapCategory]SummaryItem{ Why: "Review steps use whatever model your assistant picks by default.", Action: "Nothing. Run abcd ahoy install again if you want the table.", }, + DrainRule: { + What: "You declined recording which open issues abcd drain may fix without asking you.", + Why: "abcd drain refuses to run in this repository until the rule is recorded.", + Action: "Nothing, unless you want to drain; then run abcd ahoy install again and answer y.", + }, } // optionalSkippedHelp explains each optional step an unattended run left alone. @@ -209,6 +220,11 @@ var optionalSkippedHelp = map[string]SummaryItem{ Why: "The table decides which model, and so what cost, each review step asks for, so it needs your own yes.", Action: "Run abcd ahoy install without --yes and answer the question about the table.", }, + DrainRuleOfferGapID: { + What: "The rule for which open issues abcd drain may fix without asking you was not recorded.", + Why: "The rule decides what an unattended agent may change in this repository, so it needs your own yes; until it is recorded, abcd drain refuses to run here.", + Action: "Run abcd ahoy install at a terminal, without --yes, and answer the question about the drain rule.", + }, } // remainingHelp explains the required work a run left outstanding. diff --git a/internal/core/ahoy/oracle_routing_test.go b/internal/core/ahoy/oracle_routing_test.go index 4b3bfe51e..f9f237c10 100644 --- a/internal/core/ahoy/oracle_routing_test.go +++ b/internal/core/ahoy/oracle_routing_test.go @@ -197,7 +197,7 @@ func TestUninstallLeavesTheRoutingTables(t *testing.T) { // TestOracleRoutingIsAskedAfterTheStatusLine pins the consent order the spec // names: …, status-line, oracle-routing, user-state, … func TestOracleRoutingIsAskedAfterTheStatusLine(t *testing.T) { - want := []GapCategory{Dependency, SafeAutocreate, ConfigChange, StatusLine, OracleRouting, UserState, PluginOwned} + want := []GapCategory{Dependency, SafeAutocreate, ConfigChange, StatusLine, OracleRouting, DrainRule, UserState, PluginOwned} if !reflect.DeepEqual(categoryPromptOrder, want) { t.Fatalf("categoryPromptOrder = %v, want %v", categoryPromptOrder, want) } diff --git a/internal/core/ahoy/prompt_order_test.go b/internal/core/ahoy/prompt_order_test.go index 6124dca5e..ecc7fc2b5 100644 --- a/internal/core/ahoy/prompt_order_test.go +++ b/internal/core/ahoy/prompt_order_test.go @@ -12,8 +12,12 @@ import ( type recordingPrompter struct { asked []string confirm bool + // terminal makes it a TerminalPrompter a person answers at a terminal. + terminal bool } +func (p *recordingPrompter) AtTerminal() bool { return p.terminal } + func (p *recordingPrompter) Confirm(q string) bool { p.asked = append(p.asked, q) return p.confirm @@ -32,6 +36,7 @@ func allCategoryGaps() []Gap { {ID: "skeleton.e", Category: SafeAutocreate, Resolvable: true}, {ID: "statusline.f", Category: StatusLine, Resolvable: true}, {ID: "oracle_routing.g", Category: OracleRouting, Resolvable: true}, + {ID: "drain_rule.h", Category: DrainRule, Resolvable: true}, } } @@ -50,23 +55,43 @@ func TestResolveApprovalPromptsInCanonicalOrder(t *testing.T) { "Apply config-change changes?", "Apply status-line changes?", "Apply oracle-routing changes?", + "Apply drain-rule changes?", "Apply user-state changes?", "Apply plugin-owned changes?", } for i := 0; i < 64; i++ { - p := &recordingPrompter{confirm: true} + p := &recordingPrompter{confirm: true, terminal: true} resolveApproval(allCategoryGaps(), InstallOptions{}, p) if strings.Join(p.asked, "|") != strings.Join(want, "|") { t.Fatalf("run %d asked in a different order:\n got %v\nwant %v", i, p.asked, want) } } + // Off a terminal the drain-rule question is not asked (stepDrainRule), and + // not counted as declined: the rest keep their order, so a piped stream + // written before the offer existed still lines up. + offTerminal := make([]string, 0, len(want)) + for _, q := range want { + if q != "Apply drain-rule changes?" { + offTerminal = append(offTerminal, q) + } + } + p := &recordingPrompter{confirm: false} + _, declined := resolveApproval(allCategoryGaps(), InstallOptions{}, p) + if strings.Join(p.asked, "|") != strings.Join(offTerminal, "|") { + t.Fatalf("off a terminal:\n got %v\nwant %v", p.asked, offTerminal) + } + for _, c := range declined { + if c == string(DrainRule) { + t.Fatalf("off a terminal the unasked drain-rule category is reported declined: %v", declined) + } + } } // TestCategoryPromptOrderCoversEveryCategory keeps the canonical order honest: // a category missing from it would be asked in the sorted-tail fallback, which // is still deterministic but no longer the order the apply pass acts in. func TestCategoryPromptOrderCoversEveryCategory(t *testing.T) { - all := []GapCategory{SafeAutocreate, ConfigChange, PluginOwned, Dependency, UserState, StatusLine, OracleRouting} + all := []GapCategory{SafeAutocreate, ConfigChange, PluginOwned, Dependency, UserState, StatusLine, OracleRouting, DrainRule} for _, c := range all { found := false for _, oc := range categoryPromptOrder { @@ -93,12 +118,12 @@ func TestResolveApprovalAsksUnknownCategoriesLast(t *testing.T) { Gap{ID: "alpha.a", Category: GapCategory("alpha"), Resolvable: true}, ) for i := 0; i < 32; i++ { - p := &recordingPrompter{confirm: true} + p := &recordingPrompter{confirm: true, terminal: true} resolveApproval(gaps, InstallOptions{}, p) - if len(p.asked) != 9 { - t.Fatalf("asked %d questions, want 9: %v", len(p.asked), p.asked) + if len(p.asked) != 10 { + t.Fatalf("asked %d questions, want 10: %v", len(p.asked), p.asked) } - tail := strings.Join(p.asked[7:], "|") + tail := strings.Join(p.asked[8:], "|") if tail != "Apply alpha changes?|Apply zeta changes?" { t.Fatalf("run %d: unknown categories not asked last and sorted: %v", i, p.asked) } diff --git a/internal/core/ahoy/provider_adapter.go b/internal/core/ahoy/provider_adapter.go index db3490fe2..84d0aefe3 100644 --- a/internal/core/ahoy/provider_adapter.go +++ b/internal/core/ahoy/provider_adapter.go @@ -13,6 +13,8 @@ package ahoy // every delegated step runs on the host. import ( + "strings" + "github.com/intentdriven/abcd/internal/core/layered" "github.com/intentdriven/abcd/internal/core/oracle" "github.com/intentdriven/abcd/internal/fsutil" @@ -26,6 +28,10 @@ const ( // ProviderAdapterRefusedGapID names a provider configuration the adapter // refuses, so it is never silently unused. ProviderAdapterRefusedGapID = "oracle_api.config_refused" + // ProviderAdapterRouteSkippedGapID names each route the configuration + // read skipped, one diagnostic per line of its Detail, so a skip is never + // silent at the bare board. + ProviderAdapterRouteSkippedGapID = "oracle_api.route_skipped" ) func detectProviderAdapter(cwd string) []Gap { @@ -40,15 +46,38 @@ func detectProviderAdapter(cwd string) []Gap { Required: false, Resolvable: false, }} } - if len(cfg.Providers()) > 0 { + var gaps []Gap + if len(cfg.Providers()) == 0 { + gaps = append(gaps, Gap{ + ID: ProviderAdapterGapID, Category: UserState, Scope: "machine", + Title: "no OpenAI-compatible provider configured (optional)", + Detail: oracle.AdapterExplanation, + FixHint: "`abcd ahoy --providers` walks through the setup and where the key can live; `abcd ahoy connect` sets one up. " + + "Declining changes nothing: every delegated step runs on the host, the only route.", + Required: false, Resolvable: false, + }) + } + return append(gaps, skippedRoutes(cfg.Diagnostics)...) +} + +// skippedRoutes is the gap naming each route the configuration read skipped +// (a role outside the roster, a route to a provider this machine has not +// configured, a repository's route to a provider that holds a key), one +// diagnostic per line of its Detail. It is advisory: the rest of the +// configuration applies, so nothing is required of the person. +func skippedRoutes(diagnostics []string) []Gap { + if len(diagnostics) == 0 { return nil } + lines := make([]string, len(diagnostics)) + for i, d := range diagnostics { + lines[i] = termsafe.Sanitize(fsutil.RedactHome(d)) + } return []Gap{{ - ID: ProviderAdapterGapID, Category: UserState, Scope: "machine", - Title: "no OpenAI-compatible provider configured (optional)", - Detail: oracle.AdapterExplanation, - FixHint: "`abcd ahoy --providers` walks through the setup and where the key can live; `abcd ahoy connect` sets one up. " + - "Declining changes nothing: every delegated step runs on the host, the only route.", + ID: ProviderAdapterRouteSkippedGapID, Category: UserState, Scope: "machine", + Title: "a provider route is skipped", + Detail: strings.Join(lines, "\n"), + FixHint: "Each line names the route, why it is skipped and where to change it; `abcd ahoy --providers` shows the routes in force.", Required: false, Resolvable: false, }} } diff --git a/internal/core/ahoy/provider_adapter_test.go b/internal/core/ahoy/provider_adapter_test.go index 440c23d6d..d27ce5f7a 100644 --- a/internal/core/ahoy/provider_adapter_test.go +++ b/internal/core/ahoy/provider_adapter_test.go @@ -94,3 +94,48 @@ func TestARefusedProviderConfigurationIsNamed(t *testing.T) { t.Fatalf("the gap carries the home path: %s", g.Detail) } } + +// TestASkippedProviderRouteIsNamed: a route the configuration read skips (a +// repository's route to a provider that holds a key, ruling CD2 of +// 2026-09-29) is an optional gap naming the route, never silence at the bare +// board, and it costs nothing else: the configuration still loads. +func TestASkippedProviderRouteIsNamed(t *testing.T) { + home, _ := setupHermetic(t) + repo := installedRepo(t) + if err := os.MkdirAll(filepath.Join(home, ".abcd"), 0o700); err != nil { + t.Fatal(err) + } + cfg := `{"oracle":{"api":{"openrouter":{"base_url":"https://openrouter.ai/api/v1","key":"openrouter","models":["typesafe/jev-1.13"]}}}}` + if err := os.WriteFile(filepath.Join(home, ".abcd", "config.json"), []byte(cfg), 0o600); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(repo, ".abcd"), 0o755); err != nil { + t.Fatal(err) + } + route := `{"oracle":{"roles":{"scribe":"openrouter/typesafe/jev-1.13"}}}` + if err := os.WriteFile(filepath.Join(repo, ".abcd", "config.json"), []byte(route), 0o644); err != nil { + t.Fatal(err) + } + det, err := Detect(repo) + if err != nil { + t.Fatal(err) + } + if _, ok := providerGap(det.Gaps, ProviderAdapterRefusedGapID); ok { + t.Fatal("one skipped repository route refused the whole configuration") + } + g, ok := providerGap(det.Gaps, ProviderAdapterRouteSkippedGapID) + if !ok { + t.Fatalf("no %s gap in %v", ProviderAdapterRouteSkippedGapID, gapIDs(det.Gaps)) + } + if g.Required || g.Resolvable || g.Scope != "machine" { + t.Fatalf("gap = %+v; want optional, not resolvable by install, machine-scoped", g) + } + for _, want := range []string{"oracle.roles.scribe", "openrouter/typesafe/jev-1.13", "holds a key", "skipped"} { + if !strings.Contains(g.Detail, want) { + t.Errorf("the gap does not name %q:\n%s", want, g.Detail) + } + } + if strings.Contains(g.Detail, home) { + t.Fatalf("the gap carries the home path: %s", g.Detail) + } +} diff --git a/internal/core/ahoy/remote.go b/internal/core/ahoy/remote.go index 4d73458bd..57dc1bad3 100644 --- a/internal/core/ahoy/remote.go +++ b/internal/core/ahoy/remote.go @@ -221,7 +221,14 @@ func RemoteRead(cwd string) (RemoteResult, error) { } observed, merge, err := ghSecurityState(abs, res.Repo) if err != nil { - return refuseRemote(res, "could not read "+res.Repo+"'s security settings, so nothing is known about what would change: "+errText(err)), nil + res = refuseRemote(res, "could not read "+res.Repo+"'s security settings, so nothing is known about what would change: "+errText(err)) + // The read installs nothing (looking is never acting); it names the + // verb that offers the install. + var missing *tools.MissingError + if errors.As(err, &missing) && missing.Explanation.Tool == "gh" { + res.Notes = append(res.Notes, "abcd ahoy remote apply offers to install it, and runs the step only on a yes typed at a terminal") + } + return res, nil } res.Observed, res.Merge = observed, merge res.Changes = pendingChanges(observed) @@ -248,7 +255,7 @@ func RemoteRead(cwd string) (RemoteResult, error) { // the default), which is also what lets the confirmation name exactly what would // change. Then secret scanning, then push protection: GitHub refuses push protection // on a repo whose secret scanning is off. -func RemoteApply(cwd string, p Prompter) (RemoteResult, error) { +func RemoteApply(cwd string, p Prompter, confirmTool tools.Confirm) (RemoteResult, error) { if p == nil { p = RefusingPrompter{} } @@ -256,6 +263,15 @@ func RemoteApply(cwd string, p Prompter) (RemoteResult, error) { if done { return res, nil } + // The gh offer comes after the gates, so a verb that would refuse anyway + // never asks to install anything, and before the read, which needs gh. + offer, ok := OfferGH(abs, confirmTool) + if !ok { + res = refuseRemote(res, offer[0]) + res.Notes = append(res.Notes, offer[1:]...) + return res, nil + } + res.Notes = append(res.Notes, offer...) observed, merge, err := ghSecurityState(abs, res.Repo) if err != nil { return refuseRemote(res, "could not read "+res.Repo+"'s security settings; nothing was changed, because a write over an unknown state is a guess: "+errText(err)), nil @@ -314,6 +330,32 @@ func RemoteApply(cwd string, p Prompter) (RemoteResult, error) { return res, nil } +// OfferGH is the explain-then-install mode for gh at a remote write (itd-63 +// criterion 2; the product thinker's DQ3 ruling, 2026-09-29: offer to install +// gh on an explicit yes). With gh on PATH it says nothing. Otherwise it hands +// the registry's explanation to confirm, which the front door answers yes only +// for a person at a terminal, and runs the registry's step only on that yes. +// +// ok is true only when gh was installed AND verified; the notes then say what +// ran. Every other outcome (a no, no one asked, CI, a failed or unverified +// step) is ok=false with the reason first, then the explanation with the exact +// command, so the caller refuses loudly and reaches no gh call on a half-done +// install. +func OfferGH(guard string, confirm tools.Confirm) (notes []string, ok bool) { + if onPath("gh") { + return nil, true + } + res := newToolInstaller(guard).Install("gh", tools.GitHubSettings, confirm) + if res.Ran && res.Installed && res.Verified { + return []string{res.Summary() + "; the install does not sign gh in, so if GitHub refuses the request, run gh auth login"}, true + } + notes = []string{"the GitHub CLI (gh) is not on PATH: " + res.Summary()} + if !res.Ran { + notes = append(notes, tools.Explain("gh", tools.GitHubSettings).Lines()...) + } + return notes, false +} + // remotePrepare runs the three gates both verbs share and returns done=true when // one of them settled the outcome. The gates are the adr-44 boundary: after this // returns done=false, and only then, may a request leave the machine. diff --git a/internal/core/ahoy/remote_gh_offer_test.go b/internal/core/ahoy/remote_gh_offer_test.go new file mode 100644 index 000000000..fca7fcf1f --- /dev/null +++ b/internal/core/ahoy/remote_gh_offer_test.go @@ -0,0 +1,191 @@ +package ahoy + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/tools" +) + +// ghOfferRig is a managed repository whose PATH holds git and nothing else, so +// gh is missing, and whose tool installer is a fake: a "brew install gh" it is +// told to run writes a stand-in gh onto that PATH (answering with body) and +// records the argv, so a test sees exactly what would have run and what the +// verb did once gh was there. +type ghOfferRig struct { + repo string + path string // the only PATH directory + calls [][]string + ghLog string + fail bool // the fake brew exits non-zero +} + +func newGHOfferRig(t *testing.T, body string) *ghOfferRig { + t.Helper() + setupHermetic(t) + repo := managedRepoWithOrigin(t, "https://github.com/example-org/example-repo") + gitBin, err := exec.LookPath("git") + if err != nil { + t.Skip("git unavailable") + } + r := &ghOfferRig{repo: repo, path: t.TempDir()} + r.ghLog = filepath.Join(t.TempDir(), "gh.log") + if err := os.Symlink(gitBin, filepath.Join(r.path, "git")); err != nil { + t.Fatal(err) + } + // brew is a real file the installer's admit can resolve; the fake Run never + // executes it. + if err := os.WriteFile(filepath.Join(r.path, "brew"), []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil { + t.Fatal(err) + } + bodyFile := filepath.Join(t.TempDir(), "get.json") + if err := os.WriteFile(bodyFile, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", r.path) + + prev := newToolInstaller + newToolInstaller = func(guard string) *tools.Installer { + in := tools.Default(guard) + in.Getenv = func(string) string { return "" } + in.Run = func(_ context.Context, argv []string) ([]byte, error) { + r.calls = append(r.calls, argv) + if filepath.Base(argv[0]) != "brew" { + return []byte("gh version 2.0.0 (fake)\n"), nil + } + if r.fail { + return []byte("Error: gh: download failed\n"), errors.New("exit status 1") + } + script := "#!/bin/sh\necho \"$*\" >> '" + r.ghLog + "'\n/bin/cat '" + bodyFile + "'\n" + if err := os.WriteFile(filepath.Join(r.path, "gh"), []byte(script), 0o755); err != nil { + return nil, err + } + return []byte("==> Pouring gh\n"), nil + } + return in + } + t.Cleanup(func() { newToolInstaller = prev }) + return r +} + +func (r *ghOfferRig) ghRan(t *testing.T) bool { + t.Helper() + _, err := os.Stat(r.ghLog) + return err == nil +} + +// asked records what the confirmation was shown and answers ans. +func asked(seen *[]tools.Explanation, ans tools.Answer) tools.Confirm { + return func(e tools.Explanation) tools.Answer { + *seen = append(*seen, e) + return ans + } +} + +// TestRemoteApplyOffersGhAndRunsTheStepOnYes is the gh offer's yes (the DQ3 +// ruling, itd-63 criterion 2): a missing gh is put to the question with the +// registry's explanation, the yes runs the registry's step, the verb says what +// it ran, and the settings are then read through the gh it installed. +func TestRemoteApplyOffersGhAndRunsTheStepOnYes(t *testing.T) { + r := newGHOfferRig(t, bothEnabled) + var seen []tools.Explanation + res, err := RemoteApply(r.repo, confirmingPrompter{}, asked(&seen, tools.Answer{Yes: true, Why: "answered yes at the terminal"})) + if err != nil { + t.Fatal(err) + } + if len(seen) != 1 || seen[0].Tool != "gh" || seen[0].StepText() != "brew install gh" { + t.Fatalf("the offer was not put to the question with gh's explanation: %+v", seen) + } + if len(r.calls) == 0 || strings.Join(r.calls[0][1:], " ") != "install gh" { + t.Fatalf("the yes did not run the registry's step: %v", r.calls) + } + if res.Status == "refused" { + t.Fatalf("the verb refused after gh was installed: %+v", res) + } + if !r.ghRan(t) { + t.Fatal("the settings were not read through the installed gh") + } + notes := strings.Join(res.Notes, "\n") + for _, want := range []string{"gh: ran brew install gh — installed, and verified with gh --version", "gh auth login"} { + if !strings.Contains(notes, want) { + t.Errorf("the notes lack %q:\n%s", want, notes) + } + } +} + +// TestRemoteApplyGhDeclinedRunsNothingAndShowsTheStep is every no: an answer +// of no, and a caller that asked no one (off a terminal the front door answers +// no itself). Nothing runs, gh is never reached, and the refusal carries the +// explanation with the exact command to run by hand. +func TestRemoteApplyGhDeclinedRunsNothingAndShowsTheStep(t *testing.T) { + for name, confirm := range map[string]tools.Confirm{ + "no": func(tools.Explanation) tools.Answer { return tools.Answer{Why: "answered no at the terminal"} }, + "no-one": nil, + "no-tty": func(tools.Explanation) tools.Answer { return tools.Answer{Why: "no terminal to ask at"} }, + } { + t.Run(name, func(t *testing.T) { + r := newGHOfferRig(t, bothEnabled) + res, err := RemoteApply(r.repo, confirmingPrompter{}, confirm) + if err != nil { + t.Fatal(err) + } + if len(r.calls) != 0 || r.ghRan(t) { + t.Fatalf("a no ran something: installs %v, gh ran %v", r.calls, r.ghRan(t)) + } + if res.Status != "refused" { + t.Fatalf("status = %q, want refused", res.Status) + } + notes := strings.Join(res.Notes, "\n") + for _, want := range []string{"gh not installed (", "install step (Homebrew): brew install gh", "the settings on GitHub stay unread and unchanged"} { + if !strings.Contains(notes, want) { + t.Errorf("the notes lack %q:\n%s", want, notes) + } + } + }) + } +} + +// TestRemoteApplyGhInstallFailureIsLoudAndStops: a failed step is reported with +// its output, and the verb goes no further: no gh call, no mirror written. +func TestRemoteApplyGhInstallFailureIsLoudAndStops(t *testing.T) { + r := newGHOfferRig(t, bothEnabled) + r.fail = true + res, err := RemoteApply(r.repo, confirmingPrompter{}, func(tools.Explanation) tools.Answer { return tools.Answer{Yes: true} }) + if err != nil { + t.Fatal(err) + } + if res.Status != "refused" || r.ghRan(t) || len(res.Writes) != 0 { + t.Fatalf("a failed install went on: status %q, gh ran %v, writes %v", res.Status, r.ghRan(t), res.Writes) + } + notes := strings.Join(res.Notes, "\n") + for _, want := range []string{"ran brew install gh — it failed", "download failed", "the settings on GitHub stay unread and unchanged"} { + if !strings.Contains(notes, want) { + t.Errorf("the notes lack %q:\n%s", want, notes) + } + } +} + +// TestRemoteReadNeverOffersGh: the read is bare ahoy's, which writes nothing, +// so a missing gh is explained and pointed at the verb that offers it, never +// installed. +func TestRemoteReadNeverOffersGh(t *testing.T) { + r := newGHOfferRig(t, bothEnabled) + res, err := RemoteRead(r.repo) + if err != nil { + t.Fatal(err) + } + if len(r.calls) != 0 { + t.Fatalf("the read ran an install: %v", r.calls) + } + notes := strings.Join(res.Notes, "\n") + for _, want := range []string{"brew install gh", "abcd ahoy remote apply offers to install it"} { + if !strings.Contains(notes, want) { + t.Errorf("the read's refusal lacks %q:\n%s", want, notes) + } + } +} diff --git a/internal/core/ahoy/remote_test.go b/internal/core/ahoy/remote_test.go index eba5cfc77..e7f435111 100644 --- a/internal/core/ahoy/remote_test.go +++ b/internal/core/ahoy/remote_test.go @@ -138,7 +138,7 @@ func TestRemoteApplyRequiresConfirmationNotJustInvocation(t *testing.T) { logPath := ghFake(t, bothDisabled) repo := managedRepoWithOrigin(t, "https://github.com/example-org/example-repo") - res, err := RemoteApply(repo, RefusingPrompter{}) + res, err := RemoteApply(repo, RefusingPrompter{}, nil) if err != nil { t.Fatal(err) } @@ -158,7 +158,7 @@ func TestRemoteApplyRequiresConfirmationNotJustInvocation(t *testing.T) { } // A nil prompter is the same refusal: a caller that forgot the seam must not // get an unconfirmed write by omission. - nilRes, err := RemoteApply(repo, nil) + nilRes, err := RemoteApply(repo, nil, nil) if err != nil { t.Fatal(err) } @@ -174,7 +174,7 @@ func TestRemoteApplyDoesNotAskWhenNothingWouldChange(t *testing.T) { ghFake(t, bothEnabled) repo := managedRepoWithOrigin(t, "https://github.com/example-org/example-repo") - res, err := RemoteApply(repo, RefusingPrompter{}) + res, err := RemoteApply(repo, RefusingPrompter{}, nil) if err != nil { t.Fatal(err) } @@ -193,7 +193,7 @@ func TestRemoteApplyEnablesSecretScanningBeforePushProtection(t *testing.T) { logPath := ghFake(t, bothDisabled) repo := managedRepoWithOrigin(t, "https://github.com/example-org/example-repo.git") - res, err := RemoteApply(repo, confirmingPrompter{}) + res, err := RemoteApply(repo, confirmingPrompter{}, nil) if err != nil { t.Fatal(err) } @@ -228,7 +228,7 @@ func TestRemoteApplyMirrorsTheDesiredState(t *testing.T) { ghFake(t, bothDisabled) repo := managedRepoWithOrigin(t, "git@github.com:example-org/example-repo.git") - res, err := RemoteApply(repo, confirmingPrompter{}) + res, err := RemoteApply(repo, confirmingPrompter{}, nil) if err != nil { t.Fatal(err) } @@ -285,7 +285,7 @@ func TestRemoteApplyIsIdempotent(t *testing.T) { // The FIRST run still writes the mirror — the tree does not yet record the // intent — but it changes no toggle, and it must take no remote write at all. - res, err := RemoteApply(repo, confirmingPrompter{}) + res, err := RemoteApply(repo, confirmingPrompter{}, nil) if err != nil { t.Fatal(err) } @@ -301,7 +301,7 @@ func TestRemoteApplyIsIdempotent(t *testing.T) { } } before := treeHash(t, repo) - res2, err := RemoteApply(repo, confirmingPrompter{}) + res2, err := RemoteApply(repo, confirmingPrompter{}, nil) if err != nil { t.Fatal(err) } @@ -326,7 +326,7 @@ func TestRemoteApplyHonoursTheOptOut(t *testing.T) { repo := managedRepoWithOrigin(t, "https://github.com/example-org/example-repo") writeNativeScanningOptOut(t, repo) - res, err := RemoteApply(repo, confirmingPrompter{}) + res, err := RemoteApply(repo, confirmingPrompter{}, nil) if err != nil { t.Fatal(err) } @@ -368,7 +368,7 @@ func TestRemoteApplyRefusesRatherThanGuess(t *testing.T) { setupHermetic(t) logPath := ghFake(t, bothDisabled) repo := managedRepoWithOrigin(t, "") - res, err := RemoteApply(repo, confirmingPrompter{}) + res, err := RemoteApply(repo, confirmingPrompter{}, nil) if err != nil { t.Fatal(err) } @@ -384,7 +384,7 @@ func TestRemoteApplyRefusesRatherThanGuess(t *testing.T) { setupHermetic(t) logPath := ghFake(t, bothDisabled) repo := managedRepoWithOrigin(t, "https://gitlab.example.com/example-org/example-repo.git") - res, err := RemoteApply(repo, confirmingPrompter{}) + res, err := RemoteApply(repo, confirmingPrompter{}, nil) if err != nil { t.Fatal(err) } @@ -402,7 +402,7 @@ func TestRemoteApplyRefusesRatherThanGuess(t *testing.T) { // A path segment that traverses would turn `repos/OWNER/REPO` into some other // endpoint entirely — on a verb whose whole job is a privileged write. repo := managedRepoWithOrigin(t, "https://github.com/../../user/repos.git") - res, err := RemoteApply(repo, confirmingPrompter{}) + res, err := RemoteApply(repo, confirmingPrompter{}, nil) if err != nil { t.Fatal(err) } @@ -429,7 +429,7 @@ func TestRemoteApplyRefusesRatherThanGuess(t *testing.T) { if err := os.MkdirAll(sub, 0o755); err != nil { t.Fatal(err) } - res, err := RemoteApply(sub, confirmingPrompter{}) + res, err := RemoteApply(sub, confirmingPrompter{}, nil) if err != nil { t.Fatal(err) } @@ -452,7 +452,7 @@ func TestRemoteApplyRefusesRatherThanGuess(t *testing.T) { if err := os.MkdirAll(sub, 0o755); err != nil { t.Fatal(err) } - if _, err := RemoteApply(sub, confirmingPrompter{}); err != nil { + if _, err := RemoteApply(sub, confirmingPrompter{}, nil); err != nil { t.Fatal(err) } if mirror(t, repo) == "" { @@ -467,7 +467,7 @@ func TestRemoteApplyRefusesRatherThanGuess(t *testing.T) { setupHermetic(t) logPath := ghFake(t, bothDisabled) repo := t.TempDir() - res, err := RemoteApply(repo, confirmingPrompter{}) + res, err := RemoteApply(repo, confirmingPrompter{}, nil) if err != nil { t.Fatal(err) } @@ -484,7 +484,7 @@ func TestRemoteApplyRefusesRatherThanGuess(t *testing.T) { logPath := ghFake(t, bothDisabled) t.Setenv("GH_FAKE_GET_FAIL", "1") repo := managedRepoWithOrigin(t, "https://github.com/example-org/example-repo") - res, err := RemoteApply(repo, confirmingPrompter{}) + res, err := RemoteApply(repo, confirmingPrompter{}, nil) if err != nil { t.Fatal(err) } @@ -511,7 +511,7 @@ func TestRemoteApplyStopsAtTheFirstFailedWrite(t *testing.T) { t.Setenv("GH_FAKE_PATCH_FAIL", `"secret_scanning":`) repo := managedRepoWithOrigin(t, "https://github.com/example-org/example-repo") - res, err := RemoteApply(repo, confirmingPrompter{}) + res, err := RemoteApply(repo, confirmingPrompter{}, nil) if err != nil { t.Fatal(err) } diff --git a/internal/core/ahoy/statusline_install_test.go b/internal/core/ahoy/statusline_install_test.go index 1ec3dd824..ff2a883d9 100644 --- a/internal/core/ahoy/statusline_install_test.go +++ b/internal/core/ahoy/statusline_install_test.go @@ -774,10 +774,10 @@ func TestStatusLineCategoryIsOptionalForYes(t *testing.T) { t.Fatal("StatusLine is not in categoryPromptOrder") } gaps := []Gap{{ID: StatusLineOfferGapID, Category: StatusLine, Resolvable: true}} - if got := optionalSkipped(InstallOptions{Yes: true}, gaps); strings.Join(got, ",") != StatusLineOfferGapID { + if got := optionalSkipped(InstallOptions{Yes: true}, gaps, nil); strings.Join(got, ",") != StatusLineOfferGapID { t.Errorf("optionalSkipped under --yes = %v", got) } - if got := optionalSkipped(InstallOptions{}, gaps); len(got) != 0 { + if got := optionalSkipped(InstallOptions{}, gaps, nil); len(got) != 0 { t.Errorf("optionalSkipped without --yes = %v, want none (it is offered)", got) } } diff --git a/internal/core/ahoy/transition_test.go b/internal/core/ahoy/transition_test.go index e9a5474e9..f08ea8c54 100644 --- a/internal/core/ahoy/transition_test.go +++ b/internal/core/ahoy/transition_test.go @@ -8,49 +8,6 @@ import ( "github.com/intentdriven/abcd/internal/core" ) -func TestVersionTransitionFrom(t *testing.T) { - cases := []struct { - name string - recorded string - running string - wantChanged bool - }{ - {"a newer running version is a transition", "v1.2.0", "v1.3.0", true}, - {"an older running version is still a transition (direction-neutral)", "v1.3.0", "v1.2.0", true}, - {"same version is no transition", "v1.2.0", "v1.2.0", false}, - {"a dev running version never reports a transition", "v1.2.0", "dev", false}, - {"an unrecorded version never reports a transition", "", "v1.3.0", false}, - // iss-2608241115259170: a setup stamped by a dev build can never - // reconcile against a release, so it is no transition either. - {"a dev recorded version never reports a transition", "dev", "v1.3.0", false}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - _, _, changed := versionTransitionFrom(c.recorded, c.running) - if changed != c.wantChanged { - t.Fatalf("changed = %v, want %v", changed, c.wantChanged) - } - }) - } -} - -func TestRecordedSetupVersionReadsConfig(t *testing.T) { - dir := t.TempDir() - if err := os.MkdirAll(filepath.Join(dir, ".abcd"), 0o755); err != nil { - t.Fatal(err) - } - const cfg = `{"meta":{"setup_version":"v1.2.0","schema_version":1}}` + "\n" - if err := os.WriteFile(filepath.Join(dir, ".abcd", "config.json"), []byte(cfg), 0o644); err != nil { - t.Fatal(err) - } - if got := recordedSetupVersion(dir); got != "v1.2.0" { - t.Fatalf("recordedSetupVersion = %q, want v1.2.0", got) - } - if got := recordedSetupVersion(t.TempDir()); got != "" { - t.Fatalf("recordedSetupVersion with no config = %q, want empty", got) - } -} - // TestDetectVersionIgnoresADevSide is the detection half of // iss-2608241115259170: version.upgrade is a required gap, and with either side // a dev build it could never settle. A dev-stamped repo run by a release binary diff --git a/internal/core/ahoy/unseen_update.go b/internal/core/ahoy/unseen_update.go new file mode 100644 index 000000000..8b466b56e --- /dev/null +++ b/internal/core/ahoy/unseen_update.go @@ -0,0 +1,74 @@ +package ahoy + +import ( + "os" + "path/filepath" + "regexp" +) + +// ReleaseTagShape is the accepted release-tag alphabet, one definition for the +// updater (a tag travels into a URL path there) and for the unseen-update +// notice below (a tag travels onto a terminal there). +var ReleaseTagShape = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$`) + +// updateShownPrefix names the claim the session check takes for one release's +// unseen update: update-shown- beside the cache's binary-meta, or +// .update-shown- beside a plugin root's own .binary-meta in the degraded +// per-root mode. The tag has ReleaseTagShape before it is joined, so it can +// carry no separator and cannot begin with a dot. +const updateShownPrefix = "update-shown-" + +// TakeUnseenUpdate reports the one release swap nobody was told about, and +// marks it told. It is the ruling CJ1b's single exception to the session +// check's read-only rule, and the claim it creates is the only write. +// +// An update is announced by whatever performed the swap, when the swap +// completes: `abcd update` prints update.UpdatedFormat, and hooks/bootstrap.sh +// leads its success notice with it. The one swap whose output no one reads is +// the bootstrap salvage the per-prompt, per-command and pre-compaction hooks +// run with their output discarded (hooks/hooks.json); that run records +// transition_unseen=yes beside previous_tag in the binary-meta it writes (the +// cache's, or the plugin root's .binary-meta when there is no data dir), and +// this is what shows it, once, at the next session start. A swap whose own +// line was relayed carries no flag, so this shows it never and writes nothing. +// +// The directory the record comes from is an environment value, so it passes +// dataDirHazard before anything is read from it or written into it, and both +// tags must have ReleaseTagShape: a record an HTTP redirect filled shows +// nothing it cannot vouch for. Once is one exclusive create per release +// (O_CREATE|O_EXCL), so of any number of sessions starting together exactly +// one takes the claim; a claim path that already exists, or a create that +// fails for any other reason, reports nothing, because a notice that cannot +// remember it was shown would repeat every session. Claims for earlier +// releases are left in place: removing one would be a second write, and each +// is an empty file. +func TakeUnseenUpdate(pluginRoot, cwd string) (from, to string, ok bool) { + dir, meta, claim := "", "", "" + if data := pluginDataDir(pluginRoot).dir; data != "" { + dir = data + meta = filepath.Join(data, "cache", "binary-meta") + claim = filepath.Join(data, "cache", updateShownPrefix) + } else if pluginRoot != "" { + dir = pluginRoot + meta = filepath.Join(pluginRoot, ".binary-meta") + claim = filepath.Join(pluginRoot, "."+updateShownPrefix) + } + if dir == "" || dataDirHazard(dir, cwd) != "" { + return "", "", false + } + if metaField(meta, "transition_unseen") != "yes" { + return "", "", false + } + from, to = metaField(meta, "previous_tag"), metaField(meta, "release_tag") + if !ReleaseTagShape.MatchString(from) || !ReleaseTagShape.MatchString(to) || from == to { + return "", "", false + } + f, err := os.OpenFile(claim+to, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return "", "", false + } + if err := f.Close(); err != nil { + return "", "", false + } + return from, to, true +} diff --git a/internal/core/ahoy/unseen_update_test.go b/internal/core/ahoy/unseen_update_test.go new file mode 100644 index 000000000..4690640b1 --- /dev/null +++ b/internal/core/ahoy/unseen_update_test.go @@ -0,0 +1,166 @@ +package ahoy + +import ( + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" +) + +// seedUnseenCache writes the cache record a bootstrap swap leaves behind. +func seedUnseenCache(t *testing.T, meta string) (data string) { + t.Helper() + data = t.TempDir() + cache := filepath.Join(data, "cache") + if err := os.MkdirAll(cache, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cache, "binary-meta"), []byte(meta), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("CLAUDE_PLUGIN_DATA", data) + return data +} + +const unseenMeta = "release_tag=v0.12.0\nrelease_sha=unknown\nbinary_sha256=" + + "0000000000000000000000000000000000000000000000000000000000000000\n" + + "fetched_at=2026-09-30T00:00:00Z\nprevious_tag=v0.11.1\ntransition_unseen=yes\n" + +// TestTakeUnseenUpdateShowsOnce is the ruling CJ1b's single exception: a swap +// made where no one saw its output is shown by the next session start, once, +// and the marker that makes it once is the only thing the session check writes. +func TestTakeUnseenUpdateShowsOnce(t *testing.T) { + data := seedUnseenCache(t, unseenMeta) + cwd := t.TempDir() + + from, to, ok := TakeUnseenUpdate("", cwd) + if !ok || from != "v0.11.1" || to != "v0.12.0" { + t.Fatalf("the first session after an unseen swap must show it; got %q -> %q (%v)", from, to, ok) + } + if _, err := os.Stat(filepath.Join(data, "cache", "update-shown-v0.12.0")); err != nil { + t.Errorf("the claim for the release shown must be cache/update-shown-v0.12.0: %v", err) + } + if _, _, ok := TakeUnseenUpdate("", cwd); ok { + t.Errorf("the second session must not show the same update again") + } +} + +// TestTakeUnseenUpdateLeavesASeenSwapAlone: a swap whose own output was +// relayed carries no transition_unseen flag, so the session check neither +// shows it nor writes anything. +func TestTakeUnseenUpdateLeavesASeenSwapAlone(t *testing.T) { + data := seedUnseenCache(t, "release_tag=v0.12.0\nprevious_tag=v0.11.1\n") + if _, _, ok := TakeUnseenUpdate("", t.TempDir()); ok { + t.Errorf("a seen swap must not be shown again at session start") + } + if _, err := os.Stat(filepath.Join(data, "cache", "update-shown-v0.12.0")); !os.IsNotExist(err) { + t.Errorf("the session check must write nothing for a seen swap: %v", err) + } +} + +// TestTakeUnseenUpdateRefusesAHazardousDataDir: the data dir comes from the +// environment, so it passes the same shape check as every other reader of it +// (dataDirHazard) before anything is read from it or written into it. +func TestTakeUnseenUpdateRefusesAHazardousDataDir(t *testing.T) { + data := seedUnseenCache(t, unseenMeta) + if err := os.Chmod(filepath.Join(data, "cache"), 0o777); err != nil { + t.Fatal(err) + } + if _, _, ok := TakeUnseenUpdate("", t.TempDir()); ok { + t.Errorf("a world-writable data dir must not be believed") + } + if _, err := os.Stat(filepath.Join(data, "cache", "update-shown-v0.12.0")); !os.IsNotExist(err) { + t.Errorf("nothing may be written into a hazardous data dir: %v", err) + } +} + +// TestTakeUnseenUpdateRefusesAMisshapenTag: the tags come off a record on +// disk that an HTTP redirect filled, so only a plain release-tag shape is shown +// or written; a control byte (or anything else) shows nothing and writes +// nothing. +func TestTakeUnseenUpdateRefusesAMisshapenTag(t *testing.T) { + for _, meta := range []string{ + "release_tag=v0.12.0\x1b[2J\nprevious_tag=v0.11.1\ntransition_unseen=yes\n", + "release_tag=v0.12.0\nprevious_tag=v0.11.1 run this\ntransition_unseen=yes\n", + } { + data := seedUnseenCache(t, meta) + if from, to, ok := TakeUnseenUpdate("", t.TempDir()); ok { + t.Errorf("a misshapen tag must not be shown; got %q -> %q", from, to) + } + if _, err := os.Stat(filepath.Join(data, "cache", "update-shown-v0.12.0")); !os.IsNotExist(err) { + t.Errorf("nothing may be written for a misshapen tag: %v", err) + } + } +} + +// TestTakeUnseenUpdateShowsOnceUnderConcurrentSessions: an autonomous run +// starts several sessions in one plugin root within the same second, and each +// runs the session check. The claim is one exclusive create per release, so +// exactly one of them shows the line, however they interleave. +func TestTakeUnseenUpdateShowsOnceUnderConcurrentSessions(t *testing.T) { + seedUnseenCache(t, unseenMeta) + cwd := t.TempDir() + const sessions = 16 + var shown atomic.Int32 + var ready, done sync.WaitGroup + start := make(chan struct{}) + for range sessions { + ready.Add(1) + done.Add(1) + go func() { + defer done.Done() + ready.Done() + <-start + if _, _, ok := TakeUnseenUpdate("", cwd); ok { + shown.Add(1) + } + }() + } + ready.Wait() + close(start) + done.Wait() + if n := shown.Load(); n != 1 { + t.Errorf("%d concurrent session starts must show the unseen update exactly once; shown %d times", sessions, n) + } +} + +// TestTakeUnseenUpdateFailsClosedOnAnExistingClaim: whatever already sits at +// the claim path (a claim, or a link to someone else's file) means the release +// was claimed, so nothing is shown and nothing is written through it. +func TestTakeUnseenUpdateFailsClosedOnAnExistingClaim(t *testing.T) { + data := seedUnseenCache(t, unseenMeta) + victim := filepath.Join(t.TempDir(), "victim") + if err := os.Symlink(victim, filepath.Join(data, "cache", "update-shown-v0.12.0")); err != nil { + t.Fatal(err) + } + if _, _, ok := TakeUnseenUpdate("", t.TempDir()); ok { + t.Errorf("an existing claim path must show nothing") + } + if _, err := os.Lstat(victim); !os.IsNotExist(err) { + t.Errorf("the claim must never be created through a link: %v", err) + } +} + +// TestTakeUnseenUpdateShowsAPerRootSwapOnce: with no plugin data directory the +// bootstrap runs in its degraded per-root mode and records the swap in the +// root's own .binary-meta, so the session check reads that record and claims +// the release beside it, as .update-shown-, with the same single write. +func TestTakeUnseenUpdateShowsAPerRootSwapOnce(t *testing.T) { + t.Setenv("CLAUDE_PLUGIN_DATA", "") + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, ".binary-meta"), []byte(unseenMeta), 0o644); err != nil { + t.Fatal(err) + } + cwd := t.TempDir() + from, to, ok := TakeUnseenUpdate(root, cwd) + if !ok || from != "v0.11.1" || to != "v0.12.0" { + t.Fatalf("the first session after an unseen per-root swap must show it; got %q -> %q (%v)", from, to, ok) + } + if _, err := os.Stat(filepath.Join(root, ".update-shown-v0.12.0")); err != nil { + t.Errorf("the per-root claim must be .update-shown-v0.12.0 in the plugin root: %v", err) + } + if _, _, ok := TakeUnseenUpdate(root, cwd); ok { + t.Errorf("the second session must not show the same per-root update again") + } +} diff --git a/internal/core/ahoy/vintage.go b/internal/core/ahoy/vintage.go index 090038f9c..f9c6083ab 100644 --- a/internal/core/ahoy/vintage.go +++ b/internal/core/ahoy/vintage.go @@ -195,8 +195,8 @@ func (v VintageStatus) Staleness() string { // Only the checkout-tip comparison is ancestry-guarded, so only it may // claim a direction. A version/pin comparison is string equality — a // binary newer than its pin is the same inequality read the other way — - // so it stays non-directional ("differs from"), the caution skew.go and - // VersionTransition already take. + // so it stays non-directional ("differs from"), the caution skew.go + // already takes. if v.Source == VintageSourceCheckoutTip { return "stale — behind the checkout tip (" + ref + ")" } @@ -210,44 +210,11 @@ func (v VintageStatus) Staleness() string { } } -// VersionTransition reports a version change performed since this repo was last -// set up: the last-recorded setup_version (written into .abcd/config.json by -// install) against the running binary's version. It is AC6's report only — the -// fetch that changed the binary is provisioning's job (itd-105/108), out of this -// intent. changed is false when either side is undeterminable or they match; the -// report is direction-neutral, since a repo set up by a newer binary and now run -// through an older one is the same inequality read backwards. -func VersionTransition(cwd string) (recorded, running string, changed bool) { - return versionTransitionFrom(recordedSetupVersion(cwd), core.Version) -} - -// versionTransitionFrom is the pure comparison, split so the change/no-change -// branches are testable without a fixture config or a re-stamped core.Version. -func versionTransitionFrom(recorded, running string) (from, to string, changed bool) { - // A dev build on either side is no transition: a dev stamp can never - // reconcile against a release, and re-stamping it would flap the tracked - // config between dev and release installs (iss-2608241115259170). - if isDevOrUnknown(running) || isDevOrUnknown(recorded) { - return recorded, running, false - } - return recorded, running, recorded != running -} - // isDevOrUnknown reports a version that cannot take part in a comparison: none -// recorded, or a local dev build's. +// recorded, or a local dev build's (detect's version.upgrade gap, +// iss-2608241115259170). func isDevOrUnknown(v string) bool { return v == "" || v == "dev" } -// recordedSetupVersion reads meta.setup_version from the repo config, or "" when -// it is absent or unreadable. -func recordedSetupVersion(cwd string) string { - cfg, err := readConfig(cwd) - if err != nil || cfg == nil { - return "" - } - v, _ := subMap(cfg, "meta")["setup_version"].(string) - return v -} - // readPinnedTag reads the release tag the bootstrap's provenance record pins, // or "" when no record answers. The root-local .binary-meta wins when present — // it is written only by the degraded per-root fetch, so it describes exactly diff --git a/internal/core/capture/capture.go b/internal/core/capture/capture.go index 9b5c17ca2..60fd5e26f 100644 --- a/internal/core/capture/capture.go +++ b/internal/core/capture/capture.go @@ -138,6 +138,10 @@ type Issue struct { Status State `json:"status"` // derived from folder Path string `json:"path"` // repo-relative locator (iss-81) Body string `json:"body"` + // deferredAfter is the release-cut waiver's anchor tag (deferred_after), + // read for the drain's live-deferral hand-back and not surfaced: the cut + // reads the pair from the committed record itself. + deferredAfter string // BlockedByOpen is the derived subset of BlockedBy whose targets are still in // open/ (the priority projection populated by List/Status). Not a stored // field: an empty slice means the issue is unblocked. @@ -186,9 +190,20 @@ type CaptureRequest struct { // lock, and writes a `duplicates:` or `refines:` link naming each likely // double (itd-2609212137116617). It never refuses the capture: a match that // cannot run says why on the result and the record is filed without it. - // nil files the record unmatched, as a caller with its own matching (the - // inbox drain, the consistency pass) does. + // nil files the record unmatched. Match *match.Config + // MatchText, when non-empty, is the text the match compares in place of + // Text: the finding's own words, for a filer whose record also carries + // lines every record it files shares (the inbox's provenance, the + // consistency pass's evidence line). Matched on those, two unrelated + // records would link each other on the boilerplate alone. Empty compares + // Text. + MatchText string + // MatchExcept names records the match does not compare with: a filer + // that files several records in one pass passes the ones it has already + // filed, so two findings of one pass are never linked as doubles of each + // other. + MatchExcept []string } // CaptureResult is the outcome of a successful Capture. The timestamp-numeric diff --git a/internal/core/capture/citationgate_test.go b/internal/core/capture/citationgate_test.go index 6b6be44dc..dca20216d 100644 --- a/internal/core/capture/citationgate_test.go +++ b/internal/core/capture/citationgate_test.go @@ -165,7 +165,7 @@ func TestConsistencyIngestRefusesAnUnresolvedCitation(t *testing.T) { t.Run("armed: refused, nothing written", func(t *testing.T) { root := consistencyArmedRepo(t) - _, err := IngestConsistency(root, cite(consistencyPayload(t, root)), "2026-09-26") + _, err := IngestConsistency(root, cite(consistencyPayload(t, root)), "2026-09-26", nil) if !errors.Is(err, ErrUnresolvedCitation) || !strings.Contains(err.Error(), dangling) || !strings.Contains(err.Error(), "consistency finding 1") { t.Fatalf("err = %v, want a refusal naming finding 1 and %s", err, dangling) @@ -181,7 +181,7 @@ func TestConsistencyIngestRefusesAnUnresolvedCitation(t *testing.T) { }) t.Run("armed: a clean finding is filed", func(t *testing.T) { root := consistencyArmedRepo(t) - res, err := IngestConsistency(root, consistencyPayload(t, root), "2026-09-26") + res, err := IngestConsistency(root, consistencyPayload(t, root), "2026-09-26", nil) if err != nil || len(res.Filed) != 1 { t.Fatalf("ingest = %+v %v, want the finding filed", res, err) } @@ -189,7 +189,7 @@ func TestConsistencyIngestRefusesAnUnresolvedCitation(t *testing.T) { t.Run("no gate registered: refused", func(t *testing.T) { withNoProseGate(t) root := consistencyLedgerRepo(t) - _, err := IngestConsistency(root, consistencyPayload(t, root), "2026-09-26") + _, err := IngestConsistency(root, consistencyPayload(t, root), "2026-09-26", nil) if err == nil || !strings.Contains(err.Error(), "no prose-citation gate is registered") { t.Fatalf("err = %v, want the unregistered gate refused", err) } diff --git a/internal/core/capture/consistency.go b/internal/core/capture/consistency.go index 503d781b3..f4ca858a8 100644 --- a/internal/core/capture/consistency.go +++ b/internal/core/capture/consistency.go @@ -2,11 +2,13 @@ package capture import ( "fmt" + "slices" "strings" "unicode" "github.com/intentdriven/abcd/internal/core/intent" "github.com/intentdriven/abcd/internal/core/issueschema" + "github.com/intentdriven/abcd/internal/core/record/match" ) // consistency.go is the ledger half of the intent consistency pass (itd-48, @@ -20,8 +22,12 @@ import ( // IngestConsistency ingests a consistency findings payload with the ledger as // its filer. date is the report's date (YYYY-MM-DD; empty is today in UTC). -func IngestConsistency(repoRoot string, payload []byte, date string) (intent.ConsistencyIngestResult, error) { +// mc, when non-nil, runs capture's filing-time match on every record the pass +// files, so a finding that doubles a record in other words is filed with a +// typed link naming it; nil files unmatched. +func IngestConsistency(repoRoot string, payload []byte, date string, mc *match.Config) (intent.ConsistencyIngestResult, error) { var open []Issue + var filedHere []string loaded := false // The open records are read once, on the first finding, and only records // that were open BEFORE this pass count: two findings of one pass that @@ -51,6 +57,14 @@ func IngestConsistency(repoRoot string, payload []byte, date string) (intent.Con // H12): the record is filed, and a drain skips it until a person // writes a real remedy. Remedy: issueschema.MachineRemedy, + // The filing-time match (itd-2609212137116617) compares the + // finding's own words: the ends' paths, the class line and the + // evidence line are shared by every finding of a pass. The + // records this pass has filed are not compared, for the reason + // loadOpen gives. + Match: mc, + MatchText: f.Summary + "\n\n" + f.Explanation, + MatchExcept: slices.Clone(filedHere), } } // A finding an open record already holds is linked, and writes nothing, so @@ -82,7 +96,8 @@ func IngestConsistency(repoRoot string, payload []byte, date string) (intent.Con if err != nil { return intent.ConsistencyFiling{}, err } - return intent.ConsistencyFiling{IssueID: res.ID}, nil + filedHere = append(filedHere, res.ID) + return intent.ConsistencyFiling{IssueID: res.ID, Match: res.Match}, nil } return intent.IngestConsistency(intent.ConsistencyIngestRequest{ RepoRoot: repoRoot, Payload: payload, Date: date, File: filer, Check: check, diff --git a/internal/core/capture/consistency_test.go b/internal/core/capture/consistency_test.go index c313f479a..355c90384 100644 --- a/internal/core/capture/consistency_test.go +++ b/internal/core/capture/consistency_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/intentdriven/abcd/internal/core/intent" + "github.com/intentdriven/abcd/internal/core/record/match" "github.com/intentdriven/abcd/internal/gittest" ) @@ -35,6 +36,20 @@ func consistencyLedgerRepo(t *testing.T) string { // consistencyPayload emits a corpus request and returns a findings payload // echoing its provenance, carrying the one contradiction between itd-10 and itd-11. func consistencyPayload(t *testing.T, root string) []byte { + t.Helper() + return consistencyPayloadWith(t, root, []any{map[string]any{ + "class": "premise_contradiction", "severity": "major", + "summary": "itd-10 and itd-11 disagree on how many specs an intent owns", + "explanation": "One says exactly one spec for life; the other says one or more.", + "ends": []any{ + map[string]any{"path": cxA, "quote": cxQuoteA}, + map[string]any{"path": cxB, "quote": cxQuoteB}, + }, + }}) +} + +// consistencyPayloadWith is consistencyPayload carrying the findings given. +func consistencyPayloadWith(t *testing.T, root string, findings []any) []byte { t.Helper() em, err := intent.EmitConsistency(root, "", intent.ConsistencyEmitOptions{}) if err != nil { @@ -51,15 +66,7 @@ func consistencyPayload(t *testing.T, root string) []byte { "receipt_id": em.ReceiptID, "verifier": map[string]any{"id": "intent-auditor", "version": "claude-opus-5-5"}, "policy": map[string]any{"rubric_hash": rubric, "prompt_hash": prompt}, - "findings": []any{map[string]any{ - "class": "premise_contradiction", "severity": "major", - "summary": "itd-10 and itd-11 disagree on how many specs an intent owns", - "explanation": "One says exactly one spec for life; the other says one or more.", - "ends": []any{ - map[string]any{"path": cxA, "quote": cxQuoteA}, - map[string]any{"path": cxB, "quote": cxQuoteB}, - }, - }}, + "findings": findings, }) if err != nil { t.Fatal(err) @@ -73,7 +80,7 @@ func consistencyPayload(t *testing.T, root string) []byte { // evidence, and related to the intents it sits in. func TestConsistencyFilesOneCapturePerFinding(t *testing.T) { root := consistencyLedgerRepo(t) - res, err := IngestConsistency(root, consistencyPayload(t, root), "2026-09-26") + res, err := IngestConsistency(root, consistencyPayload(t, root), "2026-09-26", nil) if err != nil { t.Fatal(err) } @@ -133,7 +140,7 @@ func TestConsistencyLinksAFindingAnOpenRecordHolds(t *testing.T) { t.Fatal(err) } } - res, err := IngestConsistency(root, consistencyPayload(t, root), "2026-09-26") + res, err := IngestConsistency(root, consistencyPayload(t, root), "2026-09-26", nil) if err != nil { t.Fatal(err) } @@ -144,3 +151,101 @@ func TestConsistencyLinksAFindingAnOpenRecordHolds(t *testing.T) { }) } } + +// The pass's words for a finding a person already filed in other words: the +// held record doubles it without quoting either end, so the pass's own +// end-matching (openRecordHolding) does not hold it, and only the filing-time +// match can. +const ( + cxDoubleSummary = "The planned intent and the shipped intent disagree on how many specs an intent owns over its lifetime" + cxDoubleExplain = "The planned record binds every intent to exactly one spec for its whole lifetime, " + + "while the shipped decision lets an intent own several specs closed in sequence." + cxHeld = "Planned and shipped intents disagree on how many specs an intent owns over its lifetime: " + + "the planned record binds each intent to exactly one spec for its whole lifetime, and the " + + "shipped decision lets an intent own several specs closed in sequence." +) + +func cxFinding(summary, explanation string) map[string]any { + return map[string]any{ + "class": "premise_contradiction", "severity": "major", + "summary": summary, "explanation": explanation, + "ends": []any{ + map[string]any{"path": cxA, "quote": cxQuoteA}, + map[string]any{"path": cxB, "quote": cxQuoteB}, + }, + } +} + +// A finding the pass files that doubles an open record is written with +// capture's typed link naming it, and the row carries the match. +func TestConsistencyLinksANearDuplicateAtFiling(t *testing.T) { + root := consistencyLedgerRepo(t) + held := captureText(t, root, "", cxHeld, nil) + captureText(t, root, "", matchFiller1, nil) + captureText(t, root, "", matchFiller2, nil) + + res, err := IngestConsistency(root, consistencyPayloadWith(t, root, []any{cxFinding(cxDoubleSummary, cxDoubleExplain)}), "2026-09-26", bundled()) + if err != nil { + t.Fatal(err) + } + if len(res.Filed) != 1 || len(res.Rows) != 1 { + t.Fatalf("ingest = %+v; want one record filed", res) + } + o := res.Rows[0].Match + if o == nil || len(o.Matches) == 0 { + t.Fatalf("no match on the row: %+v", o) + } + m := o.Matches[0] + if m.ID != held.ID || !m.Linked || (m.Relation != match.Duplicates && m.Relation != match.Refines) { + t.Fatalf("match = %+v, want %s linked", m, held.ID) + } + list, err := List(ListRequest{RepoRoot: root, State: StateOpen}) + if err != nil { + t.Fatal(err) + } + for _, iss := range list.Issues { + if iss.ID != res.Filed[0] { + continue + } + got := iss.Duplicates + if m.Relation == match.Refines { + got = iss.Refines + } + if len(got) != 1 || got[0] != held.ID { + t.Fatalf("filed record's %s = %v, want [%s]", m.Relation, got, held.ID) + } + return + } + t.Fatalf("filed record %s not listed", res.Filed[0]) +} + +// The match reads the finding's own words, not the lines every finding of a +// pass is filed with, and never the records this pass has already filed: two +// findings of one pass are two findings, as the pass's own end-matching has +// it. +func TestConsistencyNeverLinksTwoFindingsOfOnePass(t *testing.T) { + root := consistencyLedgerRepo(t) + captureText(t, root, "", matchFiller1, nil) + captureText(t, root, "", matchFiller2, nil) + // One finding under two classes: the pass files them as two records. + second := cxFinding(cxDoubleSummary, cxDoubleExplain) + second["class"] = "scope_leakage" + payload := consistencyPayloadWith(t, root, []any{cxFinding(cxDoubleSummary, cxDoubleExplain), second}) + res, err := IngestConsistency(root, payload, "2026-09-26", bundled()) + if err != nil { + t.Fatal(err) + } + if len(res.Filed) != 2 { + t.Fatalf("ingest = %+v; want two records filed", res) + } + for _, r := range res.Rows { + if r.Match == nil { + t.Fatalf("row %d was not matched at all", r.Number) + } + for _, m := range r.Match.Matches { + if m.ID == res.Filed[0] || m.ID == res.Filed[1] { + t.Fatalf("finding %d was linked to a record of its own pass: %+v", r.Number, m) + } + } + } +} diff --git a/internal/core/capture/eligible.go b/internal/core/capture/eligible.go index 41f996944..ea1ebdb83 100644 --- a/internal/core/capture/eligible.go +++ b/internal/core/capture/eligible.go @@ -7,6 +7,8 @@ import ( "sort" "strings" + "github.com/intentdriven/abcd/internal/core/changelog" + "github.com/intentdriven/abcd/internal/core/drainrule" "github.com/intentdriven/abcd/internal/core/issueschema" ) @@ -14,18 +16,17 @@ import ( // and 7; spc-2609212015054359 scope 2, 6 and 9). It decides which open issues // a machine may take alone, from the record's fields and nothing else, and // gives every other open issue the disposition of the rule that excluded it. -// The rule is a recorded decision: EligibilityRecord names it. +// +// Which categories and severities a drain may take, and whether it takes +// security issues, is the drained repository's own decision, read from its own +// decision record by core/drainrule (rulings BX2 and H11); a repository without +// one is refused. Two hand-backs hold whatever that record says: a remedy that +// waits on a ruling, and a deferral that is live at the current anchor tag. // // Nothing here writes. The plan is what a drain WOULD do; the run that hands // each eligible issue to an issue-keyed lane is not built yet, and DrainStart // says so rather than pretending to run. -// EligibilityRecord is the decision record that states the rule eligibility -// applies (itd-82 decision 4). It is the record the drain refuses to start -// without; TestTheEligibilityRuleIsRecordedAndAccepted holds it to an accepted -// record in this repository's decision store, cited by the brief's invariants. -const EligibilityRecord = "adr-2609291342092738" - // DrainOutcome is the disposition a drain gives one open issue. type DrainOutcome string @@ -57,23 +58,16 @@ const ( RuleSecurity DrainRule = "security" RuleCategory DrainRule = "category" RuleSeverity DrainRule = "severity" - RuleRemedy DrainRule = "remedy" - RuleFields DrainRule = "fields" + // RuleDeferred: the record's deferral past the current anchor tag is live, + // so a person carried it past this release. + RuleDeferred DrainRule = "deferred" + // RuleWaitsOnRuling: the remedy opens "Waits on", so the fix it proposes + // waits on a ruling a person has not given. + RuleWaitsOnRuling DrainRule = "waits-on-ruling" + RuleRemedy DrainRule = "remedy" + RuleFields DrainRule = "fields" ) -// DrainCategories is the fixable set in the order a drain takes it (decision -// 7): clean-ups and text first, on the evidence that they merge most often; -// behaviour changes last. -var DrainCategories = []Category{"tech-debt", "documentation", "inconsistency", "drift", "bug", "ux"} - -// DrainSeverities is the severities a drain may take, in the order it takes -// them. major and critical are handed back by default. -var DrainSeverities = []Severity{SeverityNitpick, SeverityMinor} - -// DrainOrder is the ordering rule as the summary states it. -const DrainOrder = "category tech-debt, documentation, inconsistency, drift, bug, ux; " + - "then nitpick before minor; then oldest first" - // DrainVerdict is one open issue's disposition, with the rule that decided it // and the reason in words. type DrainVerdict struct { @@ -88,20 +82,26 @@ type DrainVerdict struct { Blockers []string `json:"blockers,omitempty"` } -// eligibility judges one issue by its fields alone. iss.BlockedByOpen must be -// the derived projection List fills (the blockers still in open/). The rules -// are asked in a fixed order, and the first that excludes the issue decides: -// not open, blocked, security, a category outside the fixable set, a severity -// above minor, no remedy. An issue no rule excludes is eligible. +// eligibility judges one issue by its fields alone, under the repository's +// rule r, with anchor the checkout's current release tag as far as it is known. iss.BlockedByOpen must be the derived projection List fills (the +// blockers still in open/). The rules are asked in a fixed order, and the +// first that excludes the issue decides: not open, blocked, security, a +// category outside the rule's set, a severity outside it, a remedy that waits +// on a ruling, a live deferral, no remedy, the automatic filers' remedy. An +// issue no rule excludes is eligible. // // Blocked comes first because a blocked issue is not considered at all until // its blocker clears; the severity and category hand-backs come before the -// missing remedy because adding a remedy would not make such an issue -// eligible, so naming the remedy would send a reader to the wrong fix. -func eligibility(iss Issue) DrainVerdict { +// others because answering a ruling, waiting out a deferral or adding a remedy +// would not make such an issue eligible, so naming them would send a reader to +// the wrong fix. A remedy waiting on a ruling is named before a deferral +// because it says which decision is owed; a record carrying both waits on +// both. `capture defer` writes a deferral only onto a major or +// critical record, but a hand-written one on a lighter record is read alike. +func eligibility(iss Issue, r drainrule.Rule, anchor deferralAnchor) DrainVerdict { v := DrainVerdict{ID: iss.ID, Path: iss.Path, Severity: iss.Severity, Category: iss.Category} - decide := func(o DrainOutcome, r DrainRule, reason string) DrainVerdict { - v.Outcome, v.Rule, v.Reason = o, r, reason + decide := func(o DrainOutcome, rule DrainRule, reason string) DrainVerdict { + v.Outcome, v.Rule, v.Reason = o, rule, reason return v } switch { @@ -110,13 +110,24 @@ func eligibility(iss Issue) DrainVerdict { case len(iss.BlockedByOpen) > 0: v.Blockers = append([]string(nil), iss.BlockedByOpen...) return decide(DrainSkipped, RuleBlocked, "blocked by "+strings.Join(iss.BlockedByOpen, ", ")+", still open") - case iss.Category == "security": - return decide(DrainHandBack, RuleSecurity, "category security is always a person's") - case slices.Index(DrainCategories, iss.Category) < 0: + case iss.Category == drainrule.SecurityCategory && r.Security != drainrule.SecurityTake: + return decide(DrainHandBack, RuleSecurity, "category security is always a person's under this repository's rule") + case !r.TakesCategory(string(iss.Category)): return decide(DrainHandBack, RuleCategory, fmt.Sprintf("category %s is outside the fixable set (%s)", - iss.Category, joinCategories(DrainCategories))) - case slices.Index(DrainSeverities, iss.Severity) < 0: - return decide(DrainHandBack, RuleSeverity, fmt.Sprintf("severity %s is above the drain's (nitpick, minor)", iss.Severity)) + iss.Category, strings.Join(r.Categories, ", "))) + case !r.TakesSeverity(string(iss.Severity)): + return decide(DrainHandBack, RuleSeverity, fmt.Sprintf("severity %s is above the drain's (%s)", + iss.Severity, strings.Join(r.Severities, ", "))) + case waitsOnRuling(iss.Remedy): + return decide(DrainHandBack, RuleWaitsOnRuling, + "the remedy opens \"Waits on\": the fix waits on a person's ruling, so it is a person's until the ruling is given and the remedy rewritten") + case anchor.unknown && iss.deferredAfter != "": + return decide(DrainHandBack, RuleDeferred, fmt.Sprintf( + "deferred past %s, anchor unknown: this checkout holds no release tag (a shallow clone fetches none), so whether the deferral is live cannot be read and it is a person's; `git fetch --tags` and drain again", + iss.deferredAfter)) + case anchor.tag != "" && iss.deferredAfter == anchor.tag: + return decide(DrainHandBack, RuleDeferred, fmt.Sprintf( + "deferred past %s, the current anchor: a person carried it past this release, so it is a person's until the deferral lapses", anchor.tag)) case strings.TrimSpace(iss.Remedy) == "": return decide(DrainIneligible, RuleRemedy, fmt.Sprintf( "no remedy: field; ineligible until someone adds one with `abcd capture remedy %s \"\"`", iss.ID)) @@ -131,24 +142,38 @@ func eligibility(iss Issue) DrainVerdict { "every field rule passes on its remedy; the host judgement over the remedy may still hand it back") } -func joinCategories(cs []Category) string { - s := make([]string, len(cs)) - for i, c := range cs { - s[i] = string(c) +// waitsOnPrefix opens a remedy whose fix waits on an unanswered ruling, the +// shape the ledger's remedies are written in ("Waits on : ..."). +const waitsOnPrefix = "waits on" + +// waitsOnRuling reports whether a remedy opens "Waits on" as words: followed +// by a blank, a colon, or nothing, compared case-folded after leading blanks, +// so "Waits on: ruling H4." and a lower-case spelling are held back too, and +// "Waits onward" is not. +func waitsOnRuling(remedy string) bool { + rest, ok := strings.CutPrefix(strings.ToLower(strings.TrimSpace(remedy)), waitsOnPrefix) + if !ok { + return false } - return strings.Join(s, ", ") + return rest == "" || rest[0] == ':' || rest[0] == ' ' || rest[0] == '\t' || rest[0] == '\n' || rest[0] == '\r' +} + +// drainOrder states the ordering rule r takes eligible issues in. +func drainOrder(r drainrule.Rule) string { + return "category " + strings.Join(r.Categories, ", ") + "; then " + + strings.Join(r.Severities, " before ") + "; then oldest first" } // orderEligible sorts eligible verdicts by the drain order: category, then // severity, then oldest first (ascending id number; ids are minted in time // order, the hand-numbered ordinals before the timestamp ids). -func orderEligible(vs []DrainVerdict) { +func orderEligible(vs []DrainVerdict, r drainrule.Rule) { sort.SliceStable(vs, func(i, j int) bool { a, b := vs[i], vs[j] - if ca, cb := slices.Index(DrainCategories, a.Category), slices.Index(DrainCategories, b.Category); ca != cb { + if ca, cb := slices.Index(r.Categories, string(a.Category)), slices.Index(r.Categories, string(b.Category)); ca != cb { return ca < cb } - if sa, sb := slices.Index(DrainSeverities, a.Severity), slices.Index(DrainSeverities, b.Severity); sa != sb { + if sa, sb := slices.Index(r.Severities, string(a.Severity)), slices.Index(r.Severities, string(b.Severity)); sa != sb { return sa < sb } return issNumber(a.ID) < issNumber(b.ID) @@ -163,25 +188,52 @@ type DrainPlanRequest struct { // DrainPlan is what a drain would do over the open ledger, and writes nothing: // every open issue's disposition, the eligible ones first in the order a drain -// takes them and the rest after them by id, with the ordering rule and the -// decision record the rule is stated in. +// takes them and the rest after them by id, with the repository's rule, the +// decision record it is stated in, and every floor it loosens. type DrainPlan struct { - Record string `json:"record"` - Order string `json:"order"` - Dispositions []DrainVerdict `json:"dispositions"` - Counts map[DrainOutcome]int `json:"counts"` + // Record is the repository's own decision record the rule is read from. + Record string `json:"record"` + // Rule is the rule as that record states it. + Rule drainrule.Rule `json:"rule"` + // Loosened names every floor the record loosens against abcd's baseline + // (ruling H11); an empty list when it loosens none. + Loosened []string `json:"loosened"` + // Anchor is the release tag a live deferral names, when any open record + // carries a deferral; empty otherwise. + Anchor string `json:"anchor,omitempty"` + // AnchorUnknown reports that an open record carries a deferral and the + // checkout holds no release tag (a shallow clone fetches none), so every + // record carrying a deferral is handed back rather than judged. + AnchorUnknown bool `json:"anchor_unknown,omitempty"` + Order string `json:"order"` + Dispositions []DrainVerdict `json:"dispositions"` + Counts map[DrainOutcome]int `json:"counts"` } -// PlanDrain classifies every open issue by field. Read-only: it takes no lock -// and writes nothing, as List does. +// PlanDrain classifies every open issue by field, under the repository's own +// rule. It refuses, before reading the ledger, a repository whose rule is +// unrecorded, ambiguous or malformed. Read-only: it takes no lock and writes +// nothing, as List does. func PlanDrain(req DrainPlanRequest) (DrainPlan, error) { + repoRoot, _, err := resolveRoots(req.RepoRoot, req.IssuesRoot) + if err != nil { + return DrainPlan{}, err + } + rule, err := drainrule.Load(repoRoot) + if err != nil { + return DrainPlan{}, err + } lr, err := List(ListRequest{RepoRoot: req.RepoRoot, IssuesRoot: req.IssuesRoot, State: StateOpen}) if err != nil { return DrainPlan{}, err } + anchor, err := liveDeferralAnchor(repoRoot, lr.Issues) + if err != nil { + return DrainPlan{}, err + } var eligible, rest []DrainVerdict for _, iss := range lr.Issues { - v := eligibility(iss) + v := eligibility(iss, rule, anchor) if v.Outcome == DrainEligible { eligible = append(eligible, v) } else { @@ -199,13 +251,17 @@ func PlanDrain(req DrainPlanRequest) (DrainPlan, error) { rest = append(rest, DrainVerdict{ID: id, Path: sk.Path, Outcome: DrainUnreadable, Rule: RuleUnreadable, Reason: fmt.Sprintf("the reader refuses the record at its %s stage: %s", sk.Layer, sk.Error)}) } - orderEligible(eligible) + orderEligible(eligible, rule) sort.SliceStable(rest, func(i, j int) bool { return issNumber(rest[i].ID) < issNumber(rest[j].ID) }) plan := DrainPlan{ - Record: EligibilityRecord, - Order: DrainOrder, - Dispositions: append(append([]DrainVerdict{}, eligible...), rest...), - Counts: map[DrainOutcome]int{}, + Record: rule.Record, + Rule: rule, + Loosened: rule.Loosened, + Anchor: anchor.tag, + AnchorUnknown: anchor.unknown, + Order: drainOrder(rule), + Dispositions: append(append([]DrainVerdict{}, eligible...), rest...), + Counts: map[DrainOutcome]int{}, } for _, v := range plan.Dispositions { plan.Counts[v.Outcome]++ @@ -213,27 +269,60 @@ func PlanDrain(req DrainPlanRequest) (DrainPlan, error) { return plan, nil } -// ErrDrainRuleUnrecorded is the refusal when the eligibility rule has no -// decision record (itd-82 decision 4, criterion 11). -var ErrDrainRuleUnrecorded = errors.New("the drain eligibility rule has no decision record") +// deferralAnchor is the release tag a deferral is judged against: the tag, or +// unknown when an open record carries a deferral and the checkout holds no +// release tag. The zero value is "no deferral to judge". +type deferralAnchor struct { + tag string + unknown bool +} + +// liveDeferralAnchor returns the checkout's current release tag when any open +// record carries a deferral, and the zero anchor when none does. The tags are +// read only when a deferral needs judging, and not knowing whether a deferral +// is live never lets its record through: a failure to read the tags refuses +// the plan, and a checkout holding no release tag (a shallow clone fetches +// none) marks the anchor unknown, which hands back every record carrying a +// deferral. A live deferral is a person's decision. +func liveDeferralAnchor(repoRoot string, issues []Issue) (deferralAnchor, error) { + if !slices.ContainsFunc(issues, func(iss Issue) bool { return iss.deferredAfter != "" }) { + return deferralAnchor{}, nil + } + tag, found, err := changelog.LatestReleaseTag(repoRoot) + if err != nil { + return deferralAnchor{}, fmt.Errorf("drain: an open record carries a deferral, and the release tags that say whether it is live could not be read: %w", err) + } + if !found { + return deferralAnchor{unknown: true}, nil + } + return deferralAnchor{tag: tag.Tag()}, nil +} + +// ErrDrainRuleUnrecorded is the refusal when the drained repository holds no +// record of its eligibility rule (itd-82 decision 4, criterion 11; ruling BX2). +var ErrDrainRuleUnrecorded = drainrule.ErrUnrecorded // ErrDrainRunUnbuilt is the refusal of the run itself: the issue-keyed lane a // drain hands each eligible issue to (itd-2609201916151817 decision 10) is // not built, so there is nothing safe to start. var ErrDrainRunUnbuilt = errors.New("the drain run is not built") -// DrainStart is the check an unattended drain makes before it starts. It -// refuses without the eligibility rule's decision record, naming the record it -// needs, and otherwise refuses because the run has no lane to hand an issue -// to yet. Writes nothing. -func DrainStart() error { return drainStartCheck(EligibilityRecord) } - -func drainStartCheck(record string) error { - if record == "" { - return fmt.Errorf("%w: an unattended drain needs the decision record itd-82 decision 4 owes "+ - "(the rule for which issues need no decision) before it starts", ErrDrainRuleUnrecorded) +// DrainStart is the check an unattended drain makes before it starts. It reads +// the repository's own rule and refuses without it, naming how to add it, or +// when it is ambiguous or malformed. With the rule it still refuses, because +// the run has no lane to hand an issue to yet, naming the rule's record and +// every floor the record loosens (ruling H11). Writes nothing. +func DrainStart(repoRoot string) error { + rule, err := drainrule.Load(repoRoot) + if err != nil { + return err + } + loosened := "" + if len(rule.Loosened) > 0 { + loosened = fmt.Sprintf("; this repository's rule loosens abcd's floors, letting a drain take %s", + strings.Join(rule.Loosened, ", ")) } return fmt.Errorf("%w: the issue-keyed lane it hands each eligible issue to does not exist yet "+ - "(itd-2609201916151817 decision 10); `abcd drain --dry-run` shows what it would do under %s", - ErrDrainRunUnbuilt, record) + "(itd-2609201916151817 decision 10); `abcd drain --dry-run` shows what it would do under %s%s", + ErrDrainRunUnbuilt, rule.Record, loosened) } diff --git a/internal/core/capture/eligible_test.go b/internal/core/capture/eligible_test.go index e391d89e0..59da1b5a5 100644 --- a/internal/core/capture/eligible_test.go +++ b/internal/core/capture/eligible_test.go @@ -6,6 +6,9 @@ import ( "path/filepath" "strings" "testing" + + "github.com/intentdriven/abcd/internal/core/drainrule" + "github.com/intentdriven/abcd/internal/gittest" ) // The drain's field-only eligibility (itd-82 decisions 4 and 7, @@ -22,9 +25,31 @@ type drainFixture struct { func newDrainFixture(t *testing.T) drainFixture { t.Helper() repo, ir := ledger(t) + writeRuleRecord(t, repo, drainrule.ProposalFrontmatter()) return drainFixture{t: t, repo: repo, ir: ir} } +// strictRuleRecord is the id the fixtures' strict record carries. +const strictRuleRecord = "adr-2609300000000001" + +// writeRuleRecord writes the repository's own drain eligibility record (ruling +// BX2): an accepted decision record carrying the drain fields given. +func writeRuleRecord(t *testing.T, repo, fields string) { + t.Helper() + dir := filepath.Join(repo, filepath.FromSlash(drainrule.ADRsRelDir)) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + body := "---\nid: " + strictRuleRecord + "\nslug: drain-rule\nstatus: accepted\ndate: 2026-09-30\n" + fields + "---\n\n# ADR\n" + if err := os.WriteFile(filepath.Join(dir, "2609300000000001-drain-rule.md"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +// loosenedFields is a project rule that loosens both floors H11 names. +const loosenedFields = "drain_categories: [tech-debt, documentation, inconsistency, drift, bug, ux]\n" + + "drain_severities: [nitpick, minor, major]\ndrain_security: take\ndrain_remedy: required\n" + // file captures one open record. An empty remedy files a LEGACY record: // capture refuses a new issue without a remedy (ruling BX3), so the record is // filed with one and the key is then taken out, which is the shape every @@ -162,30 +187,30 @@ func TestDrainRoutesAMixedLedgerByField(t *testing.T) { func TestEligibilityIsTheFieldRuleAndNothingElse(t *testing.T) { for _, cat := range []Category{"tech-debt", "documentation", "inconsistency", "drift", "bug", "ux"} { for _, sev := range []Severity{SeverityNitpick, SeverityMinor} { - v := eligibility(Issue{ID: "iss-1", Category: cat, Severity: sev, Remedy: "r", Status: StateOpen}) + v := eligibility(Issue{ID: "iss-1", Category: cat, Severity: sev, Remedy: "r", Status: StateOpen}, drainrule.Baseline(), deferralAnchor{}) if v.Outcome != DrainEligible { t.Errorf("%s/%s with a remedy: %s (%s), want eligible", cat, sev, v.Outcome, v.Reason) } } } for _, cat := range []Category{"process", "observation", "architectural-insight", "future-work-seed", "lapse"} { - v := eligibility(Issue{ID: "iss-1", Category: cat, Severity: SeverityMinor, Remedy: "r", Status: StateOpen}) + v := eligibility(Issue{ID: "iss-1", Category: cat, Severity: SeverityMinor, Remedy: "r", Status: StateOpen}, drainrule.Baseline(), deferralAnchor{}) if v.Outcome != DrainHandBack || v.Rule != RuleCategory { t.Errorf("%s: %s/%s, want handback/category", cat, v.Outcome, v.Rule) } } for _, sev := range []Severity{SeverityMajor, SeverityCritical} { - v := eligibility(Issue{ID: "iss-1", Category: "bug", Severity: sev, Remedy: "r", Status: StateOpen}) + v := eligibility(Issue{ID: "iss-1", Category: "bug", Severity: sev, Remedy: "r", Status: StateOpen}, drainrule.Baseline(), deferralAnchor{}) if v.Outcome != DrainHandBack || v.Rule != RuleSeverity { t.Errorf("%s: %s/%s, want handback/severity", sev, v.Outcome, v.Rule) } } // A remedy of blanks is no remedy. - if v := eligibility(Issue{ID: "iss-1", Category: "bug", Severity: SeverityMinor, Remedy: " \t", Status: StateOpen}); v.Outcome != DrainIneligible { + if v := eligibility(Issue{ID: "iss-1", Category: "bug", Severity: SeverityMinor, Remedy: " \t", Status: StateOpen}, drainrule.Baseline(), deferralAnchor{}); v.Outcome != DrainIneligible { t.Errorf("a blank remedy: %s, want ineligible", v.Outcome) } // A record that is not open is never a drain's to take. - if v := eligibility(Issue{ID: "iss-1", Category: "bug", Severity: SeverityMinor, Remedy: "r", Status: StateResolved}); v.Outcome == DrainEligible { + if v := eligibility(Issue{ID: "iss-1", Category: "bug", Severity: SeverityMinor, Remedy: "r", Status: StateResolved}, drainrule.Baseline(), deferralAnchor{}); v.Outcome == DrainEligible { t.Errorf("a resolved record was eligible") } } @@ -237,8 +262,8 @@ func TestDrainPlanWritesNothing(t *testing.T) { if after := snapshotTree(t, f.repo); after != before { t.Fatalf("the plan changed the tree:\nbefore %s\nafter %s", before, after) } - if p.Record != EligibilityRecord || !strings.HasPrefix(p.Record, "adr-") { - t.Errorf("the plan names the rule's record %q, want %q", p.Record, EligibilityRecord) + if p.Record != strictRuleRecord { + t.Errorf("the plan names the rule's record %q, want the repository's own %q", p.Record, strictRuleRecord) } } @@ -259,24 +284,180 @@ func TestDrainPlanGivesAnUnreadableOpenRecordItsOwnDisposition(t *testing.T) { } } -// TestADrainRefusesToStartWithoutTheRuleRecord is criterion 11: with no -// decision record for the eligibility rule, the run refuses naming the record it -// needs; with one, it still refuses, because the issue-keyed lane it would hand -// each issue to is not built, and it says so rather than pretending to run. -func TestADrainRefusesToStartWithoutTheRuleRecord(t *testing.T) { - err := drainStartCheck("") - if !errors.Is(err, ErrDrainRuleUnrecorded) { - t.Fatalf("no rule record: got %v, want ErrDrainRuleUnrecorded", err) +// TestADrainRefusesARepositoryWithoutItsOwnRule is criterion 11 under ruling +// BX2: the repository must hold the eligibility decision in its own record, and +// until it does both the dry run and the start refuse, naming how to add it. +// With the record, the start still refuses, because the issue-keyed lane it +// would hand each issue to is not built, and it says so rather than pretending +// to run. +func TestADrainRefusesARepositoryWithoutItsOwnRule(t *testing.T) { + repo, ir := ledger(t) + f := drainFixture{t: t, repo: repo, ir: ir} + f.file("iss-1", SeverityMinor, "bug", "r") + before := snapshotTree(t, repo) + if p, err := PlanDrain(DrainPlanRequest{RepoRoot: repo, IssuesRoot: ir}); !errors.Is(err, ErrDrainRuleUnrecorded) { + t.Fatalf("a dry run without the repository's rule: plan %+v, err %v; want ErrDrainRuleUnrecorded", p, err) + } else if !strings.Contains(err.Error(), "ahoy install") || !strings.Contains(err.Error(), drainrule.FieldCategories) { + t.Errorf("the refusal does not name how to add the record: %v", err) + } + err := DrainStart(repo) + if !errors.Is(err, ErrDrainRuleUnrecorded) || !strings.Contains(err.Error(), "ahoy install") { + t.Fatalf("a start without the repository's rule: got %v, want ErrDrainRuleUnrecorded naming ahoy install", err) } - if !strings.Contains(err.Error(), "itd-82") || !strings.Contains(err.Error(), "decision record") { - t.Errorf("the refusal does not name the record it needs: %v", err) + if after := snapshotTree(t, repo); after != before { + t.Fatalf("a refused drain wrote:\nbefore %s\nafter %s", before, after) } - err = DrainStart() + + writeRuleRecord(t, repo, drainrule.ProposalFrontmatter()) + err = DrainStart(repo) if !errors.Is(err, ErrDrainRunUnbuilt) { t.Fatalf("with the record: got %v, want ErrDrainRunUnbuilt", err) } - if !strings.Contains(err.Error(), "--dry-run") { - t.Errorf("the refusal does not name the dry run it can do: %v", err) + if !strings.Contains(err.Error(), "--dry-run") || !strings.Contains(err.Error(), strictRuleRecord) { + t.Errorf("the refusal does not name the dry run and the rule's record: %v", err) + } + if strings.Contains(err.Error(), "loosen") { + t.Errorf("the strict rule's start names a loosened floor: %v", err) + } +} + +// TestADrainAppliesTheRepositorysOwnRule: the rule is the record's, not the +// binary's. A narrowed record hands back what the baseline would take. +func TestADrainAppliesTheRepositorysOwnRule(t *testing.T) { + repo, ir := ledger(t) + writeRuleRecord(t, repo, "drain_categories: [documentation]\ndrain_severities: [nitpick]\n"+ + "drain_security: handback\ndrain_remedy: required\n") + f := drainFixture{t: t, repo: repo, ir: ir} + f.file("iss-1", SeverityNitpick, "documentation", "fix the typo") + f.file("iss-2", SeverityMinor, "documentation", "fix the page") + f.file("iss-3", SeverityNitpick, "bug", "guard the nil map") + p := f.plan() + for id, want := range map[string]string{ + "iss-1": "eligible/fields", "iss-2": "handback/severity", "iss-3": "handback/category", + } { + v := verdictOf(t, p, id) + if got := string(v.Outcome) + "/" + string(v.Rule); got != want { + t.Errorf("%s: %s (%s), want %s", id, got, v.Reason, want) + } + } + if !strings.Contains(verdictOf(t, p, "iss-2").Reason, "(nitpick)") { + t.Errorf("the severity hand-back does not name the repository's severities: %s", verdictOf(t, p, "iss-2").Reason) + } + if len(p.Loosened) != 0 || p.Loosened == nil { + t.Errorf("a narrowed rule: loosened = %#v, want an empty list", p.Loosened) + } +} + +// TestALoosenedRuleIsLoud is ruling H11: a project's record may let a drain +// take major issues and security issues, and every floor it loosens is named +// in the dry run's plan and in the start's refusal. +func TestALoosenedRuleIsLoud(t *testing.T) { + repo, ir := ledger(t) + writeRuleRecord(t, repo, loosenedFields) + f := drainFixture{t: t, repo: repo, ir: ir} + f.file("iss-1", SeverityMajor, "bug", "rewrite the parser") + f.file("iss-2", SeverityMinor, "security", "tighten the check") + f.file("iss-3", SeverityCritical, "bug", "stop the data loss") + p := f.plan() + if strings.Join(p.Loosened, ", ") != "severity major, security" { + t.Errorf("the plan names loosened floors %v, want [severity major security]", p.Loosened) + } + for id, want := range map[string]string{ + "iss-1": "eligible/fields", "iss-2": "eligible/fields", "iss-3": "handback/severity", + } { + v := verdictOf(t, p, id) + if got := string(v.Outcome) + "/" + string(v.Rule); got != want { + t.Errorf("%s: %s (%s), want %s", id, got, v.Reason, want) + } + } + // Security is taken last, after every fixable category. + if !strings.Contains(p.Order, "ux, security") || !strings.Contains(p.Order, "nitpick before minor before major") { + t.Errorf("the order does not state the loosened rule: %s", p.Order) + } + err := DrainStart(repo) + for _, want := range []string{"loosens", "severity major", "security"} { + if err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("the start's refusal does not name %q: %v", want, err) + } + } +} + +// TestAMalformedRuleRefusesTheDrain: a partial record refuses the dry run and +// the start alike, never falling back to either the baseline or a looser rule. +func TestAMalformedRuleRefusesTheDrain(t *testing.T) { + repo, ir := ledger(t) + writeRuleRecord(t, repo, "drain_categories: [bug]\ndrain_severities: [nitpick, minor, major]\ndrain_security: take\n") + f := drainFixture{t: t, repo: repo, ir: ir} + f.file("iss-1", SeverityMinor, "bug", "r") + if p, err := PlanDrain(DrainPlanRequest{RepoRoot: repo, IssuesRoot: ir}); !errors.Is(err, drainrule.ErrMalformed) { + t.Fatalf("a partial record: plan %+v, err %v; want ErrMalformed", p, err) + } + if err := DrainStart(repo); !errors.Is(err, drainrule.ErrMalformed) || !strings.Contains(err.Error(), drainrule.FieldRemedy) { + t.Fatalf("a partial record's start: %v; want ErrMalformed naming %s", err, drainrule.FieldRemedy) + } +} + +// TestADrainHandsBackARecordWaitingOnAPerson is the gap the remedy lanes found: +// a record whose remedy opens "Waits on" names a fix that waits on a person's +// ruling, and a record whose deferral past the current anchor tag is live was +// carried past this release by a person. Both are handed back, each naming its +// rule, whatever the project's rule would otherwise let through. A deferral +// past an earlier tag has lapsed and holds nothing back. +func TestADrainHandsBackARecordWaitingOnAPerson(t *testing.T) { + r := gittest.NewRepo(t) + r.Commit("root") + r.Git("tag", "v0.1.0") + r.Commit("next") + r.Git("tag", "v0.2.0") + repo := r.Root() + ir := filepath.Join(repo, LedgerRelPath) + writeRuleRecord(t, repo, loosenedFields) + f := drainFixture{t: t, repo: repo, ir: ir} + f.file("iss-1", SeverityMinor, "bug", "Waits on ruling G (keep or drop the flag): if dropped, delete it") + f.file("iss-2", SeverityMajor, "bug", "rewrite the parser") + f.file("iss-3", SeverityMajor, "bug", "rewrite the lexer") + f.file("iss-4", SeverityMinor, "bug", "waits on the facilitator's round trip: then rerun it") + f.file("iss-5", SeverityMinor, "bug", "guard the nil map; it waits on nothing") + setDeferral(t, ir, "iss-2", "v0.2.0") + setDeferral(t, ir, "iss-3", "v0.1.0") + + p := f.plan() + if p.Anchor != "v0.2.0" { + t.Errorf("the plan's anchor = %q, want v0.2.0", p.Anchor) + } + cases := map[string]struct{ want, reason string }{ + "iss-1": {"handback/waits-on-ruling", "Waits on"}, + "iss-2": {"handback/deferred", "v0.2.0"}, + "iss-3": {"eligible/fields", ""}, + "iss-4": {"handback/waits-on-ruling", "Waits on"}, + "iss-5": {"eligible/fields", ""}, + } + for id, c := range cases { + v := verdictOf(t, p, id) + if got := string(v.Outcome) + "/" + string(v.Rule); got != c.want { + t.Errorf("%s: %s (%s), want %s", id, got, v.Reason, c.want) + } + if !strings.Contains(v.Reason, c.reason) { + t.Errorf("%s: the reason %q does not name %q", id, v.Reason, c.reason) + } + } +} + +// setDeferral writes the waiver pair onto an open record by hand, the shape +// `capture defer` writes. +func setDeferral(t *testing.T, ir, id, after string) { + t.Helper() + matches, _ := filepath.Glob(filepath.Join(ir, "open", id+"-*.md")) + if len(matches) != 1 { + t.Fatalf("no open record for %s", id) + } + raw, err := os.ReadFile(matches[0]) + if err != nil { + t.Fatal(err) + } + s := strings.Replace(string(raw), "\nslug: ", "\ndeferred_after: \""+after+"\"\ndeferral_reason: a person's reason\nslug: ", 1) + if err := os.WriteFile(matches[0], []byte(s), 0o644); err != nil { + t.Fatal(err) } } @@ -306,12 +487,16 @@ func snapshotTree(t *testing.T, root string) string { return b.String() } -// TestTheEligibilityRuleIsRecordedAndAccepted is the tree half of criterion 11 -// and itd-82 decision 4: the record the binary names as the rule's is an -// accepted decision record in this repository's store, and the brief's -// invariants cite it. Deleting the record, leaving it proposed, or dropping the -// invariant fails here, before a drain could ship without them. -func TestTheEligibilityRuleIsRecordedAndAccepted(t *testing.T) { +// abcdsOwnRuleRecord is the record abcd's own repository states its drain rule +// in, cited by the brief's invariants. +const abcdsOwnRuleRecord = "adr-2609291342092738" + +// TestAbcdsOwnDrainRuleIsTheStrictBaseline is the tree half of criterion 11, +// itd-82 decision 4 and ruling H11's note: this repository holds its own drain +// rule in an accepted record, that record states abcd's strict baseline and +// loosens nothing, and the brief's invariants cite it. Loosening the record, +// deleting it, leaving it proposed, or dropping the invariant fails here. +func TestAbcdsOwnDrainRuleIsTheStrictBaseline(t *testing.T) { dir, err := os.Getwd() if err != nil { t.Fatal(err) @@ -326,23 +511,93 @@ func TestTheEligibilityRuleIsRecordedAndAccepted(t *testing.T) { } dir = parent } - stamp := strings.TrimPrefix(EligibilityRecord, "adr-") - matches, err := filepath.Glob(filepath.Join(dir, ".abcd", "development", "decisions", "adrs", stamp+"-*.md")) - if err != nil || len(matches) != 1 { - t.Fatalf("the rule's record %s: want one file in the ADR store, got %v (%v)", EligibilityRecord, matches, err) - } - adr, err := os.ReadFile(matches[0]) + r, err := drainrule.Load(dir) if err != nil { - t.Fatal(err) + t.Fatalf("abcd's own repository holds no readable drain rule: %v", err) } - if !strings.Contains(string(adr), "\nid: "+EligibilityRecord+"\n") || !strings.Contains(string(adr), "\nstatus: accepted\n") { - t.Errorf("%s is not an accepted record with that id", filepath.Base(matches[0])) + if r.Record != abcdsOwnRuleRecord { + t.Errorf("abcd's drain rule is stated in %s, want %s", r.Record, abcdsOwnRuleRecord) + } + b := drainrule.Baseline() + if len(r.Loosened) != 0 || strings.Join(r.Categories, ",") != strings.Join(b.Categories, ",") || + strings.Join(r.Severities, ",") != strings.Join(b.Severities, ",") || r.Security != b.Security { + t.Errorf("abcd's own drain rule is not the strict baseline: %+v", r) } inv, err := os.ReadFile(filepath.Join(dir, ".abcd", "development", "brief", "02-constraints", "03-invariants.md")) if err != nil { t.Fatal(err) } - if !strings.Contains(string(inv), "[adr-"+stamp+"]") { - t.Errorf("the brief's invariants do not cite %s", EligibilityRecord) + if !strings.Contains(string(inv), "["+abcdsOwnRuleRecord+"]") { + t.Errorf("the brief's invariants do not cite %s", abcdsOwnRuleRecord) + } +} + +// TestADeferralIsHandedBackWhenTheCheckoutHoldsNoReleaseTag: a shallow clone +// (fetch-depth 1, the unattended drain's likely checkout) fetches no tags, so +// whether a deferral is live cannot be known. Not knowing must not let the +// record through: every record carrying a deferral is handed back, naming the +// missing tags and how to fetch them, and a record without one is untouched. +func TestADeferralIsHandedBackWhenTheCheckoutHoldsNoReleaseTag(t *testing.T) { + src := gittest.NewRepo(t) + src.Commit("root") + src.Git("tag", "v0.1.0") + repo := src.Root() + ir := filepath.Join(repo, LedgerRelPath) + writeRuleRecord(t, repo, loosenedFields) + f := drainFixture{t: t, repo: repo, ir: ir} + f.file("iss-2", SeverityMajor, "bug", "rewrite the parser") + f.file("iss-3", SeverityMajor, "bug", "rewrite the lexer") + f.file("iss-5", SeverityMinor, "bug", "guard the nil map") + setDeferral(t, ir, "iss-2", "v0.1.0") + setDeferral(t, ir, "iss-3", "v0.0.9") + src.Commit("ledger") + src.Git("tag", "v0.2.0") + + clone := filepath.Join(t.TempDir(), "clone") + src.Git("clone", "--quiet", "--depth", "1", "--no-tags", "file://"+repo, clone) + shallow := drainFixture{t: t, repo: clone, ir: filepath.Join(clone, LedgerRelPath)} + p := shallow.plan() + if p.Anchor != "" { + t.Errorf("a tagless clone reports anchor %q", p.Anchor) + } + cases := map[string]string{ + "iss-2": "handback/deferred", + "iss-3": "handback/deferred", + "iss-5": "eligible/fields", + } + for id, want := range cases { + v := verdictOf(t, p, id) + if got := string(v.Outcome) + "/" + string(v.Rule); got != want { + t.Errorf("%s: %s (%s), want %s", id, got, v.Reason, want) + } + if want == "handback/deferred" { + for _, w := range []string{"anchor unknown", "no release tag", "git fetch --tags"} { + if !strings.Contains(v.Reason, w) { + t.Errorf("%s: the reason %q does not name %q", id, v.Reason, w) + } + } + } + } + if !p.AnchorUnknown { + t.Error("the plan does not say its anchor is unknown") + } +} + +// TestWaitsOnIsReadAsAWordNotAPrefix: "Waits on" followed by a colon, or +// ending the remedy, opens a remedy that waits on a ruling as surely as one +// followed by a space; a word that merely starts with it does not. +func TestWaitsOnIsReadAsAWordNotAPrefix(t *testing.T) { + for remedy, want := range map[string]bool{ + "Waits on: ruling H4.": true, + "WAITS ON:H4": true, + "waits on": true, + " Waits on\tH4: then do it": true, + "Waits on ruling G: drop it": true, + "Waits onward: nothing": false, + "guard the map; it waits on none": false, + } { + if got := waitsOnRuling(remedy); got != want { + t.Errorf("waitsOnRuling(%q) = %v, want %v", remedy, got, want) + } } } diff --git a/internal/core/capture/match.go b/internal/core/capture/match.go index 5341aeb92..0e0d8be47 100644 --- a/internal/core/capture/match.go +++ b/internal/core/capture/match.go @@ -2,10 +2,16 @@ package capture import ( "fmt" + "os" + "path/filepath" + "slices" "strings" "github.com/intentdriven/abcd/internal/core/intent" + "github.com/intentdriven/abcd/internal/core/issueschema" + "github.com/intentdriven/abcd/internal/core/readingitem" "github.com/intentdriven/abcd/internal/core/record/match" + "github.com/intentdriven/abcd/internal/core/recordid" ) // match.go is the ledger's half of the filing-time match @@ -74,8 +80,9 @@ func matchCandidates(repoRoot, issuesRoot string, cfg match.Config) ([]match.Can // record's rendered content, validating the frontmatter they join. It runs // under the ledger lock, before the write. It never fails the capture: a // candidate set that cannot be read, or a link the schema would refuse, comes -// back as an outcome saying why, with the content unlinked. -func matchAndLink(repoRoot, issuesRoot string, cfg match.Config, text, content string, fm map[string]any) (string, *match.Outcome) { +// back as an outcome saying why, with the content unlinked. A candidate named +// in except is not compared. +func matchAndLink(repoRoot, issuesRoot string, cfg match.Config, text string, except []string, content string, fm map[string]any) (string, *match.Outcome) { if match.Short(text) { o := match.Rank(text, nil, cfg.Threshold) return content, &o @@ -85,11 +92,29 @@ func matchAndLink(repoRoot, issuesRoot string, cfg match.Config, text, content s o := match.Unread(cfg.Threshold, err) return content, &o } - o := match.Rank(text, cands, cfg.Threshold) + return linkMatches(rankExcept(text, cands, except, cfg.Threshold), content, fm, validateStrict) +} + +// rankExcept ranks text against every candidate not named in except: the +// records a filer has already filed in the same pass, which are never doubles +// of each other. +func rankExcept(text string, cands []match.Candidate, except []string, threshold float64) match.Outcome { + if len(except) > 0 { + cands = slices.DeleteFunc(slices.Clone(cands), func(c match.Candidate) bool { return slices.Contains(except, c.ID) }) + } + return match.Rank(text, cands, threshold) +} + +// linkMatches writes an outcome's links into a record's rendered content and +// validates the frontmatter they join with the record family's own validator. +// A link the validator refuses comes back as an outcome saying so, with the +// content unlinked: the match never refuses the write. +func linkMatches(o match.Outcome, content string, fm map[string]any, validate func(map[string]any) error) (string, *match.Outcome) { links := o.Links() if len(links) == 0 { return content, &o } + var err error linked := content withLinks := make(map[string]any, len(fm)+len(links)) for k, v := range fm { @@ -106,7 +131,7 @@ func matchAndLink(repoRoot, issuesRoot string, cfg match.Config, text, content s } } if err == nil { - err = validateStrict(withLinks) + err = validate(withLinks) } if err != nil { o.Skipped = fmt.Sprintf("the links could not be written (%v), so the record is filed unlinked", err) @@ -117,3 +142,73 @@ func matchAndLink(repoRoot, issuesRoot string, cfg match.Config, text, content s } return linked, &o } + +// readingMatchText is a reading item's comparable text: the pattern it names +// and the body its position declares, in the declared order. That is the +// finding in the instrument's own words. The envelope (run, manifest, +// position, regime) is left out, because every item of a run carries the same +// envelope and two findings would otherwise match on it alone. +func readingMatchText(fm map[string]any) string { + parts := []string{asString(fm["pattern"])} + for _, f := range issueschema.ReadingBodyFields[asString(fm["position"])] { + parts = append(parts, asString(fm[f])) + } + return strings.Join(parts, "\n") +} + +// readingFilingCandidates is the candidate set a stored reading finding is +// matched against (ruling DQ2b, adr-2609300821558671): the capture set, and +// every reading item already in the ledger, so a finding a later reading +// returns again is linked to the item that first carried it. A reading record +// the family's validator refuses is not a candidate, as a skipped issue is not +// one; a readings directory that cannot be listed makes the set unknown, which +// is an error the caller reports as an unread match. +func readingFilingCandidates(repoRoot, issuesRoot string, cfg match.Config) ([]match.Candidate, error) { + out, err := matchCandidates(repoRoot, issuesRoot, cfg) + if err != nil { + return nil, err + } + readingsRoot := filepath.Join(issuesRoot, issueschema.ReadingsDir) + if err := readingitem.RefuseSymlinkedDir(readingsRoot); err != nil { + return nil, wrapLocatorErr(err) + } + runs, err := os.ReadDir(readingsRoot) + if err != nil { + if os.IsNotExist(err) { + return out, nil + } + return nil, err + } + for _, run := range runs { + if !recordid.ValidReadingRunID(run.Name()) { + continue + } + runDir := filepath.Join(readingsRoot, run.Name()) + if err := readingitem.RefuseSymlinkedDir(runDir); err != nil { + return nil, wrapLocatorErr(err) + } + if !run.IsDir() { + continue + } + items, err := os.ReadDir(runDir) + if err != nil { + return nil, err + } + for _, it := range items { + id, ok := strings.CutSuffix(it.Name(), ".md") + if !ok || !recordid.ValidReadingItemID(id) || !it.Type().IsRegular() { + continue + } + content, err := readRecordGuarded(filepath.Join(runDir, it.Name())) + if err != nil { + continue + } + fm, _, err := parseFrontmatterAndBody(content) + if err != nil || validateReadingStrict(fm) != nil { + continue + } + out = append(out, match.Candidate{ID: id, Text: readingMatchText(fm)}) + } + } + return out, nil +} diff --git a/internal/core/capture/promote.go b/internal/core/capture/promote.go index f2df7efe5..23155cbce 100644 --- a/internal/core/capture/promote.go +++ b/internal/core/capture/promote.go @@ -10,6 +10,7 @@ import ( "github.com/intentdriven/abcd/internal/core/intent" "github.com/intentdriven/abcd/internal/core/issueschema" "github.com/intentdriven/abcd/internal/core/provenance" + "github.com/intentdriven/abcd/internal/core/record/match" "github.com/intentdriven/abcd/internal/core/recordid" "github.com/intentdriven/abcd/internal/fsutil" ) @@ -37,6 +38,12 @@ type PromoteRequest struct { // derives extracted-from-record from what it did rather than from what it was // told. ProductionMode string + // Match, when non-nil, runs the filing-time match on the draft a reading + // item's promotion mints (ruling DQ2b): the item's finding, in its own + // words, is compared with the open and resolved issues and the intents, + // and each likely double is written onto the draft as capture writes it. + // nil, stamp-only mode, and the issue route mint unmatched. + Match *match.Config } // PromoteResult is the outcome of a successful Promote. Paths are @@ -65,6 +72,8 @@ type PromoteResult struct { // than not recording it. Redacted int `json:"redacted,omitempty"` Degraded string `json:"redaction_degraded,omitempty"` + // Match is the minted draft's filing-time match, when one was asked for. + Match *match.Outcome `json:"match,omitempty"` } // stampWriteHook, when non-nil, replaces the atomic in-place write inside @@ -539,6 +548,7 @@ func promoteReadingItem(repoRoot, issuesRoot string, req PromoteRequest) (Promot state := issueschema.DispositionAccepted var itdID, intentPath, backEdgeKept string + var matched *match.Outcome linked := req.LinkIntent != "" if linked { if !reItdID.MatchString(req.LinkIntent) { @@ -581,7 +591,17 @@ func promoteReadingItem(repoRoot, issuesRoot string, req PromoteRequest) (Promot } seed := "Graduated from `" + req.ID + "` (" + state + "): " + title + ". Read that reading record for the instrument's own text." - it, err := intent.CreateDraft(repoRoot, intent.DraftOptions{ + // The promote step matches again (ruling DQ2b, adr-2609300821558671): + // the draft is compared, by the item's finding rather than its one-line + // pattern, with the record a capture is compared with, and linked as a + // capture is linked. + var m *intent.Matcher + if req.Match != nil { + cfg := *req.Match + m = &intent.Matcher{Threshold: cfg.Threshold, Text: readingMatchText(fm), + Candidates: func() ([]match.Candidate, error) { return matchCandidates(repoRoot, issuesRoot, cfg) }} + } + it, outcome, err := intent.CreateDraftMatched(repoRoot, intent.DraftOptions{ Slug: slug, Title: title, SeedBody: seed, @@ -594,11 +614,12 @@ func promoteReadingItem(repoRoot, issuesRoot string, req PromoteRequest) (Promot Kind: provenance.KindContributedByReading, Run: run, Item: req.ID, }, ProductionMode: req.ProductionMode, + Match: m, }) if err != nil { return PromoteResult{}, err } - itdID, intentPath = it.ID, it.Path + itdID, intentPath, matched = it.ID, it.Path, outcome } if beforeStampHook != nil { @@ -662,6 +683,7 @@ func promoteReadingItem(repoRoot, issuesRoot string, req PromoteRequest) (Promot IntentPath: intentPath, Linked: linked, BackEdgeKept: backEdgeKept, + Match: matched, }, nil } diff --git a/internal/core/capture/reading.go b/internal/core/capture/reading.go index c965f6a87..f9fbd3638 100644 --- a/internal/core/capture/reading.go +++ b/internal/core/capture/reading.go @@ -23,13 +23,16 @@ package capture import ( "errors" "fmt" + "maps" "os" "path/filepath" "regexp" + "slices" "strings" "github.com/intentdriven/abcd/internal/core/issueschema" "github.com/intentdriven/abcd/internal/core/readingitem" + "github.com/intentdriven/abcd/internal/core/record/match" "github.com/intentdriven/abcd/internal/core/recordid" "github.com/intentdriven/abcd/internal/fsutil" "github.com/intentdriven/abcd/internal/termsafe" @@ -46,6 +49,10 @@ import ( // that package. var reDispositionID = regexp.MustCompile(`^` + issueschema.DispositionFamily + `-[0-9]+$`) +// readingLinkIDRe is what a reading item's `duplicates:` or `refines:` link may +// name: the records the filing-time match compares a finding with. +var readingLinkIDRe = regexp.MustCompile(`^(iss|itd|` + issueschema.ReadingItemFamily + `)-[0-9]+$`) + // ReadingItem is one thing the instrument returned: the pattern it named (an // envelope field, because a universal core condition must not live in a variant // part) plus the position-typed body. @@ -73,6 +80,14 @@ type IngestReadingRequest struct { Position string Regime string Items []ReadingItem + // Match, when non-nil, runs the filing-time match on every item as it is + // stored (ruling DQ2b, adr-2609300821558671): each finding is compared, by + // its own words, with the open and resolved issues, the intents and every + // earlier reading item, and a likely repeat is written onto the item as a + // `duplicates:` or `refines:` link and reported. The items this ingest + // files are never candidates for each other. nil stores the items + // unmatched. + Match *match.Config } // ReadingRecordRef is one written reading record: its minted id and its @@ -91,6 +106,17 @@ type IngestReadingResult struct { // Both exist so a surface can SAY the text was altered. Redacted int `json:"redacted,omitempty"` Degraded string `json:"redaction_degraded,omitempty"` + // Matches is the filing-time match's outcome per record written, in the + // order the records were, when the request asked for a match. It is kept + // off ReadingRecordRef because that type is also the run record's + // committed list, which the match does not belong in. + Matches []ReadingMatch `json:"matches,omitempty"` +} + +// ReadingMatch is one stored reading item's filing-time match. +type ReadingMatch struct { + ID string `json:"id"` + Match *match.Outcome `json:"match"` } // DispositionRequest writes one disposition, keyed to one reading item. @@ -110,8 +136,10 @@ type DispositionRequest struct { // Supersedes names the standing disposition this one replaces (dsp-N) — the // only exit from a hold. Supersedes string - // Recurs cites prior item ids: the recorded form of the researcher's warm - // recognition of a persistence. Never a mechanical join, and never a state. + // Recurs cites prior item ids: the recorded form of the researcher's + // confirmed recognition of a persistence, never a state. The machine's + // proposal of the same thing is the item's own duplicates:/refines: link, + // written by the filing-time match when the item was stored (ruling DQ2b). Recurs []string // HoldFrameLocation / HoldMoscow are the RESERVED two-axis hold field. Both // are refused while populated; the grammars are stated and dormant. @@ -212,9 +240,21 @@ func IngestReading(req IngestReadingRequest) (IngestReadingResult, error) { return err } + // The filing-time match (ruling DQ2b, adr-2609300821558671) reads its + // candidate set once, here under the lock, so the ledger it compares + // with is the one the items join. The items this ingest mints are not + // on disk yet, and are named as exceptions besides, so two findings of + // one reading are never linked to each other. + var cands []match.Candidate + var candErr error + if req.Match != nil { + cands, candErr = readingFilingCandidates(repoRoot, issuesRoot, *req.Match) + } + // Assemble and validate EVERY item before anything is written: a run that // is half-written is a visible world nobody can reconstruct. var pending []staged + var matches []ReadingMatch minted := map[string]bool{} for i, item := range items { id, err := mintUnusedItemID(issuesRoot, minted) @@ -230,6 +270,11 @@ func IngestReading(req IngestReadingRequest) (IngestReadingResult, error) { if err != nil { return fmt.Errorf("item %d: %w", i+1, err) } + if req.Match != nil { + var o *match.Outcome + content, o = matchReadingItem(*req.Match, cands, candErr, slices.Collect(maps.Keys(minted)), content, fm) + matches = append(matches, ReadingMatch{ID: id, Match: o}) + } // The record's size is DECIDED here, on the assembled bytes, because // this is the only place the exact count exists: the values are // already redacted, already escaped, and this string is what reaches @@ -271,6 +316,7 @@ func IngestReading(req IngestReadingRequest) (IngestReadingResult, error) { ID: p.id, Path: fsutil.RepoRel(repoRoot, path), }) } + result.Matches = matches return nil }) if err != nil { @@ -281,6 +327,23 @@ func IngestReading(req IngestReadingRequest) (IngestReadingResult, error) { return result, nil } +// matchReadingItem runs the filing-time match for one reading item being +// stored and writes its links into the item's content. It never refuses the +// ingest: a short finding, an unread candidate set, or a link the reading +// schema refuses comes back as the outcome's reason, with the item unlinked. +func matchReadingItem(cfg match.Config, cands []match.Candidate, candErr error, except []string, content string, fm map[string]any) (string, *match.Outcome) { + text := readingMatchText(fm) + if match.Short(text) { + o := match.Rank(text, nil, cfg.Threshold) + return content, &o + } + if candErr != nil { + o := match.Unread(cfg.Threshold, candErr) + return content, &o + } + return linkMatches(rankExcept(text, cands, except, cfg.Threshold), content, fm, validateReadingStrict) +} + // Disposition writes the researcher's answer to one reading item, into a // directory keyed by the ITEM and a file keyed by the disposition's own id. // @@ -722,6 +785,24 @@ func validateReadingStrict(fm map[string]any) error { } } + // The filing-time match's typed links (ruling DQ2b): a likely repeat of an + // issue, an intent, or an earlier reading item. + for _, key := range []string{string(match.Duplicates), string(match.Refines)} { + v, present := fm[key] + if !present { + continue + } + items, isList := v.([]string) + if !isList { + return fmt.Errorf("%w: %q must be a list", ErrMalformedFrontmatter, key) + } + for _, it := range items { + if !readingLinkIDRe.MatchString(it) { + return fmt.Errorf("%w: %s item %q does not match ^(iss|itd|%s)-[0-9]+$", + ErrMalformedFrontmatter, key, it, issueschema.ReadingItemFamily) + } + } + } if v, present := fm["related_intents"]; present { items, isList := v.([]string) if !isList { @@ -1086,7 +1167,7 @@ func isReadingEnvelopeField(key string) bool { if containsString(issueschema.ReadingRequired, key) { return true } - return key == "related_intents" + return key == "related_intents" || key == string(match.Duplicates) || key == string(match.Refines) } // isReadingBodyField reports whether key belongs to SOME position's body. diff --git a/internal/core/capture/reading_match_test.go b/internal/core/capture/reading_match_test.go new file mode 100644 index 000000000..76cdf075d --- /dev/null +++ b/internal/core/capture/reading_match_test.go @@ -0,0 +1,215 @@ +package capture + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/issueschema" + "github.com/intentdriven/abcd/internal/core/record/match" +) + +// reading_match_test.go proves ruling DQ2b (2026-09-30) on the reading +// family: a reading finding is matched when it is stored, its likely repeats +// are written onto it as `duplicates:` / `refines:` links and reported, and +// the promote step that mints an intent draft from it matches again. + +// A finding the reading returns in its own words, doubling plantedFinding. +var readingDouble = ReadingItem{ + Pattern: "ledger reader skips records with duplicated keys", + Body: map[string]string{ + "tension": plantedDouble, + "constraint_in_play": "every finding appears in every listing of the capture ledger", + "why_a_tension": "a record whose frontmatter carries a duplicated key disappears from every listing silently", + }, +} + +// An unrelated finding, so a run has a second item and the corpus has other +// words to weigh against. +var readingOther = ReadingItem{ + Pattern: "site builder anchors go stale", + Body: map[string]string{ + "tension": "The site builder renders a stale anchor for a heading renamed since the last build.", + "constraint_in_play": "every rendered anchor resolves to a heading on the current page", + "why_a_tension": "a renamed heading leaves the anchor pointing at nothing after the rebuild", + }, +} + +func ingestDetection(t *testing.T, repo, ir, run string, m *match.Config, items ...ReadingItem) IngestReadingResult { + t.Helper() + res, err := IngestReading(IngestReadingRequest{ + RepoRoot: repo, IssuesRoot: ir, + Run: run, Manifest: "sha256:" + strings.Repeat("a", 64), + Position: "detection", Regime: issueschema.ReadingRegime("detection"), + Items: items, Match: m, + }) + if err != nil { + t.Fatalf("IngestReading: %v", err) + } + if len(res.Records) != len(items) { + t.Fatalf("IngestReading wrote %d records, want %d", len(res.Records), len(items)) + } + return res +} + +// matchOf returns the outcome the ingest reported for record id. +func matchOf(t *testing.T, res IngestReadingResult, id string) *match.Outcome { + t.Helper() + for _, m := range res.Matches { + if m.ID == id { + return m.Match + } + } + t.Fatalf("the ingest reported no match for %s: %+v", id, res.Matches) + return nil +} + +// (a) and (b): a stored reading finding that doubles an open issue carries the +// typed link naming it, the ingest reports the match, and the linked record is +// still a record the family's own validator reads. +func TestReadingIngestLinksAFindingAnOpenRecordHolds(t *testing.T) { + repo, ir := ledger(t) + held := captureText(t, repo, ir, plantedFinding, nil) + captureText(t, repo, ir, matchFiller1, nil) + captureText(t, repo, ir, matchFiller2, nil) + + res := ingestDetection(t, repo, ir, "rdg-2609300000000001", bundled(), readingDouble) + o := matchOf(t, res, res.Records[0].ID) + if o == nil || len(o.Matches) == 0 { + t.Fatalf("no match reported: %+v", o) + } + m := o.Matches[0] + if m.ID != held.ID || !m.Linked || (m.Relation != match.Duplicates && m.Relation != match.Refines) { + t.Fatalf("match = %+v, want %s linked", m, held.ID) + } + content := readRecord(t, repo, res.Records[0].Path) + if !strings.Contains(content, "\n"+string(m.Relation)+": ["+held.ID+"]\n") { + t.Fatalf("the reading record carries no %s link naming %s:\n%s", m.Relation, held.ID, content) + } + if _, err := ValidateReadingRecord(content); err != nil { + t.Fatalf("the linked reading record does not validate: %v", err) + } +} + +// The reversal itself: a later reading that returns the same finding again is +// linked to the earlier reading's item, mechanically, at storing time. +func TestReadingIngestLinksARecurrenceOfAnEarlierReadingItem(t *testing.T) { + repo, ir := ledger(t) + captureText(t, repo, ir, matchFiller1, nil) + captureText(t, repo, ir, matchFiller2, nil) + first := ingestDetection(t, repo, ir, "rdg-2609300000000001", nil, readingDouble, readingOther) + + again := ingestDetection(t, repo, ir, "rdg-2609300000000002", bundled(), readingDouble) + o := matchOf(t, again, again.Records[0].ID) + if o == nil || len(o.Matches) == 0 || o.Matches[0].ID != first.Records[0].ID || + o.Matches[0].Relation != match.Duplicates || !o.Matches[0].Linked { + t.Fatalf("the recurrence was not linked to %s: %+v", first.Records[0].ID, o) + } + content := readRecord(t, repo, again.Records[0].Path) + if !strings.Contains(content, "\nduplicates: ["+first.Records[0].ID+"]\n") { + t.Fatalf("no duplicates link on the recurrence:\n%s", content) + } +} + +// Two findings of one reading are two findings (the consistency pass's rule): +// the items one ingest files are never candidates for each other. +func TestReadingIngestNeverLinksTwoFindingsOfOneRun(t *testing.T) { + repo, ir := ledger(t) + captureText(t, repo, ir, matchFiller1, nil) + captureText(t, repo, ir, matchFiller2, nil) + twin := readingDouble + twin.Pattern = "the ledger reader skips records carrying duplicated keys" + res := ingestDetection(t, repo, ir, "rdg-2609300000000001", bundled(), readingDouble, twin) + for _, r := range res.Records { + o := matchOf(t, res, r.ID) + for _, m := range o.Matches { + for _, same := range res.Records { + if m.ID == same.ID { + t.Fatalf("%s was matched against %s, filed by the same ingest: %+v", r.ID, same.ID, o) + } + } + } + if c := readRecord(t, repo, r.Path); strings.Contains(c, "duplicates:") || strings.Contains(c, "refines:") { + t.Fatalf("%s carries a link:\n%s", r.ID, c) + } + } +} + +// Without a match configuration the ingest files unmatched and reports none, +// so every existing caller keeps its behaviour. +func TestReadingIngestWithoutAMatchReportsNone(t *testing.T) { + repo, ir := ledger(t) + captureText(t, repo, ir, plantedFinding, nil) + res := ingestDetection(t, repo, ir, "rdg-2609300000000001", nil, readingDouble) + if len(res.Matches) != 0 { + t.Fatalf("an unmatched ingest reported matches: %+v", res.Matches) + } + if c := readRecord(t, repo, res.Records[0].Path); strings.Contains(c, "duplicates:") { + t.Fatalf("an unmatched ingest wrote a link:\n%s", c) + } +} + +// The schema widening is exactly the two typed links, each a list of record +// ids the match can name; anything else stays refused. +func TestReadingRecordTypedLinksAreRecordIDs(t *testing.T) { + base := "---\nschema_version: 1\nid: rdi-2609300000000001\nrun: rdg-2609300000000001\n" + + "manifest: sha256:beef\nposition: detection\nregime: registrative\npattern: p\n" + + "tension: t\nconstraint_in_play: c\nwhy_a_tension: w\n" + for _, ok := range []string{"duplicates: [iss-1]", "refines: [itd-2]", "duplicates: [rdi-2609300000000002]"} { + if _, err := ValidateReadingRecord(base + ok + "\n---\n"); err != nil { + t.Errorf("%q refused: %v", ok, err) + } + } + for _, bad := range []string{"duplicates: [dsp-1]", "refines: iss-1", "supersedes: [rdi-1]"} { + if _, err := ValidateReadingRecord(base + bad + "\n---\n"); !errors.Is(err, ErrMalformedFrontmatter) { + t.Errorf("%q not refused as malformed: %v", bad, err) + } + } +} + +// (c): promoting an accepted reading item mints a draft that is matched again, +// on the finding's own words, and linked as capture links. +func TestPromoteReadingItemMatchesTheDraft(t *testing.T) { + repo, ir := ledger(t) + held := captureText(t, repo, ir, plantedFinding, nil) + captureText(t, repo, ir, matchFiller1, nil) + captureText(t, repo, ir, matchFiller2, nil) + res := ingestDetection(t, repo, ir, "rdg-2609300000000001", nil, readingDouble) + item := res.Records[0].ID + if _, err := Disposition(DispositionRequest{ + RepoRoot: repo, IssuesRoot: ir, Item: item, + State: issueschema.DispositionAccepted, Grounds: "the tension is real and worth acting on", + }); err != nil { + t.Fatal(err) + } + + p, err := Promote(PromoteRequest{RepoRoot: repo, IssuesRoot: ir, ID: item, Match: bundled()}) + if err != nil { + t.Fatalf("Promote: %v", err) + } + if p.Match == nil || len(p.Match.Matches) == 0 || p.Match.Matches[0].ID != held.ID || !p.Match.Matches[0].Linked { + t.Fatalf("the promoted draft was not matched to %s: %+v", held.ID, p.Match) + } + rel := string(p.Match.Matches[0].Relation) + draft, err := os.ReadFile(filepath.Join(repo, filepath.FromSlash(p.IntentPath))) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(draft), "\n"+rel+": ["+held.ID+"]\n") { + t.Fatalf("the draft carries no %s link naming %s:\n%s", rel, held.ID, draft) + } +} + +// A promote without a match configuration mints as before and reports none. +func TestPromoteReadingItemWithoutAMatchReportsNone(t *testing.T) { + repo, ir, item := dispositionedReadingFixture(t) + p, err := Promote(PromoteRequest{RepoRoot: repo, IssuesRoot: ir, ID: item}) + if err != nil { + t.Fatal(err) + } + if p.Match != nil { + t.Fatalf("an unmatched promote reported a match: %+v", p.Match) + } +} diff --git a/internal/core/capture/remedy_required_test.go b/internal/core/capture/remedy_required_test.go index dfa60188d..ef57cdc37 100644 --- a/internal/core/capture/remedy_required_test.go +++ b/internal/core/capture/remedy_required_test.go @@ -74,7 +74,7 @@ func TestCaptureAcceptsTheMachineRemedy(t *testing.T) { // filer, so its record carries the machine value. func TestConsistencyFilesTheMachineRemedy(t *testing.T) { root := consistencyLedgerRepo(t) - if _, err := IngestConsistency(root, consistencyPayload(t, root), "2026-09-26"); err != nil { + if _, err := IngestConsistency(root, consistencyPayload(t, root), "2026-09-26", nil); err != nil { t.Fatal(err) } list, err := List(ListRequest{RepoRoot: root, State: StateOpen}) diff --git a/internal/core/capture/unreadabledir_test.go b/internal/core/capture/unreadabledir_test.go index 89b475a85..574e24817 100644 --- a/internal/core/capture/unreadabledir_test.go +++ b/internal/core/capture/unreadabledir_test.go @@ -52,7 +52,7 @@ func TestMatchReportsAnUnreadableStatusDirectoryAsUnread(t *testing.T) { t.Fatalf("the intent create's candidate set read an unreadable resolved/ as empty: %v", err) } const content = "---\nid: planted\n---\n" - got, o := matchAndLink(repo, ir, *bundled(), plantedDouble, content, map[string]any{}) + got, o := matchAndLink(repo, ir, *bundled(), plantedDouble, nil, content, map[string]any{}) if o == nil || !strings.Contains(o.Skipped, "could not be read") { t.Fatalf("an unreadable resolved/ did not read as an unread record set: %+v", o) } diff --git a/internal/core/capture/validate.go b/internal/core/capture/validate.go index 0eadcdd3d..258bd6261 100644 --- a/internal/core/capture/validate.go +++ b/internal/core/capture/validate.go @@ -54,6 +54,7 @@ func issueFromFrontmatter(fm map[string]any, status State, path, body string) Is Status: status, Path: path, Body: body, + deferredAfter: asString(fm["deferred_after"]), } iss.RelatedIntents = asStrList(fm["related_intents"]) iss.RelatedSpecs = asStrList(fm["related_specs"]) diff --git a/internal/core/capture/workflow.go b/internal/core/capture/workflow.go index 62eec25ce..302c499a3 100644 --- a/internal/core/capture/workflow.go +++ b/internal/core/capture/workflow.go @@ -290,7 +290,11 @@ func commitCapture(repoRoot, issuesRoot string, req CaptureRequest, issID, slug, // adds links to the content and never fails the write. var matched *match.Outcome if req.Match != nil { - content, matched = matchAndLink(repoRoot, issuesRoot, *req.Match, req.Text, content, fm) + text := req.Text + if req.MatchText != "" { + text = req.MatchText + } + content, matched = matchAndLink(repoRoot, issuesRoot, *req.Match, text, req.MatchExcept, content, fm) } if werr := writeLedgerFile(repoRoot, issuesRoot, placeholder, []byte(content)); werr != nil { return werr diff --git a/internal/core/changelog/targetmove.go b/internal/core/changelog/targetmove.go new file mode 100644 index 000000000..65afad34a --- /dev/null +++ b/internal/core/changelog/targetmove.go @@ -0,0 +1,46 @@ +package changelog + +// targetmove.go — the changelog's note of the targets a cut moved +// (itd-2609212103572513 criterion 3, ruling BS1 of 2026-09-29). +// +// A cut that passes a targeted intent without shipping it rewrites the target +// to `next` in the change that rolls the changelog, and the dated section says +// so in one line directly under its notice, ahead of every change-type +// heading. The line names planned intents, which this release did NOT ship, so +// every reader that credits a release with the records its section names — +// the site's release stamp — passes over it through IsTargetMoveNote. Its +// place ahead of the first `###` heading keeps it out of the delivery +// sections record-lint's delivery_state rule judges. + +import ( + "strings" + + "github.com/intentdriven/abcd/internal/core/launch" +) + +// targetMoveLead opens the note, spelled once for the writer and the reader. +const targetMoveLead = "Targeted and not shipped in this release, so each targets the next release (`next`): " + +// TargetMoveNote renders the note for the moves a cut makes, or "" when it +// makes none. Each move reads ` (targeted )`. +func TargetMoveNote(moves []launch.TargetMove) string { + if len(moves) == 0 { + return "" + } + parts := make([]string, 0, len(moves)) + for _, m := range moves { + from := m.From + if from == launch.TargetNext { + from = "`" + from + "`" + } + parts = append(parts, m.ID+" (targeted "+from+")") + } + return targetMoveLead + strings.Join(parts, ", ") + "." +} + +// IsTargetMoveNote reports whether a changelog line is a move note: a line +// that names intents a release did not ship, so no reader credits that +// release with them. +func IsTargetMoveNote(line string) bool { + return strings.HasPrefix(strings.TrimRight(line, "\r"), targetMoveLead) +} diff --git a/internal/core/changelog/targetmove_test.go b/internal/core/changelog/targetmove_test.go new file mode 100644 index 000000000..dcd4f7c50 --- /dev/null +++ b/internal/core/changelog/targetmove_test.go @@ -0,0 +1,35 @@ +package changelog + +import ( + "testing" + + "github.com/intentdriven/abcd/internal/core/launch" +) + +// TestTargetMoveNoteNamesEveryMove is criterion 3's changelog half +// (itd-2609212103572513, ruling BS1 of 2026-09-29): the note names each +// targeted intent the cut passed and the target it carried, says each targets +// `next`, and is the one line the note predicate recognises, so a reader that +// stamps a release onto the records a section credits can pass over it. +func TestTargetMoveNoteNamesEveryMove(t *testing.T) { + if got := TargetMoveNote(nil); got != "" { + t.Errorf("no move, no note: %q", got) + } + got := TargetMoveNote([]launch.TargetMove{ + {ID: "itd-7", Path: "p/itd-7.md", From: "v0.11.0"}, + {ID: "itd-9", Path: "p/itd-9.md", From: "next"}, + }) + want := "Targeted and not shipped in this release, so each targets the next release (`next`): " + + "itd-7 (targeted v0.11.0), itd-9 (targeted `next`)." + if got != want { + t.Errorf("TargetMoveNote =\n%q\nwant\n%q", got, want) + } + if !IsTargetMoveNote(got) { + t.Error("the predicate does not recognise the note the renderer writes") + } + for _, line := range []string{"", "- **A version is a fact.** Derived. (itd-7)", "### Added", "Targeted"} { + if IsTargetMoveNote(line) { + t.Errorf("IsTargetMoveNote(%q) = true", line) + } + } +} diff --git a/internal/core/decide/decide.go b/internal/core/decide/decide.go index 793d9b9b4..b540ef242 100644 --- a/internal/core/decide/decide.go +++ b/internal/core/decide/decide.go @@ -85,6 +85,31 @@ type Decision struct { // hand-numbered ordinal had exactly the cross-branch collision no convention can // close. func Create(repoRoot, title string) (Decision, error) { + return create(repoRoot, title, renderSkeleton) +} + +// Stated is a decision whose words are already written: a setup offer that +// states a rule the person accepts at a prompt (the drain eligibility record, +// ruling BX2) mints it through the same seam as Create, so its id, date and +// filename come from the one allocator. The person's yes to the offer is the +// decision, so the record is written accepted. +type Stated struct { + // Title becomes the H1 and the slug, redacted as Create's is. + Title string + // Frontmatter is extra frontmatter lines, each "key: value\n", written + // after the store's nine keys. + Frontmatter string + // Body is everything below the H1: the four sections the store carries. + Body string +} + +// CreateStated mints a decision record for s and writes it accepted. On any +// refusal nothing is written. +func CreateStated(repoRoot string, s Stated) (Decision, error) { + return create(repoRoot, s.Title, func(d Decision) string { return renderStated(d, s) }) +} + +func create(repoRoot, title string, render func(Decision) string) (Decision, error) { trimmed := strings.Join(strings.Fields(title), " ") if trimmed == "" { return Decision{}, fmt.Errorf("decide: refusing to mint a decision with an empty title") @@ -125,7 +150,7 @@ func Create(repoRoot, title string) (Decision, error) { Date: dateFromStamp(stamp), Path: rel, } - body := renderSkeleton(created) + body := render(created) if err := fsutil.WriteFileAtomic(filepath.Join(repoRoot, filepath.FromSlash(rel)), []byte(body), 0o644); err != nil { return fmt.Errorf("decide: writing %s: %w", rel, err) } @@ -204,21 +229,11 @@ func adrPresent(repoRoot, id string) bool { // an answer nobody has given yet. func renderSkeleton(d Decision) string { var b strings.Builder - b.WriteString("---\n") - b.WriteString("id: " + d.ID + "\n") - b.WriteString("slug: " + d.Slug + "\n") // proposed, not accepted: the binary knows an id and a date, and cannot know // that a decision is in force. The author sets `accepted` in the change that // states the decision. - b.WriteString("status: proposed\n") - b.WriteString("date: " + d.Date + "\n") - b.WriteString("supersedes: null\n") - b.WriteString("superseded_by: null\n") - b.WriteString("related_intents: []\n") - b.WriteString("related_rfcs: []\n") - b.WriteString("related_adrs: []\n") - b.WriteString("---\n\n") - b.WriteString("# ADR-" + strings.TrimPrefix(d.ID, adrFamily+"-") + ": " + d.Title + "\n\n") + b.WriteString(renderKeys(d, "proposed")) + b.WriteString(renderCloseAndTitle(d)) b.WriteString("## Context\n\n") b.WriteString("_What forced the decision? What constraints were already locked?_\n\n") b.WriteString("## Decision\n\n") @@ -230,6 +245,37 @@ func renderSkeleton(d Decision) string { return b.String() } +// renderKeys writes the opening delimiter and the store's nine frontmatter +// keys with the status given, leaving the block open for a caller's extra keys. +func renderKeys(d Decision, status string) string { + var b strings.Builder + b.WriteString("---\n") + b.WriteString("id: " + d.ID + "\n") + b.WriteString("slug: " + d.Slug + "\n") + b.WriteString("status: " + status + "\n") + b.WriteString("date: " + d.Date + "\n") + b.WriteString("supersedes: null\n") + b.WriteString("superseded_by: null\n") + b.WriteString("related_intents: []\n") + b.WriteString("related_rfcs: []\n") + b.WriteString("related_adrs: []\n") + return b.String() +} + +// renderCloseAndTitle writes the closing delimiter and the `ADR-: ` +// H1 below it. +func renderCloseAndTitle(d Decision) string { + return "---\n\n# ADR-" + strings.TrimPrefix(d.ID, adrFamily+"-") + ": " + d.Title + "\n\n" +} + +// renderStated lays out a stated record: the store's frontmatter keys with +// `status: accepted`, the caller's extra keys, the H1, and the caller's body. +// It is built from the same two pieces as the skeleton, so it never searches +// the skeleton for a delimiter. +func renderStated(d Decision, s Stated) string { + return renderKeys(d, "accepted") + s.Frontmatter + renderCloseAndTitle(d) + s.Body +} + // redactDecisionText sanitises the caller's title through the ONE canonical // detector — the same scanner the transcript store, the capture ledger, the // intent store and the launch bundler use. diff --git a/internal/core/decide/decide_test.go b/internal/core/decide/decide_test.go index 1a7eac27d..ca845755d 100644 --- a/internal/core/decide/decide_test.go +++ b/internal/core/decide/decide_test.go @@ -300,3 +300,27 @@ func routerBullet(content, lead string) (string, bool) { } return strings.Join(strings.Fields(strings.Join(lines[start:end], " ")), " "), true } + +// TestCreateStatedWritesAnAcceptedRecordWithItsFields: a stated decision is +// minted through the same seam as a skeleton, written accepted, with the extra +// frontmatter keys inside the block and the body below the H1. +func TestCreateStatedWritesAnAcceptedRecordWithItsFields(t *testing.T) { + root := t.TempDir() + d, err := CreateStated(root, Stated{Title: "A stated rule", Frontmatter: "drain_remedy: required\n", Body: "## Context\n\nwords\n"}) + if err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(filepath.Join(root, d.Path)) + if err != nil { + t.Fatal(err) + } + s := string(raw) + head, body, ok := strings.Cut(strings.TrimPrefix(s, "---\n"), "---\n") + if !ok || !strings.Contains(head, "\nstatus: accepted\n") || strings.Contains(head, "proposed") || + !strings.HasSuffix(head, "related_adrs: []\ndrain_remedy: required\n") || !strings.HasPrefix(head, "id: "+d.ID+"\n") { + t.Fatalf("the frontmatter is not the store's keys, accepted, then the stated ones:\n%s", s) + } + if !strings.HasPrefix(body, "\n# ADR-"+strings.TrimPrefix(d.ID, "adr-")+": A stated rule\n\n## Context\n\nwords\n") { + t.Fatalf("the body is not the H1 then the stated sections:\n%s", s) + } +} diff --git a/internal/core/drainrule/drainrule.go b/internal/core/drainrule/drainrule.go new file mode 100644 index 000000000..ef5126147 --- /dev/null +++ b/internal/core/drainrule/drainrule.go @@ -0,0 +1,436 @@ +// Package drainrule reads the drained repository's own record of which open +// issues an unattended drain may take alone (itd-82 decision 4; the product +// thinker's rulings BX2 and H11 of 2026-09-29). +// +// BX2, verbatim: "the PROJECT MUST HOLD the eligibility decision in its own +// record (e.g. added at setup); drain refuses there until it does." H11, +// verbatim: "MAY LOOSEN abcd's floors (a project may let drain take +// major/critical and security issues). NOTE for the lane: make a loosened floor +// loud (drain --dry-run and the drain start name every floor the project +// loosened), and keep abcd's own repository at the stricter default." +// +// The record is an accepted decision record in the repository's own store +// (.abcd/development/decisions/adrs/) whose frontmatter carries four fields: +// +// drain_categories: [tech-debt, documentation, inconsistency, drift, bug, ux] +// drain_severities: [nitpick, minor] +// drain_security: handback +// drain_remedy: required +// +// The baseline is abcd's strict rule, bundled here; a record is measured +// against it, and every floor it loosens is named in Rule.Loosened. A record +// may narrow the fixable set and the severities, may widen the severities to +// major and critical, and may take security issues. It may not widen the +// categories past the fixable set (the other categories are decisions by kind), +// and it may not drop the remedy: the remedy is the brief a lane works from. +// Anything else it says, or fails to say, refuses: a partial or malformed +// record never falls back to a looser rule, or to a stricter one in silence. +// +// The package is a leaf over the frontmatter scanner and the issue schema, so +// the capture package (which applies the rule) and the setup offer (which +// writes the baseline as a record) both read the one definition. +package drainrule + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path" + "slices" + "sort" + "strings" + + "github.com/intentdriven/abcd/internal/core/frontmatter" + "github.com/intentdriven/abcd/internal/core/issueschema" + "github.com/intentdriven/abcd/internal/core/recordid" + "github.com/intentdriven/abcd/internal/fsutil" +) + +// ADRsRelDir is the decision store the rule is read from, repo-relative and +// slash-separated. A test pins it to core/decide's own constant. +const ADRsRelDir = ".abcd/development/decisions/adrs" + +// The record's four fields. +const ( + FieldCategories = "drain_categories" + FieldSeverities = "drain_severities" + FieldSecurity = "drain_security" + FieldRemedy = "drain_remedy" +) + +// fieldPrefix marks a frontmatter key as the drain rule's: a record carrying +// any key with it is a drain rule record, and every such key must be one of +// the four, so a misspelt field refuses rather than being ignored. +const fieldPrefix = "drain_" + +// The values drain_security and drain_remedy take. +const ( + // SecurityHandBack: a security issue is always a person's (the baseline). + SecurityHandBack = "handback" + // SecurityTake: a drain may take a security issue that passes every other + // rule, a loosened floor (H11). + SecurityTake = "take" + // RemedyRequired: a drain takes no issue without a remedy. It is the only + // value: the remedy is the brief the issue-keyed lane works from + // (itd-2609201916151817 decision 10), so a project cannot drop it. + RemedyRequired = "required" +) + +// SecurityCategory is the issue category drain_security decides. +const SecurityCategory = "security" + +// baselineCategories is the fixable set in the order a drain takes it (itd-82 +// decision 7): clean-ups and text first, behaviour changes last. A security +// issue a project lets through comes after all of them. +var baselineCategories = []string{"tech-debt", "documentation", "inconsistency", "drift", "bug", "ux"} + +// baselineSeverities is the severities abcd's baseline lets a drain take. +var baselineSeverities = []string{"nitpick", "minor"} + +// Rule is one repository's drain eligibility rule, as its record states it. +type Rule struct { + // Record is the decision record's id (adr-N); empty for the bundled + // baseline, which is never applied as a repository's rule. + Record string `json:"record"` + // Path is the record's repo-relative, slash-separated path. + Path string `json:"path"` + // Categories are the categories a drain may take, in the order it takes + // them; "security" is last when the record takes it. + Categories []string `json:"categories"` + // Severities are the severities a drain may take, in the order it takes + // them (nitpick, minor, major, critical). + Severities []string `json:"severities"` + Security string `json:"security"` + Remedy string `json:"remedy"` + // Loosened names every floor the record loosens against abcd's baseline, + // "severity major", "severity critical" and "security", in that order. + // Always non-nil, so a JSON reader sees an empty list rather than none. + Loosened []string `json:"loosened"` +} + +// Baseline is abcd's strict rule: the fixable set, nitpick and minor, security +// handed back, a remedy required. abcd's own repository states exactly this in +// adr-2609291342092738, and the setup offer writes it. +func Baseline() Rule { + return Rule{ + Categories: slices.Clone(baselineCategories), + Severities: slices.Clone(baselineSeverities), + Security: SecurityHandBack, + Remedy: RemedyRequired, + Loosened: []string{}, + } +} + +// TakesCategory reports whether the rule lets a drain take category c. +func (r Rule) TakesCategory(c string) bool { return slices.Contains(r.Categories, c) } + +// TakesSeverity reports whether the rule lets a drain take severity s. +func (r Rule) TakesSeverity(s string) bool { return slices.Contains(r.Severities, s) } + +// The refusals. Each is wrapped with the record and field it is about. +var ( + // ErrUnrecorded: the repository holds no accepted record of the rule. + ErrUnrecorded = errors.New("this repository holds no drain eligibility record") + // ErrMalformed: the record is partial, misspelt, or states a value the rule + // does not take. + ErrMalformed = errors.New("the drain eligibility record is malformed") + // ErrAmbiguous: more than one accepted record states the rule. + ErrAmbiguous = errors.New("more than one accepted record states the drain eligibility rule") + // ErrUnreadable: the decision store, or a record in it, could not be read + // safely: a link, a file past the size cap, or a read that failed. + ErrUnreadable = errors.New("the decision store the drain eligibility rule is read from could not be read safely") +) + +// HowToAdd is the remedy every ErrUnrecorded refusal names. +const HowToAdd = "add it: run `abcd ahoy install` at a terminal and accept the drain rule it offers, " + + "which writes abcd's strict baseline as an accepted decision record; or give an accepted decision record " + + "in " + ADRsRelDir + "/ the four fields " + FieldCategories + ", " + FieldSeverities + ", " + + FieldSecurity + " and " + FieldRemedy + " (mint one with `abcd decide \"<title>\"`)" + +// Load reads the repository's drain eligibility rule from its decision store. +// It refuses, with ErrUnrecorded, a repository whose store holds no accepted +// record carrying the drain fields; with ErrAmbiguous, one holding two; and +// with ErrMalformed, a record that is partial, states a value the rule does +// not take, states any key twice, or claims an id its file name does not give +// it; and with ErrUnreadable, a store or record that cannot be read safely. +// +// The store is read inside an os.Root at the checkout, and each record through +// the capped trust-boundary reader, so a store that is a symlink leaving the +// checkout, a record that is a symlink at all, and a record past the size cap +// are refused, never followed or read whole: the rule is the drained tree's +// committed history, and a rule from elsewhere is not it. +func Load(repoRoot string) (Rule, error) { + root, err := os.OpenRoot(repoRoot) + if err != nil { + return Rule{}, fmt.Errorf("%w: opening the checkout: %w", ErrUnreadable, err) + } + defer root.Close() + entries, err := fs.ReadDir(root.FS(), ADRsRelDir) + if errors.Is(err, fs.ErrNotExist) { + return Rule{}, fmt.Errorf("%w: it has no decision store at %s; %s", ErrUnrecorded, ADRsRelDir, HowToAdd) + } + if err != nil { + return Rule{}, fmt.Errorf("%w: reading %s: %w", ErrUnreadable, ADRsRelDir, err) + } + type candidate struct { + id, rel, status string + fields map[string]frontmatter.Field + } + var accepted, other []candidate + for _, e := range entries { + fileID := recordid.ADRFileID(e.Name()) + if e.IsDir() || fileID == "" { + continue + } + rel := path.Join(ADRsRelDir, e.Name()) + raw, err := readRecord(root, rel) + if err != nil { + return Rule{}, err + } + lines := strings.Split(string(raw), "\n") + fields := frontmatter.Fields(lines) + if !carriesDrainFields(fields) { + continue + } + c := candidate{id: fileID, rel: rel, fields: fields} + // A record that states any top-level key twice says two things: the + // line scanner keeps the first value and a YAML reader the last, so + // `status: accepted` then `status: superseded` would be admitted here + // and called superseded everywhere else. Refused whatever its status + // reads as, so neither reading decides what an unattended drain takes. + if dups := frontmatter.Duplicates(lines); len(dups) > 0 { + keys := make([]string, 0, len(dups)) + for _, d := range dups { + if !slices.Contains(keys, d.Key) { + keys = append(keys, d.Key) + } + } + return Rule{}, fmt.Errorf("%w: %s (%s) states %s more than once; state each key once", + ErrMalformed, fileID, rel, strings.Join(keys, ", ")) + } + // Every surface names the rule by its record's id, so a record whose + // frontmatter claims another record's id would put that record's name + // on its own rule. The file name is the id the store allocated. + if fmID, _ := frontmatter.ScalarString(fields["id"].Value); fmID != "" { + if recordid.CanonADRID(fmID) != fileID { + return Rule{}, fmt.Errorf("%w: %s says its id is %s, but its file name makes it %s; a record states its own id", + ErrMalformed, rel, fmID, fileID) + } + c.id = fmID + } + c.status, _ = frontmatter.ScalarString(fields["status"].Value) + if c.status == "accepted" { + accepted = append(accepted, c) + } else { + other = append(other, c) + } + } + switch len(accepted) { + case 0: + var named []string + for _, c := range other { + named = append(named, fmt.Sprintf("%s carries the drain fields but is %s, not accepted", c.id, orNone(c.status))) + } + note := "" + if len(named) > 0 { + note = " (" + strings.Join(named, "; ") + ")" + } + return Rule{}, fmt.Errorf("%w: no accepted decision record in %s carries the drain fields%s; %s", + ErrUnrecorded, ADRsRelDir, note, HowToAdd) + case 1: + default: + ids := make([]string, len(accepted)) + for i, c := range accepted { + ids[i] = c.id + } + return Rule{}, fmt.Errorf("%w: %s each carry the drain fields; supersede all but one, so the rule a drain applies is one record's", + ErrAmbiguous, strings.Join(ids, " and ")) + } + c := accepted[0] + r, err := parse(c.fields) + if err != nil { + return Rule{}, fmt.Errorf("%w: %s (%s): %s", ErrMalformed, c.id, c.rel, err.Error()) + } + r.Record, r.Path = c.id, c.rel + return r, nil +} + +// readRecord reads one record of the store through the capped trust-boundary +// reader: a link (even one resolving inside the checkout), a FIFO or device, or +// a file past the ledger's record cap is refused rather than read, since the +// store is repository-authored and decides what an unattended drain takes. +func readRecord(root *os.Root, rel string) ([]byte, error) { + raw, err := fsutil.ReadGuardedInRoot(root, rel, issueschema.RecordReadLimit) + switch { + case err == nil: + return raw, nil + case errors.Is(err, fsutil.ErrTooBig): + return nil, fmt.Errorf("%w: %s is larger than the %d-byte size cap and was left unread", ErrUnreadable, rel, issueschema.RecordReadLimit) + case errors.Is(err, fsutil.ErrNotRegular): + return nil, fmt.Errorf("%w: %s is not a regular file (a link, a FIFO or a device), and a record is never read through one", ErrUnreadable, rel) + } + return nil, fmt.Errorf("%w: reading %s: %w", ErrUnreadable, rel, err) +} + +func orNone(s string) string { + if s == "" { + return "without a status" + } + return s +} + +// carriesDrainFields reports whether a record's frontmatter states any part of +// the drain rule. +func carriesDrainFields(fields map[string]frontmatter.Field) bool { + for k := range fields { + if strings.HasPrefix(k, fieldPrefix) { + return true + } + } + return false +} + +var knownFields = []string{FieldCategories, FieldSeverities, FieldSecurity, FieldRemedy} + +// parse reads the four fields and measures them against the baseline. +func parse(fields map[string]frontmatter.Field) (Rule, error) { + var unknown []string + for k := range fields { + if strings.HasPrefix(k, fieldPrefix) && !slices.Contains(knownFields, k) { + unknown = append(unknown, k) + } + } + if len(unknown) > 0 { + sort.Strings(unknown) + return Rule{}, fmt.Errorf("%s is not a drain rule field; the fields are %s", + strings.Join(unknown, ", "), strings.Join(knownFields, ", ")) + } + for _, k := range knownFields { + if _, ok := fields[k]; !ok { + return Rule{}, fmt.Errorf("%s is missing; a drain rule states all of %s", k, strings.Join(knownFields, ", ")) + } + } + + cats, err := flowList(fields, FieldCategories) + if err != nil { + return Rule{}, err + } + for _, c := range cats { + switch { + case slices.Contains(baselineCategories, c): + case c == SecurityCategory: + return Rule{}, fmt.Errorf("%s lists security; whether a drain takes security issues is %s's to state (%s or %s)", + FieldCategories, FieldSecurity, SecurityHandBack, SecurityTake) + case slices.Contains(issueschema.Categories, c): + return Rule{}, fmt.Errorf("%s lists %s, a category a person decides by kind; a project's rule may narrow the fixable set (%s), never widen it", + FieldCategories, c, strings.Join(baselineCategories, ", ")) + default: + return Rule{}, fmt.Errorf("%s lists %q, which is not an issue category; the fixable set is %s", + FieldCategories, c, strings.Join(baselineCategories, ", ")) + } + } + sevs, err := flowList(fields, FieldSeverities) + if err != nil { + return Rule{}, err + } + for _, s := range sevs { + if !slices.Contains(issueschema.Severities, s) { + return Rule{}, fmt.Errorf("%s lists %q, which is not a severity; the severities are %s", + FieldSeverities, s, strings.Join(issueschema.Severities, ", ")) + } + } + security, _ := frontmatter.ScalarString(fields[FieldSecurity].Value) + if security != SecurityHandBack && security != SecurityTake { + return Rule{}, fmt.Errorf("%s is %q; it is %s (abcd's baseline) or %s", FieldSecurity, security, SecurityHandBack, SecurityTake) + } + remedy, _ := frontmatter.ScalarString(fields[FieldRemedy].Value) + if remedy != RemedyRequired { + return Rule{}, fmt.Errorf("%s is %q; it is %s, the only value: the remedy is the brief a lane works from, so no rule takes an issue without one", + FieldRemedy, remedy, RemedyRequired) + } + + r := Rule{Security: security, Remedy: remedy, Loosened: []string{}} + for _, c := range baselineCategories { + if slices.Contains(cats, c) { + r.Categories = append(r.Categories, c) + } + } + for _, s := range issueschema.Severities { + if slices.Contains(sevs, s) { + r.Severities = append(r.Severities, s) + if !slices.Contains(baselineSeverities, s) { + r.Loosened = append(r.Loosened, "severity "+s) + } + } + } + if security == SecurityTake { + r.Categories = append(r.Categories, SecurityCategory) + r.Loosened = append(r.Loosened, SecurityCategory) + } + return r, nil +} + +// flowList reads a field written as an inline list, `[a, b]`, refusing any +// other shape (a block sequence reads as an empty value to the line scanner, +// and would otherwise state nothing) and an empty list. +func flowList(fields map[string]frontmatter.Field, key string) ([]string, error) { + v := strings.TrimSpace(fields[key].Value) + if !strings.HasPrefix(v, "[") || !strings.HasSuffix(v, "]") { + return nil, fmt.Errorf("%s is not an inline list; write it as %s: [a, b]", key, key) + } + items := frontmatter.StringList(v) + if len(items) == 0 { + return nil, fmt.Errorf("%s is empty; a rule that takes nothing is not stated this way (narrow it, or leave the drain unrun)", key) + } + return items, nil +} + +// ProposalTitle is the title the setup offer gives the record it writes. +const ProposalTitle = "A drain takes an issue alone only when its fields say it needs no decision" + +// ProposalFrontmatter is the baseline as the four frontmatter lines a record +// carries, each ending in a newline. +func ProposalFrontmatter() string { + b := Baseline() + return FieldCategories + ": [" + strings.Join(b.Categories, ", ") + "]\n" + + FieldSeverities + ": [" + strings.Join(b.Severities, ", ") + "]\n" + + FieldSecurity + ": " + b.Security + "\n" + + FieldRemedy + ": " + b.Remedy + "\n" +} + +// ProposalBody is the record's body below its title: the four sections every +// decision record carries, stating the baseline in words. +func ProposalBody() string { + return "## Context\n\n" + + "`abcd drain` works the open issue ledger unattended: it fixes what needs no decision and hands the " + + "rest back to a person. Which issue a machine may take alone is this repository's decision, and the " + + "drain refuses to run here until an accepted record states it.\n\n" + + "## Decision\n\n" + + "We will let a drain take an open issue alone only when its fields say it needs no decision, and " + + "hand every other open issue back by the rule that excluded it. The four `drain_` fields in this " + + "record's frontmatter are the rule the drain reads:\n\n" + + "- `" + FieldCategories + "`: the categories a drain may take, from the fixable set " + + "(`" + strings.Join(baselineCategories, "`, `") + "`); every other category is a person's.\n" + + "- `" + FieldSeverities + "`: the severities a drain may take; `major` and `critical` are a person's " + + "unless listed here.\n" + + "- `" + FieldSecurity + "`: `" + SecurityHandBack + "` keeps every security issue a person's; `" + + SecurityTake + "` lets a drain take one that passes every other rule.\n" + + "- `" + FieldRemedy + "`: `" + RemedyRequired + "`; an issue without a remedy, or with the value an " + + "automatic filer writes, is never taken.\n\n" + + "These values are abcd's strict baseline. Listing `major` or `critical`, or setting `" + FieldSecurity + + ": " + SecurityTake + "`, loosens a floor, and `abcd drain --dry-run` and the drain start name every " + + "floor loosened.\n\n" + + "## Alternatives Considered\n\n" + + "- **A model classifies each issue.** Rejected: a model detects its own ambiguity badly, and a " + + "classifier that can let an issue through is the failure this rule exists to prevent.\n" + + "- **Every issue with a remedy.** Rejected: a remedy says what someone proposed, not that the " + + "proposal needs no decision.\n" + + "- **The fields, read in a fixed order (chosen).** Each exclusion names the rule a person reads to " + + "act on it.\n\n" + + "## Consequences\n\n" + + "- A change to what a drain may take is a change to this record, reviewed like code; a pull request " + + "that loosens it is one a reviewer reads as a trust change.\n" + + "- An issue whose remedy waits on a ruling, or whose deferral past the current release is live, is " + + "handed back whatever this record says.\n" +} diff --git a/internal/core/drainrule/drainrule_test.go b/internal/core/drainrule/drainrule_test.go new file mode 100644 index 000000000..571d28be0 --- /dev/null +++ b/internal/core/drainrule/drainrule_test.go @@ -0,0 +1,337 @@ +package drainrule + +import ( + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/decide" +) + +// The drained repository's own eligibility record (ruling BX2) and the floors +// it may loosen (ruling H11). Every test writes a decision store into a +// temporary checkout and reads it back through Load, the one reader the drain +// uses. + +// strictFields is the baseline as a record states it. +const strictFields = "drain_categories: [tech-debt, documentation, inconsistency, drift, bug, ux]\n" + + "drain_severities: [nitpick, minor]\n" + + "drain_security: handback\n" + + "drain_remedy: required\n" + +// writeADR writes one decision record into repo's store. +func writeADR(t *testing.T, repo, name, id, status, extra string) string { + t.Helper() + dir := filepath.Join(repo, filepath.FromSlash(ADRsRelDir)) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + body := "---\nid: " + id + "\nslug: s\nstatus: " + status + "\ndate: 2026-09-30\n" + extra + "---\n\n# ADR\n" + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return p +} + +// TestLoadRefusesARepositoryWithoutItsOwnRecord is BX2: the project must hold +// the decision in its own record, and the drain refuses until it does, naming +// how to add it. A store that decides other things is not a drain rule. +func TestLoadRefusesARepositoryWithoutItsOwnRecord(t *testing.T) { + for name, setup := range map[string]func(repo string){ + "no store": func(string) {}, + "no drain fields": func(repo string) { writeADR(t, repo, "0001-other.md", "adr-1", "accepted", "") }, + "only proposed": func(repo string) { writeADR(t, repo, "0002-rule.md", "adr-2", "proposed", strictFields) }, + "only superseded": func(repo string) { writeADR(t, repo, "0003-rule.md", "adr-3", "superseded", strictFields) }, + "not a store file": func(repo string) { writeADR(t, repo, "README.md", "adr-4", "accepted", strictFields) }, + } { + t.Run(name, func(t *testing.T) { + repo := t.TempDir() + setup(repo) + _, err := Load(repo) + if !errors.Is(err, ErrUnrecorded) { + t.Fatalf("got %v, want ErrUnrecorded", err) + } + for _, want := range []string{"ahoy install", FieldCategories, "accepted decision record"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not name %q: %v", want, err) + } + } + }) + } + // A record carrying the fields but not accepted is named, so its author sees + // why it does not count. + repo := t.TempDir() + writeADR(t, repo, "0002-rule.md", "adr-2", "proposed", strictFields) + if _, err := Load(repo); err == nil || !strings.Contains(err.Error(), "adr-2") || !strings.Contains(err.Error(), "proposed") { + t.Errorf("a proposed record is not named in the refusal: %v", err) + } +} + +// TestLoadReadsTheStrictRecordAsTheBaseline: a record stating the baseline +// loosens nothing, and the rule carries the record's id and path. +func TestLoadReadsTheStrictRecordAsTheBaseline(t *testing.T) { + repo := t.TempDir() + writeADR(t, repo, "2609291342092738-rule.md", "adr-2609291342092738", "accepted", strictFields) + r, err := Load(repo) + if err != nil { + t.Fatal(err) + } + if r.Record != "adr-2609291342092738" || r.Path != ADRsRelDir+"/2609291342092738-rule.md" { + t.Errorf("record %q at %q", r.Record, r.Path) + } + if len(r.Loosened) != 0 { + t.Errorf("the baseline loosens %v", r.Loosened) + } + b := Baseline() + if !reflect.DeepEqual(r.Categories, b.Categories) || !reflect.DeepEqual(r.Severities, b.Severities) || + r.Security != SecurityHandBack || r.Remedy != RemedyRequired { + t.Errorf("the strict record reads as %+v, want the baseline %+v", r, b) + } +} + +// TestARecordMayNarrowTheFixableSet: a project that takes less than the +// baseline is not loosening anything, and the drain order stays abcd's. +func TestARecordMayNarrowTheFixableSet(t *testing.T) { + repo := t.TempDir() + writeADR(t, repo, "0007-rule.md", "adr-7", "accepted", + "drain_categories: [ux, documentation]\ndrain_severities: [nitpick]\ndrain_security: handback\ndrain_remedy: required\n") + r, err := Load(repo) + if err != nil { + t.Fatal(err) + } + if strings.Join(r.Categories, ",") != "documentation,ux" || strings.Join(r.Severities, ",") != "nitpick" { + t.Errorf("narrowed rule = %+v", r) + } + if len(r.Loosened) != 0 { + t.Errorf("a narrowed rule loosens %v", r.Loosened) + } + if r.TakesCategory("bug") || !r.TakesCategory("ux") || r.TakesSeverity("minor") { + t.Errorf("the narrowed rule takes the wrong set: %+v", r) + } +} + +// TestALoosenedFloorIsNamed is H11: a project may let a drain take major and +// critical issues and security issues, and every floor it loosens is named, +// measured against abcd's baseline. +func TestALoosenedFloorIsNamed(t *testing.T) { + repo := t.TempDir() + writeADR(t, repo, "0008-rule.md", "adr-8", "accepted", + "drain_categories: [bug]\ndrain_severities: [critical, minor, major]\ndrain_security: take\ndrain_remedy: required\n") + r, err := Load(repo) + if err != nil { + t.Fatal(err) + } + if want := []string{"severity major", "severity critical", "security"}; !reflect.DeepEqual(r.Loosened, want) { + t.Errorf("loosened = %v, want %v", r.Loosened, want) + } + if strings.Join(r.Severities, ",") != "minor,major,critical" { + t.Errorf("severities in drain order = %v", r.Severities) + } + if strings.Join(r.Categories, ",") != "bug,security" || !r.TakesCategory("security") || !r.TakesSeverity("critical") { + t.Errorf("a record taking security: %+v", r) + } +} + +// TestAMalformedRecordRefuses: a partial or malformed record never falls back +// to a looser rule or to a stricter one without saying so. It refuses, naming +// the field and the record. +func TestAMalformedRecordRefuses(t *testing.T) { + cases := map[string]struct{ fields, want string }{ + "missing remedy": {strings.Replace(strictFields, "drain_remedy: required\n", "", 1), FieldRemedy}, + "missing severities": {strings.Replace(strictFields, "drain_severities: [nitpick, minor]\n", "", 1), FieldSeverities}, + "misspelt key": {strictFields + "drain_severity: [major]\n", "drain_severity"}, + "unknown severity": {strings.Replace(strictFields, "[nitpick, minor]", "[nitpick, small]", 1), "small"}, + "decision category": {strings.Replace(strictFields, "ux]", "ux, process]", 1), "process"}, + "security as a list": {strings.Replace(strictFields, "ux]", "ux, security]", 1), FieldSecurity}, + "unknown category": {strings.Replace(strictFields, "ux]", "ux, chores]", 1), "chores"}, + "empty categories": {strings.Replace(strictFields, "[tech-debt, documentation, inconsistency, drift, bug, ux]", "[]", 1), FieldCategories}, + "block sequence": {strings.Replace(strictFields, "[nitpick, minor]", "\n - nitpick", 1), FieldSeverities}, + "security value": {strings.Replace(strictFields, "handback", "sometimes", 1), "sometimes"}, + "remedy loosened": {strings.Replace(strictFields, "drain_remedy: required", "drain_remedy: optional", 1), FieldRemedy}, + "duplicate key": {strictFields + "drain_security: take\n", "more than once"}, + "quoted list members": {strings.Replace(strictFields, "[nitpick, minor]", `["nitpick", "major"]`, 1), ""}, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + repo := t.TempDir() + writeADR(t, repo, "0009-rule.md", "adr-9", "accepted", c.fields) + r, err := Load(repo) + if c.want == "" { // a well-formed spelling the reader must accept + if err != nil { + t.Fatalf("a quoted list refused: %v", err) + } + if strings.Join(r.Loosened, ",") != "severity major" { + t.Errorf("loosened = %v", r.Loosened) + } + return + } + if !errors.Is(err, ErrMalformed) { + t.Fatalf("got rule %+v, err %v; want ErrMalformed", r, err) + } + if !strings.Contains(err.Error(), c.want) || !strings.Contains(err.Error(), "adr-9") { + t.Errorf("the refusal does not name %q and the record: %v", c.want, err) + } + }) + } +} + +// TestTwoAcceptedRecordsRefuse: which rule an unattended drain applies is never +// a choice the reader makes between two records. +func TestTwoAcceptedRecordsRefuse(t *testing.T) { + repo := t.TempDir() + writeADR(t, repo, "0010-a.md", "adr-10", "accepted", strictFields) + writeADR(t, repo, "0011-b.md", "adr-11", "accepted", strings.Replace(strictFields, "handback", "take", 1)) + _, err := Load(repo) + if !errors.Is(err, ErrAmbiguous) || !strings.Contains(err.Error(), "adr-10") || !strings.Contains(err.Error(), "adr-11") { + t.Fatalf("got %v, want ErrAmbiguous naming both records", err) + } +} + +// TestLoadNeverReadsThroughALinkOutOfTheCheckout: the rule is committed history +// of the drained tree, so a store that is a symlink leaving the checkout is not +// read, and the drain refuses rather than applying a rule from elsewhere. +func TestLoadNeverReadsThroughALinkOutOfTheCheckout(t *testing.T) { + outside := t.TempDir() + writeADR(t, outside, "0012-rule.md", "adr-12", "accepted", strings.Replace(strictFields, "handback", "take", 1)) + repo := t.TempDir() + if err := os.MkdirAll(filepath.Join(repo, ".abcd", "development", "decisions"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(outside, filepath.FromSlash(ADRsRelDir)), filepath.Join(repo, filepath.FromSlash(ADRsRelDir))); err != nil { + t.Fatal(err) + } + if r, err := Load(repo); !errors.Is(err, ErrUnreadable) { + t.Fatalf("a rule was read through a link out of the checkout: %+v, %v; want ErrUnreadable", r, err) + } +} + +// TestTheProposalIsTheBaseline: what the setup offer writes reads back as the +// strict baseline, so the offer can never loosen a floor. +func TestTheProposalIsTheBaseline(t *testing.T) { + repo := t.TempDir() + writeADR(t, repo, "0013-rule.md", "adr-13", "accepted", ProposalFrontmatter()) + r, err := Load(repo) + if err != nil { + t.Fatal(err) + } + b := Baseline() + if len(r.Loosened) != 0 || !reflect.DeepEqual(r.Categories, b.Categories) || !reflect.DeepEqual(r.Severities, b.Severities) { + t.Errorf("the proposal reads as %+v", r) + } + if !strings.Contains(ProposalBody(), "## Decision") || !strings.Contains(ProposalBody(), FieldSeverities) { + t.Errorf("the proposal body does not state the decision and its fields:\n%s", ProposalBody()) + } +} + +// TestTheStoreIsTheDecisionStore pins the store the rule is read from to the +// one `abcd decide` mints into, so the setup offer and the reader agree. +func TestTheStoreIsTheDecisionStore(t *testing.T) { + if ADRsRelDir != decide.ADRsRelDir { + t.Fatalf("drainrule reads %s, decide mints into %s", ADRsRelDir, decide.ADRsRelDir) + } +} + +// TestAnyDuplicateKeyInARuleRecordRefuses: the line scanner keeps a key's first +// value and a YAML reader its last, so a record stating any top-level key twice +// says two things, and which one an unattended drain obeys is never the +// reader's choice. `status: accepted` then `status: superseded` is the sharp +// case: first-wins admits a record every YAML reader calls superseded. +func TestAnyDuplicateKeyInARuleRecordRefuses(t *testing.T) { + cases := map[string]struct{ status, extra, want string }{ + "status accepted then superseded": {"accepted", "status: superseded\n" + strictFields, "status"}, + "status superseded then accepted": {"superseded", "status: accepted\n" + strictFields, "status"}, + "id twice": {"accepted", "id: adr-99\n" + strictFields, "id"}, + "date twice": {"accepted", "date: 2026-10-01\n" + strictFields, "date"}, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + repo := t.TempDir() + writeADR(t, repo, "0014-rule.md", "adr-14", c.status, c.extra) + r, err := Load(repo) + if !errors.Is(err, ErrMalformed) { + t.Fatalf("got rule %+v, err %v; want ErrMalformed", r, err) + } + if !strings.Contains(err.Error(), c.want) || !strings.Contains(err.Error(), "more than once") { + t.Errorf("the refusal does not name the duplicated key %q: %v", c.want, err) + } + }) + } +} + +// TestARuleRecordWhoseIdDisagreesWithItsFileNameRefuses: every surface names +// the rule by its record's id, so a record whose frontmatter claims another +// record's id (abcd's own strict one, say) while loosening floors would put +// that record's name on its own rule. +func TestARuleRecordWhoseIdDisagreesWithItsFileNameRefuses(t *testing.T) { + repo := t.TempDir() + writeADR(t, repo, "2609300000000001-x.md", "adr-2609291342092738", "accepted", + strings.Replace(strictFields, "handback", "take", 1)) + r, err := Load(repo) + if !errors.Is(err, ErrMalformed) { + t.Fatalf("got rule %+v, err %v; want ErrMalformed", r, err) + } + for _, want := range []string{"adr-2609291342092738", "adr-2609300000000001"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not name %s: %v", want, err) + } + } + // The same number written with its ordinal zeros is the same id. + repo = t.TempDir() + writeADR(t, repo, "0015-rule.md", "adr-0015", "accepted", strictFields) + if r, err := Load(repo); err != nil || r.Record != "adr-0015" && r.Record != "adr-15" { + t.Errorf("a zero-padded id refused or renamed: %+v %v", r, err) + } +} + +// TestAnOversizedRecordIsNeverRead: the store is repository-authored, so every +// record in it is read through the capped reader, and one past the cap refuses +// the load rather than being read whole. +func TestAnOversizedRecordIsNeverRead(t *testing.T) { + repo := t.TempDir() + p := writeADR(t, repo, "0016-rule.md", "adr-16", "accepted", strictFields) + f, err := os.OpenFile(p, os.O_APPEND|os.O_WRONLY, 0) + if err != nil { + t.Fatal(err) + } + if _, err := f.WriteString(strings.Repeat("padding line\n", (2<<20)/13)); err != nil { + t.Fatal(err) + } + f.Close() + r, err := Load(repo) + if !errors.Is(err, ErrUnreadable) { + t.Fatalf("got rule %+v, err %v; want ErrUnreadable", r, err) + } + if !strings.Contains(err.Error(), "0016-rule.md") || !strings.Contains(err.Error(), "size cap") { + t.Errorf("the refusal does not name the record and the cap: %v", err) + } +} + +// TestASymlinkedRecordInsideTheStoreIsNeverFollowed: a record in the store +// that is a link, even one resolving inside the checkout, is refused rather +// than read as a live record. +func TestASymlinkedRecordInsideTheStoreIsNeverFollowed(t *testing.T) { + repo := t.TempDir() + loose := filepath.Join(repo, "docs", "loose.md") + if err := os.MkdirAll(filepath.Dir(loose), 0o755); err != nil { + t.Fatal(err) + } + body := "---\nid: adr-17\nstatus: accepted\n" + strings.Replace(strictFields, "handback", "take", 1) + "---\n" + if err := os.WriteFile(loose, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + dir := filepath.Join(repo, filepath.FromSlash(ADRsRelDir)) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + // Relative, so the link resolves inside the checkout. + if err := os.Symlink(filepath.FromSlash("../../../../docs/loose.md"), filepath.Join(dir, "0017-rule.md")); err != nil { + t.Fatal(err) + } + if r, err := Load(repo); !errors.Is(err, ErrUnreadable) { + t.Fatalf("got rule %+v, err %v; want ErrUnreadable", r, err) + } +} diff --git a/internal/core/guard/defaultword_test.go b/internal/core/guard/defaultword_test.go new file mode 100644 index 000000000..73e88f34f --- /dev/null +++ b/internal/core/guard/defaultword_test.go @@ -0,0 +1,210 @@ +package guard + +import ( + "strings" + "testing" +) + +// spellingCase is one line of the written-spelling tables below: the command, +// the shells it is also read through (a bare line, `bash -c '…'`, `sh -c +// "…"`), and the verdict and entry it must get. +type spellingCase struct { + cmd string + shells int + want Verdict + entry string +} + +const ( + shellBare = 1 << iota + shellSQ + shellDQ +) + +// checkSpellingCases runs each case as a bare line and as the string of each +// shell it names, and holds it to its verdict: a block names its entry at +// every depth, a warn names it on the bare line (a string holding `${` is a +// warn the payload reader may raise itself), and an allow is no rm-target +// verdict and no block anywhere, and an allow on the bare line. +func checkSpellingCases(t *testing.T, cases []spellingCase) { + t.Helper() + const home, cwd = "rm-rf-root-or-home", "rm-rf-working-directory" + for _, tc := range cases { + var spellings []string + if tc.shells&shellBare != 0 { + spellings = append(spellings, tc.cmd) + } + if tc.shells&shellSQ != 0 { + spellings = append(spellings, `bash -c '`+tc.cmd+`'`) + } + if tc.shells&shellDQ != 0 { + spellings = append(spellings, `sh -c "`+strings.ReplaceAll(tc.cmd, `"`, `\"`)+`"`) + } + for n, cmd := range spellings { + t.Run(cmd, func(t *testing.T) { + d := verdictOf(t, cmd) + switch tc.want { + case VerdictBlock: + if d.Verdict != VerdictBlock || d.EntryID != tc.entry { + t.Errorf("Check(%q) = %q via %q, want block via %q", cmd, d.Verdict, d.EntryID, tc.entry) + } + case VerdictWarn: + if d.Verdict != VerdictWarn || (n == 0 && d.EntryID != tc.entry) { + t.Errorf("Check(%q) = %q via %q, want warn via %q", cmd, d.Verdict, d.EntryID, tc.entry) + } + default: + if d.EntryID == home || d.EntryID == cwd || d.Verdict == VerdictBlock || (n == 0 && d.Verdict != VerdictAllow) { + t.Errorf("Check(%q) = %q via %q, want no rm-target verdict", cmd, d.Verdict, d.EntryID) + } + } + }) + } + } +} + +// TestDefaultWordsTheWrittenCompareReads — iss-2609290426544292. A default +// or an assignment (`${DIR:-w}`, `${DIR-w}`, `${DIR:=w}`, `${DIR=w}`) prints +// the variable's value when it is set and its word w when it is not, so its +// written spelling is both texts, and rm-rf-root-or-home reads the word as it +// reads the plain spelling: `rm -rf ${DIR:-$HOME}` deletes the home with DIR +// unset. bash 3.2 and /bin/sh, the shells of macOS, read the same default at +// the first operator after a subscript's `]` (`${X[0]]-$HOME}`), which bash 5 +// refuses as a bad substitution. +func TestDefaultWordsTheWrittenCompareReads(t *testing.T) { + const home, cwd = "rm-rf-root-or-home", "rm-rf-working-directory" + const all = shellBare | shellSQ | shellDQ + checkSpellingCases(t, []spellingCase{ + // The home as a default's or an assignment's word. + {`rm -rf ${DIR:-$HOME}`, all, VerdictBlock, home}, + {`rm -rf ${DIR-$HOME}`, all, VerdictBlock, home}, + {`rm -rf ${DIR:=$HOME}`, all, VerdictBlock, home}, + {`rm -rf ${DIR=$HOME}`, all, VerdictBlock, home}, + {`rm -rf "${DIR:-$HOME}"`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf ${DIR:-${HOME}}`, all, VerdictBlock, home}, + {`rm -rf ${DIR:-"$HOME"}`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf ${DIR:-$HOME/}`, all, VerdictBlock, home}, + {`rm -rf ${DIR:-$HOME}/*`, all, VerdictBlock, home}, + {`rm -rf ${DIR:-~}`, all, VerdictBlock, home}, + {`rm -rf ${DIR:-~/}`, all, VerdictBlock, home}, + {`rm -rf ${A:-${B:-$HOME}}`, all, VerdictBlock, home}, + {`rm -rf ${DIR:-${HOME%/}}`, all, VerdictBlock, home}, + {`rm -rf ${DIR:-x $HOME}`, all, VerdictBlock, home}, + {`rm -rf ${A:-x}${B:-$HOME}`, all, VerdictAllow, ""}, + {`rm -rf ${A:+x}${B:-$HOME}`, all, VerdictBlock, home}, + {`rm -rf ${A:+x}$HOME`, all, VerdictBlock, home}, + {`rm -rf ${A+/tmp}/`, all, VerdictBlock, home}, + // The root as the word. + {`rm -rf ${DIR:-/}`, all, VerdictBlock, home}, + {`rm -rf ${DIR-/}`, all, VerdictBlock, home}, + {`rm -rf ${DIR:=/}`, all, VerdictBlock, home}, + {`rm -rf ${DIR=/*}`, all, VerdictBlock, home}, + {`rm -rf ${DIR:-/}*`, all, VerdictBlock, home}, + {`rm -rf ${DIR:-'/'}`, shellBare | shellDQ, VerdictBlock, home}, + // A default after a subscript, at the first operator byte past its `]`. + {`rm -rf ${X[0]]-$HOME}`, all, VerdictBlock, home}, + {`rm -rf ${X[0]]:-$HOME}`, all, VerdictBlock, home}, + {`rm -rf ${X[0]]=$HOME}`, all, VerdictBlock, home}, + {`rm -rf ${X[0]]:=$HOME}`, all, VerdictBlock, home}, + {`rm -rf ${X[0]]x-$HOME}`, all, VerdictBlock, home}, + {`rm -rf ${X[0]]-/}`, all, VerdictBlock, home}, + // The working directory as the word warns, as its plain spelling does. + {`rm -rf ${DIR:-$PWD}`, shellBare, VerdictWarn, cwd}, + {`rm -rf ${DIR:-.}`, shellBare, VerdictWarn, cwd}, + // A word that names neither: the everyday default. + {`rm -rf ${DIR:-./build}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf "${DIR:-./build}"`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${DIR:-/tmp/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${DIR:-$HOME/.cache/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf "${TMPDIR:-/tmp}/abcd-x"`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${DIR:-$HOME}x`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf "${DIR:-$HOME}/build"`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${DIR:-}`, shellBare | shellSQ, VerdictAllow, ""}, + // An error message is not printed to the command's words. + {`rm -rf ${DIR:?$HOME}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${DIR?/}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${X[0]]?$HOME}`, shellBare | shellSQ, VerdictAllow, ""}, + // A trim's pattern is not a word it prints: the home it names is + // taken off the value. (`${DIR%$HOME}` blocks for another reason: + // its pattern is unknown text at the end it trims from, which can + // leave the root, iss-2609292320015665.) + {`rm -rf ${DIR#$HOME/}`, shellBare | shellSQ, VerdictAllow, ""}, + }) +} + +// TestDeepAlternativesAndSubstringsTheWrittenCompareReads — +// iss-2609290426544292. An alternative nested deeper than the spelling used to +// follow (`${X:+${X:+${X:+${X:+$HOME}}}}`) prints its innermost word as a +// shallow one does, and a substring can print the root: `${PWD:0:1}` is the +// `/` every absolute path begins with. A replacement whose pattern is only +// `*` prints its string in place of the whole value (`${X/*/$HOME}`). +func TestDeepAlternativesAndSubstringsTheWrittenCompareReads(t *testing.T) { + const home = "rm-rf-root-or-home" + const all = shellBare | shellSQ | shellDQ + checkSpellingCases(t, []spellingCase{ + {`rm -rf ${X:+${X:+${X:+${X:+$HOME}}}}`, all, VerdictBlock, home}, + {`rm -rf ${X:+${X:+${X:+${X:+${X:+/}}}}}`, all, VerdictBlock, home}, + {`rm -rf ${A:-${B:-${C:-${D:-$HOME}}}}`, all, VerdictBlock, home}, + {`rm -rf ${PWD:0:1}`, all, VerdictBlock, home}, + {`rm -rf ${HOME:0:1}`, all, VerdictBlock, home}, + {`rm -rf ${PWD:0:1}*`, all, VerdictBlock, home}, + {`rm -rf ${X/*/$HOME}`, all, VerdictBlock, home}, + {`rm -rf ${X//*/~}`, all, VerdictBlock, home}, + {`rm -rf ${X/#*/\/}`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf ${X:+${X:+${X:+${X:+./build}}}}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${X/foo/$HOME}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf "${DIR/#\~/$HOME}"`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${X/*/./build}`, shellBare | shellSQ, VerdictAllow, ""}, + }) +} + +// TestSpellingPastItsBoundRefuses — iss-2609290426544292. A written spelling +// is followed a bounded depth into nested expansions and holds a bounded +// number of texts. Past either bound the word is read as naming every value +// an arg_values entry names, so `rm -r` of it refuses (fail closed), where +// before the bound it was read as naming nothing; a command no arg_values +// entry names reads it as it always did. +func TestSpellingPastItsBoundRefuses(t *testing.T) { + const home = "rm-rf-root-or-home" + // Sixteen alternatives deep, past the depth the spelling follows. + deep := strings.Repeat("${X:+", 16) + "./build" + strings.Repeat("}", 16) + wide := "" + for i := 0; i < 6; i++ { + wide += "${V" + string(rune('A'+i)) + ":-x}" + } + checkSpellingCases(t, []spellingCase{ + {"rm -rf " + deep, shellBare | shellSQ | shellDQ, VerdictBlock, home}, + {"rm -rf " + wide, shellBare | shellSQ | shellDQ, VerdictBlock, home}, + {"echo " + deep, shellBare | shellSQ, VerdictAllow, ""}, + {"rm -f " + deep, shellBare | shellSQ, VerdictAllow, ""}, + }) +} + +// TestSpellingSetsStayLinear holds the spelling sets to the cost bar +// (iss-2609290426544292): a nest of defaults is followed a bounded depth, a +// word of many defaults stops at the bound on its texts rather than +// enumerating every combination, and a string handed to a shell is re-read a +// bounded number of times. +func TestSpellingSetsStayLinear(t *testing.T) { + shapes := []struct { + name string + build func(int) string + }{ + {"nested defaults", func(n int) string { + return "rm -rf " + strings.Repeat("${X:-", n/5) + "$HOME" + strings.Repeat("}", n/5) + }}, + {"wide defaults", func(n int) string { + return "rm -rf " + strings.Repeat("${X:-$HOME}", n/11) + }}, + {"default words", func(n int) string { + return "rm -rf " + strings.Repeat(`${A:-"$HOME"/${B:-~/${C:-\x$D}}} `, n/32) + }}, + {"payload defaults", func(n int) string { + return `sh -c "rm -rf ` + strings.Repeat("${A:-/}${B:-~} x ", n/18) + `"` + }}, + } + for _, s := range shapes { + t.Run(s.name, func(t *testing.T) { + assertWorkGrowth(t, s.build, 1<<11, "a spelling set is bounded in its depth and its size") + }) + } +} diff --git a/internal/core/guard/guardset_test.go b/internal/core/guard/guardset_test.go new file mode 100644 index 000000000..a796e765f --- /dev/null +++ b/internal/core/guard/guardset_test.go @@ -0,0 +1,514 @@ +package guard + +import "testing" + +// TestQuotedDefaultWordsTheWrittenCompareReads — review-guardSet MAJOR-1. A +// default's or an alternative's word written as an ANSI-C string (`$'/'`, +// `$'\x2f'`, `$'\57'`, `$'/'`) or a locale string (`$"/"`) prints what +// it decodes to: bash 3.2, /bin/sh and bash 5.3 print `/` for +// `${X:-$'/'}` and `${X:-$"/"}` with X unset, as they do for the bare +// `rm -rf $'/'`. A `$` that opens nothing is the `$` it is (`${X:-$/}` +// prints `$/`), and `$!` can print nothing, which leaves the text beside it +// (`${X:-$!/}` is `/` with no background job). +func TestQuotedDefaultWordsTheWrittenCompareReads(t *testing.T) { + const home = "rm-rf-root-or-home" + const all = shellBare | shellSQ | shellDQ + checkSpellingCases(t, []spellingCase{ + {`rm -rf ${X:-$'/'}`, shellBare, VerdictBlock, home}, + {`rm -rf "${X:-$'/'}"`, shellBare, VerdictBlock, home}, + {`rm -rf ${X-$'/'}`, shellBare, VerdictBlock, home}, + {`rm -rf ${X:=$'/'}`, shellBare, VerdictBlock, home}, + {`rm -rf ${X:+$'/'}`, shellBare, VerdictBlock, home}, + {`rm -rf ${X:-$'\x2f'}`, shellBare, VerdictBlock, home}, + {`rm -rf ${X:-$'\57'}`, shellBare, VerdictBlock, home}, + {`rm -rf ${X:-$'/'}`, shellBare, VerdictBlock, home}, + {`rm -rf ${X:-$'\x2f\x00zz'}`, shellBare, VerdictBlock, home}, + {`rm -rf ${X:-"$HOME"$'/'}`, shellBare, VerdictBlock, home}, + {`rm -rf ${X:-$"/"}`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf ${X:+$"/"}`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf ${X:-$"$HOME"}`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf ${X:-$!/}`, all, VerdictBlock, home}, + // The look-alikes: a word that decodes to a path of its own, a + // quoted `$'` that stays text, and a `$` that opens nothing. + {`rm -rf ${X:-$'./build'}`, shellBare, VerdictAllow, ""}, + {`rm -rf ${X:-"$'/'"}`, shellBare, VerdictAllow, ""}, + {`rm -rf ${X:-$}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${X:-$/}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${X:-$$}`, shellBare | shellSQ, VerdictAllow, ""}, + }) +} + +// TestDefaultWordsSplitOnANamedIFSTheWrittenCompareReads — review-guardSet +// MAJOR-2. An unquoted default's or alternative's word is split into fields +// on the IFS the shell holds when it expands it, and an assignment in a +// command of its own changes that IFS: `IFS=x; rm -rf ${U:-x/x}` hands rm +// `""` and `/` on bash 3.2, /bin/sh, dash and bash 5.3. So is an unquoted +// `$HOME` (`IFS=Uv; rm -rf $HOME/x` hands rm `/` when HOME is a macOS home +// whose account name holds a v, the U of Users and that v both splitting). On a +// line that names IFS such a word refuses: the guard reads the split on the +// default IFS only. A quoted word is not split, and a variable of unknown +// value splits into text no more known than its value, so `while IFS= read +// -r d; do rm -rf $d; done` stays allowed. +func TestDefaultWordsSplitOnANamedIFSTheWrittenCompareReads(t *testing.T) { + const home = "rm-rf-root-or-home" + checkSpellingCases(t, []spellingCase{ + {`IFS=x; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`IFS=x; rm -rf ${U:+x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`IFS=x; rm -rf ${U-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`export IFS=x; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`read IFS; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`IFS=x; rm -rf ${U:-${V:-x/x}}`, shellBare | shellSQ, VerdictBlock, home}, + {`IFS=x; eval rm -rf ${U:-x/x}`, shellBare, VerdictBlock, home}, + {`IFS=x; eval 'rm -rf ${U:-x/x}'`, shellBare, VerdictBlock, home}, + {`IFS=Uv; rm -rf $HOME/x`, shellBare | shellSQ, VerdictBlock, home}, + {`IFS=Uv; rm -rf ${HOME%/}/x`, shellBare | shellSQ, VerdictBlock, home}, + // The look-alikes: a quoted word, a variable of unknown value, and a + // line that names no IFS. + {`IFS=x; rm -rf "${U:-x/x}"`, shellBare, VerdictAllow, ""}, + {`while IFS= read -r d; do rm -rf "$d"; done`, shellBare | shellSQ, VerdictAllow, ""}, + {`while IFS= read -r d; do rm -rf $d; done`, shellBare | shellSQ, VerdictAllow, ""}, + {`while IFS= read -r d; do rm -rf ${d%/}; done`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${TMPDIR:-/tmp}/abcd-x`, shellBare | shellSQ, VerdictAllow, ""}, + }) +} + +// TestQuotedSlashReplacementsTheWrittenCompareReads — review-guardSet +// MAJOR-3. bash 5 reads a quoted `/` as part of a replacement's pattern, +// where bash 3.2 ends the pattern there: `${X/"/"*/$HOME}` prints the home on +// bash 5.3 and the value on bash 3.2. Both readings are the expansion's. +// A `$""` or `$"…"` in a pattern is the quoted text it holds +// (`${X%%$""*}/` is `/`). +func TestQuotedSlashReplacementsTheWrittenCompareReads(t *testing.T) { + const home = "rm-rf-root-or-home" + checkSpellingCases(t, []spellingCase{ + {`rm -rf ${X/"/"*/$HOME}`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf ${X//"/"*/$HOME}`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf "${X/"/"*/$HOME}"`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf ${X/'/'*/$HOME}`, shellBare, VerdictBlock, home}, + {`rm -rf ${X/$"/"*/$HOME}`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf ${X/$""*/$HOME}`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf ${X%%$""*}/`, shellBare | shellSQ, VerdictBlock, home}, + // The look-alikes: a quoted `/` that replaces one byte, and a bracket. + {`rm -rf ./${X/"/"/_}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${X/[/]*/$HOME}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf "./${X//"/"/_}"`, shellBare | shellSQ, VerdictAllow, ""}, + }) +} + +// TestIndirectAndSpecialDefaultsTheWrittenCompareReads — review-guardSet +// MAJOR-4. bash 3.2 and /bin/sh read `${!X:-/}` as X's indirection with a +// default, and print `/` with X unset. The value an indirection names is not +// in the line, so its spelling past an operator reads as every value. A +// positional or special parameter takes the same operators (`${1:-/}`, +// `${@:-/}`, `${!:-/}` and `${#:+/}` print `/` on bash 3.2, /bin/sh, dash and +// bash 5.3), and its value is the parameter as written. +func TestIndirectAndSpecialDefaultsTheWrittenCompareReads(t *testing.T) { + const home = "rm-rf-root-or-home" + const all = shellBare | shellSQ | shellDQ + checkSpellingCases(t, []spellingCase{ + {`rm -rf ${!X:-/}`, all, VerdictBlock, home}, + {`rm -rf ${!X-/}`, all, VerdictBlock, home}, + {`rm -rf ${!X:-$HOME}`, all, VerdictBlock, home}, + {`rm -rf ${!X:+/}`, all, VerdictBlock, home}, + {`rm -rf ${1:-/}`, all, VerdictBlock, home}, + {`rm -rf ${1-$HOME}`, all, VerdictBlock, home}, + {`rm -rf ${10:-/}`, all, VerdictBlock, home}, + {`rm -rf ${@:-/}`, all, VerdictBlock, home}, + {`rm -rf ${*:-/}`, all, VerdictBlock, home}, + {`rm -rf ${!:-/}`, all, VerdictBlock, home}, + {`rm -rf ${#:+/}`, all, VerdictBlock, home}, + {`rm -rf ${?:+/}`, all, VerdictBlock, home}, + // The look-alikes: an indirection alone, a name list, a length, and + // a positional default naming a path of its own. + {`rm -rf ${!X}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${!X*}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${#X}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${1:-dist}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf "${1:-./build}"`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${1%/}`, shellBare | shellSQ, VerdictAllow, ""}, + }) +} + +// TestEverydayExpansionsStayAllowed pins the everyday idioms review-guardSet +// held allowed at the base and the head. +func TestEverydayExpansionsStayAllowed(t *testing.T) { + cases := []string{ + `rm -rf "${TMPDIR:-/tmp}/abcd-x"`, + `rm -rf "${XDG_CACHE_HOME:-$HOME/.cache}/abcd"`, + `rm -rf ${DIR:-./build}`, + `rm -rf ${f%.*}`, + `rm -rf ${p##*/}`, + `rm -rf "${DIR/#\~/$HOME}"`, + `rm -rf ${name//[^a-z]/}`, + `rm -rf ${1:-dist}`, + `rm -rf "./${X//\//_}"`, + `rm -rf ${X/foo/$HOME}`, + `rm -rf "${DIR%/}/build"`, + `rm -rf "$HOME/${d##*/}"`, + } + var sc []spellingCase + for _, c := range cases { + sc = append(sc, spellingCase{c, shellBare | shellSQ, VerdictAllow, ""}) + } + checkSpellingCases(t, sc) +} + +// TestSeparatorsBeforeTheHomeTheWrittenCompareReads — iss-2609300057462186. +// The home and the working directory are absolute paths, and a run of `/` +// written before one names the same directory: `rm -rf /$HOME` deletes the +// home. A separator after the name is a path beneath it. +func TestSeparatorsBeforeTheHomeTheWrittenCompareReads(t *testing.T) { + const home = "rm-rf-root-or-home" + checkSpellingCases(t, []spellingCase{ + {`rm -rf /$HOME`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf //${HOME}/`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf "/$HOME"/*`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf /$HOME/build`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf /$HOMEDIR`, shellBare | shellSQ, VerdictAllow, ""}, + }) +} + +// TestParametersThatPrintNothingTheWrittenCompareReads — iss-2609300057467536. +// A parameter that can print nothing at the top of a fresh shell leaves the +// text beside it: bash 3.2, /bin/sh, dash and bash 5.3 print `/` for +// `$!/` (no background job ran), `$@/`, `$*/` and `$1/` (no argument), +// `$_/` after `x=` or `true ""`, and dash for `$-/` (no option letter), +// braced or not, quoted or not. A number that is never empty (`$$`, `$?`, +// `$#`) and the shell's name (`$0`) name no path, and the job's number +// stays the operand `kill` and `wait` take. +func TestParametersThatPrintNothingTheWrittenCompareReads(t *testing.T) { + const home = "rm-rf-root-or-home" + const all = shellBare | shellSQ | shellDQ + checkSpellingCases(t, []spellingCase{ + {`rm -rf $!/`, all, VerdictBlock, home}, + {`rm -rf "$!"/`, all, VerdictBlock, home}, + {`rm -rf "$!/"`, all, VerdictBlock, home}, + {`rm -rf /$!`, all, VerdictBlock, home}, + {`rm -rf ~$!`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf $!/*`, all, VerdictBlock, home}, + {`rm -rf $(true)$!/`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf {$!,x}/`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf ${!}/`, all, VerdictBlock, home}, + {`rm -rf "${!}"/`, all, VerdictBlock, home}, + {`rm -rf $-/`, all, VerdictBlock, home}, + {`rm -rf "${-}/"`, all, VerdictBlock, home}, + {`rm -rf $_/`, all, VerdictBlock, home}, + {`rm -rf "${_}"/`, all, VerdictBlock, home}, + {`rm -rf $@/`, all, VerdictBlock, home}, + {`rm -rf "$@"/`, all, VerdictBlock, home}, + {`rm -rf "${@}/"`, all, VerdictBlock, home}, + {`rm -rf $*/`, all, VerdictBlock, home}, + {`rm -rf "$*/"`, all, VerdictBlock, home}, + {`rm -rf ${*}/`, all, VerdictBlock, home}, + {`rm -rf $1/`, all, VerdictBlock, home}, + {`rm -rf "${1}"/`, all, VerdictBlock, home}, + {`rm -rf ${10}/`, all, VerdictBlock, home}, + {`rm -rf ${X:-$@/}`, all, VerdictBlock, home}, + {`rm -rf ${X:-$_/}`, all, VerdictBlock, home}, + {`rm -rf ${@%x}/`, all, VerdictBlock, home}, + // In a pattern too: `$!*` can be `*`, which takes all of PWD. + {`rm -rf ${PWD%%$!*}/`, all, VerdictBlock, home}, + {`rm -rf "${X%%"$!"*}"/`, shellBare | shellSQ, VerdictBlock, home}, + // The look-alikes: a parameter that is never empty, a name that runs + // on past the `_`, and the job's number where it names no path. + {`rm -rf $$/`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf $?/`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf $#/`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf $0/`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${0}/`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf $_x/`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf $!`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf "$1"`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -f "$tmp.$!"`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf "$d/$!"`, shellBare | shellSQ, VerdictAllow, ""}, + {`kill $!`, shellBare | shellSQ, VerdictAllow, ""}, + {`wait $!`, shellBare | shellSQ, VerdictAllow, ""}, + {`echo $!`, shellBare | shellSQ, VerdictAllow, ""}, + // The empty reading splits into no field: a line that names IFS + // reads these as it did before it (ifsSplits). + {`IFS=, ; rm -rf $1`, shellBare | shellSQ, VerdictAllow, ""}, + {`IFS=, ; rm -rf ${1}`, shellBare | shellSQ, VerdictAllow, ""}, + {`IFS=, ; rm -rf ${1%/}`, shellBare | shellSQ, VerdictAllow, ""}, + {`IFS=, ; rm -rf $_`, shellBare | shellSQ, VerdictAllow, ""}, + }) +} + +// TestIFSNamedThroughAMarkTheWrittenCompareReads — reverify-guardSet finding +// 1. An IFS can be named through a word that holds an expansion: +// `export ${I}FS=x`, `declare I${F}FS=x`, `read -r ${I}FS`, +// `printf -v ${I}FS x`, and `eval "I${F:-F}S=x"`, whose string the eval +// runs as an assignment. With I=I and F=F each gives IFS the value x, and +// `rm -rf ${U:-x/x}` then hands rm `""` and `/` on bash 3.2, /bin/sh, dash +// and bash 5.3. The guard does not spell the name, so a declaration's word, +// a `read` or `printf -v` target, and an assignment word whose name holds an +// expansion count as naming IFS, and so does an arithmetic expression that +// names IFS or assigns through an expansion (`: $((IFS=1))`). +func TestIFSNamedThroughAMarkTheWrittenCompareReads(t *testing.T) { + const home = "rm-rf-root-or-home" + checkSpellingCases(t, []spellingCase{ + {`I=I; export ${I}FS=x; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`export I${F}FS=x; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`export "${I}FS"=x; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`declare ${I}FS=x; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`declare -x ${I}FS=x; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`typeset ${I}FS=x; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`readonly ${I}FS=x; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`local ${I}FS=x; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`declare $(echo I)FS=x; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`read -r ${I}FS <<< x; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`printf -v ${I}FS x; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`printf -v "$n" x; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`eval "I${F:-F}S=x"; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`eval I${F}FS=x; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`I${F:-F}S=x; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`eval "${I}FS=x"; eval rm -rf ${U:-x/x}`, shellBare, VerdictBlock, home}, + {`let ${I}FS=1; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`getopts a ${I}FS; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`mapfile -t ${I}FS < f; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`wait -p ${I}FS; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + // An arithmetic assignment leaves no word to read the name in. + {`: $((IFS=1)); rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`(( IFS=1 )); rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`: $((${I}FS=1)); rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf ${U:-1/1}; echo "$((IFS=1))"`, shellBare | shellSQ, VerdictBlock, home}, + {`: $((${I}FS<<=1)); rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + // The look-alikes: a declaration, a read and a printf whose names are + // written, an assignment whose value (not its name) holds an + // expansion, and the everyday reads with a quoted operand. + {`export PATH=$HOME/bin:$PATH; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`declare -a files; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`read -r f; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`printf '%s\n' "$x"; rm -rf ${U:-x/x}`, shellBare, VerdictAllow, ""}, + {`printf -v out '%s' "$x"; rm -rf ${U:-x/x}`, shellBare, VerdictAllow, ""}, + {`OUT=$x; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`n=$((n+1)); rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`(( $n == 1 )) && rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`: $(($n <= 1)); rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`IFS= read -r f; rm -rf "$f"`, shellBare | shellSQ, VerdictAllow, ""}, + {`IFS=, read -ra arr <<< "$x"; rm -rf "${arr[0]}"`, shellBare | shellSQ, VerdictAllow, ""}, + }) +} + +// TestANameBuiltFromAnExpansionTheWrittenCompareReads — reverify3-guardSet +// finding 2, closed by one rule rather than one more context: a line that +// assigns through a target holding an expansion anywhere reads its IFS as +// unknown (targetsAnExpansion). Every form sets IFS in bash 3.2, +// /bin/sh and bash 5.3, which then hand rm `""` and `/` for `${U:-1/1}`. +func TestANameBuiltFromAnExpansionTheWrittenCompareReads(t *testing.T) { + const home = "rm-rf-root-or-home" + checkSpellingCases(t, []spellingCase{ + // The four forms the round-3 reading never reached. + {`I=I; : $[${I}FS=1]; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`I=I; a[${I}FS=1]=x; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`I=I; : ${a[${I}FS=1]}; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`declare -i n; I=I; n=${I}FS=1; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + // Their siblings: an integer attribute by typeset, an increment, + // two expansions side by side, a substring offset, and a value an + // arithmetic reference evaluates. + {`typeset -i n; I=I; n=${I}FS=1; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`I=I; (( ${I}FS++ )); rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`a=I; b=FS; export ${a}${b}=1; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`X=abc; I=I; : ${X:${I}FS=1}; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`I=I; x=${I}FS=1; : $((x)); rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + // A whole expansion that is the target of an assignment. + {`x=$(cat f); (( $x=1 )); rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`x=$(cat f); (( ++$x )); rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`x=$(cat f); (( $x += 1 )); rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + // An element of IFS leaves the split alone in bash, but the name is + // read all the same: an over-read on the fail-closed side. + {`I=I; eval "${I}FS[0]=x"; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + // The contexts the round-3 reading reached keep their verdict. + {`I=I; : $((x[${I}FS=1])); rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`I=I; [[ 1 -eq ${I}FS=1 ]]; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`I=I; for (( ${I}FS=1; 0; )); do :; done; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`I=I; while (( ${I}FS=1 )); do break; done; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`I=I; let "${I}FS=1"; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`I=I; printf -v${I}FS x; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`I=I; env ${I}FS=x true; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`f() { local ${I}FS=x; rm -rf ${U:-x/x}; }; f`, shellBare | shellSQ, VerdictBlock, home}, + {`eval "export I${F:-F}S=x"; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`I=I; rm -rf $HOME/x; export ${I}FS=U`, shellBare | shellSQ, VerdictBlock, home}, + // The look-alikes: a name the line writes, a plain subscript, a + // value the line spells, and an expansion beside text no name the + // shell splits on can be built from. + {`IFS= read -r f; rm -rf "$f"`, shellBare | shellSQ, VerdictAllow, ""}, + {`IFS=, read -ra arr <<< "$x"; rm -rf "${arr[0]}"`, shellBare | shellSQ, VerdictAllow, ""}, + {`n=$((n+1)); rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`a[$i]=x; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`export PATH=$HOME/bin:$PATH; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`mkdir -p build_${V}; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf $HOME/.cache/app_${V}`, shellBare | shellSQ, VerdictAllow, ""}, + {`(( $n == 1 )) && rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`: $(($n <= 1)); rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`[ $a = b ] && rm -rf ${TMPDIR:-/tmp}/x`, shellBare | shellSQ, VerdictAllow, ""}, + {`git log --$fmt; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${A:-x}${B:-$HOME}/x`, shellBare | shellSQ, VerdictAllow, ""}, + }) +} + +// TestAnAssignmentTargetHoldingAnExpansionTheWrittenCompareReads — +// reverify4-guardSet finding 1. bash sets IFS from an assignment target's +// VALUE, so a target the line writes as expansions alone names IFS with no +// byte of the name written: `a=I; b=FS; (( ${a}${b} = 1 ))` sets IFS to 1, +// and `rm -rf ${U:-1/1}` then hands rm `""` and `/` on bash 3.2, /bin/sh +// and bash 5.3. The rule reads the target, not its bytes: a line where any +// assignment target holds an expansion reads its IFS as unknown. +func TestAnAssignmentTargetHoldingAnExpansionTheWrittenCompareReads(t *testing.T) { + const home = "rm-rf-root-or-home" + checkSpellingCases(t, []spellingCase{ + // The forms the round-4 byte rule let through. + {`a=I; b=FS; (( ${a}${b} = 1 )); rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`a=I; b=FS; x=$a$b; (( $x = 1 )); rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`x=ifs; (( ${x^^} = 1 )); rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`(( ${x@P} = 1 )); rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`let "$x=1"; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`declare -- "$x=1"; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`printf -v "$x" 1; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`read -r "$x" <<< 1; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`export "$x"=1; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + // Every other position a target can take: a spaced operator in a + // string arithmetic reads (let, an integer's value), a substring + // offset, a ternary's arm, a for header, a subscript, a value an + // arithmetic reference evaluates, a decrement let reads, an + // indirect default, and a nameref, whose value is the name a plain + // assignment later sets (bash 5.3). + {`let "$x = 1"; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`declare -i n; n="$y = 1"; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`: ${X:$y = 1}; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`(( 1 ? $x = 1 : 0 )); rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`for (( $x = 1; 0; )); do :; done; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`a["$x = 1"]=y; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`: $(( a[$x = 1] )); rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`y=$x=1; : $((y)); rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`let --$x; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`unset "$y"; : ${!x:=1}; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`declare -n r=$x; r=1; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`local -n r; r=$x; r=1; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + // The over-block the rule keeps: a flag's word read as a target, + // and a spaced `=` in a string. + {`read -p "$prompt" f; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`echo "$k = $v"; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`[ ! $a = b ]; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + // The look-alikes: each target is a literal name, the expansion in + // the value or the subscript. + {`n=$((n+1)); rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`a[$i]=x; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`(( i++ )); rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`(( n += $k )); rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`(( a[$i] = 1 )); rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`IFS= read -r f; rm -rf "$f"`, shellBare | shellSQ, VerdictAllow, ""}, + {`IFS=, read -ra arr <<< "$x"; rm -rf "${arr[0]}"`, shellBare | shellSQ, VerdictAllow, ""}, + {`export PATH=$HOME/bin:$PATH; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`git log --$fmt; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`mkdir ${V}S; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`[ $a = b ] && rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`[ "$a" = "$b" ] && rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`: ${X:=1}; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`declare -n r=arr; rm -rf "$f"`, shellBare | shellSQ, VerdictAllow, ""}, + }) +} + +// TestColonDefaultsOfAnEmptyParameterTheWrittenCompareReads — +// reverify-guardSet finding 2. With the colon, a default, an assignment and +// an error message treat an empty parameter as unset, so `${1:-dist}` with +// no argument, or an empty one, prints `dist`, never nothing: bash 3.2, +// /bin/sh, dash and bash 5.3 hand rm `dist/` for `${1:-dist}/`. Without the +// colon a set but empty parameter prints its value, nothing (`${1-dist}/` is +// `/` after `set -- ""`). +func TestColonDefaultsOfAnEmptyParameterTheWrittenCompareReads(t *testing.T) { + const home = "rm-rf-root-or-home" + const all = shellBare | shellSQ | shellDQ + checkSpellingCases(t, []spellingCase{ + {`rm -rf "${1:-build}"/*`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${1:-dist}/`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf "${1:-dist}/"*`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${1:=dist}/`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${1:?}/`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ./${1:-dist}`, shellBare | shellSQ, VerdictAllow, ""}, + // In a string the outer shell expands, `${1:-dist}` hands the inner + // shell `${1}`'s text, which it reads as a parameter of its own that + // can print nothing: `sh -c "rm -rf ${1:-dist}/"` refuses, a + // fail-closed over-read. + // The block forms: a default that is the root, an empty or + // colonless default, and an error message without the colon. + {`rm -rf ${1:-/}`, all, VerdictBlock, home}, + {`rm -rf ${1-}/`, all, VerdictBlock, home}, + {`rm -rf ${1-dist}/`, all, VerdictBlock, home}, + {`rm -rf ${1=dist}/`, all, VerdictBlock, home}, + {`rm -rf ${@-x}/`, all, VerdictBlock, home}, + // `@` and `*` take the colon test on the parameter COUNT, not on a + // joined value: with `set -- "" ""` there are two parameters, so + // `${@:-x}` prints the two empty ones and `${@:?}` does not stop + // (reverify3-guardSet finding 1). + {`rm -rf ${@:-x}/`, all, VerdictBlock, home}, + {`rm -rf ${@:-x}/*`, all, VerdictBlock, home}, + {`rm -rf ${*:-x}/`, all, VerdictBlock, home}, + {`rm -rf ${@:?}/`, all, VerdictBlock, home}, + {`rm -rf ${*:=x}/`, all, VerdictBlock, home}, + {`rm -rf $HOME${@:-x}`, all, VerdictBlock, home}, + {`rm -rf $HOME${*:?}`, all, VerdictBlock, home}, + {`rm -rf ${1?}/`, all, VerdictBlock, home}, + // An indirection past a colon default still reads as every value. + {`rm -rf ${!X:-dist}/`, all, VerdictBlock, home}, + {`rm -rf ${!X:?}/`, all, VerdictBlock, home}, + }) +} + +// TestExpansionsThatCanPrintNothingTheWrittenCompareReads — +// reverify-guardSet finding 5. An empty default's word prints the empty +// text (`${X:-}/` and `${X-}/` are `/` with X unset), a subscript can name +// an element that is not set (`${A[0]}/`, `${A[@]}/` with A unset or a +// scalar's `${A[1]}`), and a case change or a transform prints nothing for +// a value it maps to nothing (`${X^}/`, `${X@P}/` on bash 5.3 with X +// empty). bash 3.2, /bin/sh and bash 5.3 hand rm `/` for each. +func TestExpansionsThatCanPrintNothingTheWrittenCompareReads(t *testing.T) { + const home = "rm-rf-root-or-home" + const all = shellBare | shellSQ | shellDQ + checkSpellingCases(t, []spellingCase{ + {`rm -rf ${X:-}/`, all, VerdictBlock, home}, + {`rm -rf ${X-}/`, all, VerdictBlock, home}, + {`rm -rf ${X:=}/`, all, VerdictBlock, home}, + {`rm -rf ${X:+}/`, all, VerdictBlock, home}, + {`rm -rf ${X:-""}/`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf ${X:-''}/`, shellBare, VerdictBlock, home}, + {`rm -rf $HOME${X:-}`, all, VerdictBlock, home}, + {`rm -rf ${A[0]}/`, all, VerdictBlock, home}, + {`rm -rf ${A[@]}/`, all, VerdictBlock, home}, + {`rm -rf "${A[1]}"/*`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf ${X^}/`, all, VerdictBlock, home}, + {`rm -rf ${X^^}/`, all, VerdictBlock, home}, + {`rm -rf ${X,,}/`, all, VerdictBlock, home}, + {`rm -rf ${X@P}/`, all, VerdictBlock, home}, + {`rm -rf ${X@U}/`, all, VerdictBlock, home}, + // The look-alikes: text after the empty text, a quoted array, and + // an error message. + {`rm -rf "${TMPDIR:-}/abcd-x"`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf "${A[0]}/build"`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf "${files[@]}"`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${X^^}.txt`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf "${BUILD_DIR:?}/"*`, shellBare | shellSQ, VerdictAllow, ""}, + {`while IFS= read -r f; do rm -rf ${f:-}; done`, shellBare | shellSQ, VerdictAllow, ""}, + {`IFS=, ; rm -rf ${A[0]}`, shellBare | shellSQ, VerdictAllow, ""}, + }) +} + +// TestPositionalSlicesReadAsTheParameters — reverify-guardSet finding 3. +// `${@:2}` and `${*:2}` are the arguments from the second on, and +// `${1:2}` a part of the first: each prints what the parameters hold, as +// `"$2"` and `"$1"` do, or nothing, which leaves the text beside it +// (`"${@:2}"/` is `/` with no argument). +func TestPositionalSlicesReadAsTheParameters(t *testing.T) { + const home = "rm-rf-root-or-home" + const all = shellBare | shellSQ | shellDQ + checkSpellingCases(t, []spellingCase{ + {`rm -rf "${@:2}"`, all, VerdictAllow, ""}, + {`rm -rf "${@:1}"`, all, VerdictAllow, ""}, + {`rm -rf ${@:2}`, all, VerdictAllow, ""}, + {`rm -rf "${*:2}"`, all, VerdictAllow, ""}, + {`rm -rf "${1:2}"`, all, VerdictAllow, ""}, + {`rm -rf "${@:2}"/`, all, VerdictBlock, home}, + {`rm -rf ${1:2}/`, all, VerdictBlock, home}, + {`rm -rf ${X:0:1}/`, all, VerdictBlock, home}, + }) +} diff --git a/internal/core/guard/homeresiduals_test.go b/internal/core/guard/homeresiduals_test.go index 8adbfb8b8..009e64f58 100644 --- a/internal/core/guard/homeresiduals_test.go +++ b/internal/core/guard/homeresiduals_test.go @@ -142,11 +142,6 @@ func TestHomeSpellingsTheWrittenCompareReads(t *testing.T) { {`rm -rf ${X:+/}x`, bare | sq, VerdictAllow, ""}, {`rm -rf $HOME{1..2}`, bare | sq, VerdictAllow, ""}, {`rm -rf "$HO"{M..M}E`, bare | sq, VerdictAllow, ""}, - // With X set this prints X's value; with X unset bash 3.2 and - // /bin/sh print the word, the home. The default's word is the - // deferred class of iss-2609290426544292, so this allow is a known - // residual, not a claim that the form stays off the home. - {`rm -rf ${X[0]]-$HOME}`, bare | sq, VerdictAllow, ""}, {`rm -rf "${X:+$HOME }"`, bare | sq, VerdictAllow, ""}, {`rm -rf ${X:+"$HOME "}`, bare | sq, VerdictAllow, ""}, {`rm -rf ${X:+'x /'}`, bare, VerdictAllow, ""}, @@ -190,7 +185,7 @@ func TestHomeSpellingsTheWrittenCompareReads(t *testing.T) { // TestHomeSpellingsStayLinear holds the spellings above to the cost bar // (iss-2609290419119456): an alternative's word is followed at most -// spellAlternativeDepth deep, a brace group's words carry their variables by +// spellWordDepth deep, a brace group's words carry their variables by // index, and a name read across backslash-newlines is read once. func TestHomeSpellingsStayLinear(t *testing.T) { shapes := []struct { diff --git a/internal/core/guard/match.go b/internal/core/guard/match.go index db0e5209c..0bbb36c6a 100644 --- a/internal/core/guard/match.go +++ b/internal/core/guard/match.go @@ -557,8 +557,8 @@ type entryMatcher struct { // a value (`git -$(x) /tmp push`); a word that may print nothing both is and is // not an operand (`git $(true) push`). The subcommands, the count, the prefix // and the path are all met by one reading. spelled is the segment's -// segment.spelled, which only the arg_values clause reads (writtenOperand). -func newEntryMatcher(p Pattern, tokens []string, spelled map[int]string, glob func(int) bool) entryMatcher { +// segment.spelled, which only the arg_values clause reads (writtenMatches). +func newEntryMatcher(p Pattern, tokens []string, spelled map[int][]string, glob func(int) bool) entryMatcher { n := len(tokens) want := operandWant{ sub: p.Subcommand, sub2: p.Subcommand2, min: p.MinOperands, @@ -699,9 +699,9 @@ func argPrefixMatches(prefix string, ops []string) bool { return false } -// argValueMatches reports whether an operand, as writtenOperand reads it, is -// one of the words. Only operands are considered, and a substitution's output -// is taken as empty, as argPrefixMatches reads a prefix: a word that is wholly +// argValueMatches reports whether an operand, as writtenMatches reads each +// text of its written spelling, is one of the words. Only operands are +// considered, and a substitution's output is taken as empty, as argPrefixMatches reads a prefix: a word that is wholly // a substitution is how an everyday delete names its target (`rm -rf // "$(mktemp -d)"`), so reading it as every target would refuse them all // (unknown.go's operand residual). A variable is compared as the line wrote @@ -722,6 +722,26 @@ func argValueMatches(values []string, written string) bool { return true } } + if lead := strings.TrimLeft(field, "/"); lead != field && absoluteName(lead) && argValueMatches(values, lead) { + return true + } + } + return false +} + +// absoluteName reports whether p begins with the home or the working +// directory written as a variable (`$HOME`, `${HOME}`, `$PWD`, `${PWD}`), +// whose value is an absolute path: a run of `/` written before it names the +// same directory, so argValueMatches also reads the field without that run +// (`rm -rf /$HOME` deletes the home; iss-2609300057462186). +func absoluteName(p string) bool { + for _, name := range []string{"HOME", "PWD"} { + if strings.HasPrefix(p, "${"+name+"}") { + return true + } + if strings.HasPrefix(p, "$"+name) && (len(p) == len(name)+1 || !isNameByte(p[len(name)+1])) { + return true + } } return false } diff --git a/internal/core/guard/payload.go b/internal/core/guard/payload.go index 8fc8b2bf2..4d7613a8f 100644 --- a/internal/core/guard/payload.go +++ b/internal/core/guard/payload.go @@ -97,6 +97,14 @@ func expandPayloads(segs []segment) ([]segment, []payloadSignal) { segs []segment depth int } + // A line that names IFS reads every word whose fields rest on the + // default IFS as past its bound (capIFSSplits), before any string is + // paired with the words it was written from, so the string's words are + // read past it too (`IFS=x; eval rm -rf ${U:-x/x}`). + ifsNamed := namesIFS(segs) + if ifsNamed { + capIFSSplits(segs) + } queue := []work{{segs: segs, depth: 0}} for len(queue) > 0 { item := queue[0] @@ -194,6 +202,13 @@ func expandPayloads(segs []segment) ([]segment, []payloadSignal) { s.home.addPayload(s.at, psegs) } out = append(out, psegs...) + switch { + case ifsNamed: + capIFSSplits(psegs) + case namesIFS(psegs): + ifsNamed = true + capIFSSplits(out) + } queue = append(queue, work{segs: psegs, depth: item.depth + 1}) } } @@ -244,99 +259,141 @@ func payloadView(s segment) segment { return v } -// namedPayloads returns, parallel to refs, the text of each string as the -// line wrote its variables (segment.spelled), "" where it is no other text or -// cannot be paired: payloadView hands a string the mark of each value the +// namedPayloads returns, parallel to refs, the texts of each string as the +// line wrote its variables (segment.spelled), none where it is no other text +// or cannot be paired: payloadView hands a string the mark of each value the // enclosing shell put in it, which reads as an unknown word with no name, so -// `sh -c "rm -rf $HOME"` holds a mark where `$HOME` was written. Only the -// arg_values compare reads what spellPayload takes from it; every reading of -// the string reads the marks (iss-2609290321312087). The words are paired by -// payloadsOf's own order, and a pair whose kind or family differs is not -// paired. -func namedPayloads(s segment, refs []payloadRef) []string { - named := make([]string, len(refs)) - v, ok := spelledView(s) - if !ok { - return named - } - nrefs := payloadRefsOf(v) - if len(nrefs) != len(refs) { - return named - } - for r, ref := range refs { - if n := nrefs[r]; n.kind == ref.kind && n.family == ref.family && n.payload != ref.payload { - named[r] = n.payload +// `sh -c "rm -rf $HOME"` holds a mark where `$HOME` was written. A word's +// spelling is a set (`${DIR:-$HOME}` is `${DIR}` and `$HOME`), so the string +// is written out once for each text (spelledViews), and each reading is +// paired on its own. Only the arg_values compare reads what spellPayload +// takes from them; every reading of the string reads the marks +// (iss-2609290321312087). The words are paired by payloadsOf's own order, and +// a pair whose kind or family differs is not paired. +func namedPayloads(s segment, refs []payloadRef) [][]string { + if len(refs) == 0 { + return nil + } + named := make([][]string, len(refs)) + for _, v := range spelledViews(s) { + nrefs := payloadRefsOf(v) + if len(nrefs) != len(refs) { + continue + } + for r, ref := range refs { + if n := nrefs[r]; n.kind == ref.kind && n.family == ref.family && n.payload != ref.payload { + named[r] = appendText(named[r], n.payload) + } } } return named } -// spelledView is payloadView with each word the line wrote with a known +// spelledViews is payloadView with each word the line wrote with a known // variable spelled as the line wrote it (segment.spelled) instead of with // varMark, where payloadView spells it; a variable whose text is not known -// stays varMark. ok is false when no word changes. -func spelledView(s segment) (segment, bool) { +// stays varMark. A word's spelling is a set, so there is one view for each +// place in the largest set, and view k writes each word's k-th text (its last +// where it has fewer): every text of every word is written in some view, and +// the views number at most maxSpellings. nil when no word changes. +func spelledViews(s segment) []segment { if len(s.spelled) == 0 { - return s, false + return nil } v := payloadView(s) - var toks []string + written := map[int][]string{} + most := 0 for i, text := range s.variable { // payloadView spelled this word with varMark (text); a word it left, // where a command can sit, keeps its unknownMark and is left here too. - w, ok := s.spelled[i] + ws, ok := s.spelled[i] if !ok || v.tokens[i] != text { continue } - w = strings.ReplaceAll(strings.ReplaceAll(w, fieldText, " "), quotedFieldText, fieldText) - if w = strings.ReplaceAll(w, unknownText, varText); w == text { - continue + var texts []string + changed := false + for _, w := range ws { + w = strings.ReplaceAll(strings.ReplaceAll(w, fieldText, " "), quotedFieldText, fieldText) + w = strings.ReplaceAll(w, unknownText, varText) + changed = changed || w != text + texts = append(texts, w) } - if toks == nil { - toks = append([]string(nil), v.tokens...) + if !changed { + continue } - toks[i] = w + written[i] = texts + most = max(most, len(texts)) } - if toks == nil { - return v, false + if most == 0 { + return nil } - v.tokens = toks - return v, true + views := make([]segment, most) + for k := range views { + toks := append([]string(nil), v.tokens...) + for i, texts := range written { + toks[i] = texts[min(k, len(texts)-1)] + } + views[k] = v + views[k].tokens = toks + } + return views } -// spellPayload reads the string named, the same string as the one psegs +// spellPayload reads each string named, the same string as the one psegs // were read from with its variables written out (namedPayloads), and gives -// each word of psegs that holds a variable the spelling of the word at the -// same place of named: its own segment.spelled, or its text where the string -// quotes the name (`sh -c "rm -rf '$HOME'"`). A word is paired only when -// both readings have the same segments and words, and the word read from -// named has the mark-view word's known text in the same order around it -// (fitsWritten); an unpaired word keeps the spelling it has, which names no -// variable. Nothing else of psegs is changed. -func spellPayload(psegs []segment, named string) { - if named == "" { - return - } - nsegs, err := tokenize(named) - if err != nil || len(nsegs) != len(psegs) { - return - } - for i := range psegs { - m, n := psegs[i], nsegs[i] - if len(m.spelled) == 0 || len(m.tokens) != len(n.tokens) { +// each word of psegs that holds a variable the texts of the word at the same +// place of each: its own segment.spelled, or its text where the string quotes +// the name (`sh -c "rm -rf '$HOME'"`). A word is paired only when both +// readings have the same segments and words, and the word read from named has +// the mark-view word's known text in the same order around it (fitsWritten); +// a word no reading pairs keeps the spelling it has, which names no variable. +// A word paired with more than maxSpellings texts, or with a text past a +// bound, is spellCapped. Nothing else of psegs is changed. +func spellPayload(psegs []segment, named []string) { + paired := make([]map[int][]string, len(psegs)) + for _, nm := range named { + nsegs, err := tokenize(nm) + if err != nil || len(nsegs) != len(psegs) { continue } - for j := range m.spelled { - w, ok := n.spelled[j] - if !ok { - if isUnknown(n.tokens[j]) { + for i := range psegs { + m, n := psegs[i], nsegs[i] + if len(m.spelled) == 0 || len(m.tokens) != len(n.tokens) { + continue + } + for j := range m.spelled { + if !isUnknown(m.tokens[j]) { + // A word spelled with no variable's mark (`$!`, addBang) + // keeps the spelling its own reading gave it. continue } - w = n.tokens[j] + ws, ok := n.spelled[j] + if !ok { + if isUnknown(n.tokens[j]) { + continue + } + ws = []string{n.tokens[j]} + } + if !fitsWritten(m.tokens[j], n.tokens[j]) { + continue + } + for _, w := range ws { + if fitsWritten(m.tokens[j], w) { + if paired[i] == nil { + paired[i] = map[int][]string{} + } + paired[i][j] = appendText(paired[i][j], w) + } + } } - if fitsWritten(m.tokens[j], n.tokens[j]) && fitsWritten(m.tokens[j], w) { - m.spelled[j] = w + } + } + for i, words := range paired { + for j, texts := range words { + if capped(texts) || len(texts) > maxSpellings { + texts = []string{spellCapped} } + psegs[i].spelled[j] = texts } } } @@ -435,6 +492,376 @@ func wordFeeds(s segment, keep func(int) bool) []feed { return append(rest, run) } +// namesIFS reports whether segs name IFS, as splitAfterIFS and +// capIFSSplits read a naming: any word that holds the name once its quotes +// are read (`IFS=x`, `declare "I"'FS=x'`, `declare $'\x49FS=x'`), a text +// the tokenizer read that holds the name or assigns through a target +// holding an expansion (segment.namesIFSInText, targetsAnExpansion), an +// expansion standing as a name a builtin assigns (`printf -v "$n" x`, +// `read $v`, `declare $(cmd)=x`): namingCommands' operands, wherever they +// stand in the command, and the word after `printf -v` or `wait -p`, and a +// nameref's declaration (`declare -n r=$x`, `local -n r`), whose value is +// the name any later plain assignment to it sets. The builtin reading +// refuses on the side of a name: `read -p "$prompt" f` counts too. +func namesIFS(segs []segment) bool { + for _, s := range segs { + if s.namesIFSInText { + return true + } + naming, target, decl := false, false, false + flag := "" + for _, tok := range s.tokens { + tally(len(tok)) + if strings.Contains(tok, "IFS") { + return true + } + if nameMarked(tok) && (naming || target || flag != "" && strings.HasPrefix(tok, flag)) { + return true + } + target = flag != "" && tok == flag + if decl && len(tok) > 1 && tok[0] == '-' && strings.IndexByte(tok, 'n') > 0 { + return true + } + if namingCommands[tok] { + naming, decl = true, declarations[tok] + } + if f, ok := targetFlags[tok]; ok { + flag = f + } + } + } + return false +} + +// namingCommands are the builtins that assign a variable each name they are +// handed names: a declaration (`export ${I}FS=x`), `read`, `mapfile` and +// `readarray`, `getopts`'s name, and `let`'s expressions. +var namingCommands = map[string]bool{ + "export": true, "declare": true, "typeset": true, "readonly": true, "local": true, + "read": true, "mapfile": true, "readarray": true, "getopts": true, "let": true, +} + +// declarations are the builtins whose `-n` declares a nameref. +var declarations = map[string]bool{"declare": true, "typeset": true, "local": true} + +// targetFlags names, per builtin, the flag whose word assigns the variable +// it names: `printf -v NAME`, and bash 5.1's `wait -p NAME`. +var targetFlags = map[string]string{"printf": "-v", "wait": "-p"} + +// nameMarked reports whether the name tok would assign holds an +// expansion's mark: its text before the first `=`, or all of it where it +// has none. `PATH=$HOME/bin` names PATH, which the line spells. +func nameMarked(tok string) bool { + if eq := strings.IndexByte(tok, '='); eq >= 0 { + tok = tok[:eq] + } + return strings.IndexByte(tok, unknownMark) >= 0 || strings.IndexByte(tok, varMark) >= 0 +} + +// targetsAnExpansion reports whether text assigns through a target that +// holds an expansion, read lexically over the raw text of one layer, +// quotes, arithmetic bodies, subscripts and here-documents included. bash +// sets the variable a target's VALUE names, so a target holding an +// expansion (`$name`, `${…}` with its case changes and transforms, `$(…)`, +// `$((…))`, `$[…]`, a backtick substitution, or a mark a payload carries) +// can name IFS whatever the line writes beside it: `(( ${a}${b} = 1 ))` +// with a=I and b=FS sets IFS (reverify4-guardSet finding 1). The rule reads +// the target, never its bytes. +// +// A target is the word that stands before an assignment operator (`=`, +// `+=`, `-=`, `*=`, `/=`, `%=`, `<<=`, `>>=`, `&=`, `^=`, `|=`, and a +// parameter expansion's `:=`), or beside a `++` or `--`; its name part +// leaves out a subscript (`a[$i]=x` names a), while the subscript's own +// body is read as arithmetic. Every place bash assigns through such an +// operator is one of these shapes: an assignment word (a declaration's or +// env's operand included), an eval'd string, every arithmetic body +// (`((…))`, `$((…))`, `$[…]`, a for header, a subscript, a substring +// offset, `[[ -eq ]]`), a string an arithmetic context later reads (let's +// operand, an integer's value, a variable an arithmetic reference +// evaluates), and `${!x:=…}`, whose target is the name x holds. Arithmetic +// takes the operator spaced, so a blank between target and operator is +// read through, except where bash cannot be assigning: +// - a lone `=` whose target follows a test's word (`[`, `[[`, `test`, +// `-a`, `-o`, `&&`, `||`, `\(`) outside an arithmetic body is the +// test's comparison (`[ $a = b ]`); arithmetic refuses an assignment +// after any of those words; +// - a `--` that begins a word outside an arithmetic body is a flag +// (`git log --$fmt`), and a spaced `--` a blank follows there ends a +// command's options (`git checkout $b -- f`). +// +// The builtins that take a name as a whole operand (`read "$x"`, +// `printf -v "$x"`, a nameref's declaration) are read over the words +// (namesIFS). What it does not read is an operator the line does not write: +// a value built from expansions that an arithmetic context evaluates +// (`y=$a$b; : $((y))` with b holding `=1`), a command's output +// (`x=$(cmd); : $((x))`), and, by the second exception, a decrement written +// as its own word in such a string (`n="1 + --$x"` for an integer n). +func targetsAnExpansion(text string) bool { + tally(len(text)) + type level struct { + closer byte // the byte that closes the level; 0 at the top + exp bool // an expansion: closing it adds one to the enclosing target + sub bool // a subscript: closing it leaves the enclosing target as it was + arith bool // an arithmetic body, where an operator takes its target spaced + run bool // a target is being read + runExp bool // its name part holds an expansion + prev bool // a blank ended a target, and nothing else has come since + prevExp bool + pre bool // a `++` or `--` stands before the next target + chunk int // where the blank-delimited word being read began; -1 between words + words [2]string + } + stack := []level{{chunk: -1}} + // pending counts the open levels per closer, so a closer none is open + // for costs nothing and the scan stays linear. + var pending [4]int + slot := func(c byte) int { return strings.IndexByte("})]`", c) } + push := func(lv level) { + lv.chunk = -1 + stack = append(stack, lv) + pending[slot(lv.closer)]++ + } + end := func(l *level) { + l.run, l.runExp, l.prev, l.prevExp, l.pre = false, false, false, false, false + } + word := func(l *level) { + if !l.run { + l.run, l.runExp, l.prev = true, false, false + } + } + expand := func(l *level) bool { + word(l) + l.runExp = true + return l.pre + } + // closeAt closes the innermost open c, and reports whether it was open + // and whether the expansion it closes completes a target a `++` or `--` + // stands before. + closeAt := func(c byte) (open, hit bool) { + if pending[slot(c)] == 0 { + return false, false + } + k := len(stack) - 1 + for stack[k].closer != c { + pending[slot(stack[k].closer)]-- + k-- + } + pending[slot(c)]-- + lv := stack[k] + stack = stack[:k] + out := &stack[k-1] + switch { + case lv.exp: + return true, expand(out) + case !lv.sub: + end(out) + } + return true, false + } + for i := 0; i < len(text); i++ { + l := &stack[len(stack)-1] + c := text[i] + if isBlank(c) { + if l.run { + l.prev, l.prevExp = true, l.runExp + l.run, l.runExp, l.pre = false, false, false + } + if l.chunk >= 0 { + l.words[0], l.words[1] = l.words[1], text[l.chunk:i] + l.chunk = -1 + } + continue + } + if l.chunk < 0 { + l.chunk = i + } + switch { + case c == '\\': + word(l) + i++ + case c == '"' || c == '\'': + // A quote is part of the word it stands in. + case c == unknownMark || c == varMark: + if expand(l) { + return true + } + case c == '`': + open, hit := closeAt('`') + if hit { + return true + } + if !open { + push(level{closer: '`', exp: true}) + } + case c == '$' && i+1 < len(text): + switch n := text[i+1]; { + case n == '(' && i+2 < len(text) && text[i+2] == '(': + push(level{closer: ')', exp: true, arith: true}) + push(level{closer: ')', arith: true}) + i += 2 + case n == '(': + push(level{closer: ')', exp: true}) + i++ + case n == '{': + lv := level{closer: '}', exp: true, arith: true} + if i+2 < len(text) && text[i+2] == '!' { + // `${!x:=1}` assigns the name x holds. + lv.run, lv.runExp = true, true + i++ + } + push(lv) + i++ + case n == '[': + push(level{closer: ']', exp: true, arith: true}) + i++ + case isNameByte(n) || strings.IndexByte("@*#?$!-", n) >= 0: + if expand(l) { + return true + } + i++ + default: + word(l) + } + case c == '(': + arith := l.arith + end(l) + if !arith && i+1 < len(text) && text[i+1] == '(' { + push(level{closer: ')', arith: true}) + i++ + arith = true + } + push(level{closer: ')', arith: arith}) + case c == '{': + arith := l.arith + end(l) + push(level{closer: '}', arith: arith}) + case c == '[': + if l.run { + push(level{closer: ']', sub: true, arith: true}) + } else { + end(l) + } + case c == ')' || c == '}' || c == ']': + open, hit := closeAt(c) + if hit { + return true + } + if !open { + end(l) + } + case isNameByte(c): + word(l) + case strings.IndexByte("=+-*/%&|^<>!:", c) >= 0: + kind, n := operatorAt(text, i) + spaced := !l.run && l.prev + target := l.run && l.runExp || spaced && l.prevExp + switch kind { + case opAssign: + test := c == '=' && spaced && !l.arith && (i+1 == len(text) || isBlank(text[i+1])) && + testWords[strings.TrimLeft(l.words[0], `$'"\`)] + if target && !test { + return true + } + end(l) + case opStep: + options := !l.arith && c == '-' && spaced && i+2 < len(text) && isBlank(text[i+2]) + if target && !options { + return true + } + flag := !l.arith && c == '-' && l.chunk == i + end(l) + l.pre = !flag + default: + end(l) + } + i += n - 1 + default: + end(l) + } + } + return false +} + +// testWords are the words a test's comparison can follow (`[ $a = b ]`, +// `test "$a" = b`, `[[ $a = b && $c = d ]]`), read past the quotes a +// string that holds the test opens with (`bash -c '[ $a = b ]'`). Arithmetic takes none of them +// before an assignment: `(( 1 && $x = 1 ))` is an error, while `!` and `(` +// are not in the set because `(( ! $x = 1 ))` (bash 3.2) and +// `(( ( $x = 1 ) ))` assign. +var testWords = map[string]bool{ + "[": true, "[[": true, "test": true, "-a": true, "-o": true, "&&": true, "||": true, `\(`: true, +} + +// The operator kinds operatorAt reads. +const ( + opOther = iota // no assignment: a comparison, a redirection, a pattern + opAssign // `=` or a compound assignment + opStep // `++` or `--` +) + +// operatorAt reads the operator that begins at text[i], and returns its kind +// and its length. +func operatorAt(text string, i int) (kind, n int) { + c := text[i] + var next byte + if i+1 < len(text) { + next = text[i+1] + } + switch c { + case '=': + if next == '=' || next == '~' { + return opOther, 2 + } + return opAssign, 1 + case '<', '>': + if next == c { + if i+2 < len(text) && text[i+2] == '=' { + return opAssign, 3 + } + return opOther, 2 + } + if next == '=' { + return opOther, 2 + } + case '+', '-': + if next == c { + return opStep, 2 + } + if next == '=' { + return opAssign, 2 + } + case '!': + if next == '=' { + return opOther, 2 + } + default: // * / % & | ^ : + if next == '=' { + return opAssign, 2 + } + } + return opOther, 1 +} + +// isBlank reports whether c separates the words of a shell line. +func isBlank(c byte) bool { + return c == ' ' || c == '\t' || c == '\n' +} + +// capIFSSplits reads each word of segs whose fields rest on the default IFS +// (segment.ifsSplit) as a spelling past its bound (spellCapped), which the +// arg_values compare reads as every value (review-guardSet MAJOR-2). It runs +// on a line that names IFS anywhere: which assignment reaches which +// expansion is not modelled, as splitAfterIFS does not model it, so a +// prefix assignment (`IFS=x rm -rf ${U:-x/x}`), which bash does not apply +// to its own command's words, refuses too. +func capIFSSplits(segs []segment) { + for _, s := range segs { + for i := range s.ifsSplit { + s.spelled[i] = []string{spellCapped} + } + } +} + // splitAfterIFS reports whether a segment carrying an unquoted fixed output // shares the command line, at any payload layer, with another segment that // names IFS (review7-guard finding 2). fixedOutputSegment splits an output on @@ -467,11 +894,8 @@ func splitAfterIFS(segs []segment) bool { if s.fromFixedOutput || (carriers == 1 && carrying[i]) { continue } - for _, tok := range s.tokens { - tally(len(tok)) - if strings.Contains(tok, "IFS") { - return true - } + if namesIFS(segs[i : i+1]) { + return true } } return false diff --git a/internal/core/guard/teach.go b/internal/core/guard/teach.go index 1720ace04..d69880545 100644 --- a/internal/core/guard/teach.go +++ b/internal/core/guard/teach.go @@ -7,10 +7,12 @@ import ( ) // teach.go is the registry's teaching-plane vocabulary (spc-16, "Two planes, one -// registry"; iss-151). The rules loader builds its bundled SHELL domain from -// these renderings, so what an agent is taught before shell-heavy work is what -// the guard would say at the moment of refusal, drawn from the one registry: -// an entry added or removed changes both planes with no second edit. +// registry"; iss-151, ruling CK1). The rules loader builds its SHELL domain +// from these renderings, over the registry the guard enforces in the +// repository (the bundled entries and the repository's own), so what an agent +// is taught before shell-heavy work is what the guard would say at the moment +// of refusal, drawn from the one registry: an entry added or removed changes +// both planes with no second edit. // maxTaughtValues caps how many operand words a description lists. An entry // like rm-rf-root-or-home carries every spelling of the home directory, and a @@ -18,9 +20,40 @@ import ( // hazard, and the count says the rest exist. const maxTaughtValues = 6 +// RepoMark is the provenance a lesson carries when its words are a +// repository's own rather than abcd's: an entry the repository's +// .abcd/guard.json added, or a bundled entry it reworded (ruling CK1). It +// follows the entry id, as a rules override's "(repo override)" follows the +// domain name, so whose words an agent is being taught is never invisible +// (GHSA-22f8-qf5r-gjgq). +const RepoMark = "(repo)" + // Lessons renders every entry's Lesson in entry-id order: the rules of the // teaching plane, one per registry entry. func (r Registry) Lessons() []string { + return r.lessons(func(string, Entry) bool { return false }) +} + +// LessonsOver renders r's lessons as Lessons does, one per entry in id order, +// and marks with RepoMark every lesson bundled does not teach word for word: +// an entry bundled lacks, or one whose lesson a repository layer changed (its +// tier, pattern, why or successor). A change no lesson shows — a fixture — +// leaves the bundled lesson unmarked, because the words taught are still +// abcd's. The registry is the guard's registry in force for the repository, so +// the plane teaches exactly what the guard enforces there. +func (r Registry) LessonsOver(bundled Registry) []string { + return r.lessons(func(id string, e Entry) bool { + b, ok := bundled.Entries[id] + if !ok { + return true + } + b.ID = id + return b.Lesson() != e.Lesson() + }) +} + +// lessons renders every entry in id order, marking those repo reports true for. +func (r Registry) lessons(repo func(id string, e Entry) bool) []string { ids := make([]string, 0, len(r.Entries)) for id := range r.Entries { ids = append(ids, id) @@ -30,7 +63,7 @@ func (r Registry) Lessons() []string { for _, id := range ids { e := r.Entries[id] e.ID = id - out = append(out, e.Lesson()) + out = append(out, e.lesson(repo(id, e))) } return out } @@ -59,13 +92,21 @@ func (r Registry) RecallTerms() []string { // Lesson is the one-line rule an entry teaches: whether the guard refuses or // warns, the entry id, the command it describes, the plain-language why, and // the safe successor. -func (e Entry) Lesson() string { +func (e Entry) Lesson() string { return e.lesson(false) } + +// lesson is Lesson with the repository's provenance mark after the id when +// repo is set. +func (e Entry) lesson(repo bool) string { lead := "Refused by the guard" if e.Tier == TierWarn { lead = "Warned by the guard" } - return fmt.Sprintf("%s (%s): %s. %s Instead: %s", - lead, e.ID, e.Pattern.Describe(), strings.TrimSpace(e.Why), strings.TrimSpace(e.Successor)) + id := "(" + e.ID + ")" + if repo { + id += " " + RepoMark + } + return fmt.Sprintf("%s %s: %s. %s Instead: %s", + lead, id, e.Pattern.Describe(), strings.TrimSpace(e.Why), strings.TrimSpace(e.Successor)) } // head is the command with its subcommands, space-joined. diff --git a/internal/core/guard/teach_test.go b/internal/core/guard/teach_test.go index c44a66e3f..2c88e778d 100644 --- a/internal/core/guard/teach_test.go +++ b/internal/core/guard/teach_test.go @@ -108,3 +108,53 @@ func TestRecallTermsAreTheCommandHeads(t *testing.T) { } } } + +// TestLessonsOverMarkTheRepositorysOwnWords: a repository's .abcd/guard.json +// entries are taught by the same generator as the bundled ones (ruling CK1), +// and every lesson whose words the bundled registry does not teach carries the +// "(repo)" mark, so whose words these are is never invisible: an entry the +// repository added, and a bundled entry it reworded. A change the lesson does +// not show (a fixture) leaves the bundled lesson unmarked. +func TestLessonsOverMarkTheRepositorysOwnWords(t *testing.T) { + bundled := Defaults() + if got, want := bundled.LessonsOver(bundled), bundled.Lessons(); !reflect.DeepEqual(got, want) { + t.Fatalf("the bundled registry over itself marked a lesson:\n got %q\nwant %q", got, want) + } + + repo := Defaults() + repo.Entries["deploy-prod"] = Entry{ + ID: "deploy-prod", + Pattern: Pattern{Command: "make", Subcommand: "deploy"}, + Tier: TierBlocker, + Why: "It deploys to production from a laptop.", + Successor: "Open a release pull request; CI deploys it.", + } + clean := repo.Entries["git-clean"] + clean.Why = "Untracked files here hold the fixtures nobody committed." + repo.Entries["git-clean"] = clean + reset := repo.Entries["git-reset-hard"] + reset.Fixtures.KnownGood = append(reset.Fixtures.KnownGood, "git reset --soft HEAD~1") + repo.Entries["git-reset-hard"] = reset + + got := map[string]string{} + for _, l := range repo.LessonsOver(bundled) { + for id := range repo.Entries { + if strings.Contains(l, "("+id+")") { + got[id] = l + } + } + } + if want := "Refused by the guard (deploy-prod) (repo): `make deploy`. It deploys to production from a laptop. Instead: Open a release pull request; CI deploys it."; got["deploy-prod"] != want { + t.Errorf("the repository's own entry teaches\n %q\nwant\n %q", got["deploy-prod"], want) + } + if !strings.HasPrefix(got["git-clean"], "Warned by the guard (git-clean) (repo): `git clean`.") || + !strings.Contains(got["git-clean"], clean.Why) { + t.Errorf("a bundled entry the repository reworded is not marked as the repository's: %q", got["git-clean"]) + } + if want := bundled.Entries["git-reset-hard"].Lesson(); got["git-reset-hard"] != want { + t.Errorf("a fixture-only change marked the bundled lesson:\n got %q\nwant %q", got["git-reset-hard"], want) + } + if n := len(repo.LessonsOver(bundled)); n != len(repo.Entries) { + t.Errorf("LessonsOver gave %d lessons for %d entries", n, len(repo.Entries)) + } +} diff --git a/internal/core/guard/testdata/corpus/adversarial.txt b/internal/core/guard/testdata/corpus/adversarial.txt index c2cffc235..d9f1f1692 100644 --- a/internal/core/guard/testdata/corpus/adversarial.txt +++ b/internal/core/guard/testdata/corpus/adversarial.txt @@ -139,6 +139,79 @@ quiet flock /tmp/lock /bin/echo -c "git push --force origin main" quiet flock -w 5 /tmp/lock /bin/echo --command "git push --force origin main" warn flock /tmp/lock kubectl exec -c app pod -- gh repo delete owner/repo +# --- block: a parameter expansion that can print the root or the home ------ +# iss-2609290426544292. A default prints its word when the variable is unset, +# an alternative prints its word or nothing, a substring can print the `/` a +# path begins with, and bash 3.2 reads a default after a subscript's `]`. A +# spelling nested past the depth the guard follows refuses rather than passes. +block rm -rf ${DIR:-$HOME} +block rm -rf "${DIR:-$HOME}" +block rm -rf ${DIR:-/} +block rm -rf ${DIR-$HOME}/* +block rm -rf ${DIR:=/} +block rm -rf ${DIR:-~} +block rm -rf ${X[0]]-$HOME} +block rm -rf ${X[0]]:-/} +block rm -rf ${X:+${X:+${X:+${X:+$HOME}}}} +block rm -rf ${PREFIX:+$PREFIX}/ +block rm -rf ${PWD:0:1} +block rm -rf ${X/*/$HOME} +block sh -c "rm -rf ${DIR:-$HOME}" +block bash -c 'rm -rf ${DIR:-/}' +block rm -rf ${X:+${X:+${X:+${X:+${X:+${X:+${X:+${X:+${X:+./build}}}}}}}}} + +# --- quiet: the same expansions naming a safe path --------------------------- +quiet rm -rf ${DIR:-./build} +quiet rm -rf "${BUILD_DIR:-./build}" +quiet rm -rf "${TMPDIR:-/tmp}/abcd-x" +quiet rm -rf "${XDG_CACHE_HOME:-$HOME/.cache}/abcd" +quiet rm -rf ${DIR:-$HOME}/build +quiet rm -rf ${DIR:?} +quiet rm -rf ${DIR:?set DIR} +quiet rm -rf "${OUT%/}" +quiet rm -rf "${DIR/#\~/$HOME}" +quiet rm -rf ${X:+${X:+${X:+${X:+./build}}}} +quiet sh -c "rm -rf ${DIR:-./build}" +quiet echo ${X:+${X:+${X:+${X:+${X:+${X:+${X:+${X:+${X:+x}}}}}}}}} + +# --- block: a trim or a replacement whose pattern can take the rest --------- +# iss-2609292320015665, iss-2609300009506126, iss-2609300009581165. A pattern +# that can take a remainder of any length, from the end a trim anchors at, can +# leave only the root or nothing; one that can match the whole value hands a +# replacement's string the value's place. bash 3.2 prints nothing for a trim +# after a scalar's subscript. +block rm -rf ${X%${X#?}} +block rm -rf "${X%${X#?}}" +block rm -rf ${X%%[!/]*} +block rm -rf ${X%$Y} +block rm -rf ${T#${T%?}} +block rm -rf ${T##*[!/]} +block rm -rf ${X%${X#?}}* +block rm -rf ${X%%*}/ +block rm -rf $HOME/${X%%*} +block rm -rf ${X[0]%zzz}/ +block rm -rf ${X/${X#?}} +block rm -rf ${X/?*/$HOME} +block rm -rf ${X/\/*/$HOME} +block sh -c "rm -rf ${X%${X#?}}" +block bash -c 'rm -rf ${X%%*}/' + +# --- quiet: the everyday trims and replacements ------------------------------ +quiet rm -rf ${DIR%/} +quiet rm -rf "${DIR%/}/build" +quiet rm -rf ${f%.txt} +quiet rm -rf ${f%.*} +quiet rm -rf ${p##*/} +quiet rm -rf "$HOME/${d##*/}" +quiet rm -rf ${p%/*} +quiet rm -rf "${p%/*}/build" +quiet rm -rf "$HOME/${p#$HOME/}" +quiet rm -rf ${X%?} +quiet rm -rf ${X%%*} +quiet rm -rf ${X%%/*} +quiet rm -rf ${name//[^a-z]/} +quiet rm -rf ${X/foo/$HOME} + # --- fp: accepted false positives, and the exact shape they take ------------ fp rg git push --force docs/ fp grep -rn gh repo delete . diff --git a/internal/core/guard/tokenize.go b/internal/core/guard/tokenize.go index 392a7c296..907feae17 100644 --- a/internal/core/guard/tokenize.go +++ b/internal/core/guard/tokenize.go @@ -82,15 +82,26 @@ type segment struct { // for the re-read to take as a variable's (payloadView). variable map[int]string // spelled records, per token index, a word holding a parameter - // expansion's mark as the line WROTE it: each variable's mark replaced by - // its expansion's text (`$HOME`, `${PWD}`; a simple name the next byte - // would extend is braced), every substitution's mark dropped, and a mark - // whose text is not known — a varMark carried into a payload's text — - // kept as unknownMark. Only an entry's arg_values read it - // (writtenOperand): every other reading takes the token, where the - // variable is the unknown word's mark (iss-2609290321312087). nil when no - // word holds a variable. - spelled map[int]string + // expansion's mark as the line WROTE it, as the set of texts it can + // print (spellWritten): each variable's mark replaced by each text its + // expansion can print (`$HOME`, `${PWD}`; `${DIR}` and `$HOME` for + // `${DIR:-$HOME}`; a simple name the next byte would extend is braced), + // every substitution's mark dropped, and a mark whose text is not known — + // a varMark carried into a payload's text — kept as unknownMark. Only an + // entry's arg_values read it (writtenMatches): every other reading takes + // the token, where the variable is the unknown word's mark + // (iss-2609290321312087). nil when no word holds a variable. + spelled map[int][]string + // namesIFSInText records that the text a tokenize call read holds the + // name IFS or assigns through a target that holds an expansion + // (targetsAnExpansion), wherever it stands: an arithmetic body or a subscript leaves no word to read it + // in. It rides on an empty segment of its own, as substitutionUnread + // does, and namesIFS reads it. + namesIFSInText bool + // ifsSplit records, per token index, a spelled word whose fields rest on + // the default IFS (ifsSplits in unknown.go), which a line that names IFS + // reads as past its bound (capIFSSplits). nil when no word is. + ifsSplit map[int]bool // arrivals caches commandArrivals(tokens) once Check has its final // segments (walked records that it is set), so the walk to command position // is paid once per segment rather than once per entry. A segment built @@ -386,8 +397,10 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { // spells rides with the segment (segment.spelled); curVarAt records, // for the word being built, where in cur each variable's mark stands // and the expansion's text, "" where it is not known. - spells map[int]string + spells map[int][]string curVarAt []varSite + // splits rides with the segment (segment.ifsSplit). + splits map[int]bool // curMask is parallel to cur and records, per byte, whether it reached // the tokenizer unquoted (wordStruct) and whether it began its word // (wordRawStart) — what the brace expander needs to read a word the way @@ -615,10 +628,37 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { vars[len(toks)] = text } // addVar leaves the mark of a parameter expansion where its value goes, - // and records the expansion's text as the line wrote it (segment.spelled). - addVar := func(text string) { + // and records the texts it can print as the line wrote them + // (segment.spelled). + addVar := func(texts ...string) { addCur([]byte{varMark}, 0) - curVarAt[len(curVarAt)-1].text = text + curVarAt[len(curVarAt)-1].texts = texts + } + // addParam leaves the mark of a parameter written without braces, whose + // text is text (`$HOME`, `$1`), with the texts it can print + // (paramTexts). + addParam := func(text string) { + addVar(paramTexts(text)...) + } + // addBang keeps a `$!` in the word as the text it is, which every + // reading takes as the job's number (`kill $!`), and records a site on + // it for the written spelling alone: the number, or nothing where no job + // ran in the background, which leaves the text beside it, so `$!/` is + // also `/` to arg_values (iss-2609300057467536). + addBang := func(mask byte) { + curVarAt = append(curVarAt, varSite{at: len(cur), texts: paramTexts("$!"), width: 2}) + addCur([]byte("$!"), mask) + } + // markIFSSplit files the word being built under segment.ifsSplit when its + // sites rest on the default IFS. + markIFSSplit := func(sites []varSite) { + if !ifsSplits(sites) { + return + } + if splits == nil { + splits = map[int]bool{} + } + splits[len(toks)] = true } // recordSpelling files the word being built under segment.spelled when a // variable's mark is in it: word is the token it becomes, and whole @@ -627,17 +667,18 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { // unknownFromOpenExpansion rewrote — is filed as unknownText, which no // value names. recordSpelling := func(word string, whole bool) { - if !curVar { + if len(curVarAt) == 0 { return } if spells == nil { - spells = map[int]string{} + spells = map[int][]string{} } switch { case whole: spells[len(toks)] = spellWritten(cur, curVarAt, nil) + markIFSSplit(curVarAt) case isUnknown(word): - spells[len(toks)] = unknownText + spells[len(toks)] = []string{unknownText} } } // recordBraceSpelling is recordSpelling for one word a brace group made: @@ -645,7 +686,7 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { // name the group's unquoted text runs on from is read as bash reads it // after the expansion (`$HO{ME,}` is `$HOME`). recordBraceSpelling := func(word string, w bword) { - if !curVar { + if len(curVarAt) == 0 { return } var sites []varSite @@ -661,9 +702,10 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { return } if spells == nil { - spells = map[int]string{} + spells = map[int][]string{} } spells[len(toks)] = spellWritten(w.b, sites, w.m) + markIFSSplit(sites) } flushToken := func() { if !hasCur { @@ -748,12 +790,14 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { tokens: toks, chain: chain, braceGroup: braceGroup, globbed: globsOrNil(globs), stdinStream: curStdin || pipeNext || len(groupIn) > 0, literal: lits, feeds: feeds, piped: piped, stdinIn: groupIn, home: list, at: len(segs), variable: vars, spelled: spells, + ifsSplit: splits, }) toks = nil globs = nil lits = nil vars = nil spells = nil + splits = nil feeds = nil braceGroup = false pipeNext = false @@ -893,7 +937,7 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { openSubstitution := func(kind parenKind, pos int, procSub bool) { saved := &enclosing{ toks: toks, globs: globs, lits: lits, vars: vars, curVar: curVar, curSub: curSub, - spells: spells, curVarAt: curVarAt, + spells: spells, curVarAt: curVarAt, splits: splits, cur: cur, curMask: curMask, hasCur: hasCur, curGlob: curGlob, curBrace: curBrace, braceGroup: braceGroup, chain: chain, procSub: procSub, curStdin: curStdin, pipeNext: pipeNext, curDocs: curDocs, pieces: curPieces, @@ -902,7 +946,7 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { } toks, globs, lits, cur, curMask, hasCur, curGlob, curBrace, braceGroup = nil, nil, nil, nil, nil, false, false, false, false curPieces, vars, curVar, curSub = nil, nil, false, false - spells, curVarAt = nil, nil + spells, curVarAt, splits = nil, nil, nil // A substitution is a command string of its own: its pipelines begin // inside it. Its standard input is its command's: what was piped into // the groups around it, and the pipe into the command it sits in @@ -974,7 +1018,7 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { curStdin, pipeNext, curDocs, curPieces = e.curStdin, e.pipeNext, e.curDocs, e.pieces feeds, curFeeds, pipeFrom, braceFrom, groupIn = e.feeds, e.curFeeds, e.pipeFrom, e.braceFrom, e.groupIn vars, curVar, curSub = e.vars, e.curVar, e.curSub - spells, curVarAt = e.spells, e.curVarAt + spells, curVarAt, splits = e.spells, e.curVarAt, e.splits resumeDocs(e) if !f.bare { addCur([]byte(arithmeticOperand), 0) @@ -998,7 +1042,7 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { curStdin, pipeNext, curDocs, curPieces = e.curStdin, e.pipeNext, e.curDocs, e.pieces feeds, curFeeds, pipeFrom, braceFrom, groupIn = e.feeds, e.curFeeds, e.pipeFrom, e.braceFrom, e.groupIn vars, curVar, curSub = e.vars, e.curVar, e.curSub - spells, curVarAt = e.spells, e.curVarAt + spells, curVarAt, splits = e.spells, e.curVarAt, e.splits feedFrom(e.segStart) if e.procSub { addCur([]byte(procSubOperand), 0) @@ -1025,7 +1069,9 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { start := len(segs) expandedBody(body) feedFrom(start) - addVar(spellParameter(body, split)) + addVar(spellParameter(body, split)...) + site := &curVarAt[len(curVarAt)-1] + site.split = split if len(segs) > start { curSub = true } @@ -1148,8 +1194,13 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { j += 2 continue } + if line[j] == '$' && j+1 < len(line) && line[j+1] == '!' { + addBang(0) + j += 2 + continue + } if k := simpleParamEnd(line, j+1); line[j] == '$' && k >= 0 { - addVar(paramText(line[j:k])) + addParam(paramText(line[j:k])) j = k continue } @@ -1468,14 +1519,19 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { parens[len(parens)-1].end = end lastList = false i += 3 + case c == '$' && i+1 < len(line) && line[i+1] == '!': + addBang(wordStruct) + lastList = false + i += 2 case c == '$' && simpleParamEnd(line, i+1) >= 0: // A parameter expansion (iss-2609251824244354): the value it prints // is not in the command line, so the word holds unknownMark where // it goes (unknown.go), and `--$X` is a flag of unknown name as // `--$(x)` is. A `$` that is quoted or escaped never reaches here. end := simpleParamEnd(line, i+1) - addVar(paramText(line[i:end])) + addParam(paramText(line[i:end])) curVarAt[len(curVarAt)-1].bare = true + curVarAt[len(curVarAt)-1].split = true lastList = false i = end case c == '$' && i+1 < len(line) && line[i+1] == '{': @@ -1702,9 +1758,30 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { if len(pending) > 0 { markHeredocUnterminated(&segs, chain) } + if depth == 0 && (unwordedIFS(line, segs) || targetsAnExpansion(line)) { + // The text of every substitution depth is in the line read at + // depth 0, so it is read once, there. + segs = append(segs, segment{chain: chain, namesIFSInText: true}) + } return segs, nil } +// unwordedIFS reports whether line holds the name IFS more times than the +// words segs read from it do: an arithmetic body (`: $((IFS=1))`), a +// subscript or an offset inside a parameter expansion leave no word to +// read it in. A name the words hold is read there (namesIFS), where a +// prefix assignment does not reach its own command's substitution +// (splitAfterIFS). +func unwordedIFS(line string, segs []segment) bool { + n := strings.Count(line, "IFS") + for _, s := range segs { + for _, tok := range s.tokens { + n -= strings.Count(tok, "IFS") + } + } + return n > 0 +} + // arithmeticOperand is the word an arithmetic expansion leaves in its // command: a number, which is all `$(( … ))` can print. Its value is not // modelled, and needs not be: no flag, subcommand or path an entry names is a @@ -1951,7 +2028,8 @@ func closingDoubleQuote(line string, i int, budget *int) int { // 0), or `$@`, `$*` or `$-`, whose values are any text. It returns -1 where // the `$` opens no such expansion. `$$`, `$!`, `$?` and `$#` print a number, // which no flag, name or path an entry names can be, as an arithmetic -// expansion's does, and stay the text they are. A name runs on across a +// expansion's does, and stay the text they are; `$!` can also print nothing, +// which its written spelling alone reads (addBang). A name runs on across a // backslash-newline, which bash drops before it reads the name, so `$HO\⏎ME` // is `$HOME` (iss-2609290419119456); paramText is the name as bash reads it. func simpleParamEnd(line string, i int) int { @@ -2169,8 +2247,9 @@ type enclosing struct { vars map[int]string curVar bool curSub bool - spells map[int]string + spells map[int][]string curVarAt []varSite + splits map[int]bool cur []byte curMask []byte hasCur bool diff --git a/internal/core/guard/trimroot_test.go b/internal/core/guard/trimroot_test.go new file mode 100644 index 000000000..a94b918a4 --- /dev/null +++ b/internal/core/guard/trimroot_test.go @@ -0,0 +1,164 @@ +package guard + +import ( + "strings" + "testing" +) + +// TestTrimsThatCanLeaveTheRootTheWrittenCompareReads — iss-2609292320015665. +// A trim takes a prefix or a suffix off the value, and what it leaves depends +// on the shape of its pattern. One whose pattern can take a remainder of any +// length the line does not spell, from the end the trim anchors at, can leave +// only the `/` an absolute path begins with, or the one a directory's value +// ends with: with X=/a/b and T=/tmp/x/, bash 3.2, /bin/sh and dash print `/` +// for `${X%${X#?}}`, `${X%%[!/]*}`, `${T#${T%?}}` and `${T##*[!/]}`. The +// everyday trims, whose pattern is literal text or a glob that meets literal +// text at that end, stay allowed: `${DIR%/}`, `${f%.txt}`, `${p##*/}` and +// `${p%/*}` never leave the root on their own. +func TestTrimsThatCanLeaveTheRootTheWrittenCompareReads(t *testing.T) { + const home = "rm-rf-root-or-home" + const all = shellBare | shellSQ | shellDQ + checkSpellingCases(t, []spellingCase{ + // A suffix trim whose pattern begins with unknown text. + {`rm -rf ${X%${X#?}}`, all, VerdictBlock, home}, + {`rm -rf ${X%%${X#?}}`, all, VerdictBlock, home}, + {`rm -rf "${X%${X#?}}"`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf ${X%"${X#?}"}`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf ${X%$Y}`, all, VerdictBlock, home}, + {`rm -rf ${X%%$Y*}`, all, VerdictBlock, home}, + {`rm -rf ${X%*${X#?}}`, all, VerdictBlock, home}, + {`rm -rf ${X%$(echo a/b)}`, shellBare | shellSQ, VerdictBlock, home}, + {"rm -rf ${X%`echo a/b`}", shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf ${DIR%$HOME}`, all, VerdictBlock, home}, + // A suffix trim whose pattern begins with a glob and can take any length. + {`rm -rf ${X%%[!/]*}`, all, VerdictBlock, home}, + {`rm -rf ${X%%""[!/]*}`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf ${X%%?*}/`, all, VerdictBlock, home}, + // A prefix trim whose pattern ends with unknown text or such a glob. + {`rm -rf ${T#${T%?}}`, all, VerdictBlock, home}, + {`rm -rf ${T##*[!/]}`, all, VerdictBlock, home}, + {`rm -rf ${T##$Y}`, all, VerdictBlock, home}, + // The root it leaves, with the text around it. + {`rm -rf ${X%${X#?}}*`, all, VerdictBlock, home}, + {`rm -rf ${X%%[!/]*}*/`, all, VerdictBlock, home}, + {`rm -rf ${HOME%${HOME#?}}`, all, VerdictBlock, home}, + {`rm -rf $HOME${X%${X#?}}`, all, VerdictBlock, home}, + // An element of an array, trimmed the same way. + {`rm -rf ${A[1]%${A[1]#?}}`, all, VerdictBlock, home}, + // The everyday trims: literal text, or a glob that meets literal text + // at the end the trim anchors at, or one of a fixed width. + {`rm -rf ${DIR%/}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf "${DIR%/}"`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf "${DIR%/}/build"`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${f%.txt}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${f%.*}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${f%%.*}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${p##*/}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf "$HOME/${d##*/}"`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${p%/*}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf "${p%/*}/build"`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${p#*/}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${p##*.}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf "${p#$HOME/}"`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf "$HOME/${p#$HOME/}"`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${X%?}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${X#?}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${X%\*}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${X%"*"}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${X%*}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${X%x$Y}`, shellBare | shellSQ, VerdictAllow, ""}, + }) +} + +// TestExpansionsThatPrintNothingTheWrittenCompareReads — iss-2609300009506126. +// Some expansions print nothing whatever the value is, and the text around +// them is then the whole word: with X=/a/b, `${X%%*}`, `${X##*}`, `${X%%/*}` +// and `${X:0:0}` print nothing on bash 3.2, /bin/sh and dash, so +// `rm -rf ${X%%*}/` deletes the root. bash 3.2, the /bin/bash and /bin/sh of +// macOS, prints nothing too for a trim, a replacement or a substring after a +// scalar's subscript (`${X[0]%zzz}`). An operand the expansion is the whole +// of stays allowed: a word that prints nothing is no operand. +func TestExpansionsThatPrintNothingTheWrittenCompareReads(t *testing.T) { + const home = "rm-rf-root-or-home" + const all = shellBare | shellSQ | shellDQ + checkSpellingCases(t, []spellingCase{ + {`rm -rf ${X%%*}/`, all, VerdictBlock, home}, + {`rm -rf ${X##*}/*`, all, VerdictBlock, home}, + {`rm -rf $HOME/${X%%*}`, all, VerdictBlock, home}, + {`rm -rf ~/${X%%/*}`, all, VerdictBlock, home}, + {`rm -rf ${X##/*}/`, all, VerdictBlock, home}, + {`rm -rf ${X%${X}}/`, all, VerdictBlock, home}, + {`rm -rf ${X:0:0}/`, all, VerdictBlock, home}, + {`rm -rf $HOME${X:9}`, all, VerdictBlock, home}, + {`rm -rf ${X[0]%zzz}/`, all, VerdictBlock, home}, + {`rm -rf ${X[0]#zzz}/*`, all, VerdictBlock, home}, + {`rm -rf ${X[0]/a/b}/`, all, VerdictBlock, home}, + {`rm -rf ${X[0]:1}/`, all, VerdictBlock, home}, + // The expansion alone, and one beside a path that is not the root. + {`rm -rf ${X%%*}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${X%%/*}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${X%%*}/build`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf "${p%%/*}/build"`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${X[0]%zzz}/build`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${X%%.*}/`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf "${X%/*}/"`, shellBare | shellSQ, VerdictAllow, ""}, + }) +} + +// TestReplacementsThatCanTakeTheWholeValueTheWrittenCompareReads — +// iss-2609300009581165. A replacement whose pattern can match the whole of an +// absolute path, whatever it holds, prints its string in its place, and one +// whose pattern can match all of it after the leading `/` prints `/` and its +// string: with X=/a/b, `${X/\/*/$HOME}` and `${X/?*/$HOME}` print the home, +// and `${X/${X#?}}` and `${X//[!\/]*/}` print `/`. The everyday replacements, +// whose pattern is literal text at its start or matches a fixed width, stay +// allowed. +func TestReplacementsThatCanTakeTheWholeValueTheWrittenCompareReads(t *testing.T) { + const home = "rm-rf-root-or-home" + const all = shellBare | shellSQ | shellDQ + checkSpellingCases(t, []spellingCase{ + {`rm -rf ${X/${X#?}}`, all, VerdictBlock, home}, + {`rm -rf ${X/%${X#?}}`, all, VerdictBlock, home}, + {`rm -rf ${X//[!\/]*/}`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf ${X/\/*/$HOME}`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf ${X/?*/$HOME}`, all, VerdictBlock, home}, + {`rm -rf ${X/$Y/~}`, all, VerdictBlock, home}, + {`rm -rf ${X/${X#?}/*}`, all, VerdictBlock, home}, + {`rm -rf ${X/*/}/`, all, VerdictBlock, home}, + {`rm -rf ${X/foo/$HOME}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf "${DIR/#\~/$HOME}"`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${X/*/./build}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${name//[^a-z]/}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf "./${X//\//_}"`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${X/#$HOME/~}/build`, shellBare | shellSQ, VerdictAllow, ""}, + }) +} + +// TestPatternShapesStayLinear holds the pattern reading to the cost bar: a +// trim's or a replacement's pattern is read once, its nested expansions +// stepped over rather than read again, so a long or deeply nested pattern +// costs what its length does. +func TestPatternShapesStayLinear(t *testing.T) { + shapes := []struct { + name string + build func(int) string + }{ + {"long trim pattern", func(n int) string { + return "rm -rf ${X%" + strings.Repeat("[!/]*", n/5) + "}/" + }}, + {"nested trims", func(n int) string { + return "rm -rf " + strings.Repeat("${X%", n/4) + "x" + strings.Repeat("}", n/4) + }}, + {"many trims", func(n int) string { + return "rm -rf " + strings.Repeat("${X%${Y#?}}", n/11) + }}, + {"replacement patterns", func(n int) string { + return "rm -rf " + strings.Repeat(`${X/\/*/$HOME}`, n/14) + }}, + } + for _, s := range shapes { + t.Run(s.name, func(t *testing.T) { + assertWorkGrowth(t, s.build, 1<<11, "a pattern is read once") + }) + } +} diff --git a/internal/core/guard/unknown.go b/internal/core/guard/unknown.go index df0c99c39..455ecefff 100644 --- a/internal/core/guard/unknown.go +++ b/internal/core/guard/unknown.go @@ -18,7 +18,8 @@ import ( // reader of a token asks this file what the word can be. An arithmetic // expansion is not unknown in that sense: its output is a number, which no // flag, subcommand or path the registry names can be, and neither are `$$`, -// `$!`, `$?` and `$#`. +// `$!`, `$?` and `$#`. `$!` is also nothing before any job runs in the +// background, which only the written spelling reads (emptyable). // // The rule is that an unknown word fails closed in every role it could play, // and a reader that can read a word more than one way reads it every way — the @@ -103,63 +104,218 @@ const varMark = '\x01' const varText = "\x01" // varSite is one variable's mark in a word being built: its offset in the -// word, and the expansion's text as the line wrote it (`$HOME`, `${PWD}`), "" +// word, and the texts the expansion can print as the line wrote them (`$HOME`, +// `${PWD}`; `${DIR}` and `$HOME` for `${DIR:-$HOME}`, spellParameter), nil // for a varMark read from a payload's text, whose name the string no longer // holds. bare records a name written unquoted and without braces, which the // unquoted text a brace group places after it runs on from (spellWritten). +// split records an expansion written unquoted, whose text bash splits into +// fields on IFS (ifsSplits). +// +// width is the number of bytes of the word the site stands on where the +// word keeps the text as written rather than a mark (`$!`, which every +// other reading takes as the job's number it is, addBang), and 0 for a +// mark, which is one byte. type varSite struct { - at int - text string - bare bool + at int + texts []string + bare bool + split bool + width int +} + +// emptyable reports whether the parameter named name — the text after its +// `$`, or between its braces — can print nothing at the top of a fresh +// shell, where it leaves only the text beside it (iss-2609300057467536): `!` +// before any job runs in the background, `@`, `*` and a positional +// parameter's digits with no argument, `_` after `x=` or `true ""`, and `-`, +// which dash starts with no option letter in. bash 3.2, /bin/sh, dash and +// bash 5.3 print `/` for `$!/`, `$@/` and `$1/`. `$0` is the shell's name +// and `$$`, `$?` and `$#` are numbers, never empty. +func emptyable(name string) bool { + switch name { + case "!", "@", "*", "-", "_": + return true + } + if name == "" || strings.TrimLeft(name, "0123456789") != "" { + return false + } + return strings.TrimLeft(name, "0") != "" +} + +// paramTexts is the written spelling of a parameter written without braces +// (`$HOME`, `$1`, `$@`): the parameter itself, and the empty text where it +// can print nothing (emptyable). +func paramTexts(text string) []string { + if len(text) > 1 && emptyable(text[1:]) { + return []string{text, ""} + } + return []string{text} +} + +// ifsSplits reports whether a word's sites include one whose fields rest on +// the default IFS (review-guardSet MAJOR-2): an unquoted expansion whose +// text the guard reads — a default's or an alternative's word, a trim's or +// a replacement's texts, or the home or the working directory it names. +// Under an IFS a line assigns, bash splits that text on other bytes, and +// the fields can be the root (`IFS=x; rm -rf ${U:-x/x}` hands rm `""` and +// `/`; `IFS=Uv; rm -rf $HOME/x` hands it `/`). An unquoted variable of +// unknown value (`$d`, `${d%/}`) splits into fields no more known than its +// value, and is not counted. The empty text an expansion can print splits +// into no field under any IFS, and is not read: `${1}`, `${X:-}` and +// `${A[0]}` count as the variable alone. +func ifsSplits(sites []varSite) bool { + for _, s := range sites { + var texts []string + for _, t := range s.texts { + if t != "" { + texts = append(texts, t) + } + } + if !s.split || len(texts) == 0 { + continue + } + if len(texts) > 1 { + return true + } + name := strings.TrimPrefix(texts[0], "$") + if strings.HasPrefix(name, "{") && strings.HasSuffix(name, "}") { + name = name[1 : len(name)-1] + } + if name == "" || name == "HOME" || name == "PWD" || name == texts[0] { + return true + } + for i := 0; i < len(name); i++ { + if !isNameByte(name[i]) { + return true + } + } + } + return false } +// A written spelling (segment.spelled) is a SET of texts, one for each thing +// the word can print as the line wrote it: `${DIR:-$HOME}` prints DIR's value +// or the home, and is `${DIR}` and `$HOME` (iss-2609290426544292). A word's +// set is every combination of its sites' sets, bounded by maxSpellings, and a +// site's word is followed into its own expansions at most spellWordDepth +// deep. Past either bound the site is spellCapped: a spelling the guard +// stopped reading, which the arg_values compare reads as every value an entry +// names (writtenMatches), so a bound refuses rather than lets the word pass. +const ( + // maxSpellings bounds how many texts one word's written spelling holds, + // and so how many times a string handed to a shell is re-read to pair + // them (namedPayloads). + maxSpellings = 16 + // spellWordDepth bounds how deep a spelling follows a default's or an + // alternative's word into another expansion. + spellWordDepth = 8 + // spellCapped is the one text of a spelling past either bound. + spellCapped = "\x04" +) + // spellWritten is a word as the line wrote its variables (segment.spelled): -// each variable's mark replaced by its expansion's text, each substitution's -// mark dropped as knownText drops it, and a variable whose text is not known -// kept as unknownMark. A simple name the next byte kept would extend is -// braced (`"$A"B` is `${A}B`, not `$AB`), so the spelling reads as the same -// expansions when it is read again (spelledView). sites is in word order. +// each variable's mark replaced by each text its expansion can print, each +// substitution's mark dropped as knownText drops it, and a variable whose +// text is not known kept as unknownMark. The result is every combination of +// the sites' texts, in word order; a site already past a bound, or one that +// would make more than maxSpellings of them, is spellCapped in each. A simple name the next byte +// kept would extend is braced (`"$A"B` is `${A}B`, not `$AB`), so the +// spelling reads as the same expansions when it is read again (spelledViews). +// sites is in word order. // // mask is nil for a word as the line wrote it. For a word a brace group made // it is the word's bword.m, and a bare name directly followed by unquoted // name bytes is not braced: bash expands the group first and reads the name // after, so `$HO{ME,}` makes `$HOME` (iss-2609290419119456). A quote or // escape between them leaves the byte quoted, and the name ends there. -func spellWritten(word []byte, sites []varSite, mask []byte) string { - var b strings.Builder +func spellWritten(word []byte, sites []varSite, mask []byte) []string { + outs := [][]byte{nil} + add := func(c byte) { + for i := range outs { + outs[i] = append(outs[i], c) + } + } k := 0 isVar := func(p int) bool { return k < len(sites) && sites[k].at == p } for p := 0; p < len(word); p++ { if !isVar(p) { if word[p] != unknownMark { - b.WriteByte(word[p]) + add(word[p]) } continue } site := sites[k] - text := site.text k++ - if text == "" { - b.WriteByte(unknownMark) + if site.width > 1 { + // The site keeps its text in the word (`$!`): each text it can + // print stands in place of all of it. + p += site.width - 1 + } + if len(site.texts) == 0 { + add(unknownMark) continue } - // Only a simple name is braced: an alternative's word is spelled as - // written (`$HOME/`, `~`), and bracing that would change it. - if len(text) > 1 && text[0] == '$' && simpleParamEnd(text, 1) == len(text) { - next := p + 1 - for next < len(word) && word[next] == unknownMark && !isVar(next) { - next++ - } - if next < len(word) && word[next] != unknownMark && isNameByte(word[next]) { - runsOn := mask != nil && site.bare && next == p+1 && mask[next]&wordStruct != 0 - if !runsOn { + if capped(site.texts) || len(outs)*len(site.texts) > maxSpellings { + // The site is past a bound: it is spellCapped in every text, and + // the text around it is kept, so a string handed to a shell still + // pairs its words (spellPayload). + add(spellCapped[0]) + continue + } + // Only a simple name is braced: a default's or an alternative's word + // is spelled as written (`$HOME/`, `~`), and bracing that would + // change it. + braced := false + next := p + 1 + for next < len(word) && word[next] == unknownMark && !isVar(next) { + next++ + } + if next < len(word) && word[next] != unknownMark && isNameByte(word[next]) { + runsOn := mask != nil && site.bare && next == p+1 && mask[next]&wordStruct != 0 + braced = !runsOn + } + grown := make([][]byte, 0, len(outs)*len(site.texts)) + for _, o := range outs { + for n, text := range site.texts { + if braced && len(text) > 1 && text[0] == '$' && simpleParamEnd(text, 1) == len(text) { text = "${" + text[1:] + "}" } + b := o + if n < len(site.texts)-1 { + b = append([]byte(nil), o...) + } + grown = append(grown, append(b, text...)) } } - b.WriteString(text) + outs = grown } - return b.String() + texts := make([]string, 0, len(outs)) + for _, o := range outs { + tally(len(o)) + texts = appendText(texts, string(o)) + } + return texts +} + +// appendText adds text to a spelling's texts unless it is already there. +func appendText(texts []string, text string) []string { + for _, t := range texts { + if t == text { + return texts + } + } + return append(texts, text) +} + +// capped reports whether a spelling is past a bound (spellCapped). +func capped(texts []string) bool { + for _, t := range texts { + if strings.Contains(t, spellCapped) { + return true + } + } + return false } // paramText is a parameter expansion's text as bash reads it: without the @@ -168,44 +324,69 @@ func spellWritten(word []byte, sites []varSite, mask []byte) string { func paramText(text string) string { return strings.ReplaceAll(text, "\\\n", "") } // spellParameter is the written spelling (segment.spelled) of a `${…}` -// expansion whose text between the braces is body. Where the expansion can -// print its variable's value unchanged, it is spelled as that variable, so -// arg_values reads `${HOME%/}` as the `${HOME}` it can be -// (iss-2609290419119456): +// expansion whose text between the braces is body: every text it can print +// as the line wrote it. Where the expansion can print its variable's value +// unchanged, the set holds that variable, so arg_values reads `${HOME%/}` as +// the `${HOME}` it can be (iss-2609290419119456): // -// - a default, an assignment or an error message, with or without the colon -// (`${HOME:-x}`, `${HOME=x}`, `${HOME:?x}`): the value when the variable -// is set, and the home always is; +// - a default or an assignment, with or without the colon (`${DIR:-w}`, +// `${DIR=w}`), prints the value when the variable is set and its word w +// when it is not, so the set holds the variable and every text w can +// print, through its own expansions (spellWord): `${DIR:-$HOME}` is +// `${DIR}` and `$HOME` (iss-2609290426544292), and `${DIR:-}` is also +// the empty text; with the colon an empty value counts as unset, so a +// parameter that can print nothing (emptyable) does not print it there +// (`${1:-dist}` is `${1}` and `dist`); +// - an error message (`${HOME:?x}`) prints the value or nothing: the +// message goes to the standard error, never into the word, and with the +// colon an empty value is an error, as above; // - a trimmed prefix or suffix and a pattern replacement (`${HOME%/}`, // `${HOME#x}`, `${HOME/x/y}`): the value when the pattern does not match, -// and what a suffix trim leaves otherwise is the path above it; -// - a substring (`${HOME:0}`), whose offset is arithmetic and can be 0; +// and what a suffix trim leaves otherwise is the path above it. What +// else it can print is read from its pattern's shape (trimTexts, +// replacementTexts): a pattern that can take any remainder of the value +// can leave only the `/` an absolute path begins with (`${X%${X#?}}` is +// `${X}`, `/` and nothing), or the whole value, which a replacement's +// string takes the place of (`${X/*/$HOME}` and `${X/?*/$HOME}` are +// `${X}` and `$HOME`; iss-2609292320015665, iss-2609300009581165); +// - a substring (`${HOME:0}`), whose offset is arithmetic and can be 0 or +// past the end, and which can print the `/` an absolute path begins with +// (`${PWD:0:1}` is `${PWD}`, `/` and nothing); a positional or special +// parameter's slice or part (`${@:2}`, `${1:2}`) is what the parameters +// hold, as `$2` is; // - a case change (`${HOME^^}`, `${HOME@U}`), which names the same directory -// on a case-insensitive disk, and `@E` and `@P`, which change no path; +// on a case-insensitive disk, and `@E` and `@P`, which change no path, +// each also nothing, for a value it maps to nothing (`${X^}/` is `/`); // - a subscript (`${HOME[0]}`, `${HOME[x[0]]}`), which can be 0, read to -// its matching `]`, with anything after it but an alternative at the -// first operator byte: bash 3.2 prints the value past any other text -// (`${HOME[0]]}`, `${HOME[0]@Q}`), and a subscript with no `]` cannot be -// read further. +// its matching `]`. bash 3.2, the /bin/sh and /bin/bash of macOS, steps +// over any text after it to the first operator byte +// (subscriptOperators): an alternative there prints its word, a default +// or an assignment the value or its word (`${X[0]]-$HOME}` is the home +// with X unset), a trim, a replacement or a substring what it prints +// after a name and also nothing, which is what bash 3.2 prints for one +// after a scalar's subscript (`${X[0]%x}` with X=/a/b; +// iss-2609300009506126), and anything else the value (`${HOME[0]]}`, +// `${HOME[0]@Q}`), and alone the value or nothing, since the element +// it names need not be set (`${A[0]}/` is `/`). A subscript with no `]` +// cannot be read further. // -// An alternative (`${X:+w}`, `${X+w}`) prints w or nothing, and is -// spelled as w is written, through its own expansions (spellAlternative). -// Every other expansion keeps its text as written and names no variable an -// entry names: a length (`${#HOME}`), an indirection (`${!X}`), `@Q` and the -// other transforms, and a default word that is not the variable's own value -// (`${DIR:-$HOME}`), which is a recorded residual (17-guard.md). +// An alternative (`${X:+w}`, `${X+w}`) prints w or nothing, and is the texts +// w can print and the empty text. Every other expansion keeps its text as written and names no +// variable an entry names: a length (`${#HOME}`), an indirection (`${!X}`), +// and `@Q` and the other transforms. // -// split reports that the expansion stands unquoted, where bash splits an -// alternative's word on whitespace: each unquoted whitespace run in it is -// spelled fieldMark, which the compare splits on (argValueMatches). -func spellParameter(body string, split bool) string { +// split reports that the expansion stands unquoted, where bash splits a +// default's or an alternative's word on whitespace: each unquoted whitespace +// run in it is spelled fieldMark, which the compare splits on +// (argValueMatches). +func spellParameter(body string, split bool) []string { return spellParameterAt(paramText(body), 0, split) } // fieldMark stands in a spelling where bash splits a word into fields: at an // unquoted whitespace run in an unquoted alternative's word (`${X:+$HOME }` // hands rm the home). Only the arg_values compare splits on it; a payload -// re-read reads it as the space it was (spelledView). +// re-read reads it as the space it was (spelledViews). const fieldMark = '\x02' // fieldText is fieldMark as a string. @@ -215,83 +396,570 @@ const fieldText = "\x02" // unquoted whitespace (`sh -c "rm -rf ${X:+$HOME x}"`): the word is one // field here, which the compare reads as a space, but a shell re-reading the // string splits it there, so a payload re-read takes it for fieldMark -// (spelledView), and the string's words pair with its marked reading's. +// (spelledViews), and the string's words pair with its marked reading's. const quotedFieldMark = '\x03' // quotedFieldText is quotedFieldMark as a string. const quotedFieldText = "\x03" -// spellAlternativeDepth bounds how deep spellParameter follows an -// alternative's word into another expansion. -const spellAlternativeDepth = 3 - -func spellParameterAt(body string, depth int, split bool) string { - raw := "${" + body + "}" - n := 0 - for n < len(body) && isNameByte(body[n]) { - n++ - } - if n == 0 || body[0] >= '0' && body[0] <= '9' { +// spellParameterAt is spellParameter at depth. +func spellParameterAt(body string, depth int, split bool) []string { + raw := []string{"${" + body + "}"} + indirect, n := paramNameEnd(body) + if n < 0 { return raw } name, rest := body[:n], body[n:] same := "${" + name + "}" + value := []string{same} + // set is the value as the colon forms read it (`${1:-w}`, `${1:=w}`, + // `${1:?}`): a single empty parameter counts as unset there, so the + // expansion never prints the empty value (reverify-guardSet finding 2). + set := value + if !indirect && emptyable(name) { + // `${!}`, `${@}`, `${1}` can print nothing (emptyable), and every + // other operator reads that nothing as it reads a value. + value = append([]string{same}, "") + if name == "@" || name == "*" { + // `@` and `*` take the colon test on the parameter count, not + // on a joined value: after `set -- "" ""`, `${@:-x}` prints + // the two empty parameters and `${*:?}` does not stop, so the + // colon forms read as the value too (reverify3-guardSet + // finding 1). + set = value + } + } + // orNothing is the value, or nothing: a subscript naming an element + // that is not set (`${A[1]}` of a scalar), and a case change or a + // transform that maps the value to nothing (`${X^}`, `${X@P}` with X + // empty), leave the text beside them (reverify-guardSet finding 5). + orNothing := func() []string { + return appendText(append([]string(nil), value...), "") + } + if indirect { + // An indirection's value is the value of the variable its name + // holds, which the line does not spell: past an operator that can + // print it, it reads as every value (review-guardSet MAJOR-4). Alone, + // and as a name list (`${!X*}`, `${!A[@]}`), it keeps its text, as a + // variable of unknown value does. + if rest == "" || rest == "*" || rest == "@" { + return raw + } + value = []string{spellCapped} + set = value + } + // orWord is the value from, or the texts the word w can print. + orWord := func(from []string, w string) []string { + texts := append([]string(nil), from...) + for _, t := range spellWord(w, depth, split) { + texts = appendText(texts, t) + } + if capped(texts) || len(texts) > maxSpellings { + return []string{spellCapped} + } + return texts + } + // alternative is the texts the word w can print, or nothing: with the + // variable unset or empty the expansion prints no text, so + // `${X:+x}$HOME` is `x$HOME` and `$HOME`. A word the guard cannot read + // is the expansion as written, which names nothing. + alternative := func(w string) []string { + texts := spellWord(w, depth, split) + if len(texts) == 0 { + texts = raw + } + return appendText(texts, "") + } + subscript := false if strings.HasPrefix(rest, "[") { - // The subscript runs to its matching `]`, and what follows it is read - // only for an alternative: bash 3.2, the /bin/sh and /bin/bash of - // macOS, steps over any other text to the first operator byte - // (subscriptOperators) and reads an alternative there - // (`${X[0]]:+$HOME}`, `${X[0]x:+$HOME}`, `${X[0]]^+$HOME}` print the - // home), and prints the value past text that holds none - // (`${HOME[0]]}`, `${HOME[0]@Q}`). A subscript that does not close - // can be read no further. Every other case is spelled as the variable. k := subscriptEnd(rest) if k < 0 { - return same + return value } rest = rest[k+1:] - if op := strings.IndexAny(rest, subscriptOperators); op >= 0 { - switch { - case rest[op] == '+': - return spellAlternative(rest[op+1:], raw, depth, split) - case strings.HasPrefix(rest[op:], ":+"): - return spellAlternative(rest[op+2:], raw, depth, split) + op := strings.IndexAny(rest, subscriptOperators) + if op < 0 { + if indirect && rest == "" { + // `${!A[@]}` lists the array's keys. + return raw } + return orNothing() } - return same + rest, subscript = rest[op:], true } if rest == "" { - return same + return value } - // valueKeeping is every operator that can print the value unchanged. - const valueKeeping = "-=?#%/^,~" - if strings.IndexByte(valueKeeping, rest[0]) >= 0 { - return same + switch { + case rest[0] == '+': + return alternative(rest[1:]) + case strings.HasPrefix(rest, ":+"): + return alternative(rest[2:]) + case rest[0] == '-' || rest[0] == '=': + return orWord(value, rest[1:]) + case strings.HasPrefix(rest, ":-") || strings.HasPrefix(rest, ":="): + return orWord(set, rest[2:]) + case strings.HasPrefix(rest, ":?"): + return set } - switch rest[0] { - case '@': + var texts []string + switch { + case rest[0] == ':' && !indirect && !isNameStart(name[0]): + // A positional or special parameter's slice (`${@:2}`, `${*:2}`) + // or part (`${1:2}`) prints what the parameters hold, or nothing, + // as `"$2"` does (reverify-guardSet finding 3). + texts = append([]string(nil), value...) + case rest[0] == ':': + // A substring: a part of the value, the whole of it at offset 0, the + // `/` an absolute path begins with, and nothing at an offset past + // its end (`${X:9}`) or a length of 0. + texts = []string{same, "/", ""} + case rest[0] == '/': + texts = replacementTexts(value, rest[1:], depth, split) + case rest[0] == '%' || rest[0] == '#': + texts = trimTexts(value, rest) + case subscript: + return orNothing() + case rest[0] == '?': + // An error message without the colon prints the value. + return value + case strings.IndexByte("^,~", rest[0]) >= 0: + // A case change prints the value, changed, or nothing. + return orNothing() + case rest[0] == '@': if len(rest) == 2 && strings.IndexByte("EPULu", rest[1]) >= 0 { - return same + return orNothing() } - case '+': - return spellAlternative(rest[1:], raw, depth, split) - case ':': - if len(rest) > 1 && rest[1] == '+' { - return spellAlternative(rest[2:], raw, depth, split) + return raw + default: + return raw + } + if subscript { + // bash 3.2 prints nothing for a trim, a replacement or a substring + // after a scalar's subscript: `${X[0]%x}` with X=/a/b. + texts = appendText(texts, "") + } + return texts +} + +// paramNameEnd reads the parameter a `${…}` body begins with, as bash reads +// it, and returns where its name ends, or -1 where no parameter begins +// there. The name is a variable's (`HOME`), a positional parameter's digits +// (`1`, `10`), or one special parameter's byte (`@`, `*`, `#`, `?`, `-`, +// `$`, `!`), each of which takes the operators a variable does: with no +// arguments and no background job, `${1:-/}`, `${@:-/}` and `${!:-/}` print +// `/`, and `${#:+/}` prints it whatever the count (review-guardSet MAJOR-4). +// A `!` before a name, a digit, `@`, `*` or `#` is an indirection +// (`${!X:-/}`), reported by indirect, and the name follows it. A `#` before +// a name is a length (`${#X}`), whose text after the `#` is read as no +// operator, so it keeps its text as written. +func paramNameEnd(body string) (indirect bool, end int) { + start := 0 + if len(body) > 1 && body[0] == '!' && (isNameByte(body[1]) || strings.IndexByte("@*#", body[1]) >= 0) { + indirect, start = true, 1 + } + if start >= len(body) { + return false, -1 + } + switch c := body[start]; { + case c >= '0' && c <= '9': + n := start + 1 + for n < len(body) && body[n] >= '0' && body[n] <= '9' { + n++ } - return same + return indirect, n + case isNameByte(c): + n := start + 1 + for n < len(body) && isNameByte(body[n]) { + n++ + } + return indirect, n + case strings.IndexByte("@*#?-$!", c) >= 0: + return indirect, start + 1 } - return raw + return false, -1 +} + +// trimTexts is the written spelling of a trim, whose operator and pattern +// are rest (`%p`, `%%p`, `#p`, `##p`), where value is the variable's own: +// the value, which the trim leaves where its pattern does not match, and +// what the pattern's shape (readPattern) lets it leave whatever the value +// holds (iss-2609292320015665, iss-2609300009506126). The rule, for a value +// that is an absolute path: +// +// - a suffix trim (`%`, `%%`) can leave only the leading `/` when its +// pattern can take a remainder of any length (it holds a `*`, unknown +// text or an extglob group) and its first element past any run of `*` +// is a glob or unknown text: that element can match the text after the +// `/`, and the rest of the pattern the remainder (`${X%${X#?}}`, +// `${X%%[!/]*}`, `${X%$Y}`). A prefix trim (`#`, `##`) can leave only a +// trailing `/` when the same holds of its last element (`${T#${T%?}}`, +// `${T##*[!/]}` with T=/tmp/x/). Either can then also leave nothing. +// - a longest trim (`%%`, `##`) can leave nothing when its pattern can +// match the whole path: it can take any length, its first element is a +// `*`, a glob, unknown text or a literal `/`, and its last a `*`, a glob +// or unknown text (`${X%%*}`, `${X%%/*}`, `${X##/*}`). +// +// Every other trim is the value alone. A pattern whose element at the +// anchored end is literal text, or which matches a fixed width, leaves the +// root or nothing only for a value of one particular content or length, as +// `rm -rf $X` deletes the root only for X=/: `${DIR%/}`, `${f%.txt}`, +// `${f%.*}`, `${p##*/}`, `${p%/*}`, `${p#$HOME/}`, `${X%?}` and `${X#?}`. +// A `*` at that end is stepped over, since it can match nothing, so a +// shortest trim reads as the element behind it; for a longest trim that +// over-reads (`${X%%*[!/]*}` leaves nothing, never `/`), which only adds a +// text. Unknown text is any expansion (`$Y`, `${…}`, `$(…)`, a backtick), +// quoted or not, `$HOME` included: its text is not in the line, and +// `${X%${X#?}}` builds it from the value itself. +func trimTexts(value []string, rest string) []string { + suffix := rest[0] == '%' + longest := len(rest) > 1 && rest[1] == rest[0] + p := rest[1:] + if longest { + p = rest[2:] + } + sh := readPattern(p, false, false) + anchored := sh.lastPast + if suffix { + anchored = sh.firstPast + } + texts := value + if sh.wide && roving(anchored) { + texts = appendText(appendText(texts, "/"), "") + } + if longest && sh.whole() { + texts = appendText(texts, "") + } + return texts +} + +// replacementTexts is the written spelling of a pattern replacement, whose +// text after the first `/` is rest (`p/s`, `/p/s`, `#p/s`, `%p/s`), where +// value is the variable's own: the value, and what the pattern's shape +// (readPattern) lets it print whatever the value holds +// (iss-2609300009581165). A pattern that can match the whole of an absolute +// path, by the longest-trim rule of trimTexts, prints the string s in its +// place (`${X/*/$HOME}`, `${X/\/*/$HOME}`, `${X/?*/$HOME}`, `${X/$Y/~}`), +// and one that can match all of it after the leading `/`, by the suffix-trim +// rule and ending in a `*`, a glob or unknown text, prints `/` and s, +// unless `/#` anchors it at the start (`${X/${X#?}}` and `${X//[!\/]*/}` +// are `/`). s is read as a default's word is (spellWord), and one that +// prints nothing the guard reads is the empty text. Every other replacement +// is the value alone: `${X/foo/$HOME}`, `${DIR/#\~/$HOME}`, and +// `${name//[^a-z]/}`, whose pattern matches one byte. +func replacementTexts(value []string, rest string, depth int, split bool) []string { + p := rest + anchor := byte(0) + if p != "" && strings.IndexByte("/#%", p[0]) >= 0 { + anchor, p = p[0], p[1:] + } + // bash 3.2 ends the pattern at a quoted `/` and bash 5 does not, so a + // pattern that holds one is read both ways and prints what either + // reading prints (review-guardSet MAJOR-3). + texts := value + var last patternShape + for n, quoted := range []bool{false, true} { + sh := readPattern(p, true, quoted) + if n > 0 && sh == last { + break + } + last = sh + texts = replacementShapeTexts(texts, p, sh, anchor, depth, split) + if capped(texts) || len(texts) > maxSpellings { + return []string{spellCapped} + } + } + return texts +} + +// replacementShapeTexts adds to texts what a replacement whose pattern p +// reads as sh prints (replacementTexts). +func replacementShapeTexts(texts []string, p string, sh patternShape, anchor byte, depth int, split bool) []string { + whole := sh.whole() + tail := anchor != '#' && sh.wide && roving(sh.firstPast) && anyWidth(sh.last) + if !whole && !tail { + return texts + } + s := "" + if sh.end < len(p) { + s = p[sh.end+1:] + } + words := spellWord(s, depth, split) + if len(words) == 0 { + words = []string{""} + } + for _, w := range words { + if whole { + texts = appendText(texts, w) + } + if tail { + texts = appendText(texts, "/"+w) + } + } + return texts +} + +// patElem is one element of a trim's or a replacement's pattern, as far as +// what it can match decides what the expansion can print (readPattern). +type patElem uint8 + +const ( + // elemNone stands where a pattern has no element: an empty one. + elemNone patElem = iota + // elemLiteral is a byte the pattern matches as itself: plain, escaped + // or quoted, other than `/`. + elemLiteral + // elemSlash is a literal `/`. + elemSlash + // elemStar is an unquoted `*`, which matches any text, none included. + elemStar + // elemOne is a glob of one byte: a `?` or a bracket expression. + elemOne + // elemAny is text the line does not spell, of any length: an expansion + // (`$Y`, `${…}`, `$(…)`, a backtick, an ANSI-C string) or an extglob + // group (`@(…)`, `*(…)`), or a quote or an expansion that does not close. + elemAny +) + +// roving reports whether an element can match text the line does not spell: +// a one-byte glob or text of any length. +func roving(e patElem) bool { return e == elemOne || e == elemAny } + +// anyWidth reports whether an element can match whatever byte ends a value: +// a `*` or a roving element. +func anyWidth(e patElem) bool { return e == elemStar || roving(e) } + +// patternShape is what readPattern records of a pattern: its first and last +// element, the same past any run of `*` at that end, whether it can take a +// remainder of any length, and, for a replacement, where the `/` that ends +// it stands (len of the text where none does). +type patternShape struct { + first, last patElem + firstPast, lastPast patElem + wide bool + end int +} + +// whole reports whether the pattern can match the whole of an absolute +// path, whatever it holds: it can take any length, its first element can +// match the leading `/`, and its last can match whatever byte the path ends +// with. +func (sh patternShape) whole() bool { + return sh.wide && (sh.first == elemSlash || anyWidth(sh.first)) && anyWidth(sh.last) +} + +// readPattern reads the pattern p of a trim or, with replacement, of a +// replacement, once and left to right, recording its shape (patternShape). +// Its quotes and escapes make literal text; an expansion in it is stepped +// over to its close without being read again, so the cost is p's length. +// A replacement's pattern ends at its first unescaped `/`, which bash 3.2 +// reads as the end even inside quotes and brackets (`${X/[/]/c}` replaces +// `[`); with quoted set, a `/` inside quotes is the pattern's own, as bash 5 +// reads it (`${X/"/"*/$HOME}` prints the home on bash 5.3 and the value on +// bash 3.2), and only one in a bracket or unquoted ends it. A `$"` outside +// double quotes is the double-quoted string it opens (`${X%%$""*}` is +// `${X%%*}`). +func readPattern(p string, replacement, quoted bool) patternShape { + tally(len(p)) + sh := patternShape{end: len(p)} + add := func(e patElem) { + if sh.first == elemNone { + sh.first = e + } + if e != elemStar && sh.firstPast == elemNone { + sh.firstPast = e + } + sh.last = e + if e != elemStar { + sh.lastPast = e + } + if e == elemStar || e == elemAny { + sh.wide = true + } + } + literal := func(c byte) { + if c == '/' { + add(elemSlash) + } else { + add(elemLiteral) + } + } + // unread marks the rest of the pattern as text the guard does not read. + unread := func() patternShape { + add(elemAny) + return sh + } + budget := 4*len(p) + 16 + dq := false + for i := 0; i < len(p); { + c := p[i] + switch { + case replacement && c == '/' && !(quoted && dq): + sh.end = i + return sh + case c == '\\': + if i+1 < len(p) { + literal(p[i+1]) + } else { + literal(c) + } + i += 2 + case c == '"': + dq = !dq + i++ + case c == '\'' && !dq: + k := strings.IndexByte(p[i+1:], '\'') + if k < 0 { + return unread() + } + for j := i + 1; j < i+1+k; j++ { + if replacement && !quoted && p[j] == '/' { + sh.end = j + return sh + } + literal(p[j]) + } + i += k + 2 + case c == '$' && i+1 < len(p) && p[i+1] == '{': + end := closingDolBrace(p, i+2, &budget) + if end < 0 { + return unread() + } + add(elemAny) + i = end + 1 + case c == '$' && i+1 < len(p) && p[i+1] == '(': + end := closingParen(p, i+2, &budget) + if end < 0 { + return unread() + } + add(elemAny) + i = end + 1 + case c == '`': + end := closingBacktick(p, i+1, &budget) + if end < 0 { + return unread() + } + add(elemAny) + i = end + 1 + case c == '$' && !dq && i+1 < len(p) && p[i+1] == '"': + i++ + case c == '$' && !dq && i+1 < len(p) && p[i+1] == '\'': + k := i + 2 + for k < len(p) && p[k] != '\'' { + if p[k] == '\\' { + k++ + } + k++ + } + if k >= len(p) { + return unread() + } + add(elemAny) + i = k + 1 + case c == '$': + if end := simpleParamEnd(p, i+1); end > 0 { + add(elemAny) + i = end + continue + } + if i+1 < len(p) && p[i+1] == '!' { + // The job's number, or nothing where none ran in the + // background (emptyable): `${X%%$!*}` is `${X%%*}`. + add(elemAny) + i += 2 + continue + } + literal(c) + i++ + case dq: + literal(c) + i++ + case strings.IndexByte("*?+@!", c) >= 0 && i+1 < len(p) && p[i+1] == '(': + end := extglobEnd(p, i+1) + if end < 0 { + return unread() + } + add(elemAny) + i = end + 1 + case c == '*': + add(elemStar) + i++ + case c == '?': + add(elemOne) + i++ + case c == '[': + if end := bracketEnd(p, i, replacement); end > 0 { + add(elemOne) + i = end + 1 + continue + } + literal(c) + i++ + default: + literal(c) + i++ + } + } + return sh +} + +// bracketEnd returns the index of the `]` that closes the bracket +// expression opening at p[i], or -1 where none does: a `]` directly after +// the `[` or its `!` or `^` is a member, and a backslash quotes the next +// byte. In a replacement's pattern a `/` ends the pattern first +// (readPattern), and the `[` is then literal. +func bracketEnd(p string, i int, replacement bool) int { + j := i + 1 + if j < len(p) && (p[j] == '!' || p[j] == '^') { + j++ + } + if j < len(p) && p[j] == ']' { + j++ + } + for j < len(p) { + switch p[j] { + case '\\': + j += 2 + continue + case ']': + return j + case '/': + if replacement { + return -1 + } + } + j++ + } + return -1 +} + +// extglobEnd returns the index of the `)` that closes the extglob group +// whose `(` is at p[i], counting the groups nested in it, or -1 where none +// does. +func extglobEnd(p string, i int) int { + depth := 0 + for j := i; j < len(p); j++ { + switch p[j] { + case '\\': + j++ + case '(': + depth++ + case ')': + if depth--; depth == 0 { + return j + } + } + } + return -1 } // subscriptOperators are the bytes bash 3.2 stops at in the text after a // subscript's `]`: an operator, or a backslash, which quotes the next byte. -// Only a `+` or `:+` there reads an alternative; with X set, -// `${X[0]]-$HOME}` and `${X[0]a-b+$HOME}` print X's value, and -// `${X[0]]\+$HOME}` does too. With X unset, a `-`, `:-`, `=` or `:=` there -// prints the word: `${X[0]]-$HOME}`, `${X[0]]:-$HOME}` and `${X[0]]=$HOME}` -// print the home on bash 3.2 and /bin/sh. That is the default's word, which -// this spelling does not read (iss-2609290426544292, deferred). +// A `+` or `:+` there reads an alternative, and a `-`, `:-`, `=` or `:=` a +// default or an assignment: with X unset, `${X[0]]-$HOME}`, +// `${X[0]]:-$HOME}` and `${X[0]]=$HOME}` print the home on bash 3.2 and +// /bin/sh, and with X set they print X's value. Any other byte there prints +// the value: `${X[0]]?$HOME}`, and `${X[0]]\+$HOME}`. const subscriptOperators = "-=?+%#/:\\" // subscriptEnd returns the index of the `]` that closes the subscript opening @@ -312,21 +980,26 @@ func subscriptEnd(s string) int { return -1 } -// spellAlternative is the spelling of an alternative whose word is w. An -// alternative prints w or nothing, so w is spelled as it is written: its -// quotes and escapes removed, and each expansion in it a site spelled as a -// word's own are (spellWritten), `${…}` through spellParameterAt. +// spellWord is the texts a default's or an alternative's word w can print, +// at depth expansions deep. w is spelled as it is written: its quotes and +// escapes removed, and each expansion in it a site spelled as a word's own +// are (spellWritten), `${…}` through spellParameterAt one level deeper. // `${X:+$HOME/}` is `$HOME/`, `${X:+/}` is `/` and `${X:+"${HOME%/}"}` is -// `${HOME}`. A command substitution in it is its unknown output, which +// `${HOME}`. An ANSI-C string in it is the bytes it decodes to and a locale +// string the double-quoted string it holds (`${X:-$'\x2f'}` and +// `${X:-$"/"}` are `/`), and a `$` that opens no expansion is text. A +// command substitution in it is its unknown output, which // spellWritten drops as knownText does (`${X:+$(true)$HOME}` is `$HOME`). // Where split is set, each unquoted whitespace run is fieldMark, where bash -// splits the word (`${X:+$HOME }` is `$HOME`). A word holding a quote that -// does not close or an expansion past spellAlternativeDepth, and a word that -// spells to nothing, keep raw. -func spellAlternative(w, raw string, depth int, split bool) string { - if depth >= spellAlternativeDepth { - return raw +// splits the word (`${X:+$HOME }` is `$HOME`). A word holding a quote or an +// expansion that does not close prints no text the guard reads, and is nil; +// an empty word (`${X:-}`, `${X:-""}`) prints the empty text. A word at spellWordDepth is not read, +// and is spellCapped: the bound refuses, never passes (writtenMatches). +func spellWord(w string, depth int, split bool) []string { + if depth >= spellWordDepth { + return []string{spellCapped} } + tally(len(w)) var word []byte var sites []varSite budget := 4*len(w) + 16 @@ -347,29 +1020,29 @@ func spellAlternative(w, raw string, depth int, split bool) string { case c == '\'' && !dq: k := strings.IndexByte(w[i+1:], '\'') if k < 0 { - return raw + return nil } word = append(word, w[i+1:i+1+k]...) i += k + 2 case c == '$' && i+1 < len(w) && w[i+1] == '{': end := closingDolBrace(w, i+2, &budget) if end < 0 { - return raw + return nil } - sites = append(sites, varSite{at: len(word), text: spellParameterAt(w[i+2:end], depth+1, split && !dq)}) + sites = append(sites, varSite{at: len(word), texts: spellParameterAt(w[i+2:end], depth+1, split && !dq)}) word = append(word, varMark) i = end + 1 case c == '$' && i+1 < len(w) && w[i+1] == '(': end := closingParen(w, i+2, &budget) if end < 0 { - return raw + return nil } word = append(word, unknownMark) i = end + 1 case c == '`': end := closingBacktick(w, i+1, &budget) if end < 0 { - return raw + return nil } word = append(word, unknownMark) i = end + 1 @@ -382,12 +1055,39 @@ func spellAlternative(w, raw string, depth int, split bool) string { word = append(word, mark) } i++ + case c == '$' && !dq && i+1 < len(w) && w[i+1] == '\'': + // An ANSI-C string is the bytes it decodes to, as bash hands + // them on: `${X:-$'\x2f'}` prints `/` (review-guardSet MAJOR-1). + decoded, _, next, err := readAnsiCQuote(w, i+2) + if err != nil { + return nil + } + word = append(word, decoded...) + i = next + case c == '$' && !dq && i+1 < len(w) && w[i+1] == '"': + // A locale string is the double-quoted string it holds: + // `${X:-$"/"}` prints `/`. Inside double quotes the `$` is text. + i++ + case c == '$' && i+1 < len(w) && w[i+1] == '!': + // `$!` is the last background job's number, or nothing where + // none ran, which leaves the text beside it: `${X:-$!/}` is `/`. + sites = append(sites, varSite{at: len(word), texts: []string{"$!", ""}}) + word = append(word, varMark) + i += 2 + case c == '$' && i+1 < len(w) && strings.IndexByte("$?#", w[i+1]) >= 0: + // A number, never empty, which no path an entry names can be. + word = append(word, w[i:i+2]...) + i += 2 case c == '$': end := simpleParamEnd(w, i+1) if end < 0 { - return raw + // A `$` that opens no expansion is the `$` it is (`$/`), and + // so is one before a quote inside double quotes (`"$'/'"`). + word = append(word, c) + i++ + continue } - sites = append(sites, varSite{at: len(word), text: w[i:end]}) + sites = append(sites, varSite{at: len(word), texts: paramTexts(w[i:end])}) word = append(word, varMark) i = end default: @@ -395,27 +1095,51 @@ func spellAlternative(w, raw string, depth int, split bool) string { i++ } } - if dq || len(word) == 0 { - return raw + if dq { + return nil + } + if len(word) == 0 { + // An empty word prints the empty text: `${X:-}/` is `/` with X + // unset (reverify-guardSet finding 5). + return []string{""} } return spellWritten(word, sites, nil) } +// isNameStart reports whether c can begin a shell variable's name, as a +// positional parameter's digit and a special parameter's byte cannot. +func isNameStart(c byte) bool { + return c == '_' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' +} + // isNameByte reports whether c can continue a shell variable's name. func isNameByte(c byte) bool { return c == '_' || c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' } -// writtenOperand is the text an entry's arg_values compare reads for the -// word at i: the word as the line wrote its variables where it holds one -// (segment.spelled), else its known text. It is read by nothing else, so -// `rm -rf $HOME` names `$HOME` to arg_values while every other reading takes -// the variable as the unknown word it is (iss-2609290321312087). -func writtenOperand(tokens []string, spelled map[int]string, i int) string { - if w, ok := spelled[i]; ok { - return w +// writtenMatches reports whether the word at i names one of an entry's +// arg_values as the line wrote it: some text of its written spelling +// (segment.spelled) where it holds a variable, else its known text +// (argValueMatches). It is read by nothing else, so `rm -rf $HOME` names +// `$HOME` to arg_values while every other reading takes the variable as the +// unknown word it is (iss-2609290321312087), and `rm -rf ${DIR:-/}` names the +// root as its default's word does (iss-2609290426544292). A spelling past its +// bound (spellCapped) names every value: the guard stopped reading it, and a +// bound refuses rather than passes. +func writtenMatches(values []string, tokens []string, spelled map[int][]string, i int) bool { + texts, ok := spelled[i] + if !ok { + return argValueMatches(values, knownText(tokens[i])) } - return knownText(tokens[i]) + if capped(texts) { + return true + } + for _, w := range texts { + if argValueMatches(values, w) { + return true + } + } + return false } // isUnknown reports whether a word carries a substitution's output. @@ -967,8 +1691,8 @@ type operandWant struct { // the table's state is (word, operands so far, clauses met), filled from the // end once: linear in the words, whatever the number of places a command can // sit. spelled is the segment's segment.spelled, read by the arg_values -// clause alone (writtenOperand). -func operandAcceptance(tokens []string, spelled map[int]string, valueFlags []string, want operandWant, glob func(int) bool) []bool { +// clause alone (writtenMatches). +func operandAcceptance(tokens []string, spelled map[int][]string, valueFlags []string, want operandWant, glob func(int) bool) []bool { need := want.need() nv := 0 if len(want.values) > 0 { @@ -1004,7 +1728,7 @@ func operandAcceptance(tokens []string, spelled map[int]string, valueFlags []str hits |= 1 << (len(want.prefixes) + j) } } - if nv > 0 && argValueMatches(want.values, writtenOperand(tokens, spelled, i)) { + if nv > 0 && writtenMatches(want.values, tokens, spelled, i) { hits |= 1 << (len(want.prefixes) + len(want.paths)) } } diff --git a/internal/core/guard/unknownsites_test.go b/internal/core/guard/unknownsites_test.go index 288c3bc0c..2072b36f4 100644 --- a/internal/core/guard/unknownsites_test.go +++ b/internal/core/guard/unknownsites_test.go @@ -56,6 +56,9 @@ var wordReaders = map[string]string{ "splitStringValue": "commandArrivals and nameCouldBe", "scanEnvSplits": "readWord, flagCouldBe and clusterCouldCarry on every unknown word", "launcherPayloads": "readWord on every word", + "targetsAnExpansion": "exempt: reads the raw text for an assignment target that holds an expansion (`--` a decrement or a flag), never a command word's flag", + "operatorAt": "exempt: reads an assignment or step operator (`--`, `-=`) in the raw text, never a command word or a flag", + "namesIFS": "exempt: reads a declaration's literal nameref flag (`-n`); a flag word holding an expansion is already read as a name the builtin assigns (nameMarked)", "guessedEvalPayload": "exempt: reads eval's literal `--`; vanishable drops a word that may print nothing", "evalPayload": "exempt: reads eval's literal `--`, which no substitution spells (the rule's terminator clause)", "shellCPayloads": "clusterCouldCarry on every word", @@ -94,9 +97,10 @@ var wordReaders = map[string]string{ "committedRegistry": "exempt: hands git its own options to read the committed registry; reads no command word", "allReserved": "exempt: reserved words are grammar, which no substitution prints", "keywordAt": "exempt: reserved words are grammar, which no substitution prints", + "emptyable": "exempt: reads a parameter's name after its `$` (`-` is the option-letter parameter), never a command word", "readHeredocDelim": "exempt: the `<<-` operator is grammar", "simpleParamEnd": "exempt: reads the `$-` special parameter's name, grammar that makes the word unknown", - "spellParameterAt": "exempt: reads a `${…}` expansion's `-` operator (`${HOME:-x}`), grammar that spells the variable for arg_values", + "spellParameterAt": "exempt: reads a `${…}` expansion's `-` operator (`${HOME:-x}`), grammar that spells the variable and the default's word for arg_values", "validatePattern": "exempt: reads registry patterns, not command words", "validEntryID": "exempt: reads a registry id, not a command word", } diff --git a/internal/core/history/location.go b/internal/core/history/location.go index c2860ab64..2df625e81 100644 --- a/internal/core/history/location.go +++ b/internal/core/history/location.go @@ -47,7 +47,9 @@ import ( "fmt" "io" "os" + "os/user" "path/filepath" + "strconv" "strings" "syscall" "time" @@ -219,8 +221,12 @@ func narrowRecordsLeaf(dir string) (string, error) { if named, err := os.Lstat(dir); err != nil || !os.SameFile(held, named) { return "", storeDirFault(dir, fsutil.ErrNotRealDir) } - if uid, ok := recordsLeafOwner(held); !ok || uid != uint32(os.Geteuid()) { - return "", &StorePathError{Path: dir, Msg: "the records directory is not owned by this account; refusing to write transcripts into it"} + uid, ok := recordsLeafOwner(held) + if !ok { + return "", &StorePathError{Path: dir, Msg: "the owner of the records directory cannot be read; refusing to read or write transcripts in it"} + } + if uid != uint32(os.Geteuid()) { + return "", &StorePathError{Path: dir, Msg: foreignOwnerRefusal(uid)} } perm := held.Mode().Perm() if perm&0o077 == 0 { @@ -249,6 +255,32 @@ var recordsLeafOwner = func(fi os.FileInfo) (uint32, bool) { return st.Uid, true } +// foreignOwnerRefusal words the refusal of a records leaf another account +// owns. Every verb refuses there, reads included (ruling CB1): the leaf's owner +// can plant or rewrite records a later session reads back as context, so the +// store neither reads nor writes it. The wording names that owner and says the +// store is another account's, because a read verb that reported a refused write +// would describe an act it never attempted (iss-2609291731336469). +func foreignOwnerRefusal(uid uint32) string { + owner := fmt.Sprintf("uid %d", uid) + if name, ok := recordsLeafOwnerName(uid); ok { + owner = fmt.Sprintf("%s (uid %d)", name, uid) + } + return "the records directory is owned by another account, " + owner + ", not by this one; refusing to use a transcript store another account controls" +} + +// recordsLeafOwnerName resolves the account name of the uid that owns a +// foreign records leaf, reporting false when the platform's account database +// does not know it (a container's bind mount often carries a uid with no entry). +// It is a var so a test can name an account without one existing. +var recordsLeafOwnerName = func(uid uint32) (string, bool) { + u, err := user.LookupId(strconv.FormatUint(uint64(uid), 10)) + if err != nil || u.Username == "" { + return "", false + } + return u.Username, true +} + // recordPerm is the mode every record is written with, for the same reason as // storeDirPerm: owner-only, so a record stays private even in a chain level an // earlier binary created wider (iss-2609012029343438). Capture writes a new diff --git a/internal/core/history/records_leaf_mode_test.go b/internal/core/history/records_leaf_mode_test.go index 8a81bd638..18f7b1d93 100644 --- a/internal/core/history/records_leaf_mode_test.go +++ b/internal/core/history/records_leaf_mode_test.go @@ -2,8 +2,10 @@ package history import ( "errors" + "fmt" "os" "path/filepath" + "strings" "testing" ) @@ -83,7 +85,7 @@ func TestResolveKeepsTheOwnerBitsOfTheRecordsLeaf(t *testing.T) { } // TestResolveRefusesARecordsLeafAnotherAccountOwns: a leaf this account does not -// own is never changed, and the store refuses to write into it, because its +// own is never changed, and the store refuses to use it, because its // owner can read it whatever its mode. The owner lookup is stubbed; the test // never needs a second account. func TestResolveRefusesARecordsLeafAnotherAccountOwns(t *testing.T) { @@ -141,3 +143,63 @@ func TestNarrowRecordsLeafNeverFollowsASymlink(t *testing.T) { t.Errorf("the symlink's target is mode %#o, want 0o755: the narrowing followed the link", got) } } + +// TestForeignOwnedRecordsLeafRefusalNamesTheOwner: ruling CB1 keeps the refusal +// of every verb, reads included, over a records leaf another account owns, and +// rules its wording: it names the foreign owner (the uid, and the account name +// where it resolves) and says the store belongs to another account, never that +// the store refuses to write, because history list and show write nothing +// (iss-2609291731336469). Both lookups are stubbed; no second account is needed. +func TestForeignOwnedRecordsLeafRefusalNamesTheOwner(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + chain := wideLegacyLayout(t, home) + records := chain[len(chain)-1] + foreign := uint32(os.Geteuid()) + 1 + + restoreOwner, restoreName := recordsLeafOwner, recordsLeafOwnerName + defer func() { recordsLeafOwner, recordsLeafOwnerName = restoreOwner, restoreName }() + recordsLeafOwner = func(os.FileInfo) (uint32, bool) { return foreign, true } + + verbs := map[string]func() error{ + "list": func() error { _, err := List(t.TempDir(), testRootSHA); return err }, + "show": func() error { _, _, err := Read(t.TempDir(), testRootSHA, "any"); return err }, + "capture": func() error { + _, err := Capture(t.TempDir(), testRootSHA, []byte("assistant: hi\n"), CaptureMeta{SessionID: "sess-foreign", Kind: "native"}) + return err + }, + } + uidWord := fmt.Sprintf("uid %d", foreign) + for _, tc := range []struct { + label string + name string + resolves bool + }{ + {"name resolves", "someone-else", true}, + {"name does not resolve", "", false}, + } { + recordsLeafOwnerName = func(uid uint32) (string, bool) { + if uid != foreign { + t.Errorf("owner name looked up for uid %d, want %d", uid, foreign) + } + return tc.name, tc.resolves + } + for verb, run := range verbs { + err := run() + var spe *StorePathError + if !errors.As(err, &spe) || spe.Path != records { + t.Fatalf("%s (%s): got %v, want a *StorePathError naming %s", verb, tc.label, err, records) + } + msg := spe.Msg + if !strings.Contains(msg, "owned by another account") || !strings.Contains(msg, uidWord) { + t.Errorf("%s (%s): %q must say the store is owned by another account and name %q", verb, tc.label, msg, uidWord) + } + if tc.resolves && !strings.Contains(msg, tc.name) { + t.Errorf("%s (%s): %q must name the owning account %q", verb, tc.label, msg, tc.name) + } + if strings.Contains(msg, "write") { + t.Errorf("%s (%s): %q uses write wording; the refusal covers reads too", verb, tc.label, msg) + } + } + } +} diff --git a/internal/core/implement/loop/loop.go b/internal/core/implement/loop/loop.go index 21f03c181..02644ac00 100644 --- a/internal/core/implement/loop/loop.go +++ b/internal/core/implement/loop/loop.go @@ -904,3 +904,23 @@ func StatusLanes(repoRoot string) ([]statusblock.Started, error) { } return out, nil } + +// StatusPeers is the peers read the status block's head takes (ruling CC1 of +// 2026-09-29): build next's own peers check, read once for the block and +// judged per intent, so the board's "next up" passes over exactly the intents +// another checkout holds that the pick passes over. It judges on behalf of no +// session, so every live claim is a peer's. A peer the listing cannot read +// fails closed on each record, as it does for the pick. It is a +// statusblock.PeerReader. +func StatusPeers(repoRoot string) (statusblock.HeldBy, error) { + snap, err := readPeers(repoRoot) + if err != nil { + return nil, err + } + return func(r intent.ReadyResult) string { + if row := peersCheck(r, "", snap); !row.OK { + return row.Detail + } + return "" + }, nil +} diff --git a/internal/core/implement/loop/status_test.go b/internal/core/implement/loop/status_test.go index d841b41ca..d55c8c302 100644 --- a/internal/core/implement/loop/status_test.go +++ b/internal/core/implement/loop/status_test.go @@ -98,7 +98,7 @@ func TestTheStatusHeadIsTheIntentBuildNextPicks(t *testing.T) { if !ok { t.Fatalf("running=%v: no candidate: %+v", running, set) } - b, err := statusblock.Read(repo.Root(), StatusLanes) + b, err := statusblock.Read(repo.Root(), StatusLanes, StatusPeers) if err != nil { t.Fatal(err) } @@ -156,7 +156,7 @@ func TestTheStatusHeadPassesOverWhatBuildNextExcludesFromTheRecord(t *testing.T) t.Fatalf("precondition: %s is excluded by %q, got %+v", e.ID, want[e.ID], e) } } - b, err := statusblock.Read(repo.Root(), StatusLanes) + b, err := statusblock.Read(repo.Root(), StatusLanes, StatusPeers) if err != nil { t.Fatal(err) } @@ -170,3 +170,78 @@ func TestTheStatusHeadPassesOverWhatBuildNextExcludesFromTheRecord(t *testing.T) t.Errorf("the head is %q, build next picks %q", head, pick.Chosen.ID) } } + +// TestTheStatusHeadPassesOverAnIntentAPeerHolds (ruling CC1): the bare board +// pays build next's peers read, so "next up" always equals the pick. Another +// checkout holding the readiest intent in another bucket excludes it from the +// pick, and the head passes over it exactly as the pick does; a peer the +// listing names and cannot read fails closed on every record, for the head as +// for the pick, so neither names an intent. +func TestTheStatusHeadPassesOverAnIntentAPeerHolds(t *testing.T) { + records := map[string][2]string{ + "20": {pickIntent("20", "", settledQuestions, gwt), pickSpec("20", "")}, + "21": {pickIntent("21", "", settledQuestions, gwt), pickSpec("21", fpSmall)}, + } + head := func(b statusblock.Block) string { + for _, r := range b.Now { + if r.NextUp { + return r.ID + } + } + return "" + } + + t.Run("a branch holds the readiest intent", func(t *testing.T) { + repo := pickRepo(t, records) + ir, _ := pickRel("21") + repo.Git("checkout", "-q", "-b", "lane-alpha") + repo.Remove(ir) + repo.Write(".abcd/development/intents/shipped/itd-21-i21.md", records["21"][0]) + repo.Commit("deliver itd-21") + repo.Git("checkout", "-q", "main") + + set, err := candidates(repo.Root(), "") + if err != nil { + t.Fatal(err) + } + pick, ok := intent.Choose(set.Candidates) + if !ok || pick.Chosen.ID != "itd-20" { + t.Fatalf("precondition: build next passes over itd-21, which lane-alpha holds, and picks itd-20: %+v", set) + } + b, err := statusblock.Read(repo.Root(), StatusLanes, StatusPeers) + if err != nil { + t.Fatal(err) + } + if got := head(b); got != pick.Chosen.ID { + t.Errorf("the head is %q, build next picks %q: the board must pay the pick's peers read", got, pick.Chosen.ID) + } + }) + + t.Run("a peer cannot be read", func(t *testing.T) { + repo := pickRepo(t, records) + repo.Git("checkout", "-q", "-b", "lane-beta") + beta := "---\nid: itd-30\nslug: beta\n---\n# beta\n" + repo.Write(".abcd/development/intents/drafts/itd-30-beta.md", beta) + repo.Write(".abcd/development/intents/planned/itd-30-beta.md", beta) + repo.Commit("split beta") + repo.Git("checkout", "-q", "main") + + set, err := candidates(repo.Root(), "") + if err != nil { + t.Fatal(err) + } + if len(set.Candidates) != 0 { + t.Fatalf("precondition: an unreadable peer leaves the pick no candidate: %+v", set) + } + b, err := statusblock.Read(repo.Root(), StatusLanes, StatusPeers) + if err != nil { + t.Fatalf("an unreadable peer fails closed on each record, never the board: %v", err) + } + if got := head(b); got != "" { + t.Errorf("the head is %q; the pick has no candidate, so nothing is next up", got) + } + if len(b.Next) != 2 { + t.Errorf("Next = %+v, want both READY intents still listed", b.Next) + } + }) +} diff --git a/internal/core/intent/consistency.go b/internal/core/intent/consistency.go index b3e4cdb8a..e8ba0b3c4 100644 --- a/internal/core/intent/consistency.go +++ b/internal/core/intent/consistency.go @@ -19,6 +19,7 @@ import ( "github.com/intentdriven/abcd/internal/core/issueschema" "github.com/intentdriven/abcd/internal/core/mdrecord" + "github.com/intentdriven/abcd/internal/core/record/match" "github.com/intentdriven/abcd/internal/core/recordid" "github.com/intentdriven/abcd/internal/fsutil" "github.com/intentdriven/abcd/internal/gitutil" @@ -621,6 +622,9 @@ type consistencyReview struct { type ConsistencyFiling struct { IssueID string `json:"issue_id"` Linked bool `json:"linked"` + // Match is the filing-time match's outcome (itd-2609212137116617) on a + // record the ledger filed, when the ingest asked for one. + Match *match.Outcome `json:"match,omitempty"` } // ConsistencyFiler files one finding in the ledger, or names the open record @@ -650,8 +654,9 @@ type ConsistencyIngestRequest struct { // ConsistencyRow is one finding as the report and the result carry it. type ConsistencyRow struct { ConsistencyFinding - IssueID string `json:"issue_id"` - Linked bool `json:"linked"` + IssueID string `json:"issue_id"` + Linked bool `json:"linked"` + Match *match.Outcome `json:"match,omitempty"` } // ConsistencyIngestResult reports one ingest. @@ -736,7 +741,7 @@ func IngestConsistency(req ConsistencyIngestRequest) (ConsistencyIngestResult, e "no report was written, and ingesting the same findings again links those records rather than filing them twice", f.Number, len(rv.Findings), err, orNone(res.Filed)) } - res.Rows = append(res.Rows, ConsistencyRow{ConsistencyFinding: f, IssueID: filing.IssueID, Linked: filing.Linked}) + res.Rows = append(res.Rows, ConsistencyRow{ConsistencyFinding: f, IssueID: filing.IssueID, Linked: filing.Linked, Match: filing.Match}) if filing.Linked { res.Linked = append(res.Linked, filing.IssueID) } else { diff --git a/internal/core/intent/create.go b/internal/core/intent/create.go index e5ed5eab9..b15f3c6ea 100644 --- a/internal/core/intent/create.go +++ b/internal/core/intent/create.go @@ -205,10 +205,11 @@ type DraftOptions struct { // provenance.DefaultMode, so a draft written through a command carries the key // whatever the caller says. ProductionMode string - // Match, when non-nil, matches the draft's title and press release against - // the record under the mint lock and writes each likely double as a typed - // link (match.go). The promote route passes none: its draft is joined to - // the record it graduated from already. + // Match, when non-nil, matches the draft's title and press release (or the + // matcher's own Text) against the record under the mint lock and writes + // each likely double as a typed link (match.go). The issue promote route + // passes none; the reading-item route passes one on request (ruling DQ2b), + // comparing the item's finding. Match *Matcher } @@ -232,6 +233,12 @@ func CreateDraft(repoRoot string, opts DraftOptions) (Intent, error) { return it, err } +// CreateDraftMatched is CreateDraft returning the filing-time match's outcome +// too, nil when opts asked for none. +func CreateDraftMatched(repoRoot string, opts DraftOptions) (Intent, *match.Outcome, error) { + return createDraftMatched(repoRoot, opts) +} + // createDraftMatched is CreateDraft returning the filing-time match's outcome too, // nil when opts asked for none. func createDraftMatched(repoRoot string, opts DraftOptions) (Intent, *match.Outcome, error) { @@ -321,7 +328,11 @@ func createDraftMatched(repoRoot string, opts DraftOptions) (Intent, *match.Outc // it reads is the record the draft is written into. var links map[match.Relation][]string if opts.Match != nil { - outcome = runMatch(opts.Match, opts.Title+"\n"+opts.PressRelease) + text := opts.Title + "\n" + opts.PressRelease + if opts.Match.Text != "" { + text = opts.Match.Text + } + outcome = runMatch(opts.Match, text) links = outcome.Links() } content := seedDraft(id, opts, stamp, links) diff --git a/internal/core/intent/match.go b/internal/core/intent/match.go index b505bb29a..d83da4328 100644 --- a/internal/core/intent/match.go +++ b/internal/core/intent/match.go @@ -90,6 +90,11 @@ func matchTextOf(id, content string) MatchText { type Matcher struct { Threshold float64 Candidates func() ([]match.Candidate, error) + // Text, when non-empty, is the text the match compares in place of the + // draft's title and press release: the source record's own words, for a + // draft minted from a record whose title alone is too short to compare + // (a promoted reading item's pattern, ruling DQ2b). + Text string } // Created is a quoted-text create's result: the draft, and the match's diff --git a/internal/core/intent/startcheck.go b/internal/core/intent/startcheck.go index 4d08c2e48..fbaf167a7 100644 --- a/internal/core/intent/startcheck.go +++ b/internal/core/intent/startcheck.go @@ -7,14 +7,16 @@ package intent // sections, the hold, the blockers and the spec's steps. It is the one // statement of them, so the build (loop.Check) and the status board's "next // up" (statusblock.Read) exclude the same intents for the same reasons. The -// check that reads other checkouts, the peers, stays with the build: the board -// does not consult them. +// check that reads other checkouts, the peers, stays with the build, and the +// board reaches it through the build's own reader (loop.StatusPeers, ruling +// CC1). import ( "fmt" "path/filepath" "strings" + "github.com/intentdriven/abcd/internal/core/frontmatter" "github.com/intentdriven/abcd/internal/core/recordid" "github.com/intentdriven/abcd/internal/core/spec" ) @@ -72,7 +74,11 @@ func StartChecksIn(repoRoot string, corpus Corpus, store spec.Store, r ReadyResu res.Rows = append(res.Rows, startClaimSectionsRow(r, content)) it, _ := corpus.Lookup(r.IntentID) res.Rows = append(res.Rows, startHoldRow(it)) - res.Rows = append(res.Rows, startBlockedRow(corpus, r.IntentID, content)) + blockedRow, err := startBlockedRow(repoRoot, corpus, r.IntentID, content) + if err != nil { + return res, err + } + res.Rows = append(res.Rows, blockedRow) stepsRow, steps, err := startStepsRow(repoRoot, store, r) if err != nil { return res, err @@ -140,80 +146,157 @@ func startHoldRow(it Intent) StartRow { return row } -// startBlockedRow refuses a record that names, in `blocked_by`, an intent that has -// not shipped. A blocker the corpus does not hold is unshipped as far as this +// startBlockedRow refuses a record that names, in `blocked_by`, a blocker that +// is not settled. An intent is settled when it has shipped or sits in +// disciplines/ (ruling CF2 of 2026-09-30: a blocker reclassified as a +// discipline is a standing rule, not work that will ever ship, so it counts as +// settled). A blocker the corpus does not hold is unsettled as far as this // checkout can tell, and refuses too: the edge says something must ship first. // -// A blocker that was superseded is followed along `superseded_by` to the intent +// A blocker that was superseded is followed along `superseded_by` to the record // that replaced it, transitively, and the record waits on that replacement -// (ruling BZ2 of 2026-09-29): it is blocked exactly when the last intent of the -// chain has not shipped. A chain the check cannot finish refuses, naming the -// chain: one that loops, one whose next record this checkout does not hold, a -// superseded record naming no successor, and one ending at a decision (adr-N), -// because a decision replacing the work is not an intent that ships and nothing -// on the record says the edge is settled by it. -func startBlockedRow(corpus Corpus, id, content string) StartRow { +// (ruling BZ2 of 2026-09-29): it is blocked exactly when the last record of the +// chain is unsettled. A chain ending at a decision (adr-N) is settled when that +// ADR's status is `accepted` (ruling CF1 of 2026-09-30); a decision in any other +// status, or one this checkout's decision store does not hold, refuses naming +// it. A chain the check cannot finish refuses, naming the chain: one that +// loops, one whose next intent this checkout does not hold, and a superseded +// record naming no successor. A settled chain passes and names itself, so the +// row says which record settled the edge. An error is a fault in reading the +// checkout. +func startBlockedRow(repoRoot string, corpus Corpus, id, content string) (StartRow, error) { row := StartRow{Name: StartCheckBlocked} var open, followed []string for _, b := range blockedBy(content) { - chain, final, problem := followBlocker(corpus, b) - path := strings.Join(chain, " → ") + end, err := followBlocker(repoRoot, corpus, b) + if err != nil { + return row, err + } + path := strings.Join(end.chain, " → ") switch { - case problem != "" && len(chain) == 1: - open = append(open, b+" ("+problem+")") - case problem != "": - open = append(open, b+" (superseded: "+path+": "+problem+")") - case final.Bucket != BucketShipped && len(chain) == 1: - open = append(open, b+" ("+final.Bucket+")") - case final.Bucket != BucketShipped: - open = append(open, b+" (superseded: "+path+", "+final.Bucket+")") - case len(chain) > 1: - followed = append(followed, path+" (shipped)") + case end.problem != "" && len(end.chain) == 1: + open = append(open, b+" ("+end.problem+")") + case end.problem != "": + open = append(open, b+" (superseded: "+path+": "+end.problem+")") + case !end.settled && len(end.chain) == 1: + open = append(open, b+" ("+end.state+")") + case !end.settled: + open = append(open, b+" (superseded: "+path+", "+end.state+")") + case len(end.chain) > 1: + followed = append(followed, path+" ("+end.state+")") } } if len(open) == 0 { row.OK = true - row.Detail = id + " names no unshipped blocker" + row.Detail = id + " names no unsettled blocker" if len(followed) > 0 { - row.Detail += "; a superseded blocker waits on its replacement: " + strings.Join(followed, ", ") + row.Detail += "; a superseded blocker is settled by the record that replaced it: " + strings.Join(followed, ", ") } - return row + return row, nil } row.Detail = id + " is blocked by " + strings.Join(open, ", ") - row.Remedy = "ship the blocker first, or the intent its supersession chain ends at (each superseded record names its successor in `superseded_by`; repair a chain that loops or ends nowhere); or drop the edge from `blocked_by` if it no longer holds" - return row + row.Remedy = "ship the blocker first, or settle the record its supersession chain ends at: ship that intent, or accept that decision (`status: accepted`); each superseded record names its successor in `superseded_by`, so repair a chain that loops or ends nowhere; or drop the edge from `blocked_by` if it no longer holds" + return row, nil +} + +// blockerEnd is where one blocker's supersession chain ends. chain names every +// record visited, the blocker first. When problem is empty, state names the +// last record's standing (its intent bucket, or `accepted` for a decision) and +// settled says whether that standing releases the edge; when problem is +// non-empty the chain could not be finished and refuses. +type blockerEnd struct { + chain []string + state string + settled bool + problem string } // followBlocker walks one blocker along `superseded_by` until it reaches a -// record that is not superseded. chain names every record visited, the blocker -// first; final is the record the chain ends at, and problem is non-empty when -// the chain cannot be finished (then final is meaningless). -func followBlocker(corpus Corpus, blocker string) (chain []string, final Intent, problem string) { - chain = []string{blocker} +// record that is not a superseded intent: an intent in any other bucket, which +// settles the edge from shipped/ or disciplines/, or a decision, which settles +// it when accepted. An error is a fault in reading the decision store. +func followBlocker(repoRoot string, corpus Corpus, blocker string) (blockerEnd, error) { + end := blockerEnd{chain: []string{blocker}} seen := map[string]bool{} cur := blocker for { it, ok := corpus.Lookup(cur) if !ok { - return chain, Intent{}, "not in this checkout's intent store" + end.problem = "not in this checkout's intent store" + return end, nil } if seen[it.ID] { - return chain, Intent{}, "a supersession cycle" + end.problem = "a supersession cycle" + return end, nil } seen[it.ID] = true if it.Bucket != BucketSuperseded { - return chain, it, "" + end.state = it.Bucket + end.settled = it.Bucket == BucketShipped || it.Bucket == BucketDisciplines + return end, nil } next := it.SupersededBy if next == "" { - return chain, Intent{}, it.ID + " is superseded and names no successor" + end.problem = it.ID + " is superseded and names no successor" + return end, nil + } + if recordid.ValidIntentID(next) { + end.chain = append(end.chain, next) + cur = next + continue } - chain = append(chain, next) - if !recordid.ValidIntentID(next) { - return chain, Intent{}, next + " is not an intent: a decision replaced the blocker, and nothing on the record says that settles the edge" + adr := recordid.CanonADRID(next) + if adr == "" { + end.chain = append(end.chain, next) + end.problem = next + " names neither an intent nor a decision" + return end, nil } - cur = next + end.chain = append(end.chain, adr) + status, found, err := decisionStatus(repoRoot, adr) + switch { + case err != nil: + return end, err + case !found: + end.problem = adr + " is not in this checkout's decision store" + case status == "": + end.problem = adr + " carries no status: a decision settles the edge once its status is accepted" + case status != adrStatusAccepted: + end.problem = adr + " is " + status + ": a decision settles the edge once its status is accepted" + default: + end.state = status + end.settled = true + } + return end, nil + } +} + +// adrStatusAccepted is the ADR status that puts a decision in force. +const adrStatusAccepted = "accepted" + +// decisionStatus reads the `status` of the decision canonical names (a +// canonical ADR id). The file is found through the record-id resolver, which +// routes both ADR id vintages by filename (recordid.ADRFileID), and read through +// the shared frontmatter field reader; the file's own `id` must name the same +// decision, as the `abcd adr-N` dispatch confirms it, or the decision counts as +// absent. found is false when this checkout's decision store holds no such +// record; status is "" when the record carries none. An error is a fault in +// reading the store or the file. +func decisionStatus(repoRoot, canonical string) (status string, found bool, err error) { + rel, ok, err := recordid.LookupOne(repoRoot, canonical) + if err != nil || !ok { + return "", false, err + } + data, err := readRepoFile(filepath.Join(repoRoot, filepath.FromSlash(rel)), rel) + if err != nil { + return "", false, err + } + fields := frontmatter.Fields(strings.Split(string(data), "\n")) + got, _ := frontmatter.ScalarString(frontmatter.StripComment(fields["id"].Value)) + if recordid.CanonADRID(got) != canonical { + return "", false, nil } + status, _ = frontmatter.ScalarString(frontmatter.StripComment(fields["status"].Value)) + return strings.TrimSpace(status), true, nil } // startStepsRow reads the open spec's steps through the spec store's reader: the diff --git a/internal/core/intent/startcheck_test.go b/internal/core/intent/startcheck_test.go index 6d8997f41..e67d0d983 100644 --- a/internal/core/intent/startcheck_test.go +++ b/internal/core/intent/startcheck_test.go @@ -4,6 +4,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/intentdriven/abcd/internal/core/decide" ) // blockerRecord is a minimal intent record for the blocked check's corpus: an @@ -16,52 +18,92 @@ func blockerRecord(id, supersededBy string) string { return s + "---\n# " + id + "\n" } +// adrRecord is a minimal decision record: its id and the status line's value. +func adrRecord(id, status string) string { + return "---\nid: " + id + "\nslug: a-decision\nstatus: " + status + "\n---\n# " + id + "\n" +} + // TestStartBlockedRowFollowsASupersededBlockerToItsReplacement is ruling BZ2 // of 2026-09-29: a blocker that was superseded is followed along // `superseded_by` to the intent that replaced it, transitively, and the intent -// waits on that replacement. It is blocked exactly when the last intent of the -// chain has not shipped; a chain that loops, ends at a record this checkout -// does not hold, names no successor, or ends at a decision rather than an -// intent refuses naming the chain. +// waits on that replacement. It is blocked exactly when the last record of the +// chain is unsettled; a chain that loops, ends at a record this checkout does +// not hold, or names no successor refuses naming the chain. Rulings CF1 and CF2 +// of 2026-09-30 settle two more endings: a chain ending at a decision settles +// when that ADR is accepted, and one ending at a discipline settles as a +// shipped intent does; a decision in any other status, or one this checkout +// does not hold, still refuses naming it. func TestStartBlockedRowFollowsASupersededBlockerToItsReplacement(t *testing.T) { type rec struct{ bucket, id, by string } cases := []struct { name string records []rec + adrs map[string]string // ADR filename -> its frontmatter ok bool want []string }{ {"replaced by a shipped intent", []rec{ {BucketSuperseded, "itd-27", "itd-94"}, {BucketShipped, "itd-94", ""}, - }, true, []string{"itd-27 → itd-94"}}, + }, nil, true, []string{"itd-27 → itd-94"}}, {"replaced by an unshipped intent", []rec{ {BucketSuperseded, "itd-27", "itd-94"}, {BucketPlanned, "itd-94", ""}, - }, false, []string{"itd-27 → itd-94", "planned"}}, + }, nil, false, []string{"itd-27 → itd-94", "planned"}}, {"replaced twice, the last shipped", []rec{ {BucketSuperseded, "itd-27", "itd-94"}, {BucketSuperseded, "itd-94", "itd-95"}, {BucketShipped, "itd-95", ""}, - }, true, []string{"itd-27 → itd-94 → itd-95"}}, + }, nil, true, []string{"itd-27 → itd-94 → itd-95"}}, {"replaced twice, the last a draft", []rec{ {BucketSuperseded, "itd-27", "itd-94"}, {BucketSuperseded, "itd-94", "itd-95"}, {BucketDrafts, "itd-95", ""}, - }, false, []string{"itd-27 → itd-94 → itd-95", "drafts"}}, + }, nil, false, []string{"itd-27 → itd-94 → itd-95", "drafts"}}, {"a supersession cycle", []rec{ {BucketSuperseded, "itd-27", "itd-94"}, {BucketSuperseded, "itd-94", "itd-27"}, - }, false, []string{"itd-27 → itd-94 → itd-27", "cycle"}}, + }, nil, false, []string{"itd-27 → itd-94 → itd-27", "cycle"}}, {"a replacement this checkout does not hold", []rec{ {BucketSuperseded, "itd-27", "itd-94"}, - }, false, []string{"itd-27 → itd-94", "not in this checkout's intent store"}}, + }, nil, false, []string{"itd-27 → itd-94", "not in this checkout's intent store"}}, {"a superseded record naming no successor", []rec{ {BucketSuperseded, "itd-27", "null"}, - }, false, []string{"itd-27", "names no successor"}}, - {"replaced by a decision", []rec{ + }, nil, false, []string{"itd-27", "names no successor"}}, + {"replaced by an accepted decision", []rec{ + {BucketSuperseded, "itd-27", "adr-37"}, + }, map[string]string{"0037-changelog-driven-releases.md": adrRecord("adr-37", "accepted")}, + true, []string{"itd-27 → adr-37 (accepted)"}}, + {"replaced by an accepted decision whose status line carries a comment", []rec{ + {BucketSuperseded, "itd-27", "adr-37"}, + }, map[string]string{"0037-x.md": adrRecord("adr-37", "accepted # proposed | accepted | superseded | deprecated")}, + true, []string{"itd-27 → adr-37 (accepted)"}}, + {"replaced by an accepted minted decision, named zero-padded", []rec{ + {BucketSuperseded, "itd-27", "adr-02609012206053814"}, + }, map[string]string{"2609012206053814-x.md": adrRecord("adr-2609012206053814", "accepted")}, + true, []string{"itd-27 → adr-2609012206053814 (accepted)"}}, + {"replaced by a proposed decision", []rec{ + {BucketSuperseded, "itd-27", "adr-37"}, + }, map[string]string{"0037-x.md": adrRecord("adr-37", "proposed")}, + false, []string{"itd-27 → adr-37", "adr-37 is proposed", "accepted"}}, + {"replaced by a decision carrying no status", []rec{ + {BucketSuperseded, "itd-27", "adr-37"}, + }, map[string]string{"0037-x.md": "---\nid: adr-37\n---\n# adr-37\n"}, + false, []string{"itd-27 → adr-37", "adr-37 carries no status"}}, + {"replaced by a decision this checkout does not hold", []rec{ + {BucketSuperseded, "itd-27", "adr-37"}, + }, nil, false, []string{"itd-27 → adr-37", "not in this checkout's decision store"}}, + {"replaced by a decision whose file claims another id", []rec{ {BucketSuperseded, "itd-27", "adr-37"}, - }, false, []string{"itd-27 → adr-37", "not an intent"}}, + }, map[string]string{"0037-x.md": adrRecord("adr-38", "accepted")}, + false, []string{"itd-27 → adr-37", "not in this checkout's decision store"}}, + {"replaced by a discipline", []rec{ + {BucketSuperseded, "itd-27", "itd-94"}, + {BucketDisciplines, "itd-94", ""}, + }, nil, true, []string{"itd-27 → itd-94 (disciplines)"}}, + {"reclassified as a discipline in place", []rec{ + {BucketDisciplines, "itd-27", ""}, + }, nil, true, []string{"names no unsettled blocker"}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -69,11 +111,17 @@ func TestStartBlockedRowFollowsASupersededBlockerToItsReplacement(t *testing.T) for _, r := range tc.records { writeFile(t, root, filepath.Join(IntentsRelDir, r.bucket, r.id+"-"+strings.ReplaceAll(r.id, "itd-", "rec-")+".md"), blockerRecord(r.id, r.by)) } + for name, body := range tc.adrs { + writeFile(t, root, filepath.Join(filepath.FromSlash(decide.ADRsRelDir), name), body) + } corpus, err := Load(root) if err != nil { t.Fatal(err) } - row := startBlockedRow(corpus, "itd-10", "---\nid: itd-10\nblocked_by: [itd-27]\n---\n") + row, err := startBlockedRow(root, corpus, "itd-10", "---\nid: itd-10\nblocked_by: [itd-27]\n---\n") + if err != nil { + t.Fatal(err) + } if row.OK != tc.ok { t.Fatalf("want OK=%v, got %+v", tc.ok, row) } @@ -89,6 +137,44 @@ func TestStartBlockedRowFollowsASupersededBlockerToItsReplacement(t *testing.T) } } +// TestStartBlockedRowRefusesADecisionIdTwoFilesClaim: a supersession chain +// ending at a decision whose id two files in the decision store claim is not +// settled by whichever file the scan reads first. One file says accepted and +// the other proposed, so the standing of the decision is ambiguous, and the +// blocked check refuses naming the decision rather than settling the edge on +// the accepted copy. +func TestStartBlockedRowRefusesADecisionIdTwoFilesClaim(t *testing.T) { + // At this head the store's lookup (recordid.LookupOne) keeps the first file + // in scan order, so the accepted copy, which sorts first, settles the edge: + // the check settles first-wins rather than refusing. Watched: without this + // skip the test fails with OK=true and "itd-27 → adr-37 (accepted)". The + // uniqueness of an ADR id is made a refusal by lane adrIdUnique + // (fix/lint-adr-id-unique f6cd7b2d7), which lands later; it lifts this skip. + t.Skip("first-wins at this head: an ADR id two files claim is refused once lane adrIdUnique (fix/lint-adr-id-unique f6cd7b2d7) lands") + root := t.TempDir() + writeFile(t, root, filepath.Join(IntentsRelDir, BucketSuperseded, "itd-27-rec-27.md"), blockerRecord("itd-27", "adr-37")) + for name, status := range map[string]string{ + "0037-a-first-copy.md": "accepted", + "0037-b-second-copy.md": "proposed", + } { + writeFile(t, root, filepath.Join(filepath.FromSlash(decide.ADRsRelDir), name), adrRecord("adr-37", status)) + } + corpus, err := Load(root) + if err != nil { + t.Fatal(err) + } + row, err := startBlockedRow(root, corpus, "itd-10", "---\nid: itd-10\nblocked_by: [itd-27]\n---\n") + if err != nil { + t.Fatal(err) + } + if row.OK { + t.Fatalf("a decision id two files claim must refuse, not settle on the first file read: %+v", row) + } + if !strings.Contains(row.Detail, "adr-37") { + t.Errorf("the refusal must name the decision: %q", row.Detail) + } +} + // TestStartBlockedRowKeepsAPlainUnshippedBlocker holds the unchanged half: a // blocker that was not superseded blocks until it ships, and one this checkout // does not hold blocks too. @@ -99,7 +185,10 @@ func TestStartBlockedRowKeepsAPlainUnshippedBlocker(t *testing.T) { if err != nil { t.Fatal(err) } - row := startBlockedRow(corpus, "itd-10", "---\nid: itd-10\nblocked_by: [itd-27, itd-99]\n---\n") + row, err := startBlockedRow(root, corpus, "itd-10", "---\nid: itd-10\nblocked_by: [itd-27, itd-99]\n---\n") + if err != nil { + t.Fatal(err) + } if row.OK { t.Fatalf("an unshipped blocker blocks: %+v", row) } diff --git a/internal/core/intent/target.go b/internal/core/intent/target.go index 4f88ac843..ec5f3d66b 100644 --- a/internal/core/intent/target.go +++ b/internal/core/intent/target.go @@ -167,3 +167,65 @@ func intentNum(id string) int { } return n } + +// TargetRewrite is one intent record a cut rewrites as it moves a missed +// target to `next`: the record's repo-relative path and its bytes before and +// after, so the cut writes it with its other writes and restores it on their +// undo. +type TargetRewrite struct { + Path string + Before []byte + After []byte +} + +// PlanTargetMoves reads each record a cut passes (launch.MissedTargets) and +// returns the rewrite that moves its target to `next` (criterion 3, the +// product thinker's ruling BS1 of 2026-09-29). It writes nothing. A move whose +// target is already `next` needs no rewrite and yields none: it still names +// the following release after the cut. +// +// Every move is checked against the record as it is on disk, and one that no +// longer matches — a record not in planned/ under that path, or a target that +// moved since the cut read it — refuses the whole plan: the cut is not +// written from a stale read. The caller holds the store's lock (WithMintLock) +// across this read and its writes, as every other intent writer does. +func PlanTargetMoves(repoRoot string, moves []launch.TargetMove) ([]TargetRewrite, error) { + if len(moves) == 0 { + return nil, nil + } + corpus, err := Load(repoRoot) + if err != nil { + return nil, err + } + var out []TargetRewrite + for _, m := range moves { + it, ok := corpus.Lookup(m.ID) + if !ok || it.Bucket != BucketPlanned || filepath.ToSlash(it.Path) != m.Path { + return nil, fmt.Errorf("intent: %s is not the planned record at %s the cut read, so its target is not moved (nothing written)", m.ID, m.Path) + } + abs := filepath.Join(repoRoot, filepath.FromSlash(m.Path)) + data, err := readRepoFile(abs, m.Path) + if err != nil { + return nil, err + } + content := string(data) + current, malformed := targetField(frontmatter.Fields(strings.Split(content, "\n"))) + if malformed || current != m.From { + return nil, fmt.Errorf("intent: %s carries `%s: %s`, not the %s the cut read, so its target is not moved (nothing written)", + m.ID, launch.TargetReleaseKey, current, m.From) + } + if current == launch.TargetNext { + continue + } + updated, err := setFrontmatterFields(content, map[string]string{launch.TargetReleaseKey: launch.TargetNext}) + if err != nil { + return nil, err + } + if len(updated) > maxIntentFileBytes { + return nil, fmt.Errorf("intent: moving the target of %s would produce %d bytes, past the %d-byte cap its own reader enforces (nothing written)", + m.ID, len(updated), maxIntentFileBytes) + } + out = append(out, TargetRewrite{Path: m.Path, Before: data, After: []byte(updated)}) + } + return out, nil +} diff --git a/internal/core/intent/target_test.go b/internal/core/intent/target_test.go index d13abe866..1cd530934 100644 --- a/internal/core/intent/target_test.go +++ b/internal/core/intent/target_test.go @@ -6,6 +6,7 @@ import ( "strings" "testing" + "github.com/intentdriven/abcd/internal/core/launch" "github.com/intentdriven/abcd/internal/core/lint" ) @@ -330,3 +331,60 @@ func TestAMalformedTargetIsRefusedNotOverwritten(t *testing.T) { t.Fatalf("the listing must carry the malformed value raw and flagged: %+v", got) } } + +// Criterion 3's record half (ruling BS1 of 2026-09-29): the rewrite a cut +// makes for each target it passes. A version target becomes `next` on one +// line with nothing else in the file touched; a `next` target is already +// right and yields no rewrite; and a record whose target moved since the cut +// read it, or that left planned/, is refused rather than rewritten from a +// stale read. +func TestPlanTargetMovesRewritesAMissedTargetToNext(t *testing.T) { + root := t.TempDir() + relA := plannedDir + "/itd-10-alpha.md" + relB := plannedDir + "/itd-11-beta.md" + writeFile(t, root, relA, withTarget(plannedLinked("itd-10", "alpha", "spc-1"), "v0.11.0")) + writeFile(t, root, relB, withTarget(plannedLinked("itd-11", "beta", "spc-2"), "next")) + before := readRec(t, root, relA) + + got, err := PlanTargetMoves(root, []launch.TargetMove{ + {ID: "itd-10", Path: relA, From: "v0.11.0"}, + {ID: "itd-11", Path: relB, From: "next"}, + }) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Path != relA || string(got[0].Before) != before { + t.Fatalf("PlanTargetMoves = %+v, want one rewrite of %s", got, relA) + } + if want := withTarget(plannedLinked("itd-10", "alpha", "spc-1"), "next"); string(got[0].After) != want { + t.Fatalf("the rewrite must change the one line:\n%s\nwant\n%s", got[0].After, want) + } + if readRec(t, root, relA) != before { + t.Fatal("planning a move wrote the record") + } + + for _, tc := range []struct { + name string + move launch.TargetMove + want string + }{ + {"moved since the read", launch.TargetMove{ID: "itd-10", Path: relA, From: "v0.10.0"}, "v0.11.0"}, + {"not a planned record", launch.TargetMove{ID: "itd-10", Path: shippedDir + "/itd-10-alpha.md", From: "v0.11.0"}, "planned"}, + {"another record's file", launch.TargetMove{ID: "itd-12", Path: relA, From: "v0.11.0"}, "itd-12"}, + } { + if _, err := PlanTargetMoves(root, []launch.TargetMove{tc.move}); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Errorf("%s: PlanTargetMoves = %v, want a refusal naming %q", tc.name, err, tc.want) + } + } +} + +// Criterion 1 with the ruled value: `next` on a planned intent is a legal +// target to the record lint as it is to the verb. +func TestTheRecordLintAdmitsNextOnAPlannedIntent(t *testing.T) { + root := t.TempDir() + writeFile(t, root, plannedDir+"/itd-13-next.md", withTarget(plannedLinked("itd-13", "next", "spc-7"), "next")) + writeFile(t, root, specsOpen+"/spc-7-next.md", specNaming("spc-7", "next", "itd-13")) + if fs := targetFindings(t, root); len(fs) != 0 { + t.Fatalf("`next` on a planned intent is legal: %+v", fs) + } +} diff --git a/internal/core/issueschema/reading.go b/internal/core/issueschema/reading.go index 3277703aa..9c3b67c39 100644 --- a/internal/core/issueschema/reading.go +++ b/internal/core/issueschema/reading.go @@ -128,7 +128,7 @@ var ReadingRequired = []string{ } // ReadingKnown is the reading record's additionalProperties:false allow-list: -// the envelope, every body field of every position, and the two optional +// the envelope, every body field of every position, and the optional // properties below. A key outside it is refused, exactly as it is on an issue. var ReadingKnown = readingKnown() @@ -274,6 +274,12 @@ func readingKnown() map[string]bool { // here (with the item named in the draft's related_issues) is what joins // the two (itd-4 AC3). "related_intents": true, + // duplicates and refines are the filing-time match's typed links + // (ruling DQ2b, adr-2609300821558671): the likely repeat of an issue, + // an intent or an earlier reading item, written when the item is stored + // and confirmed or removed by a person. + "duplicates": true, + "refines": true, } for _, k := range ReadingRequired { known[k] = true diff --git a/internal/core/launch/target.go b/internal/core/launch/target.go index 6ad1ca160..3df594fa3 100644 --- a/internal/core/launch/target.go +++ b/internal/core/launch/target.go @@ -59,3 +59,46 @@ type TargetedIntent struct { // what refuses the value. Invalid string `json:"invalid,omitempty"` } + +// TargetMove is one targeted intent a cut passes without shipping it: the row +// the cut rewrites to `next` in the change that rolls the changelog, and the +// changelog's move note names (criterion 3, ruling BS1 of 2026-09-29). +type TargetMove struct { + // ID is the intent's id (itd-N). + ID string `json:"id"` + // Path is the record's repo-relative path. + Path string `json:"path"` + // From is the target the record carried before the cut: `next`, which + // named the release being cut, or a tag at or below it. + From string `json:"from"` +} + +// MissedTargets returns every target the cut to nextTag passes, in the order +// given: `next`, which names the release being cut, and a tag at or below +// nextTag, which names this release or one that will now never be cut. Each +// becomes `next` — the following release, whatever version it derives — so a +// missed target never goes stale and is never renumbered by guess (the product +// thinker's ruling BS1 of 2026-09-29). A tag above nextTag is still ahead and +// is not listed; neither is a value that is not a target (the record lint +// names it), nor anything when nextTag is empty, because a refused cut +// derives no version and writes nothing. +func MissedTargets(targets []TargetedIntent, nextTag string) []TargetMove { + cut, err := ParseSemver(strings.TrimPrefix(nextTag, "v")) + if err != nil || nextTag == "" { + return nil + } + var out []TargetMove + for _, tg := range targets { + if tg.Invalid != "" || ValidTargetRelease(tg.Target) != nil { + continue + } + if tg.Target != TargetNext { + v, err := ParseSemver(strings.TrimPrefix(tg.Target, "v")) + if err != nil || CoreGreater(v, cut) { + continue + } + } + out = append(out, TargetMove{ID: tg.ID, Path: tg.Path, From: tg.Target}) + } + return out +} diff --git a/internal/core/launch/target_test.go b/internal/core/launch/target_test.go index 80aca2b22..e910e9702 100644 --- a/internal/core/launch/target_test.go +++ b/internal/core/launch/target_test.go @@ -90,3 +90,39 @@ func TestPreflightReportWithNoTargetsSaysNothingAboutThem(t *testing.T) { t.Fatalf("no target, no section:\n%s", rep.Markdown()) } } + +// TestMissedTargetsAreTheOnesTheCutReaches is criterion 3's selection, as the +// product thinker ruled it on 2026-09-29 (BS1): a cut passes every target that +// names it or an earlier release — `next`, which names the release being cut, +// and a tag at or below the derived one — and each of those becomes `next`. A +// tag above the cut is still ahead and stays, and a value that is not a target +// is left for the record lint to name. +func TestMissedTargetsAreTheOnesTheCutReaches(t *testing.T) { + targets := []TargetedIntent{ + {ID: "itd-1", Path: "p/itd-1.md", Target: "next"}, + {ID: "itd-2", Path: "p/itd-2.md", Target: "v0.11.0"}, + {ID: "itd-3", Path: "p/itd-3.md", Target: "v0.10.2"}, + {ID: "itd-4", Path: "p/itd-4.md", Target: "v0.11.1"}, + {ID: "itd-5", Path: "p/itd-5.md", Target: "v1.0.0"}, + {ID: "itd-6", Path: "p/itd-6.md", Target: "0.11", Invalid: "not a target"}, + } + var got []string + for _, m := range MissedTargets(targets, "v0.11.0") { + got = append(got, m.ID+"="+m.From+"@"+m.Path) + } + if want := "itd-1=next@p/itd-1.md,itd-2=v0.11.0@p/itd-2.md,itd-3=v0.10.2@p/itd-3.md"; strings.Join(got, ",") != want { + t.Errorf("MissedTargets = %v, want %s", got, want) + } + // A breaking cut that skips a minor passes the minor it skipped. + got = nil + for _, m := range MissedTargets(targets, "v1.0.0") { + got = append(got, m.ID) + } + if want := "itd-1,itd-2,itd-3,itd-4,itd-5"; strings.Join(got, ",") != want { + t.Errorf("MissedTargets at v1.0.0 = %v, want %s", got, want) + } + // No derived version, no move: a refused cut carries none. + if m := MissedTargets(targets, ""); len(m) != 0 { + t.Errorf("a cut with no version moves nothing: %v", m) + } +} diff --git a/internal/core/lint/reading_links_test.go b/internal/core/lint/reading_links_test.go new file mode 100644 index 000000000..9ec13ffe2 --- /dev/null +++ b/internal/core/lint/reading_links_test.go @@ -0,0 +1,42 @@ +package lint + +import ( + "path/filepath" + "testing" +) + +// readingLinkRecord is a well-formed detection reading record with the extra +// frontmatter lines given. +func readingLinkRecord(id, extra string) string { + return "---\nschema_version: 1\nid: " + id + "\nrun: rdg-1\nmanifest: sha256:beef\nposition: detection\n" + + "regime: registrative\npattern: a stated constraint\ntension: t\nconstraint_in_play: c\nwhy_a_tension: w\n" + + extra + "---\n\n" +} + +// A reading record carries the filing-time match's typed links (ruling DQ2b, +// adr-2609300821558671). The committed-tree gate accepts them, and resolves +// each id it names: a link to a reading item the ledger holds is clean, and a +// link to one it does not is a finding, as it is on an issue. +func TestReadingRecordTypedLinksAreAcceptedAndResolved(t *testing.T) { + root := t.TempDir() + writeFile(t, root, "rec/.keep", "") + writeFile(t, root, "work/issues/readings/rdg-1/rdi-2.md", readingLinkRecord("rdi-2", "")) + writeFile(t, root, "work/issues/readings/rdg-1/rdi-3.md", readingLinkRecord("rdi-3", "duplicates: [rdi-2]\nrefines: [rdi-2]\n")) + + fs, err := Lint(readingSchemaConfig(), root) + if err != nil { + t.Fatal(err) + } + if n := countRule(fs, ruleRecordSchema); n != 0 { + t.Fatalf("a reading record linking an item the ledger holds must be clean, got %d finding(s): %+v", n, fs) + } + + writeFile(t, root, "work/issues/readings/rdg-1/rdi-4.md", readingLinkRecord("rdi-4", "duplicates: [rdi-9]\n")) + fs, err = Lint(readingSchemaConfig(), root) + if err != nil { + t.Fatal(err) + } + if !findingWith(fs, filepath.Join("work", "issues", "readings", "rdg-1", "rdi-4.md"), ruleRecordSchema, "rdi-9") { + t.Fatalf("a link to a reading item the ledger does not hold must be reported: %+v", fs) + } +} diff --git a/internal/core/lint/schema.go b/internal/core/lint/schema.go index 472956dde..58bfabe13 100644 --- a/internal/core/lint/schema.go +++ b/internal/core/lint/schema.go @@ -49,8 +49,10 @@ var ( // and bare spellings of one id are the same handle. The alternation covers // every store the rule INDEXES — a prefix indexed but not matched here reads as // "no handle at all", which turns a well-formed link into a false blocker and - // leaves its reverse direction unchecked. - recordHandleRe = regexp.MustCompile(`(?i)\b(adr|itd|iss|spc)-(\d+)\b`) + // leaves its reverse direction unchecked. `rdi` is here because a reading + // record's `duplicates:` and `refines:` may name an earlier reading item + // (ruling DQ2b, adr-2609300821558671), and that link must resolve too. + recordHandleRe = regexp.MustCompile(`(?i)\b(adr|itd|iss|spc|rdi)-(\d+)\b`) // The same handle, anchored: a whole frontmatter id value and nothing else. recordHandleFullRe = regexp.MustCompile(`(?i)^(adr|itd|iss|spc)-(\d+)$`) // A frontmatter id of ANY store, parsed by shape rather than against the diff --git a/internal/core/oracle/config.go b/internal/core/oracle/config.go index 728cb830c..ae22d563b 100644 --- a/internal/core/oracle/config.go +++ b/internal/core/oracle/config.go @@ -289,9 +289,11 @@ func denial(e DenyEntry) string { } // readRoutes reads one route family (roles or judgement types) from the repo -// and machine layers, the higher layer winning per name. A winning route from -// any layer but the machine's that names a provider holding a key is refused -// (keyed): only the person's own machine may point a route at their key. +// and machine layers, the higher layer winning per name. A route from any layer +// but the machine's that names a provider holding a key is skipped with a +// diagnostic (skipKeyedRoute), and the machine's own route to the name, if it +// has one, wins in its place: only the person's own machine may point a route +// at their key. func (c *APIConfig) readRoutes(s *layered.Stack, key string, into map[string]Target) error { names := map[string]bool{} for _, l := range []layered.Layer{layered.Repo, layered.Machine} { @@ -320,6 +322,12 @@ func (c *APIConfig) readRoutes(s *layered.Stack, key string, into map[string]Tar if err != nil { return fmt.Errorf("oracle adapter: %w", err) } + // A repository's route to a provider that holds a key is skipped, and + // the next layer's route to the name, the machine's own, applies in its + // place (ruling CD2 of 2026-09-29). + for len(found) > 0 && c.skipKeyedRoute(key, name, found[0]) { + found = found[1:] + } if len(found) == 0 { continue } @@ -343,12 +351,6 @@ func (c *APIConfig) readRoutes(s *layered.Stack, key string, into map[string]Tar "it runs on the host, as it would with no provider configured", where, layered.BoundKey(provider))) continue } - if p := c.providers[provider]; win.Layer != layered.Machine && keyed(p) { - return fmt.Errorf("oracle adapter: %s points at %s, a provider that holds a key (its block in %s names the credential %s); "+ - "only a route set on this machine may spend that key, so a repository's route to it is refused before any call: "+ - "set %s.%s in %s and remove it from %s, or point it at a provider whose block names no key", - where, layered.BoundKey(text), p.Origin, p.Key, key, name, layered.Config.MachineOrigin(), win.Origin) - } if err := c.Admit(provider, model); err != nil { return fmt.Errorf("oracle adapter: %s points at %s, %w", where, layered.BoundKey(text), err) } @@ -357,6 +359,42 @@ func (c *APIConfig) readRoutes(s *layered.Stack, key string, into map[string]Tar return nil } +// skipKeyedRoute reports whether one layer's route to name is a route from any +// layer but the machine's to a configured provider that holds a key, and says +// so in a diagnostic when it is. Only a route the person set up on their own +// machine may spend their paid key (ruling AA(b) of 2026-09-29), and such a +// route is skipped rather than refusing the whole configuration (ruling CD2 of +// the same day), so every other route and every command that reads the +// configuration keeps working. It is judged before the model is checked, so a +// keyed provider's list is never consulted on a repository's behalf; a route +// the denylist matches is left to the refusal below, which no layer softens, +// and a route this reader cannot parse is too. +func (c *APIConfig) skipKeyedRoute(key, name string, fd layered.Found) bool { + if fd.Layer == layered.Machine { + return false + } + text, err := layered.Decode[string](fd.Raw) + if err != nil { + return false + } + provider, model, ok := strings.Cut(text, "/") + if !ok || provider == "" || model == "" { + return false + } + p, configured := c.providers[provider] + if !configured || !keyed(p) { + return false + } + if _, denied := Denied(c.denylist, model); denied { + return false + } + c.Diagnostics = append(c.Diagnostics, fmt.Sprintf("oracle adapter: %s (%s layer): %s.%s points at %s, a provider that holds a key "+ + "(its block in %s names the credential %s); only a route set on this machine may spend that key, so this route is skipped "+ + "and the rest of the configuration applies: set %s.%s in %s and remove it from %s, or point it at a provider whose block names no key", + fd.Origin, fd.Layer, key, name, layered.BoundKey(text), p.Origin, p.Key, key, name, layered.Config.MachineOrigin(), fd.Origin)) + return true +} + // keyed reports whether p holds a key: whether its block names a credential. // It is judged from the block alone and never from the credential store, so no // secret is read to answer it, and a key stored or removed later cannot change diff --git a/internal/core/oracle/config_test.go b/internal/core/oracle/config_test.go index f8e1c089f..aed23e1c4 100644 --- a/internal/core/oracle/config_test.go +++ b/internal/core/oracle/config_test.go @@ -125,8 +125,8 @@ func TestAnUnlistedModelIsRefusedWhenTheConfigurationIsRead(t *testing.T) { } } // A route in the repository is held to the machine's list the same way. The - // provider is keyless: a repository's route to a keyed one is refused before - // its list is consulted (TestARepositoryRouteToAKeyedProviderIsRefused). + // provider is keyless: a repository's route to a keyed one is skipped before + // its list is consulted (TestARepositoryRouteToAKeyedProviderIsSkipped). f := newFx(t) f.machineConfig(`{"oracle":{"api":{` + localBlock + `}}}`) f.repoConfig(`{"oracle":{"roles":{"scribe":"local/openai/gpt-5"}}}`) @@ -139,39 +139,102 @@ func TestAnUnlistedModelIsRefusedWhenTheConfigurationIsRead(t *testing.T) { // block naming no credential. const localBlock = `"local":{"base_url":"http://localhost:11434/v1","models":["qwen/qwen3-8b"]}` -// TestARepositoryRouteToAKeyedProviderIsRefused is the product thinker's -// ruling AA(b) of 2026-09-29: only a route the person set up on their own -// machine may spend their paid key, so a role or a judgement type the -// repository's configuration points at a provider that holds a key is refused -// when the configuration is read, naming the route, the provider and the -// machine's file as where the route is set. A provider holds a key when its -// block names one; the credential store is never consulted, so no secret is -// read to decide it. A machine route to the same name does not rescue the -// repository's: the repository's is the one that would win, so it is refused. -func TestARepositoryRouteToAKeyedProviderIsRefused(t *testing.T) { - for name, tc := range map[string]struct{ repo, machine, setting string }{ - "role": {repo: `"roles":{"scribe":"openrouter/typesafe/jev-1.13"}`, setting: "oracle.roles.scribe"}, +// TestARepositoryRouteToAKeyedProviderIsSkipped is the product thinker's +// ruling AA(b) of 2026-09-29 as ruling CD2 of the same day shapes it: only a +// route the person set up on their own machine may spend their paid key, so a +// role or a judgement type the repository's configuration points at a provider +// that holds a key never reaches it. The route is skipped, with one diagnostic +// naming the route, the provider and the machine's file as where the route is +// set, and the rest of the configuration loads: every other route keeps +// working, and a machine route to the same name is the one that applies. A +// provider holds a key when its block names one; the credential store is never +// consulted, so no secret is read to decide it. +func TestARepositoryRouteToAKeyedProviderIsSkipped(t *testing.T) { + for name, tc := range map[string]struct { + repo, machine, setting string + family, route string + machineModel string + }{ + "role": {repo: `"roles":{"scribe":"openrouter/typesafe/jev-1.13"}`, setting: "oracle.roles.scribe", + family: rolesKey, route: "scribe"}, "judgement type": {repo: `"judgements":{"duplicate-match":"openrouter/typesafe/jev-latest"}`, - setting: "oracle.judgements.duplicate-match"}, + setting: "oracle.judgements.duplicate-match", family: judgementsKey, route: "duplicate-match"}, "role over a machine route": {repo: `"roles":{"scribe":"openrouter/typesafe/jev-latest"}`, - machine: `,"roles":{"scribe":"openrouter/typesafe/jev-1.13"}`, setting: "oracle.roles.scribe"}, - "unlisted model": {repo: `"roles":{"scribe":"openrouter/openai/gpt-5"}`, setting: "oracle.roles.scribe"}, + machine: `,"roles":{"scribe":"openrouter/typesafe/jev-1.13"}`, setting: "oracle.roles.scribe", + family: rolesKey, route: "scribe", machineModel: "typesafe/jev-1.13"}, + "unlisted model": {repo: `"roles":{"scribe":"openrouter/openai/gpt-5"}`, setting: "oracle.roles.scribe", + family: rolesKey, route: "scribe"}, } { t.Run(name, func(t *testing.T) { f := newFx(t) - f.machineConfig(`{"oracle":{"api":{` + openrouterBlock + `}` + tc.machine + `}}`) - f.repoConfig(`{"oracle":{` + tc.repo + `}}`) - err := f.loadAPIErr() - for _, want := range []string{".abcd/config.json (repo layer)", tc.setting, "openrouter", "holds a key", + f.machineConfig(`{"oracle":{"api":{` + openrouterBlock + `,` + localBlock + `}` + tc.machine + `}}`) + // A keyless route beside the refused one: the skip costs nothing else. + other := `"roles":{"scribe":"local/qwen/qwen3-8b"}` + if tc.family == rolesKey { + other = `"judgements":{"duplicate-match":"local/qwen/qwen3-8b"}` + } + f.repoConfig(`{"oracle":{` + tc.repo + `,` + other + `}}`) + c, err := LoadAPI(f.roots) + if err != nil { + t.Fatalf("LoadAPI refused the whole configuration over one repository route: %v", err) + } + var hits []string + for _, d := range c.Diagnostics { + if strings.Contains(d, "holds a key") { + hits = append(hits, d) + } + } + if len(hits) != 1 { + t.Fatalf("diagnostics %q, want exactly one naming the skipped keyed route", c.Diagnostics) + } + for _, want := range []string{".abcd/config.json (repo layer)", tc.setting, "openrouter", "holds a key", "skipped", "set " + tc.setting + " in ~/.abcd/config.json and remove it from .abcd/config.json,"} { - if !strings.Contains(err.Error(), want) { - t.Errorf("refusal %q does not name %q", err, want) + if !strings.Contains(hits[0], want) { + t.Errorf("diagnostic %q does not name %q", hits[0], want) + } + } + var got Target + var ok bool + if tc.family == rolesKey { + got, ok = c.Role(tc.route) + if tgt, on := c.Judgement("duplicate-match"); !on || tgt.Provider != "local" { + t.Errorf("the other route = %+v, %v; want it loaded", tgt, on) + } + } else { + got, ok = c.Judgement(tc.route) + if tgt, on := c.Role("scribe"); !on || tgt.Provider != "local" { + t.Errorf("the other route = %+v, %v; want it loaded", tgt, on) + } + } + switch { + case tc.machineModel != "": + if !ok || got.Model != tc.machineModel || got.Origin != "~/.abcd/config.json" { + t.Errorf("%s = %+v, %v; want the machine's own route", tc.route, got, ok) } + case ok: + t.Errorf("%s = %+v; a repository route to a keyed provider must never load", tc.route, got) } }) } } +// TestADenylistedKeyedRepositoryRouteIsStillRefused: skipping a repository's +// route to a keyed provider never softens the denylist. A route the denylist +// matches refuses the configuration from any layer, keyed provider or not. +// No denylist is bundled (adr-2609300107513982), so the entry is the one the +// person's configuration writes. +func TestADenylistedKeyedRepositoryRouteIsStillRefused(t *testing.T) { + f := newFx(t) + f.machineConfig(`{"oracle":{"denylist":["anthropic/*"],"api":{` + openrouterBlock + `}}}`) + f.repoConfig(`{"oracle":{"roles":{"scribe":"openrouter/anthropic/claude-opus-4"}}}`) + err := f.loadAPIErr() + for _, want := range []string{"anthropic/*", "oracle.denylist", "oracle.roles.scribe"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("refusal %q does not name %q", err, want) + } + } +} + // TestAMachineRouteToAKeyedProviderIsAdmitted: the machine's own route to a // provider that holds a key is the person's, and loads as it always did. func TestAMachineRouteToAKeyedProviderIsAdmitted(t *testing.T) { diff --git a/internal/core/oracle/connect.go b/internal/core/oracle/connect.go index 2392a50c3..8681f9c46 100644 --- a/internal/core/oracle/connect.go +++ b/internal/core/oracle/connect.go @@ -78,6 +78,10 @@ type ConnectResult struct { Verified CallRecord `json:"verified"` // Wrote names each file written, in the tilde form. Wrote []string `json:"wrote"` + // Diagnostics are the configuration read's non-fatal reports (APIConfig's), + // for the front door to print on stderr; the JSON form omits them, so they + // are said once and never mixed into what a machine reader parses. + Diagnostics []string `json:"-"` } // verifyBrief is the verification call's brief: one short exchange, judged @@ -112,7 +116,7 @@ func Connect(ctx context.Context, req ConnectRequest) (ConnectResult, error) { opts = append(opts, openaiapi.WithTimeout(req.Timeout)) } res := ConnectResult{Provider: req.Provider, BaseURL: req.BaseURL, Models: append([]string(nil), req.Models...), - KeyHome: req.Home} + KeyHome: req.Home, Diagnostics: append([]string(nil), cfg.Diagnostics...)} svc := providerService(Provider{Name: req.Provider, BaseURL: req.BaseURL, Key: req.KeyName, Models: req.Models}, cfg.denylist, &res.Verified, opts...) block := map[string]any{"base_url": req.BaseURL, "models": req.Models} @@ -354,16 +358,17 @@ func providerService(p Provider, denylist []DenyEntry, rec *CallRecord, opts ... // CredentialService is the walkthrough's service for the credential name, when // a configured provider names it as its key: the walkthrough then verifies a // key with that provider's own call. A name no provider names is not the -// adapter's. -func CredentialService(roots layered.Roots, name string) (credential.Service, bool, error) { +// adapter's. The configuration read's diagnostics come back beside it, for the +// front door to print on stderr, whether or not a provider names the name. +func CredentialService(roots layered.Roots, name string) (credential.Service, bool, []string, error) { cfg, err := LoadAPI(roots) if err != nil { - return credential.Service{}, false, err + return credential.Service{}, false, nil, err } for _, p := range cfg.Providers() { if p.Key == name && len(p.Models) > 0 { - return providerService(p, cfg.denylist, nil), true, nil + return providerService(p, cfg.denylist, nil), true, cfg.Diagnostics, nil } } - return credential.Service{}, false, nil + return credential.Service{}, false, cfg.Diagnostics, nil } diff --git a/internal/core/oracle/connect_test.go b/internal/core/oracle/connect_test.go index 8ab46bbc0..e39636381 100644 --- a/internal/core/oracle/connect_test.go +++ b/internal/core/oracle/connect_test.go @@ -310,6 +310,32 @@ func TestConnectToALocalServerNeedsNoKey(t *testing.T) { } } +// TestConnectCarriesTheConfigurationReadsDiagnostics: the setup reads the +// configuration in force before it writes, and a route that read skipped (a +// repository's route to a provider that holds a key, ruling CD2 of 2026-09-29) +// comes back on the result for the front door to say, never dropped. The JSON +// form omits it, so a front door says it once, on stderr. +func TestConnectCarriesTheConfigurationReadsDiagnostics(t *testing.T) { + p := newProvFake(t, 200, chat("local-model", "ok")) + f := newFx(t) + f.machineConfig(`{"oracle":{"api":{` + openrouterBlock + `}}}`) + f.repoConfig(`{"oracle":{"roles":{"scribe":"openrouter/typesafe/jev-1.13"}}}`) + req := connectReq(f, p.base()) + req.Provider, req.Home, req.Key = "desk", KeyHomeNone, "" + res, err := Connect(context.Background(), req) + if err != nil { + t.Fatalf("Connect: %v", err) + } + if len(res.Diagnostics) != 1 || !strings.Contains(res.Diagnostics[0], "oracle.roles.scribe") || + !strings.Contains(res.Diagnostics[0], "holds a key") { + t.Fatalf("diagnostics = %q; want the skipped repository route named", res.Diagnostics) + } + enc, _ := json.Marshal(res) + if strings.Contains(string(enc), "holds a key") { + t.Fatalf("the JSON result carries the diagnostic, which the front door prints on stderr:\n%s", enc) + } +} + // TestConcurrentConnectsKeepEveryKeyAndBlock: two setups that overlap must // not lose each other's key or provider block while each reports it wrote // them. Every connect's key resolves and every block reads back. diff --git a/internal/core/reading/ingest.go b/internal/core/reading/ingest.go index f5a73b2f3..2dfb1db3b 100644 --- a/internal/core/reading/ingest.go +++ b/internal/core/reading/ingest.go @@ -51,6 +51,7 @@ import ( "github.com/intentdriven/abcd/internal/core/capture" "github.com/intentdriven/abcd/internal/core/issueschema" + "github.com/intentdriven/abcd/internal/core/record/match" "github.com/intentdriven/abcd/internal/core/recordid" "github.com/intentdriven/abcd/internal/fsutil" "github.com/intentdriven/abcd/internal/termsafe" @@ -383,15 +384,22 @@ type IngestRequest struct { // does not read OutputPath again, so what was routed and reported is what // is ingested. Nil means the ingest reads OutputPath itself. Output []byte + // Match, when non-nil, runs the filing-time match on every item stored + // (ruling DQ2b, adr-2609300821558671); capture.IngestReadingRequest.Match + // says what it compares and writes. nil stores the items unmatched. + Match *match.Config } // IngestResult is what an ingest did. type IngestResult struct { - RunID string `json:"run_id"` - Position Position `json:"position"` - Regime string `json:"regime"` - Records []capture.ReadingRecordRef `json:"records"` - RefusedItems []ItemRefusal `json:"refused_items,omitempty"` + RunID string `json:"run_id"` + Position Position `json:"position"` + Regime string `json:"regime"` + Records []capture.ReadingRecordRef `json:"records"` + // Matches is the filing-time match's outcome per stored record, when the + // request asked for one: the likely repeats the ingest linked and listed. + Matches []capture.ReadingMatch `json:"matches,omitempty"` + RefusedItems []ItemRefusal `json:"refused_items,omitempty"` // RefusedCount is how many items were refused in total. RefusedItems is // capped — the item count is payload-chosen — so the two differ when a run // refused more than the cap, and the count is what nothing truncates. @@ -691,7 +699,7 @@ func ingestUnderLock(root *os.Root, repoRoot string, req IngestRequest, res *Ing return err } - return write(root, repoRoot, res, out, manifest, def, free, items) + return write(root, repoRoot, res, out, manifest, def, free, items, req.Match) } // leftPending is the orphans found minus the stages that were cleared: what a @@ -864,7 +872,7 @@ func resolveParkedManifest(root *os.Root, repoRoot string, out Output) (Manifest // write is the staged-write protocol. Nothing durable exists for the run until // step 1 has already validated everything, and the run metadata is written last. func write(root *os.Root, repoRoot string, res *IngestResult, out Output, m Manifest, def Definition, - free payloadField, items []capture.ReadingItem) error { + free payloadField, items []capture.ReadingItem, mc *match.Config) error { stageRel := IngestStageDir + "/" + out.RunID marker := stageMarker{Type: StageType, RunID: out.RunID, Records: []string{}} if err := writeJSONIn(root, stageRel+"/"+stageFileName, marker); err != nil { @@ -881,8 +889,10 @@ func write(root *os.Root, repoRoot string, res *IngestResult, out Output, m Mani Position: string(def.Position), Regime: def.Regime, Items: items, + Match: mc, }) res.Records = written.Records + res.Matches = written.Matches res.Redacted = written.Redacted noteDegraded(res, written.Degraded) if err != nil { diff --git a/internal/core/release/ingest.go b/internal/core/release/ingest.go index a48f879e2..1550aeaf8 100644 --- a/internal/core/release/ingest.go +++ b/internal/core/release/ingest.go @@ -42,6 +42,8 @@ import ( "github.com/intentdriven/abcd/internal/adapter/scanner" "github.com/intentdriven/abcd/internal/core/changelog" + "github.com/intentdriven/abcd/internal/core/intent" + "github.com/intentdriven/abcd/internal/core/launch" "github.com/intentdriven/abcd/internal/core/lint" "github.com/intentdriven/abcd/internal/core/surface" "github.com/intentdriven/abcd/internal/core/update" @@ -291,6 +293,11 @@ type IngestResult struct { // Page reports the release page: written (and what moved to the archive), or // why no page was written. Page PageResult `json:"page"` + // Moved lists every targeted intent the cut passed without shipping it, + // each rewritten to `next` in the same change unless it already named + // `next` (itd-2609212103572513 criterion 3, ruling BS1 of 2026-09-29), and + // named in the section's move note. + Moved []launch.TargetMove `json:"moved_targets,omitempty"` // Undo reverses the cut's writes. The ship verb applies it when a step after // the ingest refuses, so a refused ship leaves nothing behind. Undo UndoPlan `json:"-"` @@ -376,7 +383,10 @@ func ingest(root string, current surface.Snapshot, raw []byte, at time.Time, ops // verdict is returned: a fault here is a stop, and recomposing against it // would loop for nothing. heading := datedHeading(cut.NextTag, at) - section := renderSection(heading, entries) + // Each target this cut passes becomes `next` in the same change, and the + // section names the move (itd-2609212103572513 criterion 3, ruling BS1). + moves := launch.MissedTargets(cut.Targets, cut.NextTag) + section := renderSection(heading, entries, changelog.TargetMoveNote(moves)) content, before, err := insertSection(root, section) if err != nil { return res, err @@ -421,7 +431,23 @@ func ingest(root string, current surface.Snapshot, raw []byte, at time.Time, ops if hasPage { plan.page = []byte(pageText) } - undo, err := execute(ops, root, plan) + var undo UndoPlan + if len(moves) == 0 { + undo, err = execute(ops, root, plan) + } else { + // The records are read and rewritten under the intent store's lock, as + // every intent write is, so a target edited since the cut read it is + // refused rather than overwritten. + err = intent.WithMintLock(root, func() error { + rewrites, err := intent.PlanTargetMoves(root, moves) + if err != nil { + return err + } + plan.records = rewrites + undo, err = execute(ops, root, plan) + return err + }) + } if err != nil { return res, err } @@ -431,6 +457,7 @@ func ingest(root string, current surface.Snapshot, raw []byte, at time.Time, ops res.Heading = heading res.Lines = len(entries) res.Cited = required + res.Moved = moves res.Undo = undo if hasPage { res.Page = PageResult{ @@ -712,8 +739,14 @@ func datedHeading(nextTag string, at time.Time) string { // The notice sits directly under the heading, before any section, once: the // function renders exactly one cut, and each cut is its own dated section, so // idempotence across cuts holds by construction rather than by a scan. -func renderSection(heading string, entries []ChangelogEntry) []string { +func renderSection(heading string, entries []ChangelogEntry, moveNote string) []string { lines := []string{heading, "", sectionNotice, ""} + // The move note sits under the notice, ahead of every change-type heading: + // it names intents this release did not ship, so it is no entry of any + // section (changelog.TargetMoveNote). + if moveNote != "" { + lines = append(lines, moveNote, "") + } for _, section := range sectionOrder { var body []string for _, e := range entries { diff --git a/internal/core/release/targetmove_test.go b/internal/core/release/targetmove_test.go new file mode 100644 index 000000000..b3688e484 --- /dev/null +++ b/internal/core/release/targetmove_test.go @@ -0,0 +1,125 @@ +package release + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/changelog" + "github.com/intentdriven/abcd/internal/gittest" +) + +// targetedShippable is shippableRepo (a ready cut to v0.4.1) with three +// planned intents naming a release: one at the release being cut, one at +// `next`, and one at a later release the cut does not reach. +func targetedShippable(t *testing.T) *gittest.Repo { + t.Helper() + r := shippableRepo(t) + r.Write(plannedDir+"itd-91-due.md", "---\nid: itd-91\nimpact: additive\ntarget_release: v0.4.1\n---\n# Due\n") + r.Write(plannedDir+"itd-92-next.md", "---\nid: itd-92\nimpact: additive\ntarget_release: next\n---\n# Next\n") + r.Write(plannedDir+"itd-93-later.md", "---\nid: itd-93\nimpact: additive\ntarget_release: v0.5.0\n---\n# Later\n") + r.Commit("three planned intents name a release") + return r +} + +func readRel(t *testing.T, root, rel string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(rel))) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +// TestIngestMovesAMissedTargetToNext is itd-2609212103572513 criterion 3 as +// the product thinker ruled it on 2026-09-29 (BS1): given the cut is written, +// when the changelog is rolled, then each unshipped target the cut reaches +// becomes `next` (whatever the following release is numbered) in the same +// change, and the changelog names the move. A target past the cut is left. +func TestIngestMovesAMissedTargetToNext(t *testing.T) { + r := targetedShippable(t) + root := r.Root() + ops := newRecordingOps(root) + res, err := ingest(root, liveSurface(), marshalPayload(t, "v0.4.1", goodEntries()), cutAt, ops) + if err != nil { + t.Fatalf("ingest: %v", err) + } + if !res.Written { + t.Fatalf("nothing was written; refusals = %v", refusalKinds(res.Cut)) + } + + if got := readRel(t, root, plannedDir+"itd-91-due.md"); got != "---\nid: itd-91\nimpact: additive\ntarget_release: next\n---\n# Due\n" { + t.Errorf("the missed version target must become `next`:\n%s", got) + } + if got := readRel(t, root, plannedDir+"itd-92-next.md"); !strings.Contains(got, "target_release: next\n") { + t.Errorf("a `next` target stays `next`:\n%s", got) + } + if got := readRel(t, root, plannedDir+"itd-93-later.md"); !strings.Contains(got, "target_release: v0.5.0\n") { + t.Errorf("a target past the cut is not moved:\n%s", got) + } + // The record rewrite is one of the cut's writes, ahead of the heading the + // tagging workflow reads. + want := []string{"replace " + plannedDir + "itd-91-due.md", "replace " + PageFile, "replace " + changelogFile} + if strings.Join(ops.log, "|") != strings.Join(want, "|") { + t.Errorf("writes = %v, want %v", ops.log, want) + } + + var moved []string + for _, m := range res.Moved { + moved = append(moved, m.ID+"="+m.From) + } + if strings.Join(moved, ",") != "itd-91=v0.4.1,itd-92=next" { + t.Errorf("Moved = %v", moved) + } + + note := "Targeted and not shipped in this release, so each targets the next release (`next`): " + + "itd-91 (targeted v0.4.1), itd-92 (targeted `next`)." + log := readChangelog(t, root) + if !strings.Contains(log, "## [0.4.1] - 2026-07-21\n\n"+sectionNotice+"\n\n"+note+"\n\n### Added\n") { + t.Errorf("the dated section must name the move under its notice, ahead of every change-type heading:\n%s", log) + } + if !changelog.IsTargetMoveNote(note) { + t.Error("the note written is not the one the readers pass over") + } +} + +// A cut that fails after the record rewrite puts the record back with the +// rest of the tree, and a later refusal's undo (the ship verb's payload +// render) does the same. +func TestTheMoveRollsBackWithTheCut(t *testing.T) { + r := targetedShippable(t) + root := r.Root() + before := treeDigest(t, root) + if _, err := ingest(root, liveSurface(), marshalPayload(t, "v0.4.1", goodEntries()), cutAt, newRecordingOps(root, "replace "+changelogFile)); err == nil { + t.Fatal("ingest succeeded through an injected failure") + } + if after := treeDigest(t, root); after != before { + t.Error("a failed cut left the moved target behind") + } + + res, err := Ingest(root, liveSurface(), marshalPayload(t, "v0.4.1", goodEntries()), cutAt) + if err != nil || !res.Written { + t.Fatalf("Ingest: %v (written=%v)", err, res.Written) + } + if failures := res.Undo.Apply(root); len(failures) > 0 { + t.Fatalf("undo: %v", failures) + } + if after := treeDigest(t, root); after != before { + t.Error("the cut's undo left the moved target behind") + } +} + +// A cut with no target reached writes no note and touches no record. +func TestIngestWithNoMissedTargetWritesNoNote(t *testing.T) { + r := shippableRepo(t) + r.Write(plannedDir+"itd-93-later.md", "---\nid: itd-93\nimpact: additive\ntarget_release: v0.5.0\n---\n# Later\n") + r.Commit("a target past the cut") + res, err := Ingest(r.Root(), liveSurface(), marshalPayload(t, "v0.4.1", goodEntries()), cutAt) + if err != nil || !res.Written { + t.Fatalf("Ingest: %v", err) + } + if len(res.Moved) != 0 || strings.Contains(readChangelog(t, r.Root()), "Targeted and not shipped") { + t.Errorf("no target reached, no move: %+v", res.Moved) + } +} diff --git a/internal/core/release/write.go b/internal/core/release/write.go index 642313bb3..3bf9c86a2 100644 --- a/internal/core/release/write.go +++ b/internal/core/release/write.go @@ -2,7 +2,9 @@ package release // write.go — the cut's writes, their order, and their undo. // -// A feature cut makes up to three writes, and they land together or not at all: +// A feature cut makes up to three writes, and they land together or not at all, +// after the rewrite of every intent record whose missed target the cut moves to +// `next` (itd-2609212103572513 criterion 3): // // 1. the ARCHIVE: the outgoing RELEASE.md's bytes, created under // .abcd/development/releases/<its version>.md, never overwriting; @@ -23,6 +25,7 @@ import ( "path/filepath" "strings" + "github.com/intentdriven/abcd/internal/core/intent" "github.com/intentdriven/abcd/internal/fsutil" ) @@ -62,6 +65,9 @@ func (o osOps) remove(rel string) error { // The ship verb applies it when a step AFTER the ingest refuses (the payload // render), so a refused ship leaves no release record behind. type UndoPlan struct { + // records are the intent records the cut rewrote (a missed target moved to + // `next`), each with its bytes before the write. + records []intent.TargetRewrite changelogBefore []byte pageBefore []byte pageExisted bool @@ -114,11 +120,20 @@ func (u UndoPlan) apply(ops fileOps, root string) []string { _ = os.Remove(filepath.Join(root, filepath.FromSlash(path.Dir(u.archived)))) } } + for i := len(u.records) - 1; i >= 0; i-- { + rec := u.records[i] + if err := ops.replace(rec.Path, rec.Before); err != nil { + failures = append(failures, rec.Path+": "+err.Error()) + } + } return failures } // cutPlan is the validated set of writes, built before any of them runs. type cutPlan struct { + // records are the intent records rewritten as the cut moves a missed + // target to `next`, written first. + records []intent.TargetRewrite changelog []byte page []byte // nil when no page is written undo UndoPlan @@ -164,6 +179,7 @@ func planPage(root string, plan *cutPlan) error { // returns the undo that reverses a completed plan. func execute(ops fileOps, root string, plan cutPlan) (UndoPlan, error) { done := plan.undo + done.records = nil done.archived = "" done.pageWritten = false done.changelogDone = false @@ -176,6 +192,12 @@ func execute(ops fileOps, root string, plan cutPlan) (UndoPlan, error) { return UndoPlan{}, fmt.Errorf("%s\n the steps already taken were rolled back; the tree is as it was", msg) } + for _, rec := range plan.records { + if err := ops.replace(rec.Path, rec.After); err != nil { + return fail(rec.Path, err) + } + done.records = append(done.records, rec) + } if plan.page != nil && plan.undo.archived != "" { if err := ops.createExclusive(plan.undo.archived, plan.undo.pageBefore); err != nil { // The create may have made the directory before failing. diff --git a/internal/core/report/inbox.go b/internal/core/report/inbox.go index 93bcfd626..28403053c 100644 --- a/internal/core/report/inbox.go +++ b/internal/core/report/inbox.go @@ -15,6 +15,7 @@ import ( "github.com/intentdriven/abcd/internal/core/capture" "github.com/intentdriven/abcd/internal/core/issueschema" + "github.com/intentdriven/abcd/internal/core/record/match" "github.com/intentdriven/abcd/internal/core/recordid" "github.com/intentdriven/abcd/internal/fsutil" "github.com/intentdriven/abcd/internal/gitutil" @@ -547,6 +548,10 @@ type Promoted struct { // Resumed says this call completed an earlier promotion that filed its // capture but did not finish moving the report: nothing new was filed. Resumed bool `json:"resumed,omitempty"` + // Match is the filing-time match's outcome (itd-2609212137116617) when + // the promotion asked for one: the links written onto the capture, the + // near misses, or why nothing was compared. + Match *match.Outcome `json:"match,omitempty"` } // Source is the capture source a promoted report is filed under. @@ -564,7 +569,12 @@ const Source = "managed-repo" // a move that fails leaves a report still waiting whose capture is already on // record. Promoting it again files nothing: it finishes the move and names the // capture the first attempt filed (Resumed). -func Promote(ledgerRoot, id string) (Promoted, error) { +// +// mc, when non-nil, runs capture's filing-time match (itd-2609212137116617) +// on the report's own title and prose, and the capture carries a typed link +// naming each likely double, exactly as a capture filed by hand does. nil +// files the capture unmatched. +func Promote(ledgerRoot, id string, mc *match.Config) (Promoted, error) { m := idRe.FindStringSubmatch(id) if m == nil { return Promoted{}, fmt.Errorf("%w: %q is not a report id (rpt- and sixteen digits)", ErrRefused, id) @@ -626,6 +636,7 @@ func Promote(ledgerRoot, id string) (Promoted, error) { return move(done.Capture) } req := captureRequest(ledgerRoot, id, *e.Report) + req.Match = mc res, err := capture.Capture(req) if err != nil { // Capture writes transactionally and sweeps its reservation on any @@ -633,7 +644,7 @@ func Promote(ledgerRoot, id string) (Promoted, error) { // promotion is refused (exit 2) whatever the ledger's reason. return fmt.Errorf("%w: the capture was refused, and the report still waits: %w", ErrRefused, err) } - out = Promoted{Report: id, Capture: res.ID, Path: res.Path, Redacted: res.Redacted, Degraded: res.Degraded} + out = Promoted{Report: id, Capture: res.ID, Path: res.Path, Redacted: res.Redacted, Degraded: res.Degraded, Match: res.Match} line, err := json.Marshal(promotion{Report: id, Capture: res.ID, Path: res.Path, At: now().UTC().Format(time.RFC3339)}) if err != nil { return err @@ -663,10 +674,12 @@ func captureRequest(ledgerRoot, id string, r Report) capture.CaptureRequest { rewrote = rewrote || n > 0 return out } + // The report's own words, which the filing-time match compares: the + // provenance lines below are the same on every promoted report, and + // matched on them two unrelated reports would link each other. + own := scrub(r.Title) + "\n\n" + scrub(r.Prose) var b strings.Builder - b.WriteString(scrub(r.Title)) - b.WriteString("\n\n") - b.WriteString(scrub(r.Prose)) + b.WriteString(own) b.WriteString("\n") if r.Remedy != "" { fmt.Fprintf(&b, "\nRemedy the reporter proposes: %s\n", scrub(r.Remedy)) @@ -698,6 +711,7 @@ func captureRequest(ledgerRoot, id string, r Report) capture.CaptureRequest { FoundDuring: fmt.Sprintf("abcd inbox report %s from %s (root commit %s)", id, GenericSender, r.SenderKey), FoundAt: foundAt, Remedy: issueschema.MachineRemedy, + MatchText: own, } } diff --git a/internal/core/report/inbox_test.go b/internal/core/report/inbox_test.go index e49834c0c..924db002f 100644 --- a/internal/core/report/inbox_test.go +++ b/internal/core/report/inbox_test.go @@ -10,6 +10,7 @@ import ( "time" "github.com/intentdriven/abcd/internal/core/capture" + "github.com/intentdriven/abcd/internal/core/drainrule" "github.com/intentdriven/abcd/internal/core/issueschema" "github.com/intentdriven/abcd/internal/core/lint" "github.com/intentdriven/abcd/internal/gittest" @@ -185,7 +186,7 @@ func TestUnknownVersionIsListedUnreadable(t *testing.T) { if tally, _ := Count(); tally.Reports != 1 { t.Errorf("Count = %+v, want the unreadable report counted", tally) } - if _, err := Promote(abcdCheckout(t).Root(), list[0].ID); !errors.Is(err, ErrRefused) || !strings.Contains(err.Error(), "unreadable") { + if _, err := Promote(abcdCheckout(t).Root(), list[0].ID, nil); !errors.Is(err, ErrRefused) || !strings.Contains(err.Error(), "unreadable") { t.Errorf("Promote(unreadable) = %v, want a refusal", err) } } @@ -223,7 +224,7 @@ func TestPromoteFingerprintsAndNeverNamesTheSender(t *testing.T) { t.Fatalf("a report filed itself before anyone acted:\n%s", st) } - p, err := Promote(ledger.Root(), f.ID) + p, err := Promote(ledger.Root(), f.ID, nil) if err != nil { t.Fatalf("Promote: %v", err) } @@ -270,7 +271,7 @@ func TestPromoteFingerprintsAndNeverNamesTheSender(t *testing.T) { if e.SenderName != name { t.Errorf("the inbox stopped naming the sender: %+v", e) } - if _, err := Promote(ledger.Root(), f.ID); !errors.Is(err, ErrRefused) { + if _, err := Promote(ledger.Root(), f.ID, nil); !errors.Is(err, ErrRefused) { t.Errorf("a second promote = %v, want a refusal", err) } } @@ -351,7 +352,7 @@ func TestPromoteRetryAfterAFailedMoveFilesOneCapture(t *testing.T) { if err := os.MkdirAll(filepath.Join(blocker, "x"), 0o700); err != nil { t.Fatal(err) } - if _, err := Promote(ledger.Root(), f.ID); err == nil { + if _, err := Promote(ledger.Root(), f.ID, nil); err == nil { t.Fatal("Promote succeeded with its destination occupied") } if err := os.RemoveAll(blocker); err != nil { @@ -366,7 +367,7 @@ func TestPromoteRetryAfterAFailedMoveFilesOneCapture(t *testing.T) { t.Fatalf("the failed promotion filed %d captures, want 1", len(first)) } - p, err := Promote(ledger.Root(), f.ID) + p, err := Promote(ledger.Root(), f.ID, nil) if err != nil { t.Fatalf("retry: %v", err) } @@ -379,7 +380,7 @@ func TestPromoteRetryAfterAFailedMoveFilesOneCapture(t *testing.T) { if e, err := Show(f.ID); err != nil || e.State != StatePromoted || e.PromotedTo != p.Capture { t.Errorf("Show after retry = %+v, %v", e, err) } - if _, err := Promote(ledger.Root(), f.ID); !errors.Is(err, ErrRefused) { + if _, err := Promote(ledger.Root(), f.ID, nil); !errors.Is(err, ErrRefused) { t.Errorf("a third promote = %v, want a refusal", err) } } @@ -409,7 +410,7 @@ func TestPromoteRefusesOutsideAbcdsOwnCheckout(t *testing.T) { if err != nil { t.Fatal(err) } - _, err = Promote(other.Root(), f.ID) + _, err = Promote(other.Root(), f.ID, nil) if !errors.Is(err, ErrRefused) { t.Fatalf("Promote(unrelated repository) = %v, want a refusal", err) } @@ -429,7 +430,7 @@ func TestPromoteRefusesOutsideAbcdsOwnCheckout(t *testing.T) { } abcd := abcdCheckout(t) - if p, err := Promote(abcd.Root(), f.ID); err != nil || !strings.HasPrefix(p.Capture, "iss-") { + if p, err := Promote(abcd.Root(), f.ID, nil); err != nil || !strings.HasPrefix(p.Capture, "iss-") { t.Fatalf("Promote(abcd's checkout) = %+v, %v", p, err) } } @@ -457,7 +458,7 @@ func TestPromotedCaptureCitesNothingOfTheSenders(t *testing.T) { if err != nil { t.Fatal(err) } - p, err := Promote(ledger.Root(), f.ID) + p, err := Promote(ledger.Root(), f.ID, nil) if err != nil { t.Fatalf("Promote: %v", err) } @@ -502,7 +503,7 @@ func TestACaptureRefusalIsARefusal(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := Promote(ledger.Root(), f.ID); !errors.Is(err, ErrRefused) { + if _, err := Promote(ledger.Root(), f.ID, nil); !errors.Is(err, ErrRefused) { t.Fatalf("Promote(symlinked ledger) = %v, want a refusal", err) } if entries, _ := os.ReadDir(elsewhere); len(entries) != 0 { @@ -579,7 +580,7 @@ func TestAnInboxPathThatIsNotARealDirectoryIsARefusal(t *testing.T) { if _, err := Show(id); !errors.Is(err, ErrRefused) { t.Errorf("Show = %v, want a refusal", err) } - if _, err := Promote(ledger.Root(), id); !errors.Is(err, ErrRefused) { + if _, err := Promote(ledger.Root(), id, nil); !errors.Is(err, ErrRefused) { t.Errorf("Promote = %v, want a refusal", err) } if _, err := File(mustParse(t, filled(t)), Sender{Key: strings.Repeat("9", 40), Name: "linked"}); !errors.Is(err, ErrRefused) { @@ -801,7 +802,7 @@ func TestAPromotedReportIsIneligibleForADrain(t *testing.T) { if err != nil { t.Fatalf("File: %v", err) } - p, err := Promote(ledger.Root(), f.ID) + p, err := Promote(ledger.Root(), f.ID, nil) if err != nil { t.Fatalf("Promote: %v", err) } @@ -812,6 +813,17 @@ func TestAPromotedReportIsIneligibleForADrain(t *testing.T) { if !strings.Contains(string(body), "Remedy the reporter proposes: accept a leading digit") { t.Errorf("the body lacks the sender's remedy:\n%s", body) } + // The drain reads the checkout's own eligibility record (ruling BX2): the + // baseline, as the setup offer writes it. + adrs := filepath.Join(ledger.Root(), filepath.FromSlash(drainrule.ADRsRelDir)) + if err := os.MkdirAll(adrs, 0o755); err != nil { + t.Fatal(err) + } + rule := "---\nid: adr-2609300000000003\nslug: drain-rule\nstatus: accepted\ndate: 2026-09-30\n" + + drainrule.ProposalFrontmatter() + "---\n\n# ADR\n" + if err := os.WriteFile(filepath.Join(adrs, "2609300000000003-drain-rule.md"), []byte(rule), 0o644); err != nil { + t.Fatal(err) + } plan, err := capture.PlanDrain(capture.DrainPlanRequest{RepoRoot: ledger.Root()}) if err != nil { t.Fatalf("PlanDrain: %v", err) diff --git a/internal/core/report/promote_match_test.go b/internal/core/report/promote_match_test.go new file mode 100644 index 000000000..a972011f0 --- /dev/null +++ b/internal/core/report/promote_match_test.go @@ -0,0 +1,146 @@ +package report + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/intentdriven/abcd/internal/core/capture" + "github.com/intentdriven/abcd/internal/core/issueschema" + "github.com/intentdriven/abcd/internal/core/record/match" +) + +// promote_match_test.go covers the filing-time match on the promoted inbox +// report (itd-2609212137116617, iss-2609281911024185): a promoted report is +// matched against the ledger exactly as a capture is, on the report's own +// title and prose, never on the provenance lines every promoted report +// carries. + +const ( + pmFinding = "The capture ledger reader silently skips a record whose frontmatter carries " + + "a duplicated key, so the finding disappears from every listing without a warning." + pmDoubleTitle = "Ledger reader skips records with a duplicated frontmatter key" + pmDouble = "Capture ledger reader silently skips any record whose frontmatter carries a " + + "duplicated key: the finding disappears from every listing, and no warning is printed." + pmFiller1 = "The site builder renders a stale anchor for a heading renamed since the last build." + pmFiller2 = "The history store drops a transcript that exceeds its byte budget without saying so." +) + +// reportWith is the filled template with its title and prose replaced. +func reportWith(t *testing.T, title, prose string) string { + t.Helper() + s := filled(t) + s = strings.Replace(s, `title: "capture refuses a slug with a digit first"`, `title: "`+title+`"`, 1) + s = strings.Replace(s, "Running capture with a slug of 9lives was refused.", prose, 1) + return s +} + +func plantOpen(t *testing.T, root, text string) string { + t.Helper() + res, err := capture.Capture(capture.CaptureRequest{ + RepoRoot: root, Text: text, Severity: capture.SeverityMinor, Category: "bug", + Source: "user-observation", FoundDuring: "fixture", Remedy: issueschema.MachineRemedy, + }) + if err != nil { + t.Fatalf("plant: %v", err) + } + return res.ID +} + +func bundledMatch() *match.Config { c := match.Bundled(); return &c } + +// A promoted report that doubles an open record is written with capture's +// typed link naming it, and the promotion carries the match. +func TestPromoteLinksANearDuplicateOfAnOpenRecord(t *testing.T) { + sandbox(t, time.Date(2026, 9, 30, 9, 0, 0, 0, time.UTC)) + ledger := abcdCheckout(t) + held := plantOpen(t, ledger.Root(), pmFinding) + plantOpen(t, ledger.Root(), pmFiller1) + plantOpen(t, ledger.Root(), pmFiller2) + + f, err := File(mustParse(t, reportWith(t, pmDoubleTitle, pmDouble)), Sender{Key: strings.Repeat("d", 40), Name: "delta"}) + if err != nil { + t.Fatalf("File: %v", err) + } + p, err := Promote(ledger.Root(), f.ID, bundledMatch()) + if err != nil { + t.Fatalf("Promote: %v", err) + } + if p.Match == nil || len(p.Match.Matches) == 0 { + t.Fatalf("no match reported: %+v", p.Match) + } + m := p.Match.Matches[0] + if m.ID != held || !m.Linked || (m.Relation != match.Duplicates && m.Relation != match.Refines) { + t.Fatalf("match = %+v, want %s linked", m, held) + } + body, err := os.ReadFile(filepath.Join(ledger.Root(), filepath.FromSlash(p.Path))) + if err != nil { + t.Fatal(err) + } + if want := "\n" + string(m.Relation) + ": [" + held + "]\n"; !strings.Contains(string(body), want) { + t.Fatalf("the promoted capture carries no %q link:\n%s", strings.TrimSpace(want), body) + } +} + +// Two unrelated reports share only the provenance every promoted report +// carries (the sender's key, the inbox, the kind, the version, the surface, +// the evidence line). The second must not be matched to the first: the match +// reads the report's own title and prose. +func TestPromoteDoesNotMatchOnTheInboxBoilerplate(t *testing.T) { + sandbox(t, time.Date(2026, 9, 30, 10, 0, 0, 0, time.UTC)) + ledger := abcdCheckout(t) + sender := Sender{Key: strings.Repeat("e", 40), Name: "echo"} + + a, err := File(mustParse(t, reportWith(t, "Site anchors go stale", "Renamed headings keep their old anchor.")), sender) + if err != nil { + t.Fatalf("File a: %v", err) + } + pa, err := Promote(ledger.Root(), a.ID, bundledMatch()) + if err != nil { + t.Fatalf("Promote a: %v", err) + } + setClock(t, time.Date(2026, 9, 30, 10, 1, 0, 0, time.UTC)) + b, err := File(mustParse(t, reportWith(t, "Transcripts over budget vanish", "History drops oversized transcripts quietly.")), sender) + if err != nil { + t.Fatalf("File b: %v", err) + } + pb, err := Promote(ledger.Root(), b.ID, bundledMatch()) + if err != nil { + t.Fatalf("Promote b: %v", err) + } + if pb.Match == nil { + t.Fatal("no match outcome: the promotion was not matched at all") + } + for _, m := range pb.Match.Matches { + if m.ID == pa.Capture { + t.Fatalf("%s matched %s on the inbox boilerplate alone: %+v", pb.Capture, pa.Capture, m) + } + } + body, err := os.ReadFile(filepath.Join(ledger.Root(), filepath.FromSlash(pb.Path))) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(body), pa.Capture) { + t.Fatalf("the second capture names the first:\n%s", body) + } +} + +// A promotion without a match configuration files unmatched, as before. +func TestPromoteWithoutAMatchReportsNone(t *testing.T) { + sandbox(t, time.Date(2026, 9, 30, 11, 0, 0, 0, time.UTC)) + ledger := abcdCheckout(t) + plantOpen(t, ledger.Root(), pmFinding) + f, err := File(mustParse(t, reportWith(t, pmDoubleTitle, pmDouble)), Sender{Key: strings.Repeat("f", 40), Name: "foxtrot"}) + if err != nil { + t.Fatalf("File: %v", err) + } + p, err := Promote(ledger.Root(), f.ID, nil) + if err != nil { + t.Fatalf("Promote: %v", err) + } + if p.Match != nil { + t.Fatalf("match = %+v, want none", p.Match) + } +} diff --git a/internal/core/rules/rules.go b/internal/core/rules/rules.go index f897f2539..f1326ff16 100644 --- a/internal/core/rules/rules.go +++ b/internal/core/rules/rules.go @@ -237,10 +237,13 @@ func Load(repoRoot string) (RuleSet, error) { if err != nil { return RuleSet{}, err } + // SHELL is regenerated from the guard registry in force for this + // repository before any rules.json layer lands on it (ruling CK1). + base := withRepoShellDomain(Defaults(), repoRoot) if !haveUser && !haveRepo { - return Defaults(), nil + return base, nil } - merged := Defaults() + merged := base if haveUser { merged = mergeFrom(merged, user, SourceUser) // The user layer is validated on its own before the repo layer lands, diff --git a/internal/core/rules/shell.go b/internal/core/rules/shell.go index f7026d522..3ca5bb1c7 100644 --- a/internal/core/rules/shell.go +++ b/internal/core/rules/shell.go @@ -1,6 +1,10 @@ package rules -import "github.com/intentdriven/abcd/internal/core/guard" +import ( + "fmt" + + "github.com/intentdriven/abcd/internal/core/guard" +) // ShellDomain is the bundled domain that carries itd-103's teaching plane // (spc-16, "Two planes, one registry"; iss-151, ruling J10): the shell-hazard @@ -18,10 +22,14 @@ import "github.com/intentdriven/abcd/internal/core/guard" // *SHELL star command, dedup and provenance — and a guardrail domain to // noteWithheld. // -// It is built from the BUNDLED registry only. A repo's .abcd/guard.json -// changes what the guard refuses in that repo, and the two features keep -// independent switches (spc-16, "Config home"); a repo that wants its own -// entries taught says so in its rules.json, as for any bundled domain. +// The bundled defaults carry the domain generated from the bundled registry. +// Load regenerates it, by the same generator, from the registry the guard +// enforces in the repository — the bundled entries merged with the +// repository's own .abcd/guard.json (ruling CK1) — so a repository's own +// hazards are taught as the bundled ones are, each such lesson marked +// guard.RepoMark. The switches stay independent (spc-16, "Config home"): the +// guard file decides what is refused, and rules.json overrides, silences or +// kills the teaching of it like any bundled domain's. const ShellDomain = "SHELL" // shellAliases is the fixed half of the domain's recall: words that name @@ -33,11 +41,12 @@ const ShellDomain = "SHELL" // the domain alone. var shellAliases = []string{"bash", "command line", "force push", "shell", "zsh"} -// shellDomain generates the teaching-plane domain from reg. ok is false when -// the registry has no entries: a domain with no rules would render as a heading -// with nothing under it, the shape Validate refuses. -func shellDomain(reg guard.Registry) (Domain, bool) { - lessons := reg.Lessons() +// shellDomain generates the teaching-plane domain from reg, marking every +// lesson bundled does not teach word for word as the repository's. ok is false +// when the registry has no entries: a domain with no rules would render as a +// heading with nothing under it, the shape Validate refuses. +func shellDomain(reg, bundled guard.Registry) (Domain, bool) { + lessons := reg.LessonsOver(bundled) if len(lessons) == 0 { return Domain{}, false } @@ -57,7 +66,7 @@ func withShellDomain(rs RuleSet) RuleSet { if _, ok := rs.Domains[ShellDomain]; ok { panic("rules: bundled defaults declare " + ShellDomain + " by hand; it is generated from the guard registry") } - if d, ok := shellDomain(guard.Defaults()); ok { + if d, ok := shellDomain(guard.Defaults(), guard.Defaults()); ok { if rs.Domains == nil { rs.Domains = map[string]Domain{} } @@ -65,3 +74,32 @@ func withShellDomain(rs RuleSet) RuleSet { } return rs } + +// withRepoShellDomain regenerates the SHELL domain of the bundled set rs from +// the registry the guard enforces in repoRoot (ruling CK1): the bundled +// entries merged with the repository's .abcd/guard.json. It runs before any +// rules.json layer, so the regenerated domain is the base those layers +// override per field, exactly as they override the bundled one. +// +// A guard.json the guard refuses — unreadable, invalid, or an uncommitted +// edit that weakens it — is refused here too, loudly: the domain teaches the +// registry the guard falls back to (the bundled one, or HEAD's committed +// file), never the refused entries, and a note names the file and the reason +// on every load. Failing the whole rule set instead would take PII, COMMITTING +// and every other domain down with one broken guard file; skipping it silently +// would leave a repository believing its own hazard is taught. +func withRepoShellDomain(rs RuleSet, repoRoot string) RuleSet { + ld := guard.LoadRepo(repoRoot) + if ld.Err != nil { + rs.notes = append(rs.notes, fmt.Sprintf( + "rules: %s: the repository's own guard entries are refused and not taught (%v); %s teaches the registry the guard enforces in their place", + ShellDomain, ld.Err, ShellDomain)) + } + if ld.Posture == guard.LoadUnavailable { + return rs + } + if d, ok := shellDomain(ld.Registry, guard.Defaults()); ok { + rs.Domains[ShellDomain] = d + } + return rs +} diff --git a/internal/core/rules/shell_test.go b/internal/core/rules/shell_test.go index 583e17b01..5a47198d1 100644 --- a/internal/core/rules/shell_test.go +++ b/internal/core/rules/shell_test.go @@ -2,6 +2,8 @@ package rules import ( "encoding/json" + "os" + "path/filepath" "reflect" "strings" "testing" @@ -62,7 +64,7 @@ func TestShellDomainIsNotHandWritten(t *testing.T) { // removing one removes both. func TestShellDomainFollowsRegistryEdits(t *testing.T) { reg := guard.Defaults() - base, ok := shellDomain(reg) + base, ok := shellDomain(reg, reg) if !ok { t.Fatal("the bundled registry generated no domain") } @@ -75,7 +77,7 @@ func TestShellDomainFollowsRegistryEdits(t *testing.T) { Successor: "Delete the one file you mean with rm.", Why: "Shredding cannot be undone.", } - grown, _ := shellDomain(added) + grown, _ := shellDomain(added, added) if len(grown.Rules) != len(base.Rules)+1 { t.Fatalf("adding an entry gave %d rules, want %d", len(grown.Rules), len(base.Rules)+1) } @@ -88,7 +90,7 @@ func TestShellDomainFollowsRegistryEdits(t *testing.T) { removed := guard.Defaults() delete(removed.Entries, "git-clean") - shrunk, _ := shellDomain(removed) + shrunk, _ := shellDomain(removed, removed) if len(shrunk.Rules) != len(base.Rules)-1 { t.Fatalf("removing an entry gave %d rules, want %d", len(shrunk.Rules), len(base.Rules)-1) } @@ -102,7 +104,7 @@ func TestShellDomainFollowsRegistryEdits(t *testing.T) { } // An empty registry generates no domain at all, never a heading-only one. - if _, ok := shellDomain(guard.Registry{SchemaVersion: guard.SchemaVersion}); ok { + if _, ok := shellDomain(guard.Registry{SchemaVersion: guard.SchemaVersion}, guard.Defaults()); ok { t.Fatal("an empty registry generated a domain; it would render as a heading with nothing under it") } } @@ -231,3 +233,108 @@ func mustLoad(t *testing.T, dir string) RuleSet { } return rs } + +// repoGuardEntry is a repository's own hazard, declared in its +// .abcd/guard.json, and repoGuardLesson the one rule it teaches: the text is +// pinned here so a change to the generator's wording is a change someone saw. +const ( + repoGuardEntry = `{"schema_version":1,"entries":{"deploy-prod":{ + "tier":"blocker", + "pattern":{"command":"make","subcommand":"deploy"}, + "why":"It deploys to production from a laptop.", + "successor":"Open a release pull request; CI deploys it."}}}` + repoGuardLesson = "Refused by the guard (deploy-prod) (repo): `make deploy`. It deploys to production from a laptop. Instead: Open a release pull request; CI deploys it." +) + +func writeRepoGuard(t *testing.T, dir, body string) { + t.Helper() + abcd := filepath.Join(dir, ".abcd") + if err := os.MkdirAll(abcd, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(abcd, "guard.json"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +// TestShellDomainTeachesTheRepositorysOwnGuardEntries (ruling CK1): an entry +// a repository adds in its .abcd/guard.json is taught in SHELL by the same +// generator as the bundled ones — its lesson in id order among them, marked +// "(repo)", and its command head a recall term — whether or not the +// repository also carries a rules.json. +func TestShellDomainTeachesTheRepositorysOwnGuardEntries(t *testing.T) { + bundled := Defaults().Domains[ShellDomain] + for _, withRules := range []bool{false, true} { + dir := t.TempDir() + writeRepoGuard(t, dir, repoGuardEntry) + if withRules { + writeRepoRules(t, dir, `{"schema_version":1,"domains":{}}`) + } + rs := mustLoad(t, dir) + d, ok := rs.Lookup(ShellDomain) + if !ok { + t.Fatalf("rules.json=%v: no %s domain", withRules, ShellDomain) + } + if !holds(d.Rules, repoGuardLesson) { + t.Errorf("rules.json=%v: the repository's own entry is not taught as\n %q\namong the %d rules", withRules, repoGuardLesson, len(d.Rules)) + } + if len(d.Rules) != len(bundled.Rules)+1 { + t.Errorf("rules.json=%v: %d lessons, want the %d bundled ones plus the repository's", withRules, len(d.Rules), len(bundled.Rules)) + } + for _, l := range bundled.Rules { + if !holds(d.Rules, l) { + t.Errorf("rules.json=%v: a bundled lesson is missing or marked: %q", withRules, l) + } + } + if !holds(d.Recall, "make deploy") { + t.Errorf("rules.json=%v: the entry's command head is not a recall term: %q", withRules, d.Recall) + } + if !has(rs.Match("make deploy the docs site"), ShellDomain) { + t.Errorf("rules.json=%v: a prompt naming the repository's hazard did not recall %s", withRules, ShellDomain) + } + if d.Source != SourceBundled { + t.Errorf("rules.json=%v: source %q; the domain is still the generated one, its repository words marked per lesson", withRules, d.Source) + } + if n := rs.Notes(); len(n) != 0 { + t.Errorf("rules.json=%v: a valid guard.json produced notes: %q", withRules, n) + } + } +} + +// TestShellDomainRefusesAnInvalidRepoGuardEntryLoudly (ruling CK1): a +// repository guard.json the guard refuses is refused here too, never taught +// and never silently skipped. SHELL teaches the registry the guard enforces in +// its place (the bundled one), the rest of the rule set loads, and a note +// names the file and the reason on every load. +func TestShellDomainRefusesAnInvalidRepoGuardEntryLoudly(t *testing.T) { + dir := t.TempDir() + writeRepoGuard(t, dir, `{"schema_version":1,"entries":{"deploy-prod":{ + "tier":"blocker", + "pattern":{"command":"make","subcommand":"deploy"}, + "successor":"Open a release pull request; CI deploys it."}}}`) + rs := mustLoad(t, dir) + d, _ := rs.Lookup(ShellDomain) + if want := Defaults().Domains[ShellDomain].Rules; !reflect.DeepEqual(d.Rules, want) { + t.Errorf("an invalid repository entry changed what SHELL teaches: %d rules, want the %d bundled ones", len(d.Rules), len(want)) + } + for _, r := range d.Rules { + if strings.Contains(r, "deploy-prod") { + t.Errorf("the refused entry is taught: %q", r) + } + } + if holds(d.Recall, "make deploy") { + t.Errorf("the refused entry's head is a recall term: %q", d.Recall) + } + if _, ok := rs.Lookup("PII"); !ok { + t.Error("a refused guard.json took the rest of the rule set with it") + } + var note string + for _, n := range rs.Notes() { + if strings.Contains(n, ShellDomain) && strings.Contains(n, ".abcd/guard.json") { + note = n + } + } + if note == "" || !strings.Contains(note, "deploy-prod has no why") || !strings.Contains(note, "refused") { + t.Fatalf("the refused guard.json is not named loudly; notes: %q", rs.Notes()) + } +} diff --git a/internal/core/site/build.go b/internal/core/site/build.go index 83e2232c6..6d308f152 100644 --- a/internal/core/site/build.go +++ b/internal/core/site/build.go @@ -468,7 +468,7 @@ func Build(req Request) (Result, error) { return Result{}, err } if ex.pages.status { - block, err := statusblock.Read(repoRoot, req.Lanes) + block, err := statusblock.Read(repoRoot, req.Lanes, nil) if err != nil { return Result{}, err } diff --git a/internal/core/site/compose.go b/internal/core/site/compose.go index cc09ab6eb..6035eda3b 100644 --- a/internal/core/site/compose.go +++ b/internal/core/site/compose.go @@ -1444,6 +1444,8 @@ func (c *composer) auditIsMet(rel string) bool { // The fence check comes first, ahead of the dated-heading test, so a fenced // heading moves no version cursor either: both failures are silent, rendering a // plausible wrong version rather than none (iss-2609090951287232). +// A cut's move note is passed over too: it names the targeted intents the +// release did NOT ship (changelog.IsTargetMoveNote). func (c *composer) releaseOf(id string) string { data, err := fsutil.ReadGuardedInRoot(c.root, "CHANGELOG.md", changelog.MaxChangelogBytes) if err != nil { @@ -1464,6 +1466,12 @@ func (c *composer) releaseOf(id string) string { } continue } + // A cut's move note names the targeted intents it passed without + // shipping them (itd-2609212103572513 criterion 3): it credits no + // record with the release it sits in. + if changelog.IsTargetMoveNote(line) { + continue + } if version != "" && creditsHandle(line, want) { return version } diff --git a/internal/core/site/compose_test.go b/internal/core/site/compose_test.go index f745fb6f6..c0b51f080 100644 --- a/internal/core/site/compose_test.go +++ b/internal/core/site/compose_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/intentdriven/abcd/internal/core/changelog" + "github.com/intentdriven/abcd/internal/core/launch" ) // The header and footer forge links are labelled with the forge's declared @@ -161,6 +162,42 @@ func TestReleaseOfMatchesTheHandleAtAWordBoundary(t *testing.T) { } } +// A cut that passes a targeted intent names it in the section's move note +// (itd-2609212103572513 criterion 3, ruling BS1 of 2026-09-29), and that line +// names a release the intent did NOT ship in: releaseOf passes over it, so the +// planned intent is stamped with nothing and a shipped one keeps the release +// that credits it. +func TestReleaseOfPassesOverTheTargetMoveNote(t *testing.T) { + dir := t.TempDir() + writeSourceFile(t, dir, "CHANGELOG.md", strings.Join([]string{ + "# Changelog", + "", + "## [Unreleased]", + "", + "## [0.9.0] - 2026-09-01", + "", + changelog.TargetMoveNote([]launch.TargetMove{{ID: "itd-500", From: "v0.9.0"}, {ID: "itd-199", From: "next"}}), + "", + "### Added", + "", + "- A later promise, delivered. (itd-1990)", + "", + "## [0.3.0] - 2026-05-01", + "", + "### Added", + "", + "- The promise this release delivered. (itd-199)", + "", + }, "\n")) + + c := &composer{root: mustOpenRoot(t, dir)} + for id, want := range map[string]string{"itd-500": "", "itd-199": "0.3.0", "itd-1990": "0.9.0"} { + if got := c.releaseOf(id); got != want { + t.Errorf("releaseOf(%q) = %q, want %q", id, got, want) + } + } +} + // The word boundary the pattern ends in closes the handle against a word // character, and `-` is not one — so `fix/itd-199-cleanup`, `iss-0100-*.md` and // every other branch name, file stem and run id of that shape still yields the diff --git a/internal/core/site/fixture_test.go b/internal/core/site/fixture_test.go index b0310d9e4..af8c6bc6a 100644 --- a/internal/core/site/fixture_test.go +++ b/internal/core/site/fixture_test.go @@ -323,7 +323,7 @@ func (f *fixture) writeSources() { "multi_trailer": "Commits declaring more than one model", "not_a_defect": "Not a fault: it is why the trailer count and the commit count differ", "supersedes_lead": "Not a fault: the record on the left replaced the one on the right", "clean": "Nothing to report", "suggestion": "Suggested"}, - "status": {"now": "Now", "next": "Next", "later": "Later", "next_up": "next up", "fails": "fails", "draft": "draft", "none": "None", + "status": {"now": "Now", "next": "Next", "later": "Later", "next_up": "next up", "fails": "fails", "draft": "draft", "target": "target", "none": "None", "order_record_id": "READY intents read oldest id first"}, "panels": {"latest": "Latest decisions", "health": "Record health", "unresolved": "unresolved", "baseline": "baseline", "isolated": "isolated"}, diff --git a/internal/core/site/setup.go b/internal/core/site/setup.go index a080b6df6..fcd1e8dd0 100644 --- a/internal/core/site/setup.go +++ b/internal/core/site/setup.go @@ -48,6 +48,7 @@ import ( "github.com/intentdriven/abcd/internal/core/credential" "github.com/intentdriven/abcd/internal/core/launch/scaffold" "github.com/intentdriven/abcd/internal/core/positioning" + "github.com/intentdriven/abcd/internal/core/tools" "github.com/intentdriven/abcd/internal/gitutil" ) @@ -134,6 +135,9 @@ type SetupRequest struct { Asker Asker // Forge is the repository's forge; nil resolves GitHub through gh. Forge Forge + // ConfirmTool answers the offer to install a missing gh when Forge is nil + // (the DQ3 ruling); nil asks no one, so nothing is installed. + ConfirmTool tools.Confirm // Credentials resolves the hosting credential by name; nil is this // machine's store. Credentials credential.Source @@ -210,18 +214,26 @@ func Setup(req SetupRequest) (SetupResult, error) { adapter = a } s := hosting.Site{Name: hostingBlock.Name, Domain: hostingBlock.Domain} - forge, forgeNote := req.Forge, "" + forge, forgeNote, offerNote := req.Forge, "", "" if forge == nil { f, ferr := GitHubForge(root) if ferr != nil { forgeNote = "the forge is not reachable from this checkout, so the environments were not created: " + ferr.Error() + } else if offer, ok := ahoy.OfferGH(root, req.ConfirmTool); !ok { + // gh is missing and was not installed: the forge is unreachable, + // and the note carries why and the command to run. + forgeNote = "the forge is not reachable from this checkout, so the environments were not created: " + strings.Join(offer, "\n") } else { forge = f + offerNote = strings.Join(offer, "\n") } } branch, branchNote := defaultBranch(ctx, root, forge) res := SetupResult{Host: HostOutcome{Provider: adapter.Name(), Name: s.Name, Domain: s.Domain, Status: HostNotReached}} + if offerNote != "" { + res.Notes = append(res.Notes, offerNote) + } if branchNote != "" { res.Notes = append(res.Notes, branchNote) } diff --git a/internal/core/site/setup_test.go b/internal/core/site/setup_test.go index 95e553982..a6cda5579 100644 --- a/internal/core/site/setup_test.go +++ b/internal/core/site/setup_test.go @@ -10,6 +10,7 @@ import ( "encoding/json" "errors" "os" + "os/exec" "path/filepath" "reflect" "regexp" @@ -22,6 +23,7 @@ import ( "github.com/intentdriven/abcd/internal/adapter/hosting/cloudflare/cloudflaretest" "github.com/intentdriven/abcd/internal/core/credential" "github.com/intentdriven/abcd/internal/core/launch/scaffold" + "github.com/intentdriven/abcd/internal/core/tools" "github.com/intentdriven/abcd/internal/gittest" ) @@ -967,3 +969,64 @@ func TestASiteWithNoDocsBuildLinksNoDocsTree(t *testing.T) { t.Error("a composition declaring its docs surface lost the header's Docs link or the docs route") } } + +// ghFreePath leaves git on PATH and nothing else, so neither gh nor a package +// manager is found and no install step can run from the test. +func ghFreePath(t *testing.T) { + t.Helper() + gitBin, err := exec.LookPath("git") + if err != nil { + t.Skip("git unavailable") + } + dir := t.TempDir() + if err := os.Symlink(gitBin, filepath.Join(dir, "git")); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir) + t.Setenv("CI", "") + t.Setenv("GITHUB_ACTIONS", "") +} + +// TestSetupOffersAMissingGh is the gh offer at site setup (the DQ3 ruling, +// itd-63 criterion 2): with gh missing the forge stage puts the install to the +// question with the registry's explanation. A no runs nothing and the note +// carries the command; a yes reaches the registry's step (here Homebrew is +// absent, so the step says so and nothing ran). Either way the forge stage is +// not reached and says what remains. +func TestSetupOffersAMissingGh(t *testing.T) { + for _, tc := range []struct { + name string + ans tools.Answer + want string + }{ + {"no", tools.Answer{Why: "answered no at the terminal"}, "gh not installed (answered no at the terminal)"}, + {"yes", tools.Answer{Yes: true}, "Homebrew (brew) is not on PATH, so the step cannot run"}, + } { + t.Run(tc.name, func(t *testing.T) { + h := newHarness(t) + ghFreePath(t) + var seen []string + res := h.run(t, func(r *SetupRequest) { + r.Forge = nil + r.ConfirmTool = func(e tools.Explanation) tools.Answer { + seen = append(seen, e.Tool+": "+e.StepText()) + return tc.ans + } + }) + if len(seen) != 1 || seen[0] != "gh: brew install gh" { + t.Fatalf("gh was not offered with its step: %v", seen) + } + notes := strings.Join(res.Notes, "\n") + for _, want := range []string{tc.want, "brew install gh"} { + if !strings.Contains(notes, want) { + t.Errorf("the notes lack %q:\n%s", want, notes) + } + } + for _, env := range res.Environments { + if env.Status != RemoteUnreachable { + t.Errorf("environment %s = %q with gh missing", env.Name, env.Status) + } + } + }) + } +} diff --git a/internal/core/site/setupsrc/ui.json b/internal/core/site/setupsrc/ui.json index 6f64e3f7b..c7c80329a 100644 --- a/internal/core/site/setupsrc/ui.json +++ b/internal/core/site/setupsrc/ui.json @@ -120,6 +120,7 @@ "next_up": "next up", "fails": "fails", "draft": "draft", + "target": "target", "none": "None", "order_record_id": "READY intents read oldest id first" } diff --git a/internal/core/site/status.go b/internal/core/site/status.go index c99ba28e7..3886d56ea 100644 --- a/internal/core/site/status.go +++ b/internal/core/site/status.go @@ -9,7 +9,8 @@ package site // door passes the implement loop's; a nil one reads as an absent state file), // so the page and the board cannot disagree about what is Now, Next or Later. // Every word the block adds is an id, a title, a lane state from the state file, -// a readiness check's name, or an interface label from `site-src/ui.json`. +// a readiness check's name, a target release from the record, or an interface +// label from `site-src/ui.json`. import ( "strconv" @@ -62,9 +63,24 @@ func (e *explorer) statusRows(rows []statusblock.Row) string { return b.String() } -// statusTag is what places a row, as escaped HTML: the lane and its next step, -// the next-up mark, the gating checks a refused intent fails, or the draft mark. +// statusTag is what places a row, as escaped HTML — the lane and its next +// step, the next-up mark, the gating checks a refused intent fails, or the +// draft mark — then the release the intent targets, when it names one +// (itd-2609212103572513 criterion 4). func (e *explorer) statusTag(r statusblock.Row) string { + place := e.statusPlace(r) + if r.Target == "" { + return place + } + target := escapeText(e.c.ui.Status.Target) + ` ` + escapeText(r.Target) + if place == "" { + return target + } + return place + ` · ` + target +} + +// statusPlace is what places a row, as escaped HTML, or nothing. +func (e *explorer) statusPlace(r statusblock.Row) string { ui := e.c.ui.Status switch { case r.Lane != nil: diff --git a/internal/core/site/status_test.go b/internal/core/site/status_test.go index 0e12a2c24..b1a5a8fa2 100644 --- a/internal/core/site/status_test.go +++ b/internal/core/site/status_test.go @@ -28,7 +28,7 @@ func TestStatusPageRendersTheBlockFromTheSameRead(t *testing.T) { } page := outFile(t, out, "record/health/index.html") - want, err := statusblock.Read(f.Root(), lanes) + want, err := statusblock.Read(f.Root(), lanes, nil) if err != nil { t.Fatal(err) } @@ -127,3 +127,31 @@ func TestStatusPageCarriesNoOrderNote(t *testing.T) { t.Errorf("the block does not open on its panels:\n%s", block) } } + +// TestStatusSectionShowsEachRowsTarget is itd-2609212103572513 criterion 4 on +// the site's Status page: a row whose intent names a release shows the target +// after what places it there, under the ui.json label, in Now, Next and +// Later alike; a row with none shows none. +func TestStatusSectionShowsEachRowsTarget(t *testing.T) { + f := newFixture(t) + ui, err := LoadUI(f.Root(), "site-src/ui.json") + if err != nil { + t.Fatal(err) + } + e := &explorer{c: &composer{ui: ui}, status: &statusblock.Block{ + Now: []statusblock.Row{{ID: "itd-5", Title: "Five", Bucket: "planned", Target: "next", NextUp: true}}, + Next: []statusblock.Row{{ID: "itd-5", Title: "Five", Bucket: "planned", Target: "next"}, {ID: "itd-4", Title: "Four", Bucket: "planned"}}, + Later: []statusblock.Row{{ID: "itd-6", Title: "Six", Bucket: "planned", Target: "v0.11.0", Failing: []string{"spec_link"}}}, + }} + got := e.statusSection() + for _, want := range []string{ + `<span class="id">itd-5</span><span>Five</span><span class="s"><b>next up</b> · target next</span>`, + `<span class="id">itd-5</span><span>Five</span><span class="s">target next</span>`, + `<span class="id">itd-4</span><span>Four</span></li>`, + `<span class="id">itd-6</span><span>Six</span><span class="s">fails spec_link · target v0.11.0</span>`, + } { + if !strings.Contains(got, want) { + t.Errorf("the block lacks\n%s\nin\n%s", want, got) + } + } +} diff --git a/internal/core/site/ui.go b/internal/core/site/ui.go index 830d49414..1913464d4 100644 --- a/internal/core/site/ui.go +++ b/internal/core/site/ui.go @@ -168,6 +168,8 @@ type StatusUI struct { Fails string `json:"fails"` // Draft marks a Later row that is a draft. Draft string `json:"draft"` + // Target leads the release a row's intent targets (itd-2609212103572513). + Target string `json:"target"` // None stands in an empty list. None string `json:"none"` // OrderRecordID is the note a block read oldest id first carried. The diff --git a/internal/core/statusblock/statusblock.go b/internal/core/statusblock/statusblock.go index d2786d142..051dd705a 100644 --- a/internal/core/statusblock/statusblock.go +++ b/internal/core/statusblock/statusblock.go @@ -8,8 +8,10 @@ // - Now is every intent the build's state file shows in a lane, with its lane // state, then the head marked "next up": the first READY intent in pick // order that the build's record-only pre-start checks -// (intent.StartChecksIn) let start and that is in no lane. Now is empty -// only when no READY intent passes them. +// (intent.StartChecksIn) let start, that is in no lane, and that no peer +// holds when the caller hands in the build's peers check (ruling CC1 of +// 2026-09-29: the bare board pays that read, so its "next up" is the +// pick). Now is empty only when no READY intent passes them. // - Next is every planned intent the readiness gate reports READY and the // state file shows in no lane, in pick order: `abcd build next`'s one // order (intent.PickLess), each intent scored by the read the pick scores @@ -27,10 +29,14 @@ // returns each such intent to the list the gate places it in, and leaves a // head (criterion 3). // -// The package reads the state file through a LaneReader its caller supplies -// rather than importing the implement loop: the loop's own imports reach the -// site renderer, which renders this block, so the reader is handed in by the -// front door (the loop's StatusLanes) and both surfaces call the one Read. +// The package reads the state file through a LaneReader, and the peers through +// a PeerReader, its caller supplies rather than importing the implement loop: +// the loop's own imports reach the site renderer, which renders this block, so +// each reader is handed in by the front door (the loop's StatusLanes and +// StatusPeers) and both surfaces call the one Read. The bare board hands in +// both; the site's Status page hands in no PeerReader, because another +// checkout's holdings are this machine's local state and never a published +// page's. // // Core never writes to stdout; the front doors format the Block. package statusblock @@ -58,13 +64,18 @@ type Block struct { Order string `json:"order"` } -// Row is one intent on the block: its id and title, the shelf it sits on, and -// what places it where it is. A field another placement needs (a target -// release, a score) joins here, omitted when empty. +// Row is one intent on the block: its id and title, the shelf it sits on, the +// release it targets, and what places it where it is. A field another +// placement needs (a score) joins here, omitted when empty. type Row struct { ID string `json:"id"` Title string `json:"title"` Bucket string `json:"bucket"` + // Target is the release a planned intent names as the one it must land by + // (`target_release`: `next` or vX.Y.Z, itd-2609212103572513 criterion 4), + // empty when it names none. A draft shows none: a target is a promise about + // planned work, and the cut reads it off planned intents alone. + Target string `json:"target_release,omitempty"` // NextUp marks the pick order's head on Now. NextUp bool `json:"next_up,omitempty"` // Lane is the lane state of a Now row the state file shows. @@ -98,9 +109,20 @@ type Started struct { // absent state file reads as no lanes, never as an error. type LaneReader func(repoRoot string) ([]Started, error) +// HeldBy reports whether a peer holds the intent a readiness result judges, +// and why: the build's peers check, read once for the whole block. A non-empty +// reason is a holding. +type HeldBy func(r intent.ReadyResult) string + +// PeerReader reads the peers of the checkout at repoRoot once and returns the +// check the head is judged by. It is the build's own peers check (the loop's +// StatusPeers), so the head passes over exactly the intents the pick does. +type PeerReader func(repoRoot string) (HeldBy, error) + // Read computes the block for the checkout at repoRoot. lanes may be nil, which -// reads as an absent state file. It writes nothing. -func Read(repoRoot string, lanes LaneReader) (Block, error) { +// reads as an absent state file; peers may be nil, which leaves the head +// unjudged against other checkouts. It writes nothing. +func Read(repoRoot string, lanes LaneReader, peers PeerReader) (Block, error) { b := Block{Now: []Row{}, Next: []Row{}, Later: []Row{}, Order: OrderPick} corpus, err := intent.Load(repoRoot) @@ -121,7 +143,11 @@ func Read(repoRoot string, lanes LaneReader) (Block, error) { if err != nil { return Row{}, err } - return Row{ID: it.ID, Title: l.Title, Bucket: it.Bucket}, nil + r := Row{ID: it.ID, Title: l.Title, Bucket: it.Bucket} + if it.Bucket == intent.BucketPlanned { + r.Target = it.TargetRelease + } + return r, nil } // The state file is read first: an intent it shows in a lane is listed @@ -158,6 +184,7 @@ func Read(repoRoot string, lanes LaneReader) (Block, error) { type ready struct { it intent.Intent row Row + res intent.ReadyResult cand intent.PickCandidate startable bool } @@ -181,7 +208,7 @@ func Read(repoRoot string, lanes LaneReader) (Block, error) { if err != nil { return Block{}, fmt.Errorf("reading the pre-start checks of %s: %w", it.ID, err) } - readies = append(readies, ready{it: it, row: r, cand: intent.PickCandidate{ID: it.ID, Score: score}, startable: chk.OK()}) + readies = append(readies, ready{it: it, row: r, res: res, cand: intent.PickCandidate{ID: it.ID, Score: score}, startable: chk.OK()}) continue } for _, c := range res.Checks { @@ -194,15 +221,28 @@ func Read(repoRoot string, lanes LaneReader) (Block, error) { sort.SliceStable(readies, func(i, j int) bool { return intent.PickLess(readies[i].cand, readies[j].cand) }) var head *Row + var heldBy HeldBy + peersRead := false for _, rd := range readies { b.Next = append(b.Next, rd.row) // The head is the first READY intent in pick order the build would // start: not one its record-only pre-start checks refuse (an open // question, an unanswered claim section, a hold, an unshipped blocker, - // no step left to build); one already in a lane is not in readies at - // all. The build's peers check is not run: the block does not consult - // other checkouts. + // no step left to build), nor one the build's peers check finds another + // checkout holding when the caller handed that check in; one already + // in a lane is not in readies at all. if head == nil && rd.startable { + // The peers are read once, and only when a head is in reach: a + // board with nothing to start pays no read of other checkouts. + if peers != nil && !peersRead { + peersRead = true + if heldBy, err = peers(repoRoot); err != nil { + return Block{}, fmt.Errorf("reading the peers for the next-up head: %w", err) + } + } + if heldBy != nil && heldBy(rd.res) != "" { + continue + } h := rd.row h.NextUp = true head = &h diff --git a/internal/core/statusblock/statusblock_test.go b/internal/core/statusblock/statusblock_test.go index 9aac332f5..4130172c0 100644 --- a/internal/core/statusblock/statusblock_test.go +++ b/internal/core/statusblock/statusblock_test.go @@ -2,6 +2,7 @@ package statusblock import ( "encoding/json" + "errors" "os" "path/filepath" "reflect" @@ -87,7 +88,7 @@ func lanesOf(started ...Started) LaneReader { func TestBlockPlacesEveryIntent(t *testing.T) { root := store(t) lane := Lane{Run: "run-2609290000000001", Lane: "lane-1", Stage: "implement", Awaiting: "implementer"} - b, err := Read(root, lanesOf(Started{Intent: "itd-2609010000000001", Lane: lane})) + b, err := Read(root, lanesOf(Started{Intent: "itd-2609010000000001", Lane: lane}), nil) if err != nil { t.Fatal(err) } @@ -133,12 +134,12 @@ func TestBlockPlacesEveryIntent(t *testing.T) { // Next and Later are otherwise exactly what they were. func TestBlockWithoutAStateFileKeepsOnlyTheHead(t *testing.T) { root := store(t) - with, err := Read(root, lanesOf(Started{Intent: "itd-7", Lane: Lane{Run: "run-1", Lane: "lane-1", Stage: "brief"}})) + with, err := Read(root, lanesOf(Started{Intent: "itd-7", Lane: Lane{Run: "run-1", Lane: "lane-1", Stage: "brief"}}), nil) if err != nil { t.Fatal(err) } for name, reader := range map[string]LaneReader{"nil reader": nil, "no lanes": lanesOf()} { - without, err := Read(root, reader) + without, err := Read(root, reader, nil) if err != nil { t.Fatal(err) } @@ -173,7 +174,7 @@ func TestAnIntentInALaneIsOnlyUnderNow(t *testing.T) { for _, id := range inLane { started = append(started, Started{Intent: id, Lane: lane}) } - b, err := Read(root, lanesOf(started...)) + b, err := Read(root, lanesOf(started...), nil) if err != nil { t.Fatal(err) } @@ -192,7 +193,7 @@ func TestAnIntentInALaneIsOnlyUnderNow(t *testing.T) { // name, each row with its id and title, the lane state and the failing checks. func TestBlockJSONCarriesTheThreeLists(t *testing.T) { root := store(t) - b, err := Read(root, lanesOf(Started{Intent: "itd-2609010000000001", Lane: Lane{Run: "run-1", Lane: "lane-2", Stage: "validate", Awaiting: "validator"}})) + b, err := Read(root, lanesOf(Started{Intent: "itd-2609010000000001", Lane: Lane{Run: "run-1", Lane: "lane-2", Stage: "validate", Awaiting: "validator"}}), nil) if err != nil { t.Fatal(err) } @@ -215,7 +216,7 @@ func TestBlockJSONCarriesTheThreeLists(t *testing.T) { // TestBlockOnAnEmptyStoreHasEmptyLists: a record with no intents renders three // empty lists, never null ones, and no head. func TestBlockOnAnEmptyStoreHasEmptyLists(t *testing.T) { - b, err := Read(t.TempDir(), nil) + b, err := Read(t.TempDir(), nil, nil) if err != nil { t.Fatal(err) } @@ -249,7 +250,7 @@ func TestTheHeadSkipsAHeldIntent(t *testing.T) { w(in+"itd-3-held.md", readyIntent("itd-3", "The held one", "spc-13", "held: \"awaiting a ruling\"\n")) w(sp+"spc-13-held.md", writtenSpec("spc-13", "itd-3")) - b, err := Read(root, nil) + b, err := Read(root, nil, nil) if err != nil { t.Fatal(err) } @@ -262,7 +263,7 @@ func TestTheHeadSkipsAHeldIntent(t *testing.T) { w(in+"itd-4-free.md", readyIntent("itd-4", "The free one", "spc-14", "")) w(sp+"spc-14-free.md", writtenSpec("spc-14", "itd-4")) - b, err = Read(root, nil) + b, err = Read(root, nil, nil) if err != nil { t.Fatal(err) } @@ -324,7 +325,7 @@ func TestTheHeadIsThePicksChoice(t *testing.T) { t.Fatalf("precondition: the pick takes itd-4 over its tie with itd-9: %+v", pick) } - b, err := Read(root, nil) + b, err := Read(root, nil, nil) if err != nil { t.Fatal(err) } @@ -344,7 +345,7 @@ func TestTheHeadIsThePicksChoice(t *testing.T) { // itd-4 in a lane: the pick would not start it again, so the head is the // runner-up. - b, err = Read(root, lanesOf(Started{Intent: "itd-4", Lane: Lane{Run: "run-1", Lane: "lane-1", Stage: "implement"}})) + b, err = Read(root, lanesOf(Started{Intent: "itd-4", Lane: Lane{Run: "run-1", Lane: "lane-1", Stage: "implement"}}), nil) if err != nil { t.Fatal(err) } @@ -353,6 +354,65 @@ func TestTheHeadIsThePicksChoice(t *testing.T) { } } +// TestTheHeadTakesAnIntentWhoseBlockerASettledRecordReplaced is rulings CF1 +// and CF2 of 2026-09-30 on the board: the "next up" reads the build's own +// blocked check (intent.StartChecksIn), so an intent whose blocker was +// superseded by an accepted decision, or reclassified as a discipline, heads +// the board, and one whose blocker a proposed decision replaced does not. +func TestTheHeadTakesAnIntentWhoseBlockerASettledRecordReplaced(t *testing.T) { + const superseded = "---\nid: itd-27\nslug: s\nkind: standalone\nsuperseded_by: adr-37\nkind_at_supersession: standalone\n---\n# The replaced one\n" + cases := []struct { + name string + files map[string]string + heads bool + }{ + {"superseded by an accepted decision", map[string]string{ + ".abcd/development/intents/superseded/itd-27-s.md": superseded, + ".abcd/development/decisions/adrs/0037-d.md": "---\nid: adr-37\nstatus: accepted\n---\n# d\n", + }, true}, + {"reclassified as a discipline", map[string]string{ + ".abcd/development/intents/disciplines/itd-27-s.md": "---\nid: itd-27\nslug: s\nkind: discipline\n---\n# A rule\n", + }, true}, + {"superseded by a proposed decision", map[string]string{ + ".abcd/development/intents/superseded/itd-27-s.md": superseded, + ".abcd/development/decisions/adrs/0037-d.md": "---\nid: adr-37\nstatus: proposed\n---\n# d\n", + }, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + w := func(rel, body string) { + t.Helper() + p := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + w(".abcd/development/intents/planned/itd-4-blocked.md", readyIntent("itd-4", "The blocked one", "spc-14", "blocked_by: [itd-27]\n")) + w(".abcd/development/specs/open/spc-14-blocked.md", scoredSpec("spc-14", "itd-4")) + for rel, body := range tc.files { + w(rel, body) + } + b, err := Read(root, nil, nil) + if err != nil { + t.Fatal(err) + } + if got := ids(b.Next); !reflect.DeepEqual(got, []string{"itd-4"}) { + t.Fatalf("Next = %v, want [itd-4]", got) + } + switch { + case tc.heads && (len(b.Now) != 1 || b.Now[0].ID != "itd-4" || !b.Now[0].NextUp): + t.Errorf("Now = %+v, want itd-4 marked next up: its blocker is settled", b.Now) + case !tc.heads && len(b.Now) != 0: + t.Errorf("Now = %+v, want no head: a proposed decision does not settle the blocker", b.Now) + } + }) + } +} + // TestTheHeadPassesOverWhatTheBuildRefusesFromTheRecord is the head under the // build's record-only pre-start checks (iss-2609291803334904): a READY intent // with an open question, an unanswered claim section, an unshipped blocker, or @@ -401,7 +461,7 @@ func TestTheHeadPassesOverWhatTheBuildRefusesFromTheRecord(t *testing.T) { w(in+"itd-4-refused.md", refused) w(sp+"spc-14-refused.md", scoredSpec("spc-14", "itd-4")+tc.specAdd) - b, err := Read(root, nil) + b, err := Read(root, nil, nil) if err != nil { t.Fatal(err) } @@ -414,7 +474,7 @@ func TestTheHeadPassesOverWhatTheBuildRefusesFromTheRecord(t *testing.T) { w(in+"itd-9-free.md", readyIntent("itd-9", "The free one", "spc-19", "")) w(sp+"spc-19-free.md", writtenSpec("spc-19", "itd-9")) - if b, err = Read(root, nil); err != nil { + if b, err = Read(root, nil, nil); err != nil { t.Fatal(err) } if got := ids(b.Next); !reflect.DeepEqual(got, []string{"itd-4", "itd-9"}) { @@ -426,3 +486,127 @@ func TestTheHeadPassesOverWhatTheBuildRefusesFromTheRecord(t *testing.T) { }) } } + +// TestTheHeadPassesOverAnIntentAPeerHolds (ruling CC1): the head is judged by +// the peers check the caller hands in, read once for the block and only when a +// head is in reach. An intent it reports held stays in Next and is never the +// head; a fault reading the peers is the block's fault, as it is the pick's; +// and a record with nothing to start pays no peers read at all. +func TestTheHeadPassesOverAnIntentAPeerHolds(t *testing.T) { + root := t.TempDir() + w := func(rel, body string) { + t.Helper() + p := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + reads := 0 + holding := func(held string) PeerReader { + return func(string) (HeldBy, error) { + reads++ + return func(r intent.ReadyResult) string { + if r.IntentID == held { + return "lane-alpha holds it in shipped/" + } + return "" + }, nil + } + } + + if _, err := Read(root, nil, holding("itd-4")); err != nil || reads != 0 { + t.Fatalf("a record with nothing to start: err %v, %d peers read(s), want none", err, reads) + } + + const in = ".abcd/development/intents/planned/" + const sp = ".abcd/development/specs/open/" + w(in+"itd-4-held.md", readyIntent("itd-4", "The held one", "spc-14", "")) + w(sp+"spc-14-held.md", scoredSpec("spc-14", "itd-4")) + w(in+"itd-9-free.md", readyIntent("itd-9", "The free one", "spc-19", "")) + w(sp+"spc-19-free.md", writtenSpec("spc-19", "itd-9")) + + b, err := Read(root, nil, nil) + if err != nil { + t.Fatal(err) + } + if got := ids(b.Now); !reflect.DeepEqual(got, []string{"itd-4"}) { + t.Fatalf("precondition: without a peers check itd-4 heads, got Now = %v", got) + } + + b, err = Read(root, nil, holding("itd-4")) + if err != nil { + t.Fatal(err) + } + if got := ids(b.Next); !reflect.DeepEqual(got, []string{"itd-4", "itd-9"}) { + t.Errorf("Next = %v, want [itd-4 itd-9]: a held intent is still READY", got) + } + if got := ids(b.Now); !reflect.DeepEqual(got, []string{"itd-9"}) || !b.Now[0].NextUp { + t.Errorf("Now = %+v, want only itd-9 marked next up: a peer holds itd-4", b.Now) + } + if reads != 1 { + t.Errorf("the peers were read %d times for one block, want once", reads) + } + + fault := errors.New("git could not name the common dir") + if _, err := Read(root, nil, func(string) (HeldBy, error) { return nil, fault }); !errors.Is(err, fault) { + t.Errorf("a fault reading the peers: got %v, want it returned", err) + } +} + +// TestARowShowsItsTarget is itd-2609212103572513 criterion 4: given the +// status block, when a targeted intent is listed, then its row shows the +// target — in Now (a lane row and the head), in Next and in Later alike, and in +// the JSON as `target_release`. A row with no target carries none, and a draft +// carrying one by hand shows none: a target is a promise about planned work, +// and the cut reads it off planned intents alone. +func TestARowShowsItsTarget(t *testing.T) { + root := store(t) + w := func(rel, body string) { + t.Helper() + if err := os.WriteFile(filepath.Join(root, filepath.FromSlash(rel)), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + const in = ".abcd/development/intents/" + w(in+"planned/itd-2609010000000001-late.md", readyIntent("itd-2609010000000001", "The stamped one", "spc-2609010000000011", "target_release: v0.11.0\n")) + w(in+"planned/itd-7-seven.md", readyIntent("itd-7", "The seventh", "spc-17", "target_release: next\n")) + w(in+"planned/itd-5-held.md", readyIntent("itd-5", "The held one", "spc-15", "held: \"awaiting a ruling\"\ntarget_release: v0.12.0\n")) + w(in+"planned/itd-8-unlinked.md", readyIntent("itd-8", "The unlinked one", "null", "target_release: next\n")) + w(in+"drafts/itd-3-old.md", strings.Replace(draft("itd-3", "An old idea"), "kind: standalone\n", "kind: standalone\ntarget_release: next\n", 1)) + + b, err := Read(root, lanesOf(Started{Intent: "itd-2609010000000001", Lane: Lane{Run: "run-1", Stage: "implement"}}), nil) + if err != nil { + t.Fatal(err) + } + targets := map[string]string{} + for _, list := range [][]Row{b.Now, b.Next, b.Later} { + for _, r := range list { + targets[r.ID] = targets[r.ID] + "|" + r.Target + } + } + for id, want := range map[string]string{ + "itd-2609010000000001": "|v0.11.0", // Now, in a lane + "itd-7": "|next|next", // Now as the head, and Next + "itd-5": "|v0.12.0", // Next + "itd-8": "|next", // Later, not READY + "itd-3": "|", // a draft shows none + "itd-2609020000000002": "|", // no target, none shown + } { + if targets[id] != want { + t.Errorf("%s rows carry targets %q, want %q", id, targets[id], want) + } + } + raw, err := json.Marshal(b) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), `"id":"itd-8","title":"The unlinked one","bucket":"planned","target_release":"next"`) { + t.Errorf("--json must carry each row's target as target_release:\n%s", raw) + } + if strings.Count(string(raw), `"target_release"`) != 5 { + t.Errorf("a row with no target carries no target_release key:\n%s", raw) + } +} diff --git a/internal/core/surface/sentences.go b/internal/core/surface/sentences.go index a5fdcac2b..5c0db7de5 100644 --- a/internal/core/surface/sentences.go +++ b/internal/core/surface/sentences.go @@ -120,8 +120,8 @@ var sentences = map[string]string{ "abcd docs cite refresh": "Fetch every cited URL once, the one documentation verb that reaches the network: " + "Writes the citation baseline; refuses an unreadable docs-lint configuration.", - "abcd drain": "Sort the open issues by the drain's field rule, eligible first in drain order: " + - "Writes nothing; refuses to start without --dry-run, as the run is not built.", + "abcd drain": "Sort open issues by this repository's own drain rule, naming each loosened floor: " + + "Writes nothing; refuses without the rule's record, or without --dry-run.", "abcd embark": "Unpack a verified lifeboat into a target repository, probing first: " + "Writes only its record families and marker block; refuses the whole write on any conflict.", diff --git a/internal/core/update/update.go b/internal/core/update/update.go index 984649b28..e0a31a3c0 100644 --- a/internal/core/update/update.go +++ b/internal/core/update/update.go @@ -49,6 +49,21 @@ const ( ActionRefused Action = "refused" ) +// UpdatedFormat is the one wording of the line every binary swap prints when it +// completes: `abcd update`'s receipt and hooks/bootstrap.sh's success notice +// both lead with it, and the session check uses it for the one swap whose +// output nobody sees (the ruling CJ1b in .abcd/work/DECISIONS.md). The +// bootstrap is POSIX sh and cannot import it, so +// TestBootstrapUpdateLineIsTheSharedWording holds the script's printf literal +// to this constant. +const UpdatedFormat = "abcd updated from %s to %s" + +// UpdatedLine renders UpdatedFormat. The caller sanitises both tags: they are +// read off an HTTP response or a record on disk. +func UpdatedLine(from, to string) string { + return fmt.Sprintf(UpdatedFormat, from, to) +} + // Refusal is a named no: the shape that refused, why, and the remedy. Every // refusal is loud and names its way out (the itd-130 dispatch contract). type Refusal struct { @@ -287,8 +302,9 @@ var scrubbedEnv = []string{ } // tagShape is the accepted release-tag alphabet; a tag travels into a URL -// path, so anything path-shaped refuses before any request is built. -var tagShape = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$`) +// path, so anything path-shaped refuses before any request is built. It is +// ahoy's, which holds the session check's unseen-update tags to the same shape. +var tagShape = ahoy.ReleaseTagShape const ( maxChecksumsBytes = 1 << 20 // 1 MiB: checksums.txt is a few hundred bytes diff --git a/internal/reachaudit/testdata/core-unreached.txt b/internal/reachaudit/testdata/core-unreached.txt index 4e91f67f7..a130eb853 100644 --- a/internal/reachaudit/testdata/core-unreached.txt +++ b/internal/reachaudit/testdata/core-unreached.txt @@ -57,7 +57,6 @@ internal/core/intent.ReEmitAudit internal/core/intent.ReEmitCommand internal/core/intent.UnmarkedConditionOrdinals internal/core/intent.Validate -internal/core/intent.WithMintLock internal/core/issuerecord.ParseBlock internal/core/issuerecord.ParseScalarOrList internal/core/lab.ValidID diff --git a/internal/surface/cli/ahoy_connect.go b/internal/surface/cli/ahoy_connect.go index 05a105560..a6934d761 100644 --- a/internal/surface/cli/ahoy_connect.go +++ b/internal/surface/cli/ahoy_connect.go @@ -130,6 +130,17 @@ func runAhoyProviders(cmd *cobra.Command, cwd string, asJSON bool) error { }) } +// printConfigDiagnostics says the provider configuration read's non-fatal +// reports (oracle.APIConfig.Diagnostics: a route skipped, and why) on w, one +// line each. It is the one printer every front door that reads the +// configuration and is not the board uses, so a skipped route is said the +// same way wherever it is met. +func printConfigDiagnostics(w io.Writer, diagnostics []string) { + for _, d := range diagnostics { + fmt.Fprintf(w, "abcd %s\n", termsafe.Sanitize(fsutil.RedactHome(d))) + } +} + // keyState says whether a named credential resolves through the store, and // from which home: set, not set, refused (the store is unsafe), or none for a // keyless provider. Never the value. @@ -183,6 +194,9 @@ func newAhoyConnectCommand(asJSON *bool) *cobra.Command { msg := openaiapi.Scrub(err.Error(), req.Key) return &exitError{Code: 2, Msg: "abcd ahoy connect: " + termsafe.Sanitize(fsutil.RedactHome(msg))} } + // A route the configuration read skipped (ruling CD2) is said on + // stderr, in the text and the JSON form alike, and the setup stands. + printConfigDiagnostics(cmd.ErrOrStderr(), res.Diagnostics) return render(cmd.OutOrStdout(), *asJSON, withMember{v: res, key: "dispatch", val: dispatchPending}, func(w io.Writer) { line := func(s string) { fmt.Fprintf(w, " %s\n", termsafe.Sanitize(s)) } fmt.Fprintf(w, "abcd ahoy connect — %s verified and configured\n", termsafe.Sanitize(res.Provider)) diff --git a/internal/surface/cli/ahoy_connect_test.go b/internal/surface/cli/ahoy_connect_test.go index 0a38d8dd5..ef45909b2 100644 --- a/internal/surface/cli/ahoy_connect_test.go +++ b/internal/surface/cli/ahoy_connect_test.go @@ -1,6 +1,7 @@ package cli import ( + "bytes" "encoding/json" "io" "net/http" @@ -227,3 +228,79 @@ func TestBareAhoyNamesTheProviderAdapter(t *testing.T) { t.Fatalf("bare ahoy does not name the provider adapter:\n%s", out) } } + +// repoRouteToKeyedProvider sets up a machine provider that holds a key and a +// repository whose configuration routes a role to it, the route ruling CD2 of +// 2026-09-29 skips, and changes into the repository. +func repoRouteToKeyedProvider(t *testing.T) string { + t.Helper() + providerNamingKey(t, "https://openrouter.ai/api/v1") + repo := t.TempDir() + if err := os.Mkdir(filepath.Join(repo, ".git"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(repo, ".abcd"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, ".abcd", "config.json"), + []byte(`{"oracle":{"roles":{"scribe":"openrouter/typesafe/jev-1.13"}}}`), 0o644); err != nil { + t.Fatal(err) + } + t.Chdir(repo) + return repo +} + +// TestAhoyConnectSaysASkippedRoute: the setup reads the configuration in +// force, and a route that read skipped is said once on stderr, in the text +// form and the JSON form alike, and never in what a machine reader parses. +func TestAhoyConnectSaysASkippedRoute(t *testing.T) { + for _, asJSON := range []bool{false, true} { + t.Run(map[bool]string{false: "text", true: "json"}[asJSON], func(t *testing.T) { + hermeticEnv(t) + repoRouteToKeyedProvider(t) + base, calls, _ := fakeProvider(t, 200, completionReply("qwen/qwen3-8b")) + args := []string{"ahoy", "connect", "desk", "--base-url", base, "--model", "qwen/qwen3-8b", "--home", "none"} + if asJSON { + args = append(args, "--json") + } + root := NewRootCommand() + root.SetArgs(args) + var so, se bytes.Buffer + root.SetOut(&so) + root.SetErr(&se) + if err := root.Execute(); err != nil || calls.Load() != 1 { + t.Fatalf("ahoy connect (%d call(s)): %v\n%s%s", calls.Load(), err, so.String(), se.String()) + } + if n := strings.Count(se.String(), "holds a key"); n != 1 { + t.Fatalf("stderr carries %d keyed-route warning(s), want one:\n%s", n, se.String()) + } + for _, want := range []string{"abcd oracle adapter: ", "oracle.roles.scribe", "openrouter/typesafe/jev-1.13", "skipped"} { + if !strings.Contains(se.String(), want) { + t.Errorf("the warning does not name %q:\n%s", want, se.String()) + } + } + if strings.Contains(so.String(), "holds a key") { + t.Errorf("stdout carries the warning:\n%s", so.String()) + } + if asJSON && !json.Valid(so.Bytes()) { + t.Errorf("stdout is not one JSON document:\n%s", so.String()) + } + }) + } +} + +// TestBareAhoyNamesASkippedRoute: the bare board names a route the +// configuration read skipped, where it names the provider adapter. +func TestBareAhoyNamesASkippedRoute(t *testing.T) { + hermeticEnv(t) + repoRouteToKeyedProvider(t) + out, err := runCLIErr(t, "ahoy") + if err != nil { + t.Fatalf("ahoy: %v\n%s", err, out) + } + for _, want := range []string{"provider: route skipped — oracle adapter: ", "oracle.roles.scribe", "holds a key"} { + if !strings.Contains(string(out), want) { + t.Errorf("bare ahoy does not say %q:\n%s", want, out) + } + } +} diff --git a/internal/surface/cli/ahoy_credential.go b/internal/surface/cli/ahoy_credential.go index 4ea2d147c..002c10b48 100644 --- a/internal/surface/cli/ahoy_credential.go +++ b/internal/surface/cli/ahoy_credential.go @@ -38,12 +38,15 @@ func pointerFlags(cmd *cobra.Command, p *credential.Pointer) { // credentialService finds the walkthrough's service for name among the // adapters that read a credential: the site setup's hosting providers, then -// the configured model providers. -func credentialService(roots layered.Roots, name string) (credential.Service, bool, error) { +// the configured model providers. Reading the provider configuration says its +// diagnostics on stderr (a route skipped under ruling CD2). +func credentialService(stderr io.Writer, roots layered.Roots, name string) (credential.Service, bool, error) { if svc, ok := site.CredentialServiceFor(name); ok { return svc, true, nil } - return oracle.CredentialService(roots, name) + svc, ok, diagnostics, err := oracle.CredentialService(roots, name) + printConfigDiagnostics(stderr, diagnostics) + return svc, ok, err } // credentialView is one credential as a surface shows it: presence and home, @@ -97,7 +100,7 @@ func newAhoyCredentialCommand(asJSON *bool) *cobra.Command { if !credential.ValidName(name) { return fail(errors.New("the name is not a plain credential name"), "") } - svc, ok, err := credentialService(roots, name) + svc, ok, err := credentialService(cmd.ErrOrStderr(), roots, name) if err != nil { return fail(err, "") } @@ -158,6 +161,9 @@ func runCredentialList(cmd *cobra.Command, roots layered.Roots, asJSON bool) err if err != nil { return &exitError{Code: 2, Msg: "abcd ahoy credential: " + termsafe.Sanitize(fsutil.RedactHome(err.Error()))} } + // A route the read skipped (a repository's route to a provider that holds + // a key, ruling CD2) is said on stderr, and the listing goes on. + printConfigDiagnostics(cmd.ErrOrStderr(), cfg.Diagnostics) for _, p := range cfg.Providers() { if p.Key != "" { names[p.Key] = true diff --git a/internal/surface/cli/ahoy_credential_test.go b/internal/surface/cli/ahoy_credential_test.go index bc1cef751..1fcf416bc 100644 --- a/internal/surface/cli/ahoy_credential_test.go +++ b/internal/surface/cli/ahoy_credential_test.go @@ -1,6 +1,7 @@ package cli import ( + "bytes" "encoding/json" "os" "path/filepath" @@ -160,3 +161,65 @@ func TestAhoyCredentialRefusesAFailedVerification(t *testing.T) { t.Fatal("a refused key was stored") } } + +// TestARepositoryRouteToAKeyedProviderIsSkippedWithAWarning is ruling CD2 of +// 2026-09-29 at the front doors that read the provider configuration: a +// repository's route to a provider that holds a key no longer refuses the +// command. The route is skipped with one warning on stderr naming the route +// and why, and the command does its work. +func TestARepositoryRouteToAKeyedProviderIsSkippedWithAWarning(t *testing.T) { + hermeticEnv(t) + providerNamingKey(t, "https://openrouter.ai/api/v1") + repo := t.TempDir() + if err := os.MkdirAll(filepath.Join(repo, ".abcd"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, ".abcd", "config.json"), + []byte(`{"oracle":{"roles":{"scribe":"openrouter/typesafe/jev-1.13"}}}`), 0o644); err != nil { + t.Fatal(err) + } + t.Chdir(repo) + + root := NewRootCommand() + root.SetArgs([]string{"ahoy", "credential"}) + var so, se bytes.Buffer + root.SetOut(&so) + root.SetErr(&se) + if err := root.Execute(); err != nil { + t.Fatalf("ahoy credential refused over one repository route: %v\n%s%s", err, so.String(), se.String()) + } + if !strings.Contains(so.String(), "openrouter") { + t.Errorf("ahoy credential did not list the provider's credential:\n%s", so.String()) + } + warn := se.String() + if n := strings.Count(warn, "holds a key"); n != 1 { + t.Fatalf("stderr carries %d keyed-route warning(s), want one:\n%s", n, warn) + } + for _, want := range []string{"oracle.roles.scribe", "openrouter/typesafe/jev-1.13", "skipped", "~/.abcd/config.json"} { + if !strings.Contains(warn, want) { + t.Errorf("the warning does not name %q:\n%s", want, warn) + } + } +} + +// TestAhoyCredentialByNameSaysASkippedRoute: naming a provider's credential +// reads the provider configuration to find the provider that verifies it, and +// a route that read skipped (ruling CD2) is said on stderr there too. +func TestAhoyCredentialByNameSaysASkippedRoute(t *testing.T) { + hermeticEnv(t) + repoRouteToKeyedProvider(t) + root := NewRootCommand() + root.SetArgs([]string{"ahoy", "credential", "openrouter"}) + var so, se bytes.Buffer + root.SetOut(&so) + root.SetErr(&se) + if err := root.Execute(); err != nil { + t.Fatalf("ahoy credential openrouter: %v\n%s%s", err, so.String(), se.String()) + } + if n := strings.Count(se.String(), "holds a key"); n != 1 { + t.Fatalf("stderr carries %d keyed-route warning(s), want one:\n%s", n, se.String()) + } + if !strings.Contains(se.String(), "oracle.roles.scribe") || strings.Contains(so.String(), "holds a key") { + t.Fatalf("stdout:\n%s\nstderr:\n%s", so.String(), se.String()) + } +} diff --git a/internal/surface/cli/ahoy_prompt_stdin_test.go b/internal/surface/cli/ahoy_prompt_stdin_test.go index 6f7cb2e76..bb29decea 100644 --- a/internal/surface/cli/ahoy_prompt_stdin_test.go +++ b/internal/surface/cli/ahoy_prompt_stdin_test.go @@ -253,11 +253,16 @@ func TestAhoyInstallYesDisclosesOptionalIdentityPin(t *testing.T) { t.Fatalf("install output not JSON: %v\n%s", err, jsonOut) } // The model-tier routing offers are optional too (itd-2609170822093401): a - // table is accepted only on an answer, so --yes names them as well. - want := "git_identity.unpinned oracle_routing.machine_offered oracle_routing.repo_offered" + // table is accepted only on an answer, so --yes names them as well; and so + // is the drain eligibility record (ruling BX2), which decides what an + // unattended agent may change. + want := "git_identity.unpinned oracle_routing.machine_offered oracle_routing.repo_offered drain_rule.offered" if strings.Join(res.OptionalSkipped, " ") != want { t.Fatalf("optional_skipped = %v, want [%s]\n%s", res.OptionalSkipped, want, jsonOut) } + if !strings.Contains(text, "the drain eligibility record decides what an unattended agent may change") { + t.Fatalf("the exclusion notice gives no reason for the drain rule offer:\n%s", text) + } if !strings.Contains(text, "a routing table decides which model every delegated step asks for") { t.Fatalf("the exclusion notice gives no reason for the routing offers:\n%s", text) } diff --git a/internal/surface/cli/ahoy_remote_gh_offer_test.go b/internal/surface/cli/ahoy_remote_gh_offer_test.go new file mode 100644 index 000000000..b98ab61aa --- /dev/null +++ b/internal/surface/cli/ahoy_remote_gh_offer_test.go @@ -0,0 +1,108 @@ +package cli + +import ( + "bufio" + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/ahoy" + "github.com/intentdriven/abcd/internal/core/tools" + "github.com/intentdriven/abcd/internal/gittest" +) + +// TestTerminalToolConfirmAsksOnlyAPersonAtATerminal is the gh offer's +// confirmation at the remote write (the DQ3 ruling): only an answer typed at +// a terminal is a yes. A piped y, --yes and a prompter that is no terminal all +// decline, and each decline carries the command to run by hand. The question +// shows the explanation and the exact step before anything runs. +func TestTerminalToolConfirmAsksOnlyAPersonAtATerminal(t *testing.T) { + e := tools.Explain("gh", tools.GitHubSettings) + var w bytes.Buffer + + piped := &stdinPrompter{r: bufio.NewReader(strings.NewReader("y\n")), w: &w} + if ans := terminalToolConfirm(piped, false, &w)(e); ans.Yes || + !strings.Contains(ans.Why, "no terminal to ask at") || !strings.Contains(ans.Why, "brew install gh") { + t.Fatalf("a piped y: %+v", ans) + } + tty := &stdinPrompter{r: bufio.NewReader(strings.NewReader("y\n")), w: &w, tty: true} + if ans := terminalToolConfirm(tty, true, &w)(e); ans.Yes || + !strings.Contains(ans.Why, "--yes") || !strings.Contains(ans.Why, "brew install gh") { + t.Fatalf("--yes at a terminal: %+v", ans) + } + if ans := terminalToolConfirm(ahoy.RefusingPrompter{}, false, &w)(e); ans.Yes { + t.Fatal("the refusing prompter installed gh") + } + if w.Len() != 0 { + t.Fatalf("a decline asked a question anyway:\n%s", w.String()) + } + + tty = &stdinPrompter{r: bufio.NewReader(strings.NewReader("n\n")), w: &w, tty: true} + if ans := terminalToolConfirm(tty, false, &w)(e); ans.Yes { + t.Fatal("an n typed at a terminal was a yes") + } + w.Reset() + tty = &stdinPrompter{r: bufio.NewReader(strings.NewReader("y\n")), w: &w, tty: true} + ans := terminalToolConfirm(tty, false, &w)(e) + if !ans.Yes { + t.Fatalf("a y typed at a terminal was not a yes: %+v", ans) + } + asked := w.String() + q := strings.Index(asked, "Install gh now by running brew install gh? [y/N]") + if q < 0 { + t.Fatalf("the question does not show the exact step:\n%s", asked) + } + for _, want := range []string{"what abcd uses it for:", "without it:", "gh auth login"} { + if i := strings.Index(asked, want); i < 0 || i > q { + t.Errorf("%q is not shown before the question:\n%s", want, asked) + } + } + if !strings.Contains(asked[q:], "running brew install gh") { + t.Errorf("the step is not announced as it starts:\n%s", asked) + } +} + +// TestAhoyRemoteApplyNeverInstallsGhOnAScriptedYes is the end-to-end no: a +// piped y and --yes each reach the gh offer and decline it, so the step is +// never attempted (brew's absence would otherwise be the reason), and the +// refusal carries the command to run. +func TestAhoyRemoteApplyNeverInstallsGhOnAScriptedYes(t *testing.T) { + hermeticEnv(t) + repo := gittest.NewRepo(t) + repo.Git("remote", "add", "origin", "https://github.com/example-org/example-repo.git") + t.Chdir(repo.Root()) + if _, err := runCLIErr(t, "ahoy", "install", "--yes", "--adopt", + "--visibility", "private", "--docs-target", "both", + "--oracle-backend", "host-delegated", "--scan-deep", "false"); err != nil { + t.Fatalf("install: %v", err) + } + toolFreePath(t) + + for _, tc := range []struct { + args []string + why string + }{ + {[]string{"ahoy", "remote", "apply", "--json"}, "no terminal to ask at"}, + {[]string{"ahoy", "remote", "apply", "--yes", "--json"}, "--yes"}, + } { + out, errOut, err := runCLIPipedStdinSplit(t, "y\ny\n", tc.args...) + if err == nil { + t.Fatalf("%v exited zero with gh missing:\n%s", tc.args, out) + } + var res ahoy.RemoteResult + if jerr := json.Unmarshal(out, &res); jerr != nil { + t.Fatalf("%v: not JSON: %v\n%s\n%s", tc.args, jerr, out, errOut) + } + notes := strings.Join(res.Notes, "\n") + if res.Status != "refused" || !strings.Contains(notes, tc.why) || !strings.Contains(notes, "brew install gh") { + t.Errorf("%v: status %q, notes lack %q or the step:\n%s", tc.args, res.Status, tc.why, notes) + } + if strings.Contains(notes, "is not on PATH, so the step cannot run") { + t.Errorf("%v: a scripted yes reached the install step:\n%s", tc.args, notes) + } + if strings.Contains(string(errOut), "[y/N]") { + t.Errorf("%v: the install was asked of a pipe:\n%s", tc.args, errOut) + } + } +} diff --git a/internal/surface/cli/barerender.go b/internal/surface/cli/barerender.go index ee3dd0176..cce7d0f0b 100644 --- a/internal/surface/cli/barerender.go +++ b/internal/surface/cli/barerender.go @@ -20,8 +20,8 @@ var bareRenderExceptions = map[string]string{ "disembark": "a parent of stage sub-verbs that each act on a named repository or " + "lifeboat; with no operand there is no state to render, so bare prints its sub-verbs", "drain": "bare is the run itself, which is not built, so bare refuses to start " + - "(exit 2) naming the missing lane and writes nothing; what a drain would do " + - "renders with --dry-run", + "(exit 2) naming the missing lane, or the repository's missing eligibility record, " + + "and writes nothing; what a drain would do renders with --dry-run", "docs": "a parent holding the citation-baseline writer alone; the documentation's " + "state is `abcd lint docs`, so bare prints its sub-verb", "embark": "a parent whose sub-verbs act on a named lifeboat; with no operand there " + diff --git a/internal/surface/cli/board_status.go b/internal/surface/cli/board_status.go index 3993a13e0..66d8b2139 100644 --- a/internal/surface/cli/board_status.go +++ b/internal/surface/cli/board_status.go @@ -27,7 +27,7 @@ func boardStatus(cwd string, stderr io.Writer) *statusblock.Block { if err != nil || !ahoy.Managed(root) { return nil } - b, err := statusblock.Read(root, loop.StatusLanes) + b, err := statusblock.Read(root, loop.StatusLanes, loop.StatusPeers) if err != nil { fmt.Fprintf(stderr, "abcd: the Now / Next / Later block is omitted — %s\n", termsafe.Sanitize(fsutil.RedactHome(err.Error()))) return nil @@ -37,7 +37,8 @@ func boardStatus(cwd string, stderr io.Writer) *statusblock.Block { // renderBoardStatus writes the block: a heading with the three counts, then // Now and Next, one row per intent (Next in the pick order) — its id, its -// title, and in brackets what places it there (its lane state or "next up") — +// title, and in brackets what places it there (its lane state or "next up") +// and the release it targets — // then Later as a count of intents alone (ruling BV1 of 2026-09-29): its rows, // with the gating checks each fails, are in --json and on the site's Status // page. @@ -70,9 +71,25 @@ func renderBoardStatus(w io.Writer, b *statusblock.Block) { fmt.Fprintf(w, " Later: %d %s\n", len(b.Later), noun) } -// statusRowTag is what places a Now or Next row where it is, in words: its -// lane state or "next up"; a READY intent in no lane carries none. +// statusRowTag is what places a Now or Next row where it is, in words — its +// lane state or "next up"; a READY intent in no lane carries none — then the +// release the intent targets, when it names one (itd-2609212103572513 +// criterion 4). func statusRowTag(r statusblock.Row) string { + place := statusRowPlace(r) + if r.Target == "" { + return place + } + target := "target " + termsafe.Sanitize(r.Target) + if place == "" { + return target + } + return place + "; " + target +} + +// statusRowPlace is what places a Now or Next row where it is: its lane state +// or "next up", or nothing. +func statusRowPlace(r statusblock.Row) string { switch { case r.Lane != nil: l := r.Lane diff --git a/internal/surface/cli/board_status_test.go b/internal/surface/cli/board_status_test.go index bacc98ec7..6e6c4f682 100644 --- a/internal/surface/cli/board_status_test.go +++ b/internal/surface/cli/board_status_test.go @@ -223,3 +223,35 @@ func TestBoardRendersLaterAsACount(t *testing.T) { } } } + +// TestBoardRowShowsItsTarget is itd-2609212103572513 criterion 4 at the text +// board: a Now or Next row whose intent names a release shows the target in +// its brackets, after what places it there; a row with none shows none. +// Later is a count on the text board (ruling BV1), so a Later row's target is +// in --json and on the site's Status page. +func TestBoardRowShowsItsTarget(t *testing.T) { + var buf bytes.Buffer + renderBoardStatus(&buf, &statusblock.Block{ + Now: []statusblock.Row{ + {ID: "itd-7", Title: "In a lane", Bucket: "planned", Target: "v0.11.0", + Lane: &statusblock.Lane{Run: "run-1", Lane: "lane-1", Stage: "implement"}}, + {ID: "itd-5", Title: "The head", Bucket: "planned", Target: "next", NextUp: true}, + }, + Next: []statusblock.Row{ + {ID: "itd-5", Title: "The head", Bucket: "planned", Target: "next"}, + {ID: "itd-6", Title: "Untargeted", Bucket: "planned"}, + }, + Later: []statusblock.Row{}, + }) + got := buf.String() + for _, want := range []string{ + " itd-7 In a lane [lane-1: implement (run-1); target v0.11.0]\n", + " itd-5 The head [next up; target next]\n", + " itd-5 The head [target next]\n", + " itd-6 Untargeted\n", + } { + if !strings.Contains(got, want) { + t.Errorf("the board must carry %q:\n%s", want, got) + } + } +} diff --git a/internal/surface/cli/bootstrap_cache_test.go b/internal/surface/cli/bootstrap_cache_test.go index 87e685065..e5e8b9aa8 100644 --- a/internal/surface/cli/bootstrap_cache_test.go +++ b/internal/surface/cli/bootstrap_cache_test.go @@ -11,6 +11,8 @@ import ( "sync/atomic" "testing" "time" + + "github.com/intentdriven/abcd/internal/core/update" ) // spc-35: the harness's persistent per-plugin data directory @@ -348,8 +350,11 @@ func TestBootstrapNewReleaseLeavesForeignPathFileAlone(t *testing.T) { // shell is known to resolve (iss-207). func assertPathRefreshRefusedLoudly(t *testing.T, out, home, pathCopy, reason string) { t.Helper() - if got := firstLine(out); !strings.HasPrefix(got, "abcd bootstrap: installed") { - t.Errorf("the success must still lead the first visible line; first line = %q", got) + // A new release leads with the update line it swapped in (CJ1b); the + // install success follows it on the same line. + got := strings.TrimPrefix(firstLine(out), update.UpdatedLine("v9.9.8", bootstrapTag)+". ") + if !strings.HasPrefix(got, "abcd bootstrap: installed") { + t.Errorf("the success must still lead the first visible line; first line = %q", firstLine(out)) } if strings.Count(strings.TrimSpace(out), "\n") != 0 { t.Errorf("the notice must stay one line — only the first line of a hook's stderr reaches the transcript; output %q", out) diff --git a/internal/surface/cli/bootstrap_update_test.go b/internal/surface/cli/bootstrap_update_test.go new file mode 100644 index 000000000..b3e894030 --- /dev/null +++ b/internal/surface/cli/bootstrap_update_test.go @@ -0,0 +1,162 @@ +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/update" +) + +// The ruling CJ1 has the installer record the release it replaced, in the +// binary-meta it writes at the swap, and CJ1b has it say "abcd updated from X +// to Y" ONCE, when the swap completes, so the session check needs to neither +// show it nor write anything (iss-2609291942520919). + +// firstLine (hooks_sessionstart_test.go) is the one line of a hook's stderr the +// harness relays (iss-208), which is why the update statement must sit there. + +// TestBootstrapNewReleaseRecordsAndReportsTheUpdateOnce: a cache swap from an +// older release records that release as previous_tag and leads its notice with +// the shared update line; the next root, served from the cache, and the fast +// path that follows say nothing about an update. +func TestBootstrapNewReleaseRecordsAndReportsTheUpdateOnce(t *testing.T) { + data := t.TempDir() + old := []byte("#!/bin/sh\n# old release\nexit 0\n") + fresh := []byte("#!/bin/sh\n# new release\nexit 0\n") + seedBootstrapCache(t, data, "v9.9.8", old) + fx := bootstrapServer(t, fresh, bootstrapManifest(fresh)) + want := update.UpdatedLine("v9.9.8", bootstrapTag) + + root := bootstrapRoot(t) + out, code := runBootstrapWithData(t, root, data, fx, "") + if code != 0 { + t.Fatalf("a new release must install, got %d (output %q)", code, out) + } + if !strings.HasPrefix(firstLine(out), want) { + t.Errorf("the swap's first output line must lead with %q; got %q", want, firstLine(out)) + } + if n := strings.Count(out, want); n != 1 { + t.Errorf("the update line must be printed exactly once, got %d in %q", n, out) + } + meta := cacheMetaValues(t, data) + if meta["previous_tag"] != "v9.9.8" { + t.Errorf("the cache meta must record the replaced release as previous_tag; got %v", meta) + } + if _, ok := meta["transition_unseen"]; ok { + t.Errorf("a run whose output is relayed must not flag the transition as unseen; got %v", meta) + } + + // The fast path of the same root: nothing at all. + if out, _ := runBootstrapWithData(t, root, data, fx, ""); strings.Contains(out, "updated from") { + t.Errorf("the fast path must not repeat the update line; got %q", out) + } + // A second root served from the now-current cache: no swap, no line. + out, code = runBootstrapWithData(t, bootstrapRootNamed(t, strings.Repeat("a", 40)), data, fx, "") + if code != 0 { + t.Fatalf("a cache hit must install, got %d (output %q)", code, out) + } + if strings.Contains(out, "updated from") { + t.Errorf("a cache hit swaps no release and must not report an update; got %q", out) + } +} + +// TestBootstrapUnseenSwapFlagsTheTransition: the salvage runs in the +// per-prompt, per-command and pre-compaction hooks discard this script's output +// (hooks/hooks.json passes --unseen there), so a swap made there says so in the +// record, which is what lets the next session start show it once. +func TestBootstrapUnseenSwapFlagsTheTransition(t *testing.T) { + data := t.TempDir() + old := []byte("#!/bin/sh\n# old release\nexit 0\n") + fresh := []byte("#!/bin/sh\n# new release\nexit 0\n") + seedBootstrapCache(t, data, "v9.9.8", old) + fx := bootstrapServer(t, fresh, bootstrapManifest(fresh)) + bootstrapRequires(t) + script := bootstrapFixtureScript(t, fx.base) + wrapper := filepath.Join(t.TempDir(), "unseen.sh") + if err := os.WriteFile(wrapper, []byte("#!/bin/sh\nexec '"+script+"' --unseen\n"), 0o755); err != nil { + t.Fatal(err) + } + out, code := runScript(t, wrapper, bootstrapRoot(t), append(fx.env(), "CLAUDE_PLUGIN_DATA="+data), "") + if code != 0 { + t.Fatalf("a new release must install, got %d (output %q)", code, out) + } + meta := cacheMetaValues(t, data) + if meta["previous_tag"] != "v9.9.8" || meta["transition_unseen"] != "yes" { + t.Errorf("an unseen swap must record previous_tag and transition_unseen=yes; got %v", meta) + } +} + +// TestBootstrapDegradedSwapReportsTheReplacedRelease: without a data dir the +// per-root fetch knows the replaced release only from the root's own record, +// which survives when the binary beside it was removed. +func TestBootstrapDegradedSwapReportsTheReplacedRelease(t *testing.T) { + root := bootstrapRoot(t) + prior := "release_tag=v9.9.8\nrelease_sha=" + bootstrapRelease + "\nbinary_sha256=" + strings.Repeat("0", 64) + "\nfetched_at=2026-08-01T00:00:00Z\n" + if err := os.WriteFile(filepath.Join(root, ".binary-meta"), []byte(prior), 0o644); err != nil { + t.Fatal(err) + } + body := []byte("#!/bin/sh\nexit 0\n") + fx := bootstrapServer(t, body, bootstrapManifest(body)) + out, code := runBootstrap(t, root, fx, "") + if code != 0 { + t.Fatalf("the degraded install must succeed, got %d (output %q)", code, out) + } + if want := update.UpdatedLine("v9.9.8", bootstrapTag); !strings.HasPrefix(firstLine(out), want) { + t.Errorf("the degraded swap must lead with %q; got %q", want, firstLine(out)) + } + if got := metaValues(t, root)["previous_tag"]; got != "v9.9.8" { + t.Errorf("the root meta must record previous_tag=v9.9.8; got %q", got) + } +} + +// TestBootstrapFirstInstallReportsNoUpdate: a fresh root with no record of an +// earlier release is an install, not an update, and says nothing of one. +func TestBootstrapFirstInstallReportsNoUpdate(t *testing.T) { + root := bootstrapRoot(t) + body := []byte("#!/bin/sh\nexit 0\n") + fx := bootstrapServer(t, body, bootstrapManifest(body)) + out, code := runBootstrapWithData(t, root, t.TempDir(), fx, "") + if code != 0 { + t.Fatalf("a first install must succeed, got %d (output %q)", code, out) + } + if strings.Contains(out, "updated from") { + t.Errorf("a first install must not report an update; got %q", out) + } +} + +// TestBootstrapUpdateLineIsTheSharedWording: one wording, one place. The script +// cannot import update.UpdatedFormat, so its printf literal is held to it. +func TestBootstrapUpdateLineIsTheSharedWording(t *testing.T) { + body := mustReadFile(t, bootstrapScript(t)) + if want := "printf '" + update.UpdatedFormat + "'"; strings.Count(body, want) != 1 { + t.Errorf("hooks/bootstrap.sh must carry the update line exactly once as %s", want) + } +} + +// TestDiscardedSalvageRunsPassUnseen: every hook entry that runs the bootstrap +// with its output thrown away tells it so, and the one entry that relays the +// output (SessionStart) does not, so the update line reaches a reader exactly +// once whichever entry performed the swap. +func TestDiscardedSalvageRunsPassUnseen(t *testing.T) { + doc := decodedHooksManifest(t) + for event, entries := range doc.Hooks { + for _, entry := range entries { + for _, h := range entry.Hooks { + if !strings.Contains(h.Command, "hooks/bootstrap.sh") { + continue + } + discarded := strings.Contains(h.Command, `/hooks/bootstrap.sh" >/dev/null 2>&1`) || + strings.Contains(h.Command, `/hooks/bootstrap.sh" --unseen >/dev/null 2>&1`) + unseen := strings.Contains(h.Command, `bootstrap.sh" --unseen`) + switch { + case event == "SessionStart" && unseen: + t.Errorf("SessionStart relays the bootstrap's stderr and must not pass --unseen") + case event != "SessionStart" && discarded && !unseen: + t.Errorf("%s discards the bootstrap's output and must pass --unseen", event) + } + } + } + } +} diff --git a/internal/surface/cli/build.go b/internal/surface/cli/build.go index ac27f61d2..3d66dbb3f 100644 --- a/internal/surface/cli/build.go +++ b/internal/surface/cli/build.go @@ -92,7 +92,7 @@ func newBuildCommand(asJSON *bool) *cobra.Command { Long: "Start the implement loop for one intent, or resume the run already in progress for it.\n" + "A new run's checks run first, and every one must pass:\n" + "the intent is READY (planned, criteria written, its spec linked and written), asks no\n" + - "open question, has no unanswered claim section, is not held, names no unshipped intent\n" + + "open question, has no unanswered claim section, is not held, names no unsettled blocker\n" + "in `blocked_by`, its spec leaves a step to build, and no peer holds it (no sibling\n" + "worktree or local branch holds it in another bucket, and no session holds a live claim\n" + "on it; a peer or claim that cannot be read counts as holding it). A refusal names the\n" + @@ -182,7 +182,7 @@ func newBuildNextCommand(asJSON *bool) *cobra.Command { Use: "next [--session <id>] [--pace <work-minutes>/<pause-minutes>] [--sub-agents <n>] [--max <n>] [--until-empty]", Long: "Pick the readiest planned intent, write down why, and start its run.\n\n" + "The candidates are the planned intents that pass every check `abcd build <itd-N>` runs\n" + - "(READY, no open question, no unanswered claim section, not held, no unshipped intent in\n" + + "(READY, no open question, no unanswered claim section, not held, no unsettled blocker in\n" + "`blocked_by`, a step left to build, no peer holding it), less one this checkout already has\n" + "a run in progress for. Each is scored from its record, three parts at equal weight, each 0\n" + "to 100: criteria clarity (the share of its acceptance criteria in Given-When-Then form), a\n" + diff --git a/internal/surface/cli/capture_remedy_required_test.go b/internal/surface/cli/capture_remedy_required_test.go index a828189ca..44fe85d7a 100644 --- a/internal/surface/cli/capture_remedy_required_test.go +++ b/internal/surface/cli/capture_remedy_required_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/intentdriven/abcd/internal/core/capture" + "github.com/intentdriven/abcd/internal/core/drainrule" "github.com/intentdriven/abcd/internal/core/issueschema" ) @@ -61,7 +62,7 @@ func TestCaptureRefusesTheMachineRemedyFromAPerson(t *testing.T) { // remedy` writes the person's fix, says what it replaced, and the dry run then // lists the record as eligible. func TestCaptureRemedyVerbWritesTheRemedyAndTheDrainTakesIt(t *testing.T) { - repo := captureLedgerRepo(t) + repo := drainRuleRepo(t, drainrule.ProposalFrontmatter()) res, err := capture.Capture(capture.CaptureRequest{ RepoRoot: repo, Text: "a nil map is written before it is made", Severity: "minor", Category: "bug", Source: "agent-finding", FoundDuring: "t", Remedy: issueschema.MachineRemedy, diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index b1f344c8d..7e12b4399 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -1845,14 +1845,19 @@ an error included, exits 0, so the hook can never wedge a session.`, notices = append(notices, n) } } - // itd-111 (AC6): a version transition performed since this repo was - // last set up — the running binary differs from the recorded - // setup_version. Report only; the fetch that changed it is - // provisioning's job. Both values come from disk (config + build info). - if from, to, changed := ahoy.VersionTransition(cwd); changed { - notices = append(notices, fmt.Sprintf( - "abcd: the running binary is version %s, but this repo was last set up with %s — run `/abcd:ahoy install` (or `abcd ahoy install`) to reconcile the recorded version.", - termsafe.Sanitize(to), termsafe.Sanitize(from))) + // itd-111 (AC6): an update is announced once, by whatever swapped + // the binary, when the swap completes (the ruling CJ1b), so session + // start shows nothing about it — except the one swap whose output + // no one read: the bootstrap salvage the per-prompt, per-command and + // pre-compaction hooks run with their output discarded. That one is + // shown here once, and its marker is this hook's single write. + pluginRoot := os.Getenv("ABCD_PLUGIN_ROOT") + if pluginRoot == "" { + pluginRoot = os.Getenv("CLAUDE_PLUGIN_ROOT") + } + if from, to, ok := ahoy.TakeUnseenUpdate(pluginRoot, cwd); ok { + notices = append(notices, update.UpdatedLine(termsafe.Sanitize(from), termsafe.Sanitize(to))+ + " — the update ran while a hook discarded its output, so it is reported here, once.") } // The inbox greeting (itd-2609221656361680): one line saying how // many reports wait and from how many repositories, and nothing @@ -2168,10 +2173,14 @@ is named on stderr, with the file that set the list, here and on every hook prompt. To keep an entry, restate it in the list, or leave the field out to inherit the bundled list. -SHELL is generated from the bundled shell-hazard registry that "abcd guard" -enforces: one rule per registry entry, naming the command, why it is dangerous -and what to run instead, recalled by the commands the registry names. It -teaches before shell work what the guard refuses at the moment a command runs. +SHELL is generated from the shell-hazard registry that "abcd guard" enforces +in this repository, the bundled entries and the repository's own +.abcd/guard.json entries alike: one rule per registry entry, naming the +command, why it is dangerous and what to run instead, recalled by the commands +the registry names. A rule in the repository's words is marked "(repo)" after +its entry id. A guard.json the guard refuses is named on stderr and not taught; +SHELL then teaches the registry the guard enforces in its place. It teaches +before shell work what the guard refuses at the moment a command runs. Read-only.`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -3441,6 +3450,10 @@ func newAhoyCommand(asJSON *bool) *cobra.Command { fmt.Fprintf(w, " provider: none configured (optional); every delegated step runs on the host — `abcd ahoy --providers` explains the adapter\n") case ahoy.ProviderAdapterRefusedGapID: fmt.Fprintf(w, " provider: configuration refused — %s\n", termsafe.Sanitize(g.Detail)) + case ahoy.ProviderAdapterRouteSkippedGapID: + for _, d := range strings.Split(g.Detail, "\n") { + fmt.Fprintf(w, " provider: route skipped — %s\n", termsafe.Sanitize(d)) + } } } if res.FolderKind != ahoy.UnmanagedFolder { @@ -3551,16 +3564,27 @@ func newAhoyCommand(asJSON *bool) *cobra.Command { fmt.Fprintf(w, " remaining gaps: %s\n", strings.Join(res.Remaining, ", ")) } // --yes approves every category but never writes the identity - // pin, the status-line wiring or a routing table, so say which optional work it - // left, why each needs an answer, and how to apply it. + // pin, the status-line wiring, a routing table or the drain + // rule, and off a terminal the drain rule is not asked at all, + // so say which optional work it left, why each needs an answer, + // and how to apply it. if len(res.OptionalSkipped) > 0 { - fmt.Fprintf(w, " optional, not covered by --yes: %s\n", strings.Join(res.OptionalSkipped, ", ")) + label := "optional, not covered by --yes" + if !yes { + label = "optional, asked only at a terminal" + } + fmt.Fprintf(w, " %s: %s\n", label, strings.Join(res.OptionalSkipped, ", ")) for _, id := range res.OptionalSkipped { if why := optionalSkipReason(id); why != "" { fmt.Fprintf(w, " %s\n", why) } } - fmt.Fprint(w, " run `abcd ahoy install` (no --yes) and answer y at each prompt — non-interactively, `yes | abcd ahoy install`\n") + if yes { + fmt.Fprint(w, " run `abcd ahoy install` (no --yes) and answer y at each prompt — non-interactively, `yes | abcd ahoy install`\n") + } + if slices.Contains(res.OptionalSkipped, ahoy.DrainRuleOfferGapID) { + fmt.Fprint(w, " the drain rule is asked only of a person at a terminal: run `abcd ahoy install` there, without --yes, and answer it\n") + } } }) }, @@ -3735,11 +3759,17 @@ func newAhoyRemoteCommand(asJSON *bool) *cobra.Command { // adr-44 / invariant 10: the remote write is CONFIRMED as well as // invoked. An unanswered run declines, so a script that pipes nothing // changes nothing; --yes is the explicit way to say yes in advance. + // + // A missing gh is offered for install only to a person at a + // terminal: --yes answers the settings change, never the install + // of a program (the DQ3 ruling), so the offer's confirmation is + // built from the prompter before --yes replaces it. p := newPrompter(cmd) + confirmTool := terminalToolConfirm(p, remoteYes, cmd.ErrOrStderr()) if remoteYes { p = alwaysConfirm{} } - res, err := ahoy.RemoteApply(cwd, p) + res, err := ahoy.RemoteApply(cwd, p, confirmTool) if err != nil { return err } @@ -3761,7 +3791,7 @@ func newAhoyRemoteCommand(asJSON *bool) *cobra.Command { return nil }, } - applyCmd.Flags().BoolVar(&remoteYes, "yes", false, "confirm the remote change without being asked; without it an unanswered run declines and changes nothing") + applyCmd.Flags().BoolVar(&remoteYes, "yes", false, "confirm the remote change without being asked (never the install of a missing gh); without it an unanswered run declines and changes nothing") remoteCmd.AddCommand(applyCmd) return remoteCmd } @@ -3884,6 +3914,8 @@ func optionalSkipReason(id string) string { return "the status line rewrites a setting of the host harness and takes element choices, so it is only written against an answered prompt" case ahoy.OracleRoutingMachineGapID, ahoy.OracleRoutingRepoGapID: return "a routing table decides which model every delegated step asks for, so abcd's proposal is only accepted against an answered prompt" + case ahoy.DrainRuleOfferGapID: + return "the drain eligibility record decides what an unattended agent may change in this repository, so it is only added against a prompt answered at a terminal" } return "" } @@ -3914,16 +3946,36 @@ func installToolNames(names []string) (map[string]bool, error) { // --install-tool, which is how a host relays the answer its own question tool // got. --yes never installs a tool. Every no carries the way to say yes. func toolConfirm(p ahoy.Prompter, named map[string]bool, yes bool, w io.Writer) tools.Confirm { + ask := askToolAtTerminal(p, yes, w, func(e tools.Explanation) string { return "name it with --install-tool " + e.Tool }) return func(e tools.Explanation) tools.Answer { if named[e.Tool] { return tools.Answer{Yes: true, Why: "named with --install-tool"} } + return ask(e) + } +} + +// terminalToolConfirm is the install question at a verb with no +// --install-tool: the gh offer at ahoy remote apply (the product thinker's +// DQ3 ruling, 2026-09-29). Its only yes is one typed at a terminal; --yes, a +// piped stream and a caller with no terminal each decline, carrying the +// command the person can run themselves. +func terminalToolConfirm(p ahoy.Prompter, yes bool, w io.Writer) tools.Confirm { + return askToolAtTerminal(p, yes, w, func(e tools.Explanation) string { return "install it yourself with " + e.StepText() }) +} + +// askToolAtTerminal asks the install question of a person at a terminal and +// declines everywhere else. otherwise names the other way to a yes, for every +// decline to carry. The explanation and the exact step are shown before the +// question, and the step is announced as it starts. +func askToolAtTerminal(p ahoy.Prompter, yes bool, w io.Writer, otherwise func(tools.Explanation) string) tools.Confirm { + return func(e tools.Explanation) tools.Answer { if yes { - return tools.Answer{Why: "--yes never installs a tool; name it with --install-tool " + e.Tool + ", or run without --yes at a terminal"} + return tools.Answer{Why: "--yes never installs a tool; " + otherwise(e) + ", or run without --yes at a terminal"} } sp, ok := p.(*stdinPrompter) if !ok || !sp.tty { - return tools.Answer{Why: "no terminal to ask at: abcd installs a tool only on an answer typed at a terminal, or with --install-tool " + e.Tool} + return tools.Answer{Why: "no terminal to ask at: abcd installs a tool only on an answer typed at a terminal; " + otherwise(e)} } for _, line := range e.Lines() { fmt.Fprintln(w, termsafe.Sanitize(line)) @@ -3992,8 +4044,9 @@ func (p *stdinPrompter) echo(answer string) { } // AtTerminal reports whether a person is answering at a terminal, which makes -// the prompter an ahoy.TerminalPrompter: the one question abcd asks only of a -// person (whether to change who commits, itd-131) is never put to a pipe. +// the prompter an ahoy.TerminalPrompter: the questions abcd asks only of a +// person (whether to change who commits, itd-131, and whether to record the +// drain eligibility rule) are never put to a pipe. func (p *stdinPrompter) AtTerminal() bool { return p.tty } func (p *stdinPrompter) Confirm(question string) bool { @@ -4728,13 +4781,26 @@ func newCaptureCommand(asJSON *bool) *cobra.Command { if err != nil { return err } + // A reading item's promotion matches the draft it mints (ruling + // DQ2b, adr-2609300821558671), configured as the capture verb's is + // and never a refusal. The issue route and link mode mint nothing + // the match would compare. + var mc *match.Config + var matchRefused *match.Outcome + readingMint := strings.HasPrefix(args[0], issueschema.ReadingItemFamily+"-") && promoteIntent == "" + if readingMint { + mc, matchRefused = resolveMatch(cmd.ErrOrStderr(), "capture promote", repoRoot) + } res, err := capture.Promote(capture.PromoteRequest{ RepoRoot: repoRoot, ID: args[0], LinkIntent: promoteIntent, Grounds: promoteGrounds, - ProductionMode: mode, + ProductionMode: mode, Match: mc, }) if err != nil { return captureRefusal("promote", err) } + if readingMint && res.Match == nil { + res.Match = matchRefused + } return renderLedger(cmd.OutOrStdout(), *asJSON, repoRoot, res, func(w io.Writer) { verb := "minted" if res.Linked { @@ -4751,6 +4817,7 @@ func newCaptureCommand(asJSON *bool) *cobra.Command { if res.BackEdgeKept != "" { fmt.Fprintf(w, "back_edge: kept %s\n", termsafe.Sanitize(res.BackEdgeKept)) } + renderMatch(w, res.Match) emitRedactionNote(w, res.Redacted, res.Degraded) }) }, @@ -4855,7 +4922,7 @@ func newCaptureCommand(asJSON *bool) *cobra.Command { dispositionCmd.Flags().StringVar(&dispGrounds, "grounds", "", "disposition_grounds: why this answer (free text; required on every state except held)") dispositionCmd.Flags().StringVar(&dispExit, "exit-condition", "", "what would end a held disposition (required on held; a hold exits only through a superseding disposition that cites it)") dispositionCmd.Flags().StringVar(&dispSupersedes, "supersedes", "", "the standing dsp-N this answer replaces; required once an item already carries one") - dispositionCmd.Flags().StringVar(&dispRecurs, "recurs", "", "comma-separated prior rdi-ids this item recurs from — the recorded form of a warm recognition, never a mechanical join") + dispositionCmd.Flags().StringVar(&dispRecurs, "recurs", "", "comma-separated prior rdi-ids this item recurs from — the researcher's confirmed recognition; the ingest's duplicates/refines link is only a proposal") // The two-axis hold field is RESERVED and dormant. The flags exist so the // reservation is a behaviour a caller meets rather than a comment nobody // reads: a populated value is refused, and the refusal states the grammar. diff --git a/internal/surface/cli/drain.go b/internal/surface/cli/drain.go index be0c9a890..945e4c7e2 100644 --- a/internal/surface/cli/drain.go +++ b/internal/surface/cli/drain.go @@ -1,11 +1,13 @@ package cli import ( + "errors" "fmt" "io" "strings" "github.com/intentdriven/abcd/internal/core/capture" + "github.com/intentdriven/abcd/internal/core/drainrule" "github.com/intentdriven/abcd/internal/termsafe" "github.com/spf13/cobra" ) @@ -26,35 +28,51 @@ func newDrainCommand(asJSON *bool) *cobra.Command { cmd := &cobra.Command{ Use: "drain", Long: "Work the open issue ledger unattended: fix the issues that need no decision, and\n" + - "hand the rest back by kind. The rule for which issues need no decision is a\n" + - "recorded decision, and it reads the record's fields alone: nothing open in\n" + - "blocked_by; a category in the fixable set (tech-debt, documentation,\n" + - "inconsistency, drift, bug, ux); severity nitpick or minor; and a remedy: field.\n" + - "A security issue is always a person's. Every other open issue is handed back,\n" + - "listed as ineligible, or skipped naming its blocker, by the rule that excluded it.\n\n" + + "hand the rest back by kind. Which issues need no decision is this repository's own\n" + + "recorded decision: an accepted decision record whose frontmatter carries the four\n" + + "fields drain_categories, drain_severities, drain_security and drain_remedy. The\n" + + "rule reads the record's fields alone: nothing open in blocked_by; a category the\n" + + "rule takes; a severity it takes; and a remedy: field. abcd's strict baseline takes\n" + + "tech-debt, documentation, inconsistency, drift, bug and ux at nitpick or minor, and\n" + + "hands every security issue to a person. A repository's record may loosen those\n" + + "floors (major, critical, security), and every floor it loosens is named. An issue\n" + + "whose remedy opens \"Waits on\", or whose deferral past the current release tag is\n" + + "live, is always handed back. Every other open issue is handed back, listed as\n" + + "ineligible, or skipped naming its blocker, by the rule that excluded it.\n\n" + "--dry-run shows every open issue's disposition, the eligible ones first in the\n" + - "order a drain takes them (category tech-debt, documentation, inconsistency,\n" + - "drift, bug, ux; then nitpick before minor; then oldest first), and writes\n" + - "nothing. The host judgement over each eligible remedy does not run in a dry\n" + + "order a drain takes them (by category, then severity, then oldest first), and\n" + + "writes nothing. The host judgement over each eligible remedy does not run in a dry\n" + "run; it can only ever hand an issue back.\n\n" + - "The run itself is not built: without --dry-run the verb refuses to start, and\n" + - "exits 2 with nothing read or written.", + "Without the repository's record, the dry run and the run both refuse (exit 2),\n" + + "naming how to add it; `abcd ahoy install` offers it. The run itself is not built:\n" + + "without --dry-run the verb refuses to start, and exits 2 with nothing written.", Example: " abcd drain --dry-run\n abcd drain --dry-run --json", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - if !dryRun { - if err := capture.DrainStart(); err != nil { - return &exitError{Code: 2, Msg: "abcd drain: refused to start: " + err.Error() + " (nothing read, nothing written)"} - } - } repoRoot, err := ledgerRootFor(cmd, "abcd drain") if err != nil { return err } + if !dryRun { + err := capture.DrainStart(repoRoot) + return &exitError{Code: 2, Msg: "abcd drain: refused to start: " + termsafe.Sanitize(err.Error()) + " (nothing written)"} + } plan, err := capture.PlanDrain(capture.DrainPlanRequest{RepoRoot: repoRoot}) + // Every refusal of the rule exits 2, as the bare verb's does: a rule + // unrecorded, ambiguous, malformed, or unreadable (a link, a record + // past the size cap). + if isDrainRuleRefusal(err) { + return &exitError{Code: 2, Msg: "abcd drain: " + termsafe.Sanitize(err.Error()) + " (nothing written)"} + } if err != nil { return fmt.Errorf("abcd drain: %w", err) } + // A loosened floor is loud (ruling H11): named on stderr whatever the + // output mode, so a reader of --json sees it too. + if len(plan.Loosened) > 0 { + fmt.Fprintf(cmd.ErrOrStderr(), "abcd drain: warning: this repository's rule (%s) loosens abcd's floors: a drain may take %s\n", + termsafe.Sanitize(plan.Record), termsafe.Sanitize(strings.Join(plan.Loosened, ", "))) + } out := drainOutput{DryRun: true, DrainPlan: plan} return renderLedger(cmd.OutOrStdout(), *asJSON, repoRoot, out, func(w io.Writer) { renderDrainPlan(w, plan) }) }, @@ -63,6 +81,17 @@ func newDrainCommand(asJSON *bool) *cobra.Command { return cmd } +// isDrainRuleRefusal reports whether err is the rule load refusing: every one +// of drainrule's sentinels. +func isDrainRuleRefusal(err error) bool { + for _, s := range []error{drainrule.ErrUnrecorded, drainrule.ErrMalformed, drainrule.ErrAmbiguous, drainrule.ErrUnreadable} { + if errors.Is(err, s) { + return true + } + } + return false +} + // drainOutcomes is the order the counts line names the dispositions in. var drainOutcomes = []capture.DrainOutcome{ capture.DrainEligible, capture.DrainHandBack, capture.DrainIneligible, @@ -73,7 +102,21 @@ var drainOutcomes = []capture.DrainOutcome{ // line per open issue, and the counts. Every runtime string is sanitised. func renderDrainPlan(w io.Writer, plan capture.DrainPlan) { fmt.Fprintf(w, "abcd drain --dry-run: %d open issue(s) classified by field; writes nothing\n", len(plan.Dispositions)) - fmt.Fprintf(w, " rule: %s (which issues need no decision)\n", termsafe.Sanitize(plan.Record)) + fmt.Fprintf(w, " rule: %s, this repository's own record (which issues need no decision)\n", termsafe.Sanitize(plan.Record)) + if len(plan.Loosened) == 0 { + fmt.Fprintln(w, " floors: the rule loosens none of abcd's floors") + } else { + fmt.Fprintln(w, " LOOSENED: this repository's rule lets a drain take what abcd's baseline hands to a person:") + for _, f := range plan.Loosened { + fmt.Fprintf(w, " - %s\n", termsafe.Sanitize(f)) + } + } + if plan.Anchor != "" { + fmt.Fprintf(w, " anchor: %s (a deferral past it is live)\n", termsafe.Sanitize(plan.Anchor)) + } + if plan.AnchorUnknown { + fmt.Fprintln(w, " anchor: unknown: this checkout holds no release tag (a shallow clone fetches none), so every record carrying a deferral is handed back; `git fetch --tags` and drain again") + } fmt.Fprintf(w, " order: %s\n", termsafe.Sanitize(plan.Order)) for _, v := range plan.Dispositions { kind := strings.TrimSpace(string(v.Severity) + " " + string(v.Category)) diff --git a/internal/surface/cli/drain_surface_test.go b/internal/surface/cli/drain_surface_test.go index 2000cb2dc..04b534f01 100644 --- a/internal/surface/cli/drain_surface_test.go +++ b/internal/surface/cli/drain_surface_test.go @@ -7,6 +7,9 @@ import ( "path/filepath" "strings" "testing" + + "github.com/intentdriven/abcd/internal/core/capture" + "github.com/intentdriven/abcd/internal/core/drainrule" ) // The front doors of the drain's field-only slice (itd-82, @@ -52,7 +55,7 @@ func TestCaptureRemedyFlagWritesTheField(t *testing.T) { // each open issue once with its disposition and rule, states the order and the // rule's record, in text and in --json, and leaves the ledger as it was. func TestDrainDryRunRendersEveryDispositionAndWritesNothing(t *testing.T) { - repo := captureLedgerRepo(t) + repo := drainRuleRepo(t, drainrule.ProposalFrontmatter()) eligible := captureWithRemedy(t, "a nil map is written before it is made", "--category", "bug", "--remedy", "make the map first") // A legacy record: filed before the remedy was required, so it carries // none. capture refuses such a filing now (ruling BX3), so the key is taken @@ -106,7 +109,7 @@ func TestDrainDryRunRendersEveryDispositionAndWritesNothing(t *testing.T) { // TestDrainWithoutDryRunRefusesToStart: the run itself is not built, so a bare // `drain` refuses, says why, points at the dry run, and writes nothing. func TestDrainWithoutDryRunRefusesToStart(t *testing.T) { - repo := captureLedgerRepo(t) + repo := drainRuleRepo(t, drainrule.ProposalFrontmatter()) captureWithRemedy(t, "a nil map is written before it is made", "--category", "bug", "--remedy", "make the map first") before := ledgerIssueCount(t, repo) @@ -148,3 +151,178 @@ func stripRemedyLine(t *testing.T, repo, id string) { t.Fatal(err) } } + +// drainRuleRepo is a ledger checkout holding its own drain eligibility record +// (ruling BX2), an accepted decision record carrying the drain fields given. +func drainRuleRepo(t *testing.T, fields string) string { + t.Helper() + repo := captureLedgerRepo(t) + dir := filepath.Join(repo, filepath.FromSlash(drainrule.ADRsRelDir)) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + body := "---\nid: adr-2609300000000002\nslug: drain-rule\nstatus: accepted\ndate: 2026-09-30\n" + fields + "---\n\n# ADR\n" + if err := os.WriteFile(filepath.Join(dir, "2609300000000002-drain-rule.md"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return repo +} + +// TestDrainRefusesARepositoryWithoutItsOwnRule is ruling BX2 at the front door: +// with no eligibility record of the repository's own, the dry run and the bare +// verb both refuse (exit 2), name how to add the record, and write nothing. +func TestDrainRefusesARepositoryWithoutItsOwnRule(t *testing.T) { + repo := captureLedgerRepo(t) + captureWithRemedy(t, "a nil map is written before it is made", "--category", "bug", "--remedy", "make the map first") + before := ledgerIssueCount(t, repo) + for _, args := range [][]string{{"drain", "--dry-run"}, {"drain", "--dry-run", "--json"}, {"drain"}} { + var stdout, stderr bytes.Buffer + code := Run(args, &stdout, &stderr) + msg := stdout.String() + stderr.String() + if code != 2 { + t.Fatalf("%v exited %d, want 2:\n%s", args, code, msg) + } + for _, want := range []string{"no drain eligibility record", "ahoy install", drainrule.FieldCategories, "nothing written"} { + if !strings.Contains(msg, want) { + t.Errorf("%v: the refusal does not say %q:\n%s", args, want, msg) + } + } + } + if _, err := os.Stat(filepath.Join(repo, filepath.FromSlash(drainrule.ADRsRelDir))); !os.IsNotExist(err) { + t.Errorf("a refused drain created the decision store: %v", err) + } + if after := ledgerIssueCount(t, repo); after != before { + t.Fatalf("a refused drain changed the ledger") + } +} + +// TestDrainNamesEveryLoosenedFloor is ruling H11 at the front door: a project +// whose record lets a drain take major and security issues has every loosened +// floor named by the dry run's text, on stderr, in --json, and by the start's +// refusal. +func TestDrainNamesEveryLoosenedFloor(t *testing.T) { + drainRuleRepo(t, "drain_categories: [tech-debt, documentation, inconsistency, drift, bug, ux]\n"+ + "drain_severities: [nitpick, minor, major]\ndrain_security: take\ndrain_remedy: required\n") + major := captureWithRemedy(t, "the parser drops a whole record", "--category", "bug", "--severity", "major", "--remedy", "rewrite it") + + var stdout, stderr bytes.Buffer + if code := Run([]string{"drain", "--dry-run"}, &stdout, &stderr); code != 0 { + t.Fatalf("dry run exited %d:\n%s%s", code, stdout.String(), stderr.String()) + } + for _, want := range []string{"LOOSENED", "severity major", "security", "adr-2609300000000002", major} { + if !strings.Contains(stdout.String(), want) { + t.Errorf("the dry-run text does not carry %q:\n%s", want, stdout.String()) + } + } + if !strings.Contains(stderr.String(), "loosens abcd's floors") || !strings.Contains(stderr.String(), "severity major, security") { + t.Errorf("stderr does not warn of the loosened floors:\n%s", stderr.String()) + } + + stdout.Reset() + stderr.Reset() + if code := Run([]string{"drain", "--dry-run", "--json"}, &stdout, &stderr); code != 0 { + t.Fatalf("--json dry run exited %d:\n%s%s", code, stdout.String(), stderr.String()) + } + var plan struct { + Loosened []string `json:"loosened"` + Rule struct { + Record string `json:"record"` + Severities []string `json:"severities"` + Security string `json:"security"` + } `json:"rule"` + } + if err := json.Unmarshal(stdout.Bytes(), &plan); err != nil { + t.Fatalf("--json is not JSON: %v\n%s", err, stdout.String()) + } + if strings.Join(plan.Loosened, ",") != "severity major,security" || plan.Rule.Security != "take" || plan.Rule.Record == "" { + t.Errorf("--json plan = %+v", plan) + } + if !strings.Contains(stderr.String(), "loosens abcd's floors") { + t.Errorf("--json does not warn of the loosened floors on stderr:\n%s", stderr.String()) + } + + stdout.Reset() + stderr.Reset() + code := Run([]string{"drain"}, &stdout, &stderr) + msg := stdout.String() + stderr.String() + if code != 2 || !strings.Contains(msg, "loosens abcd's floors") || !strings.Contains(msg, "severity major, security") { + t.Errorf("the start (exit %d) does not name the loosened floors:\n%s", code, msg) + } +} + +// TestDrainOnTheStrictRuleNamesNoLoosening: the baseline says so in one line, +// and warns of nothing. +func TestDrainOnTheStrictRuleNamesNoLoosening(t *testing.T) { + drainRuleRepo(t, drainrule.ProposalFrontmatter()) + captureWithRemedy(t, "a nil map is written before it is made", "--category", "bug", "--remedy", "make the map first") + var stdout, stderr bytes.Buffer + if code := Run([]string{"drain", "--dry-run"}, &stdout, &stderr); code != 0 { + t.Fatalf("dry run exited %d:\n%s%s", code, stdout.String(), stderr.String()) + } + if !strings.Contains(stdout.String(), "loosens none of abcd's floors") || strings.Contains(stdout.String(), "LOOSENED") { + t.Errorf("the strict rule's dry run:\n%s", stdout.String()) + } + if strings.Contains(stderr.String(), "loosens") { + t.Errorf("the strict rule warns on stderr:\n%s", stderr.String()) + } +} + +// TestEveryRefusalOfTheRuleExitsTwo: a rule the drain cannot read safely (a +// store or a record that is a symlink out of the checkout) refuses with exit 2 +// on the dry run and the bare verb alike, as every other refusal of the rule +// does, and writes nothing. +func TestEveryRefusalOfTheRuleExitsTwo(t *testing.T) { + outside := t.TempDir() + loose := filepath.Join(outside, "2609300000000003-rule.md") + body := "---\nid: adr-2609300000000003\nstatus: accepted\ndrain_categories: [bug]\ndrain_severities: [minor]\ndrain_security: take\ndrain_remedy: required\n---\n" + if err := os.WriteFile(loose, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + for name, link := range map[string]func(repo string) error{ + "symlinked store": func(repo string) error { + parent := filepath.Join(repo, ".abcd", "development", "decisions") + if err := os.MkdirAll(parent, 0o755); err != nil { + return err + } + return os.Symlink(outside, filepath.Join(parent, "adrs")) + }, + "symlinked record": func(repo string) error { + dir := filepath.Join(repo, filepath.FromSlash(drainrule.ADRsRelDir)) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + return os.Symlink(loose, filepath.Join(dir, "2609300000000003-rule.md")) + }, + } { + t.Run(name, func(t *testing.T) { + repo := captureLedgerRepo(t) + if err := link(repo); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{{"drain", "--dry-run"}, {"drain", "--dry-run", "--json"}, {"drain"}} { + var stdout, stderr bytes.Buffer + code := Run(args, &stdout, &stderr) + msg := stdout.String() + stderr.String() + if code != 2 { + t.Errorf("%v exited %d, want 2:\n%s", args, code, msg) + } + if !strings.Contains(msg, "nothing written") { + t.Errorf("%v: the refusal does not say nothing was written:\n%s", args, msg) + } + } + }) + } +} + +// TestDrainDryRunSaysWhenTheAnchorIsUnknown: a checkout with no release tag +// cannot say whether a deferral is live, and the dry run says so above the +// records it hands back, naming how to fetch the tags. +func TestDrainDryRunSaysWhenTheAnchorIsUnknown(t *testing.T) { + var buf bytes.Buffer + renderDrainPlan(&buf, capture.DrainPlan{Record: "adr-1", Loosened: []string{}, AnchorUnknown: true}) + for _, want := range []string{"anchor: unknown", "no release tag", "git fetch --tags"} { + if !strings.Contains(buf.String(), want) { + t.Errorf("the dry run does not say %q:\n%s", want, buf.String()) + } + } +} diff --git a/internal/surface/cli/filing_match_surface_test.go b/internal/surface/cli/filing_match_surface_test.go new file mode 100644 index 000000000..27d7070bf --- /dev/null +++ b/internal/surface/cli/filing_match_surface_test.go @@ -0,0 +1,75 @@ +package cli + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/capture" + "github.com/intentdriven/abcd/internal/core/issueschema" + "github.com/intentdriven/abcd/internal/core/report" + "github.com/intentdriven/abcd/internal/gitutil" +) + +// filing_match_surface_test.go is the wiring proof for the filing-time match +// on the two unattended writers (itd-2609212137116617, iss-2609281911024185): +// `inbox promote` and `intent consistency ingest` run it through the layered +// configuration, and print what it found as the capture verb does. + +const ( + fmHeld = "The capture ledger reader silently skips a record whose frontmatter carries " + + "a duplicated key, so the finding disappears from every listing without a warning." + fmDouble = "Capture ledger reader silently skips any record whose frontmatter carries a " + + "duplicated key: the finding disappears from every listing, and no warning is printed." +) + +// TestInboxPromoteMatchesTheLedger: a promoted report that doubles an open +// record is filed with the link written, and the verb says so. +func TestInboxPromoteMatchesTheLedger(t *testing.T) { + repo, _ := gitRepoNoStore(t) + t.Chdir(repo) + held, err := capture.Capture(capture.CaptureRequest{ + RepoRoot: repo, Text: fmHeld, Severity: capture.SeverityMinor, Category: "bug", + Source: "user-observation", FoundDuring: "fixture", Remedy: issueschema.MachineRemedy, + }) + if err != nil { + t.Fatal(err) + } + skeleton := string(runCLI(t, "report", "--template")) + filed := string(runCLIStdin(t, fillTemplate(t, skeleton, "Ledger reader skips duplicated keys", fmDouble), "report", "-")) + i := strings.Index(filed, "rpt-") + if i < 0 { + t.Fatalf("report did not name an id:\n%s", filed) + } + id := filed[i : i+20] + t.Cleanup(report.SetAbcdRootCommitForTest(gitutil.RootCommit(repo))) + + out := string(runCLI(t, "inbox", "promote", id)) + if !strings.Contains(out, "matched "+held.ID) || !strings.Contains(out, "link written") { + t.Fatalf("promote does not report the match on %s:\n%s", held.ID, out) + } +} + +// TestIntentConsistencyIngestReportsTheMatch: every finding the pass files +// carries the filing-time match's outcome, in the text render and in --json. +func TestIntentConsistencyIngestReportsTheMatch(t *testing.T) { + repo := consistencyCLIRepo(t) + var em consistencyEmitted + if err := json.Unmarshal(runCLI(t, "intent", "consistency", "--json"), &em); err != nil { + t.Fatal(err) + } + fp := consistencyFindingsFile(t, repo, em) + var res struct { + Rows []struct { + Match *struct { + Threshold float64 `json:"threshold"` + } `json:"match"` + } `json:"rows"` + } + if err := json.Unmarshal(runCLI(t, "intent", "consistency", "ingest", "--findings-json", fp, "--json"), &res); err != nil { + t.Fatal(err) + } + if len(res.Rows) != 1 || res.Rows[0].Match == nil || res.Rows[0].Match.Threshold == 0 { + t.Fatalf("ingest --json rows = %+v; want the match outcome on the filed row", res.Rows) + } +} diff --git a/internal/surface/cli/intent_consistency.go b/internal/surface/cli/intent_consistency.go index 2d0705b19..abacaceb1 100644 --- a/internal/surface/cli/intent_consistency.go +++ b/internal/surface/cli/intent_consistency.go @@ -84,10 +84,19 @@ func newIntentConsistencyCommand(asJSON *bool) *cobra.Command { if err != nil { return &exitError{Code: 2, Msg: "abcd intent consistency ingest: " + fsutil.RedactHome(err.Error())} } - res, err := capture.IngestConsistency(repoRoot, payload, "") + // The filing-time match (itd-2609212137116617) on every record the + // pass files, configured as the capture verb's is and never a + // refusal: a refused configuration files unmatched and says why. + mc, matchRefused := resolveMatch(cmd.ErrOrStderr(), "intent consistency ingest", repoRoot) + res, err := capture.IngestConsistency(repoRoot, payload, "", mc) if err != nil { return &exitError{Code: 2, Msg: "abcd intent consistency ingest: " + fsutil.RedactHome(err.Error())} } + for i := range res.Rows { + if res.Rows[i].Match == nil && !res.Rows[i].Linked { + res.Rows[i].Match = matchRefused + } + } return render(cmd.OutOrStdout(), *asJSON, withReceipt(res, route, payload), func(w io.Writer) { fmt.Fprintf(w, "abcd intent consistency ingest — %s (receipt %s, scope %s)\n", res.Status, res.ReceiptID, res.Scope) dirty := "" @@ -104,6 +113,7 @@ func newIntentConsistencyCommand(asJSON *bool) *cobra.Command { } fmt.Fprintf(w, " %d. %s (%s) — %s %s: %s\n", r.Number, r.ClassLabel(), r.Severity, r.IssueID, how, termsafe.Sanitize(r.Summary)) + renderMatch(w, r.Match) } } renderReceiptLine(w, route, payload) diff --git a/internal/surface/cli/intent_target_cli_test.go b/internal/surface/cli/intent_target_cli_test.go index 0b17cd300..41680fdad 100644 --- a/internal/surface/cli/intent_target_cli_test.go +++ b/internal/surface/cli/intent_target_cli_test.go @@ -138,4 +138,16 @@ func TestLaunchPreviewAndCutListTheTargetedIntent(t *testing.T) { if !strings.Contains(string(shipped), "targeted: itd-91 targets v0.4.1, not shipped") || !strings.Contains(string(shipped), "wrote:") { t.Errorf("the written cut must list the targeted intent:\n%s", shipped) } + // Criterion 3 (ruling BS1 of 2026-09-29): the cut passed the target, so + // the same write moves it to `next` and the report says so. + if !strings.Contains(string(shipped), "moved: itd-91 targets next (targeted v0.4.1)") { + t.Errorf("the written cut must report the moved target:\n%s", shipped) + } + rec, err := os.ReadFile(filepath.Join(r.Root(), ".abcd/development/intents/planned/itd-91-targeted.md")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(rec), "target_release: next\n") { + t.Errorf("the cut must rewrite the missed target to next:\n%s", rec) + } } diff --git a/internal/surface/cli/reading.go b/internal/surface/cli/reading.go index a02f04443..b077585ce 100644 --- a/internal/surface/cli/reading.go +++ b/internal/surface/cli/reading.go @@ -36,6 +36,7 @@ import ( "strconv" "strings" + "github.com/intentdriven/abcd/internal/core/capture" "github.com/intentdriven/abcd/internal/core/oracle" "github.com/intentdriven/abcd/internal/core/reading" "github.com/intentdriven/abcd/internal/termsafe" @@ -210,7 +211,11 @@ func newReadingCommand(asJSON *bool) *cobra.Command { "marker the sweep ROLLS THAT RUN'S READING RECORDS OUT OF THE COMMITTED LEDGER, because the\n" + "run never happened; where the marker is there the run stands and only the stage goes. A\n" + "refused run reports the orphans it left in place, and the ids a sweep removed are reported as\n" + - "rolled_back_records on every exit, including a failing one.", + "rolled_back_records on every exit, including a failing one.\n\n" + + "Every stored finding is matched against the record as a capture is: its pattern and body are\n" + + "compared with the open and resolved issues, the intents and every earlier reading item, never\n" + + "with another item of the same run, and a likely repeat is written onto the reading record as a\n" + + "duplicates: or refines: link and shown, printed and as matches in --json.", Example: " abcd reading ingest --reading-json ./reading-output.json --json", Args: func(_ *cobra.Command, args []string) error { if len(args) > 0 { @@ -253,11 +258,21 @@ func newReadingCommand(asJSON *bool) *cobra.Command { if err != nil { return err } + // The filing-time match (ruling DQ2b, adr-2609300821558671), + // configured as the capture verb's is and never a refusal. + root := captureRoot(cwd) + mc, matchRefused := resolveMatch(cmd.ErrOrStderr(), "reading ingest", root) res, err := reading.Ingest(reading.IngestRequest{ - RepoRoot: captureRoot(cwd), + RepoRoot: root, OutputPath: resolved, Output: payload, + Match: mc, }) + if err == nil && mc == nil && matchRefused != nil { + for _, r := range res.Records { + res.Matches = append(res.Matches, capture.ReadingMatch{ID: r.ID, Match: matchRefused}) + } + } if err != nil { // A refusal that produced a durable record renders it before it // exits. The record path is the operator's handle on the event, @@ -626,6 +641,12 @@ func renderIngestResult(w io.Writer, res reading.IngestResult) { // elision entry names no item, so neither surface renders it as one. fmt.Fprintf(w, " %s\n", r.Render()) } + // The likely repeats of each stored finding (ruling DQ2b): the links + // written onto it, a match past the link cap, or why nothing was compared. + for _, m := range res.Matches { + fmt.Fprintf(w, " %s:\n", termsafe.Sanitize(m.ID)) + renderMatch(w, m.Match) + } if len(res.ClearedStages) > 0 { fmt.Fprintf(w, " cleared: orphaned stage(s) of %s\n", strings.Join(res.ClearedStages, ", ")) } diff --git a/internal/surface/cli/reading_match_surface_test.go b/internal/surface/cli/reading_match_surface_test.go new file mode 100644 index 000000000..54417a3e6 --- /dev/null +++ b/internal/surface/cli/reading_match_surface_test.go @@ -0,0 +1,164 @@ +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/capture" + "github.com/intentdriven/abcd/internal/core/issueschema" + "github.com/intentdriven/abcd/internal/core/reading" +) + +// reading_match_surface_test.go is the wiring proof for ruling DQ2b +// (adr-2609300821558671): `reading ingest` matches every finding it stores and +// shows the likely repeats, printed and in --json, and `capture promote rdi-N` +// matches the draft it mints. + +// The reading's finding, doubling fmHeld in the instrument's own words. +var fmReadingItem = map[string]any{ + "pattern": "ledger reader skips records with duplicated keys", + "tension": fmDouble, + "constraint_in_play": "every finding appears in every listing of the capture ledger", + "why_a_tension": "a record whose frontmatter carries a duplicated key disappears from every listing silently", +} + +func fmCapture(t *testing.T, repo, text string) capture.CaptureResult { + t.Helper() + res, err := capture.Capture(capture.CaptureRequest{ + RepoRoot: repo, Text: text, Severity: capture.SeverityMinor, Category: "bug", + Source: "user-observation", FoundDuring: "fixture", Remedy: issueschema.MachineRemedy, + }) + if err != nil { + t.Fatal(err) + } + return res +} + +// detectionPayloadWith writes a legal detection payload carrying the items +// given, and returns the path the verb reads. +func detectionPayloadWith(t *testing.T, runID, manifestHash string, def reading.Definition, items ...map[string]any) string { + t.Helper() + list := make([]any, 0, len(items)) + for _, it := range items { + list = append(list, it) + } + raw, err := json.Marshal(map[string]any{ + "_type": "abcd.reading.output/1", "run_id": runID, + "position": "detection", "regime": def.Regime, + "manifest_sha256": manifestHash, + "instrument": map[string]any{ + "model": "a-model", "definition_sha256": def.SHA256, + "assembler_version": reading.AssemblerVersion(), + }, + "items": list, + }) + if err != nil { + t.Fatal(err) + } + outPath := filepath.Join(t.TempDir(), "output.json") + if err := os.WriteFile(outPath, raw, 0o644); err != nil { + t.Fatal(err) + } + return outPath +} + +// TestReadingIngestShowsTheLikelyRepeats: the ingest reports the match for +// every record in --json and prints it; a later reading returning the same +// finding is linked to the earlier item as well as to the issue. +func TestReadingIngestShowsTheLikelyRepeats(t *testing.T) { + srcRoot := repoRootFromTest(t) + repo := readingRepo(t) + t.Chdir(repo) + held := fmCapture(t, repo, fmHeld) + fmCapture(t, repo, "The site builder renders a stale anchor for a heading renamed since the last build.") + fmCapture(t, repo, "The history store drops a transcript that exceeds its byte budget without saying so.") + + runID, manifestHash, def := parkedRunForIngest(t, srcRoot, repo, "detection") + raw := runCLI(t, "reading", "ingest", "--reading-json", detectionPayloadWith(t, runID, manifestHash, def, fmReadingItem), "--json") + var res struct { + Records []struct { + ID string `json:"id"` + } `json:"records"` + Matches []struct { + ID string `json:"id"` + Match *struct { + Matches []struct { + ID string `json:"id"` + Linked bool `json:"linked"` + } `json:"matches"` + } `json:"match"` + } `json:"matches"` + } + if err := json.Unmarshal(raw, &res); err != nil { + t.Fatalf("decode: %v\n%s", err, raw) + } + if len(res.Records) != 1 || len(res.Matches) != 1 || res.Matches[0].ID != res.Records[0].ID || + res.Matches[0].Match == nil || len(res.Matches[0].Match.Matches) == 0 || + res.Matches[0].Match.Matches[0].ID != held.ID || !res.Matches[0].Match.Matches[0].Linked { + t.Fatalf("ingest --json does not carry the match on %s:\n%s", held.ID, raw) + } + first := res.Records[0].ID + + runID2, manifestHash2, def2 := parkedRunForIngest(t, srcRoot, repo, "detection") + out := string(runCLI(t, "reading", "ingest", "--reading-json", detectionPayloadWith(t, runID2, manifestHash2, def2, fmReadingItem))) + if !strings.Contains(out, "matched "+first) || !strings.Contains(out, "link written") { + t.Fatalf("the ingest does not print the repeat of %s:\n%s", first, out) + } +} + +// TestCapturePromoteReadingItemReportsTheMatch: promoting an accepted reading +// item matches the draft it mints, prints the match and carries it in --json. +func TestCapturePromoteReadingItemReportsTheMatch(t *testing.T) { + repo, _ := gitRepoNoStore(t) + t.Chdir(repo) + held := fmCapture(t, repo, fmHeld) + fmCapture(t, repo, "The site builder renders a stale anchor for a heading renamed since the last build.") + body := map[string]string{} + for k, v := range fmReadingItem { + if k != "pattern" { + body[k] = v.(string) + } + } + res, err := capture.IngestReading(capture.IngestReadingRequest{ + RepoRoot: repo, Run: "rdg-2609300000000001", Manifest: "sha256:" + strings.Repeat("a", 64), + Position: "detection", Regime: issueschema.ReadingRegime("detection"), + Items: []capture.ReadingItem{ + {Pattern: fmReadingItem["pattern"].(string), Body: body}, + {Pattern: "the capture ledger reader skips records carrying duplicated keys", Body: body}, + }, + }) + if err != nil { + t.Fatal(err) + } + for _, r := range res.Records { + if _, err := capture.Disposition(capture.DispositionRequest{ + RepoRoot: repo, Item: r.ID, State: issueschema.DispositionAccepted, + Grounds: "the tension is real and worth acting on", + }); err != nil { + t.Fatal(err) + } + } + item := res.Records[0].ID + + raw := runCLI(t, "capture", "promote", item, "--json") + var p struct { + Match *struct { + Matches []struct { + ID string `json:"id"` + } `json:"matches"` + } `json:"match"` + } + if err := json.Unmarshal(raw, &p); err != nil { + t.Fatalf("decode: %v\n%s", err, raw) + } + if p.Match == nil || len(p.Match.Matches) == 0 || p.Match.Matches[0].ID != held.ID { + t.Fatalf("promote --json does not carry the match on %s:\n%s", held.ID, raw) + } + out := string(runCLI(t, "capture", "promote", res.Records[1].ID)) + if !strings.Contains(out, "matched "+held.ID) { + t.Fatalf("promote does not print the match on %s:\n%s", held.ID, out) + } +} diff --git a/internal/surface/cli/report.go b/internal/surface/cli/report.go index 6358628f2..51311ec61 100644 --- a/internal/surface/cli/report.go +++ b/internal/surface/cli/report.go @@ -346,15 +346,22 @@ func newInboxCommand(asJSON *bool) *cobra.Command { if err != nil { return &exitError{Code: 2, Msg: "abcd inbox promote: " + termsafe.Sanitize(err.Error()) + " (nothing written)"} } - p, err := report.Promote(ledger, args[0]) + // The filing-time match (itd-2609212137116617), configured as the + // capture verb's is and never a refusal. + mc, matchRefused := resolveMatch(cmd.ErrOrStderr(), "inbox promote", ledger) + p, err := report.Promote(ledger, args[0], mc) if err != nil { return reportRefusal("inbox promote", err, "nothing written") } + if p.Match == nil && !p.Resumed { + p.Match = matchRefused + } return render(cmd.OutOrStdout(), *asJSON, p, func(w io.Writer) { fmt.Fprintf(w, "promoted %s to %s — %s\n", p.Report, p.Capture, termsafe.Sanitize(p.Path)) if p.Resumed { fmt.Fprintln(w, " finished an earlier promotion that filed this capture; nothing new was filed") } + renderMatch(w, p.Match) fmt.Fprintln(w, " the report is kept in the inbox, marked promoted") if p.Redacted > 0 { fmt.Fprintf(w, " redacted %d span(s) before writing (home paths and identifiers are never committed)\n", p.Redacted) diff --git a/internal/surface/cli/rules_shell_test.go b/internal/surface/cli/rules_shell_test.go index 884cce20f..a8f4a1e42 100644 --- a/internal/surface/cli/rules_shell_test.go +++ b/internal/surface/cli/rules_shell_test.go @@ -2,6 +2,8 @@ package cli import ( "encoding/json" + "os" + "path/filepath" "strings" "testing" ) @@ -47,3 +49,62 @@ func TestHookPromptRouterTeachesShellHazards(t *testing.T) { t.Fatalf("the unchanged SHELL domain re-injected within one session:\n%s", again) } } + +// writeGuardFile lays a repository's own .abcd/guard.json into dir. +func writeGuardFile(t *testing.T, dir, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Join(dir, ".abcd"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, ".abcd", "guard.json"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +// TestRulesTeachesTheRepositorysOwnGuardEntries (ruling CK1): an entry a +// repository adds in its .abcd/guard.json is taught through both front doors, +// marked as the repository's; a guard.json the guard refuses is named on +// stderr from both, and SHELL still teaches the bundled lessons. +func TestRulesTeachesTheRepositorysOwnGuardEntries(t *testing.T) { + const entry = `{"schema_version":1,"entries":{"deploy-prod":{ + "tier":"blocker", + "pattern":{"command":"make","subcommand":"deploy"}, + "why":"It deploys to production from a laptop.", + "successor":"Open a release pull request; CI deploys it."}}}` + const lesson = "- Refused by the guard (deploy-prod) (repo): `make deploy`. It deploys to production from a laptop. Instead: Open a release pull request; CI deploys it.\n" + + t.Run("taught", func(t *testing.T) { + t.Setenv("ABCD_RULES_STATE_DIR", t.TempDir()) + cwd := t.TempDir() + writeGuardFile(t, cwd, entry) + out, errlog := runHook(t, hookInputJSON(t, "teach-repo", cwd, "make deploy the docs site"), "hook", "prompt-router") + if !strings.Contains(out, "## SHELL\n") || !strings.Contains(out, lesson) { + t.Fatalf("the repository's own hazard was not taught by the hook:\n%s\nstderr:\n%s", out, errlog) + } + t.Chdir(cwd) + so, se, err := runCLISplit(t, "rules", "shell") + if err != nil || !strings.Contains(so, lesson) || se != "" { + t.Fatalf("abcd rules shell: err=%v\nstdout:\n%s\nstderr:\n%s", err, so, se) + } + }) + + t.Run("refused loudly", func(t *testing.T) { + t.Setenv("ABCD_RULES_STATE_DIR", t.TempDir()) + cwd := t.TempDir() + writeGuardFile(t, cwd, strings.Replace(entry, `"why":"It deploys to production from a laptop.",`, "", 1)) + out, errlog := runHook(t, hookInputJSON(t, "teach-repo-bad", cwd, "rm -rf the old build output"), "hook", "prompt-router") + if !strings.Contains(out, "(rm-rf-root-or-home)") || strings.Contains(out, "deploy-prod") { + t.Fatalf("a refused guard.json changed the bundled lessons:\n%s", out) + } + for _, want := range []string{"SHELL", ".abcd/guard.json", "deploy-prod has no why", "refused and not taught"} { + if !strings.Contains(errlog, want) { + t.Fatalf("the hook's stderr does not name the refusal (%q):\n%s", want, errlog) + } + } + t.Chdir(cwd) + _, se, err := runCLISplit(t, "rules", "shell") + if err != nil || !strings.Contains(se, "refused and not taught") { + t.Fatalf("abcd rules shell did not name the refused guard.json: err=%v\nstderr:\n%s", err, se) + } + }) +} diff --git a/internal/surface/cli/ship.go b/internal/surface/cli/ship.go index d1de3aad5..d3219aee3 100644 --- a/internal/surface/cli/ship.go +++ b/internal/surface/cli/ship.go @@ -765,6 +765,11 @@ func renderIngest(w io.Writer, res shipResult) { fmt.Fprintf(w, " wrote: %s\n", res.Path) fmt.Fprintf(w, " %s\n", res.Heading) fmt.Fprintf(w, " %d line(s), citing %s\n", res.Lines, termsafe.Sanitize(strings.Join(res.Cited, ", "))) + // Every target the cut passed without shipping it, moved to `next` in the + // same write and named in the section (itd-2609212103572513 criterion 3). + for _, m := range res.Moved { + fmt.Fprintf(w, " moved: %s targets next (targeted %s)\n", termsafe.Sanitize(m.ID), termsafe.Sanitize(m.From)) + } if res.Page.Written { fmt.Fprintf(w, " page: %s\n", res.Page.Path) fmt.Fprintf(w, " %s\n", termsafe.Sanitize(res.Page.Heading)) diff --git a/internal/surface/cli/site.go b/internal/surface/cli/site.go index d8d03e04c..04bbb4130 100644 --- a/internal/surface/cli/site.go +++ b/internal/surface/cli/site.go @@ -280,13 +280,18 @@ func newSiteSetupCommand(asJSON *bool) *cobra.Command { if err != nil { return err } - var asker site.Asker = newPrompter(cmd) + // A missing gh is offered for install only to a person at a + // terminal, never on --yes (the DQ3 ruling), so its confirmation is + // built from the prompter before --yes replaces it. + p := newPrompter(cmd) + confirmTool := terminalToolConfirm(p, yes, cmd.ErrOrStderr()) + var asker site.Asker = p if yes { asker = alwaysConfirm{} } res, err := site.Setup(site.SetupRequest{ RepoRoot: cwd, Name: name, Domain: domain, Confirm: confirm, Asker: asker, - Context: cmd.Context(), + ConfirmTool: confirmTool, Context: cmd.Context(), }) if err != nil { return &exitError{Code: 2, Msg: "abcd site setup: " + scrubPaths(err)} @@ -308,7 +313,7 @@ func newSiteSetupCommand(asJSON *bool) *cobra.Command { cmd.Flags().StringVar(&name, "name", "", "host name when the composition names none (default: the repository's name)") cmd.Flags().StringVar(&domain, "domain", "", "custom domain to route to the host when the composition names none") cmd.Flags().BoolVar(&confirm, "confirm", false, "replace a workflow or host configuration that differs from what setup writes") - cmd.Flags().BoolVar(&yes, "yes", false, "confirm the forge and host changes without being asked; without it an unanswered run declines them") + cmd.Flags().BoolVar(&yes, "yes", false, "confirm the forge and host changes without being asked (never the install of a missing gh); without it an unanswered run declines them") return cmd } diff --git a/internal/surface/cli/transition_test.go b/internal/surface/cli/transition_test.go index 15f1e3e4e..3d990ae15 100644 --- a/internal/surface/cli/transition_test.go +++ b/internal/surface/cli/transition_test.go @@ -7,15 +7,28 @@ import ( "testing" "github.com/intentdriven/abcd/internal/core" + "github.com/intentdriven/abcd/internal/core/update" ) -// TestSessionStartReportsVersionTransition proves AC6: when the running binary's -// version differs from the version recorded when the repo was last set up, the -// session-start hook reports the transition. The comparison is disk-only. -func TestSessionStartReportsVersionTransition(t *testing.T) { +// sessionStartSandbox gives a session-start run its own HOME, no plugin root +// and the given data dir, so nothing a run writes lands outside the test. +func sessionStartSandbox(t *testing.T, data string) { + t.Helper() + t.Setenv("HOME", t.TempDir()) + t.Setenv("ABCD_PLUGIN_ROOT", "") + t.Setenv("CLAUDE_PLUGIN_ROOT", "") + t.Setenv("CLAUDE_PLUGIN_DATA", data) +} + +// TestSessionStartDoesNotCompareSetupVersion: the ruling CJ1 replaces the +// setup_version comparison. An update is announced once, by whatever swapped +// the binary, so a repo whose recorded setup_version differs from the running +// binary gets no transition notice at session start. +func TestSessionStartDoesNotCompareSetupVersion(t *testing.T) { orig := core.Version core.Version = "v9.9.9" t.Cleanup(func() { core.Version = orig }) + sessionStartSandbox(t, "") repo := t.TempDir() if err := os.MkdirAll(filepath.Join(repo, ".abcd"), 0o755); err != nil { @@ -25,34 +38,36 @@ func TestSessionStartReportsVersionTransition(t *testing.T) { []byte(`{"meta":{"setup_version":"v1.0.0"}}`+"\n"), 0o644); err != nil { t.Fatal(err) } - - // The hook exits non-zero when it emits notices (so SessionStart shows them); - // the notice text is on the captured stream regardless. out, _ := runCLIStdinErr(t, `{"cwd":"`+repo+`"}`, "hook", "session-start") - s := string(out) - if !strings.Contains(s, "v9.9.9") || !strings.Contains(s, "v1.0.0") { - t.Fatalf("transition notice missing running/recorded versions:\n%s", s) + if s := string(out); strings.Contains(s, "last set up with") || strings.Contains(s, "v1.0.0") { + t.Fatalf("session start must not report a setup_version transition:\n%s", s) } } -// TestSessionStartSilentWhenVersionMatches proves the report does not fire when -// the recorded and running versions agree. -func TestSessionStartSilentWhenVersionMatches(t *testing.T) { - orig := core.Version - core.Version = "v9.9.9" - t.Cleanup(func() { core.Version = orig }) - - repo := t.TempDir() - if err := os.MkdirAll(filepath.Join(repo, ".abcd"), 0o755); err != nil { +// TestSessionStartShowsAnUnseenUpdateOnce is the ruling CJ1b's single +// exception, end to end through the hook: a swap whose own output was +// discarded is shown by the next session start in the shared wording, and not +// by the one after it. +func TestSessionStartShowsAnUnseenUpdateOnce(t *testing.T) { + data := t.TempDir() + cache := filepath.Join(data, "cache") + if err := os.MkdirAll(cache, 0o755); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(repo, ".abcd", "config.json"), - []byte(`{"meta":{"setup_version":"v9.9.9"}}`+"\n"), 0o644); err != nil { + meta := "release_tag=v0.12.0\nrelease_sha=unknown\nfetched_at=2026-09-30T00:00:00Z\nprevious_tag=v0.11.1\ntransition_unseen=yes\n" + if err := os.WriteFile(filepath.Join(cache, "binary-meta"), []byte(meta), 0o644); err != nil { t.Fatal(err) } + sessionStartSandbox(t, data) + repo := t.TempDir() + want := update.UpdatedLine("v0.11.1", "v0.12.0") out, _ := runCLIStdinErr(t, `{"cwd":"`+repo+`"}`, "hook", "session-start") - if strings.Contains(string(out), "last set up with") { - t.Fatalf("transition reported when versions match:\n%s", out) + if n := strings.Count(string(out), want); n != 1 { + t.Fatalf("the first session after an unseen swap must show %q once, got %d:\n%s", want, n, out) + } + out, _ = runCLIStdinErr(t, `{"cwd":"`+repo+`"}`, "hook", "session-start") + if strings.Contains(string(out), "updated from") { + t.Fatalf("the next session must not show the update again:\n%s", out) } } diff --git a/internal/surface/cli/update.go b/internal/surface/cli/update.go index 773a364fb..e2805650c 100644 --- a/internal/surface/cli/update.go +++ b/internal/surface/cli/update.go @@ -184,7 +184,11 @@ func renderUpdateReport(w io.Writer, asJSON bool, rep update.Report) { if rep.OldVersion == "" { old = "an unpublished build" } - fmt.Fprintf(w, "updated %s: %s -> %s\n", termsafe.Sanitize(rep.TargetPath), old, termsafe.Sanitize(rep.NewVersion)) + // The first line is the one wording every swap prints when it + // completes (update.UpdatedFormat, the ruling CJ1b); the bootstrap's + // success notice leads with the same line. + fmt.Fprintln(w, update.UpdatedLine(old, termsafe.Sanitize(rep.NewVersion))) + fmt.Fprintf(w, " path: %s\n", termsafe.Sanitize(rep.TargetPath)) fmt.Fprintf(w, " origin: %s\n", rep.Origin) if rep.OldDigest != "" { fmt.Fprintf(w, " replaced: sha256 %s — in no published release; %s\n", termsafe.Sanitize(rep.OldDigest), rep.Ownership.Prose()) diff --git a/internal/surface/cli/update_test.go b/internal/surface/cli/update_test.go index b156540d7..99d38247d 100644 --- a/internal/surface/cli/update_test.go +++ b/internal/surface/cli/update_test.go @@ -196,8 +196,13 @@ func TestUpdateReceiptKeepsTheOrdinaryVersionLine(t *testing.T) { Digest: strings.Repeat("cd", 32), }) got := out.String() - if !strings.Contains(got, "v0.6.9 -> v0.7.0") { - t.Errorf("the ordinary receipt line changed:\n%s", got) + // The swap's first line is the one wording every swap shares (CJ1b): + // the bootstrap's success notice leads with the same line. + if first, _, _ := strings.Cut(got, "\n"); first != update.UpdatedLine("v0.6.9", "v0.7.0") { + t.Errorf("the receipt must open with %q; got %q", update.UpdatedLine("v0.6.9", "v0.7.0"), first) + } + if !strings.Contains(got, "~/.local/bin/abcd") { + t.Errorf("the receipt must still name the path it swapped:\n%s", got) } if strings.Contains(got, "unpublished") { t.Errorf("a provable old build must not be reported as unpublished:\n%s", got) diff --git a/internal/termsafe/codespan_canonical_test.go b/internal/termsafe/codespan_canonical_test.go index 3df3e300a..5a5d5a53e 100644 --- a/internal/termsafe/codespan_canonical_test.go +++ b/internal/termsafe/codespan_canonical_test.go @@ -32,7 +32,8 @@ var backtickScanners = map[string]backtickScanner{ "internal/adapter/scanner/identity.go": {2, "two delimiter sets: a backtick is one of the characters that may end an identity token, and one of those that bounds the path token an owner slug is judged in; nothing is paired"}, "internal/core/capture/promote.go": {1, "a WRITER: codeSpan measures the longest backtick run to choose a fence the value cannot close; nothing is paired"}, "internal/core/guard/tokenize.go": {23, "the shell tokenizer: a backtick there is command substitution, a shell grammar, not markdown"}, - "internal/core/guard/unknown.go": {1, "spellAlternative spells an alternative's shell word: a backtick there opens a command substitution, which leaves the word unspelled; nothing is paired"}, + "internal/core/guard/payload.go": {3, "targetsAnExpansion reads a shell line's raw text for an assignment target that holds an expansion: a backtick there opens or closes a command substitution, a shell grammar, not markdown; nothing is paired"}, + "internal/core/guard/unknown.go": {2, "spellWord spells a default's or an alternative's shell word, and readPattern reads a trim's or a replacement's pattern: a backtick in either opens a command substitution, whose output the spelling drops or the pattern reads as unknown text; nothing is paired"}, "internal/core/history/reconstruct_render.go": {1, "a WRITER: longestBacktickRun sizes a fence longer than any run in the body; nothing is paired"}, "internal/core/lifeboat/mdrender.go": {1, "escapeLeadingMarker asks whether a value opens with a backtick, then asks OpensBalancedCodeSpan, which pairs through PairCodeSpan"}, "internal/core/lint/lint.go": {2, "stripInlineCode walks to each run and pairs it through PairCodeSpan, stepping over an unpaired run whole"}, diff --git a/site-src/ui.json b/site-src/ui.json index 6f64e3f7b..c7c80329a 100644 --- a/site-src/ui.json +++ b/site-src/ui.json @@ -120,6 +120,7 @@ "next_up": "next up", "fails": "fails", "draft": "draft", + "target": "target", "none": "None", "order_record_id": "READY intents read oldest id first" }