From f81199f00b5c82656745a8fad29cf60f9ce99137 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:15:50 +0100 Subject: [PATCH 1/2] chore: plan itd-3 (codebase mapping) and split off terminal anchoring Moves itd-3 from drafts to planned with its acceptance criteria, mechanism claim, five stamped scope conditions, and grounds; mints and links its spec, spc-2609152218094570, which designs the host-delegated `map` command, the `refs.jsonl` record family, and `review -kind refs`. Terminal-session anchoring is a different problem and is filed as its own draft, itd-2609152113364815, with its three known obstacles as open questions. The decomposition calibration note records the routing and grades it against the maintainer's confirmation. One decision-log line records the planning choices. Assisted-by: Claude:claude-fable-5-1 --- ...irmed-terminal-session-finding-to-the-c.md | 45 +++ .../intents/drafts/itd-3-codebase-mapping.md | 38 -- .../intents/planned/itd-3-codebase-mapping.md | 72 ++++ .../2026-09-15-decomposition-calibration.md | 35 ++ .../spc-2609152218094570-codebase-mapping.md | 380 ++++++++++++++++++ .abcd/work/DECISIONS.md | 12 + 6 files changed, 544 insertions(+), 38 deletions(-) create mode 100644 .abcd/development/intents/drafts/itd-2609152113364815-testimony-maps-a-confirmed-terminal-session-finding-to-the-c.md delete mode 100644 .abcd/development/intents/drafts/itd-3-codebase-mapping.md create mode 100644 .abcd/development/intents/planned/itd-3-codebase-mapping.md create mode 100644 .abcd/development/research/notes/2026-09-15-decomposition-calibration.md create mode 100644 .abcd/development/specs/open/spc-2609152218094570-codebase-mapping.md diff --git a/.abcd/development/intents/drafts/itd-2609152113364815-testimony-maps-a-confirmed-terminal-session-finding-to-the-c.md b/.abcd/development/intents/drafts/itd-2609152113364815-testimony-maps-a-confirmed-terminal-session-finding-to-the-c.md new file mode 100644 index 0000000..dcd6639 --- /dev/null +++ b/.abcd/development/intents/drafts/itd-2609152113364815-testimony-maps-a-confirmed-terminal-session-finding-to-the-c.md @@ -0,0 +1,45 @@ +--- +id: itd-2609152113364815 +slug: testimony-maps-a-confirmed-terminal-session-finding-to-the-c +spec_id: null +kind: null +suggested_kind: null +reclassification_history: [] +builds_on: [itd-3, itd-11] +severity: minor +impact: additive +origin: researcher-authored +production_mode: hand-written +--- + +# Testimony maps a confirmed terminal-session finding to the command that produced it + +## Press Release + +> _Seeded from a quoted-text intent capture. Expand into the full press-release narrative before planning._ + +## Why This Matters + +Split from itd-3 (codebase mapping) during its planning interview on 2026-09-15. Web findings carry a selector or route anchor; a terminal finding carries neither, so the mapping step for terminal sessions is a different problem and is recorded on its own rather than deferred silently inside itd-3. + +## Mechanism + +> _Prompted (the claim-recording gradient): why the authors expect this to work, as a falsifiable "we expect X because Y" — not the outcome restated. Replace this line with the claim, or with the exact token `None stated.` alone on its line to record the claim as considered and declined._ + +## Scope Conditions + +> _Required (the claim-recording gradient): the population, platform, scale, or assumptions this claim holds under, one per top-level bullet — `abcd intent plan` stamps each with a persistent identity. Replace this line with those bullets, or with the exact token `None stated.` alone on its line._ + +## Acceptance Criteria + +> _Required (the itd-1 discipline): add at least one Given-When-Then bullet describing the verifiable bar for "shipped" before this draft can be planned._ + +## Open Questions + +- A terminal finding has no `ui` anchor field: `terminal_output` timeline records carry only `t`, `kind`, and `text`. The only anchor is the finding's quote and its cited evidence ids, so what the request hands the host, and what ingest can verify, both need deciding. +- Timeline text keeps its escape sequences verbatim and is stripped only at `report`'s render boundary. Anything that searches the text for a command string must strip control bytes first, and nothing does so today. +- `terminal.cast` is archival: nothing downstream reads it. Anchoring must work from the timeline, not the cast. + +## Audit Notes + +_Empty. Populated by intent-auditor when intent moves to shipped/._ diff --git a/.abcd/development/intents/drafts/itd-3-codebase-mapping.md b/.abcd/development/intents/drafts/itd-3-codebase-mapping.md deleted file mode 100644 index 3ca4c03..0000000 --- a/.abcd/development/intents/drafts/itd-3-codebase-mapping.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -id: itd-3 -slug: codebase-mapping -spec_id: null -severity: major ---- - -# Findings Land in the Code, Not Just the Report - -## Press Release - -> **Testimony maps confirmed findings back to the codebase.** An agentic mapping step resolves each finding's UI anchor — a `data-testid` attribute, a CSS selector, a route, or the command text visible in a terminal cast — to source locations, attaching `code_refs` with a confidence level. Where the evidence supports it, the step drafts an issue: title, reproduction steps reconstructed from the event window, the participant's quote as user evidence, and the suspected file. A tester's spoken "I expected this button to save immediately" arrives in the tracker pointing at the component that owns the button. -> -> "The gap was always between 'users struggled here' and 'this file, this handler'," said Alice, the maintainer. "Now a finding comes to me with the selector, the route, the suspect file, and the person's own words. I start fixing instead of reconstructing." - -## Why This Matters - -Session findings that stop at a report require a second act of translation before anyone can act on them, and that translation is where evidence goes stale. Anchoring interactive elements with stable `data-testid` attributes at build time makes selectors near-deterministic grep targets, so the translation can be automated — turning a qualitative observation into a workable, evidenced issue while the session is still fresh. This is the deliberately last and most novel step of the pipeline, kept separable so it can iterate without destabilising capture or analysis. - -## What's In Scope - -- The `data-testid` build-time convention for our own web apps, documented as the anchoring contract. -- The agentic mapping step: selectors and test IDs grepped directly; routes resolved through the router table; CLI findings anchored on command/flag/output text from the cast stream. -- `code_refs` written back onto findings with `high | medium | low` confidence. -- Drafted issues (title, repro from the event window, quote as evidence, suspected file) for findings that warrant them. - -## What's Out of Scope - -- Automatic filing of issues into a tracker without human review. -- Native-app accessibility-identifier instrumentation beyond using identifiers already present. -- Mapping for third-party apps — Mode B has no codebase access by definition (itd-4). -- Seeding regression tests from confirmed findings; noted as an open question, not built here. - -## Acceptance Criteria - -- **Given** a confirmed finding with a `data-testid` selector anchor, **when** the mapping step runs against the app's repository, **then** the finding gains at least one `code_refs` entry with a file path and confidence level. -- **Given** a finding from a CLI session, **when** the mapping step runs, **then** the anchor is resolved from the command or output text in the cast stream to the owning source location. -- **Given** a mapped finding, **when** an issue draft is produced, **then** it contains a title, reproduction steps from the event window, the participant quote, and the suspected file. diff --git a/.abcd/development/intents/planned/itd-3-codebase-mapping.md b/.abcd/development/intents/planned/itd-3-codebase-mapping.md new file mode 100644 index 0000000..730292a --- /dev/null +++ b/.abcd/development/intents/planned/itd-3-codebase-mapping.md @@ -0,0 +1,72 @@ +--- +id: itd-3 +slug: codebase-mapping +spec_id: spc-2609152218094570 +kind: standalone +suggested_kind: null +reclassification_history: [] +builds_on: [itd-2, itd-9] +severity: major +--- + +# Findings Land in the Code, Not Just the Report + +## Press Release + +> **Testimony maps confirmed findings to the code that owns them.** Once a human has confirmed a finding, the mapping step hands its selector, route, and event window, together with the path to the application's repository, to the operator's chosen model. The model answers with the source locations it believes own that anchor. Testimony checks each answer against the repository, the file exists and the line is in range, and records it as a proposed reference. From a mapped finding, Testimony renders an issue draft: a title, reproduction steps from the event window, the participant's own words as evidence, and the suspected file. A person accepts or rejects each reference, and the decision is kept beside it. Nothing is filed anywhere, and nothing is written into the application's repository. +> +> "The gap was always between 'users struggled here' and 'this file, this handler'," said Alice, the maintainer. "Now a finding comes to me with the selector, the route, the suspect file, and the person's own words. I start fixing instead of reconstructing." + +## Why This Matters + +Session findings that stop at a report require a second act of translation before anyone can act on them, and that translation is where evidence goes stale. Anchoring interactive elements with stable `data-testid` attributes at build time makes selectors near-deterministic grep targets, so the translation can be delegated to a model and checked by the CLI, turning a qualitative observation into a workable, evidenced issue while the session is still fresh. + +The step sits downstream of verification, as regression-test drafting (itd-9) does: only a finding a human has confirmed is mapped, every reference is born a proposal, and the human decision on it is retained beside the machine record. The mapping is the last step of the pipeline, kept separable so it can iterate without destabilising capture or analysis. + +## What's In Scope + +- Emitting a mapping request over each confirmed finding that carries a selector or route anchor: the anchor, the participant's quote, the event window, and the path to the application's repository. +- Ingesting the answer into a new record family, `refs.jsonl`, beside `findings.jsonl` and `tests.jsonl`: each reference names a confirmed finding and a repo-relative path that exists under the repository, with any line within the file's length, and is born `proposed`. +- A human accept / reject pass over each reference, appended non-destructively as verdicts and test decisions are, so the reference stays linked to its finding and session. +- Rendering an issue draft from a mapped finding: a title, reproduction steps from the event window, the participant's quote, and the suspected file. +- Reading the application's repository only to verify that a returned path exists and a line is in range. + +## What's Out of Scope + +- Automatic filing of issues into a tracker without human review. +- Anchoring a finding from a terminal session on the command or output text in the timeline; a terminal finding carries no selector or route, and the timeline text keeps its escape sequences, so that is its own draft (itd-2609152113364815). +- A confidence level on a reference. Ingest cannot validate a model-asserted word, and the human decision is the only quality signal. +- Grepping, router-table resolution, or any other reasoning about the application's source inside the CLI; resolution is the host's job. +- Writing anything into the application's repository. +- Native-app accessibility-identifier instrumentation beyond using identifiers already present. +- Mapping for third-party apps; Mode B has no codebase access by definition (itd-4). +- The `data-testid` anchoring convention itself, which the how-to for instrumenting your own app documents. + +## Scope Conditions + +- The session is a web session and at least one confirmed finding carries a selector or route anchor. A session with none is refused loudly, not mapped. +- The application's repository is available locally and passed by path. Testimony reads it only to verify that a returned path exists and a line is in range, and never writes into it. +- The application anchors interactive elements with stable `data-testid` attributes. Without them, resolution rests on selectors that may not survive a rebuild. +- The host that answers the mapping request is the operator's chosen model. The CLI emits the request, never calls a model, holds no keys, and adds no network dependency. +- `timeline.jsonl` is present, so each mapped finding's event window resolves for the issue draft. + +## Mechanism + +We expect a `data-testid` selector or a route, handed to a model with the repository path, to resolve to the owning component because stable test ids are near-deterministic grep targets in the application's own source. It would be shown wrong by a high reject rate on references, or by references that land on the test suite instead of the component. + +## Acceptance Criteria + +- **Given** a session with a confirmed finding that carries a selector or route anchor, and a path to the application's repository, **when** the mapping request is emitted, **then** it carries that finding's anchor, quote, event window, and the repository path, and no finding that is unverified, rejected, or duplicate. +- **Given** a mapping answer from the host, **when** it is ingested, **then** each reference that names a confirmed finding and a repo-relative path that exists under the repository, with any line within the file's length, is written to `refs.jsonl` with status `proposed`, and an answer in which any reference fails a check is refused as a whole with every error reported. +- **Given** a mapped finding, **when** an issue draft is produced, **then** it contains a title, reproduction steps from the event window, the participant quote, and the suspected file. +- **Given** a proposed reference, **when** a human accepts or rejects it, **then** the decision is appended to `refs.jsonl` without rewriting the reference line, and the reference stays linked to its finding and session. +- **Given** a session with no confirmed finding that carries a selector or route, **when** the mapping step runs, **then** it refuses with the finding count by status and writes nothing. + +## Open Questions + +- Does a route anchor resolve as reliably as a test id? A route lives in a router table the model must read and interpret, where a test id is a literal string to search for. +- Should an accepted reference feed the regression-test draft (itd-9), so a test arrives with the file it exercises already named? + +## Grounds + +- pursued: itd-9 proved the emit, ingest, review, render shape on drafts, and mapping is the last pipeline step that turns a finding into something a developer opens in an editor; it would be shown wrong if reviewers reject most references, or if the issue drafts are not what a maintainer actually files diff --git a/.abcd/development/research/notes/2026-09-15-decomposition-calibration.md b/.abcd/development/research/notes/2026-09-15-decomposition-calibration.md new file mode 100644 index 0000000..cdf1ef9 --- /dev/null +++ b/.abcd/development/research/notes/2026-09-15-decomposition-calibration.md @@ -0,0 +1,35 @@ +# Decomposition calibration note + +Hand-run itd-84 decompositions, graded against the maintainer's confirmation. +One entry per proposal. The corpus this note builds gates the automated rung. + +## 2026-09-15: itd-3 codebase mapping (planning interview) + +Initial routing, proposed before the interview: + +| Part | Type | Home | +|---|---|---| +| Web-anchor mapping: emit over confirmed findings with selector, route, window, repository path; ingest validates each reference into a new record family; human accept or reject | intent | itd-3 | +| Issue draft rendered from a mapped finding | intent | itd-3, or split off | +| Terminal-anchor mapping over timeline text | intent | new draft, or a recorded deferral in itd-3 | +| `data-testid` anchoring convention for the app under test | docs | a how-to page, not a CLI capability | +| Third record family through the shared append primitive and review verb | ADR | refines adr-1 | + +Typed links proposed: `builds_on` itd-2 and itd-9; `refines` adr-1; unblocked +by itd-11. No reversal flagged. + +Verdict proposed: SPLIT. + +Confirmed routing: + +| Part | Type | Home | Survived? | +|---|---|---|---| +| Web-anchor mapping | intent | itd-3 | yes | +| Issue draft | intent | itd-3 (kept in; the maintainer chose the draft to render from any mapped finding, not only an accepted reference) | yes, with a scope change | +| Terminal-anchor mapping | intent | new draft itd-2609152113364815, filed in the same session | yes; the maintainer chose the spin-off over the deferral | +| `data-testid` convention | docs | already documented in `docs/how-to/instrument-your-own-app.md`; no new work | routing survived, but the part was already home | +| Third record family | ADR | refines adr-1; recorded in the spec rather than a new ADR, since adr-1 already names the pattern | yes | + +Verdict adopted: SPLIT. The initial routing survived confirmation in full; the +two corrections were within a part (the issue draft's trigger) and a +discovery that one part needed no work (the convention doc already exists). diff --git a/.abcd/development/specs/open/spc-2609152218094570-codebase-mapping.md b/.abcd/development/specs/open/spc-2609152218094570-codebase-mapping.md new file mode 100644 index 0000000..9819867 --- /dev/null +++ b/.abcd/development/specs/open/spc-2609152218094570-codebase-mapping.md @@ -0,0 +1,380 @@ +--- +id: spc-2609152218094570 +slug: codebase-mapping +intent: itd-3 +origin: researcher-authored +production_mode: hand-written +--- +# codebase-mapping + +## Summary + +`testimony map` adds the mapping step that turns a **confirmed** web-session +finding into proposed references to the application's source, and +`testimony review -kind refs` adds the human accept / reject pass over each +reference. The resolution stays **host-delegated**, exactly as for `analyze` and +`draft-tests`: the CLI never calls a model, holds no keys, and adds no network +dependency. `map -session DIR -repo DIR` *emits* one self-contained mapping +request: a versioned rubric, the session context, the repository path, and, for +each confirmed finding that carries a selector or route anchor, that finding's +record plus its event window from `timeline.jsonl`. Any agent host or human +resolves the anchors against the repository and saves the JSON answer. +`map -session DIR -repo DIR -ingest FILE` is the **validation boundary**: every +reference must name a finding whose current status is `confirmed` and whose +`ui` carries a selector or route, a repo-relative path that exists as a regular +file under `-repo`, and, when a line is given, a line within that file's +length. Ingest writes `refs.jsonl`, a third record family beside +`findings.jsonl` and `tests.jsonl`, with every reference forced to +`status: proposed`. `review -kind refs` walks the proposed references (or takes +one decision non-interactively) and records `accepted | rejected` as appended, +non-destructive records. `map -session DIR -render` renders an issue draft per +mapped finding to stdout: a title, reproduction steps from the event window, +the participant's quote, and the suspected files. + +Nothing is filed anywhere, and the CLI never writes into the application's +repository: `-repo` is opened read-only, for existence and line-count checks. + +A session with no confirmed finding carrying a selector or route is **staged +loudly**: `map` refuses, prints the finding count by status, exits non-zero, +and writes nothing. + +Every test in this slice is fixture-based and hermetic; stdlib only. The +package is `internal/coderefs`. + +## Design + +### Decisions carried in from the planning interview + +- **Web anchors only.** A finding is mappable iff its effective status is + `confirmed`, its `mode` is not `B`, and its `ui` carries a non-empty + `selector` or `route`. Terminal findings carry no `ui` and are the subject of + their own draft (itd-2609152113364815); this slice does not read + `terminal_output` text. +- **A separate `refs.jsonl`.** The finding schema is closed and `analyze + -ingest` refuses to rewrite a verdict-bearing `findings.jsonl`, so nothing is + written onto a finding. The reference is its own record family through the + ADR 0001 primitives (`session.CommitRecords` for the machine record, + `session.AppendRecord` for the human decision) and its own `review -kind`. +- **No confidence field.** A model-asserted `high | medium | low` cannot be + validated at the boundary. The reference carries what ingest verified and the + human decision is the only quality signal. An unknown field, `confidence` + included, is refused by `DisallowUnknownFields`. +- **The issue draft renders from a mapped finding**, not only from an accepted + reference. The render lists every reference for the finding with its current + status, so the reader sees which paths a person has already vouched for; + review does not gate the render. +- **Resolution is the host's job.** The CLI hands over the anchor and the + repository path; it never greps, never reads a router table, and never + interprets the source. Its only reads of the repository are the existence + and line-count checks at ingest. + +### Eligibility + +`map` calls `analyze.Load(dir)` and `analyze.EffectiveStatus(findings, +verdicts)`, the single helper `review`, `report`, and `draft-tests` already use, +so a finding confirmed then later rejected is not eligible and one rejected then +later confirmed is. `duplicate` and `unverified` are never eligible. On top of +the `draft-tests` rule, a finding whose `ui` is absent, or whose `selector` and +`route` are both empty, is not eligible: there is no anchor to hand over. + +The refusal names both counts, so an operator can tell a session with no +confirmed finding from one whose confirmed findings carry no anchor: + +``` +map: no mappable finding in 5 findings: 1 confirmed (0 with a selector or route), 3 unverified, 0 duplicate, 1 rejected +``` + +### CLI surface + +``` +testimony map -session DIR -repo DIR [-window 10] [-out FILE] emit the mapping request (stdout default) +testimony map -session DIR -repo DIR -ingest FILE validate answer JSON → refs.jsonl (FILE may be "-" for stdin) +testimony map -session DIR -render [-out FILE] render an issue draft per mapped finding (stdout default) + +testimony review -session DIR -kind refs interactive accept/reject walk +testimony review -session DIR -kind refs -ref R-NNN -decision accepted|rejected +``` + +`map` runs in exactly one mode, following `draft-tests`' rule: emit (neither +`-ingest` nor `-render`), ingest (`-ingest`), or render (`-render`). `-ingest` +combines with neither `-out` nor `-render`; `-window` belongs to emit alone and +is refused with `-ingest` or `-render` rather than silently ignored. `-repo` is +required for emit and ingest and refused with `-render`, which reads only what is +on disk in the session. `-repo` must name an existing directory; a path that is +not a directory, or an explicitly empty value, is refused at exit 2. Every flag +follows the existing gauntlet: `-session` required or inferred from the current +directory; empty explicit values refused; a non-finite `-window` refused; no +positional arguments. + +| Flag | Default | Meaning | +|---|---|---| +| `-session` | *(required, or inferred)* | session directory | +| `-repo` | *(required for emit and ingest)* | the application's repository root; read only | +| `-window` | `10` | emit mode: the event-window half-width in seconds, as `draft-tests` | +| `-out` | *(stdout)* | emit/render mode: write to `FILE` instead of stdout | +| `-ingest` | *(off)* | ingest mode: validate the answer JSON at `FILE` (or `-`) into `refs.jsonl` | +| `-render` | *(off)* | render mode: write the issue drafts as Markdown | + +`review` gains `refs` as a third value of `-kind` and two flags: + +| Flag | Default | Meaning | +|---|---|---| +| `-kind` | `findings` | `findings`, `tests`, or `refs` | +| `-ref` | *(interactive)* | non-interactive: the reference to decide (`R-NNN`), `-kind refs` only | +| `-decision` | *(interactive)* | non-interactive: `accepted` or `rejected`; `edited` is refused with `-kind refs` | + +Flags belonging to another record family (`-finding`, `-verdict`, `-test`, +`-edit`) are refused with `-kind refs` at exit 2, checked in `internal/cli` and +again in `review.Run`, as the `tests` kind already is. `-kind findings` and +`-kind tests` are byte-for-byte unchanged. + +Reads by mode: emit reads `manifest.json`, `timeline.jsonl`, and +`findings.jsonl`; ingest reads `manifest.json`, `findings.jsonl`, and the +repository (existence and line counts only); render reads `manifest.json`, +`timeline.jsonl`, `findings.jsonl`, and `refs.jsonl`. Emit hints to run `merge` +first when the timeline is missing; every mode hints to run `analyze -ingest` +first when there is no `findings.jsonl`; render and `review -kind refs` hint to +run `map -ingest` first when there is no `refs.jsonl`. + +### The event window + +Reused, not reimplemented: `drafttests.Window(entries, f, window)` is the one +helper that turns a finding's cited evidence into a time-ordered slice of the +timeline, and the issue draft's reproduction steps need exactly the window the +test draft needs. The default half-width is 10 seconds for the same reason +`draft-tests` chose it: a repro needs the lead-up and the aftermath. + +### `map` — emitting the request (host-delegated) + +`EmitRequest(dir, repo string, window float64) (string, error)` builds one +self-contained prompt, mirroring `drafttests.EmitRequest`: + +1. **Rubric header**: `Testimony code-mapping rubric: testimony-coderefs/v1`, + with `RubricVersion` a package constant. +2. **Stance**: every reference is a proposal, born `proposed`; a human accepts + or rejects it. Only the confirmed findings below are eligible. Resolve each + anchor by reading the repository at the path given; never invent a path, + never guess a line, and omit a finding rather than answer it with a path + that does not exist. Never alter the quote. +3. **Instructions**: zero or more references per finding, in finding-id order; + each names the finding, a repo-relative path (forward slashes, no leading + `/`, no `..` segment), an optional 1-based line, and one `role` from the + closed set `owner | handler | route | test` (the file that renders the + element, the code that handles its interaction, the router entry for the + route, or a test that exercises it). The role is the model's description of + *why* the path is relevant, which the human reads; it is validated as a + member of the set, never for truth. +4. **Rubric body**: field definitions, the hard constraints restated as the + rules ingest enforces, and the `status` explanation `draft-tests` already + carries (the record is shown verbatim, so its `status` reads `unverified`; + the confirming verdict is named in the header). +5. **Repository**: the absolute path passed as `-repo`, as the host will open + it, plus a note that the CLI verifies existence and line counts at ingest. + This is the one place an absolute local path appears in an emitted request; + it is the operator's own machine's path, written to a file the operator + chooses, and it is never written into the session directory. +6. **Session context**: manifest `app`, `participant`, and ordered `tasks`. +7. **Eligible findings**: per finding, in id order: a prose header naming its + id, `type`, `severity`, clock, the selector and route, and the date of the + confirming verdict; the finding's own JSON line in a ```jsonl fence; its + event window as a ```jsonl fence. +8. **Required output shape and worked example**: + + > Answer with a single JSON document: `{"rubric":"testimony-coderefs/v1","refs":[ … ]}`. A bare top-level array is also accepted. Output JSON only. + + ```json + {"rubric":"testimony-coderefs/v1","refs":[ + {"id":"R-001","finding":"F-001","session":"sample-session", + "path":"src/settings/ProfileForm.tsx","line":48,"role":"owner", + "status":"proposed"}, + {"id":"R-002","finding":"F-001","session":"sample-session", + "path":"src/settings/saveProfile.ts","line":12,"role":"handler", + "status":"proposed"} + ]} + ``` + +**Escaping** follows the established rule: prose and list-item values go +through `session.SafeInline`; fenced values through `session.SafeText`. The +repository path goes through `SafeInline` too. + +Nothing in the session directory is mutated by emit. + +### Reference record — the `Ref` type + +One reference per line of `refs.jsonl`. Reference lines carry no `kind` field; +the schema is closed (`DisallowUnknownFields`). + +| Field | Type | Required | Validation at ingest | +|---|---|---|---| +| `id` | string | yes | matches `^R-\d{3}$`; unique within the answer and the file | +| `finding` | string | yes | names a finding in `findings.jsonl` whose effective status is `confirmed`, whose `mode` is not `B`, and whose `ui` carries a selector or route | +| `session` | string | yes | equals the manifest's `session` | +| `path` | string | yes | repo-relative: non-empty after `SafeText`+trim, at most 512 bytes, forward slashes, no leading `/`, no `.` or `..` segment, no NUL; after `filepath.Join(repo, path)` and `filepath.Rel` back, still inside `repo`; names an existing **regular file** (symlinks are not followed: `os.Lstat`) | +| `line` | int | no | when present, `1 <= line <= lines(file)`, where `lines` counts `\n`-terminated lines plus an unterminated last line, reading at most `session.MaxJSONLBytes`; a file larger than that bound is refused for a line check rather than counted | +| `role` | string | yes | one of `owner \| handler \| route \| test` | +| `status` | string | no on input, always present on disk | ignored on input and forced to `"proposed"` | + +The field is `finding`, following the convention the verdict and decision +records set: the field is named after the kind of record it points at. The +decision record below names its referent `ref`. + +**Why the path check is a containment check and not a string check.** A model +answer is untrusted input naming a filesystem path the CLI will open. The +`..`-segment rule catches the obvious escape; the `Rel`-back-inside-`repo` rule +catches the ones a string rule misses (a path through a symlinked directory, +an absolute path on a platform where `Join` does not neutralise it). `Lstat` +rather than `Stat` means a symlink inside the repository pointing outside it is +refused as "not a regular file" rather than followed. The CLI opens the file +only when `line` is present, only to count lines, and only up to the shared +byte bound. + +**Why there is no `quote` on the reference.** The test draft carries +`rationale_quote` because a test is a document a person reads on its own. A +reference is a pointer; its finding carries the quote, and the render joins +them. Copying the quote onto every reference would be a second place for the +evidence to drift. + +### `map -ingest FILE` — the validation boundary + +1. Load `manifest.json` and `findings.jsonl`; compute effective status; build + the eligible set `id → finding`. If empty, refuse loudly before reading a + byte of the answer. +2. Read `FILE` (or stdin) bounded by `session.MaxAnswerBytes`. Accept an object + with a `refs` array or a bare array. `rubric`, when present, must be known. +3. Decode each reference with `DisallowUnknownFields` through a `rawRef` whose + `Line` is `*int`, so an absent line is distinguishable from `0`. +4. Run every rule in the table. Validation is **transactional and exhaustive**: + all errors across all references are collected, each naming the reference by + id or answer position, the field, and the offending value; if any exists, + nothing is written and the exit is non-zero. +5. Force `status: "proposed"`; pre-flight the serialised size against + `session.MaxJSONLLine` per line and `session.MaxJSONLBytes` in total. +6. Commit through `session.CommitRecords` with a guard that refuses to + overwrite a `refs.jsonl` already holding any `kind:"decision"` line, exactly + as `commitDrafts` protects `tests.jsonl`. +7. Print `validated N references → (all proposed)`. + +An empty `refs` array is refused rather than written, so a bad answer cannot +erase a prior `refs.jsonl`. A model that found nothing for a finding omits it; +an operator who wants that recorded has the refusal message and the request. + +### The human pass — `review -kind refs` + +**Decision record**, appended to `refs.jsonl` through `session.AppendRecord`, +never an in-place rewrite: + +```json +{"kind":"decision","ref":"R-001","decision":"accepted","at":"2026-09-15"} +{"kind":"decision","ref":"R-002","decision":"rejected","at":"2026-09-15"} +``` + +| Field | Type | Validation | +|---|---|---| +| `kind` | string | literal `"decision"` | +| `ref` | string | an existing reference id in the file | +| `decision` | string | `accepted \| rejected`; there is no `edited`, because a wrong path is rejected and a corrected one is ingested, never patched | +| `at` | string | ISO date `YYYY-MM-DD` | + +The interactive walk shows, per proposed reference, the finding's quote and +anchor, the path, line, and role, and the first lines around `line` from the +repository when `-repo` is given (read-only, bounded), so the reviewer can +judge without leaving the terminal. Without `-repo` the walk shows the record +alone. + +`EffectiveStatus(refs, decisions)`: every reference starts `proposed`; decisions +apply in file order and the last one wins; a decision naming an unknown +reference or carrying a value outside the enum is ignored for display. + +### `map -render` — the issue draft + +One Markdown block per mapped finding, in finding-id order, to stdout or +`-out`: + +```markdown +## F-001: Saving gives no confirmation + +**Severity** 3 · **Anchor** `[data-testid=save-btn]` on `/settings/profile` + +> "I clicked save and nothing happened" +> Alice, 00:22 + +### Steps to reproduce +1. Open /settings/profile. +2. Click the display-name field ([data-testid=display-name]). +3. Type "Alice". +4. Click Save ([data-testid=save-btn]). + +### Suspected files +- `src/settings/ProfileForm.tsx:48` (owner) — accepted 2026-09-15 +- `src/settings/saveProfile.ts:12` (handler) — proposed + +Session `sample-session` · finding F-001 · references R-001, R-002 +``` + +The title is derived by the CLI, not the model: the finding's `type` and the +first clause of its quote, capped at 80 characters, because a title is +presentation and the render must work from the records alone. The steps are +derived from the window the way `draft-tests`' rubric instructs the model to, +but here by the CLI: one orientation line from the first event's route, then +one imperative line per event in the window at or before the finding's `t`, +naming the selector where the event carries one. Steps the CLI derives are +mechanical and may read flatly; that is the trade for a render that needs no +second model round-trip and cannot invent a step. Every reference for the +finding is listed with its current status, so a draft rendered before review is +visibly unreviewed. + +Nothing is written into the application's repository, and the render does not +open it. + +### Docs and record + +- `docs/reference/cli.md`: `map` and the `review -kind refs` additions. +- `docs/reference/session-directory.md`: `refs.jsonl`. +- `docs/how-to/map-findings-to-code.md`: the four-step flow. +- `examples/sample-session`: a `refs.jsonl` with one accepted and one proposed + reference, and a `request.md` fixture; the sample references point into a + small fixture tree under `internal/coderefs/testdata/repo/` rather than into + Testimony's own source, so the smoke run in CI does not depend on the layout + of this repository. +- `CHANGELOG.md` `[Unreleased]`: one Added entry. +- The change that lands this closes the spec: `abcd spec close + spc-2609152218094570 --impact additive`. + +## How the acceptance criteria are satisfied + +1. **The emitted request carries the anchor, quote, window, and repository + path, and no ineligible finding.** `EmitRequest` renders only the eligible + set computed from `EffectiveStatus` plus the anchor rule; each finding's + header names selector and route, its JSON line carries the quote, its window + follows, and the repository section carries `-repo`. Tested over the sample + session with F-001 confirmed and anchored, F-002 unverified, F-003 rejected, + F-005 duplicate, and a confirmed finding with no `ui`: exactly F-001 appears. +2. **Ingest verifies finding, path, and line, and refuses the whole answer on + any failure.** The `Ref` table is enforced field by field; the path check is + the containment-plus-`Lstat` rule; the line check counts the file. Tests + cover: an unconfirmed finding, an anchorless finding, a `..` path, a symlink + out of the repository, a directory, a missing file, a line past the end, an + unknown field (`confidence`), and a second good reference in the same answer + that is not written because the first failed. +3. **An issue draft from a mapped finding carries title, steps, quote, and + suspected file.** `Render` derives the title, steps from `Window`, the + quote from the finding, and lists every reference. Tested against a golden + file from the sample session, and against a session whose `refs.jsonl` holds + a proposed-only reference to show the render does not wait for review. +4. **A decision is appended without rewriting the reference line, and the link + survives.** `AppendDecision` goes through `session.AppendRecord`; the + round-trip test asserts the reference line is byte-identical after two + decisions; there is no edit path, so `finding`, `session`, and `path` are + unreachable by any later write. +5. **A session with no mappable finding is refused with the tally and writes + nothing.** The eligibility check runs first on every write path; the test + strips the sample's verdicts and asserts exit 1, the by-status line with the + anchored count, and no `refs.jsonl`. + +## Open questions carried on the intent + +- Route anchors: the first slice hands the route over and lets the human + decide; the accept rate on `role: route` references against `role: owner` + ones is the measurement, and it is readable from `refs.jsonl` alone. +- Feeding accepted references into `draft-tests`: not built here. When it is, + the reference id is the link, and the render above already shows the join. diff --git a/.abcd/work/DECISIONS.md b/.abcd/work/DECISIONS.md index 1aaaded..0ae6761 100644 --- a/.abcd/work/DECISIONS.md +++ b/.abcd/work/DECISIONS.md @@ -1708,3 +1708,15 @@ Architecture-shaping decisions graduate to an ADR under an exchange unit. The quality-floor question the draft raised is deferred to the retained verdicts, which now become a per-backend comparison, rather than built as an unmeasured number the CLI could not enforce anyway. +- 2026-09-15 — itd-3 (codebase mapping) planned as a SPLIT: web anchors only + (a confirmed finding whose `ui` carries a selector or route), with terminal + anchoring spun off as its own draft (itd-2609152113364815) rather than + deferred silently. References land in a separate `refs.jsonl` record family + through the ADR 0001 primitives, never on the finding, because the finding + schema is closed and `analyze -ingest` protects a verdict-bearing file. No + confidence field: ingest cannot validate a model-asserted word, so the human + accept / reject decision is the only quality signal. Resolution is the host's + job; the CLI reads the application repository only for existence and + line-count checks and never writes into it. The issue draft renders from any + mapped finding, listing every reference with its current status, so review + does not gate the render. Spec: spc-2609152218094570. From f447002eada036fc1bd01ab16807d9ec3a4d481e Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:56:35 +0100 Subject: [PATCH 2/2] feat: map confirmed findings to the code that owns them Ships itd-3 (spec spc-2609152218094570). `testimony map -session DIR -repo DIR` emits a host-delegated mapping request over each confirmed finding whose `ui` carries a selector or route: the anchor, the finding's record, its event window, and the repository's path. `map -ingest FILE` is the validation boundary into a new `refs.jsonl` record family: every reference must name a currently confirmed anchored finding, this session, a role in the closed set, and a repo-relative path that is an existing regular file under `-repo` (containment checked through symlinked parents, no symlink at the leaf), with any line within the file's length; the checked path is what is recorded, an answer is capped at 1000 references before any is checked, and unknown fields such as `confidence` are refused. `review -kind refs` appends accept / reject decisions with no edit path, showing the source around each line when `-repo` is given to the walk. `map -render` renders one issue draft per mapped finding with a CLI-derived title and steps, the quote, and every reference with its current status; review does not gate it and nothing is filed. A session with no confirmed anchored finding is refused with the by-status tally, including the anchored count, and writes nothing. The repository is opened read-only and never written; the emitted request is the one artefact naming an absolute local path, so `map` refuses an `-out` inside the session directory. Docs, sample session, and CI smoke follow; the spec is closed and the intent shipped in the same change, with the fidelity audit ingested. Assisted-by: Claude:claude-fable-5-1 --- .../intents/planned/itd-3-codebase-mapping.md | 72 --- .../intents/shipped/itd-3-codebase-mapping.md | 206 +++++++ .../spc-2609152218094570-codebase-mapping.md | 0 .abcd/work/CONTEXT.md | 17 +- .github/workflows/ci.yml | 10 + AGENTS.md | 9 +- CHANGELOG.md | 30 + README.md | 12 +- docs/README.md | 2 +- docs/how-to/map-findings-to-code.md | 163 +++++ docs/reference/cli.md | 69 ++- docs/reference/session-directory.md | 40 +- examples/sample-session/refs.jsonl | 3 + internal/cli/cli.go | 295 ++++++++- internal/cli/cli_test.go | 211 ++++++- internal/coderefs/coderefs.go | 415 +++++++++++++ internal/coderefs/coderefs_test.go | 237 ++++++++ internal/coderefs/emit.go | 239 ++++++++ internal/coderefs/emit_test.go | 171 ++++++ internal/coderefs/ingest.go | 563 ++++++++++++++++++ internal/coderefs/ingest_test.go | 408 +++++++++++++ internal/coderefs/render.go | 292 +++++++++ internal/coderefs/render_test.go | 228 +++++++ internal/coderefs/review.go | 353 +++++++++++ internal/coderefs/review_test.go | 240 ++++++++ internal/coderefs/testdata/findings.jsonl | 13 + internal/coderefs/testdata/issues.md | 24 + internal/coderefs/testdata/manifest.json | 11 + internal/coderefs/testdata/repo/src/routes.ts | 5 + .../repo/src/settings/ProfileForm.test.tsx | 5 + .../repo/src/settings/ProfileForm.tsx | 51 ++ .../testdata/repo/src/settings/saveProfile.ts | 13 + internal/coderefs/testdata/timeline.jsonl | 13 + internal/review/review.go | 62 +- internal/review/review_test.go | 40 ++ internal/session/records.go | 9 +- internal/session/session.go | 1 + 37 files changed, 4396 insertions(+), 136 deletions(-) delete mode 100644 .abcd/development/intents/planned/itd-3-codebase-mapping.md create mode 100644 .abcd/development/intents/shipped/itd-3-codebase-mapping.md rename .abcd/development/specs/{open => closed}/spc-2609152218094570-codebase-mapping.md (100%) create mode 100644 docs/how-to/map-findings-to-code.md create mode 100644 examples/sample-session/refs.jsonl create mode 100644 internal/coderefs/coderefs.go create mode 100644 internal/coderefs/coderefs_test.go create mode 100644 internal/coderefs/emit.go create mode 100644 internal/coderefs/emit_test.go create mode 100644 internal/coderefs/ingest.go create mode 100644 internal/coderefs/ingest_test.go create mode 100644 internal/coderefs/render.go create mode 100644 internal/coderefs/render_test.go create mode 100644 internal/coderefs/review.go create mode 100644 internal/coderefs/review_test.go create mode 100644 internal/coderefs/testdata/findings.jsonl create mode 100644 internal/coderefs/testdata/issues.md create mode 100644 internal/coderefs/testdata/manifest.json create mode 100644 internal/coderefs/testdata/repo/src/routes.ts create mode 100644 internal/coderefs/testdata/repo/src/settings/ProfileForm.test.tsx create mode 100644 internal/coderefs/testdata/repo/src/settings/ProfileForm.tsx create mode 100644 internal/coderefs/testdata/repo/src/settings/saveProfile.ts create mode 100644 internal/coderefs/testdata/timeline.jsonl diff --git a/.abcd/development/intents/planned/itd-3-codebase-mapping.md b/.abcd/development/intents/planned/itd-3-codebase-mapping.md deleted file mode 100644 index 730292a..0000000 --- a/.abcd/development/intents/planned/itd-3-codebase-mapping.md +++ /dev/null @@ -1,72 +0,0 @@ ---- -id: itd-3 -slug: codebase-mapping -spec_id: spc-2609152218094570 -kind: standalone -suggested_kind: null -reclassification_history: [] -builds_on: [itd-2, itd-9] -severity: major ---- - -# Findings Land in the Code, Not Just the Report - -## Press Release - -> **Testimony maps confirmed findings to the code that owns them.** Once a human has confirmed a finding, the mapping step hands its selector, route, and event window, together with the path to the application's repository, to the operator's chosen model. The model answers with the source locations it believes own that anchor. Testimony checks each answer against the repository, the file exists and the line is in range, and records it as a proposed reference. From a mapped finding, Testimony renders an issue draft: a title, reproduction steps from the event window, the participant's own words as evidence, and the suspected file. A person accepts or rejects each reference, and the decision is kept beside it. Nothing is filed anywhere, and nothing is written into the application's repository. -> -> "The gap was always between 'users struggled here' and 'this file, this handler'," said Alice, the maintainer. "Now a finding comes to me with the selector, the route, the suspect file, and the person's own words. I start fixing instead of reconstructing." - -## Why This Matters - -Session findings that stop at a report require a second act of translation before anyone can act on them, and that translation is where evidence goes stale. Anchoring interactive elements with stable `data-testid` attributes at build time makes selectors near-deterministic grep targets, so the translation can be delegated to a model and checked by the CLI, turning a qualitative observation into a workable, evidenced issue while the session is still fresh. - -The step sits downstream of verification, as regression-test drafting (itd-9) does: only a finding a human has confirmed is mapped, every reference is born a proposal, and the human decision on it is retained beside the machine record. The mapping is the last step of the pipeline, kept separable so it can iterate without destabilising capture or analysis. - -## What's In Scope - -- Emitting a mapping request over each confirmed finding that carries a selector or route anchor: the anchor, the participant's quote, the event window, and the path to the application's repository. -- Ingesting the answer into a new record family, `refs.jsonl`, beside `findings.jsonl` and `tests.jsonl`: each reference names a confirmed finding and a repo-relative path that exists under the repository, with any line within the file's length, and is born `proposed`. -- A human accept / reject pass over each reference, appended non-destructively as verdicts and test decisions are, so the reference stays linked to its finding and session. -- Rendering an issue draft from a mapped finding: a title, reproduction steps from the event window, the participant's quote, and the suspected file. -- Reading the application's repository only to verify that a returned path exists and a line is in range. - -## What's Out of Scope - -- Automatic filing of issues into a tracker without human review. -- Anchoring a finding from a terminal session on the command or output text in the timeline; a terminal finding carries no selector or route, and the timeline text keeps its escape sequences, so that is its own draft (itd-2609152113364815). -- A confidence level on a reference. Ingest cannot validate a model-asserted word, and the human decision is the only quality signal. -- Grepping, router-table resolution, or any other reasoning about the application's source inside the CLI; resolution is the host's job. -- Writing anything into the application's repository. -- Native-app accessibility-identifier instrumentation beyond using identifiers already present. -- Mapping for third-party apps; Mode B has no codebase access by definition (itd-4). -- The `data-testid` anchoring convention itself, which the how-to for instrumenting your own app documents. - -## Scope Conditions - -- The session is a web session and at least one confirmed finding carries a selector or route anchor. A session with none is refused loudly, not mapped. -- The application's repository is available locally and passed by path. Testimony reads it only to verify that a returned path exists and a line is in range, and never writes into it. -- The application anchors interactive elements with stable `data-testid` attributes. Without them, resolution rests on selectors that may not survive a rebuild. -- The host that answers the mapping request is the operator's chosen model. The CLI emits the request, never calls a model, holds no keys, and adds no network dependency. -- `timeline.jsonl` is present, so each mapped finding's event window resolves for the issue draft. - -## Mechanism - -We expect a `data-testid` selector or a route, handed to a model with the repository path, to resolve to the owning component because stable test ids are near-deterministic grep targets in the application's own source. It would be shown wrong by a high reject rate on references, or by references that land on the test suite instead of the component. - -## Acceptance Criteria - -- **Given** a session with a confirmed finding that carries a selector or route anchor, and a path to the application's repository, **when** the mapping request is emitted, **then** it carries that finding's anchor, quote, event window, and the repository path, and no finding that is unverified, rejected, or duplicate. -- **Given** a mapping answer from the host, **when** it is ingested, **then** each reference that names a confirmed finding and a repo-relative path that exists under the repository, with any line within the file's length, is written to `refs.jsonl` with status `proposed`, and an answer in which any reference fails a check is refused as a whole with every error reported. -- **Given** a mapped finding, **when** an issue draft is produced, **then** it contains a title, reproduction steps from the event window, the participant quote, and the suspected file. -- **Given** a proposed reference, **when** a human accepts or rejects it, **then** the decision is appended to `refs.jsonl` without rewriting the reference line, and the reference stays linked to its finding and session. -- **Given** a session with no confirmed finding that carries a selector or route, **when** the mapping step runs, **then** it refuses with the finding count by status and writes nothing. - -## Open Questions - -- Does a route anchor resolve as reliably as a test id? A route lives in a router table the model must read and interpret, where a test id is a literal string to search for. -- Should an accepted reference feed the regression-test draft (itd-9), so a test arrives with the file it exercises already named? - -## Grounds - -- pursued: itd-9 proved the emit, ingest, review, render shape on drafts, and mapping is the last pipeline step that turns a finding into something a developer opens in an editor; it would be shown wrong if reviewers reject most references, or if the issue drafts are not what a maintainer actually files diff --git a/.abcd/development/intents/shipped/itd-3-codebase-mapping.md b/.abcd/development/intents/shipped/itd-3-codebase-mapping.md new file mode 100644 index 0000000..831e5c7 --- /dev/null +++ b/.abcd/development/intents/shipped/itd-3-codebase-mapping.md @@ -0,0 +1,206 @@ +--- +id: itd-3 +slug: codebase-mapping +spec_id: spc-2609152218094570 +kind: standalone +suggested_kind: null +reclassification_history: [] +builds_on: [itd-2, itd-9] +severity: major +--- + +# Findings Land in the Code, Not Just the Report + +## Press Release + +> **Testimony maps confirmed findings to the code that owns them.** Once a human has confirmed a finding, the mapping step hands its selector, route, and event window, together with the path to the application's repository, to the operator's chosen model. The model answers with the source locations it believes own that anchor. Testimony checks each answer against the repository, the file exists and the line is in range, and records it as a proposed reference. From a mapped finding, Testimony renders an issue draft: a title, reproduction steps from the event window, the participant's own words as evidence, and the suspected file. A person accepts or rejects each reference, and the decision is kept beside it. Nothing is filed anywhere, and nothing is written into the application's repository. +> +> "The gap was always between 'users struggled here' and 'this file, this handler'," said Alice, the maintainer. "Now a finding comes to me with the selector, the route, the suspect file, and the person's own words. I start fixing instead of reconstructing." + +## Why This Matters + +Session findings that stop at a report require a second act of translation before anyone can act on them, and that translation is where evidence goes stale. Anchoring interactive elements with stable `data-testid` attributes at build time makes selectors near-deterministic grep targets, so the translation can be delegated to a model and checked by the CLI, turning a qualitative observation into a workable, evidenced issue while the session is still fresh. + +The step sits downstream of verification, as regression-test drafting (itd-9) does: only a finding a human has confirmed is mapped, every reference is born a proposal, and the human decision on it is retained beside the machine record. The mapping is the last step of the pipeline, kept separable so it can iterate without destabilising capture or analysis. + +## What's In Scope + +- Emitting a mapping request over each confirmed finding that carries a selector or route anchor: the anchor, the participant's quote, the event window, and the path to the application's repository. +- Ingesting the answer into a new record family, `refs.jsonl`, beside `findings.jsonl` and `tests.jsonl`: each reference names a confirmed finding and a repo-relative path that exists under the repository, with any line within the file's length, and is born `proposed`. +- A human accept / reject pass over each reference, appended non-destructively as verdicts and test decisions are, so the reference stays linked to its finding and session. +- Rendering an issue draft from a mapped finding: a title, reproduction steps from the event window, the participant's quote, and the suspected file. +- Reading the application's repository only to verify that a returned path exists and a line is in range. + +## What's Out of Scope + +- Automatic filing of issues into a tracker without human review. +- Anchoring a finding from a terminal session on the command or output text in the timeline; a terminal finding carries no selector or route, and the timeline text keeps its escape sequences, so that is its own draft (itd-2609152113364815). +- A confidence level on a reference. Ingest cannot validate a model-asserted word, and the human decision is the only quality signal. +- Grepping, router-table resolution, or any other reasoning about the application's source inside the CLI; resolution is the host's job. +- Writing anything into the application's repository. +- Native-app accessibility-identifier instrumentation beyond using identifiers already present. +- Mapping for third-party apps; Mode B has no codebase access by definition (itd-4). +- The `data-testid` anchoring convention itself, which the how-to for instrumenting your own app documents. + +## Scope Conditions + +- The session is a web session and at least one confirmed finding carries a selector or route anchor. A session with none is refused loudly, not mapped. +- The application's repository is available locally and passed by path. Testimony reads it only to verify that a returned path exists and a line is in range, and never writes into it. +- The application anchors interactive elements with stable `data-testid` attributes. Without them, resolution rests on selectors that may not survive a rebuild. +- The host that answers the mapping request is the operator's chosen model. The CLI emits the request, never calls a model, holds no keys, and adds no network dependency. +- `timeline.jsonl` is present, so each mapped finding's event window resolves for the issue draft. + +## Mechanism + +We expect a `data-testid` selector or a route, handed to a model with the repository path, to resolve to the owning component because stable test ids are near-deterministic grep targets in the application's own source. It would be shown wrong by a high reject rate on references, or by references that land on the test suite instead of the component. + +## Acceptance Criteria + +- **Given** a session with a confirmed finding that carries a selector or route anchor, and a path to the application's repository, **when** the mapping request is emitted, **then** it carries that finding's anchor, quote, event window, and the repository path, and no finding that is unverified, rejected, or duplicate. +- **Given** a mapping answer from the host, **when** it is ingested, **then** each reference that names a confirmed finding and a repo-relative path that exists under the repository, with any line within the file's length, is written to `refs.jsonl` with status `proposed`, and an answer in which any reference fails a check is refused as a whole with every error reported. +- **Given** a mapped finding, **when** an issue draft is produced, **then** it contains a title, reproduction steps from the event window, the participant quote, and the suspected file. +- **Given** a proposed reference, **when** a human accepts or rejects it, **then** the decision is appended to `refs.jsonl` without rewriting the reference line, and the reference stays linked to its finding and session. +- **Given** a session with no confirmed finding that carries a selector or route, **when** the mapping step runs, **then** it refuses with the finding count by status and writes nothing. + +## Open Questions + +- Does a route anchor resolve as reliably as a test id? A route lives in a router table the model must read and interpret, where a test id is a literal string to search for. +- Should an accepted reference feed the regression-test draft (itd-9), so a test arrives with the file it exercises already named? + +## Grounds + +- pursued: itd-9 proved the emit, ingest, review, render shape on drafts, and mapping is the last pipeline step that turns a finding into something a developer opens in an editor; it would be shown wrong if reviewers reject most references, or if the issue drafts are not what a maintainer actually files + +## Audit Notes + + +Fidelity review — receipt rcp-d9678d4d4b1c (verifier abcd:intent-auditor claude-opus-5[1m]). + +Provenance: abcd:intent-auditor@claude-opus-5[1m] · rubric_hash sha256:542ed2cd51ff938717a3f47b2b332e8d47910beec0ca7ecdfd238ae7edf5ced5 · prompt_hash sha256:b6903c509bd327dd021530fadba8ebf088b81858a93cecc855ffb41dd3e13d2e +Input attestations: diff:f81199f..working tree (uncommitted, incl. untracked internal/coderefs/, examples/sample-session/refs.jsonl, docs/how-to/map-findings-to-code.md)@sha256:b7fd7176e3eaa3962ad8fb997d15c95efd19526a79f021b9471b87e628cb5668; + +Acceptance rollup: MET 4 · MET_WITH_CONCERNS 1 · NOT_MET 0 · INCONCLUSIVE 0 + +Per-criterion verdicts: +- ac-1 — MET: EmitRequest computes the eligible set before reading the timeline and renders, per finding, a header naming the selector and route, the finding's own JSON line (which carries the quote), and its event window from drafttests.Window, under a Repository section carrying the absolute -repo path; eligibility is confirmed-and-anchored-and-not-mode-B, so a smoke run over a copy of examples/sample-session emitted exactly one finding (F-001) out of five, omitting unverified F-002/F-004, rejected F-003 and duplicate F-005. + evidence: internal/coderefs/emit.go:55 — "mappable := eligible(findings, verdicts)" + evidence: internal/coderefs/emit.go:117 — "fmt.Fprintf(&b, "- Path: %s\n", session.SafeInline(root.abs))" + evidence: internal/coderefs/emit.go:149 — "clock(f.T), anchorPhrase(f), confirmedOn(eff[f.ID])" + evidence: internal/coderefs/emit.go:155 — "b.WriteString(session.SafeText(string(line)))" + evidence: internal/coderefs/emit.go:160 — "for _, e := range drafttests.Window(entries, f, window) {" + evidence: internal/coderefs/coderefs.go:309 — "if eff[f.ID].Value == "confirmed" && f.Mode != "B" && hasAnchor(f) {" + evidence: internal/coderefs/emit_test.go:19 — "func TestEmitCarriesAnchorQuoteWindowAndRepo(t *testing.T) {" +- ac-2 — MET: Ingest is the sole validation boundary: it refuses a reference whose finding is not in the confirmed-and-anchored set, holds the path to a containment-plus-Lstat rule against the repository, checks a present line against the counted file length, forces status to "proposed" on every written reference, and joins all errors so nothing is committed on any failure; a smoke run wrote two references (one answer-claimed "accepted" laundered to "proposed") and a four-reference bad answer produced eight errors across three references — finding, session, role, path and line all reported for the same reference — with no refs.jsonl written. + evidence: internal/coderefs/ingest.go:530 — "if _, ok := sources[session.SafeText(r.Finding)]; !ok {" + evidence: internal/coderefs/ingest.go:186 — "func (root repoRoot) checkPath(raw string) (full, clean string, err error) {" + evidence: internal/coderefs/ingest.go:225 — "fi, err := os.Lstat(full)" + evidence: internal/coderefs/ingest.go:548 — "if perr == nil && p.line != nil {" + evidence: internal/coderefs/ingest.go:110 — "refs[i].Status = "proposed"" + evidence: internal/coderefs/ingest.go:118 — "return nil, errors.Join(errs...)" + evidence: internal/coderefs/ingest_test.go:256 — "func TestIngestIsTransactional(t *testing.T) {" + evidence: examples/sample-session/refs.jsonl:1 — ""role":"owner","status":"proposed"" +- ac-3 — MET_WITH_CONCERNS: Render emits one Markdown block per mapped finding carrying a CLI-derived title, the participant's quote, reproduction steps derived from drafttests.Window over timeline.jsonl, and a Suspected files list of every reference with its current status; a smoke run produced a four-step repro for F-001 with both references labelled. Concern: the steps are only as good as the window — when the window holds no interaction event at or before the finding's t, the Steps to reproduce heading is filled with the single line "(no interaction event precedes the finding in its event window)", which I reproduced live, so the draft can carry a steps heading with no step derived; and the render refuses outright (exit 1) when timeline.jsonl is absent rather than degrading. + evidence: internal/coderefs/render.go:86 — "fmt.Fprintf(&b, "## %s — %s\n\n", mdInline(f.ID), title(f))" + evidence: internal/coderefs/render.go:88 — "mdOrPlaceholder(f.Quote, "no quote")" + evidence: internal/coderefs/render.go:91 — "for n, s := range steps(drafttests.Window(entries, f, DefaultWindow), f) {" + evidence: internal/coderefs/render.go:104 — "fmt.Fprintf(&b, "- %s (%s) — %s\n", mdCode(loc), mdOrDash(r.Role), decisionPhrase(st))" + evidence: internal/coderefs/render.go:203 — "return []string{"(no interaction event precedes the finding in its event window)"}" + evidence: internal/coderefs/render.go:50 — "entries, err := analyze.LoadTimeline(dir)" + evidence: internal/coderefs/testdata/issues.md:1 — "# Issue drafts" +- ac-4 — MET: A decision goes through AppendDecision → session.AppendRecord, the same append-only primitive the verdicts and test decisions use, with a Verify closure that re-reads the locked file and refuses if the target changed; there is no edit path, and commitRefs guards against a re-ingest overwriting a file that already holds decisions. Smoke run: accepting R-001 and rejecting R-002 appended lines 3 and 4 while the two reference lines stayed byte-identical (cmp clean), their finding and session fields intact, and a re-ingest of the same answer was refused. + evidence: internal/coderefs/review.go:211 — "func AppendDecision(dir string, d Decision, expect *Ref) error {" + evidence: internal/coderefs/review.go:226 — "return session.AppendRecord(a)" + evidence: internal/session/records.go:56 — "func AppendRecord(a Append) error {" + evidence: internal/coderefs/ingest.go:345 — "refusing to overwrite %s: it already holds decision records (the retained human record)" + evidence: internal/coderefs/review_test.go:53 — "func TestDecisionIsAppendedAndRefLinesUnchanged(t *testing.T) {" + evidence: examples/sample-session/refs.jsonl:3 — "{"kind":"decision","ref":"R-001","decision":"accepted","at":"2026-09-16"}" +- ac-5 — MET: The eligibility check runs first on both write paths — emit before the timeline is read, ingest before a byte of the answer — and the refusal carries the by-status tally with the anchored count beside the confirmed count; a session with the verdicts stripped refused on both emit and ingest at exit 1 with "5 findings: 0 confirmed (0 with a selector or route), 5 unverified, 0 duplicate, 0 rejected" and wrote no refs.jsonl, and a session with one confirmed but anchorless finding refused with "1 confirmed (0 with a selector or route)". + evidence: internal/coderefs/emit.go:56 — "if len(mappable) == 0 {" + evidence: internal/coderefs/ingest.go:47 — "if len(mappable) == 0 {" + evidence: internal/coderefs/coderefs.go:332 — "%d findings: %d confirmed (%d with a selector or route), %d unverified, %d duplicate, %d rejected" + evidence: internal/coderefs/coderefs.go:381 — "a finding is mappable when its verdict is confirmed and it carries a selector or route" + evidence: internal/coderefs/emit_test.go:75 — "func TestEmitRefusesWithNoMappableFinding(t *testing.T) {" + evidence: internal/coderefs/ingest_test.go:310 — "func TestIngestRefusesWithNoMappableFinding(t *testing.T) {" + +Gap audit: +- honoured: + - The mapping step hands the confirmed finding's selector, route, and event window, together with the path to the application's repository, to the operator's chosen model. + evidence: internal/coderefs/emit.go:117 — "- Path: %s" + evidence: internal/coderefs/emit.go:149 — "anchorPhrase(f), confirmedOn(eff[f.ID])" + evidence: internal/coderefs/emit.go:160 — "drafttests.Window(entries, f, window)" + - Testimony checks each answer against the repository and records it as a proposed reference in a new record family, refs.jsonl, beside findings.jsonl and tests.jsonl. + evidence: internal/coderefs/ingest.go:186 — "func (root repoRoot) checkPath(raw string)" + evidence: internal/coderefs/ingest.go:110 — "refs[i].Status = "proposed"" + evidence: internal/session/session.go:62 — "RefsFile = "refs.jsonl"" + evidence: docs/reference/session-directory.md:239 — "## `refs.jsonl`" + - A person accepts or rejects each reference, and the decision is kept beside it, appended non-destructively as verdicts and test decisions are. + evidence: internal/coderefs/review.go:211 — "func AppendDecision(dir string, d Decision, expect *Ref) error {" + evidence: internal/review/review.go:47 — "KindRefs = "refs"" + evidence: internal/session/records.go:56 — "func AppendRecord(a Append) error {" + - From a mapped finding, Testimony renders an issue draft: a title, reproduction steps from the event window, the participant's own words as evidence, and the suspected file. + evidence: internal/coderefs/render.go:37 — "func Render(dir string) (string, error) {" + evidence: internal/coderefs/render.go:96 — "b.WriteString("### Suspected files\n\n")" + evidence: docs/how-to/map-findings-to-code.md:1 — "# Map findings to code" + - Reading the application's repository only to verify that a returned path exists and a line is in range; nothing is written into the application's repository. + evidence: internal/coderefs/ingest.go:138 — "func newRepoRoot(repo string) (repoRoot, error) {" + evidence: internal/coderefs/ingest.go:264 — "func countLines(full string) (int, error) {" + evidence: internal/coderefs/coderefs.go:12 — "Its only reads of the repository are the existence and line-count checks at ingest, and it never writes into it." + - No confidence level on a reference: ingest cannot validate a model-asserted word, and the human decision is the only quality signal. + evidence: internal/coderefs/ingest.go:477 — "dec.DisallowUnknownFields()" + evidence: internal/coderefs/emit.go:107 — "Any field outside the six above — a confidence, a snippet, a note — is refused" + - Nothing is filed anywhere: no automatic filing of issues into a tracker without human review. + evidence: internal/coderefs/render.go:35 — "The result is a hand-off artefact for the operator to file where they choose." + evidence: go.mod:1 — "module github.com/REPPL/Testimony" + - Grepping, router-table resolution, or any other reasoning about the application's source stays out of the CLI; resolution is the host's job. + evidence: internal/coderefs/coderefs.go:10 — "Resolution is the host's job." + evidence: internal/coderefs/emit.go:84 — "a route is an entry in a router table you must read and interpret" +- diverged: + - "Testimony maps confirmed findings to the code that owns them" — delivered as a two-phase host-delegated exchange (emit a request, then ingest and validate an answer) rather than one step; the CLI maps nothing itself and the operator must run a model between the halves. + evidence: internal/coderefs/emit.go:35 — "func EmitRequest(dir, repo string, window float64) (string, error) {" + evidence: internal/coderefs/ingest.go:33 — "func Ingest(dir, repo string, r io.Reader) ([]Ref, error) {" + evidence: docs/reference/cli.md:297 — "## `testimony map`" + - "Testimony checks each answer against the repository, the file exists and the line is in range" — the line is optional, so a reference with no line is admitted on the existence check alone; the in-range half of the promise binds only when the host chose to offer a line. + evidence: internal/coderefs/ingest.go:548 — "if perr == nil && p.line != nil {" + evidence: internal/coderefs/coderefs.go:84 — "Line int `json:"line,omitempty"`" + evidence: internal/coderefs/ingest_test.go:69 — "func TestIngestLineIsOptional(t *testing.T) {" + - "reproduction steps from the event window" — the steps are derived by the CLI, not the model (a strengthening against invention), but they are mechanical and degrade to a placeholder line under the Steps heading when the window carries no interaction event at or before the finding's t. + evidence: internal/coderefs/render.go:186 — "func steps(window []timeline.Entry, f analyze.Finding) []string {" + evidence: internal/coderefs/render.go:203 — "(no interaction event precedes the finding in its event window)" + evidence: internal/coderefs/render_test.go:83 — "func TestRenderStepsEndAtTheFinding(t *testing.T) {" + - "a confirmed finding that carries a selector or route anchor" is enforced as a non-empty ui.selector OR ui.route on a non-mode-B finding — a proxy for the intent's "web session" assumption, not a capture-mode check; a mode-A finding with a route and no selector is equally eligible, so the data-testid premise the Mechanism rests on is never required by the boundary. + evidence: internal/coderefs/coderefs.go:290 — "return strings.TrimSpace(session.SafeText(f.UI.Selector)) != "" ||" + evidence: internal/coderefs/coderefs.go:309 — "eff[f.ID].Value == "confirmed" && f.Mode != "B" && hasAnchor(f)" +- missing: + - The Mechanism's stated falsifiers — "a high reject rate on references, or references that land on the test suite instead of the component" — are not instrumented: `test` is an equal member of the closed role set, nothing counts or flags it, and no surface reports an accept/reject rate; the signal exists only as lines a human would tally out of refs.jsonl by hand. + evidence: internal/coderefs/coderefs.go:110 — "roleSet = map[string]bool{"owner": true, "handler": true, "route": true, "test": true}" + evidence: internal/coderefs/emit.go:82 — "or `test` (a test that exercises it)" + evidence: internal/coderefs/coderefs.go:343 — "%d references: %d accepted, %d proposed, %d rejected" + +Scope-condition dispositions: +- cond-2609152218105354 — survived: A session with no confirmed anchored finding is refused loudly rather than mapped: the eligibility gate runs first on both write paths and the refusal names the tally, including how many confirmed findings carry an anchor; verified live at exit 1 on emit and on ingest with nothing written, for both a no-verdict session and a confirmed-but-anchorless one. + evidence: internal/coderefs/coderefs.go:373 — "var ErrNoMappableFindings = errors.New("no mappable finding")" + evidence: internal/coderefs/emit.go:56 — "if len(mappable) == 0 {" + evidence: internal/coderefs/ingest.go:47 — "if len(mappable) == 0 {" + evidence: internal/coderefs/coderefs.go:332 — "%d confirmed (%d with a selector or route)" +- cond-2609152218108484 — survived: The repository is passed by path, resolved once, and opened read-only for the existence and line-count checks alone; I ingested against a copy of the fixture repo with every file and directory made unwritable (chmod a-w) and the ingest succeeded, with a sha256 sweep of the tree byte-identical before and after. + evidence: internal/coderefs/ingest.go:138 — "func newRepoRoot(repo string) (repoRoot, error) {" + evidence: internal/coderefs/ingest.go:265 — "f, err := session.OpenFileNoFollowRead(full)" + evidence: internal/coderefs/render.go:33 — "Nothing is written into the application's repository, and the render does not open it" + evidence: internal/coderefs/ingest.go:225 — "fi, err := os.Lstat(full)" +- cond-2609152218105360 — narrowed: The assumption held everywhere it was exercised, but it was exercised only against a four-file synthetic fixture tree authored alongside the sample session to carry exactly the data-testid attributes that session names; the CLI neither requires nor inspects a data-testid, and nothing in the delivery bears on whether a selector survives a rebuild. + narrowing: Holds for the fixture tree under internal/coderefs/testdata/repo, whose data-testid attributes were written to match examples/sample-session; no real application was mapped, and eligibility accepts any non-empty selector or route, so a route-only or fragile-selector finding is equally mappable and the rebuild-survival claim is untested. + evidence: internal/coderefs/testdata/repo/src/settings/ProfileForm.tsx:46 — "< button data-testid="save-btn" onClick={() => saveProfile(name)}>" + evidence: internal/coderefs/coderefs.go:290 — "strings.TrimSpace(session.SafeText(f.UI.Selector)) != "" ||" + evidence: docs/how-to/map-findings-to-code.md:21 — "with stable `data-testid` attributes, because a test id is a literal string the" + evidence: .github/workflows/ci.yml:170 — "./testimony map -session examples/sample-session -repo internal/coderefs/testdata/repo" +- cond-2609152218106626 — survived: The mapping layer is stdlib-plus-internal only and the module still declares no dependency: EmitRequest returns the request as text for the operator's chosen host, Ingest reads the answer from a file or stdin, and the package imports nothing under net/ and never shells out, so no model is called and no key is held on this path. + evidence: go.mod:1 — "module github.com/REPPL/Testimony" + evidence: internal/coderefs/coderefs.go:6 — "The CLI never calls a model, holds no keys, and adds no network dependency" + evidence: internal/coderefs/emit.go:178 — "return b.String(), nil" + evidence: internal/cli/cli.go:733 — "refs, err := coderefs.Ingest(sess, *repo, in)" +- cond-2609152218107939 — narrowed: The render does hard-require timeline.jsonl — it refuses at exit 1 with a run-merge hint when the file is absent, which I reproduced — but the condition's consequent is weaker than it reads: the window always yields a slice, not necessarily one the steps can be built from, so a present timeline does not guarantee that a mapped finding's event window resolves into reproduction steps. + narrowing: Holds as "a timeline is required and its absence is refused"; it does not hold as "each mapped finding's event window resolves for the issue draft" — a window containing no interaction event at or before the finding's t renders the single placeholder line "(no interaction event precedes the finding in its event window)" under the Steps heading, which I reproduced on a session whose confirmed anchored finding sits at t=0.2. + evidence: internal/coderefs/render.go:50 — "entries, err := analyze.LoadTimeline(dir)" + evidence: internal/coderefs/render.go:91 — "steps(drafttests.Window(entries, f, DefaultWindow), f)" + evidence: internal/coderefs/render.go:203 — "(no interaction event precedes the finding in its event window)" + evidence: docs/reference/cli.md:318 — "Emit and render hint to run `merge` first when the timeline is missing" \ No newline at end of file diff --git a/.abcd/development/specs/open/spc-2609152218094570-codebase-mapping.md b/.abcd/development/specs/closed/spc-2609152218094570-codebase-mapping.md similarity index 100% rename from .abcd/development/specs/open/spc-2609152218094570-codebase-mapping.md rename to .abcd/development/specs/closed/spc-2609152218094570-codebase-mapping.md diff --git a/.abcd/work/CONTEXT.md b/.abcd/work/CONTEXT.md index 46b20d6..998f80d 100644 --- a/.abcd/work/CONTEXT.md +++ b/.abcd/work/CONTEXT.md @@ -18,12 +18,17 @@ request, then validate the answer into `findings.jsonl`) and `review` regression-test drafting layer, `draft-tests` (emit a drafting request carrying each confirmed finding and its event window, validate the answer into `tests.jsonl`, render the accepted drafts as a Markdown test plan) -with `review -kind tests` for the accept / edit / reject pass. The oracle is -host-delegated: the CLI never calls a model, holds no keys, and adds no -network dependency; every finding is born `unverified`, every drafted test is -born `proposed`, and each ingest is the sole validation boundary for its own -answer. Next is codebase mapping (itd-3), then Mode B / the -pattern library (itd-4). Command and file contracts in +with `review -kind tests` for the accept / edit / reject pass, and the +codebase-mapping layer, `map` (emit a mapping request carrying each +confirmed anchored finding and the application's repository path, validate +the answer's source references against that repository into `refs.jsonl`, +render an issue draft per mapped finding) with `review -kind refs` for the +accept / reject pass. The oracle is host-delegated: the CLI never calls a +model, holds no keys, and adds no network dependency; every finding is born +`unverified`, every drafted test and every code reference is born +`proposed`, and each ingest is the sole validation boundary for its own +answer. Next is terminal-session anchoring (itd-2609152113364815), then +Mode B / the pattern library (itd-4). Command and file contracts in [`../../docs/reference/cli.md`](../../docs/reference/cli.md) and [`../../docs/reference/session-directory.md`](../../docs/reference/session-directory.md). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1db5cd4..d6d1d3b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -160,6 +160,16 @@ jobs: # sample still has a confirmed finding to draft from. ./testimony draft-tests -session examples/sample-session > /tmp/tests-request.md grep -q "testimony-testdraft/v1" /tmp/tests-request.md + # The mapping layer over the same merged timeline: render proves the + # bundled refs.jsonl still renders an issue draft for F-001 with its + # accepted reference, and emit proves the sample still has a confirmed + # anchored finding to map and carries the repository path handed to it. + ./testimony map -render -session examples/sample-session | tee /tmp/issues.md + grep -q "## F-001" /tmp/issues.md + grep -q "ProfileForm.tsx:46\` (owner) — accepted" /tmp/issues.md + ./testimony map -session examples/sample-session -repo internal/coderefs/testdata/repo > /tmp/map-request.md + grep -q "testimony-coderefs/v1" /tmp/map-request.md + grep -q "internal/coderefs/testdata/repo" /tmp/map-request.md # The counts line above catches events going missing from the merge, # but not a windowing/attachment regression: report's header counts are # raw entry counts, computed before the join, so they stay "10 · 10" diff --git a/AGENTS.md b/AGENTS.md index 5c8e076..65fb461 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,14 +50,15 @@ Testimony captures usability evidence, on the record. ## Current state -v0.4.0. A Go CLI (`testimony`, standard library only) whose nine pipeline +v0.4.0. A Go CLI (`testimony`, standard library only) whose ten pipeline commands are all implemented and dispatched from `internal/cli` behind the `cmd/testimony` entry point: `record` and `demo` (capture), `transcribe` and `import` (hand-off of a recording the CLI never made — a voice recording, or an operator-recorded asciinema terminal session), -`merge`, `report`, the analysis layer `analyze` and `review`, and the +`merge`, `report`, the analysis layer `analyze` and `review`, the regression-test drafting layer `draft-tests` (with `review -kind tests` for its -human pass) — plus `version` and `help`. The model work is host-delegated — +human pass), and the codebase-mapping layer `map` (with `review -kind refs` +for its human pass) — plus `version` and `help`. The model work is host-delegated — the CLI never calls a model, holds no keys, and adds no network dependency. The user-facing documentation is [`docs/README.md`](docs/README.md); the exact command and file contracts are [`docs/reference/cli.md`](docs/reference/cli.md) and @@ -80,6 +81,8 @@ go test -run TestEventsNearWindow ./internal/timeline/ # a single test ./testimony merge -session examples/sample-session # pipeline smoke: ./testimony report -session examples/sample-session # writes timeline.jsonl + report.md ./testimony draft-tests -session examples/sample-session # emit the drafting request (after merge) +./testimony map -render -session examples/sample-session # issue drafts from the bundled refs.jsonl (after merge) +./testimony map -session examples/sample-session -repo internal/coderefs/testdata/repo # emit the mapping request (after merge) S=$(mktemp -d) && cp examples/sample-session/manifest.json "$S" \ && ./testimony import -session "$S" -cast internal/cast/testdata/v2.cast # terminal hand-off smoke sh -n install.sh && bash -n install.sh # installer syntax diff --git a/CHANGELOG.md b/CHANGELOG.md index 79e8737..8742f9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,36 @@ break an existing invocation is called out in the entry that records it. ### Added +- `testimony map` maps a **confirmed** finding to the code that owns its + on-screen anchor, and `testimony review -kind refs` records the human accept / + reject pass over each reference. The resolution stays host-delegated, exactly + as for `analyze` and `draft-tests`: `map -session DIR -repo DIR` emits one + self-contained mapping request — a versioned rubric, the repository path, and, + for each confirmed finding whose `ui` carries a selector or route, that + finding's record and its event window — and `map -session DIR -repo DIR + -ingest FILE` is the validation boundary, forcing every reference to + `status: proposed`, refusing any reference to a finding that is not currently + confirmed or carries no anchor, and checking every `path` against the + repository: repo-relative, no `.` or `..` segment, inside the repository even + through a symlinked directory, an existing regular file (a symlink or + directory is refused), with any `line` within the file's length. The CLI + opens the repository read-only for those checks and never writes into it; it + never greps or interprets the source, and a model-asserted `confidence` field + is refused as unknown, so the human decision is the only quality signal. An + answer is bounded to 1000 references before any is checked, and the recorded + path is the checked form. The emitted request is the one artefact that names + an absolute local path, the repository's, so `map` refuses an `-out` inside + the session directory. + References and decisions live in a new `refs.jsonl` session artefact, + append-only in both directions with no edit path: a wrong path is rejected + and a corrected one ingested. `map -session DIR -render` renders one Markdown + issue draft per mapped finding — a title and reproduction steps derived by + the CLI from the finding and its event window, the participant's quote, and + every reference with its current status — so a draft rendered before review + is visibly unreviewed. Nothing is filed anywhere. A session with no confirmed + anchored finding is staged loudly with the counts by status, including how + many confirmed findings carry an anchor, nothing written, exit 1. Terminal + findings carry no anchor and are the subject of their own intent. - `testimony analyze -ingest` records what answered the analysis request. `-backend local|cloud` and a free-text `-model NAME` write one provenance record — `{"kind":"provenance","rubric":…,"backend":…,"model":…,"at":…}` — as diff --git a/README.md b/README.md index 9719223..f5a924b 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,7 @@ Each session is one folder of small, inspectable files, created under `~/Testimo timeline.jsonl # merged, session-relative timeline findings.jsonl # analysis findings + verdicts tests.jsonl # regression-test drafts + decisions + refs.jsonl # code references + decisions report.md # human-readable aligned record ``` @@ -121,14 +122,19 @@ or whisper.cpp), `import` (an asciinema terminal recording joins the session's interaction stream on the shared clock), `merge`, `report`, the first-pass analysis layer — `analyze` (emit an analysis request, then validate the answer into findings, recording the backend and model you declare answered it) and -`review` (record human verdicts) — and the regression-test +`review` (record human verdicts) — the regression-test drafting layer, `draft-tests` (turn a confirmed finding into a proposed test case, then render the accepted ones as a Markdown test plan) with -`review -kind tests` for the accept / edit / reject pass. `record` captures the +`review -kind tests` for the accept / edit / reject pass, and the +codebase-mapping layer, `map` (hand a confirmed finding's selector or route to +your assistant with the application's repository, validate the source +references it returns, and render an issue draft per mapped finding) with +`review -kind refs` for the accept / reject pass. `record` captures the microphone by default; screen video is opt-in with `-video`. The model work is host-delegated — the CLI never calls a model, holds no keys, and adds no network dependency — every finding is *unverified* until you confirm or reject it, and -every drafted test is a *proposal* until you accept it. Run the analysis request +every drafted test and every code reference is a *proposal* until you accept +it. Run the analysis request against a model on your own machine and no session content leaves it at any step; `findings.jsonl` and `report.md` carry your declaration that it did ([analyse a session locally](docs/how-to/analyse-locally.md)). diff --git a/docs/README.md b/docs/README.md index dca435f..cf8117e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,6 @@ # Testimony documentation - **[Tutorials](tutorials/getting-started.md)** — learn by doing: capture, transcribe, and report on your first session in about five minutes. -- **[How-to guides](how-to/)** — recipes for specific tasks: [transcribe a recording](how-to/transcribe-a-recording.md), [record a terminal session](how-to/record-a-terminal-session.md), [instrument your own app](how-to/instrument-your-own-app.md), [analyse a session](how-to/analyse-a-session.md), [analyse a session locally](how-to/analyse-locally.md), [draft regression tests](how-to/draft-regression-tests.md). +- **[How-to guides](how-to/)** — recipes for specific tasks: [transcribe a recording](how-to/transcribe-a-recording.md), [record a terminal session](how-to/record-a-terminal-session.md), [instrument your own app](how-to/instrument-your-own-app.md), [analyse a session](how-to/analyse-a-session.md), [analyse a session locally](how-to/analyse-locally.md), [draft regression tests](how-to/draft-regression-tests.md), [map findings to code](how-to/map-findings-to-code.md). - **[Reference](reference/)** — exact descriptions of the [command line](reference/cli.md) and the [session directory](reference/session-directory.md). - **[Explanation](explanation/)** — background and reasoning: [how alignment works](explanation/how-alignment-works.md), [privacy](explanation/privacy.md). diff --git a/docs/how-to/map-findings-to-code.md b/docs/how-to/map-findings-to-code.md new file mode 100644 index 0000000..2e3fd26 --- /dev/null +++ b/docs/how-to/map-findings-to-code.md @@ -0,0 +1,163 @@ +# Map findings to code + +This guide covers the mapping layer: turning a **confirmed** finding into +proposed references to the source files that own its on-screen anchor, deciding +each reference by hand, and rendering an issue draft you can file wherever your +tracker lives. Testimony delegates the model work to an assistant of your +choice: `map` never calls a model, holds no keys, and adds no network +dependency. It emits a request, you run it against the application's +repository, and it validates the answer. + +Prerequisites: a session with at least one finding whose current verdict is +`confirmed` and whose `ui` carries a `selector` or a `route`, a merged +`timeline.jsonl`, and the application's repository checked out on this machine. +The step sits downstream of verification on purpose, so +[analyse the session](analyse-a-session.md) first and confirm what you believe. +A session with no such finding is refused, with the finding count by status, +rather than mapped. A terminal-session finding carries no selector or route, so +`map` has nothing to hand over for it. + +The anchors resolve best when the application marks its interactive elements +with stable `data-testid` attributes, because a test id is a literal string the +assistant can search the source for, where a route is a router-table entry it +has to read and interpret. [Instrument your own app](instrument-your-own-app.md) +covers the attribute. + +The flow is five steps: **emit** the mapping request, **run** it with your +assistant, **ingest** the answer, **review** the references, and **render** the +issue drafts. + +## 1. Emit the mapping request + +`testimony map` writes a single, self-contained prompt: a versioned rubric, the +session context, the path to the repository, and, for each confirmed finding +with an anchor, that finding's own record plus its event window from the +timeline. Send it to stdout to read it, or to a file to hand off: + +```sh +testimony map -session ~/Testimony/sessions/ -repo ~/code/your-app -out request.md +``` + +`-repo` names the repository root as the assistant will open it, so it is the +one place an absolute local path appears in the request. Keep the request out +of the session directory, which is what you hand to others: `map` refuses an +`-out` inside it, and nothing in the session directory changes. + +`-window` sets the event window's half-width in seconds, as it does for +`draft-tests`, and defaults to 10. + +## 2. Run it with your assistant of choice + +Give the request to an assistant that can read the repository: an agent host +with the checkout open, or a colleague. Ask it to follow the instructions and +return **only** the JSON answer. Save that answer to a file, for example +`refs.json`. + +The expected shape is a JSON object with a `refs` array (a bare array is also +accepted): + +```json +{"rubric":"testimony-coderefs/v1","refs":[ + {"id":"R-001","finding":"F-001","session":"sample-session", + "path":"src/settings/ProfileForm.tsx","line":46,"role":"owner", + "status":"proposed"}, + {"id":"R-002","finding":"F-001","session":"sample-session", + "path":"src/settings/saveProfile.ts","line":12,"role":"handler", + "status":"proposed"} +]} +``` + +One finding may yield several references: the component that renders the +element, the handler behind it, the router entry, a test. The `role` says which, +and `line` is optional. There is no confidence field, and an answer that adds +one is refused: the human decision in step 4 is the only quality signal. + +## 3. Ingest the answer + +Validate the answer against the reference schema and the repository, and write +`refs.jsonl`: + +```sh +testimony map -session ~/Testimony/sessions/ -repo ~/code/your-app -ingest refs.json +``` + +Ingest is the validation boundary, and it never trusts the model. It rejects, +with a precise message, any reference that names a finding which is not +currently `confirmed` or carries no anchor, claims a session other than this +one, uses a role outside the set, names a path that does not exist as a regular +file under the repository or escapes it, or gives a line past the file's end. +All errors are reported at once and nothing is written until the whole answer is +clean, so you can fix a batch in one pass. Every reference lands +`status: proposed`, whatever the answer claimed. + +The repository is opened read-only, to check that each path exists and each line +is in range, and nothing is written into it. The path check is a containment +check: a `..` segment, an absolute path, a symlink, or a path through a +symlinked directory that leaves the repository is refused. + +You can also pipe the answer straight in with `-ingest -`: + +```sh +your-assistant < request.md | testimony map -session ~/Testimony/sessions/ -repo ~/code/your-app -ingest - +``` + +Once a decision exists in `refs.jsonl`, ingest refuses to overwrite the file: +the human record is retained, so a re-map needs a fresh session directory or a +removed `refs.jsonl`. + +## 4. Review the references + +Each reference is a *proposal* until you judge it. `testimony review -kind refs` +walks the proposed references and records your decision: + +```sh +testimony review -session ~/Testimony/sessions/ -kind refs -repo ~/code/your-app +``` + +For each reference it shows its id, path, line and role, the finding it +resolves with that finding's quote, anchor and clock, and, because `-repo` is +given, the source lines around the referenced line, then prompts +`[a]ccept [r]eject [s]kip [q]uit`. Without `-repo` it shows the record alone. + +Your decision is *appended* to `refs.jsonl` with today's date. The reference +line is never overwritten, so the record of what the machine proposed and what +you decided both survive. There is no edit: a wrong path is rejected, and a +corrected one arrives through a fresh ingest. + +To record a single decision without the interactive walk (handy in scripts): + +```sh +testimony review -session ~/Testimony/sessions/ -kind refs -ref R-001 -decision accepted +testimony review -session ~/Testimony/sessions/ -kind refs -ref R-002 -decision rejected +``` + +Interactive review needs stdin to be a character device (an interactive terminal +is one); when it is a pipe or a redirected regular file (as in CI) it prints a +notice and exits without blocking. A later decision overrides an earlier one, and +both are kept. + +## 5. Render the issue drafts + +Render one issue draft per mapped finding: + +```sh +testimony map -session ~/Testimony/sessions/ -render -out issues.md +``` + +Each draft carries a title derived from the finding, its severity, anchor and +clock, the participant's own words, the reproduction steps derived from the +event window, and the suspected files: every reference for the finding, with +its role and its current status. A draft rendered before review therefore says +`proposed` beside each file, so a reader can see which paths a person has +vouched for. The title and the steps are derived by the CLI from the records, +not by the model, so the render needs no second round-trip and cannot invent a +step; the steps read mechanically for the same reason. + +Nothing is filed anywhere: Testimony never opens an issue and never writes into +the application's repository, so the output defaults to stdout and `-out` puts it +wherever you assemble your tickets. The **record** stays with the session, in +`refs.jsonl`, linked to its finding. + +For the exact field rules, see the +[session directory reference](../reference/session-directory.md#refsjsonl); for +every flag, the [command-line reference](../reference/cli.md#testimony-map). diff --git a/docs/reference/cli.md b/docs/reference/cli.md index abb1478..93b0f67 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -16,7 +16,7 @@ Running `testimony` with no command, or with an unknown command, prints the usag ## Session directory inference -The seven pipeline commands — `transcribe`, `import`, `merge`, `report`, `analyze`, `draft-tests`, and `review` — take their session directory from `-session DIR`. When `-session` is omitted and the current directory itself holds a Testimony session `manifest.json`, that directory is the session: the command operates on it exactly as `-session .` does, and prints one line to stderr naming what it inferred (`merge: using session . (inferred from the current directory)`) before it starts work, so the implicit choice is visible in the output of the run. The line goes to stderr, never stdout, so `analyze`'s emitted request stays a clean pipe. An explicit `-session` always wins, is used verbatim, and prints no such line — the current directory is not consulted at all. +The eight pipeline commands — `transcribe`, `import`, `merge`, `report`, `analyze`, `draft-tests`, `map`, and `review` — take their session directory from `-session DIR`. When `-session` is omitted and the current directory itself holds a Testimony session `manifest.json`, that directory is the session: the command operates on it exactly as `-session .` does, and prints one line to stderr naming what it inferred (`merge: using session . (inferred from the current directory)`) before it starts work, so the implicit choice is visible in the output of the run. The line goes to stderr, never stdout, so `analyze`'s emitted request stays a clean pipe. An explicit `-session` always wins, is used verbatim, and prints no such line — the current directory is not consulted at all. The marker is a session manifest, not merely the file name. `manifest.json` is one of the most common file names in software, so the file must be a regular file (a directory or a symlink at that name is not a marker) and, when it parses, must carry the `session` field every `testimony` session has (see [`manifest.json`](session-directory.md#manifestjson)). A `manifest.json` that belongs to something else leaves the command refusing rather than writing into a directory that is not a session: @@ -294,28 +294,75 @@ testimony: no accepted test drafts to render (3 drafts: 0 accepted, 0 edited, 2 The first applies to emit and to ingest, and on ingest it fires before a byte of the answer is read: with no eligible finding, every draft in the answer would fail the same rule. The second keeps `-out FILE` from truncating an existing test plan into an empty document. +## `testimony map` + +The codebase-mapping layer. Like `analyze` and `draft-tests`, `map` never calls a model, holds no keys, and adds no network dependency: it *emits* a self-contained mapping request that any assistant (or a human) resolves against the application's repository, then *ingests* and validates the JSON answer into `refs.jsonl`. A third mode *renders* an issue draft per mapped finding as Markdown. The step sits downstream of verification, so only a finding a person already confirmed, and only one that carries a selector or route anchor, can be mapped. Resolution is the host's job: the CLI hands over the anchor and the repository path, never greps or reads a router table, and opens the repository only to verify at ingest that a returned path exists and a line is in range. It never writes into the repository. + +``` +testimony map [-session DIR] -repo DIR [-window 10] [-out FILE] # emit the request +testimony map [-session DIR] -repo DIR -ingest FILE # validate the answer → refs.jsonl +testimony map [-session DIR] -render [-out FILE] # render an issue draft per mapped finding +``` + +| Flag | Default | Meaning | +|---|---|---| +| `-session` | *(inferred)* | session directory; when omitted, the current directory if it holds a Testimony session `manifest.json` (see [session directory inference](#session-directory-inference)) | +| `-repo` | *(required for emit and ingest)* | the application's repository root, an existing directory; read only | +| `-window` | `10` | emit mode: the event-window half-width in seconds around each finding's cited evidence | +| `-out` | *(stdout)* | emit or render mode: write the document to `FILE` instead of stdout | +| `-ingest` | *(off)* | ingest mode: validate the answer JSON at `FILE` (or `-` for stdin) into `refs.jsonl` | +| `-render` | *(off)* | render mode: write an issue draft per mapped finding as Markdown | + +`map` runs in exactly one mode: emit (neither `-ingest` nor `-render`), ingest (`-ingest`), or render (`-render`). `-ingest` combines with neither `-out` nor `-render`; `-out` pairs with emit or render. `-window` belongs to emit alone, so passing it with `-ingest` or `-render` is a usage error, and a non-finite `-window` is a usage error too; a negative one is legitimate and narrows the window. `-repo` is required for emit and ingest and refused with `-render`, which reads only what is on disk in the session. A `-repo` that is not an existing directory, or an explicitly empty value, is refused at exit 2. In emit mode an `-out` that resolves inside the session directory is refused at exit 2 as well: the request names the repository's absolute path, and a session directory is an exchange unit, so the request must live outside it. + +Emit reads `manifest.json`, `findings.jsonl`, and `timeline.jsonl`; ingest reads `manifest.json`, `findings.jsonl`, and the repository (existence and line counts only); render reads `manifest.json`, `timeline.jsonl`, `findings.jsonl`, and `refs.jsonl`. Emit and render hint to run `merge` first when the timeline is missing; every mode hints to run `analyze -ingest` first when there is no `findings.jsonl`; render and `review -kind refs` hint to run `map -ingest` first when there is no `refs.jsonl`. + +**Eligibility.** A finding may be mapped when its effective status is `confirmed`, its `mode` is not `B`, and its `ui` carries a non-empty `selector` or `route`. Effective status is the same computation `review`, `report`, and `draft-tests` use, so a later verdict overriding an earlier one is honoured. `unverified`, `rejected`, and `duplicate` findings are never eligible, and neither is a confirmed finding with no anchor: a terminal-session finding carries no `ui`, so a session of terminal findings has nothing `map` can hand over. + +Emit behaviour: writes a single self-contained prompt: the rubric version header (`testimony-coderefs/v1`), the proposal stance, the per-field instructions, the rubric body (field definitions, how to read each finding record, and the hard constraints ingest enforces), the repository path as the host will open it, the session context (session, app, participant, tasks), then, per eligible finding in id order, a prose header naming its id, type, severity, clock, selector and route, and the date of the verdict that confirmed it, its own record verbatim in a ```jsonl fence, and its event window in a second fence, followed by the required output shape with a worked example. The repository path is the one place an absolute local path appears in an emitted request; it is written to stdout or to an `-out` outside the session directory, and the command refuses an `-out` inside it. Nothing in the session directory is mutated. With `-out FILE` the prompt goes to a file and the command prints `wrote `; otherwise it prints to stdout. + +The **event window** is the same one `draft-tests` emits, computed by the same helper: every timeline entry between the earliest cited evidence entry's start minus `-window` and the latest cited entry's end plus `-window`, in time order. + +Ingest behaviour: reads the answer from `FILE` (or stdin when `-`), accepting a top-level object with a `refs` array (optionally a `rubric`, which must be a known version) or a bare array. Ingest is the sole validation boundary and never trusts the model. Each reference is decoded with unknown fields disallowed (a `confidence` field is refused, because ingest cannot validate a model-asserted word), then checked against every schema rule (see [session directory reference](session-directory.md#refsjsonl)): id format and uniqueness, a `finding` that is currently confirmed and carries an anchor, a `session` equal to the manifest's, a `role` in the closed set, a `path` that is repo-relative and names an existing regular file under `-repo`, and, when a `line` is given, a line within that file's length. Validation is transactional: all errors are reported at once and nothing is written on any failure. On success every reference is forced to `status: proposed`, `refs.jsonl` is written, and the command prints `validated N references → (all proposed)`. An answer with no references (a bare `[]`, `{"refs":[]}`, or a truncated file) is refused rather than written, so it cannot erase a prior `refs.jsonl`; so is an answer carrying more than 1000 references, refused before any is checked because each check reads the repository, and an answer whose references would together push `refs.jsonl` past the session's 16 MiB total-size limit. The `path` written to `refs.jsonl` is the checked form: control and invisible characters stripped and surrounding whitespace trimmed, so the record names the file the boundary opened. Ingest refuses to overwrite a `refs.jsonl` that already holds decision records, counting any `kind:"decision"` line, even one whose value is outside the closed enum. + +**The path rule.** A model answer is untrusted input naming a filesystem path the CLI will open, so the check is a containment check rather than a string check. The path must be non-empty, at most 512 bytes, use forward slashes, carry no leading `/` or drive letter, and contain no empty, `.`, or `..` segment. It is then joined to `-repo` and must still lie inside it; the parent directory is resolved through any symlinks and must still lie inside the resolved repository, so a path through a symlinked directory pointing outside is refused; and the final component is inspected without following symlinks, so a symlink there is refused as not a regular file, as is a directory or a missing file. When `line` is given the file is read, up to 16 MiB, and its lines counted (`\n`-terminated lines plus an unterminated last line); a file over that bound is refused for a line check and can be referenced without a line. + +Render behaviour: writes one Markdown block per mapped finding (a finding that at least one reference names), in finding-id order: a heading derived from the finding's id, type, and the first clause of its quote; its severity, anchor, and clock; the participant's quote; the reproduction steps derived from the event window (one orientation line from the first event's route, then one imperative line per interaction event at or before the finding's `t`, naming the selector where the event carries one); and every reference for the finding with its role and current status, so a draft rendered before review is visibly unreviewed. Review does not gate the render. The title and steps are derived by the CLI, not the model, so the render works from the records alone and cannot invent a step. With `-out FILE` the drafts go to a file and the command prints `wrote `; otherwise they print to stdout, which is the default because Testimony never files an issue and never writes into the application's repository. + +**Loud staging.** Two states are refused at exit 1, a well-formed invocation whose work cannot be done, with the counts by status and nothing written: + +``` +testimony: no mappable finding in 5 findings: 1 confirmed (0 with a selector or route), 3 unverified, 0 duplicate, 1 rejected; a finding is mappable when its verdict is confirmed and it carries a selector or route (confirm one with `testimony review -session ~/Testimony/sessions/x` first) +testimony: no mapped finding to render (1 references: 0 accepted, 1 proposed, 0 rejected, none naming a finding in findings.jsonl); ingest a mapping answer with `testimony map -session ~/Testimony/sessions/x -repo DIR -ingest FILE` first +``` + +The first applies to emit and to ingest, and on ingest it fires before a byte of the answer is read. The anchored count beside the confirmed count tells a session with no confirmed finding from one whose confirmed findings carry no selector or route. The second keeps `-out FILE` from truncating an existing file into an empty document. + ## `testimony review` -The one human-decision verb, across both record families. With `-kind findings` (the default) it records a verdict on each candidate finding; with `-kind tests` it records an accept / edit / reject decision on each drafted regression test. Either way the decision is *appended* — to `findings.jsonl` or to `tests.jsonl` — without ever rewriting the machine record in place, so the record's birth state and the full decision history are retained as the precision measure. +The one human-decision verb, across all three record families. With `-kind findings` (the default) it records a verdict on each candidate finding; with `-kind tests` it records an accept / edit / reject decision on each drafted regression test; with `-kind refs` it records an accept / reject decision on each proposed code reference. Either way the decision is *appended*, to `findings.jsonl`, `tests.jsonl`, or `refs.jsonl`, without ever rewriting the machine record in place, so the record's birth state and the full decision history are retained as the precision measure. ``` -testimony review [-session DIR] [-kind findings|tests] +testimony review [-session DIR] [-kind findings|tests|refs] testimony review [-session DIR] -finding F-NNN -verdict confirmed|rejected|duplicate-of-F-NNN testimony review [-session DIR] -kind tests -test T-NNN -decision accepted|rejected testimony review [-session DIR] -kind tests -test T-NNN -decision edited -edit FILE +testimony review [-session DIR] -kind refs [-repo DIR] -ref R-NNN -decision accepted|rejected ``` | Flag | Default | Meaning | |---|---|---| | `-session` | *(inferred)* | session directory; when omitted, the current directory if it holds a Testimony session `manifest.json` (see [session directory inference](#session-directory-inference)) | -| `-kind` | `findings` | which record family to review: `findings` or `tests` | +| `-kind` | `findings` | which record family to review: `findings`, `tests`, or `refs` | | `-finding` | *(interactive)* | non-interactive: the finding to judge (`F-NNN`), `-kind findings` only | | `-verdict` | *(interactive)* | non-interactive: `confirmed`, `rejected`, or `duplicate-of-F-NNN`, `-kind findings` only | | `-test` | *(interactive)* | non-interactive: the test draft to decide (`T-NNN`), `-kind tests` only | -| `-decision` | *(interactive)* | non-interactive: `accepted`, `edited`, or `rejected`, `-kind tests` only | -| `-edit` | *(off)* | with `-decision edited`: the replacement fields as a JSON object at `FILE` (or `-` for stdin) | +| `-decision` | *(interactive)* | non-interactive: `accepted`, `edited`, or `rejected` with `-kind tests`; `accepted` or `rejected` with `-kind refs` | +| `-edit` | *(off)* | with `-decision edited`: the replacement fields as a JSON object at `FILE` (or `-` for stdin), `-kind tests` only | +| `-ref` | *(interactive)* | non-interactive: the reference to decide (`R-NNN`), `-kind refs` only | +| `-repo` | *(off)* | `-kind refs`, interactive walk only: the application's repository root, read only, so the walk can show the source around each reference's line; refused alongside `-ref`, which shows no source | -A flag belonging to the other record family is a usage error, not a silently ignored value: `-finding` or `-verdict` with `-kind tests`, and `-test`, `-decision` or `-edit` with `-kind findings`, each exit 2. So do an unknown `-kind`, a `-test` that is not `T-NNN`, a `-decision` outside the enum, `-decision edited` without `-edit`, and `-edit` alongside any other decision. +A flag belonging to another record family is a usage error, not a silently ignored value: `-finding` or `-verdict` with `-kind tests` or `-kind refs`; `-test`, `-decision` or `-edit` with `-kind findings`; `-test` or `-edit` with `-kind refs`; and `-ref` or `-repo` with `-kind findings` or `-kind tests`, each exit 2. So do an unknown `-kind`, a `-test` that is not `T-NNN`, a `-ref` that is not `R-NNN`, a `-decision` outside the kind's enum (`edited` is refused with `-kind refs`), `-decision edited` without `-edit`, `-edit` alongside any other decision, a `-repo` that is not an existing directory, and `-repo` alongside `-ref`. ### `-kind findings` (the default) @@ -339,6 +386,14 @@ Non-interactive (`-kind tests -test T-001 -decision accepted`): validates that t The `edit` object is closed: one naming `id`, `finding`, `session`, `severity`, or `rationale_quote` is an error rather than a silently dropped key, so no edit can re-point a draft at different evidence. The only way to change the link is to reject the draft and ingest a new one. A decision may be appended even when one already exists (append-only correction; the latest wins), and the accepted and edited drafts are what [`draft-tests -render`](#testimony-draft-tests) puts in the test plan. +### `-kind refs` + +Behaviour: loads the references and existing decisions (hinting to run `map -ingest` first when there is no `refs.jsonl`) and computes each reference's effective status: every reference starts `proposed`, decision records apply in file order, and the last one for a reference wins. A decision naming an unknown reference, or carrying a value outside the closed enum, is ignored rather than applied. + +Interactive (`review -session DIR -kind refs [-repo DIR]`): walks the `proposed` references in id order, printing each reference's id, path, line and role, the finding it resolves, that finding's quote, anchor and clock, and, when `-repo` is given, the numbered source lines around the reference's line (two either side, the referenced line marked `>`), then prompting `[a]ccept [r]eject [s]kip [q]uit`. The source is read through the same containment rule ingest applies and never written; a path that fails it, or a file too large to show, prints a one-line note in place of the snippet and the walk continues. The character-device gate is the same as the findings walk's. + +Non-interactive (`-kind refs -ref R-001 -decision accepted`): validates that the reference exists and the decision parses, appends one decision record, and prints `recorded: R-001 accepted ()`. There is no `edited`: a wrong path is rejected and a corrected one is ingested, never patched, so a reference's `finding`, `session`, and `path` are unreachable by any later write. A decision may be appended even when one already exists (append-only correction; the latest wins), and every reference, whatever its status, is what [`map -render`](#testimony-map) lists under the finding's suspected files. + ## `testimony version` Prints `testimony ` — the version stamped at release, or `dev`. diff --git a/docs/reference/session-directory.md b/docs/reference/session-directory.md index 79f9c41..a6bc89f 100644 --- a/docs/reference/session-directory.md +++ b/docs/reference/session-directory.md @@ -15,10 +15,11 @@ Every capture session lives in one directory. `record` and `demo` create it unde timeline.jsonl # merged, session-relative timeline (written by merge) findings.jsonl # analysis findings + appended verdicts (written by analyze/review) tests.jsonl # regression-test drafts + appended decisions (written by draft-tests/review) + refs.jsonl # code references + appended decisions (written by map/review) report.md # human-readable aligned record (written by report) ``` -All `.jsonl` files are JSON Lines: one JSON value per line, blank lines ignored. `timeline.jsonl`, `transcript.jsonl`, `interactions.jsonl`, `findings.jsonl`, and `tests.jsonl` each carry a 16 MiB total-size limit: a write that would push one over the cap is refused, and a load of one already over it is refused, so a session that reaches it needs a fresh session directory to continue in. `events.rrweb.jsonl` is archival and carries no such limit, and `terminal.cast` is not a JSON Lines file at all — it carries its own read bound, described below. +All `.jsonl` files are JSON Lines: one JSON value per line, blank lines ignored. `timeline.jsonl`, `transcript.jsonl`, `interactions.jsonl`, `findings.jsonl`, `tests.jsonl`, and `refs.jsonl` each carry a 16 MiB total-size limit: a write that would push one over the cap is refused, and a load of one already over it is refused, so a session that reaches it needs a fresh session directory to continue in. `events.rrweb.jsonl` is archival and carries no such limit, and `terminal.cast` is not a JSON Lines file at all — it carries its own read bound, described below. ## `manifest.json` @@ -235,6 +236,43 @@ The `edit` object is a **closed** four-field subset: an `edit` naming `id`, `fin A draft's effective status starts `proposed`; decision records apply in file order and the last one for that draft wins. An `edited` draft's rendered fields are the last `edited` decision's `edit` applied over the draft, computed when the plan is rendered — the draft line itself is never rewritten. `testimony draft-tests -render` renders the drafts whose effective status is `accepted` or `edited`. +## `refs.jsonl` + +The codebase-mapping layer's output, written by `testimony map -ingest` and appended to by `testimony review -kind refs`. Two record kinds share the file, one per line: a **reference** line (no `kind` field) and a **decision** line (`kind: "decision"`). Decisions are appended, never written in place, so a reference's original state and the full decision history are retained. Blank lines are ignored. + +Ingest validates every reference against `findings.jsonl` and the application's repository, and is the sole validation boundary. Each reference object is closed: an unknown field is rejected rather than dropped, which is how a model-asserted `confidence` is kept out of the record. The top-level answer container is not closed (a key beside `rubric` and `refs` is tolerated, mirroring `analyze`), so strictness lands on the records themselves. `status` is forced to `"proposed"` on ingest regardless of the answer JSON. A reference may only ever name a finding whose effective status is `confirmed` and whose `ui` carries a selector or route. The repository path the references were checked against is not recorded: a session directory is an exchange unit, and the reference is repo-relative so it survives the repository moving. + +**Reference record** + +| Field | Type | Required | Meaning | +|---|---|---|---| +| `id` | string | yes | `R-NNN`, zero-padded (`^R-\d{3}$`); unique within the file | +| `finding` | string | yes | an existing finding id in `findings.jsonl` whose effective status is `confirmed`, whose `mode` is not `B`, and whose `ui` carries a non-empty `selector` or `route` | +| `session` | string | yes | equal to the manifest's `session`, so the link survives the line being copied out of the session directory | +| `path` | string | yes | repo-relative: forward slashes, no leading `/` or drive letter, no empty, `.`, or `..` segment, at most 512 bytes; names an existing regular file under the repository given at ingest (a directory, a symlink, or a path through a symlinked directory out of the repository is refused) | +| `line` | integer | no | 1-based; when present, at most the file's line count (`\n`-terminated lines plus an unterminated last line, counted up to 16 MiB); absent when the reference names the file alone | +| `role` | string | yes | why the path is relevant, one of `owner` (the file that renders the element), `handler` (the code that handles its interaction), `route` (the router entry for the route), `test` (a test that exercises it); validated as a member of the set, never for truth | +| `status` | string | yes | always `"proposed"` on ingest, whatever the answer claims (the answer may omit it; the written record always carries it) | + +```json +{"id":"R-001","finding":"F-001","session":"sample-session","path":"src/settings/ProfileForm.tsx","line":46,"role":"owner","status":"proposed"} +``` + +**Decision record** + +| Field | Type | Required | Meaning | +|---|---|---|---| +| `kind` | string | yes | literal `"decision"` (the discriminator) | +| `ref` | string | yes | an existing reference id in the file | +| `decision` | string | yes | one of `accepted`, `rejected` | +| `at` | string | yes | decision date, ISO `YYYY-MM-DD` | + +```json +{"kind":"decision","ref":"R-001","decision":"accepted","at":"2026-09-16"} +``` + +There is no `edited` decision and no edit payload: a wrong path is rejected and a corrected one is ingested, never patched, so a reference's `finding`, `session`, and `path` are unreachable by any later write. A reference's effective status starts `proposed`; decision records apply in file order and the last one for that reference wins. `testimony map -render` lists every reference for a finding with its current status; review does not gate the render. + ## `report.md` Human-readable Markdown rendered from the timeline and findings: diff --git a/examples/sample-session/refs.jsonl b/examples/sample-session/refs.jsonl new file mode 100644 index 0000000..f60a8b0 --- /dev/null +++ b/examples/sample-session/refs.jsonl @@ -0,0 +1,3 @@ +{"id":"R-001","finding":"F-001","session":"sample-session","path":"src/settings/ProfileForm.tsx","line":46,"role":"owner","status":"proposed"} +{"id":"R-002","finding":"F-001","session":"sample-session","path":"src/settings/saveProfile.ts","line":12,"role":"handler","status":"proposed"} +{"kind":"decision","ref":"R-001","decision":"accepted","at":"2026-09-16"} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index bbf3ba7..8d9c3bc 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -13,6 +13,7 @@ import ( "github.com/REPPL/Testimony/internal/analyze" "github.com/REPPL/Testimony/internal/cast" + "github.com/REPPL/Testimony/internal/coderefs" "github.com/REPPL/Testimony/internal/demo" "github.com/REPPL/Testimony/internal/drafttests" "github.com/REPPL/Testimony/internal/record" @@ -45,16 +46,21 @@ Usage: testimony draft-tests [-session DIR] [-window 10] [-out FILE] emit the regression-test drafting request (rubric + confirmed findings + event windows) testimony draft-tests [-session DIR] -ingest FILE validate answer JSON (FILE or "-") → tests.jsonl (all drafts proposed) testimony draft-tests [-session DIR] -render [-out FILE] render the accepted drafts as Markdown test cases - testimony review [-session DIR] [-kind findings|tests] interactively record verdicts on unverified findings, or decisions on proposed test drafts (stdin must be a character device) + testimony map [-session DIR] -repo DIR [-window 10] [-out FILE] emit the code-mapping request (rubric + confirmed anchored findings + event windows + repository path) + testimony map [-session DIR] -repo DIR -ingest FILE validate answer JSON (FILE or "-") → refs.jsonl (all references proposed; paths and lines checked against -repo) + testimony map [-session DIR] -render [-out FILE] render an issue draft per mapped finding as Markdown + testimony review [-session DIR] [-kind findings|tests|refs] interactively record verdicts on unverified findings, decisions on proposed test drafts, or decisions on proposed code references (stdin must be a character device) testimony review [-session DIR] -finding F-NNN -verdict confirmed|rejected|duplicate-of-F-NNN testimony review [-session DIR] -kind tests -test T-NNN -decision accepted|rejected testimony review [-session DIR] -kind tests -test T-NNN -decision edited -edit FILE + testimony review [-session DIR] -kind refs [-repo DIR] interactively decide proposed code references, showing the source around each when -repo is given + testimony review [-session DIR] -kind refs -ref R-NNN -decision accepted|rejected testimony version testimony help A session directory is described in docs/reference/session-directory.md. -Omitting -session on transcribe, import, merge, report, analyze, draft-tests, or -review uses the current directory when it holds a Testimony session +Omitting -session on transcribe, import, merge, report, analyze, draft-tests, +map, or review uses the current directory when it holds a Testimony session manifest.json (one with a session field), and names the inferred session on stderr. record and demo create a new session under ~/Testimony/sessions unless -out @@ -666,23 +672,157 @@ func Run(args []string) int { fmt.Print(doc) return 0 + case "map": + fs := flag.NewFlagSet("map", flag.ExitOnError) + dir := fs.String("session", "", "session directory") + repo := fs.String("repo", "", "emit/ingest mode: the application's repository root (read only)") + window := fs.Float64("window", coderefs.DefaultWindow, "emit mode: event-window half-width around a finding's evidence, seconds") + out := fs.String("out", "", "emit/render mode: write to FILE instead of stdout") + ingest := fs.String("ingest", "", "validate answer JSON at FILE (or \"-\" for stdin) into refs.jsonl") + render := fs.Bool("render", false, "render an issue draft per mapped finding as Markdown") + fs.Parse(rest) + if err := rejectArgs(fs); err != nil { + return usageErr(err) + } + outSet, ingestSet, windowSet, repoSet := false, false, false, false + fs.Visit(func(f *flag.Flag) { + switch f.Name { + case "out": + outSet = true + case "ingest": + ingestSet = true + case "window": + windowSet = true + case "repo": + repoSet = true + } + }) + // An explicitly-empty path flag is a wrong invocation (an unset shell + // variable spliced into the flag, say), not a valid path — the draft-tests + // guard, applied to -repo as well. + if ingestSet && *ingest == "" { + return usageErr(fmt.Errorf("map: -ingest must not be empty")) + } + if outSet && *out == "" { + return usageErr(fmt.Errorf("map: -out must not be empty")) + } + if repoSet && *repo == "" { + return usageErr(fmt.Errorf("map: -repo must not be empty")) + } + // map runs in exactly one mode, following draft-tests' rule: emit (neither + // -ingest nor -render), ingest (-ingest), or render (-render). + if *ingest != "" { + if *out != "" { + return usageErr(fmt.Errorf("map: -out and -ingest cannot be combined")) + } + if *render { + return usageErr(fmt.Errorf("map: -render and -ingest cannot be combined")) + } + } + if windowSet && (*ingest != "" || *render) { + return usageErr(fmt.Errorf("map: -window applies to the emit mode only")) + } + if math.IsNaN(*window) || math.IsInf(*window, 0) { + return usageErr(fmt.Errorf("map: -window must be a finite number of seconds, got %v", *window)) + } + // -repo is what emit hands the host and what ingest checks paths against; + // render reads only what is on disk in the session and never opens the + // repository, so a -repo alongside it is a flag from another mode, refused + // rather than silently ignored. Required in the other two modes: there is + // no default repository, and a request without one cannot be answered. + if *render { + if repoSet { + return usageErr(fmt.Errorf("map: -repo applies to the emit and ingest modes only")) + } + } else { + if *repo == "" { + return usageErr(fmt.Errorf("map: -repo is required (the application's repository root)")) + } + // Refused from the flags alone, at the usage status: a -repo that is not + // a directory is a wrong invocation, not a session that cannot be read. + if fi, err := os.Stat(*repo); err != nil || !fi.IsDir() { + if err == nil { + err = fmt.Errorf("%s is not a directory", *repo) + } + return usageErr(fmt.Errorf("map: -repo: %v", err)) + } + } + sess, err := resolveSession(fs, *dir) + if err != nil { + return usageErr(err) + } + // The emitted request carries the repository's absolute path, the one + // place an absolute local path appears in any artefact this tool writes, + // and a session directory is an exchange unit. So in emit mode -out may + // not land inside the session: an operator who inferred the session from + // the current directory and wrote `-out request.md` would otherwise ship + // their machine's layout with the session. Refused from the paths alone, + // before any file is read. + if *ingest == "" && !*render && *out != "" { + if inside, err := insideDir(sess, *out); err != nil { + return usageErr(fmt.Errorf("map: -out: %v", err)) + } else if inside { + return usageErr(fmt.Errorf("map: -out must not be inside the session directory (the request names the repository's absolute path, and a session directory is an exchange unit)")) + } + } + if *ingest != "" { + in := os.Stdin + if *ingest != "-" { + f, err := session.OpenFileNoFollowRead(*ingest) + if err != nil { + return fail(err) + } + defer f.Close() + in = f + } + refs, err := coderefs.Ingest(sess, *repo, in) + if err != nil { + return fail(err) + } + fmt.Printf("validated %d references → %s (all proposed)\n", + len(refs), filepath.Join(sess, session.RefsFile)) + return 0 + } + var doc string + if *render { + doc, err = coderefs.Render(sess) + } else { + doc, err = coderefs.EmitRequest(sess, *repo, *window) + } + if err != nil { + return fail(err) + } + if *out != "" { + if err := session.WriteFileNoFollow(*out, []byte(doc), 0o644); err != nil { + return fail(err) + } + fmt.Printf("wrote %s\n", *out) + return 0 + } + fmt.Print(doc) + return 0 + case "review": fs := flag.NewFlagSet("review", flag.ExitOnError) dir := fs.String("session", "", "session directory") - kind := fs.String("kind", review.KindFindings, "which record family to review: findings | tests") + kind := fs.String("kind", review.KindFindings, "which record family to review: findings | tests | refs") finding := fs.String("finding", "", "non-interactive: the finding to judge (F-NNN)") verdict := fs.String("verdict", "", "non-interactive: confirmed | rejected | duplicate-of-F-NNN") test := fs.String("test", "", "non-interactive (-kind tests): the test draft to decide (T-NNN)") - decision := fs.String("decision", "", "non-interactive (-kind tests): accepted | edited | rejected") + decision := fs.String("decision", "", "non-interactive (-kind tests or refs): accepted | edited | rejected (edited is -kind tests only)") edit := fs.String("edit", "", "with -decision edited: the replacement fields as a JSON object at FILE (or \"-\" for stdin)") + ref := fs.String("ref", "", "non-interactive (-kind refs): the reference to decide (R-NNN)") + repo := fs.String("repo", "", "-kind refs: the application's repository root, read only, to show the source around each reference") fs.Parse(rest) if err := rejectArgs(fs); err != nil { return usageErr(err) } f, v := strings.TrimSpace(*finding), strings.TrimSpace(*verdict) tst, dec := strings.TrimSpace(*test), strings.TrimSpace(*decision) + rf := strings.TrimSpace(*ref) findingSet, verdictSet := false, false kindSet, testSet, decisionSet, editSet := false, false, false, false + refSet, repoSet := false, false fs.Visit(func(fl *flag.Flag) { switch fl.Name { case "finding": @@ -697,6 +837,10 @@ func Run(args []string) int { decisionSet = true case "edit": editSet = true + case "ref": + refSet = true + case "repo": + repoSet = true } }) // An explicitly-empty -finding or -verdict is a wrong invocation (an @@ -728,6 +872,12 @@ func Run(args []string) int { if editSet && *edit == "" { return usageErr(fmt.Errorf("review: -edit must not be empty")) } + if refSet && rf == "" { + return usageErr(fmt.Errorf("review: -ref must not be empty")) + } + if repoSet && *repo == "" { + return usageErr(fmt.Errorf("review: -repo must not be empty")) + } // The record family is a closed set, so an unknown one is a wrong invocation // rather than a silently-ignored value that would run the findings walk under // a name the caller did not mean. @@ -741,28 +891,84 @@ func Run(args []string) int { // would let a script believe it landed. review.Run refuses the same pairings, // so the rule holds for any caller; refusing here gives it the usage status // and does so before the session is resolved or any file is read. - if recordKind == review.KindTests { + switch recordKind { + case review.KindTests: if f != "" || v != "" { return usageErr(fmt.Errorf("review: -finding and -verdict apply to -kind findings, not -kind tests")) } - } else if tst != "" || dec != "" || *edit != "" { - return usageErr(fmt.Errorf("review: -test, -decision and -edit apply to -kind tests, not -kind findings")) + if rf != "" || *repo != "" { + return usageErr(fmt.Errorf("review: -ref and -repo apply to -kind refs, not -kind tests")) + } + case review.KindRefs: + if f != "" || v != "" { + return usageErr(fmt.Errorf("review: -finding and -verdict apply to -kind findings, not -kind refs")) + } + if tst != "" || *edit != "" { + return usageErr(fmt.Errorf("review: -test and -edit apply to -kind tests, not -kind refs")) + } + default: + // The refs family is checked first so that -ref alongside -decision is + // named for the flag that identifies the family, not for the one the + // two families share. + if rf != "" || *repo != "" { + return usageErr(fmt.Errorf("review: -ref and -repo apply to -kind refs, not -kind findings")) + } + if tst != "" || dec != "" || *edit != "" { + return usageErr(fmt.Errorf("review: -test, -decision and -edit apply to -kind tests, not -kind findings")) + } + } + // The -ref/-decision pairing, the reference id's syntax, and the decision + // enum (no "edited": a wrong path is rejected and a corrected one is + // ingested, never patched) are invocation facts, refused at the usage + // status. A -repo that is not a directory is refused the same way, as map + // refuses it; the walk opens it read-only for the source snippet. + if recordKind == review.KindRefs { + if rf != "" && dec == "" { + return usageErr(fmt.Errorf("review: -decision is required with -ref")) + } + if dec != "" && rf == "" { + return usageErr(fmt.Errorf("review: -ref is required with -decision")) + } + if rf != "" && !coderefs.IsRefID(rf) { + return usageErr(fmt.Errorf("review: invalid -ref %q (want R-NNN)", rf)) + } + if dec != "" { + if _, err := coderefs.ParseDecisionFlag(dec); err != nil { + return usageErr(fmt.Errorf("review: %w", err)) + } + } + if *repo != "" { + // The snippet is shown only by the interactive walk, so -repo beside + // a single decision would be silently ignored; refused instead, as + // map refuses -window outside emit. + if rf != "" { + return usageErr(fmt.Errorf("review: -repo applies to the interactive walk, not to -ref/-decision")) + } + if fi, err := os.Stat(*repo); err != nil || !fi.IsDir() { + if err == nil { + err = fmt.Errorf("%s is not a directory", *repo) + } + return usageErr(fmt.Errorf("review: -repo: %v", err)) + } + } } // The -test/-decision pairing, the draft id's syntax, the decision enum, and // -edit's pairing are all invocation facts, so they are refused here at the // usage status rather than from inside the package after the drafts load. - if tst != "" && dec == "" { - return usageErr(fmt.Errorf("review: -decision is required with -test")) - } - if dec != "" && tst == "" { - return usageErr(fmt.Errorf("review: -test is required with -decision")) - } - if tst != "" && !drafttests.IsDraftID(tst) { - return usageErr(fmt.Errorf("review: invalid -test %q (want T-NNN)", tst)) - } - if dec != "" { - if _, err := drafttests.ParseDecisionFlag(dec); err != nil { - return usageErr(fmt.Errorf("review: %w", err)) + if recordKind == review.KindTests { + if tst != "" && dec == "" { + return usageErr(fmt.Errorf("review: -decision is required with -test")) + } + if dec != "" && tst == "" { + return usageErr(fmt.Errorf("review: -test is required with -decision")) + } + if tst != "" && !drafttests.IsDraftID(tst) { + return usageErr(fmt.Errorf("review: invalid -test %q (want T-NNN)", tst)) + } + if dec != "" { + if _, err := drafttests.ParseDecisionFlag(dec); err != nil { + return usageErr(fmt.Errorf("review: %w", err)) + } } } // An "edited" decision with no replacement fields is not representable, and @@ -834,6 +1040,8 @@ func Run(args []string) int { Test: tst, Decision: dec, EditIn: editIn, + Ref: rf, + Repo: *repo, In: os.Stdin, Out: os.Stdout, IsTTY: isCharDevice(os.Stdin), @@ -894,6 +1102,49 @@ func describeProvenance(p analyze.Provenance) string { return backend + ", model " + session.SafeText(p.Model) } +// insideDir reports whether path lies inside dir (or is dir itself), comparing +// absolute, symlink-resolved forms so a session reached through a symlinked +// temp root and an -out written through the real one still compare equal. The +// output file need not exist yet, so its parent is what is resolved. +func insideDir(dir, path string) (bool, error) { + root, err := filepath.Abs(dir) + if err != nil { + return false, err + } + if r, err := filepath.EvalSymlinks(root); err == nil { + root = r + } + target, err := filepath.Abs(path) + if err != nil { + return false, err + } + target = resolveExisting(target) + rel, err := filepath.Rel(root, target) + if err != nil { + return false, nil + } + return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))), nil +} + +// resolveExisting resolves the symlinks in the deepest existing ancestor of an +// absolute path and rejoins the rest, so a path whose file or parent does not +// exist yet still compares against a resolved root on equal terms. +func resolveExisting(abs string) string { + rest := "" + dir := abs + for { + if r, err := filepath.EvalSymlinks(dir); err == nil { + return filepath.Join(r, rest) + } + parent := filepath.Dir(dir) + if parent == dir { + return abs + } + rest = filepath.Join(filepath.Base(dir), rest) + dir = parent + } +} + // rejectArgs refuses leftover positional arguments after flag parsing. Flag // parsing stops at the first non-flag argument, so a stray positional silently // discarded every flag that followed it and the command ran with defaults at @@ -949,8 +1200,8 @@ func unresolvedRootErr(err error) error { // resolveSession returns the session directory a pipeline command operates on: // the explicit -session flag when it is given, otherwise the current directory // when that directory itself holds a manifest.json. It is the single resolution -// point for transcribe, merge, report, analyze, draft-tests, and review, so the -// six commands cannot drift in what they accept. +// point for transcribe, import, merge, report, analyze, draft-tests, map, and +// review, so the eight commands cannot drift in what they accept. // // Inference covers the exact current directory only — never a parent, the way // git searches upward for .git — because a command that operated on an ancestor diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 81076a2..99ca695 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -1,6 +1,7 @@ package cli import ( + "errors" "fmt" "io" "os" @@ -305,7 +306,7 @@ func TestUsageListsEveryFlagAndCommand(t *testing.T) { for _, want := range []string{"-commit HASH", "testimony help", "testimony draft-tests", "-window 10", "-kind findings|tests", "-decision edited -edit FILE", "-backend local|cloud", "-model NAME", - "transcribe, import, merge, report, analyze, draft-tests, or"} { + "transcribe, import, merge, report, analyze, draft-tests,\nmap, or"} { if !strings.Contains(usage, want) { t.Errorf("usage text does not mention %q", want) } @@ -1231,7 +1232,7 @@ func TestUsageListsImport(t *testing.T) { "testimony import [-session DIR] [-cast FILE]", "[-offset SECONDS]", "import an asciinema recording's output into interactions.jsonl", - "Omitting -session on transcribe, import, merge, report, analyze, draft-tests, or", + "Omitting -session on transcribe, import, merge, report, analyze, draft-tests,\nmap, or", } { if !strings.Contains(usage, want) { t.Errorf("usage text does not mention %q", want) @@ -1537,3 +1538,209 @@ func TestAnalyzeProvenanceFlagsAreUsageErrors(t *testing.T) { }) } } + +// --- map -------------------------------------------------------------------- + +// mappableSession writes a session the mapping layer can work on: a manifest, +// a two-entry timeline, and a findings.jsonl whose F-001 is confirmed and +// carries a selector — the one state `map` is allowed to map from — plus a +// throwaway repository holding the one file a reference can name. +func mappableSession(t *testing.T) (dir, repo string) { + t.Helper() + dir = t.TempDir() + if err := session.SaveManifest(dir, session.Manifest{Session: "s", App: "app", Participant: "P1"}); err != nil { + t.Fatalf("SaveManifest: %v", err) + } + tl := `{"t":0,"src":"speech","id":"utt-001","payload":{"speaker":"P1","t1":5,"text":"I clicked save and nothing happened"}}` + "\n" + + `{"t":1,"src":"event","id":"ev-001","payload":{"kind":"click","selector":"[data-testid=save-btn]","route":"#general"}}` + "\n" + if err := os.WriteFile(filepath.Join(dir, session.TimelineFile), []byte(tl), 0o644); err != nil { + t.Fatalf("write timeline: %v", err) + } + fnd := `{"id":"F-001","t":2,"type":"bug","severity":3,"mode":"A","quote":"I clicked save and nothing happened","evidence":["utt-001","ev-001"],"ui":{"selector":"[data-testid=save-btn]","route":"#general"},"status":"unverified"}` + "\n" + + `{"kind":"verdict","finding":"F-001","verdict":"confirmed","at":"2026-09-12"}` + "\n" + if err := os.WriteFile(filepath.Join(dir, session.FindingsFile), []byte(fnd), 0o644); err != nil { + t.Fatalf("write findings: %v", err) + } + repo = t.TempDir() + if err := os.MkdirAll(filepath.Join(repo, "src"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, "src", "Save.tsx"), []byte("a\nb\nc\n"), 0o644); err != nil { + t.Fatal(err) + } + return dir, repo +} + +const goodMapAnswer = `{"rubric":"testimony-coderefs/v1","refs":[` + + `{"id":"R-001","finding":"F-001","session":"s","path":"src/Save.tsx","line":2,"role":"owner","status":"accepted"}]}` + +// TestMapFlagGauntletExitsTwo pins every wrong invocation of map and of +// review -kind refs at the usage status, before any file is read. +func TestMapFlagGauntletExitsTwo(t *testing.T) { + dir, repo := mappableSession(t) + file := filepath.Join(repo, "src", "Save.tsx") + cases := []struct { + args []string + want string + }{ + {[]string{"map", "-session", dir, "-repo", ""}, "map: -repo must not be empty"}, + {[]string{"map", "-session", dir}, "map: -repo is required"}, + {[]string{"map", "-session", dir, "-repo", filepath.Join(repo, "missing")}, "map: -repo:"}, + {[]string{"map", "-session", dir, "-repo", file}, "is not a directory"}, + {[]string{"map", "-session", dir, "-render", "-repo", repo}, "map: -repo applies to the emit and ingest modes only"}, + {[]string{"map", "-session", dir, "-repo", repo, "-ingest", ""}, "map: -ingest must not be empty"}, + {[]string{"map", "-session", dir, "-repo", repo, "-out", ""}, "map: -out must not be empty"}, + {[]string{"map", "-session", dir, "-repo", repo, "-out", "f.md", "-ingest", "-"}, "map: -out and -ingest cannot be combined"}, + {[]string{"map", "-session", dir, "-repo", repo, "-render", "-ingest", "-"}, "map: -render and -ingest cannot be combined"}, + {[]string{"map", "-session", dir, "-repo", repo, "-window", "NaN"}, "map: -window must be a finite number of seconds"}, + {[]string{"map", "-session", dir, "-repo", repo, "-window", "20", "-ingest", "-"}, "map: -window applies to the emit mode only"}, + {[]string{"map", "-session", dir, "-window", "20", "-render"}, "map: -window applies to the emit mode only"}, + {[]string{"map", "-session", dir, "-repo", repo, "junk"}, "unexpected argument"}, + {[]string{"review", "-session", dir, "-kind", "refs", "-ref", ""}, "review: -ref must not be empty"}, + {[]string{"review", "-session", dir, "-kind", "refs", "-repo", ""}, "review: -repo must not be empty"}, + {[]string{"review", "-session", dir, "-kind", "refs", "-ref", "R-001"}, "review: -decision is required with -ref"}, + {[]string{"review", "-session", dir, "-kind", "refs", "-decision", "accepted"}, "review: -ref is required with -decision"}, + {[]string{"review", "-session", dir, "-kind", "refs", "-ref", "T-001", "-decision", "accepted"}, `review: invalid -ref "T-001"`}, + {[]string{"review", "-session", dir, "-kind", "refs", "-ref", "R-001", "-decision", "edited"}, `review: invalid decision "edited" (want accepted|rejected)`}, + {[]string{"review", "-session", dir, "-kind", "refs", "-test", "T-001", "-decision", "accepted"}, "review: -test and -edit apply to -kind tests, not -kind refs"}, + {[]string{"review", "-session", dir, "-kind", "refs", "-finding", "F-001", "-verdict", "confirmed"}, "review: -finding and -verdict apply to -kind findings, not -kind refs"}, + {[]string{"review", "-session", dir, "-kind", "refs", "-repo", file}, "review: -repo: "}, + {[]string{"review", "-session", dir, "-kind", "refs", "-repo", repo, "-ref", "R-001", "-decision", "accepted"}, "review: -repo applies to the interactive walk, not to -ref/-decision"}, + {[]string{"map", "-session", dir, "-repo", repo, "-out", filepath.Join(dir, "request.md")}, "map: -out must not be inside the session directory"}, + {[]string{"map", "-session", dir, "-repo", repo, "-out", filepath.Join(dir, "sub", "request.md")}, "map: -out must not be inside the session directory"}, + {[]string{"review", "-session", dir, "-kind", "tests", "-ref", "R-001", "-decision", "accepted"}, "review: -ref and -repo apply to -kind refs, not -kind tests"}, + {[]string{"review", "-session", dir, "-ref", "R-001", "-decision", "accepted"}, "review: -ref and -repo apply to -kind refs, not -kind findings"}, + {[]string{"review", "-session", dir, "-repo", repo}, "review: -ref and -repo apply to -kind refs, not -kind findings"}, + {[]string{"review", "-session", dir, "-kind", "nope"}, `review: invalid kind "nope" (want findings|tests|refs)`}, + } + for _, c := range cases { + var code int + stderr := captureStderr(t, func() { code = Run(c.args) }) + if code != 2 { + t.Errorf("%v: exit %d, want 2 (stderr %q)", c.args, code, stderr) + } + if !strings.Contains(stderr, c.want) { + t.Errorf("%v: stderr %q lacks %q", c.args, stderr, c.want) + } + } + if _, err := os.Stat(filepath.Join(dir, session.RefsFile)); !errors.Is(err, os.ErrNotExist) { + t.Fatal("a refused invocation wrote refs.jsonl") + } +} + +// TestMapLoudStagingExitsOne pins the no-mappable-finding refusal at exit 1 +// with the tally, including the anchored count, and nothing written. +func TestMapLoudStagingExitsOne(t *testing.T) { + dir, repo := mappableSession(t) + fnd := `{"id":"F-001","t":2,"type":"bug","severity":3,"quote":"I clicked save and nothing happened","evidence":["utt-001"],"status":"unverified"}` + "\n" + + `{"kind":"verdict","finding":"F-001","verdict":"confirmed","at":"2026-09-12"}` + "\n" + if err := os.WriteFile(filepath.Join(dir, session.FindingsFile), []byte(fnd), 0o644); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{ + {"map", "-session", dir, "-repo", repo}, + {"map", "-session", dir, "-repo", repo, "-ingest", "-"}, + } { + var code int + stderr := captureStderr(t, func() { code = Run(args) }) + if code != 1 { + t.Errorf("%v: exit %d, want 1", args, code) + } + if !strings.Contains(stderr, "no mappable finding in 1 findings: 1 confirmed (0 with a selector or route), 0 unverified, 0 duplicate, 0 rejected") { + t.Errorf("%v: stderr %q lacks the tally", args, stderr) + } + } + if _, err := os.Stat(filepath.Join(dir, session.RefsFile)); !errors.Is(err, os.ErrNotExist) { + t.Fatal("a refused map wrote refs.jsonl") + } +} + +// TestMapRoundTripThroughTheCLI drives emit → ingest → review -kind refs → +// render through Run, asserting the hand-offs between them: the request names +// the repository, ingest forces proposed, the decision appends, and the render +// lists the reference with its status. +func TestMapRoundTripThroughTheCLI(t *testing.T) { + dir, repo := mappableSession(t) + reqPath := filepath.Join(t.TempDir(), "request.md") + if code := Run([]string{"map", "-session", dir, "-repo", repo, "-out", reqPath}); code != 0 { + t.Fatalf("emit: exit %d", code) + } + req, err := os.ReadFile(reqPath) + if err != nil { + t.Fatal(err) + } + absRepo, _ := filepath.Abs(repo) + if !strings.Contains(string(req), session.SafeInline(absRepo)) || !strings.Contains(string(req), "Finding F-001") { + t.Fatalf("request lacks the repository path or the finding:\n%s", req) + } + answerPath := filepath.Join(t.TempDir(), "refs.json") + if err := os.WriteFile(answerPath, []byte(goodMapAnswer), 0o644); err != nil { + t.Fatal(err) + } + stdout := captureStdout(t, func() { + if code := Run([]string{"map", "-session", dir, "-repo", repo, "-ingest", answerPath}); code != 0 { + t.Errorf("ingest: exit %d", code) + } + }) + if !strings.Contains(stdout, "validated 1 references → ") || !strings.Contains(stdout, "(all proposed)") { + t.Fatalf("ingest stdout = %q", stdout) + } + b, _ := os.ReadFile(filepath.Join(dir, session.RefsFile)) + if !strings.Contains(string(b), `"status":"proposed"`) { + t.Fatalf("refs.jsonl = %q", b) + } + stdout = captureStdout(t, func() { + if code := Run([]string{"review", "-session", dir, "-kind", "refs", "-ref", "R-001", "-decision", "accepted"}); code != 0 { + t.Errorf("review: exit %d", code) + } + }) + if !strings.Contains(stdout, "recorded: R-001 accepted (") { + t.Fatalf("review stdout = %q", stdout) + } + stdout = captureStdout(t, func() { + if code := Run([]string{"map", "-session", dir, "-render"}); code != 0 { + t.Errorf("render: exit %d", code) + } + }) + if !strings.Contains(stdout, "## F-001 — bug: I clicked save and nothing happened") || !strings.Contains(stdout, "`src/Save.tsx:2` (owner) — accepted ") { + t.Fatalf("render stdout = %q", stdout) + } + // A re-ingest is refused once the decision exists, at exit 1. + if code := Run([]string{"map", "-session", dir, "-repo", repo, "-ingest", answerPath}); code != 1 { + t.Fatalf("re-ingest over a decision: exit %d, want 1", code) + } +} + +func TestMapHintsMissingArtefacts(t *testing.T) { + dir, repo := mappableSession(t) + for _, c := range []struct { + remove string + args []string + want string + }{ + {session.RefsFile, []string{"map", "-session", dir, "-render"}, "no refs.jsonl (run `testimony map -ingest` first)"}, + {session.RefsFile, []string{"review", "-session", dir, "-kind", "refs", "-ref", "R-001", "-decision", "accepted"}, "no refs.jsonl (run `testimony map -ingest` first)"}, + {session.TimelineFile, []string{"map", "-session", dir, "-repo", repo}, "run `testimony merge` first"}, + {session.FindingsFile, []string{"map", "-session", dir, "-repo", repo}, "no findings.jsonl (run `testimony analyze -ingest` first)"}, + } { + os.Remove(filepath.Join(dir, c.remove)) + var code int + stderr := captureStderr(t, func() { code = Run(c.args) }) + if code != 1 || !strings.Contains(stderr, c.want) { + t.Errorf("%v: exit %d, stderr %q, want exit 1 containing %q", c.args, code, stderr, c.want) + } + } +} + +func TestUsageListsMap(t *testing.T) { + for _, want := range []string{ + "testimony map [-session DIR] -repo DIR", + "-kind findings|tests|refs", + "-kind refs [-repo DIR] interactively decide proposed code references", + "-kind refs -ref R-NNN -decision accepted|rejected", + } { + if !strings.Contains(usage, want) { + t.Errorf("usage text does not mention %q", want) + } + } +} diff --git a/internal/coderefs/coderefs.go b/internal/coderefs/coderefs.go new file mode 100644 index 0000000..b1e36c0 --- /dev/null +++ b/internal/coderefs/coderefs.go @@ -0,0 +1,415 @@ +// Package coderefs implements the codebase-mapping layer: it emits a +// self-contained, host-delegated mapping request (a versioned rubric, the +// session context, the path to the application's repository, and each confirmed +// finding that carries a selector or route anchor together with its event +// window) and is the sole validation boundary for the model's answer, writing +// validated references to refs.jsonl. The CLI never calls a model, holds no +// keys, and adds no network dependency, exactly as for internal/analyze and +// internal/drafttests. +// +// Resolution is the host's job. The CLI hands over the anchor and the +// repository path; it never greps, never reads a router table, and never +// interprets the source. Its only reads of the repository are the existence and +// line-count checks at ingest, and it never writes into it. +// +// The step sits downstream of verification: a reference may only ever name a +// finding a human already confirmed, so a session with none is staged loudly +// rather than mapped. A reference is born a proposal — ingest forces every +// reference to status:"proposed" regardless of what the answer JSON claims — and +// the human's accept / reject decision is appended as a separate, +// non-destructive record, so the proposal and the decision both survive and the +// reference's link to its finding and session is unreachable by any later write. +package coderefs + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "math" + "path/filepath" + "reflect" + "regexp" + "sort" + "strings" + + "github.com/REPPL/Testimony/internal/analyze" + "github.com/REPPL/Testimony/internal/session" +) + +// RubricVersion pins the mapping scheme so references are comparable across +// sessions and future rubric revisions are explicit. +const RubricVersion = "testimony-coderefs/v1" + +// DefaultWindow is the event-window half-width, in seconds, that emit uses when +// the operator gives none and that render always uses: the issue draft's +// reproduction steps need exactly the window the test draft needs, so the +// default is drafttests' for the same reason (a repro needs the lead-up and the +// aftermath, not only the moment). +const DefaultWindow = 10 + +// maxPathBytes bounds a reference's repo-relative path. A real source path is +// tens of bytes; the bound stops a hostile answer from smuggling a reference +// that is individually well-formed yet serialises past the JSONL line limit its +// readers scan to. +const maxPathBytes = 512 + +// maxRefs bounds the number of references one answer may carry. A real answer +// holds a handful per finding; the bound is checked before any reference is +// validated, because validation reads the repository (an existence check and, +// with a line, a bounded file read per distinct path) and an answer is untrusted +// model output that could otherwise buy that work a hundred thousand times over +// from one file. +const maxRefs = 1000 + +// maxClockSeconds bounds a value clock will format, mirroring report, review, +// and drafttests: a real session stamp is minutes to hours, and 1e9 seconds +// (~31 years) stays well inside int64 so the float64→int conversion in clock can +// never go out of range on an attacker-authored findings.jsonl time. +const maxClockSeconds = 1e9 + +// Ref is one proposed reference from a confirmed finding to a source location — +// one line of refs.jsonl. Reference lines carry no "kind" field; the schema is +// closed (ingest decodes with DisallowUnknownFields). Line is optional: 0 on +// disk means the reference names the file alone, and ingest never writes a 0 +// line because a present line must be at least 1. +type Ref struct { + ID string `json:"id"` + Finding string `json:"finding"` + Session string `json:"session"` + Path string `json:"path"` + Line int `json:"line,omitempty"` + Role string `json:"role"` // owner | handler | route | test + Status string `json:"status"` +} + +// Decision is an appended, non-destructive human decision on a reference. It is +// discriminated by kind:"decision"; the last decision for a reference wins. +// There is no "edited": a wrong path is rejected and a corrected one is +// ingested, never patched, so the reference's path is unreachable by any later +// write. +type Decision struct { + Kind string `json:"kind"` // literal "decision" + Ref string `json:"ref"` + Decision string `json:"decision"` // accepted | rejected + At string `json:"at"` // YYYY-MM-DD +} + +// Status is a reference's effective status for display. +type Status struct { + Value string // proposed | accepted | rejected + At string // decision date, when a decision exists +} + +var ( + refIDRe = regexp.MustCompile(`^R-\d{3}$`) + decisionSet = map[string]bool{"accepted": true, "rejected": true} + roleSet = map[string]bool{"owner": true, "handler": true, "route": true, "test": true} + knownRubrics = map[string]bool{RubricVersion: true} +) + +// IsRefID reports whether s is a well-formed reference id (R-NNN). +func IsRefID(s string) bool { return refIDRe.MatchString(s) } + +// ParseDecisionFlag validates a -decision flag value against the closed enum. +func ParseDecisionFlag(s string) (string, error) { + if !decisionSet[s] { + return "", fmt.Errorf("invalid decision %q (want accepted|rejected)", s) + } + return s, nil +} + +// Load reads refs.jsonl from dir, splitting reference lines from appended +// decision lines. A missing file returns an error satisfying fs.ErrNotExist so +// callers can render an absence notice. +func Load(dir string) ([]Ref, []Decision, error) { + path := filepath.Join(dir, session.RefsFile) + // Route through the read-side no-follow guard, not plain os.Open: refs.jsonl + // in an exchanged (attacker-authored) session may be a symlink or a FIFO, and + // a FIFO would block this open in open(2) for ever. + f, err := session.OpenFileNoFollowRead(path) + if err != nil { + return nil, nil, err + } + defer f.Close() + return ParseRecords(f, path) +} + +// ParseRecords splits a refs.jsonl stream into reference and decision records, +// mirroring drafttests.ParseRecords rule for rule: blank lines are skipped, a +// decision carrying an out-of-enum value is ignored rather than applied, and the +// file is held to both the per-line and the total-size JSONL caps. name labels +// errors. AppendDecision reuses it to re-read the current references through +// its own already-locked descriptor, so the re-check and the append observe the +// same file under one lock. +func ParseRecords(r io.Reader, name string) ([]Ref, []Decision, error) { + var refs []Ref + var decisions []Decision + // Reference ids must be unique across the file: EffectiveStatus and refByID + // both key on the id, so two references sharing one would collapse onto a + // single status entry and the walk would only ever reach the first. Ids are + // compared in their session.SafeText form, the form every surface renders + // them in. + seenID := map[string]bool{} + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 0, 64*1024), session.MaxJSONLLine) + line := 0 + var total int64 + for sc.Scan() { + line++ + raw := sc.Bytes() + total += int64(len(raw)) + 1 + if total > session.MaxJSONLBytes { + return nil, nil, fmt.Errorf("%s: exceeds %d bytes across %d lines; refusing to read", name, session.MaxJSONLBytes, line) + } + if len(bytes.TrimSpace(raw)) == 0 { + continue + } + var probe struct { + Kind string `json:"kind"` + Path *string `json:"path"` + } + if err := json.Unmarshal(raw, &probe); err != nil { + return nil, nil, fmt.Errorf("%s:%d: %w", name, line, err) + } + if probe.Kind == "decision" { + var d Decision + if err := json.Unmarshal(raw, &d); err != nil { + return nil, nil, fmt.Errorf("%s:%d: %w", name, line, err) + } + // The decision enum is closed (accepted|rejected). A decision carrying + // any other value is not representable, so it is ignored rather than + // applied: the reference keeps "proposed" and still appears in the + // review queue instead of vanishing from every surface. + if !decisionSet[d.Decision] { + continue + } + decisions = append(decisions, d) + continue + } + // A line that is JSON null (or {}) decodes cleanly into a value-typed Ref + // as its zero value, so a hand-edited or exchanged refs.jsonl carrying one + // would silently inject a phantom reference into the review queue and the + // render. probe.Path is a pointer for exactly this reason: every reference + // this tool writes names a path, so its absence means the line was never a + // reference at all. + if probe.Path == nil { + return nil, nil, fmt.Errorf("%s:%d: not a reference or decision record (missing path)", name, line) + } + var ref Ref + if err := json.Unmarshal(raw, &ref); err != nil { + return nil, nil, fmt.Errorf("%s:%d: %w", name, line, err) + } + id := session.SafeText(ref.ID) + if strings.TrimSpace(id) == "" { + return nil, nil, fmt.Errorf("%s:%d: reference has no id; every reference must have a unique id", name, line) + } + if seenID[id] { + return nil, nil, fmt.Errorf("%s:%d: duplicate reference id %q; each reference must have a unique id", name, line, ref.ID) + } + seenID[id] = true + refs = append(refs, ref) + } + if err := sc.Err(); err != nil { + return nil, nil, fmt.Errorf("%s: %w", name, err) + } + return refs, decisions, nil +} + +// SameIdentity reports whether a and b are the same reference — equal in every +// field a human decision is recorded against. Status is excluded: it is the one +// field a decision is meant to change, and Ingest launders it to "proposed" on +// every written reference regardless. AppendDecision uses it under the append +// lock to confirm a decision still targets the reference the operator was +// shown, rather than a different one a concurrent re-ingest slid under the same +// id. +func SameIdentity(a, b Ref) bool { + a.Status, b.Status = "", "" + return reflect.DeepEqual(a, b) +} + +// EffectiveStatus maps each reference id to its effective status: every +// reference starts "proposed"; decision records are applied in file order and +// the last one for an id wins. A decision naming an unknown reference is +// ignored for display. The review walk (to pick the work queue) and the render +// (to label each reference) share this one helper. +func EffectiveStatus(refs []Ref, decisions []Decision) map[string]Status { + m := make(map[string]Status, len(refs)) + for _, r := range refs { + m[r.ID] = Status{Value: "proposed"} + } + for _, d := range decisions { + if _, ok := m[d.Ref]; !ok { + continue // a decision referencing an unknown reference is ignored for display + } + m[d.Ref] = Status{Value: d.Decision, At: d.At} + } + return m +} + +// refByID returns a pointer to the reference with the given id, or nil. Ids are +// compared in their session.SafeText form, matching ParseRecords' load-time +// uniqueness check: a reference's id renders through SafeText everywhere it is +// shown, so an operator matching it via -ref only ever has the rendered form to +// type. The returned pointer is into a copy, safe to retain. +func refByID(refs []Ref, id string) *Ref { + want := session.SafeText(id) + for i := range refs { + if session.SafeText(refs[i].ID) == want { + r := refs[i] + return &r + } + } + return nil +} + +func findingByID(findings []analyze.Finding, id string) *analyze.Finding { + want := session.SafeText(id) + for i := range findings { + if session.SafeText(findings[i].ID) == want { + f := findings[i] + return &f + } + } + return nil +} + +// hasAnchor reports whether f carries something the mapping step can hand over: +// a selector or a route that renders as non-empty. Presence is decided on the +// SafeText form, the form the request shows, so a selector that is non-empty +// raw but strips to nothing does not make a finding mappable on the strength of +// an anchor the host would never see. A terminal finding carries no ui at all +// and is the subject of its own intent (itd-2609152113364815). +func hasAnchor(f analyze.Finding) bool { + if f.UI == nil { + return false + } + return strings.TrimSpace(session.SafeText(f.UI.Selector)) != "" || + strings.TrimSpace(session.SafeText(f.UI.Route)) != "" +} + +// eligible returns the findings a reference may name: those whose effective +// status is "confirmed", whose mode is not "B", and which carry a selector or +// route anchor, in id order. +// +// Effective status is not recomputed here — analyze.EffectiveStatus is the +// single helper review, report, and draft-tests already use — so "the last +// verdict wins" is true for free. A duplicate is never eligible even when its +// target is confirmed: the canonical finding carries the evidence. Mode B +// (reference capture of a third-party app) is excluded because there is no +// codebase to map into. On top of the draft-tests rule, a finding with no +// anchor is not eligible: there is nothing to hand over. +func eligible(findings []analyze.Finding, verdicts []analyze.Verdict) []analyze.Finding { + eff := analyze.EffectiveStatus(findings, verdicts) + var out []analyze.Finding + for _, f := range findings { + if eff[f.ID].Value == "confirmed" && f.Mode != "B" && hasAnchor(f) { + out = append(out, f) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out +} + +// findingCounts renders the by-status tally the no-mappable-finding refusal +// names. The confirmed count carries the anchored count beside it, so an +// operator can tell a session with no confirmed finding from one whose confirmed +// findings carry no selector or route. +func findingCounts(findings []analyze.Finding, verdicts []analyze.Verdict) string { + eff := analyze.EffectiveStatus(findings, verdicts) + n := map[string]int{} + anchored := 0 + for _, f := range findings { + st := eff[f.ID].Value + n[st]++ + if st == "confirmed" && f.Mode != "B" && hasAnchor(f) { + anchored++ + } + } + return fmt.Sprintf("%d findings: %d confirmed (%d with a selector or route), %d unverified, %d duplicate, %d rejected", + len(findings), n["confirmed"], anchored, n["unverified"], n["duplicate"], n["rejected"]) +} + +// refCounts is findingCounts' sibling for the nothing-to-render refusal. +func refCounts(refs []Ref, decisions []Decision) string { + eff := EffectiveStatus(refs, decisions) + n := map[string]int{} + for _, r := range refs { + n[eff[r.ID].Value]++ + } + return fmt.Sprintf("%d references: %d accepted, %d proposed, %d rejected", + len(refs), n["accepted"], n["proposed"], n["rejected"]) +} + +// clock renders a session-relative time as [MM:SS]. Negative times are +// legitimate — an external recording whose creation_time predates the manifest +// t0 yields a negative offset — so the sign is rendered rather than clamped +// away. This mirrors report.clock, review.clock, and drafttests.clock. +func clock(sec float64) string { + if math.IsNaN(sec) || math.Abs(sec) > maxClockSeconds { + return "--:--" + } + neg := sec < 0 + if neg { + sec = -sec + } + s := int(sec + 0.5) + sign := "" + if neg && s > 0 { + sign = "-" + } + return fmt.Sprintf("%s%02d:%02d", sign, s/60, s%60) +} + +// ErrNoMappableFindings marks the refusal that stages an empty mapping step +// loudly: a session with no confirmed finding that carries a selector or route +// has nothing a reference could legally name, so emit and ingest both refuse, +// name the finding count by status, and write nothing. It is a sentinel so a +// caller can tell a well-formed invocation whose work cannot be done from a +// genuine failure; the CLI maps both to exit 1. +var ErrNoMappableFindings = errors.New("no mappable finding") + +// ErrNoMappedFindings is its render-side twin: refs.jsonl names no finding that +// is present in findings.jsonl, so the issue draft would be an empty document, +// and writing one over an existing file (with -out) would erase it. +var ErrNoMappedFindings = errors.New("no mapped finding to render") + +func noMappableFindings(dir string, findings []analyze.Finding, verdicts []analyze.Verdict) error { + return fmt.Errorf("%w in %s; a finding is mappable when its verdict is confirmed and it carries a selector or route (confirm one with `testimony review -session %s` first)", + ErrNoMappableFindings, findingCounts(findings, verdicts), dir) +} + +func noMappedFindings(dir string, refs []Ref, decisions []Decision) error { + return fmt.Errorf("%w (%s, none naming a finding in %s); ingest a mapping answer with `testimony map -session %s -repo DIR -ingest FILE` first", + ErrNoMappedFindings, refCounts(refs, decisions), session.FindingsFile, dir) +} + +// loadFindings reads the session's findings, hinting to run `analyze -ingest` +// first when there is none — every mode of this package needs them, because a +// reference is only ever a proposal about a finding a human confirmed. +func loadFindings(dir string) ([]analyze.Finding, []analyze.Verdict, error) { + _, findings, verdicts, err := analyze.Load(dir) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, nil, fmt.Errorf("no %s (run `testimony analyze -ingest` first)", session.FindingsFile) + } + return nil, nil, err + } + return findings, verdicts, nil +} + +// loadRefs reads the session's references and decisions, hinting to run +// `map -ingest` first when there is no refs.jsonl yet. +func loadRefs(dir string) ([]Ref, []Decision, error) { + refs, decisions, err := Load(dir) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, nil, fmt.Errorf("no %s (run `testimony map -ingest` first)", session.RefsFile) + } + return nil, nil, err + } + return refs, decisions, nil +} diff --git a/internal/coderefs/coderefs_test.go b/internal/coderefs/coderefs_test.go new file mode 100644 index 0000000..dc3d8be --- /dev/null +++ b/internal/coderefs/coderefs_test.go @@ -0,0 +1,237 @@ +package coderefs + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/REPPL/Testimony/internal/analyze" + "github.com/REPPL/Testimony/internal/session" +) + +// repoPath is the fixture repository the sample session's references point +// into. It is relative to the package directory, which is where go test runs. +const repoPath = "testdata/repo" + +// fixture reads one testdata file. The fixture session mirrors the bundled +// example: F-001 confirmed with a selector and route (eligible), F-002 +// unverified with an anchor, F-003 rejected, F-004 confirmed but mode B, F-005 a +// duplicate of F-001, F-006 confirmed with no ui at all (anchorless), and F-007 +// confirmed with a route only (eligible). +func fixture(t *testing.T, name string) []byte { + t.Helper() + b, err := os.ReadFile(filepath.Join("testdata", name)) + if err != nil { + t.Fatalf("read fixture %s: %v", name, err) + } + return b +} + +// writeSession lays a session directory from the testdata fixtures. Any +// artefact can be replaced by passing its name and contents; empty contents +// omits the artefact, so the missing-file hints can be exercised. +func writeSession(t *testing.T, overrides ...string) string { + t.Helper() + if len(overrides)%2 != 0 { + t.Fatalf("writeSession: overrides must be name/contents pairs") + } + files := map[string][]byte{ + session.ManifestFile: fixture(t, "manifest.json"), + session.FindingsFile: fixture(t, "findings.jsonl"), + session.TimelineFile: fixture(t, "timeline.jsonl"), + } + for i := 0; i < len(overrides); i += 2 { + if overrides[i+1] == "" { + delete(files, overrides[i]) + continue + } + files[overrides[i]] = []byte(overrides[i+1]) + } + dir := t.TempDir() + for name, body := range files { + if err := os.WriteFile(filepath.Join(dir, name), body, 0o644); err != nil { + t.Fatalf("write %s: %v", name, err) + } + } + return dir +} + +func loadFixtureFindings(t *testing.T) ([]analyze.Finding, []analyze.Verdict) { + t.Helper() + _, findings, verdicts, err := analyze.Load(writeSession(t)) + if err != nil { + t.Fatalf("analyze.Load: %v", err) + } + return findings, verdicts +} + +func ref(id string) Ref { + return Ref{ + ID: id, Finding: "F-001", Session: "fixture-session", + Path: "src/settings/ProfileForm.tsx", Line: 46, Role: "owner", Status: "proposed", + } +} + +// --- eligibility ----------------------------------------------------------- + +// TestEligibilityRequiresConfirmedAndAnchored is AC1's load-side half: a finding +// is mappable iff its effective status is confirmed, its mode is not B, and its +// ui carries a selector or route. F-006 is confirmed and still excluded because +// there is nothing to hand over. +func TestEligibilityRequiresConfirmedAndAnchored(t *testing.T) { + findings, verdicts := loadFixtureFindings(t) + got := eligible(findings, verdicts) + var ids []string + for _, f := range got { + ids = append(ids, f.ID) + } + if want := "F-001 F-007"; strings.Join(ids, " ") != want { + t.Fatalf("eligible = %v, want %s", ids, want) + } +} + +// TestEligibilityHonoursLastVerdict: effective status comes from +// analyze.EffectiveStatus, so a later verdict overriding an earlier one is +// honoured for free. +func TestEligibilityHonoursLastVerdict(t *testing.T) { + findings, verdicts := loadFixtureFindings(t) + verdicts = append(verdicts, + analyze.Verdict{Kind: "verdict", Finding: "F-001", Verdict: "rejected", At: "2026-09-13"}, + analyze.Verdict{Kind: "verdict", Finding: "F-002", Verdict: "confirmed", At: "2026-09-13"}, + ) + got := eligible(findings, verdicts) + var ids []string + for _, f := range got { + ids = append(ids, f.ID) + } + if want := "F-002 F-007"; strings.Join(ids, " ") != want { + t.Fatalf("eligible after later verdicts = %v, want %s", ids, want) + } +} + +// TestHasAnchorDecidesOnRenderedForm: a selector that is non-empty raw but +// strips to nothing under SafeText is not an anchor the host would ever see. +func TestHasAnchorDecidesOnRenderedForm(t *testing.T) { + f := analyze.Finding{UI: &analyze.UI{Selector: "\u200b\u200b"}} + if hasAnchor(f) { + t.Fatal("an invisible-only selector counts as an anchor") + } + f.UI.Route = "#general" + if !hasAnchor(f) { + t.Fatal("a route alone is an anchor") + } + if hasAnchor(analyze.Finding{}) { + t.Fatal("a finding with no ui has an anchor") + } +} + +// TestFindingCountsNamesAnchoredCount pins the refusal tally's shape, including +// the anchored count that tells "no confirmed finding" from "confirmed findings +// with no anchor". +func TestFindingCountsNamesAnchoredCount(t *testing.T) { + findings, verdicts := loadFixtureFindings(t) + got := findingCounts(findings, verdicts) + want := "7 findings: 4 confirmed (2 with a selector or route), 1 unverified, 1 duplicate, 1 rejected" + if got != want { + t.Fatalf("findingCounts = %q, want %q", got, want) + } +} + +// --- records --------------------------------------------------------------- + +func TestParseRecordsSplitsRefsAndDecisions(t *testing.T) { + in := `{"id":"R-001","finding":"F-001","session":"s","path":"a.ts","line":1,"role":"owner","status":"proposed"}` + "\n" + + "\n" + + `{"kind":"decision","ref":"R-001","decision":"accepted","at":"2026-09-16"}` + "\n" + refs, decisions, err := ParseRecords(strings.NewReader(in), "refs.jsonl") + if err != nil { + t.Fatalf("ParseRecords: %v", err) + } + if len(refs) != 1 || len(decisions) != 1 { + t.Fatalf("got %d refs and %d decisions, want 1 and 1", len(refs), len(decisions)) + } + if refs[0].Line != 1 || refs[0].Role != "owner" { + t.Fatalf("ref = %+v", refs[0]) + } +} + +func TestParseRecordsIgnoresOutOfEnumDecision(t *testing.T) { + in := `{"id":"R-001","finding":"F-001","session":"s","path":"a.ts","role":"owner","status":"proposed"}` + "\n" + + `{"kind":"decision","ref":"R-001","decision":"edited","at":"2026-09-16"}` + "\n" + refs, decisions, err := ParseRecords(strings.NewReader(in), "refs.jsonl") + if err != nil { + t.Fatalf("ParseRecords: %v", err) + } + if len(decisions) != 0 { + t.Fatalf("an out-of-enum decision was applied: %+v", decisions) + } + if st := EffectiveStatus(refs, decisions)["R-001"].Value; st != "proposed" { + t.Fatalf("status = %q, want proposed", st) + } +} + +func TestParseRecordsRefusesMalformedLines(t *testing.T) { + for name, in := range map[string]string{ + "null line": "null\n", + "empty object": "{}\n", + "no id": `{"path":"a.ts","role":"owner"}` + "\n", + "duplicate id": `{"id":"R-001","path":"a.ts"}` + "\n" + `{"id":"R-001","path":"b.ts"}` + "\n", + "not json": "nope\n", + } { + if _, _, err := ParseRecords(strings.NewReader(in), "refs.jsonl"); err == nil { + t.Errorf("%s: accepted", name) + } + } +} + +func TestEffectiveStatusLastDecisionWins(t *testing.T) { + refs := []Ref{ref("R-001"), ref("R-002")} + decisions := []Decision{ + {Kind: "decision", Ref: "R-001", Decision: "rejected", At: "2026-09-15"}, + {Kind: "decision", Ref: "R-001", Decision: "accepted", At: "2026-09-16"}, + {Kind: "decision", Ref: "R-999", Decision: "accepted", At: "2026-09-16"}, + } + eff := EffectiveStatus(refs, decisions) + if eff["R-001"].Value != "accepted" || eff["R-001"].At != "2026-09-16" { + t.Fatalf("R-001 = %+v", eff["R-001"]) + } + if eff["R-002"].Value != "proposed" { + t.Fatalf("R-002 = %+v", eff["R-002"]) + } + if _, ok := eff["R-999"]; ok { + t.Fatal("a decision on an unknown reference created a status entry") + } +} + +func TestSameIdentityIgnoresStatusOnly(t *testing.T) { + a, b := ref("R-001"), ref("R-001") + b.Status = "accepted" + if !SameIdentity(a, b) { + t.Fatal("status alone breaks identity") + } + b.Line = 47 + if SameIdentity(a, b) { + t.Fatal("a different line keeps identity") + } +} + +func TestIDAndDecisionFlags(t *testing.T) { + if !IsRefID("R-001") || IsRefID("R-1") || IsRefID("T-001") { + t.Fatal("IsRefID") + } + if _, err := ParseDecisionFlag("edited"); err == nil { + t.Fatal("edited is not a reference decision") + } + if d, err := ParseDecisionFlag("rejected"); err != nil || d != "rejected" { + t.Fatalf("rejected: %q, %v", d, err) + } +} + +func TestClockRendersNegativeAndBounded(t *testing.T) { + for sec, want := range map[float64]string{22: "00:22", -61: "-01:01", -0.2: "00:00", 1e12: "--:--"} { + if got := clock(sec); got != want { + t.Errorf("clock(%v) = %q, want %q", sec, got, want) + } + } +} diff --git a/internal/coderefs/emit.go b/internal/coderefs/emit.go new file mode 100644 index 0000000..208bde1 --- /dev/null +++ b/internal/coderefs/emit.go @@ -0,0 +1,239 @@ +package coderefs + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/REPPL/Testimony/internal/analyze" + "github.com/REPPL/Testimony/internal/drafttests" + "github.com/REPPL/Testimony/internal/session" +) + +// outputExample is the worked output-shape example embedded in the request. It +// is illustrative text, not validated data. +const outputExample = `{"rubric":"testimony-coderefs/v1","refs":[ + {"id":"R-001","finding":"F-001","session":"sample-session", + "path":"src/settings/ProfileForm.tsx","line":46,"role":"owner", + "status":"proposed"}, + {"id":"R-002","finding":"F-001","session":"sample-session", + "path":"src/settings/saveProfile.ts","line":12,"role":"handler", + "status":"proposed"} +]}` + +// EmitRequest builds the single, self-contained mapping request for the session +// in dir: a versioned rubric, the session context, the repository path, and, +// for each confirmed finding that carries a selector or route, that finding's +// own record plus its event window, so that an agent given only this text and +// the repository can answer. window is the event-window half-width in seconds. +// Nothing in the session directory is mutated. +// +// A session with no mappable finding is staged loudly — the refusal names the +// finding count by status and nothing is emitted — because eligibility is the +// whole point of the step: only evidence a human already vouched for, and only +// evidence with an anchor, can be mapped. +func EmitRequest(dir, repo string, window float64) (string, error) { + // The repository is checked here as Ingest checks it, so the rule is a + // property of the API: an emitted request telling the host to resolve anchors + // against a path that is not a directory (or, for "", the current directory) + // would be a request for the wrong tree. + root, err := newRepoRoot(repo) + if err != nil { + return "", err + } + man, err := session.LoadManifest(dir) + if err != nil { + return "", err + } + findings, verdicts, err := loadFindings(dir) + if err != nil { + return "", err + } + // Eligibility is checked before the timeline is read, so a session with + // nothing to map hears why it is empty rather than being sent to run merge + // for a request it could not fill either way. + mappable := eligible(findings, verdicts) + if len(mappable) == 0 { + return "", noMappableFindings(dir, findings, verdicts) + } + entries, err := analyze.LoadTimeline(dir) + if err != nil { + return "", err + } + + var b strings.Builder + fmt.Fprintf(&b, "Testimony code-mapping rubric: %s\n\n", RubricVersion) + + b.WriteString("## Stance\n\n") + b.WriteString("Every reference you return is a *proposal*, born `proposed`; a human accepts or " + + "rejects it afterwards. Only the confirmed findings below are eligible — a human has " + + "already vouched for each one. Resolve each anchor by **reading the repository** at the " + + "path given below: never invent a path, never guess a line, and omit a finding rather than " + + "answer it with a path that does not exist. Never alter the quote.\n\n") + + b.WriteString("## Instructions\n\n") + b.WriteString("Return zero or more references per finding, in finding-id order. For each reference:\n\n") + b.WriteString("- **`path`** — the repo-relative path of the file, with forward slashes, no leading " + + "`/`, and no `.` or `..` segment. It must exist as a regular file under the repository.\n") + b.WriteString("- **`line`** — optional, 1-based: the line the anchor resolves to. Give it only when " + + "you have read the file and can name the line; omit it otherwise.\n") + b.WriteString("- **`role`** — why the path is relevant, one of `owner` (the file that renders the " + + "element), `handler` (the code that handles its interaction), `route` (the router entry for " + + "the route), or `test` (a test that exercises it). A human reads the role to judge the " + + "reference; it is validated as a member of the set, never for truth.\n") + b.WriteString("- A `data-testid` selector is a literal string to search the source for; a route is an " + + "entry in a router table you must read and interpret. Prefer the component that owns the " + + "element over the test that exercises it.\n\n") + + b.WriteString("## Rubric\n\n") + b.WriteString("Field definitions:\n\n") + b.WriteString("- `id` — `R-NNN`, zero-padded and unique within your answer.\n") + b.WriteString("- `finding` — the id of the confirmed finding this reference resolves.\n") + b.WriteString("- `session` — the session this reference belongs to, copied unchanged from the session context below.\n") + b.WriteString("- `path` — repo-relative, at most 512 bytes, as described above.\n") + b.WriteString("- `line` — optional integer, at least 1 and at most the file's line count.\n") + b.WriteString("- `role` — `owner`, `handler`, `route`, or `test`.\n\n") + b.WriteString("Reading each finding record below — two of its fields are about the record, not about your reference:\n\n") + b.WriteString("- `status` is the finding's **birth state**, and it reads `unverified` on every " + + "finding this tool writes: a finding is born a candidate. It is *not* the finding's current " + + "status. Every finding below is confirmed, and its header names the date the verdict was recorded.\n") + b.WriteString("- `mode` is the capture mode: `A` is the application under test, `B` is reference " + + "capture of a third-party app. Only mode `A` findings are eligible, so every finding below is mode `A`.\n\n") + b.WriteString("Hard constraints (each is enforced when your answer is ingested):\n\n") + b.WriteString("- `path` must name an existing regular file under the repository; a path that does not exist, names a directory or a symlink, or escapes the repository is refused.\n") + b.WriteString("- `line`, when given, must be within the file's length.\n") + b.WriteString("- `finding` must name one of the confirmed findings below; a finding that is unverified, rejected, a duplicate, or carries no selector or route is not eligible.\n") + b.WriteString("- `session` must equal the session named below.\n") + b.WriteString("- Any field outside the six above — a confidence, a snippet, a note — is refused; the reference carries what ingest can verify, and the human decision is the only quality signal.\n") + b.WriteString("- `status` is ignored: every reference is written as `proposed`, whatever your answer says.\n") + b.WriteString("- An answer in which any reference fails a check is refused as a whole, with every error reported.\n\n") + + // The repository path is the operator's own machine's path, rendered so the + // host can open it. It is written to the operator's terminal or to a file the + // operator chooses, never into the session directory. It goes through + // SafeInline like every other prose value: an operator-supplied path is not + // attacker-authored, but the escape set is one home for all inline values. + b.WriteString("## Repository\n\n") + fmt.Fprintf(&b, "- Path: %s\n", session.SafeInline(root.abs)) + b.WriteString("- Read it to resolve each anchor. Testimony verifies at ingest that every path you " + + "return exists under it and that every line is in range; it never writes into it.\n\n") + + b.WriteString("## Session\n\n") + fmt.Fprintf(&b, "- Session: %s\n", safeOrNone(man.Session)) + fmt.Fprintf(&b, "- App: %s\n", safeOrNone(man.App)) + fmt.Fprintf(&b, "- Participant: %s\n", safeOrNone(man.Participant)) + var tasks []string + for _, t := range man.Tasks { + if rendered := session.SafeInline(t); strings.TrimSpace(rendered) != "" { + tasks = append(tasks, rendered) + } + } + if len(tasks) > 0 { + b.WriteString("- Tasks:\n") + for i, t := range tasks { + fmt.Fprintf(&b, " %d. %s\n", i+1, t) + } + } else { + b.WriteString("- Tasks: (none recorded)\n") + } + b.WriteString("\n") + + b.WriteString("## Confirmed findings\n\n") + b.WriteString("Each finding below is confirmed by a human, carries a selector or route, and is " + + "eligible. Its header names the anchor to resolve; its own record follows verbatim as it is " + + "stored (its `status` is the birth state, see the rubric above), then its event window: the " + + "timeline entries around it, in time order, which show the interaction the anchor was part of.\n\n") + eff := analyze.EffectiveStatus(findings, verdicts) + for _, f := range mappable { + fmt.Fprintf(&b, "Finding %s — %s, severity %d, at [%s], %s, %s:\n\n", + session.SafeInline(f.ID), safeOrDash(f.Type), f.Severity, clock(f.T), anchorPhrase(f), confirmedOn(eff[f.ID])) + line, err := json.Marshal(f) + if err != nil { + return "", err + } + b.WriteString("```jsonl\n") + b.WriteString(session.SafeText(string(line))) + b.WriteString("\n```\n\n") + + b.WriteString("Event window:\n\n") + b.WriteString("```jsonl\n") + for _, e := range drafttests.Window(entries, f, window) { + el, err := json.Marshal(e) + if err != nil { + return "", err + } + b.WriteString(session.SafeText(string(el))) + b.WriteByte('\n') + } + b.WriteString("```\n\n") + } + + b.WriteString("## Answer\n\n") + fmt.Fprintf(&b, "Answer with a single JSON document: `{\"rubric\":\"%s\",\"refs\":[ … ]}`. "+ + "A bare top-level array of references is also accepted. Output JSON only, no prose.\n\n", RubricVersion) + b.WriteString("```json\n") + b.WriteString(outputExample) + b.WriteString("\n```\n") + + return b.String(), nil +} + +// anchorPhrase names a finding's selector and route in its prose header, so the +// host sees what to resolve before it reads the record. Both are +// attacker-authorable; each renders inside a code span, where backslash escapes +// do not apply, so the code-span rule is used (SafeText, backticks stripped so +// the span cannot be closed early) rather than SafeInline, which would show the +// host `\[data-testid=x\]` for a selector it is meant to search for literally. +// An anchor that renders as nothing is omitted rather than shown blank; +// eligible guarantees at least one is present. +func anchorPhrase(f analyze.Finding) string { + var parts []string + if f.UI != nil { + if !session.CodeRendersEmpty(f.UI.Selector) { + parts = append(parts, "selector "+codeSpan(f.UI.Selector)) + } + if !session.CodeRendersEmpty(f.UI.Route) { + parts = append(parts, "route "+codeSpan(f.UI.Route)) + } + } + if len(parts) == 0 { + return "no anchor" + } + return strings.Join(parts, " on ") +} + +// codeSpan renders untrusted text inside a Markdown code span; report.mdCode's +// rule, shared with render.mdCode. +func codeSpan(s string) string { + return "`" + strings.ReplaceAll(session.SafeText(s), "`", "") + "`" +} + +// safeOrNone applies session.SafeInline and falls back to "(none)" when the +// result renders as nothing; the twin of drafttests.safeOrNone. +func safeOrNone(s string) string { + t := session.SafeInline(s) + if strings.TrimSpace(t) == "" { + return "(none)" + } + return t +} + +// safeOrDash is safeOrNone for a value rendered mid-sentence. +func safeOrDash(s string) string { + t := session.SafeInline(s) + if strings.TrimSpace(t) == "" { + return "—" + } + return t +} + +// confirmedOn renders the human verdict behind an eligible finding, so the +// request never shows a record whose `status` reads "unverified" without saying +// in the same line what made it eligible. +func confirmedOn(st analyze.Status) string { + at := session.SafeInline(st.At) + if strings.TrimSpace(at) == "" { + return "confirmed by human verdict" + } + return "confirmed by human verdict on " + at +} diff --git a/internal/coderefs/emit_test.go b/internal/coderefs/emit_test.go new file mode 100644 index 0000000..6e3a5d7 --- /dev/null +++ b/internal/coderefs/emit_test.go @@ -0,0 +1,171 @@ +package coderefs + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/REPPL/Testimony/internal/session" +) + +// TestEmitCarriesAnchorQuoteWindowAndRepo is AC1: the request carries each +// eligible finding's anchor, its record (and so its quote), its event window, +// and the repository path — and no ineligible finding. +func TestEmitCarriesAnchorQuoteWindowAndRepo(t *testing.T) { + dir := writeSession(t) + req, err := EmitRequest(dir, repoPath, DefaultWindow) + if err != nil { + t.Fatalf("EmitRequest: %v", err) + } + abs, _ := filepath.Abs(repoPath) + for _, want := range []string{ + "Testimony code-mapping rubric: testimony-coderefs/v1", + "- Path: " + session.SafeInline(abs), + "Finding F-001 — bug, severity 3, at [00:22], selector `[data-testid=save-btn]` on route `#general`, confirmed by human verdict on 2026-09-12:", + `"quote":"I clicked save and nothing happened"`, + "Finding F-007 — friction, severity 1, at [00:32], route `#appearance`, confirmed by human verdict on 2026-09-12:", + `"id":"ev-003"`, + `{"rubric":"testimony-coderefs/v1","refs":[`, + } { + if !strings.Contains(req, want) { + t.Errorf("request lacks %q", want) + } + } + for _, absent := range []string{"Finding F-002", "Finding F-003", "Finding F-004", "Finding F-005", "Finding F-006"} { + if strings.Contains(req, absent) { + t.Errorf("request carries ineligible %q", absent) + } + } +} + +// Emit checks the repository as ingest does, so a request can never tell the +// host to resolve anchors against the current directory or a file. +func TestEmitRefusesBadRepo(t *testing.T) { + dir := writeSession(t) + if _, err := EmitRequest(dir, "", 10); err == nil { + t.Fatal("an empty repository path was accepted") + } + if _, err := EmitRequest(dir, filepath.Join(repoPath, "src/routes.ts"), 10); err == nil || !strings.Contains(err.Error(), "is not a directory") { + t.Fatalf("a file as repository: %v", err) + } +} + +func TestEmitIsDeterministic(t *testing.T) { + dir := writeSession(t) + a, err := EmitRequest(dir, repoPath, 10) + if err != nil { + t.Fatal(err) + } + b, err := EmitRequest(dir, repoPath, 10) + if err != nil { + t.Fatal(err) + } + if a != b { + t.Fatal("two emits differ") + } +} + +// TestEmitRefusesWithNoMappableFinding is AC5's emit half: the refusal names the +// tally with the anchored count and is the sentinel the CLI maps to exit 1. +func TestEmitRefusesWithNoMappableFinding(t *testing.T) { + // F-006 confirmed but anchorless is the only confirmed finding left. + fnd := `{"id":"F-006","t":57,"type":"idea","severity":2,"mode":"A","quote":"A toast would do","evidence":["utt-008"],"status":"unverified"}` + "\n" + + `{"id":"F-002","t":38,"type":"preference","severity":2,"quote":"x","evidence":["utt-006"],"ui":{"route":"#a"},"status":"unverified"}` + "\n" + + `{"kind":"verdict","finding":"F-006","verdict":"confirmed","at":"2026-09-12"}` + "\n" + dir := writeSession(t, session.FindingsFile, fnd) + _, err := EmitRequest(dir, repoPath, 10) + if !errors.Is(err, ErrNoMappableFindings) { + t.Fatalf("err = %v, want ErrNoMappableFindings", err) + } + if want := "2 findings: 1 confirmed (0 with a selector or route), 1 unverified, 0 duplicate, 0 rejected"; !strings.Contains(err.Error(), want) { + t.Fatalf("refusal %q lacks %q", err, want) + } +} + +// The refusal precedes the timeline read, so a session with nothing to map is +// not sent to run merge first. +func TestEmitRefusalPrecedesTheTimelineRead(t *testing.T) { + fnd := `{"id":"F-001","t":22,"type":"bug","severity":3,"quote":"x","evidence":["utt-004"],"ui":{"route":"#g"},"status":"unverified"}` + "\n" + dir := writeSession(t, session.FindingsFile, fnd, session.TimelineFile, "") + if _, err := EmitRequest(dir, repoPath, 10); !errors.Is(err, ErrNoMappableFindings) { + t.Fatalf("err = %v, want the eligibility refusal, not a missing-timeline error", err) + } +} + +func TestEmitHintsMissingArtefacts(t *testing.T) { + dir := writeSession(t, session.TimelineFile, "") + if _, err := EmitRequest(dir, repoPath, 10); err == nil || !strings.Contains(err.Error(), "merge") { + t.Fatalf("missing timeline: err = %v, want a merge hint", err) + } + dir = writeSession(t, session.FindingsFile, "") + if _, err := EmitRequest(dir, repoPath, 10); err == nil || !strings.Contains(err.Error(), "analyze -ingest") { + t.Fatalf("missing findings: err = %v, want an analyze -ingest hint", err) + } +} + +func TestEmitMutatesNothing(t *testing.T) { + dir := writeSession(t) + before := dirDigest(t, dir) + if _, err := EmitRequest(dir, repoPath, 10); err != nil { + t.Fatal(err) + } + if after := dirDigest(t, dir); after != before { + t.Fatal("emit changed the session directory") + } + if _, err := os.Stat(filepath.Join(dir, session.RefsFile)); !errors.Is(err, os.ErrNotExist) { + t.Fatal("emit wrote refs.jsonl") + } +} + +// TestEmitAnchorRendersAsCodeSpan: the selector is a literal string the host +// searches for, so the header must not backslash-escape its brackets. +func TestEmitAnchorRendersAsCodeSpan(t *testing.T) { + dir := writeSession(t) + req, err := EmitRequest(dir, repoPath, 10) + if err != nil { + t.Fatal(err) + } + if strings.Contains(req, `\[data-testid`) { + t.Fatal("the selector is backslash-escaped inside its code span") + } +} + +func TestEmitEscapesInlineMarkdownInManifestFields(t *testing.T) { + man := `{"session":"fixture-session","app":"![x](http://h/b.png)","participant":"P1","t0_epoch_ms":1784300400000}` + dir := writeSession(t, session.ManifestFile, man) + req, err := EmitRequest(dir, repoPath, 10) + if err != nil { + t.Fatal(err) + } + if strings.Contains(req, "![x](http://h/b.png)") { + t.Fatal("an image beacon survived into the request") + } +} + +func dirDigest(t *testing.T, dir string) string { + t.Helper() + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + var names []string + for _, e := range entries { + names = append(names, e.Name()) + } + sort.Strings(names) + h := sha256.New() + for _, n := range names { + b, err := os.ReadFile(filepath.Join(dir, n)) + if err != nil { + t.Fatal(err) + } + h.Write([]byte(n)) + h.Write(b) + } + return hex.EncodeToString(h.Sum(nil)) +} diff --git a/internal/coderefs/ingest.go b/internal/coderefs/ingest.go new file mode 100644 index 0000000..76ae610 --- /dev/null +++ b/internal/coderefs/ingest.go @@ -0,0 +1,563 @@ +package coderefs + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/REPPL/Testimony/internal/analyze" + "github.com/REPPL/Testimony/internal/session" +) + +// Ingest validates the model's answer JSON from r against the reference schema +// and, only if every reference passes, writes refs.jsonl with status forced to +// "proposed". It is the sole validation boundary: a reference naming a finding +// that is not currently confirmed or carries no anchor, claiming another +// session, naming a path that does not exist as a regular file under repo or +// escapes it, giving a line past the file's end, carrying a role outside the +// set, or bearing a stray field is rejected here, transactionally (all errors +// reported, nothing written on any failure). +// +// It reads manifest.json, findings.jsonl, and the repository — the repository +// only for the existence and line-count checks. The repository is never written +// to. +// +// To protect the retained human record it refuses to overwrite a refs.jsonl +// that already holds decision records. +func Ingest(dir, repo string, r io.Reader) ([]Ref, error) { + man, err := session.LoadManifest(dir) + if err != nil { + return nil, err + } + findings, verdicts, err := loadFindings(dir) + if err != nil { + return nil, err + } + mappable := eligible(findings, verdicts) + // Refused before a byte of the answer is read: with no eligible finding there + // is nothing a reference could legally name, so every reference in the answer + // would fail the same rule and the operator would read a wall of errors + // instead of the one fact that explains them. + if len(mappable) == 0 { + return nil, noMappableFindings(dir, findings, verdicts) + } + // Keyed in SafeText form, the only form of the finding the answering agent is + // ever shown. + sources := make(map[string]analyze.Finding, len(mappable)) + for _, f := range mappable { + sources[session.SafeText(f.ID)] = f + } + root, err := newRepoRoot(repo) + if err != nil { + return nil, err + } + + // The answer is untrusted model output and -ingest reads it from stdin or a + // file, so cap the read: a multi-gigabyte answer must not OOM the process + // before validation runs. + data, err := io.ReadAll(io.LimitReader(r, session.MaxAnswerBytes+1)) + if err != nil { + return nil, err + } + if len(data) > session.MaxAnswerBytes { + return nil, fmt.Errorf("answer exceeds %d bytes: refusing to ingest", session.MaxAnswerBytes) + } + raws, rubric, err := parseContainer(data) + if err != nil { + return nil, err + } + if rubric != "" && !knownRubrics[rubric] { + return nil, fmt.Errorf("unknown rubric %q (expected %s)", rubric, RubricVersion) + } + // An empty refs array is a no-op, not a truncating write: the commit below + // replaces the file whole, so proceeding would erase a prior good refs.jsonl + // and report success. A model that found nothing for a finding omits it. + if len(raws) == 0 { + return nil, fmt.Errorf("answer contains no references; refusing to overwrite %s", session.RefsFile) + } + if len(raws) > maxRefs { + return nil, fmt.Errorf("answer carries %d references, exceeding the limit of %d; the repository is read per reference, so the count is bounded before any is checked", len(raws), maxRefs) + } + + var ( + decoded []positioned + errs []error + ) + for i, raw := range raws { + p, derr := decodeRef(raw) + if derr != nil { + errs = append(errs, fmt.Errorf("reference #%d: %v", i+1, derr)) + continue + } + p.at = i + 1 + decoded = append(decoded, p) + } + errs = append(errs, validate(decoded, sources, man.Session, root)...) + + // The model is never trusted: every reference lands proposed, and carries the + // clean path the check actually ran on. Laundering both here, before the size + // check below, is what makes that check measure the line actually written + // rather than the one the answer proposed. + refs := make([]Ref, len(decoded)) + for i, p := range decoded { + refs[i] = p.ref + refs[i].Status = "proposed" + if p.clean != "" { + refs[i].Path = p.clean + } + } + errs = append(errs, oversizedRefs(refs, decoded)...) + + if len(errs) > 0 { + return nil, errors.Join(errs...) + } + + if err := commitRefs(dir, refs); err != nil { + return nil, err + } + return refs, nil +} + +// repoRoot is the repository as the path checks see it: the absolute path the +// operator gave, and that path with every symlink resolved, so a reference that +// passes through a symlinked directory can be checked against the real tree. +type repoRoot struct { + abs string + resolved string +} + +// newRepoRoot resolves repo once for the whole answer. The CLI already refuses a +// -repo that is not a directory; the check is repeated here so the rule is a +// property of the API rather than of one caller's invariants. +func newRepoRoot(repo string) (repoRoot, error) { + // An empty path would resolve to the current directory, which is never what + // a caller meant by "the application's repository". + if repo == "" { + return repoRoot{}, errors.New("repository path must be non-empty") + } + abs, err := filepath.Abs(repo) + if err != nil { + return repoRoot{}, fmt.Errorf("repository %s: %w", repo, err) + } + fi, err := os.Stat(abs) + if err != nil { + return repoRoot{}, fmt.Errorf("repository: %w", err) + } + if !fi.IsDir() { + return repoRoot{}, fmt.Errorf("repository %s is not a directory", repo) + } + resolved, err := filepath.EvalSymlinks(abs) + if err != nil { + return repoRoot{}, fmt.Errorf("repository %s: %w", repo, err) + } + return repoRoot{abs: abs, resolved: resolved}, nil +} + +// checkPath holds a reference's path to the containment rule and returns the +// file's full path, and the clean repo-relative form that was checked, when it +// names an existing regular file under the repository. The clean form is what +// ingest records: the check runs on the SafeText-trimmed path, so storing the +// raw one would put a path in refs.jsonl (with stray whitespace or invisible +// characters) that no editor opens and that the check never saw. +// +// A model answer is untrusted input naming a filesystem path the CLI will open, +// so the rule is a containment check, not a string check. The lexical rules +// (forward slashes, no leading slash, no volume, no `.` or `..` segment, no +// empty segment) are what actually keep the join inside the repository; the +// join-and-Rel rule after them is belt and braces, not load-bearing. Resolving +// the parent directory's symlinks and checking it against the resolved +// repository catches a path that walks through a symlinked directory pointing +// outside, which no lexical rule can see; and Lstat rather than Stat means a +// symlink at the final component is refused as "not a regular file" rather than +// followed. +// +// Accepted residual: the parent directory is resolved here and re-resolved by +// the kernel when countLines or the review snippet opens the file, so a local +// attacker who can swap a directory inside the repository for a symlink between +// the two could point the open elsewhere. The final component is opened under +// the no-follow guard either way; closing the parent window portably needs +// openat-style traversal the standard library does not offer. +func (root repoRoot) checkPath(raw string) (full, clean string, err error) { + if strings.ContainsRune(raw, 0) { + return "", "", errors.New("path contains a NUL byte") + } + p := strings.TrimSpace(session.SafeText(raw)) + if p == "" { + return "", "", errors.New("path must be non-empty") + } + if len(p) > maxPathBytes { + return "", "", fmt.Errorf("path is %d bytes, exceeding the limit of %d", len(p), maxPathBytes) + } + if strings.Contains(p, `\`) { + return "", "", errors.New("path must use forward slashes") + } + if strings.HasPrefix(p, "/") || hasDriveLetter(p) || filepath.VolumeName(p) != "" { + return "", "", errors.New("path must be repo-relative, not absolute") + } + for _, seg := range strings.Split(p, "/") { + switch seg { + case "": + return "", "", errors.New("path has an empty segment") + case ".", "..": + return "", "", errors.New("path must not contain a . or .. segment") + } + } + full = filepath.Join(root.abs, filepath.FromSlash(p)) + if escapes(root.abs, full) { + return "", "", errors.New("path resolves outside the repository") + } + parent, err := filepath.EvalSymlinks(filepath.Dir(full)) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", "", errors.New("path does not exist under the repository") + } + return "", "", fmt.Errorf("path: %v", err) + } + if escapes(root.resolved, parent) { + return "", "", errors.New("path resolves outside the repository (through a symlinked directory)") + } + fi, err := os.Lstat(full) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", "", errors.New("path does not exist under the repository") + } + return "", "", fmt.Errorf("path: %v", err) + } + if !fi.Mode().IsRegular() { + return "", "", errors.New("path is not a regular file (a directory or a symlink is refused)") + } + return full, p, nil +} + +// hasDriveLetter reports a Windows drive prefix (`C:`) on any platform, so an +// answer written on one is refused as absolute here too. Only an ASCII letter +// before the colon counts: `a:b.ts` is a legal POSIX file name. +func hasDriveLetter(p string) bool { + if len(p) < 2 || p[1] != ':' { + return false + } + c := p[0] + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') +} + +// escapes reports whether target, made relative to root, lands outside it. +func escapes(root, target string) bool { + rel, err := filepath.Rel(root, target) + if err != nil { + return true + } + return rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +// countLines counts the lines of the regular file at full — `\n`-terminated +// lines plus an unterminated last line — reading at most session.MaxJSONLBytes, +// streamed through a fixed buffer so nothing near that size is held. A file +// larger than the bound is refused for a line check rather than counted: the +// bound is the one every session reader already scans to, and a reference into +// a file that size can name the file without a line. +func countLines(full string) (int, error) { + f, err := session.OpenFileNoFollowRead(full) + if err != nil { + return 0, err + } + defer f.Close() + r := bufio.NewReader(io.LimitReader(f, session.MaxJSONLBytes+1)) + var ( + n int + total int64 + last byte = '\n' + buf = make([]byte, 64*1024) + ) + for { + k, rerr := r.Read(buf) + if k > 0 { + total += int64(k) + n += bytes.Count(buf[:k], []byte{'\n'}) + last = buf[k-1] + } + if rerr == io.EOF { + break + } + if rerr != nil { + return 0, rerr + } + } + if total > session.MaxJSONLBytes { + return 0, fmt.Errorf("file exceeds %d bytes, the bound for a line check; omit line to reference the file alone", session.MaxJSONLBytes) + } + if last != '\n' { + n++ + } + return n, nil +} + +// lineCounter memoises countLines per file across one answer, so an answer +// naming the same file in many references reads it once rather than once per +// reference; the answer is untrusted, and re-reading a large file per line is +// the cheap amplification it would otherwise buy. +type lineCounter map[string]struct { + n int + err error +} + +func (c lineCounter) count(full string) (int, error) { + if r, ok := c[full]; ok { + return r.n, r.err + } + n, err := countLines(full) + c[full] = struct { + n int + err error + }{n, err} + return n, err +} + +// commitRefs runs the decision guard and the whole-file replacement as one +// locked step, through session.CommitRecords — the same primitive +// analyze.commitFindings and drafttests.commitDrafts use, so a concurrent +// `testimony review -kind refs` appending a decision cannot slip between the +// probe and the rewrite and have its record destroyed. +func commitRefs(dir string, refs []Ref) error { + path := filepath.Join(dir, session.RefsFile) + records := make([][]byte, 0, len(refs)) + for _, r := range refs { + b, err := json.Marshal(r) + if err != nil { + return fmt.Errorf("write %s: %w", session.RefsFile, err) + } + records = append(records, b) + } + return session.CommitRecords(session.Commit{ + Path: path, + Records: records, + Guard: func(current io.Reader) error { + held, err := holdsDecisions(current, path) + if err != nil { + return err + } + if held { + return fmt.Errorf("refusing to overwrite %s: it already holds decision records (the retained human record)", session.RefsFile) + } + return nil + }, + }) +} + +// holdsDecisions reports whether the refs.jsonl open on r already contains any +// decision record. It scans for raw kind:"decision" lines rather than reusing +// Load, whose decision slice is filtered to the closed enum: a hand-edited file +// whose only decision lines carry a foreign value would otherwise slip past the +// guard and have its human-decision records truncated by a re-ingest. +func holdsDecisions(r io.Reader, path string) (bool, error) { + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 0, 64*1024), session.MaxJSONLLine) + for sc.Scan() { + raw := sc.Bytes() + if len(bytes.TrimSpace(raw)) == 0 { + continue + } + var probe struct { + Kind string `json:"kind"` + } + if err := json.Unmarshal(raw, &probe); err != nil { + return false, fmt.Errorf("%s: %w", path, err) + } + if probe.Kind == "decision" { + return true, nil + } + } + if err := sc.Err(); err != nil { + return false, fmt.Errorf("%s: %w", path, err) + } + return false, nil +} + +// oversizedRefs reports any reference whose refs.jsonl line would exceed +// session.MaxJSONLLine, and refuses an answer whose references would together +// exceed session.MaxJSONLBytes once written. maxPathBytes bounds the one free +// field, but nothing bounds the number of references in an answer, so a set of +// individually valid references can still serialise to a refs.jsonl that +// ParseRecords refuses to read back. +func oversizedRefs(refs []Ref, decoded []positioned) []error { + var errs []error + var total int64 + var counted int + for i, r := range refs { + label := refLabel(r, decoded[i].at) + line, err := json.Marshal(r) + if err != nil { + errs = append(errs, fmt.Errorf("%s: cannot encode as JSON: %w", label, err)) + continue + } + lineLen := int64(len(line) + 1) + if lineLen > session.MaxJSONLLine { + errs = append(errs, fmt.Errorf("%s: encodes to %d bytes, exceeding the %d-byte %s line limit", label, lineLen, session.MaxJSONLLine, session.RefsFile)) + continue + } + total += lineLen + counted++ + } + if total > session.MaxJSONLBytes { + errs = append(errs, fmt.Errorf("references encode to %d bytes across %d references, exceeding the %d-byte %s file limit; refusing to write a file review and render could not read back", total, counted, session.MaxJSONLBytes, session.RefsFile)) + } + return errs +} + +// parseContainer accepts either a top-level object with a "refs" array (the +// preferred container, optionally carrying a "rubric") or a bare array of +// references. It returns the raw elements and the rubric string (empty for the +// bare-array form). +func parseContainer(data []byte) ([]json.RawMessage, string, error) { + trimmed := bytes.TrimSpace(data) + if len(trimmed) == 0 { + return nil, "", fmt.Errorf("empty answer: expected a JSON object or array of references") + } + switch trimmed[0] { + case '[': + var arr []json.RawMessage + if err := json.Unmarshal(trimmed, &arr); err != nil { + return nil, "", fmt.Errorf("parse references array: %w", err) + } + return arr, "", nil + case '{': + var doc struct { + Rubric string `json:"rubric"` + Refs []json.RawMessage `json:"refs"` + } + if err := json.Unmarshal(trimmed, &doc); err != nil { + return nil, "", fmt.Errorf("parse answer: %w", err) + } + if doc.Refs == nil { + return nil, "", fmt.Errorf("answer object has no \"refs\" array") + } + return doc.Refs, doc.Rubric, nil + default: + return nil, "", fmt.Errorf("expected a JSON object or array of references") + } +} + +// rawRef is how one element of the untrusted answer is decoded before it is +// trusted. Its Line is a pointer so that an absent "line" stays distinguishable +// from a present 0: an absent line means the reference names the file alone and +// is checked for existence only, while a present 0 is out of range and is +// reported as such. Everything else mirrors Ref, which is the shape +// DisallowUnknownFields is closed against — a `confidence`, a `snippet`, or any +// other field the model adds is a hard error rather than a silently dropped key. +type rawRef struct { + ID string `json:"id"` + Finding string `json:"finding"` + Session string `json:"session"` + Path string `json:"path"` + Line *int `json:"line"` + Role string `json:"role"` + Status string `json:"status"` +} + +// positioned pairs a decoded reference with the line the answer gave (nil when +// absent) and at: its 1-based position in the answer the operator actually +// wrote, so an error can say "reference #3" and mean the third one in the +// answer even when an earlier element failed to decode. +type positioned struct { + ref Ref + line *int + at int + clean string // the checked repo-relative path, set by validate when the path passes +} + +// decodeRef strictly decodes one reference element. +func decodeRef(raw json.RawMessage) (positioned, error) { + var rr rawRef + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&rr); err != nil { + return positioned{}, err + } + p := positioned{ref: Ref{ + ID: rr.ID, + Finding: rr.Finding, + Session: rr.Session, + Path: rr.Path, + Role: rr.Role, + Status: rr.Status, + }, line: rr.Line} + if rr.Line != nil { + p.ref.Line = *rr.Line + } + return p, nil +} + +// refLabel names a reference in an error message: its own id when that id is +// well-formed, and otherwise its position in the answer. +func refLabel(r Ref, at int) string { + if IsRefID(r.ID) { + return r.ID + } + return fmt.Sprintf("reference #%d", at) +} + +// validate runs every schema rule against the decoded references and returns +// all errors (transactional and exhaustive), each naming the reference, the +// field, and the offending value. The path and line checks are the only reads +// of the repository this package ever makes. +func validate(refs []positioned, sources map[string]analyze.Finding, wantSession string, root repoRoot) []error { + var errs []error + seen := map[string]int{} + lines := lineCounter{} + + for i := range refs { + p := &refs[i] + r := p.ref + label := refLabel(r, p.at) + if !IsRefID(r.ID) { + errs = append(errs, fmt.Errorf("%s: id %q must match ^R-\\d{3}$", label, r.ID)) + } else if prev, dup := seen[r.ID]; dup { + errs = append(errs, fmt.Errorf("%s: duplicate id (first seen at reference #%d)", r.ID, prev)) + } else { + seen[r.ID] = p.at + } + + // finding: must name a currently-confirmed, anchored, non-Mode-B finding. + // Enforced here as well as in the request (emit omits every non-eligible + // finding), so a hand-written or stale answer cannot smuggle a reference + // to an unverified, rejected, duplicate, or anchorless finding past this + // boundary. + if _, ok := sources[session.SafeText(r.Finding)]; !ok { + errs = append(errs, fmt.Errorf("%s: finding %q is not a confirmed finding with a selector or route in %s", label, r.Finding, session.FindingsFile)) + } + + if session.SafeText(r.Session) != session.SafeText(wantSession) { + errs = append(errs, fmt.Errorf("%s: session %q is not this session (%q in %s)", label, r.Session, wantSession, session.ManifestFile)) + } + + if !roleSet[r.Role] { + errs = append(errs, fmt.Errorf("%s: role %q must be one of owner|handler|route|test", label, r.Role)) + } + + full, clean, perr := root.checkPath(r.Path) + if perr != nil { + errs = append(errs, fmt.Errorf("%s: path %q: %v", label, session.SafeText(r.Path), perr)) + } else { + p.clean = clean + } + if perr == nil && p.line != nil { + // The line is checked only once the path is known to be a regular file + // inside the repository: there is nothing to count otherwise, and a + // second error would only restate the first. + l := *p.line + if l < 1 { + errs = append(errs, fmt.Errorf("%s: line %d must be at least 1", label, l)) + } else if n, cerr := lines.count(full); cerr != nil { + errs = append(errs, fmt.Errorf("%s: line %d: %v", label, l, cerr)) + } else if l > n { + errs = append(errs, fmt.Errorf("%s: line %d is past the end of %s (%d lines)", label, l, session.SafeText(r.Path), n)) + } + } + } + return errs +} diff --git a/internal/coderefs/ingest_test.go b/internal/coderefs/ingest_test.go new file mode 100644 index 0000000..d779398 --- /dev/null +++ b/internal/coderefs/ingest_test.go @@ -0,0 +1,408 @@ +package coderefs + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/REPPL/Testimony/internal/session" +) + +// goodRef is a schema-clean reference for F-001, the fixture's first eligible +// finding, into the fixture repository. The failure table mutates one rule at a +// time from it. +const goodRef = `{"id":"R-001","finding":"F-001","session":"fixture-session",` + + `"path":"src/settings/ProfileForm.tsx","line":46,"role":"owner"}` + +func answer(refs ...string) string { + return `{"rubric":"testimony-coderefs/v1","refs":[` + strings.Join(refs, ",") + `]}` +} + +// with returns goodRef with one field replaced (or, with a "-" prefix, removed). +func with(t *testing.T, field string, value any) string { + t.Helper() + var m map[string]any + if err := json.Unmarshal([]byte(goodRef), &m); err != nil { + t.Fatalf("unmarshal goodRef: %v", err) + } + if strings.HasPrefix(field, "-") { + delete(m, strings.TrimPrefix(field, "-")) + } else { + m[field] = value + } + b, err := json.Marshal(m) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return string(b) +} + +func refsPath(dir string) string { return filepath.Join(dir, session.RefsFile) } + +// TestIngestGood is the happy path: a clean reference validates, lands in +// refs.jsonl, and is forced to "proposed" even though the answer claimed +// "accepted". +func TestIngestGood(t *testing.T) { + dir := writeSession(t) + refs, err := Ingest(dir, repoPath, strings.NewReader(answer(with(t, "status", "accepted")))) + if err != nil { + t.Fatalf("Ingest: %v", err) + } + if len(refs) != 1 || refs[0].Status != "proposed" || refs[0].Line != 46 { + t.Fatalf("refs = %+v", refs) + } + b, err := os.ReadFile(refsPath(dir)) + if err != nil { + t.Fatal(err) + } + want := `{"id":"R-001","finding":"F-001","session":"fixture-session","path":"src/settings/ProfileForm.tsx","line":46,"role":"owner","status":"proposed"}` + "\n" + if string(b) != want { + t.Fatalf("refs.jsonl = %q, want %q", b, want) + } +} + +// A reference may name the file alone; an absent line is not a 0 line. +func TestIngestLineIsOptional(t *testing.T) { + dir := writeSession(t) + refs, err := Ingest(dir, repoPath, strings.NewReader(answer(with(t, "-line", nil)))) + if err != nil { + t.Fatalf("Ingest: %v", err) + } + b, _ := os.ReadFile(refsPath(dir)) + if strings.Contains(string(b), `"line"`) || refs[0].Line != 0 { + t.Fatalf("an absent line was written: %s", b) + } +} + +// The record carries the path the check ran on: SafeText-stripped and trimmed, +// so stray whitespace or an invisible character in the answer cannot land a +// path in refs.jsonl that no editor opens and that the boundary never saw. +func TestIngestStoresTheCheckedPath(t *testing.T) { + for _, raw := range []string{" src/settings/ProfileForm.tsx ", "src/settings/Profile\u200bForm.tsx"} { + dir := writeSession(t) + refs, err := Ingest(dir, repoPath, strings.NewReader(answer(with(t, "path", raw)))) + if err != nil { + t.Fatalf("%q: %v", raw, err) + } + if refs[0].Path != "src/settings/ProfileForm.tsx" { + t.Fatalf("%q: stored path %q", raw, refs[0].Path) + } + b, _ := os.ReadFile(refsPath(dir)) + if !strings.Contains(string(b), `"path":"src/settings/ProfileForm.tsx"`) { + t.Fatalf("%q: refs.jsonl = %s", raw, b) + } + } +} + +// The reference count is bounded before any reference is checked, because +// each check reads the repository. +func TestIngestCapsReferenceCount(t *testing.T) { + dir := writeSession(t) + refs := make([]string, maxRefs+1) + for i := range refs { + refs[i] = with(t, "id", fmt.Sprintf("R-%03d", i%1000)) + } + _, err := Ingest(dir, repoPath, strings.NewReader(answer(refs...))) + if err == nil || !strings.Contains(err.Error(), "exceeding the limit of 1000") { + t.Fatalf("err = %v", err) + } + if _, serr := os.Stat(refsPath(dir)); !errors.Is(serr, os.ErrNotExist) { + t.Fatal("an over-long answer wrote refs.jsonl") + } +} + +func TestIngestBareArrayAccepted(t *testing.T) { + dir := writeSession(t) + if _, err := Ingest(dir, repoPath, strings.NewReader("["+goodRef+"]")); err != nil { + t.Fatalf("bare array: %v", err) + } +} + +// TestIngestValidationFailures is AC2's rule table: each case breaks exactly one +// rule and must be refused, naming the reference and the field. +func TestIngestValidationFailures(t *testing.T) { + cases := []struct { + name string + field string + value any + want string + }{ + {"bad id", "id", "R-1", `id "R-1" must match`}, + {"unverified finding", "finding", "F-002", `finding "F-002" is not a confirmed finding with a selector or route`}, + {"rejected finding", "finding", "F-003", `finding "F-003" is not a confirmed`}, + {"mode B finding", "finding", "F-004", `finding "F-004" is not a confirmed`}, + {"duplicate finding", "finding", "F-005", `finding "F-005" is not a confirmed`}, + {"anchorless finding", "finding", "F-006", `finding "F-006" is not a confirmed finding with a selector or route`}, + {"wrong session", "session", "other", `session "other" is not this session`}, + {"bad role", "role", "maybe", `role "maybe" must be one of owner|handler|route|test`}, + {"missing role", "-role", nil, `role "" must be one of`}, + {"empty path", "path", "", "path must be non-empty"}, + {"parent segment", "path", "../secret.txt", "must not contain a . or .. segment"}, + {"dot segment", "path", "./src/routes.ts", "must not contain a . or .. segment"}, + {"absolute path", "path", "/etc/hosts", "must be repo-relative, not absolute"}, + {"drive path", "path", "C:/x.ts", "must be repo-relative, not absolute"}, + {"backslash", "path", `src\routes.ts`, "must use forward slashes"}, + {"empty segment", "path", "src//routes.ts", "has an empty segment"}, + {"missing file", "path", "src/nope.ts", "does not exist under the repository"}, + {"directory", "path", "src/settings", "is not a regular file"}, + {"line zero", "line", 0, "line 0 must be at least 1"}, + {"line past end", "line", 999, "line 999 is past the end of src/settings/ProfileForm.tsx (51 lines)"}, + {"line on short file", "path", "src/routes.ts", "line 46 is past the end of src/routes.ts (5 lines)"}, + {"colon in a file name is not a drive", "path", "src/a:b.ts", "does not exist under the repository"}, + {"unknown field confidence", "confidence", "high", `unknown field "confidence"`}, + {"unknown field snippet", "snippet", "x", `unknown field "snippet"`}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + dir := writeSession(t) + _, err := Ingest(dir, repoPath, strings.NewReader(answer(with(t, c.field, c.value)))) + if err == nil { + t.Fatalf("accepted") + } + if !strings.Contains(err.Error(), c.want) { + t.Fatalf("err = %q, want it to contain %q", err, c.want) + } + if _, serr := os.Stat(refsPath(dir)); !errors.Is(serr, os.ErrNotExist) { + t.Fatal("a refused answer wrote refs.jsonl") + } + }) + } +} + +// A symlink at the final component is refused as not a regular file rather than +// followed, and a path through a symlinked directory pointing outside the +// repository is refused as escaping it. +func TestIngestRefusesSymlinks(t *testing.T) { + outside := t.TempDir() + if err := os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("s\n"), 0o644); err != nil { + t.Fatal(err) + } + repo := t.TempDir() + if err := os.WriteFile(filepath.Join(repo, "real.ts"), []byte("a\nb\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(repo, "real.ts"), filepath.Join(repo, "link.ts")); err != nil { + t.Skipf("symlink: %v", err) + } + if err := os.Symlink(outside, filepath.Join(repo, "vendor")); err != nil { + t.Skipf("symlink: %v", err) + } + dir := writeSession(t) + _, err := Ingest(dir, repo, strings.NewReader(answer(with(t, "path", "link.ts")))) + if err == nil || !strings.Contains(err.Error(), "is not a regular file") { + t.Fatalf("symlinked file: err = %v", err) + } + _, err = Ingest(dir, repo, strings.NewReader(answer(with(t, "path", "vendor/secret.txt")))) + if err == nil || !strings.Contains(err.Error(), "through a symlinked directory") { + t.Fatalf("symlinked directory: err = %v", err) + } + // The real file, through no symlink, is fine: the repository itself may sit + // under a symlinked temp root (macOS /var → /private/var) and must still pass. + real := strings.Replace(with(t, "path", "real.ts"), `"line":46`, `"line":2`, 1) + if _, err := Ingest(dir, repo, strings.NewReader(answer(real))); err != nil { + t.Fatalf("a regular file under a symlinked temp root: %v", err) + } +} + +// A line is counted on an unterminated last line too, and a file past the +// read bound is refused for a line check rather than counted. +func TestIngestLineCountRules(t *testing.T) { + repo := t.TempDir() + if err := os.WriteFile(filepath.Join(repo, "u.ts"), []byte("a\nb"), 0o644); err != nil { + t.Fatal(err) + } + dir := writeSession(t) + twoLines := with(t, "path", "u.ts") + if _, err := Ingest(dir, repo, strings.NewReader(answer(strings.Replace(twoLines, `"line":46`, `"line":2`, 1)))); err != nil { + t.Fatalf("line 2 of an unterminated two-line file: %v", err) + } + os.Remove(refsPath(dir)) + if _, err := Ingest(dir, repo, strings.NewReader(answer(strings.Replace(twoLines, `"line":46`, `"line":3`, 1)))); err == nil || !strings.Contains(err.Error(), "(2 lines)") { + t.Fatalf("line 3: err = %v", err) + } + big, err := os.Create(filepath.Join(repo, "big.bin")) + if err != nil { + t.Fatal(err) + } + if err := big.Truncate(session.MaxJSONLBytes + 1); err != nil { + t.Fatal(err) + } + big.Close() + _, err = Ingest(dir, repo, strings.NewReader(answer(strings.Replace(with(t, "path", "big.bin"), `"line":46`, `"line":1`, 1)))) + if err == nil || !strings.Contains(err.Error(), "the bound for a line check") { + t.Fatalf("oversized file: err = %v", err) + } + // Without a line the same file is referenceable. + noLine := strings.Replace(with(t, "-line", nil), "src/settings/ProfileForm.tsx", "big.bin", 1) + if _, err := Ingest(dir, repo, strings.NewReader(answer(noLine))); err != nil { + t.Fatalf("oversized file without a line: %v", err) + } +} + +func TestIngestRejectsDuplicateID(t *testing.T) { + dir := writeSession(t) + _, err := Ingest(dir, repoPath, strings.NewReader(answer(goodRef, goodRef))) + if err == nil || !strings.Contains(err.Error(), "duplicate id (first seen at reference #1)") { + t.Fatalf("err = %v", err) + } +} + +// TestIngestIsTransactional is AC2's "refused as a whole": a second, good +// reference in the same answer is not written because the first failed. +func TestIngestIsTransactional(t *testing.T) { + dir := writeSession(t) + bad := with(t, "path", "src/nope.ts") + good := with(t, "id", "R-002") + _, err := Ingest(dir, repoPath, strings.NewReader(answer(bad, good))) + if err == nil { + t.Fatal("accepted") + } + if _, serr := os.Stat(refsPath(dir)); !errors.Is(serr, os.ErrNotExist) { + t.Fatal("a partly-bad answer wrote refs.jsonl") + } + // Every error is reported at once: the finding rule and the path rule both. + twoBad := strings.Replace(with(t, "path", "src/nope.ts"), `"F-001"`, `"F-002"`, 1) + _, err = Ingest(dir, repoPath, strings.NewReader(answer(twoBad))) + if err == nil || !strings.Contains(err.Error(), "does not exist") || !strings.Contains(err.Error(), "is not a confirmed") { + t.Fatalf("errors are not exhaustive: %v", err) + } +} + +func TestIngestLabelsUndecodableNeighbourByAnswerPosition(t *testing.T) { + dir := writeSession(t) + _, err := Ingest(dir, repoPath, strings.NewReader(answer("null", with(t, "id", "bad")))) + if err == nil || !strings.Contains(err.Error(), "reference #2: id \"bad\"") { + t.Fatalf("err = %v", err) + } +} + +func TestIngestRefusesEmptyAnswer(t *testing.T) { + dir := writeSession(t) + // Seed a good file, then confirm an empty answer cannot erase it. + if _, err := Ingest(dir, repoPath, strings.NewReader(answer(goodRef))); err != nil { + t.Fatal(err) + } + for _, empty := range []string{"[]", `{"refs":[]}`, "", " "} { + if _, err := Ingest(dir, repoPath, strings.NewReader(empty)); err == nil { + t.Errorf("%q: accepted", empty) + } + } + b, _ := os.ReadFile(refsPath(dir)) + if !strings.Contains(string(b), "R-001") { + t.Fatal("an empty answer erased refs.jsonl") + } +} + +func TestIngestUnknownRubric(t *testing.T) { + dir := writeSession(t) + _, err := Ingest(dir, repoPath, strings.NewReader(`{"rubric":"testimony-coderefs/v9","refs":[`+goodRef+`]}`)) + if err == nil || !strings.Contains(err.Error(), "unknown rubric") { + t.Fatalf("err = %v", err) + } +} + +// TestIngestRefusesWithNoMappableFinding is AC5's ingest half, and fires before +// the answer is read. +func TestIngestRefusesWithNoMappableFinding(t *testing.T) { + fnd := `{"id":"F-001","t":22,"type":"bug","severity":3,"quote":"x","evidence":["utt-004"],"ui":{"route":"#g"},"status":"unverified"}` + "\n" + dir := writeSession(t, session.FindingsFile, fnd) + _, err := Ingest(dir, repoPath, strings.NewReader("this is not even json")) + if !errors.Is(err, ErrNoMappableFindings) { + t.Fatalf("err = %v", err) + } +} + +func TestIngestRefusesBadRepo(t *testing.T) { + dir := writeSession(t) + if _, err := Ingest(dir, filepath.Join(t.TempDir(), "missing"), strings.NewReader(answer(goodRef))); err == nil { + t.Fatal("a missing repository was accepted") + } + if _, err := Ingest(dir, filepath.Join(repoPath, "src/routes.ts"), strings.NewReader(answer(goodRef))); err == nil || !strings.Contains(err.Error(), "is not a directory") { + t.Fatalf("a file as repository: %v", err) + } +} + +// TestIngestRefusesOverwriteWithDecisions is AC4's guard: once a decision exists +// the machine record cannot be replaced under it. +func TestIngestRefusesOverwriteWithDecisions(t *testing.T) { + dir := writeSession(t) + if _, err := Ingest(dir, repoPath, strings.NewReader(answer(goodRef))); err != nil { + t.Fatal(err) + } + if err := AppendDecision(dir, Decision{Kind: "decision", Ref: "R-001", Decision: "accepted", At: "2026-09-16"}, nil); err != nil { + t.Fatal(err) + } + before, _ := os.ReadFile(refsPath(dir)) + _, err := Ingest(dir, repoPath, strings.NewReader(answer(with(t, "line", 12)))) + if err == nil || !strings.Contains(err.Error(), "already holds decision records") { + t.Fatalf("err = %v", err) + } + after, _ := os.ReadFile(refsPath(dir)) + if string(before) != string(after) { + t.Fatal("a refused re-ingest changed refs.jsonl") + } + // A foreign decision value counts too. + dir2 := writeSession(t) + if _, err := Ingest(dir2, repoPath, strings.NewReader(answer(goodRef))); err != nil { + t.Fatal(err) + } + f, _ := os.OpenFile(refsPath(dir2), os.O_APPEND|os.O_WRONLY, 0o644) + f.WriteString(`{"kind":"decision","ref":"R-001","decision":"maybe","at":"2026-09-16"}` + "\n") + f.Close() + if _, err := Ingest(dir2, repoPath, strings.NewReader(answer(goodRef))); err == nil { + t.Fatal("a foreign decision did not protect the file") + } +} + +func TestIngestRejectsOversizedAnswer(t *testing.T) { + dir := writeSession(t) + r := strings.NewReader("[" + strings.Repeat(" ", session.MaxAnswerBytes) + "]") + if _, err := Ingest(dir, repoPath, r); err == nil || !strings.Contains(err.Error(), "exceeds") { + t.Fatalf("err = %v", err) + } +} + +// The bundled sample's references, re-ingested against the fixture repository +// they point into, come back byte for byte. The sample is documentation, and +// documentation that would not survive the tool's own boundary is wrong. +func TestSampleRefsJSONLPassesIngest(t *testing.T) { + const sample = "../../examples/sample-session" + refs, decisions, err := Load(sample) + if err != nil { + t.Fatalf("Load the bundled sample: %v", err) + } + if len(refs) != 2 || len(decisions) != 1 { + t.Fatalf("the sample holds %d refs and %d decisions, want 2 and 1", len(refs), len(decisions)) + } + dir := t.TempDir() + for _, name := range []string{session.ManifestFile, session.FindingsFile} { + b, rerr := os.ReadFile(filepath.Join(sample, name)) + if rerr != nil { + t.Fatal(rerr) + } + if werr := os.WriteFile(filepath.Join(dir, name), b, 0o644); werr != nil { + t.Fatal(werr) + } + } + var lines []string + for _, r := range refs { + b, merr := json.Marshal(r) + if merr != nil { + t.Fatal(merr) + } + lines = append(lines, string(b)) + } + if _, err := Ingest(dir, repoPath, strings.NewReader(answer(lines...))); err != nil { + t.Fatalf("the bundled sample's references do not pass Ingest: %v", err) + } + written, _ := os.ReadFile(refsPath(dir)) + sampleBytes, _ := os.ReadFile(filepath.Join(sample, session.RefsFile)) + wantPrefix := strings.Join(strings.Split(strings.TrimRight(string(sampleBytes), "\n"), "\n")[:2], "\n") + "\n" + if string(written) != wantPrefix { + t.Fatalf("re-ingesting the sample's references does not reproduce them:\n got %q\nwant %q", written, wantPrefix) + } +} diff --git a/internal/coderefs/render.go b/internal/coderefs/render.go new file mode 100644 index 0000000..6d7dcb8 --- /dev/null +++ b/internal/coderefs/render.go @@ -0,0 +1,292 @@ +package coderefs + +import ( + "fmt" + "sort" + "strings" + "unicode" + "unicode/utf8" + + "github.com/REPPL/Testimony/internal/analyze" + "github.com/REPPL/Testimony/internal/drafttests" + "github.com/REPPL/Testimony/internal/session" + "github.com/REPPL/Testimony/internal/timeline" +) + +// maxTitleRunes caps the derived issue title. A title is presentation, and an +// issue tracker truncates a long one anyway; the cap keeps the heading on one +// line whatever the quote's length. +const maxTitleRunes = 80 + +// Render returns one Markdown issue draft per mapped finding — a finding that +// at least one reference in refs.jsonl names — in finding-id order: a title +// derived from the finding, its anchor and clock, the participant's quote, the +// reproduction steps derived from the event window, and every reference for the +// finding with its current status, so a draft rendered before review is visibly +// unreviewed. Review does not gate the render. +// +// The title and the steps are derived by the CLI, not the model, because the +// render must work from the records alone and cannot invent a step. Steps the +// CLI derives are mechanical and may read flatly; that is the trade for a render +// that needs no second model round-trip. +// +// Nothing is written into the application's repository, and the render does +// not open it: it reads manifest.json, timeline.jsonl, findings.jsonl, and +// refs.jsonl only. The result is a hand-off artefact for the operator to file +// where they choose. +func Render(dir string) (string, error) { + man, err := session.LoadManifest(dir) + if err != nil { + return "", err + } + findings, _, err := loadFindings(dir) + if err != nil { + return "", err + } + refs, decisions, err := loadRefs(dir) + if err != nil { + return "", err + } + entries, err := analyze.LoadTimeline(dir) + if err != nil { + return "", err + } + eff := EffectiveStatus(refs, decisions) + + // Group references by finding, in finding-id order, keeping each group's + // references in id order. A reference whose finding is no longer in + // findings.jsonl (a hand-edited session) has nothing to render under and is + // skipped; a file with no renderable finding is refused rather than rendered + // empty. + byFinding := map[string][]Ref{} + for _, r := range refs { + byFinding[session.SafeText(r.Finding)] = append(byFinding[session.SafeText(r.Finding)], r) + } + var ids []string + for id := range byFinding { + if findingByID(findings, id) != nil { + ids = append(ids, id) + } + } + if len(ids) == 0 { + return "", noMappedFindings(dir, refs, decisions) + } + sort.Strings(ids) + + var b strings.Builder + fmt.Fprintf(&b, "# Issue drafts — %s\n\n", mdOrNone(man.Session)) + fmt.Fprintf(&b, "Drafted from mapped findings in session %s (app %s, participant %s). %d of %d references accepted.\n\n", + codeOrNone(man.Session), codeOrNone(man.App), codeOrNone(man.Participant), countAccepted(refs, eff), len(refs)) + + for k, id := range ids { + f := *findingByID(findings, id) + group := byFinding[id] + sort.SliceStable(group, func(i, j int) bool { return group[i].ID < group[j].ID }) + + fmt.Fprintf(&b, "## %s — %s\n\n", mdInline(f.ID), title(f)) + fmt.Fprintf(&b, "**Severity** %d · **Anchor** %s · **At** [%s]\n\n", f.Severity, anchorLine(f), clock(f.T)) + fmt.Fprintf(&b, "> “%s”\n> — %s, [%s]\n\n", mdOrPlaceholder(f.Quote, "no quote"), mdOrNone(man.Participant), clock(f.T)) + + b.WriteString("### Steps to reproduce\n\n") + for n, s := range steps(drafttests.Window(entries, f, DefaultWindow), f) { + fmt.Fprintf(&b, "%d. %s\n", n+1, s) + } + b.WriteString("\n") + + b.WriteString("### Suspected files\n\n") + var refIDs []string + for _, r := range group { + st := eff[r.ID] + loc := session.SafeText(r.Path) + if r.Line > 0 { + loc = fmt.Sprintf("%s:%d", loc, r.Line) + } + fmt.Fprintf(&b, "- %s (%s) — %s\n", mdCode(loc), mdOrDash(r.Role), decisionPhrase(st)) + refIDs = append(refIDs, mdCode(r.ID)) + } + b.WriteString("\n") + fmt.Fprintf(&b, "Session %s · finding %s · references %s\n", codeOrNone(man.Session), mdCode(f.ID), strings.Join(refIDs, ", ")) + if k < len(ids)-1 { + b.WriteString("\n") + } + } + return b.String(), nil +} + +func countAccepted(refs []Ref, eff map[string]Status) int { + n := 0 + for _, r := range refs { + if eff[r.ID].Value == "accepted" { + n++ + } + } + return n +} + +// decisionPhrase renders a reference's current status for the suspected-files +// list: "accepted 2026-09-16", "rejected 2026-09-16", or "proposed". +func decisionPhrase(st Status) string { + if st.Value == "proposed" || strings.TrimSpace(session.SafeText(st.At)) == "" { + return mdOrDash(st.Value) + } + return mdInline(st.Value) + " " + mdInline(st.At) +} + +// title derives the issue title from the finding: its type and the first clause +// of its quote, capped at maxTitleRunes. It is presentation, derived by the CLI +// so the render works from the records alone. +func title(f analyze.Finding) string { + typ := strings.TrimSpace(session.SafeText(f.Type)) + if typ == "" { + typ = "finding" + } + // The first clause is the text up to the first separator, skipping any + // separators the quote opens with so a quote like ".NET crashed" does not + // empty the clause and drop the title to the bare type. + clause := strings.TrimLeft(strings.TrimSpace(session.SafeText(f.Quote)), ".,;:!? ") + if i := strings.IndexAny(clause, ".,;:!?"); i >= 0 { + clause = strings.TrimSpace(clause[:i]) + } + if clause == "" { + return mdInline(typ) + } + t := typ + ": " + clause + if r := []rune(t); len(r) > maxTitleRunes { + t = strings.TrimRightFunc(string(r[:maxTitleRunes-1]), unicode.IsSpace) + "…" + } + return mdInline(t) +} + +// anchorLine renders the finding's selector and route as code spans for the +// header line; eligibility at ingest guarantees at least one, but the render +// reads a file that may have been hand-edited, so an absent anchor falls back to +// a placeholder rather than a blank. +func anchorLine(f analyze.Finding) string { + var parts []string + if f.UI != nil { + if !session.CodeRendersEmpty(f.UI.Selector) { + parts = append(parts, mdCode(f.UI.Selector)) + } + if !session.CodeRendersEmpty(f.UI.Route) { + parts = append(parts, mdCode(f.UI.Route)) + } + } + if len(parts) == 0 { + return "—" + } + return strings.Join(parts, " on ") +} + +// steps derives the reproduction steps from the finding's event window: one +// orientation line from the first event's route, then one imperative line per +// event at or before the finding's t, naming the selector where the event +// carries one. Utterances are not steps; they are what the quote already +// carries. A window with no such event yields a single line saying so, rather +// than an empty list under a heading that promises steps. +func steps(window []timeline.Entry, f analyze.Finding) []string { + var out []string + oriented := false + for _, e := range window { + if e.Src != "event" || e.T > f.T { + continue + } + route := payloadString(e, "route") + if !oriented { + oriented = true + if !session.CodeRendersEmpty(route) { + out = append(out, "Open "+mdCode(route)+".") + } + } + out = append(out, describeEvent(e)) + } + if len(out) == 0 { + return []string{"(no interaction event precedes the finding in its event window)"} + } + return out +} + +// describeEvent turns one interaction event into an imperative step. Every +// value comes from an attacker-authorable timeline, so each goes through the +// shared escape set: selectors and values in code spans, text inline. +func describeEvent(e timeline.Entry) string { + kind := strings.TrimSpace(session.SafeText(payloadString(e, "kind"))) + selector := payloadString(e, "selector") + text := payloadString(e, "text") + value := payloadString(e, "value") + target := "" + switch { + case !session.CodeRendersEmpty(selector): + target = mdCode(selector) + case strings.TrimSpace(session.SafeText(text)) != "": + target = "“" + mdInline(text) + "”" + } + switch kind { + case "click": + if target == "" { + return "Click." + } + return "Click " + target + "." + case "input": + if session.CodeRendersEmpty(value) { + if target == "" { + return "Edit the field." + } + return "Edit " + target + "." + } + if target == "" { + return "Enter " + mdCode(value) + "." + } + return "Enter " + mdCode(value) + " in " + target + "." + case "": + kind = "interact with" + } + // Capitalise the first rune, not the first byte: kind is attacker-authorable + // and a multi-byte first character sliced at kind[:1] would put invalid UTF-8 + // into the rendered draft after every sanitiser has run. The verb then goes + // through the inline escape like every other value, so a kind of + // `![x](http://h/b.png)` cannot become a live image in the pasted issue. + first, size := utf8.DecodeRuneInString(kind) + verb := mdInline(string(unicode.ToUpper(first)) + kind[size:]) + if target == "" { + return verb + "." + } + return verb + " " + target + "." +} + +func payloadString(e timeline.Entry, key string) string { + if s, ok := e.Payload[key].(string); ok { + return s + } + return "" +} + +// mdInline neutralises the inline Markdown an attacker-authored record could +// otherwise smuggle into the rendered draft; the escape set lives in +// session.SafeInline, shared with every Markdown artefact. +func mdInline(s string) string { return session.SafeInline(s) } + +// mdCode renders untrusted text inside a Markdown code span, where backslash +// escapes do not apply: backticks are stripped so the span cannot be closed +// early. This is report.mdCode's rule, applied for the same reason. +func mdCode(s string) string { + return "`" + strings.ReplaceAll(session.SafeText(s), "`", "") + "`" +} + +func codeOrNone(s string) string { + if session.CodeRendersEmpty(s) { + return "`(none)`" + } + return mdCode(s) +} + +func mdOrNone(s string) string { return mdOrPlaceholder(s, "(none)") } + +func mdOrDash(s string) string { return mdOrPlaceholder(s, "—") } + +// mdOrPlaceholder decides presence on the rendered form, not the raw one. +func mdOrPlaceholder(s, placeholder string) string { + if strings.TrimSpace(session.SafeText(s)) == "" { + return placeholder + } + return mdInline(s) +} diff --git a/internal/coderefs/render_test.go b/internal/coderefs/render_test.go new file mode 100644 index 0000000..12ef95c --- /dev/null +++ b/internal/coderefs/render_test.go @@ -0,0 +1,228 @@ +package coderefs + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + "unicode/utf8" + + "github.com/REPPL/Testimony/internal/analyze" + "github.com/REPPL/Testimony/internal/session" + "github.com/REPPL/Testimony/internal/timeline" +) + +const samplePath = "../../examples/sample-session" + +// sampleSession copies the bundled sample's records into a scratch directory +// and merges its timeline there, so the golden test never depends on the +// generated timeline.jsonl being present in the checkout. +func sampleSession(t *testing.T) string { + t.Helper() + dir := t.TempDir() + for _, name := range []string{session.ManifestFile, session.FindingsFile, session.TranscriptFile, session.InteractionsFile, session.RefsFile} { + b, err := os.ReadFile(filepath.Join(samplePath, name)) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + if err := os.WriteFile(filepath.Join(dir, name), b, 0o644); err != nil { + t.Fatalf("write %s: %v", name, err) + } + } + if _, _, err := timeline.Merge(dir); err != nil { + t.Fatalf("merge: %v", err) + } + return dir +} + +// TestRenderGoldenFromSampleSession is AC3, pinned byte for byte: the issue +// draft carries the title, the steps from the window, the quote, and the +// suspected files with their current status. +func TestRenderGoldenFromSampleSession(t *testing.T) { + got, err := Render(sampleSession(t)) + if err != nil { + t.Fatalf("Render: %v", err) + } + want := string(fixture(t, "issues.md")) + if got != want { + t.Fatalf("rendered draft does not match testdata/issues.md:\n--- got ---\n%s\n--- want ---\n%s", got, want) + } + for _, s := range []string{ + "## F-001 — bug: I clicked save and nothing happened", + "> “I clicked save and nothing happened”", + "1. Open `#general`.", + "4. Click `[data-testid=save-btn]`.", + "- `src/settings/ProfileForm.tsx:46` (owner) — accepted 2026-09-16", + "- `src/settings/saveProfile.ts:12` (handler) — proposed", + } { + if !strings.Contains(got, s) { + t.Errorf("render lacks %q", s) + } + } +} + +// Review does not gate the render: a proposed-only refs.jsonl renders, with the +// reference visibly unreviewed. +func TestRenderDoesNotWaitForReview(t *testing.T) { + dir := writeSession(t) + if _, err := Ingest(dir, repoPath, strings.NewReader(answer(goodRef))); err != nil { + t.Fatal(err) + } + got, err := Render(dir) + if err != nil { + t.Fatalf("Render: %v", err) + } + if !strings.Contains(got, "(owner) — proposed") || !strings.Contains(got, "0 of 1 references accepted") { + t.Fatalf("a proposed reference is not shown as such:\n%s", got) + } +} + +// Steps stop at the finding's t: ev-004, the second save click after the +// utterance, is not a step. A finding with no preceding event says so. +func TestRenderStepsEndAtTheFinding(t *testing.T) { + dir := writeSession(t) + if _, err := Ingest(dir, repoPath, strings.NewReader(answer(goodRef))); err != nil { + t.Fatal(err) + } + got, err := Render(dir) + if err != nil { + t.Fatal(err) + } + if strings.Count(got, "Click `[data-testid=save-btn]`.") != 1 { + t.Fatalf("the save click after the finding became a step:\n%s", got) + } + // F-007 is anchored on a route with one click event (ev-005) at its t. + r7 := strings.Replace(strings.Replace(goodRef, `"F-001"`, `"F-007"`, 1), `"R-001"`, `"R-002"`, 1) + r7 = strings.Replace(r7, "ProfileForm.tsx", "../routes.ts", 1) + r7 = strings.Replace(r7, `"line":46`, `"line":3`, 1) + r7 = strings.Replace(r7, `"owner"`, `"route"`, 1) + r7 = strings.Replace(r7, "src/settings/../routes.ts", "src/routes.ts", 1) + os.Remove(refsPath(dir)) + if _, err := Ingest(dir, repoPath, strings.NewReader(answer(goodRef, r7))); err != nil { + t.Fatal(err) + } + got, err = Render(dir) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(got, "## F-007 — friction: Where did the appearance tab go") || !strings.Contains(got, "`src/routes.ts:3` (route)") { + t.Fatalf("F-007 block missing:\n%s", got) + } + if !strings.Contains(got, "**Anchor** `#appearance` ·") { + t.Fatalf("route-only anchor renders wrongly:\n%s", got) + } +} + +func TestRenderRefusesWithNoMappedFinding(t *testing.T) { + dir := writeSession(t) + refs := `{"id":"R-001","finding":"F-999","session":"fixture-session","path":"a.ts","role":"owner","status":"proposed"}` + "\n" + if err := os.WriteFile(refsPath(dir), []byte(refs), 0o644); err != nil { + t.Fatal(err) + } + _, err := Render(dir) + if !errors.Is(err, ErrNoMappedFindings) { + t.Fatalf("err = %v", err) + } +} + +func TestRenderHintsMissingArtefacts(t *testing.T) { + dir := writeSession(t) + if _, err := Render(dir); err == nil || !strings.Contains(err.Error(), "map -ingest") { + t.Fatalf("missing refs: %v", err) + } + if _, err := Ingest(dir, repoPath, strings.NewReader(answer(goodRef))); err != nil { + t.Fatal(err) + } + os.Remove(filepath.Join(dir, session.TimelineFile)) + if _, err := Render(dir); err == nil || !strings.Contains(err.Error(), "merge") { + t.Fatalf("missing timeline: %v", err) + } + os.Remove(filepath.Join(dir, session.FindingsFile)) + if _, err := Render(dir); err == nil || !strings.Contains(err.Error(), "analyze -ingest") { + t.Fatalf("missing findings: %v", err) + } +} + +// The render neither opens nor writes the repository: it works with the +// repository gone. +func TestRenderNeedsNoRepository(t *testing.T) { + repo := t.TempDir() + if err := os.WriteFile(filepath.Join(repo, "a.ts"), []byte("x\n"), 0o644); err != nil { + t.Fatal(err) + } + dir := writeSession(t) + if _, err := Ingest(dir, repo, strings.NewReader(answer(strings.Replace(strings.Replace(goodRef, "src/settings/ProfileForm.tsx", "a.ts", 1), `"line":46`, `"line":1`, 1)))); err != nil { + t.Fatal(err) + } + os.RemoveAll(repo) + if _, err := Render(dir); err != nil { + t.Fatalf("render with the repository gone: %v", err) + } +} + +func TestRenderEscapesUntrustedText(t *testing.T) { + fnd := `{"id":"F-001","t":22,"type":"bug","severity":3,"mode":"A","quote":"[x](http://h) hi","evidence":["utt-004"],"ui":{"selector":"[data-testid=a]` + "`" + `b","route":"#g"},"status":"unverified"}` + "\n" + + `{"kind":"verdict","finding":"F-001","verdict":"confirmed","at":"2026-09-12"}` + "\n" + dir := writeSession(t, session.FindingsFile, fnd) + if _, err := Ingest(dir, repoPath, strings.NewReader(answer(goodRef))); err != nil { + t.Fatal(err) + } + got, err := Render(dir) + if err != nil { + t.Fatal(err) + } + if strings.Contains(got, "[x](http://h)") { + t.Fatal("an active link survived in the quote") + } + if strings.Contains(got, "`[data-testid=a]`b`") { + t.Fatal("a backtick closed the anchor's code span early") + } +} + +// An event's kind is attacker-authorable and is the one value the step verb is +// built from: it must be escaped like every other value, and capitalised by +// rune so a non-ASCII kind cannot corrupt the document's encoding. +func TestRenderEscapesAndEncodesEventKind(t *testing.T) { + tl := `{"t":20,"src":"event","id":"ev-003","payload":{"kind":"![x](http://h/b.png)","route":"#general"}}` + "\n" + + `{"t":21,"src":"event","id":"ev-004","payload":{"kind":"émettre","selector":"[data-testid=a]"}}` + "\n" + + `{"t":22,"src":"speech","id":"utt-004","payload":{"speaker":"P1","t1":28,"text":"x"}}` + "\n" + dir := writeSession(t, session.TimelineFile, tl) + if _, err := Ingest(dir, repoPath, strings.NewReader(answer(goodRef))); err != nil { + t.Fatal(err) + } + got, err := Render(dir) + if err != nil { + t.Fatal(err) + } + if strings.Contains(got, "![x](http://h/b.png)") { + t.Fatalf("an image beacon in an event kind survived:\n%s", got) + } + if !utf8.ValidString(got) { + t.Fatal("the render is not valid UTF-8") + } + if !strings.Contains(got, "Émettre `[data-testid=a]`.") { + t.Fatalf("non-ASCII kind not capitalised by rune:\n%s", got) + } +} + +func TestTitleCapsAndClauses(t *testing.T) { + long := strings.Repeat("word ", 40) + f := fixtureFinding("bug", long) + if got := title(f); len([]rune(got)) > maxTitleRunes || !strings.HasSuffix(got, "…") { + t.Fatalf("title = %q", got) + } + if got := title(fixtureFinding("", "First clause, second clause.")); got != "finding: First clause" { + t.Fatalf("title = %q", got) + } + if got := title(fixtureFinding("idea", "")); got != "idea" { + t.Fatalf("title = %q", got) + } + if got := title(fixtureFinding("bug", ".NET crashed, twice")); got != "bug: NET crashed" { + t.Fatalf("title = %q", got) + } +} + +func fixtureFinding(typ, quote string) analyze.Finding { + return analyze.Finding{ID: "F-001", Type: typ, Quote: quote} +} diff --git a/internal/coderefs/review.go b/internal/coderefs/review.go new file mode 100644 index 0000000..cd74543 --- /dev/null +++ b/internal/coderefs/review.go @@ -0,0 +1,353 @@ +package coderefs + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/REPPL/Testimony/internal/analyze" + "github.com/REPPL/Testimony/internal/session" +) + +// snippetContext is how many lines either side of a reference's line the +// interactive walk shows when the repository is given. +const snippetContext = 2 + +// ReviewOptions configures a `review -kind refs` run. It mirrors +// drafttests.ReviewOptions where the two agree; there is no edit payload, +// because a wrong path is rejected and a corrected one is ingested. +type ReviewOptions struct { + Dir string // session directory + Repo string // optional: the application's repository, read only, for the source snippet + Ref string // non-interactive: the reference to decide (R-NNN) + Decision string // non-interactive: accepted | rejected + In io.Reader // interactive input + Out io.Writer // status and prompts + IsTTY bool // whether In is an interactive terminal + Today string // ISO date stamped onto decisions (YYYY-MM-DD) +} + +// Review records human decisions on the session's references. With +// -ref/-decision it records one decision non-interactively; otherwise it walks +// the proposed references interactively (skipping cleanly when stdin is not a +// terminal, so CI never blocks). +func Review(opts ReviewOptions) error { + if fi, err := os.Stat(opts.Dir); err != nil || !fi.IsDir() { + if err == nil { + err = fmt.Errorf("%s is not a directory", opts.Dir) + } + return fmt.Errorf("session directory: %w", err) + } + refs, decisions, err := loadRefs(opts.Dir) + if err != nil { + return err + } + + if opts.Ref != "" || opts.Decision != "" { + // The CLI refuses this at the usage status; checked here too so the rule is + // a property of the API: the snippet is shown only by the walk, so a -repo + // alongside a single decision would otherwise be silently ignored. + if opts.Repo != "" { + return fmt.Errorf("-repo applies to the interactive walk, not to -ref/-decision") + } + return singleDecision(opts, refs) + } + + if !opts.IsTTY { + fmt.Fprintln(opts.Out, "review: stdin is not a terminal; skipping the interactive walk "+ + "(use -ref R-NNN -decision accepted|rejected for a single decision).") + return nil + } + return walk(opts, refs, decisions) +} + +// findingsFor reads the session's findings so the walk can show each +// reference's finding: its quote and anchor are what the reviewer judges the +// path against. They are context, not substance, so an unreadable or absent +// findings.jsonl degrades to placeholders rather than blocking a decision. +func findingsFor(dir string) []analyze.Finding { + _, findings, _, err := analyze.Load(dir) + if err != nil { + return nil + } + return findings +} + +// singleDecision records one decision non-interactively. +func singleDecision(opts ReviewOptions, refs []Ref) error { + if opts.Ref == "" { + return fmt.Errorf("-ref is required with -decision") + } + if opts.Decision == "" { + return fmt.Errorf("-decision is required with -ref") + } + decision, err := ParseDecisionFlag(opts.Decision) + if err != nil { + return err + } + target := refByID(refs, opts.Ref) + if target == nil { + return fmt.Errorf("reference %s not found", session.SafeText(opts.Ref)) + } + // The decision's Ref carries the reference's actual (raw) id, not the + // operator's clean flag value: EffectiveStatus keys on the raw id. + rec := Decision{Kind: "decision", Ref: target.ID, Decision: decision, At: opts.Today} + if err := AppendDecision(opts.Dir, rec, target); err != nil { + return err + } + fmt.Fprintln(opts.Out, describe(rec)) + return nil +} + +// errPersist marks an error that arose while writing a decision to disk, as +// distinct from the validation errors the walk raises for an unrecognised +// keystroke. A validation error is a retry situation; a failed append means the +// human's decision never reached refs.jsonl, so anything wrapping this sentinel +// aborts the walk and propagates to the CLI's non-zero exit. +var errPersist = errors.New("recording the decision failed") + +// walk interactively decides each proposed reference in id order. +func walk(opts ReviewOptions, refs []Ref, decisions []Decision) error { + eff := EffectiveStatus(refs, decisions) + var queue []Ref + for _, r := range refs { + if eff[r.ID].Value == "proposed" { + queue = append(queue, r) + } + } + sort.Slice(queue, func(i, j int) bool { return queue[i].ID < queue[j].ID }) + if len(queue) == 0 { + fmt.Fprintln(opts.Out, "No proposed references to review.") + return nil + } + findings := findingsFor(opts.Dir) + var root *repoRoot + if opts.Repo != "" { + if rr, err := newRepoRoot(opts.Repo); err == nil { + root = &rr + } else { + fmt.Fprintf(opts.Out, "(source snippets unavailable: %s)\n", session.SafeText(err.Error())) + } + } + + r := bufio.NewReader(opts.In) + for i, ref := range queue { + fmt.Fprintf(opts.Out, "\n(%d/%d) ", i+1, len(queue)) + printRef(opts.Out, ref, findingByID(findings, ref.Finding), root) + for { + fmt.Fprint(opts.Out, "[a]ccept [r]eject [s]kip [q]uit: ") + choice, err := readLine(r) + if err != nil { + fmt.Fprintln(opts.Out, "\n(end of input) stopping.") + return nil + } + done, quit, verr := applyChoice(opts, ref, choice) + if verr != nil { + if errors.Is(verr, errPersist) { + return verr + } + fmt.Fprintf(opts.Out, " %v\n", verr) + continue + } + if quit { + return nil + } + if done { + break + } + } + } + return nil +} + +// applyChoice handles one keystroke. done means advance to the next reference; +// quit means stop the walk. +func applyChoice(opts ReviewOptions, ref Ref, choice string) (done, quit bool, err error) { + trimmed := strings.TrimSpace(choice) + switch strings.ToLower(trimmed) { + case "a": + return true, false, record(opts, ref, Decision{Kind: "decision", Ref: ref.ID, Decision: "accepted", At: opts.Today}) + case "r": + return true, false, record(opts, ref, Decision{Kind: "decision", Ref: ref.ID, Decision: "rejected", At: opts.Today}) + case "s", "": + fmt.Fprintln(opts.Out, " skipped.") + return true, false, nil + case "q": + return false, true, nil + default: + return false, false, fmt.Errorf("unrecognised choice %q", trimmed) + } +} + +func record(opts ReviewOptions, judged Ref, rec Decision) error { + if err := AppendDecision(opts.Dir, rec, &judged); err != nil { + return fmt.Errorf("%w: %v", errPersist, err) + } + fmt.Fprintf(opts.Out, " %s\n", describe(rec)) + return nil +} + +// AppendDecision appends one decision record to refs.jsonl without touching any +// existing line (append-only; the latest decision wins for display). The +// reference line is never rewritten, which is what keeps its id, finding, +// session, and path unreachable by any later write. +// +// The dangerous part of the write lives once in session.AppendRecord, shared +// with the verdicts findings.jsonl holds and the decisions tests.jsonl holds; +// this function supplies the vocabulary and the target re-check. +// +// expect, when non-nil, is the reference the operator was shown when they made +// this decision. session.AppendRecord runs the Verify closure over the current +// references under its lock and refuses if the targeted id is gone or now names +// a different reference: a concurrent `map -ingest` may truncate-and-rewrite in +// the gap while the walk blocks on the operator (permitted until the first +// decision exists), and reference ids restart at R-001. +func AppendDecision(dir string, d Decision, expect *Ref) error { + b, err := json.Marshal(d) + if err != nil { + return err + } + a := session.Append{ + Path: filepath.Join(dir, session.RefsFile), + Record: b, + Label: "decision for " + session.SafeText(d.Ref), + Kind: "decision", + } + if expect != nil { + judged := *expect + a.Verify = func(current io.Reader) error { return verifyTarget(current, d, judged) } + } + return session.AppendRecord(a) +} + +// verifyTarget re-reads the references currently in the locked refs.jsonl and +// confirms the decision d still applies to the reference expect — the one the +// operator was shown. +func verifyTarget(current io.Reader, d Decision, expect Ref) error { + refs, _, err := ParseRecords(current, session.RefsFile) + if err != nil { + return err + } + cur := refByID(refs, d.Ref) + if cur == nil { + return fmt.Errorf("reference %s is no longer in %s; it changed since review started — re-run `testimony review -kind refs`", + session.SafeText(d.Ref), session.RefsFile) + } + if !SameIdentity(*cur, expect) { + return fmt.Errorf("reference %s changed since review started (a re-ingest rewrote %s); re-run `testimony review -kind refs` before recording a decision", + session.SafeText(d.Ref), session.RefsFile) + } + return nil +} + +// printRef writes a reference to the operator's terminal: the finding's quote +// and anchor, then the path, line, and role, then, when the repository is +// given, the lines around the reference's line. Every attacker-influenceable +// field is passed through session.SafeText first, and presence is decided on +// the rendered form. +func printRef(w io.Writer, r Ref, f *analyze.Finding, root *repoRoot) { + loc := orPlaceholder(r.Path, "no path") + if r.Line > 0 { + loc = fmt.Sprintf("%s:%d", loc, r.Line) + } + fmt.Fprintf(w, "%s — %s (%s), from %s\n", + session.SafeText(r.ID), loc, orPlaceholder(r.Role, "no role"), session.SafeText(r.Finding)) + if f != nil { + fmt.Fprintf(w, " “%s”\n", orPlaceholder(f.Quote, "no quote")) + fmt.Fprintf(w, " anchor: %s [%s]\n", anchorText(*f), clock(f.T)) + } else { + fmt.Fprintln(w, " (finding not readable)") + } + if root != nil && r.Line > 0 { + for _, l := range snippet(*root, r) { + fmt.Fprintf(w, " %s\n", l) + } + } +} + +// anchorText renders the finding's selector and route for the terminal. +func anchorText(f analyze.Finding) string { + var parts []string + if f.UI != nil { + if sel := session.SafeText(f.UI.Selector); strings.TrimSpace(sel) != "" { + parts = append(parts, sel) + } + if route := session.SafeText(f.UI.Route); strings.TrimSpace(route) != "" { + parts = append(parts, route) + } + } + if len(parts) == 0 { + return "none" + } + return strings.Join(parts, " on ") +} + +// snippet returns the numbered source lines around r.Line, or a one-line note +// when the file cannot be shown. The path is re-checked through the same +// containment rule ingest applies, so a hand-edited refs.jsonl cannot make the +// walk open a file outside the repository, and the read is bounded the same way. +func snippet(root repoRoot, r Ref) []string { + full, _, err := root.checkPath(r.Path) + if err != nil { + return []string{"(source unavailable: " + session.SafeText(err.Error()) + ")"} + } + f, err := session.OpenFileNoFollowRead(full) + if err != nil { + return []string{"(source unavailable: " + session.SafeText(err.Error()) + ")"} + } + defer f.Close() + data, err := io.ReadAll(io.LimitReader(f, session.MaxJSONLBytes+1)) + if err != nil || len(data) > session.MaxJSONLBytes { + return []string{"(source unavailable: file too large to show)"} + } + lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") + if r.Line > len(lines) { + return []string{fmt.Sprintf("(source unavailable: line %d is past the end of the file, %d lines)", r.Line, len(lines))} + } + lo, hi := r.Line-snippetContext, r.Line+snippetContext + if lo < 1 { + lo = 1 + } + if hi > len(lines) { + hi = len(lines) + } + var out []string + for n := lo; n <= hi; n++ { + mark := " " + if n == r.Line { + mark = ">" + } + out = append(out, fmt.Sprintf("%s %4d %s", mark, n, session.SafeText(lines[n-1]))) + } + return out +} + +// describe echoes a recorded decision to the operator's terminal. +func describe(d Decision) string { + return fmt.Sprintf("recorded: %s %s (%s)", + session.SafeText(d.Ref), session.SafeText(d.Decision), session.SafeText(d.At)) +} + +// orPlaceholder renders untrusted text for the terminal, falling back to a +// placeholder when it renders as nothing. +func orPlaceholder(s, placeholder string) string { + t := session.SafeText(s) + if strings.TrimSpace(t) == "" { + return placeholder + } + return t +} + +func readLine(r *bufio.Reader) (string, error) { + line, err := r.ReadString('\n') + if err != nil && line == "" { + return "", err + } + return line, nil +} diff --git a/internal/coderefs/review_test.go b/internal/coderefs/review_test.go new file mode 100644 index 0000000..806f650 --- /dev/null +++ b/internal/coderefs/review_test.go @@ -0,0 +1,240 @@ +package coderefs + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +// ingestTwo lays a session whose refs.jsonl holds two proposed references for +// F-001, which is what lets the walk exercise both decisions. +func ingestTwo(t *testing.T) string { + t.Helper() + dir := writeSession(t) + second := strings.Replace(strings.Replace(goodRef, `"R-001"`, `"R-002"`, 1), "ProfileForm.tsx\",\"line\":46", "saveProfile.ts\",\"line\":12", 1) + second = strings.Replace(second, `"owner"`, `"handler"`, 1) + if _, err := Ingest(dir, repoPath, strings.NewReader(answer(goodRef, second))); err != nil { + t.Fatalf("Ingest: %v", err) + } + return dir +} + +// refLines returns only the reference (non-decision) lines. +func refLines(t *testing.T, dir string) []string { + t.Helper() + b, err := os.ReadFile(refsPath(dir)) + if err != nil { + t.Fatal(err) + } + var out []string + for _, l := range strings.Split(strings.TrimRight(string(b), "\n"), "\n") { + if !strings.Contains(l, `"kind":"decision"`) { + out = append(out, l) + } + } + return out +} + +func effective(t *testing.T, dir string) map[string]Status { + t.Helper() + refs, decisions, err := Load(dir) + if err != nil { + t.Fatal(err) + } + return EffectiveStatus(refs, decisions) +} + +// TestDecisionIsAppendedAndRefLinesUnchanged is AC4, asserted byte for byte: a +// decision is a new line, the reference line is never rewritten, and after two +// decisions the reference's finding, session, and path are exactly what ingest +// wrote. +func TestDecisionIsAppendedAndRefLinesUnchanged(t *testing.T) { + dir := ingestTwo(t) + before := refLines(t, dir) + var out bytes.Buffer + for _, d := range []string{"rejected", "accepted"} { + if err := Review(ReviewOptions{Dir: dir, Ref: "R-001", Decision: d, Out: &out, Today: "2026-09-16"}); err != nil { + t.Fatalf("Review %s: %v", d, err) + } + } + after := refLines(t, dir) + if strings.Join(before, "\n") != strings.Join(after, "\n") { + t.Fatalf("reference lines changed:\nbefore %v\nafter %v", before, after) + } + if st := effective(t, dir)["R-001"]; st.Value != "accepted" || st.At != "2026-09-16" { + t.Fatalf("R-001 = %+v, want the last decision", st) + } + if !strings.Contains(out.String(), "recorded: R-001 rejected (2026-09-16)") || !strings.Contains(out.String(), "recorded: R-001 accepted (2026-09-16)") { + t.Fatalf("output = %q", out.String()) + } + b, _ := os.ReadFile(refsPath(dir)) + if !strings.HasSuffix(string(b), `{"kind":"decision","ref":"R-001","decision":"accepted","at":"2026-09-16"}`+"\n") { + t.Fatalf("refs.jsonl tail = %q", b) + } +} + +func TestSingleDecisionErrors(t *testing.T) { + dir := ingestTwo(t) + for name, o := range map[string]ReviewOptions{ + "ref without decision": {Ref: "R-001"}, + "decision without ref": {Decision: "accepted"}, + "edited is not allowed": {Ref: "R-001", Decision: "edited"}, + "unknown reference": {Ref: "R-009", Decision: "accepted"}, + "decision out of enum": {Ref: "R-001", Decision: "maybe"}, + "repo with a single decision": {Ref: "R-001", Decision: "accepted", Repo: repoPath}, + } { + o.Dir, o.Out, o.Today = dir, &bytes.Buffer{}, "2026-09-16" + if err := Review(o); err == nil { + t.Errorf("%s: accepted", name) + } + } + if err := Review(ReviewOptions{Dir: dir + "/missing", Ref: "R-001", Decision: "accepted", Out: &bytes.Buffer{}}); err == nil || !strings.Contains(err.Error(), "session directory") { + t.Fatalf("missing dir: %v", err) + } + if err := Review(ReviewOptions{Dir: writeSession(t), Ref: "R-001", Decision: "accepted", Out: &bytes.Buffer{}}); err == nil || !strings.Contains(err.Error(), "map -ingest") { + t.Fatalf("no refs.jsonl: %v", err) + } +} + +// The decision is bound to the reference the operator was shown: a re-ingest +// that slid a different reference under the same id is refused under the lock. +func TestAppendDecisionRefusesWhenRefChangedUnderTheLock(t *testing.T) { + dir := ingestTwo(t) + shown := ref("R-001") + shown.Line = 45 // not what is on disk + err := AppendDecision(dir, Decision{Kind: "decision", Ref: "R-001", Decision: "accepted", At: "2026-09-16"}, &shown) + if err == nil || !strings.Contains(err.Error(), "changed since review started") { + t.Fatalf("err = %v", err) + } + gone := ref("R-009") + err = AppendDecision(dir, Decision{Kind: "decision", Ref: "R-009", Decision: "accepted", At: "2026-09-16"}, &gone) + if err == nil || !strings.Contains(err.Error(), "no longer in") { + t.Fatalf("err = %v", err) + } + if len(refLines(t, dir)) != 2 || len(effective(t, dir)) != 2 { + t.Fatal("a refused decision changed the file") + } +} + +func TestInteractiveGatedWhenNotTTY(t *testing.T) { + dir := ingestTwo(t) + var out bytes.Buffer + if err := Review(ReviewOptions{Dir: dir, In: strings.NewReader("a\n"), Out: &out, IsTTY: false, Today: "2026-09-16"}); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "stdin is not a terminal") { + t.Fatalf("output = %q", out.String()) + } + if st := effective(t, dir)["R-001"].Value; st != "proposed" { + t.Fatal("a non-TTY run recorded a decision") + } +} + +// TestInteractiveWalk: accept, then an unrecognised key re-prompts, then reject; +// the walk shows the finding's quote and anchor and, with the repository given, +// the source around the line. +func TestInteractiveWalk(t *testing.T) { + dir := ingestTwo(t) + var out bytes.Buffer + err := Review(ReviewOptions{Dir: dir, Repo: repoPath, In: strings.NewReader("a\nx\nr\n"), Out: &out, IsTTY: true, Today: "2026-09-16"}) + if err != nil { + t.Fatalf("walk: %v", err) + } + o := out.String() + for _, want := range []string{ + "(1/2) R-001 — src/settings/ProfileForm.tsx:46 (owner), from F-001", + "“I clicked save and nothing happened”", + "anchor: [data-testid=save-btn] on #general [00:22]", + `> 46 ` + " + + ); +} diff --git a/internal/coderefs/testdata/repo/src/settings/saveProfile.ts b/internal/coderefs/testdata/repo/src/settings/saveProfile.ts new file mode 100644 index 0000000..f467c93 --- /dev/null +++ b/internal/coderefs/testdata/repo/src/settings/saveProfile.ts @@ -0,0 +1,13 @@ +// saveProfile persists the display name. +// Fixture for internal/coderefs. +export async function saveProfile(name: string) { + const body = JSON.stringify({ name }); + // line 5 + // line 6 + // line 7 + // line 8 + // line 9 + // line 10 + // The intentional flaw: nothing on screen changes when this resolves. + await fetch('/api/profile', { method: 'POST', body }); +} diff --git a/internal/coderefs/testdata/timeline.jsonl b/internal/coderefs/testdata/timeline.jsonl new file mode 100644 index 0000000..32d1bf9 --- /dev/null +++ b/internal/coderefs/testdata/timeline.jsonl @@ -0,0 +1,13 @@ +{"t":2,"src":"speech","id":"utt-001","payload":{"speaker":"P1","t1":5.5,"text":"Session start. I'm looking at the settings prototype for the first time."}} +{"t":8,"src":"speech","id":"utt-002","payload":{"speaker":"P1","t1":12.5,"text":"Okay, three tabs, General is open. I'll change my display name first."}} +{"t":9.5,"src":"event","id":"ev-001","payload":{"kind":"click","route":"#general","selector":"[data-testid=display-name]","text":"Display name"}} +{"t":14.8,"src":"event","id":"ev-002","payload":{"kind":"input","route":"#general","selector":"[data-testid=display-name]","value":"Alice"}} +{"t":16,"src":"speech","id":"utt-003","payload":{"speaker":"P1","t1":21,"text":"Typing feels fine. Now I expect this save button to confirm somehow."}} +{"t":19.2,"src":"event","id":"ev-003","payload":{"kind":"click","route":"#general","selector":"[data-testid=save-btn]","text":"Save"}} +{"t":22,"src":"speech","id":"utt-004","payload":{"speaker":"P1","t1":28,"text":"Hm. I clicked save and nothing happened. No message, no spinner."}} +{"t":24.1,"src":"event","id":"ev-004","payload":{"kind":"click","route":"#general","selector":"[data-testid=save-btn]","text":"Save"}} +{"t":31,"src":"speech","id":"utt-005","payload":{"speaker":"P1","t1":36,"text":"I'll switch to Appearance. The tab switch is instant, that's nice."}} +{"t":32,"src":"event","id":"ev-005","payload":{"kind":"click","route":"#general","selector":"[data-testid=tab-appearance]","text":"Appearance"}} +{"t":38,"src":"speech","id":"utt-006","payload":{"speaker":"P1","t1":45,"text":"Oh, I like this dark mode toggle. Immediate feedback."}} +{"t":40.5,"src":"event","id":"ev-006","payload":{"kind":"click","route":"#appearance","selector":"[data-testid=theme-toggle]","text":"Dark mode"}} +{"t":48,"src":"speech","id":"utt-007","payload":{"speaker":"P1","t1":54,"text":"Turning it back. The label just says dark mode whether it's on or off."}} diff --git a/internal/review/review.go b/internal/review/review.go index beef723..00163c6 100644 --- a/internal/review/review.go +++ b/internal/review/review.go @@ -1,13 +1,15 @@ -// Package review is the pipeline's one human-decision surface, across both -// record families. Its own half records verdicts on candidate findings: a +// Package review is the pipeline's one human-decision surface, across all +// three record families. Its own half records verdicts on candidate findings: a // verdict is appended to findings.jsonl as a separate, non-destructive record // (never an in-place rewrite of the finding), so the finding's birth state and // the full verdict history survive as the precision measure the method stands on // (architecture note §2; itd-2 press release). Options.Kind dispatches the tests // half to internal/drafttests, which records an accept / edit / reject decision -// on each drafted regression test the same appended way — one verb for the whole -// pipeline, with the vocabularies kept per-kind because "edited" carries a -// payload no verdict ever does (ADR 0001). Interactive review is gated on stdin +// on each drafted regression test the same appended way, and the refs half to +// internal/coderefs, which records an accept / reject decision on each proposed +// code reference — one verb for the whole pipeline, with the vocabularies kept +// per-kind because "edited" carries a payload no verdict or reference decision +// ever does (ADR 0001). Interactive review is gated on stdin // being a character device so a redirected or piped run (CI) never blocks; a // single decision can also be recorded non-interactively on either side. package review @@ -26,6 +28,7 @@ import ( "strings" "github.com/REPPL/Testimony/internal/analyze" + "github.com/REPPL/Testimony/internal/coderefs" "github.com/REPPL/Testimony/internal/drafttests" "github.com/REPPL/Testimony/internal/session" ) @@ -41,6 +44,7 @@ const maxClockSeconds = 1e9 const ( KindFindings = "findings" KindTests = "tests" + KindRefs = "refs" ) // ParseKindFlag validates a -kind flag value against the closed set, so the CLI @@ -53,22 +57,28 @@ func ParseKindFlag(s string) (string, error) { return KindFindings, nil case KindTests: return KindTests, nil + case KindRefs: + return KindRefs, nil } - return "", fmt.Errorf("invalid kind %q (want findings|tests)", s) + return "", fmt.Errorf("invalid kind %q (want findings|tests|refs)", s) } // Options configures a review run. The Finding/Verdict pair belongs to -// KindFindings and the Test/Decision/EditIn set to KindTests; the CLI refuses a -// flag from the other family at the usage status, and Run refuses it too, so the -// pairing is a property of this API rather than of one caller's invariants. +// KindFindings, the Test/Decision/EditIn set to KindTests, and the +// Ref/Decision/Repo set to KindRefs (Decision is shared by the two kinds that +// take one); the CLI refuses a flag from another family at the usage status, and +// Run refuses it too, so the pairing is a property of this API rather than of +// one caller's invariants. type Options struct { Dir string // session directory - Kind string // record family: "findings" (the default) or "tests" + Kind string // record family: "findings" (the default), "tests", or "refs" Finding string // non-interactive: the finding to judge (F-NNN) Verdict string // non-interactive: confirmed | rejected | duplicate-of-F-NNN Test string // non-interactive, -kind tests: the draft to decide (T-NNN) - Decision string // non-interactive, -kind tests: accepted | edited | rejected + Decision string // non-interactive, -kind tests or refs: accepted | edited | rejected (edited is tests only) EditIn io.Reader // -kind tests, with Decision "edited": the replacement fields as a JSON object + Ref string // non-interactive, -kind refs: the reference to decide (R-NNN) + Repo string // -kind refs, optional: the application's repository, read only, for the source snippet In io.Reader // interactive input Out io.Writer // status and prompts IsTTY bool // whether In is an interactive terminal @@ -76,18 +86,40 @@ type Options struct { } // Run records human decisions for the session. With -kind tests it delegates to -// the drafting layer's walk; otherwise, with -finding/-verdict it records one -// verdict non-interactively, and with neither it walks the unverified findings -// interactively (skipping cleanly when stdin is not a terminal). +// the drafting layer's walk and with -kind refs to the mapping layer's; +// otherwise, with -finding/-verdict it records one verdict non-interactively, +// and with neither it walks the unverified findings interactively (skipping +// cleanly when stdin is not a terminal). func Run(opts Options) error { kind, err := ParseKindFlag(opts.Kind) if err != nil { return err } - // A flag belonging to the other record family is a wrong invocation, not a + // A flag belonging to another record family is a wrong invocation, not a // silently ignored one: a caller who typed -verdict against -kind tests meant // something this walk cannot do, and recording nothing while exiting 0 would // let a script believe the decision landed. + if kind == KindRefs { + if opts.Finding != "" || opts.Verdict != "" { + return fmt.Errorf("-finding and -verdict apply to -kind findings, not -kind refs") + } + if opts.Test != "" || opts.EditIn != nil { + return fmt.Errorf("-test and -edit apply to -kind tests, not -kind refs") + } + return coderefs.Review(coderefs.ReviewOptions{ + Dir: opts.Dir, + Repo: opts.Repo, + Ref: opts.Ref, + Decision: opts.Decision, + In: opts.In, + Out: opts.Out, + IsTTY: opts.IsTTY, + Today: opts.Today, + }) + } + if opts.Ref != "" || opts.Repo != "" { + return fmt.Errorf("-ref and -repo apply to -kind refs, not -kind %s", kind) + } if kind == KindTests { if opts.Finding != "" || opts.Verdict != "" { return fmt.Errorf("-finding and -verdict apply to -kind findings, not -kind tests") diff --git a/internal/review/review_test.go b/internal/review/review_test.go index 45a3fc1..80dee74 100644 --- a/internal/review/review_test.go +++ b/internal/review/review_test.go @@ -982,6 +982,41 @@ func TestRunDispatchesKindTests(t *testing.T) { } } +const refFixture = `{"id":"R-001","finding":"F-001","session":"s","path":"src/a.ts","line":1,"role":"owner","status":"proposed"} +` + +// TestRunDispatchesKindRefs: the third record family reaches the mapping +// layer's decision path and appends its record to refs.jsonl, leaving both +// other files untouched. +func TestRunDispatchesKindRefs(t *testing.T) { + dir := writeSession(t) + if err := os.WriteFile(filepath.Join(dir, session.RefsFile), []byte(refFixture), 0o644); err != nil { + t.Fatalf("write refs: %v", err) + } + before := findingLines(t, dir) + var out bytes.Buffer + err := Run(Options{Dir: dir, Kind: KindRefs, Ref: "R-001", Decision: "rejected", Out: &out, Today: "2026-09-16"}) + if err != nil { + t.Fatalf("Run: %v", err) + } + if got := out.String(); got != "recorded: R-001 rejected (2026-09-16)\n" { + t.Fatalf("echo = %q", got) + } + b, rerr := os.ReadFile(filepath.Join(dir, session.RefsFile)) + if rerr != nil { + t.Fatalf("read refs: %v", rerr) + } + if !strings.HasPrefix(string(b), refFixture) || !strings.HasSuffix(string(b), `{"kind":"decision","ref":"R-001","decision":"rejected","at":"2026-09-16"}`+"\n") { + t.Fatalf("refs.jsonl = %q", b) + } + if strings.Join(before, "\n") != strings.Join(findingLines(t, dir), "\n") { + t.Fatal("a refs-side decision modified findings.jsonl") + } + if _, err := os.Stat(filepath.Join(dir, session.TestsFile)); !os.IsNotExist(err) { + t.Fatal("a refs-side decision created tests.jsonl") + } +} + // TestRunRefusesCrossFamilyFlags: a flag belonging to the other record family is // a wrong invocation, not a silently ignored one — recording nothing while // exiting 0 would let a script believe the decision landed. @@ -995,6 +1030,11 @@ func TestRunRefusesCrossFamilyFlags(t *testing.T) { {"test with kind findings", Options{Test: "T-001", Decision: "accepted"}, "-test, -decision and -edit apply to -kind tests"}, {"edit with kind findings", Options{EditIn: strings.NewReader("{}")}, "-test, -decision and -edit apply to -kind tests"}, {"unknown kind", Options{Kind: "verdicts"}, "invalid kind"}, + {"verdict with kind refs", Options{Kind: KindRefs, Finding: "F-001", Verdict: "confirmed"}, "-finding and -verdict apply to -kind findings, not -kind refs"}, + {"test with kind refs", Options{Kind: KindRefs, Test: "T-001", Decision: "accepted"}, "-test and -edit apply to -kind tests, not -kind refs"}, + {"edit with kind refs", Options{Kind: KindRefs, EditIn: strings.NewReader("{}")}, "-test and -edit apply to -kind tests, not -kind refs"}, + {"ref with kind findings", Options{Ref: "R-001", Decision: "accepted"}, "-ref and -repo apply to -kind refs, not -kind findings"}, + {"repo with kind tests", Options{Kind: KindTests, Repo: "."}, "-ref and -repo apply to -kind refs, not -kind tests"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/internal/session/records.go b/internal/session/records.go index 0e9bbde..eb71e67 100644 --- a/internal/session/records.go +++ b/internal/session/records.go @@ -11,13 +11,14 @@ import ( // Append is one record appended to a session JSONL file. // -// Two session artefacts hold a machine record plus appended human records — -// findings.jsonl (findings plus verdicts) and tests.jsonl (test drafts plus -// decisions) — and both are appended to under the same hazards: a planted +// Three session artefacts hold a machine record plus appended human records — +// findings.jsonl (findings plus verdicts), tests.jsonl (test drafts plus +// decisions), and refs.jsonl (code references plus decisions) — and all are +// appended to under the same hazards: a planted // symlink or FIFO at the path, a concurrent writer racing the measure-then-write // sequence, an unterminated last line that a blind append would fuse onto, a // short write leaving a newline-less fragment, and the read-side size invariants -// every reader scans to. AppendRecord holds that logic once so the two callers +// every reader scans to. AppendRecord holds that logic once so the callers // cannot drift apart; the vocabulary of each record family stays with its own // package. type Append struct { diff --git a/internal/session/session.go b/internal/session/session.go index 7b62c0d..a62c72f 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -59,6 +59,7 @@ const ( TimelineFile = "timeline.jsonl" FindingsFile = "findings.jsonl" TestsFile = "tests.jsonl" + RefsFile = "refs.jsonl" ReportFile = "report.md" )