diff --git a/CHANGELOG.md b/CHANGELOG.md index 59b813be..26aa1acc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -100,6 +100,39 @@ Until `1.0.0`, minor releases may include breaking changes `matcherAppliesToMember` applies a qualified matcher when it equals either name. No surface passes a member yet; that is ADR-0059's federation work. +### Added + +- **`@adrkit/core` exports `todayUtcDate()`, the `--as-of` default.** When + `--as-of` is absent, `adr queue`, the queue Action, and `adr new` now take + today's UTC date from this one function instead of three inline copies. So + does the unpublished `@adrkit/sdk`, and the decision-portal Action now takes + its default from the SDK rather than computing a fifth copy. +- **The decision-portal Action's `as-of` accepts an ISO datetime with an + explicit timezone**, as `adr queue --as-of` does, and resolves it to the UTC + calendar date. A timezone-less datetime is still refused. Underneath, the + unpublished `@adrkit/sdk`'s `queue({ asOf })` takes the same grammar and + echoes the resolved date. A library consumer can now default the + queue exactly as CI does, instead of risking a local-date default that + disagrees near midnight + ([ADR-0031](docs/adr/0031-publish-a-narrow-consumer-sdk-as-the-contract-and-document-the-cli-json-as-its-s.md) + action item 8). No output changes. +- **`@adrkit/core` owns the `adr explain`, `adr lint`, and `adr new` JSON + shapes.** `buildExplainReport`/`formatExplainReportJson`, + `buildLintReport`/`formatLintReportJson`, and + `buildNewAdrReport`/`formatNewAdrReportJson` join `formatQueueReportJson` and + `renderJsonGraph`, and the CLI prints their output (ADR-0031 action item 4). + The bytes are unchanged, which goldens captured before the change pin. +- **A conformance fixture for the CLI JSON contract.** A fixed corpus with + golden `adr queue --format json` and `adr check --json` output, at + `packages/core/test/conformance/golden/`. The CLI and `@adrkit/sdk` are + tested against it, and a downstream repository can run its `manifest.json` + against the `adr` it pins + ([ADR-0030](docs/adr/0030-keep-extension-surfaces-that-carry-a-dependency-tree-outside-this-repository.md) + clause 5). +- **`docs/RELEASING.md` documents the two consumer contracts**: the + `@adrkit/sdk` surface (still unpublished) and the CLI JSON, each with the + additive-only obligation stated (ADR-0031 action item 5). + ## [0.19.0] - 2026-10-10 This release ships the decision portal: a third, build-only nested Action, diff --git a/docs/RELEASING.md b/docs/RELEASING.md index fb66c333..dd0cba25 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -98,6 +98,61 @@ renames, or reinterprets that field breaks badges in other people's repositories; call it out in the release notes and keep the v1 field emitted for at least one minor release. +## Consumer contracts: `@adrkit/sdk` and the CLI JSON + +[ADR-0031](adr/0031-publish-a-narrow-consumer-sdk-as-the-contract-and-document-the-cli-json-as-its-s.md) +names two contracts for code outside this repository, and attaches the +additive-only obligation of ADR-0029 clause 11 to them instead of to +`@adrkit/core`. Core's exports are the engine: the surface test pins them so +each change is deliberate, but a downstream surface should bind to one of these +two. + +**`@adrkit/sdk`** is the typed library contract: `openDecisions()` and a +`DecisionSet` handle (`records`, `issues`, `get`, `queue`, `graph`, +`governing`), with its own declared types and no re-export of core's. The +surface, its counts, and the reasoning behind each entry are in +[`docs/sdk-surface.md`](sdk-surface.md). It is implemented and **not +published**. It stays at `0.0.0` with no `exports` or `files` and is absent +from `RELEASE_PACKAGES`, until a later record authorizes publication +(ADR-0031 clause 8, with the gates in +[ADR-0053](adr/0053-implement-adrkit-sdk-over-core-s-kernels-and-accept-an-in-repo-consumer-as-its-r.md) +decision 9). Once published it is versioned independently, as +`@adrkit/spec-kit` is, and is additive-only: fields, options, and methods may +be added, but none is removed, renamed, or reinterpreted. A core change reaches +SDK consumers only through a deliberate edit to its mapping layer. + +**The CLI JSON** is the language-agnostic contract. Every `--json` shape below +is a type core owns and serializes with `JSON.stringify(value, null, 2)` plus a +trailing newline: + +| Command | Shape | Formatter | +|---|---|---| +| `adr queue --format json` | `QueueReport` v1 ([`queue-report.md`](../specs/007-arb-queue/contracts/queue-report.md)) | `formatQueueReportJson` | +| `adr check --json` | `CheckOutcome` | serialized directly | +| `adr explain --json` | `ExplainReport` | `formatExplainReportJson` | +| `adr lint --json` | `LintReport` (`{ checked, findings }`) | `formatLintReportJson` | +| `adr new --json` | `NewAdrReport` (`{ id, path }`) | `formatNewAdrReportJson` | +| `adr graph --format json` | `{ nodes, edges }` | `renderJsonGraph` | + +It is additive-only too. A key may be added. Removing, renaming, or +reinterpreting a key, or reclassifying an exit code, is a break. One +exception is admitted, by +[ADR-0059](adr/0059-aggregate-decision-corpora-across-an-organization-s-repositories-as-a-pinned-rea.md) +clause 5: `adr lint --json` reports a log-qualified `relatesTo` or +`conflictsWith` whose log is absent as `federated-ref-unresolved`. When +`--as-of` is absent, `adr queue` uses core's `todayUtcDate()`, the one +implementation of the default in +[`cli-contract.md` §As-Of Resolution](../specs/007-arb-queue/contracts/cli-contract.md). + +Goldens pin these shapes. The conformance fixture, +[`packages/core/test/conformance/golden/`](../packages/core/test/conformance/golden/README.md), +is a fixed corpus with golden `adr queue` and `adr check` output (ADR-0030 +clause 5). The CLI and the SDK are both tested against it, and a downstream +repository can run its `manifest.json` against the `adr` version it pins. +`packages/cli/test/fixtures/json-report/` pins `explain`, `lint`, and `new` +the same way. A change that has to regenerate a golden changes the contract. +Say so in the CHANGELOG and the release notes. + ## Local release simulation From a clean checkout: diff --git a/docs/adr/0031-publish-a-narrow-consumer-sdk-as-the-contract-and-document-the-cli-json-as-its-s.md b/docs/adr/0031-publish-a-narrow-consumer-sdk-as-the-contract-and-document-the-cli-json-as-its-s.md index d74c4c22..66088f76 100644 --- a/docs/adr/0031-publish-a-narrow-consumer-sdk-as-the-contract-and-document-the-cli-json-as-its-s.md +++ b/docs/adr/0031-publish-a-narrow-consumer-sdk-as-the-contract-and-document-the-cli-json-as-its-s.md @@ -264,12 +264,23 @@ recorded as its own wrongness signal. object shapes structurally identical to a core type. Measuring it is what revealed that this record's own wrongness criterion tested the wrong property — see *Consequences*, where the criterion is replaced and the correction recorded.** -4. [ ] Converge `explain`, `lint`, and `new` `--json` onto core formatters, as `queue` is, - so the two modes describe the same shapes. -5. [ ] Document the CLI JSON contract and the SDK surface in `docs/RELEASING.md`, with the - additive-only obligation stated at both. -6. [ ] Build the conformance fixture ADR-0030 action item 5 names, as the SDK's own test - fixture, so one artifact discharges both records. +4. [x] Converge `explain`, `lint`, and `new` `--json` onto core formatters, as `queue` is, + so the two modes describe the same shapes. **Done 2026-10-10:** `buildExplainReport` / + `formatExplainReportJson`, `buildLintReport` / `formatLintReportJson`, and + `buildNewAdrReport` / `formatNewAdrReportJson` in `packages/core/src/report/`, exported + from the entry point; the CLI supplies inputs only. Output is byte-for-byte unchanged: + goldens captured from the inline assembly before it was removed + (`packages/cli/test/json-report.test.ts`), and a before/after comparison of the old and + new CLI over this repository's corpus and the SDK fixtures. +5. [x] Document the CLI JSON contract and the SDK surface in `docs/RELEASING.md`, with the + additive-only obligation stated at both. **Done 2026-10-10:** "Consumer contracts: + `@adrkit/sdk` and the CLI JSON", linking `docs/sdk-surface.md`. +6. [x] Build the conformance fixture ADR-0030 action item 5 names, as the SDK's own test + fixture, so one artifact discharges both records. **Done 2026-10-10:** + `packages/core/test/conformance/golden/` (a fixed corpus, `manifest.json`, `queue.json`, + `check.json`). `packages/cli/test/conformance.test.ts` requires the CLI to reproduce both + goldens byte for byte, and `packages/sdk/test/conformance.test.ts` runs the SDK against + them. Publishing it to downstream consumers rides on the package publication records. 7. [~] **Close the `--as-of` resolution gap: `queue`'s date resolver is a contract that lives outside the contract.** `resolveAsOf` in `packages/cli/src/queue.ts` is ~25 lines implementing `cli-contract.md §As-Of Resolution` — bare `YYYY-MM-DD` or an ISO datetime with @@ -301,7 +312,7 @@ recorded as its own wrongness signal. boundary — the same divergence class this item was opened to close. Carried as a successor item rather than marked closed. -8. [ ] Export the absent-input clause of `cli-contract.md` §As-Of Resolution — successor to +8. [x] Export the absent-input clause of `cli-contract.md` §As-Of Resolution — successor to item 7. `new Date().toISOString().slice(0,10)` is inlined at `packages/cli/src/queue.ts`, `packages/ci/src/queue-action-entrypoint.ts`, and `packages/core/src/scaffold/new.ts`. Until it is exported, "the queue as of now" — the @@ -324,3 +335,13 @@ recorded as its own wrongness signal. answer, it was an unreachable rule, so no behavioural assertion could have detected it. A suite of correctness cases alone would have reported this contract gap as fully covered. + + **Done 2026-10-10.** `todayUtcDate()` lives in `packages/core/src/queue/as-of.ts` beside + `resolveAsOf`, is exported from the entry point, and is consumed by `adr queue`, the queue + Action (bundle regenerated), `adr new`'s scaffold, and the SDK's `queue()`, which was the + fourth copy. A test in `packages/core/test/queue/as-of.test.ts` fails if any of the four + spells the expression again; observed failing by restoring it in the SDK. The static portal, + which may import only `@adrkit/sdk`, takes the default the SDK echoes in `queue().asOf` + rather than computing its own (a second test bans the expression under + `packages/portal/src`). A caller-supplied `asOf` goes through the same method, which now takes + `adr queue --as-of`'s whole grammar and echoes the resolved UTC date. diff --git a/docs/adr/0053-implement-adrkit-sdk-over-core-s-kernels-and-accept-an-in-repo-consumer-as-its-r.md b/docs/adr/0053-implement-adrkit-sdk-over-core-s-kernels-and-accept-an-in-repo-consumer-as-its-r.md index b880ad10..0445ed34 100644 --- a/docs/adr/0053-implement-adrkit-sdk-over-core-s-kernels-and-accept-an-in-repo-consumer-as-its-r.md +++ b/docs/adr/0053-implement-adrkit-sdk-over-core-s-kernels-and-accept-an-in-repo-consumer-as-its-r.md @@ -325,12 +325,12 @@ No external validation is claimed. 2. [x] Implement `openDecisions` and the six `DecisionSet` members over `@adrkit/core`, keeping the declared types, adding the fields of decision 11, and adding no re-export. -3. [ ] Before publication, export the absent-input `--as-of` default from +3. [x] Before publication, export the absent-input `--as-of` default from core (ADR-0031 action item 8) and replace all four inline copies with it: the CLI, the CI Action, the scaffold, and the SDK's `queue()`. 4. [x] Add behaviour tests per entry point and contract tests against `adr queue`, `adr graph`, and `adr explain` JSON, each observed failing first. -5. [ ] Build the conformance fixture (ADR-0031 action item 6, ADR-0030 action +5. [x] Build the conformance fixture (ADR-0031 action item 6, ADR-0030 action item 5) and run the SDK's tests against it. 6. [x] Extend `packages/cli/test/node-compatibility.test.ts`, or add an equivalent check, to cover `packages/sdk/src`. @@ -338,7 +338,7 @@ No external validation is claimed. `package.json`'s `//status`, and `docs/sdk-surface.md`'s "Nothing is implemented" and rung statement to match the tree, and replace the body exclusion there with decision 11's reason. -8. [ ] Converge `explain`, `lint`, and `new` `--json` onto core formatters +8. [x] Converge `explain`, `lint`, and `new` `--json` onto core formatters (ADR-0031 action item 4). 9. [ ] Before any publication record: a consumer built against the implemented SDK in this repository and exercised in CI, with the build-time gap of diff --git a/docs/sdk-surface.md b/docs/sdk-surface.md index 83522f49..f02e85d0 100644 --- a/docs/sdk-surface.md +++ b/docs/sdk-surface.md @@ -189,11 +189,11 @@ follows the CLI command that answers the same question, call for call: | `.records` | `lintCorpus().records`, `decisionBucketFor`; `body` is core's `Adr.body` | `adr graph --format json` nodes (id, title, status, order); `body` against each file's text after its closing fence | | `.issues` | `lintCorpus().findings`, `sortFindings` | `adr lint --json` findings (`rule` → `code`) | | `.get(id)` | id lookup, last duplicate wins as in `toGoverningDecisions`; `31` pads to `0031` | — | -| `.queue({ asOf })` | `buildQueueReport`; a supplied `asOf` checked with `resolveAsOf` and required to be a bare `YYYY-MM-DD` | `adr queue --format json --as-of` items (including the review counts) and `corpusFindings` | +| `.queue({ asOf })` | `buildQueueReport`; a supplied `asOf` resolved with `resolveAsOf`, the `adr queue --as-of` grammar (bare date, or a datetime with an explicit zone), and the resolved date echoed | `adr queue --format json --as-of` items (including the review counts) and `corpusFindings` | | `.graph({ kinds })` | `buildAdrGraph`, edges filtered to `kinds` (default `['supersedes']`) | nodes: `adr graph --format json`; edges: `--kind supersedes` by default, each `--kind` for one kind, unfiltered for all three | | `.governing(path)` | `readSourceMarkers` → `resolveAffects` → `resolveSourceMarkers` → `mergeSourceDeclarations` → `toGoverningDecisions` → `bucketDecisions` → `sortFindings` | `adr explain --json` groups and findings | -Five points where the declared surface left a choice, and how each was settled: +Six points where the declared surface left a choice, and how each was settled: - **Issue codes follow the CLI JSON for the same question.** `DecisionSet.issues` and `PathGovernance.issues` carry lint rule names verbatim (`corpus-file-skipped`), and @@ -210,9 +210,17 @@ Five points where the declared surface left a choice, and how each was settled: `buildAdrGraph`'s stable id order, never looked up by id, so a corpus that repeats an id shows both records, as the CLI does. `get(id)` alone resolves a repeated id to the last record, the one `governing` and `adr explain` resolve it to. -- **The `asOf` default is inlined** as `new Date().toISOString().slice(0, 10)`, a fourth copy of - the absent-input clause. ADR-0031 action item 8 — a publication gate for this package — - replaces all four with one core export; it is deliberately not done here. +- **The `asOf` default is core's `todayUtcDate()`**, the one export of `cli-contract.md` + §As-Of Resolution's absent-input clause, which `adr queue`, the queue Action, and `adr new` + also call. The SDK once inlined a fourth copy; ADR-0031 action item 8 replaced all four. +- **A supplied `asOf` takes the CLI's whole grammar.** `queue({ asOf })` accepts exactly what + `adr queue --as-of` accepts — a bare `YYYY-MM-DD`, or an ISO datetime with an explicit zone — + through core's `resolveAsOf`, and `QueueView.asOf` echoes the resolved UTC calendar date; a + timezone-less datetime throws `RangeError`. The sketch accepted bare dates only and told a + caller with a datetime to import `resolveAsOf` from core, which is the reach-into-core + ADR-0031 names as the facade failing; widening the input closed that without a new symbol. + The static portal relies on both: it passes a caller's `asOf` (or none) to `queue()` and builds + from the echoed date, so it computes no date and restates no grammar of its own. All filesystem access is `openDecisions` plus `governing`'s marker read through core's confined reader (skipped with `readMarkers: false`). Nothing in the package writes. The returned records, @@ -338,8 +346,16 @@ Decisions this left open, and how they were settled: - **ADR-0014 rung 1 only.** The implementation has unit, behaviour, and CLI-equivalence tests (`packages/sdk/test/`), the equivalence run against two fixture corpora and this repository's - own `docs/adr`. No consumer has exercised the surface, because the one it was designed for does - not exist yet. ADR-0031's own trade-off — *"the risk is that the guess hardens before the + own `docs/adr`, and it is tested against the committed conformance fixture + (`packages/core/test/conformance/golden/`, ADR-0031 action item 6) that the CLI's goldens also + pin. One consumer outside `packages/sdk/` exercises it: the static portal (`packages/portal`, + ADR-0054) calls `openDecisions` and five of the six handle members (`records`, `issues`, + `queue`, `graph`, `governing`; not `get`), and takes its default `asOf` from `queue()`'s echo. + `bun test` runs it in CI over fixture corpora, and the portal Action's bundle is tested the + same way. That consumer is build-time only: it opens a corpus once per build, so the handle's + long-lived use is not exercised (ADR-0053 decision 8), and whether it satisfies ADR-0053 + action item 9 is for the publication record to decide. The consumer the surface was designed + for, a Backstage backend, does not exist yet. ADR-0031's own trade-off — *"the risk is that the guess hardens before the Backstage plugin tests it"* — is not mitigated by this work, only made concrete enough to criticize. - **Implemented, not exercised.** The eight-call `explain` assembly the surface claims to absorb diff --git a/packages/ci/dist/portal-action.js b/packages/ci/dist/portal-action.js index 137c5514..c71c6739 100644 --- a/packages/ci/dist/portal-action.js +++ b/packages/ci/dist/portal-action.js @@ -41557,6 +41557,39 @@ async function lintCorpus(options = {}) { records: [...records].sort((a, b) => compareCodeUnits(a.frontmatter.id, b.frontmatter.id)) }; } +// ../core/src/queue/as-of.ts +function isCalendarDate(candidate) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(candidate)) + return false; + const probe = new Date(`${candidate}T00:00:00Z`); + return Number.isFinite(probe.getTime()) && probe.toISOString().slice(0, 10) === candidate; +} +function resolveAsOf(value) { + if (/^\d{4}-\d{2}-\d{2}$/.test(value)) { + return isCalendarDate(value) ? { ok: true, date: value } : { ok: false, code: "invalid" }; + } + const tIndex = value.indexOf("T"); + if (tIndex !== -1) { + const timePart = value.slice(tIndex + 1); + const hasTimezone = /Z$/.test(timePart) || /[+-]\d{2}:?\d{2}$/.test(timePart); + if (!hasTimezone) + return { ok: false, code: "tzless" }; + if (!isCalendarDate(value.slice(0, tIndex))) + return { ok: false, code: "invalid" }; + const parsed = new Date(value); + if (!Number.isFinite(parsed.getTime())) + return { ok: false, code: "invalid" }; + const resolved = parsed.toISOString().slice(0, 10); + return isCalendarDate(resolved) ? { ok: true, date: resolved } : { ok: false, code: "invalid" }; + } + return { ok: false, code: "invalid" }; +} +function todayUtcDate(now = new Date) { + const date = now.toISOString().slice(0, 10); + if (!isCalendarDate(date)) + throw new RangeError(`todayUtcDate: ${now.toISOString()} is outside years 0000-9999.`); + return date; +} // ../core/src/graph/build.ts function edgeKey(edge) { return `${edge.from}\x00${edge.to}\x00${edge.kind}`; @@ -42236,33 +42269,6 @@ function buildQueueReport(input) { corpusFindings }; } -// ../core/src/queue/as-of.ts -function isCalendarDate(candidate) { - if (!/^\d{4}-\d{2}-\d{2}$/.test(candidate)) - return false; - const probe = new Date(`${candidate}T00:00:00Z`); - return Number.isFinite(probe.getTime()) && probe.toISOString().slice(0, 10) === candidate; -} -function resolveAsOf(value) { - if (/^\d{4}-\d{2}-\d{2}$/.test(value)) { - return isCalendarDate(value) ? { ok: true, date: value } : { ok: false, code: "invalid" }; - } - const tIndex = value.indexOf("T"); - if (tIndex !== -1) { - const timePart = value.slice(tIndex + 1); - const hasTimezone = /Z$/.test(timePart) || /[+-]\d{2}:?\d{2}$/.test(timePart); - if (!hasTimezone) - return { ok: false, code: "tzless" }; - if (!isCalendarDate(value.slice(0, tIndex))) - return { ok: false, code: "invalid" }; - const parsed = new Date(value); - if (!Number.isFinite(parsed.getTime())) - return { ok: false, code: "invalid" }; - const resolved = parsed.toISOString().slice(0, 10); - return isCalendarDate(resolved) ? { ok: true, date: resolved } : { ok: false, code: "invalid" }; - } - return { ok: false, code: "invalid" }; -} // ../sdk/src/corpus-directory.ts import { isAbsolute as isAbsolute4, resolve as resolve5 } from "node:path"; var CORPUS_DIRECTORY_NOT_FOUND_CODES = new Set(["ENOENT", "ENOTDIR"]); @@ -42374,7 +42380,6 @@ function issueFromMarkerScan(scan) { }); } } -var BARE_CALENDAR_DATE = /^\d{4}-\d{2}-\d{2}$/; var EDGE_KINDS = ["supersedes", "relatesTo", "conflictsWith"]; var DEFAULT_EDGE_KINDS = ["supersedes"]; function resolveEdgeKinds(kinds) { @@ -42390,16 +42395,15 @@ function resolveEdgeKinds(kinds) { } return EDGE_KINDS.filter((kind) => kinds.includes(kind)); } -function todayUtc() { - return new Date().toISOString().slice(0, 10); -} function resolveQueueAsOf(asOf) { if (asOf === undefined) - return todayUtc(); - if (typeof asOf !== "string" || !BARE_CALENDAR_DATE.test(asOf) || !resolveAsOf(asOf).ok) { - throw new RangeError(`queue({ asOf }) must be a real UTC calendar date in YYYY-MM-DD form; received ${JSON.stringify(asOf)}.`); + return todayUtcDate(); + const resolution = typeof asOf === "string" ? resolveAsOf(asOf) : undefined; + if (resolution === undefined || !resolution.ok) { + const reason = resolution?.ok === false && resolution.code === "tzless" ? "a datetime needs an explicit timezone (Z or an offset such as +05:00)" : "expected YYYY-MM-DD or an ISO datetime with an explicit timezone"; + throw new RangeError(`queue({ asOf }) received ${JSON.stringify(asOf)}: ${reason}.`); } - return asOf; + return resolution.date; } function emptyScan(path) { return { path, state: "scanned", markers: [], truncated: false }; @@ -44270,28 +44274,12 @@ var DEFAULT_CONCURRENCY = 16; function usage(message) { return { exitCode: 2, dataFile: null, data: null, errors: [], message }; } -function isCalendarDate2(value) { - const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); - if (!match) - return false; - const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])]; - const date = new Date(Date.UTC(year, month - 1, day)); - return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day; -} function toPosix(path) { return path.split(sep6).join("/"); } async function generatePortal(options = {}) { const given = resolve6(options.cwd ?? process.cwd()); const cwd = await realpath2(given).catch(() => given); - let asOf; - if (options.asOf !== undefined) { - if (!isCalendarDate2(options.asOf)) - return usage(`asOf must be a UTC calendar date YYYY-MM-DD; got "${options.asOf}".`); - asOf = options.asOf; - } else { - asOf = (options.now ?? (() => new Date))().toISOString().slice(0, 10); - } let baseUrl = null; if (options.baseUrl !== undefined && options.baseUrl !== "") { let parsed = null; @@ -44347,6 +44335,15 @@ async function generatePortal(options = {}) { } throw error; } + let asOf; + try { + asOf = handle.queue(options.asOf === undefined ? {} : { asOf: options.asOf }).asOf; + } catch (error) { + if (error instanceof RangeError) { + return usage(`asOf must be YYYY-MM-DD or an ISO datetime with an explicit timezone (e.g. 2026-01-08T00:00:00Z); got "${options.asOf}".`); + } + throw error; + } const errorIssues = handle.issues.filter((issue) => issue.severity === "error"); let commit = null; if (toplevel !== null) { diff --git a/packages/ci/dist/queue-action.js b/packages/ci/dist/queue-action.js index 1c4ed5ab..d9640de5 100644 --- a/packages/ci/dist/queue-action.js +++ b/packages/ci/dist/queue-action.js @@ -37907,6 +37907,19 @@ async function lintCorpus(options = {}) { records: [...records].sort((a, b) => compareCodeUnits(a.frontmatter.id, b.frontmatter.id)) }; } +// ../core/src/queue/as-of.ts +function isCalendarDate(candidate) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(candidate)) + return false; + const probe = new Date(`${candidate}T00:00:00Z`); + return Number.isFinite(probe.getTime()) && probe.toISOString().slice(0, 10) === candidate; +} +function todayUtcDate(now = new Date) { + const date = now.toISOString().slice(0, 10); + if (!isCalendarDate(date)) + throw new RangeError(`todayUtcDate: ${now.toISOString()} is outside years 0000-9999.`); + return date; +} // ../core/src/import/status.ts var MADR_RECOGNIZED_STATUSES = [ "draft", @@ -38368,7 +38381,7 @@ async function main() { setFailed(`adrkit queue: could not load the ADR corpus at '${dir}': ${error instanceof Error ? error.message : String(error)}`); return; } - const asOf = new Date().toISOString().slice(0, 10); + const asOf = todayUtcDate(); const report = buildQueueReport({ corpus, asOf }); const markdown = formatQueueReportMarkdown(report); const [owner = "", repo = ""] = (process.env.GITHUB_REPOSITORY ?? "").split("/"); diff --git a/packages/ci/portal/action.yml b/packages/ci/portal/action.yml index 3ac37436..867907e5 100644 --- a/packages/ci/portal/action.yml +++ b/packages/ci/portal/action.yml @@ -25,8 +25,9 @@ inputs: default: "" as-of: description: > - Optional UTC calendar date (YYYY-MM-DD) for the queue's SLA states. - Defaults to today (UTC). + Optional date for the queue's SLA states, as `adr queue --as-of` + takes it: YYYY-MM-DD, or an ISO datetime with an explicit timezone + (resolved to its UTC calendar date). Defaults to today (UTC). required: false default: "" publish-private: diff --git a/packages/ci/src/queue-action-entrypoint.ts b/packages/ci/src/queue-action-entrypoint.ts index 1f8ec39e..dde7a58d 100644 --- a/packages/ci/src/queue-action-entrypoint.ts +++ b/packages/ci/src/queue-action-entrypoint.ts @@ -7,7 +7,7 @@ import * as core from '@actions/core'; import { getOctokit } from '@actions/github'; -import { buildQueueReport, formatQueueReportMarkdown, lintCorpus } from '@adrkit/core'; +import { buildQueueReport, formatQueueReportMarkdown, lintCorpus, todayUtcDate } from '@adrkit/core'; import { publishQueueReport } from './queue-issue.ts'; import { createOctokitQueueClient, handleGitHubApiError, type QueueOctokit } from './queue-github-client.ts'; @@ -28,7 +28,7 @@ async function main(): Promise { return; } - const asOf = new Date().toISOString().slice(0, 10); + const asOf = todayUtcDate(); const report = buildQueueReport({ corpus, asOf }); const markdown = formatQueueReportMarkdown(report); diff --git a/packages/ci/test/portal-bundle.test.ts b/packages/ci/test/portal-bundle.test.ts index d938422a..1767e657 100644 --- a/packages/ci/test/portal-bundle.test.ts +++ b/packages/ci/test/portal-bundle.test.ts @@ -187,10 +187,21 @@ describe('the committed portal Action bundle', () => { }); test('a usage error fails the step with the generator message and writes nothing', async () => { - const run = await runBundle('usage-site', { private: false }, { 'INPUT_AS-OF': '2026-07-05T00:00:00Z' }); + const run = await runBundle('usage-site', { private: false }, { 'INPUT_AS-OF': '2026-07-05T00:00:00' }); expect(run.status).not.toBe(0); expect(exitCodeOutput(run.outputs)).toBe('2'); - expect(run.stdout).toContain('::error::adrkit portal: asOf must be a UTC calendar date'); + expect(run.stdout).toContain('::error::adrkit portal: asOf must be YYYY-MM-DD or an ISO datetime with an explicit timezone'); expect(existsSync(join(repo, 'usage-site'))).toBe(false); }); + + // The bundle takes `adr queue --as-of`'s grammar through the SDK: a zoned datetime + // resolves to its UTC calendar date. Observed failing against the bundle built before + // the SDK's queue() accepted datetimes (exit 2, "asOf must be a UTC calendar date"). + test('a zoned datetime as-of resolves to its UTC calendar date', async () => { + const run = await runBundle('datetime-site', { private: false }, { 'INPUT_AS-OF': '2026-01-08T23:30:00-05:00' }); + expect(run.status).toBe(0); + expect(exitCodeOutput(run.outputs)).toBe('0'); + const data = JSON.parse(readFileSync(join(repo, 'datetime-site', 'portal-data.json'), 'utf8')); + expect(data.asOf).toBe('2026-01-09'); + }); }); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 9876d6cf..c7c186c8 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -4,16 +4,21 @@ import { parseArgs, type ParseArgsConfig } from 'node:util'; import { readdir } from 'node:fs/promises'; import { buildAdrGraph, + buildExplainReport, + buildLintReport, + buildNewAdrReport, bucketDecisions, checkChanges, countFindings, createAdr, exitCodeForFindings, filterAdrGraph, + formatExplainReportJson, + formatLintReportJson, + formatNewAdrReportJson, isInvertedWindow, lintCorpus, MARKER_DECLARATION_FILE_CAP, - MARKER_HEADER_WINDOW_BYTES, mergeSourceDeclarations, migrateMadr, readSourceMarkers, @@ -512,7 +517,7 @@ async function runLint(args: string[]): Promise { const counts = countFindings(findings); if (parsed.values.json) { - writeStdout(`${JSON.stringify({ checked: result.checked, findings }, null, 2)}\n`); + writeStdout(formatLintReportJson(buildLintReport(result, findings))); } else { const humanFindings = renderHumanLint(findings, getPresentation().stderr); if (humanFindings) writeStderr(humanFindings); @@ -644,7 +649,7 @@ async function runNew(args: string[]): Promise { dir: String(parsed.values.dir), }); if (parsed.values.json) { - writeStdout(`${JSON.stringify({ id: result.id, path: result.path }, null, 2)}\n`); + writeStdout(formatNewAdrReportJson(buildNewAdrReport(result))); } else { writeStdout(`${getPresentation().stdout.path(result.path)}\n`); } @@ -803,22 +808,9 @@ async function runExplain(args: string[]): Promise { const corpusFindings = sortFindings(corpus.findings); if (exitCodeForFindings(corpusFindings) !== 0) { if (parsed.values.json) { - writeStdout( - `${JSON.stringify( - { - path, - ...(asOf ? { asOf: emptyAsOfJson(asOf) } : {}), - governedBy: [], - governing: [], - activeProposals: [], - history: [], - markers: markerScanJson(scan), - findings: corpusFindings, - }, - null, - 2, - )}\n`, - ); + // No `governedBy` and no `asOfView`: the report answers with empty groups, and still + // reports the `--as-of` resolution and the marker scan, which do not depend on the corpus. + writeStdout(formatExplainReportJson(buildExplainReport({ path, scan, ...(asOf ? { asOf } : {}), findings: corpusFindings }))); } else { const humanFindings = renderHumanLint(corpusFindings, getPresentation().stderr); if (humanFindings) writeStderr(humanFindings); @@ -850,18 +842,9 @@ async function runExplain(args: string[]): Promise { if (parsed.values.json) { writeStdout( - `${JSON.stringify( - { - path, - ...(asOf && asOfView ? { asOf: asOfJson(asOf, asOfView) } : {}), - governedBy, - ...buckets, - markers: markerScanJson(scan), - findings, - }, - null, - 2, - )}\n`, + formatExplainReportJson( + buildExplainReport({ path, scan, governedBy, ...(asOf && asOfView ? { asOf, asOfView } : {}), findings }), + ), ); return 0; } @@ -916,46 +899,6 @@ function renderDecisionGroup( return output; } -/** - * The `asOf` block of `adr explain --json`. - * - * Additive, and present only when `--as-of` was given. The present-tense `governing`, - * `activeProposals`, and `history` keys beside it keep their meaning untouched, because a - * consumer that has never heard of this flag must not find those keys re-dated under it — - * the same discipline `markerScan` follows in `CheckOutcome`. - */ -function asOfJson(resolved: ResolvedAsOf, view: DecisionsAsOf) { - return { - requested: resolved.requested, - date: resolved.date, - resolvedFrom: resolved.resolvedFrom, - ...(resolved.commit === undefined ? {} : { commit: resolved.commit }), - ...(resolved.committedAt === undefined ? {} : { committedAt: resolved.committedAt }), - governing: view.governing, - activeProposals: view.activeProposals, - history: view.history, - notYetRecorded: view.notYetRecorded, - undetermined: view.undetermined, - }; -} - -/** - * The same block when the corpus did not parse. The resolution still happened and is still - * reported, because what the user asked for is a fact independent of whether it could be - * answered — the same reason the marker scan state is reported on this path. - */ -function emptyAsOfJson(resolved: ResolvedAsOf) { - return asOfJson(resolved, { - date: resolved.date, - governing: [], - activeProposals: [], - history: [], - notYetRecorded: [], - undetermined: [], - findings: [], - }); -} - /** * The human as-of view. * @@ -1043,40 +986,6 @@ function renderWindow(decision: DecisionAsOf): string { return `in force ${decision.window.opensOn} → ${decision.window.closesOn}${closedBy}`; } -/** - * The `markers` block of `adr explain --json`: what was scanned, and what was found. - * - * `truncated` says bytes were left unscanned, but not how many — and the window constant - * does not answer that either, because the scan stops at the last complete line inside it. - * `scannedBytes` / `fileBytes` report the measurement, so a consumer can size the - * unscanned remainder and set its own policy instead of inheriting one (#108). Both are - * omitted for a state that never opened the file, rather than reported as `0`. - */ -function markerScanJson(scan: SourceMarkerScan): { - state: SourceMarkerScan['state']; - windowBytes: number; - scannedBytes?: number; - fileBytes?: number; - truncated: boolean; - declarationLimit: number; - totalDeclarations: number; - omittedDeclarations: number; - declared: Array<{ ref: string; line: number }>; -} { - const omittedDeclarations = scan.omittedMarkers ?? 0; - return { - state: scan.state, - windowBytes: MARKER_HEADER_WINDOW_BYTES, - ...(scan.scannedBytes === undefined ? {} : { scannedBytes: scan.scannedBytes }), - ...(scan.fileBytes === undefined ? {} : { fileBytes: scan.fileBytes }), - truncated: scan.truncated, - declarationLimit: MARKER_DECLARATION_FILE_CAP, - totalDeclarations: scan.markers.length + omittedDeclarations, - omittedDeclarations, - declared: scan.markers.map((marker) => ({ ref: marker.ref, line: marker.line })), - }; -} - /** * The one line of human output that keeps "this file declares nothing" from reading * identically to "I never opened this file" or "I stopped reading before the marker" diff --git a/packages/cli/src/queue.ts b/packages/cli/src/queue.ts index fd5e7a2b..9f97d789 100644 --- a/packages/cli/src/queue.ts +++ b/packages/cli/src/queue.ts @@ -7,6 +7,7 @@ import { formatQueueReportMarkdown, lintCorpus, resolveAsOf, + todayUtcDate, } from '@adrkit/core'; import { commandOptions, renderGlobalColorUsageLine, requiredCommandValueChoices, withGlobalColorOption } from './command-registry.ts'; import { corpusDirectoryErrorKind, corpusDirectoryErrorMessage, formatUsageError } from './errors.ts'; @@ -185,7 +186,7 @@ export async function runQueue(args: string[]): Promise { } asOf = resolution.date; } else { - asOf = new Date().toISOString().slice(0, 10); + asOf = todayUtcDate(); } let corpus: Awaited>; diff --git a/packages/cli/test/conformance.test.ts b/packages/cli/test/conformance.test.ts new file mode 100644 index 00000000..03aec9f2 --- /dev/null +++ b/packages/cli/test/conformance.test.ts @@ -0,0 +1,69 @@ +/** + * The conformance fixture (ADR-0030 clause 5, ADR-0031 action item 6): the CLI run over the + * committed corpus must reproduce both goldens byte for byte, with the manifest's exit codes. + * + * This is the half of the fixture a downstream repository runs against a pinned `adr`. The + * other half, `packages/sdk/test/conformance.test.ts`, holds `@adrkit/sdk` to the same goldens. + * + * ADR-0016: observed failing. Changing one byte of `queue.json` (the `asOf` date) failed the + * queue case, and changing the manifest's check exit code to 1 failed the check case; both + * were reverted. + */ + +import { describe, expect, test } from 'bun:test'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +const CLI_PATH = resolve(process.cwd(), 'packages/cli/src/index.ts'); +const FIXTURE = resolve(process.cwd(), 'packages/core/test/conformance/golden'); + +interface ManifestCommand { + args: string[]; + exitCode: number; + golden: string; +} + +interface Manifest { + fixture: string; + version: number; + cwd: string; + commands: Record<'queue' | 'check', ManifestCommand>; +} + +async function readManifest(): Promise { + return JSON.parse(await readFile(resolve(FIXTURE, 'manifest.json'), 'utf8')) as Manifest; +} + +async function runAdr(args: string[], cwd: string) { + const proc = Bun.spawn([process.execPath, CLI_PATH, ...args], { + cwd, + stdout: 'pipe', + stderr: 'pipe', + env: { ...process.env, NO_COLOR: '1' }, + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + return { stdout, stderr, exitCode }; +} + +describe('conformance fixture — the CLI reproduces the goldens', () => { + for (const name of ['queue', 'check'] as const) { + test(`adr ${name} matches ${name}.json byte for byte`, async () => { + const manifest = await readManifest(); + const command = manifest.commands[name]; + const result = await runAdr(command.args, resolve(FIXTURE, manifest.cwd)); + expect(result.stderr).toBe(''); + expect(result.exitCode).toBe(command.exitCode); + expect(result.stdout).toBe(await readFile(resolve(FIXTURE, command.golden), 'utf8')); + }); + } + + test('the manifest names exactly the two commands ADR-0030 clause 5 specifies', async () => { + const manifest = await readManifest(); + expect(Object.keys(manifest.commands).sort()).toEqual(['check', 'queue']); + expect(manifest.commands.queue.args).toContain('--as-of'); + }); +}); diff --git a/packages/cli/test/fixtures/json-report/.gitattributes b/packages/cli/test/fixtures/json-report/.gitattributes new file mode 100644 index 00000000..fcadb2cf --- /dev/null +++ b/packages/cli/test/fixtures/json-report/.gitattributes @@ -0,0 +1 @@ +* text eol=lf diff --git a/packages/cli/test/fixtures/json-report/explain-as-of-lru.json b/packages/cli/test/fixtures/json-report/explain-as-of-lru.json new file mode 100644 index 00000000..cef62501 --- /dev/null +++ b/packages/cli/test/fixtures/json-report/explain-as-of-lru.json @@ -0,0 +1,29 @@ +{ + "path": "src/cache/lru.ts", + "asOf": { + "requested": "2026-07-15", + "date": "2026-07-15", + "resolvedFrom": "date", + "governing": [], + "activeProposals": [], + "history": [], + "notYetRecorded": [], + "undetermined": [] + }, + "governedBy": [], + "governing": [], + "activeProposals": [], + "history": [], + "markers": { + "state": "scanned", + "windowBytes": 8192, + "scannedBytes": 30, + "fileBytes": 30, + "truncated": false, + "declarationLimit": 64, + "totalDeclarations": 0, + "omittedDeclarations": 0, + "declared": [] + }, + "findings": [] +} diff --git a/packages/cli/test/fixtures/json-report/explain-as-of-missing.json b/packages/cli/test/fixtures/json-report/explain-as-of-missing.json new file mode 100644 index 00000000..d17f95ef --- /dev/null +++ b/packages/cli/test/fixtures/json-report/explain-as-of-missing.json @@ -0,0 +1,159 @@ +{ + "path": "src/sync/missing.ts", + "asOf": { + "requested": "2026-07-15", + "date": "2026-07-15", + "resolvedFrom": "date", + "governing": [], + "activeProposals": [], + "history": [], + "notYetRecorded": [ + { + "recordId": "0001", + "title": "Use the sync protocol", + "status": "accepted", + "bucket": "governing", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ], + "window": { + "opensOn": "2026-07-18", + "closesOn": null + }, + "standing": "notYetRecorded" + }, + { + "recordId": "0003", + "title": "Poll for changes", + "status": "superseded", + "bucket": "history", + "supersededBy": "0004", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ], + "window": { + "opensOn": "2026-07-18", + "closesOn": "2026-07-18", + "closedBy": "0004" + }, + "standing": "notYetRecorded" + }, + { + "recordId": "0005", + "title": "Batch sync writes", + "status": "proposed", + "bucket": "activeProposals", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ], + "window": { + "opensOn": "2026-07-18", + "closesOn": null + }, + "standing": "notYetRecorded" + } + ], + "undetermined": [] + }, + "governedBy": [ + { + "recordId": "0001", + "title": "Use the sync protocol", + "status": "accepted", + "bucket": "governing", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ] + }, + { + "recordId": "0003", + "title": "Poll for changes", + "status": "superseded", + "bucket": "history", + "supersededBy": "0004", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ] + }, + { + "recordId": "0005", + "title": "Batch sync writes", + "status": "proposed", + "bucket": "activeProposals", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ] + } + ], + "governing": [ + { + "recordId": "0001", + "title": "Use the sync protocol", + "status": "accepted", + "bucket": "governing", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ] + } + ], + "activeProposals": [ + { + "recordId": "0005", + "title": "Batch sync writes", + "status": "proposed", + "bucket": "activeProposals", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ] + } + ], + "history": [ + { + "recordId": "0003", + "title": "Poll for changes", + "status": "superseded", + "bucket": "history", + "supersededBy": "0004", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ] + } + ], + "markers": { + "state": "absent", + "windowBytes": 8192, + "truncated": false, + "declarationLimit": 64, + "totalDeclarations": 0, + "omittedDeclarations": 0, + "declared": [] + }, + "findings": [] +} diff --git a/packages/cli/test/fixtures/json-report/explain-as-of-protocol.json b/packages/cli/test/fixtures/json-report/explain-as-of-protocol.json new file mode 100644 index 00000000..3c3ca130 --- /dev/null +++ b/packages/cli/test/fixtures/json-report/explain-as-of-protocol.json @@ -0,0 +1,202 @@ +{ + "path": "src/sync/protocol.ts", + "asOf": { + "requested": "2026-07-15", + "date": "2026-07-15", + "resolvedFrom": "date", + "governing": [], + "activeProposals": [], + "history": [], + "notYetRecorded": [ + { + "recordId": "0001", + "title": "Use the sync protocol", + "status": "accepted", + "bucket": "governing", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ], + "window": { + "opensOn": "2026-07-18", + "closesOn": null + }, + "standing": "notYetRecorded" + }, + { + "recordId": "0003", + "title": "Poll for changes", + "status": "superseded", + "bucket": "history", + "supersededBy": "0004", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ], + "window": { + "opensOn": "2026-07-18", + "closesOn": "2026-07-18", + "closedBy": "0004" + }, + "standing": "notYetRecorded" + }, + { + "recordId": "0004", + "title": "Push changes over websockets", + "status": "accepted", + "bucket": "governing", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/protocol.ts" + } + ], + "window": { + "opensOn": "2026-07-18", + "closesOn": null + }, + "standing": "notYetRecorded" + }, + { + "recordId": "0005", + "title": "Batch sync writes", + "status": "proposed", + "bucket": "activeProposals", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ], + "window": { + "opensOn": "2026-07-18", + "closesOn": null + }, + "standing": "notYetRecorded" + } + ], + "undetermined": [] + }, + "governedBy": [ + { + "recordId": "0001", + "title": "Use the sync protocol", + "status": "accepted", + "bucket": "governing", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ] + }, + { + "recordId": "0003", + "title": "Poll for changes", + "status": "superseded", + "bucket": "history", + "supersededBy": "0004", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ] + }, + { + "recordId": "0004", + "title": "Push changes over websockets", + "status": "accepted", + "bucket": "governing", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/protocol.ts" + } + ] + }, + { + "recordId": "0005", + "title": "Batch sync writes", + "status": "proposed", + "bucket": "activeProposals", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ] + } + ], + "governing": [ + { + "recordId": "0001", + "title": "Use the sync protocol", + "status": "accepted", + "bucket": "governing", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ] + }, + { + "recordId": "0004", + "title": "Push changes over websockets", + "status": "accepted", + "bucket": "governing", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/protocol.ts" + } + ] + } + ], + "activeProposals": [ + { + "recordId": "0005", + "title": "Batch sync writes", + "status": "proposed", + "bucket": "activeProposals", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ] + } + ], + "history": [ + { + "recordId": "0003", + "title": "Poll for changes", + "status": "superseded", + "bucket": "history", + "supersededBy": "0004", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ] + } + ], + "markers": { + "state": "scanned", + "windowBytes": 8192, + "scannedBytes": 27, + "fileBytes": 27, + "truncated": false, + "declarationLimit": 64, + "totalDeclarations": 0, + "omittedDeclarations": 0, + "declared": [] + }, + "findings": [] +} diff --git a/packages/cli/test/fixtures/json-report/explain-as-of-retry.json b/packages/cli/test/fixtures/json-report/explain-as-of-retry.json new file mode 100644 index 00000000..b69b3f04 --- /dev/null +++ b/packages/cli/test/fixtures/json-report/explain-as-of-retry.json @@ -0,0 +1,213 @@ +{ + "path": "src/sync/retry.ts", + "asOf": { + "requested": "2026-07-15", + "date": "2026-07-15", + "resolvedFrom": "date", + "governing": [], + "activeProposals": [], + "history": [], + "notYetRecorded": [ + { + "recordId": "0001", + "title": "Use the sync protocol", + "status": "accepted", + "bucket": "governing", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ], + "window": { + "opensOn": "2026-07-18", + "closesOn": null + }, + "standing": "notYetRecorded" + }, + { + "recordId": "0002", + "title": "Retry on transient failure", + "status": "accepted", + "bucket": "governing", + "firedMatchers": [], + "declaredBy": [ + { + "path": "src/sync/retry.ts", + "line": 1, + "ref": "0002" + } + ], + "window": { + "opensOn": "2026-07-18", + "closesOn": null + }, + "standing": "notYetRecorded" + }, + { + "recordId": "0003", + "title": "Poll for changes", + "status": "superseded", + "bucket": "history", + "supersededBy": "0004", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ], + "window": { + "opensOn": "2026-07-18", + "closesOn": "2026-07-18", + "closedBy": "0004" + }, + "standing": "notYetRecorded" + }, + { + "recordId": "0005", + "title": "Batch sync writes", + "status": "proposed", + "bucket": "activeProposals", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ], + "window": { + "opensOn": "2026-07-18", + "closesOn": null + }, + "standing": "notYetRecorded" + } + ], + "undetermined": [] + }, + "governedBy": [ + { + "recordId": "0001", + "title": "Use the sync protocol", + "status": "accepted", + "bucket": "governing", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ] + }, + { + "recordId": "0002", + "title": "Retry on transient failure", + "status": "accepted", + "bucket": "governing", + "firedMatchers": [], + "declaredBy": [ + { + "path": "src/sync/retry.ts", + "line": 1, + "ref": "0002" + } + ] + }, + { + "recordId": "0003", + "title": "Poll for changes", + "status": "superseded", + "bucket": "history", + "supersededBy": "0004", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ] + }, + { + "recordId": "0005", + "title": "Batch sync writes", + "status": "proposed", + "bucket": "activeProposals", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ] + } + ], + "governing": [ + { + "recordId": "0001", + "title": "Use the sync protocol", + "status": "accepted", + "bucket": "governing", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ] + }, + { + "recordId": "0002", + "title": "Retry on transient failure", + "status": "accepted", + "bucket": "governing", + "firedMatchers": [], + "declaredBy": [ + { + "path": "src/sync/retry.ts", + "line": 1, + "ref": "0002" + } + ] + } + ], + "activeProposals": [ + { + "recordId": "0005", + "title": "Batch sync writes", + "status": "proposed", + "bucket": "activeProposals", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ] + } + ], + "history": [ + { + "recordId": "0003", + "title": "Poll for changes", + "status": "superseded", + "bucket": "history", + "supersededBy": "0004", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ] + } + ], + "markers": { + "state": "scanned", + "windowBytes": 8192, + "scannedBytes": 40, + "fileBytes": 40, + "truncated": false, + "declarationLimit": 64, + "totalDeclarations": 1, + "omittedDeclarations": 0, + "declared": [ + { + "ref": "0002", + "line": 1 + } + ] + }, + "findings": [] +} diff --git a/packages/cli/test/fixtures/json-report/explain-as-of-stale.json b/packages/cli/test/fixtures/json-report/explain-as-of-stale.json new file mode 100644 index 00000000..cba71a75 --- /dev/null +++ b/packages/cli/test/fixtures/json-report/explain-as-of-stale.json @@ -0,0 +1,196 @@ +{ + "path": "src/sync/stale.ts", + "asOf": { + "requested": "2026-07-15", + "date": "2026-07-15", + "resolvedFrom": "date", + "governing": [], + "activeProposals": [], + "history": [], + "notYetRecorded": [ + { + "recordId": "0001", + "title": "Use the sync protocol", + "status": "accepted", + "bucket": "governing", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ], + "window": { + "opensOn": "2026-07-18", + "closesOn": null + }, + "standing": "notYetRecorded" + }, + { + "recordId": "0003", + "title": "Poll for changes", + "status": "superseded", + "bucket": "history", + "supersededBy": "0004", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ], + "declaredBy": [ + { + "path": "src/sync/stale.ts", + "line": 1, + "ref": "0003" + } + ], + "window": { + "opensOn": "2026-07-18", + "closesOn": "2026-07-18", + "closedBy": "0004" + }, + "standing": "notYetRecorded" + }, + { + "recordId": "0005", + "title": "Batch sync writes", + "status": "proposed", + "bucket": "activeProposals", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ], + "window": { + "opensOn": "2026-07-18", + "closesOn": null + }, + "standing": "notYetRecorded" + } + ], + "undetermined": [] + }, + "governedBy": [ + { + "recordId": "0001", + "title": "Use the sync protocol", + "status": "accepted", + "bucket": "governing", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ] + }, + { + "recordId": "0003", + "title": "Poll for changes", + "status": "superseded", + "bucket": "history", + "supersededBy": "0004", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ], + "declaredBy": [ + { + "path": "src/sync/stale.ts", + "line": 1, + "ref": "0003" + } + ] + }, + { + "recordId": "0005", + "title": "Batch sync writes", + "status": "proposed", + "bucket": "activeProposals", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ] + } + ], + "governing": [ + { + "recordId": "0001", + "title": "Use the sync protocol", + "status": "accepted", + "bucket": "governing", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ] + } + ], + "activeProposals": [ + { + "recordId": "0005", + "title": "Batch sync writes", + "status": "proposed", + "bucket": "activeProposals", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ] + } + ], + "history": [ + { + "recordId": "0003", + "title": "Poll for changes", + "status": "superseded", + "bucket": "history", + "supersededBy": "0004", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ], + "declaredBy": [ + { + "path": "src/sync/stale.ts", + "line": 1, + "ref": "0003" + } + ] + } + ], + "markers": { + "state": "scanned", + "windowBytes": 8192, + "scannedBytes": 40, + "fileBytes": 40, + "truncated": false, + "declarationLimit": 64, + "totalDeclarations": 1, + "omittedDeclarations": 0, + "declared": [ + { + "ref": "0003", + "line": 1 + } + ] + }, + "findings": [ + { + "rule": "stale-marker", + "severity": "warn", + "message": "Source marker \"@adr 0003\" in src/sync/stale.ts:1 names superseded ADR 0003; update it to \"@adr 0004\" or re-affirm 0003", + "path": "src/sync/stale.ts", + "field": "marker", + "pattern": "0003" + } + ] +} diff --git a/packages/cli/test/fixtures/json-report/explain-broken-as-of.json b/packages/cli/test/fixtures/json-report/explain-broken-as-of.json new file mode 100644 index 00000000..d63dd684 --- /dev/null +++ b/packages/cli/test/fixtures/json-report/explain-broken-as-of.json @@ -0,0 +1,42 @@ +{ + "path": "src/retry.ts", + "asOf": { + "requested": "2026-07-15T23:30:00-05:00", + "date": "2026-07-16", + "resolvedFrom": "date", + "governing": [], + "activeProposals": [], + "history": [], + "notYetRecorded": [], + "undetermined": [] + }, + "governedBy": [], + "governing": [], + "activeProposals": [], + "history": [], + "markers": { + "state": "scanned", + "windowBytes": 8192, + "scannedBytes": 40, + "fileBytes": 40, + "truncated": false, + "declarationLimit": 64, + "totalDeclarations": 1, + "omittedDeclarations": 0, + "declared": [ + { + "ref": "0001", + "line": 1 + } + ] + }, + "findings": [ + { + "rule": "frontmatter-fence", + "severity": "error", + "message": "ADR frontmatter is missing its closing --- fence", + "path": "docs/adr/0002-no-closing-fence.md", + "field": "frontmatter" + } + ] +} diff --git a/packages/cli/test/fixtures/json-report/explain-broken.json b/packages/cli/test/fixtures/json-report/explain-broken.json new file mode 100644 index 00000000..eda0497e --- /dev/null +++ b/packages/cli/test/fixtures/json-report/explain-broken.json @@ -0,0 +1,32 @@ +{ + "path": "src/retry.ts", + "governedBy": [], + "governing": [], + "activeProposals": [], + "history": [], + "markers": { + "state": "scanned", + "windowBytes": 8192, + "scannedBytes": 40, + "fileBytes": 40, + "truncated": false, + "declarationLimit": 64, + "totalDeclarations": 1, + "omittedDeclarations": 0, + "declared": [ + { + "ref": "0001", + "line": 1 + } + ] + }, + "findings": [ + { + "rule": "frontmatter-fence", + "severity": "error", + "message": "ADR frontmatter is missing its closing --- fence", + "path": "docs/adr/0002-no-closing-fence.md", + "field": "frontmatter" + } + ] +} diff --git a/packages/cli/test/fixtures/json-report/explain-lru.json b/packages/cli/test/fixtures/json-report/explain-lru.json new file mode 100644 index 00000000..c001bd79 --- /dev/null +++ b/packages/cli/test/fixtures/json-report/explain-lru.json @@ -0,0 +1,19 @@ +{ + "path": "src/cache/lru.ts", + "governedBy": [], + "governing": [], + "activeProposals": [], + "history": [], + "markers": { + "state": "scanned", + "windowBytes": 8192, + "scannedBytes": 30, + "fileBytes": 30, + "truncated": false, + "declarationLimit": 64, + "totalDeclarations": 0, + "omittedDeclarations": 0, + "declared": [] + }, + "findings": [] +} diff --git a/packages/cli/test/fixtures/json-report/explain-missing.json b/packages/cli/test/fixtures/json-report/explain-missing.json new file mode 100644 index 00000000..8aaf68d4 --- /dev/null +++ b/packages/cli/test/fixtures/json-report/explain-missing.json @@ -0,0 +1,95 @@ +{ + "path": "src/sync/missing.ts", + "governedBy": [ + { + "recordId": "0001", + "title": "Use the sync protocol", + "status": "accepted", + "bucket": "governing", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ] + }, + { + "recordId": "0003", + "title": "Poll for changes", + "status": "superseded", + "bucket": "history", + "supersededBy": "0004", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ] + }, + { + "recordId": "0005", + "title": "Batch sync writes", + "status": "proposed", + "bucket": "activeProposals", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ] + } + ], + "governing": [ + { + "recordId": "0001", + "title": "Use the sync protocol", + "status": "accepted", + "bucket": "governing", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ] + } + ], + "activeProposals": [ + { + "recordId": "0005", + "title": "Batch sync writes", + "status": "proposed", + "bucket": "activeProposals", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ] + } + ], + "history": [ + { + "recordId": "0003", + "title": "Poll for changes", + "status": "superseded", + "bucket": "history", + "supersededBy": "0004", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ] + } + ], + "markers": { + "state": "absent", + "windowBytes": 8192, + "truncated": false, + "declarationLimit": 64, + "totalDeclarations": 0, + "omittedDeclarations": 0, + "declared": [] + }, + "findings": [] +} diff --git a/packages/cli/test/fixtures/json-report/explain-protocol.json b/packages/cli/test/fixtures/json-report/explain-protocol.json new file mode 100644 index 00000000..77963199 --- /dev/null +++ b/packages/cli/test/fixtures/json-report/explain-protocol.json @@ -0,0 +1,121 @@ +{ + "path": "src/sync/protocol.ts", + "governedBy": [ + { + "recordId": "0001", + "title": "Use the sync protocol", + "status": "accepted", + "bucket": "governing", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ] + }, + { + "recordId": "0003", + "title": "Poll for changes", + "status": "superseded", + "bucket": "history", + "supersededBy": "0004", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ] + }, + { + "recordId": "0004", + "title": "Push changes over websockets", + "status": "accepted", + "bucket": "governing", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/protocol.ts" + } + ] + }, + { + "recordId": "0005", + "title": "Batch sync writes", + "status": "proposed", + "bucket": "activeProposals", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ] + } + ], + "governing": [ + { + "recordId": "0001", + "title": "Use the sync protocol", + "status": "accepted", + "bucket": "governing", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ] + }, + { + "recordId": "0004", + "title": "Push changes over websockets", + "status": "accepted", + "bucket": "governing", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/protocol.ts" + } + ] + } + ], + "activeProposals": [ + { + "recordId": "0005", + "title": "Batch sync writes", + "status": "proposed", + "bucket": "activeProposals", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ] + } + ], + "history": [ + { + "recordId": "0003", + "title": "Poll for changes", + "status": "superseded", + "bucket": "history", + "supersededBy": "0004", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ] + } + ], + "markers": { + "state": "scanned", + "windowBytes": 8192, + "scannedBytes": 27, + "fileBytes": 27, + "truncated": false, + "declarationLimit": 64, + "totalDeclarations": 0, + "omittedDeclarations": 0, + "declared": [] + }, + "findings": [] +} diff --git a/packages/cli/test/fixtures/json-report/explain-retry.json b/packages/cli/test/fixtures/json-report/explain-retry.json new file mode 100644 index 00000000..53830edf --- /dev/null +++ b/packages/cli/test/fixtures/json-report/explain-retry.json @@ -0,0 +1,130 @@ +{ + "path": "src/sync/retry.ts", + "governedBy": [ + { + "recordId": "0001", + "title": "Use the sync protocol", + "status": "accepted", + "bucket": "governing", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ] + }, + { + "recordId": "0002", + "title": "Retry on transient failure", + "status": "accepted", + "bucket": "governing", + "firedMatchers": [], + "declaredBy": [ + { + "path": "src/sync/retry.ts", + "line": 1, + "ref": "0002" + } + ] + }, + { + "recordId": "0003", + "title": "Poll for changes", + "status": "superseded", + "bucket": "history", + "supersededBy": "0004", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ] + }, + { + "recordId": "0005", + "title": "Batch sync writes", + "status": "proposed", + "bucket": "activeProposals", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ] + } + ], + "governing": [ + { + "recordId": "0001", + "title": "Use the sync protocol", + "status": "accepted", + "bucket": "governing", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ] + }, + { + "recordId": "0002", + "title": "Retry on transient failure", + "status": "accepted", + "bucket": "governing", + "firedMatchers": [], + "declaredBy": [ + { + "path": "src/sync/retry.ts", + "line": 1, + "ref": "0002" + } + ] + } + ], + "activeProposals": [ + { + "recordId": "0005", + "title": "Batch sync writes", + "status": "proposed", + "bucket": "activeProposals", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ] + } + ], + "history": [ + { + "recordId": "0003", + "title": "Poll for changes", + "status": "superseded", + "bucket": "history", + "supersededBy": "0004", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ] + } + ], + "markers": { + "state": "scanned", + "windowBytes": 8192, + "scannedBytes": 40, + "fileBytes": 40, + "truncated": false, + "declarationLimit": 64, + "totalDeclarations": 1, + "omittedDeclarations": 0, + "declared": [ + { + "ref": "0002", + "line": 1 + } + ] + }, + "findings": [] +} diff --git a/packages/cli/test/fixtures/json-report/explain-stale.json b/packages/cli/test/fixtures/json-report/explain-stale.json new file mode 100644 index 00000000..d098c4fa --- /dev/null +++ b/packages/cli/test/fixtures/json-report/explain-stale.json @@ -0,0 +1,125 @@ +{ + "path": "src/sync/stale.ts", + "governedBy": [ + { + "recordId": "0001", + "title": "Use the sync protocol", + "status": "accepted", + "bucket": "governing", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ] + }, + { + "recordId": "0003", + "title": "Poll for changes", + "status": "superseded", + "bucket": "history", + "supersededBy": "0004", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ], + "declaredBy": [ + { + "path": "src/sync/stale.ts", + "line": 1, + "ref": "0003" + } + ] + }, + { + "recordId": "0005", + "title": "Batch sync writes", + "status": "proposed", + "bucket": "activeProposals", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ] + } + ], + "governing": [ + { + "recordId": "0001", + "title": "Use the sync protocol", + "status": "accepted", + "bucket": "governing", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ] + } + ], + "activeProposals": [ + { + "recordId": "0005", + "title": "Batch sync writes", + "status": "proposed", + "bucket": "activeProposals", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ] + } + ], + "history": [ + { + "recordId": "0003", + "title": "Poll for changes", + "status": "superseded", + "bucket": "history", + "supersededBy": "0004", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ], + "declaredBy": [ + { + "path": "src/sync/stale.ts", + "line": 1, + "ref": "0003" + } + ] + } + ], + "markers": { + "state": "scanned", + "windowBytes": 8192, + "scannedBytes": 40, + "fileBytes": 40, + "truncated": false, + "declarationLimit": 64, + "totalDeclarations": 1, + "omittedDeclarations": 0, + "declared": [ + { + "ref": "0003", + "line": 1 + } + ] + }, + "findings": [ + { + "rule": "stale-marker", + "severity": "warn", + "message": "Source marker \"@adr 0003\" in src/sync/stale.ts:1 names superseded ADR 0003; update it to \"@adr 0004\" or re-affirm 0003", + "path": "src/sync/stale.ts", + "field": "marker", + "pattern": "0003" + } + ] +} diff --git a/packages/cli/test/fixtures/json-report/lint-broken.json b/packages/cli/test/fixtures/json-report/lint-broken.json new file mode 100644 index 00000000..79baa004 --- /dev/null +++ b/packages/cli/test/fixtures/json-report/lint-broken.json @@ -0,0 +1,12 @@ +{ + "checked": 2, + "findings": [ + { + "rule": "frontmatter-fence", + "severity": "error", + "message": "ADR frontmatter is missing its closing --- fence", + "path": "docs/adr/0002-no-closing-fence.md", + "field": "frontmatter" + } + ] +} diff --git a/packages/cli/test/fixtures/json-report/lint.json b/packages/cli/test/fixtures/json-report/lint.json new file mode 100644 index 00000000..d31212f1 --- /dev/null +++ b/packages/cli/test/fixtures/json-report/lint.json @@ -0,0 +1,11 @@ +{ + "checked": 7, + "findings": [ + { + "rule": "corpus-file-skipped", + "severity": "warn", + "message": "Markdown file is in a subdirectory of the corpus, and discovery reads only the top level of the corpus directory; move it to the corpus root as -.md for it to be linted and enforced", + "path": "docs/adr/notes/0008-nested-record.md" + } + ] +} diff --git a/packages/cli/test/fixtures/json-report/new.json b/packages/cli/test/fixtures/json-report/new.json new file mode 100644 index 00000000..4e30147b --- /dev/null +++ b/packages/cli/test/fixtures/json-report/new.json @@ -0,0 +1,4 @@ +{ + "id": "0008", + "path": "docs/adr/0008-cap-batch-size.md" +} diff --git a/packages/cli/test/json-report.test.ts b/packages/cli/test/json-report.test.ts new file mode 100644 index 00000000..b348357d --- /dev/null +++ b/packages/cli/test/json-report.test.ts @@ -0,0 +1,112 @@ +/** + * `adr lint --json`, `adr new --json`, and `adr explain --json` come from core's report + * formatters (ADR-0031 action item 4, ADR-0053 action item 8) and are byte-for-byte what the + * CLI printed when it assembled them inline. + * + * The goldens in `fixtures/json-report/` were captured from the CLI **before** the inline + * assembly was removed, run over the conformance corpus (`packages/core/test/conformance/golden/repo`) + * and a two-record corpus that does not parse (written below). Because the CLI JSON carries an + * additive-only obligation (ADR-0031 clause 5), a key moved by the convergence would be a + * contract break, so equality here is the whole claim. `--as-of ` cannot be pinned in + * a golden (the commit moves); `packages/core/test/report.test.ts` covers the ref provenance + * keys' order, and the one-off before/after comparison on this repository's corpus is + * recorded in the change that landed this file. + * + * ADR-0016: observed failing. Swapping `governedBy` and `governing` in `buildExplainReport` + * failed every explain case; emitting `findings` before `checked` in `buildLintReport` failed + * both lint cases; leaking `content` through `buildNewAdrReport` failed the new case. Each + * mutation was reverted. + */ + +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { cp, readFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { cleanupTestDir, resetTestDir, writeText } from '../../core/test/helpers.ts'; + +const CLI_PATH = resolve(process.cwd(), 'packages/cli/src/index.ts'); +const CORPUS = resolve(process.cwd(), 'packages/core/test/conformance/golden/repo'); +const GOLDEN = resolve(process.cwd(), 'packages/cli/test/fixtures/json-report'); +const BROKEN = 'cli-json-report-broken'; +const NEW = 'cli-json-report-new'; + +let brokenRoot = ''; +let newRoot = ''; + +async function runAdr(args: string[], cwd: string) { + const proc = Bun.spawn([process.execPath, CLI_PATH, ...args], { + cwd, + stdout: 'pipe', + stderr: 'pipe', + env: { ...process.env, NO_COLOR: '1' }, + }); + const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); + return { stdout, exitCode }; +} + +async function golden(name: string): Promise { + return readFile(join(GOLDEN, name), 'utf8'); +} + +beforeAll(async () => { + brokenRoot = await resetTestDir(BROKEN); + await cp(join(CORPUS, 'docs/adr/0001-use-the-sync-protocol.md'), join(brokenRoot, 'docs/adr/0001-use-the-sync-protocol.md')); + await writeText(join(brokenRoot, 'docs/adr/0002-no-closing-fence.md'), '---\nid: "0002"\ntitle: No closing fence\n\n# Broken\n'); + await writeText(join(brokenRoot, 'src/retry.ts'), '// @adr 0001\nexport const retry = true;\n'); + newRoot = await resetTestDir(NEW); + await cp(join(CORPUS, 'docs'), join(newRoot, 'docs'), { recursive: true }); +}); + +afterAll(async () => { + await cleanupTestDir(BROKEN); + await cleanupTestDir(NEW); +}); + +describe('adr lint --json is core\'s LintReport, unchanged', () => { + test('a clean corpus', async () => { + const result = await runAdr(['lint', '--json'], CORPUS); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(await golden('lint.json')); + }); + + test('a corpus that does not parse', async () => { + const result = await runAdr(['lint', '--json'], brokenRoot); + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(await golden('lint-broken.json')); + }); +}); + +describe('adr explain --json is core\'s ExplainReport, unchanged', () => { + for (const name of ['retry', 'protocol', 'stale', 'lru', 'missing']) { + const path = name === 'lru' ? 'src/cache/lru.ts' : `src/sync/${name}.ts`; + test(`${path}`, async () => { + const result = await runAdr(['explain', '--json', path], CORPUS); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(await golden(`explain-${name}.json`)); + }); + test(`${path} --as-of 2026-07-15`, async () => { + const result = await runAdr(['explain', '--json', '--as-of', '2026-07-15', path], CORPUS); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(await golden(`explain-as-of-${name}.json`)); + }); + } + + test('a corpus that does not parse answers with empty groups', async () => { + const result = await runAdr(['explain', '--json', 'src/retry.ts'], brokenRoot); + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(await golden('explain-broken.json')); + }); + + test('a corpus that does not parse still reports the --as-of resolution', async () => { + const result = await runAdr(['explain', '--json', '--as-of', '2026-07-15T23:30:00-05:00', 'src/retry.ts'], brokenRoot); + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(await golden('explain-broken-as-of.json')); + }); +}); + +describe('adr new --json is core\'s NewAdrReport, unchanged', () => { + test('id and display path only', async () => { + const result = await runAdr(['new', '--json', '--status', 'proposed', 'Cap batch size'], newRoot); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(await golden('new.json')); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d78361f9..5b635965 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -40,9 +40,25 @@ export type { ResolveDecisionsAsOfInput, } from './temporal/decisions.ts'; export { buildQueueReport } from './queue/kernel.ts'; -export { resolveAsOf } from './queue/as-of.ts'; +export { resolveAsOf, todayUtcDate } from './queue/as-of.ts'; export type { AsOfResolution } from './queue/as-of.ts'; export { formatQueueReportJson, formatQueueReportMarkdown } from './queue/format.ts'; +export { + buildExplainReport, + buildLintReport, + buildNewAdrReport, + explainMarkerScan, + formatExplainReportJson, + formatLintReportJson, + formatNewAdrReportJson, + type ExplainAsOf, + type ExplainAsOfResolution, + type ExplainMarkerScan, + type ExplainReport, + type ExplainReportInput, + type LintReport, + type NewAdrReport, +} from './report/index.ts'; export { sortCorpusFindings, sortItemFindings, sortQueueItems } from './queue/sort.ts'; export { mapFindingToCorpusFinding, RULE_TO_CORPUS_CODE } from './queue/findings.ts'; export { diff --git a/packages/core/src/queue/as-of.ts b/packages/core/src/queue/as-of.ts index b88c6024..5887e703 100644 --- a/packages/core/src/queue/as-of.ts +++ b/packages/core/src/queue/as-of.ts @@ -60,3 +60,25 @@ export function resolveAsOf(value: string): AsOfResolution { return { ok: false, code: 'invalid' }; } + +/** + * Today's UTC calendar date, `YYYY-MM-DD` — the absent-input clause of + * `cli-contract.md §As-Of Resolution` ("if absent: the current UTC date"). + * + * `resolveAsOf` turns a *supplied* value into a date and takes a required string, so it + * cannot express the absent case. Until this was exported the default was inlined at four + * call sites (`adr queue`, the queue Action, `adr new`'s scaffold, and the SDK's `queue()`), + * and a consumer defaulting from a *local* calendar date rather than a UTC one disagreed with + * CI near midnight with no error anywhere (ADR-0031 action item 8). + * + * This is the one clock read the rule needs, and it belongs at a boundary: pure kernels such + * as `buildQueueReport` keep taking the date as an argument. `now` exists so a caller (and a + * test) can supply the instant instead of reading the clock; it is not a second rule. + */ +export function todayUtcDate(now: Date = new Date()): string { + const date = now.toISOString().slice(0, 10); + // `toISOString` emits `±YYYYYY-…` outside years 0000-9999 (see `resolveAsOf`); refuse such an + // instant rather than hand the kernel a non-date it would compare lexicographically. + if (!isCalendarDate(date)) throw new RangeError(`todayUtcDate: ${now.toISOString()} is outside years 0000-9999.`); + return date; +} diff --git a/packages/core/src/report/index.ts b/packages/core/src/report/index.ts new file mode 100644 index 00000000..2830ea0f --- /dev/null +++ b/packages/core/src/report/index.ts @@ -0,0 +1,201 @@ +/** + * @adrkit/core — canonical shapes and serializers for `adr lint --json`, `adr new --json`, + * and `adr explain --json`. + * + * Until ADR-0031 action item 4 these three were assembled inline in + * `packages/cli/src/index.ts`, so the CLI JSON contract for them existed nowhere in core + * while `adr queue` (`formatQueueReportJson`) and `adr graph` (`renderJsonGraph`) already + * came from core formatters. Each command now builds its report here and prints the + * formatter's string; the CLI supplies inputs only. + * + * **Byte-for-byte unchanged.** Every key, its insertion order, and every conditional key's + * presence rule is carried over exactly from the inline assembly, and the formatters are + * `JSON.stringify(report, null, 2) + "\n"` like the queue's. The CLI JSON is a documented + * contract with an additive-only obligation (ADR-0031 clause 5), so a convergence that moved + * a key would have been a break dressed as hygiene. `packages/cli/test/json-report.test.ts` + * pins the output against goldens captured from the inline assembly before it was removed. + * + * Pure: no clock, no filesystem, no git. The `--as-of` provenance on an explain report is + * resolved by the CLI (it may run git) and handed in as plain fields. + */ + +import type { GoverningDecision } from '../check/decisions.ts'; +import type { MarkerScanState, SourceMarkerScan } from '../markers/read.ts'; +import { MARKER_DECLARATION_FILE_CAP, MARKER_HEADER_WINDOW_BYTES } from '../markers/scan.ts'; +import { bucketDecisions } from '../check/decisions.ts'; +import type { NewAdrResult } from '../scaffold/new.ts'; +import type { DecisionAsOf, DecisionsAsOf } from '../temporal/decisions.ts'; +import type { Finding } from '../validate/findings.ts'; +import type { LintCorpusResult } from '../validate/index.ts'; + +/* ------------------------------------------------------------------ * + * adr lint --json + * ------------------------------------------------------------------ */ + +/** `adr lint --json`: how many records were checked, and the findings in canonical order. */ +export interface LintReport { + checked: number; + findings: Finding[]; +} + +/** + * Build the lint report. `findings` must already be in canonical order (`sortFindings`); the + * CLI sorts once and uses the same array for its exit code and human view. + */ +export function buildLintReport(result: Pick, findings: Finding[]): LintReport { + return { checked: result.checked, findings }; +} + +export function formatLintReportJson(report: LintReport): string { + return `${JSON.stringify(report, null, 2)}\n`; +} + +/* ------------------------------------------------------------------ * + * adr new --json + * ------------------------------------------------------------------ */ + +/** + * `adr new --json`: the allocated id and the display path of the written record. The + * scaffolded `content` is deliberately not part of it — the file is on disk. + */ +export interface NewAdrReport { + id: string; + path: string; +} + +export function buildNewAdrReport(result: Pick): NewAdrReport { + return { id: result.id, path: result.path }; +} + +export function formatNewAdrReportJson(report: NewAdrReport): string { + return `${JSON.stringify(report, null, 2)}\n`; +} + +/* ------------------------------------------------------------------ * + * adr explain --json + * ------------------------------------------------------------------ */ + +/** + * The `markers` block: what the inbound-marker scan of the asked-about file reached, so + * "declares nothing" and "could not look" never render alike (ADR-0016). + */ +export interface ExplainMarkerScan { + state: MarkerScanState; + windowBytes: number; + scannedBytes?: number; + fileBytes?: number; + truncated: boolean; + declarationLimit: number; + totalDeclarations: number; + omittedDeclarations: number; + declared: Array<{ ref: string; line: number }>; +} + +export function explainMarkerScan(scan: SourceMarkerScan): ExplainMarkerScan { + const omittedDeclarations = scan.omittedMarkers ?? 0; + return { + state: scan.state, + windowBytes: MARKER_HEADER_WINDOW_BYTES, + ...(scan.scannedBytes === undefined ? {} : { scannedBytes: scan.scannedBytes }), + ...(scan.fileBytes === undefined ? {} : { fileBytes: scan.fileBytes }), + truncated: scan.truncated, + declarationLimit: MARKER_DECLARATION_FILE_CAP, + totalDeclarations: scan.markers.length + omittedDeclarations, + omittedDeclarations, + declared: scan.markers.map((marker) => ({ ref: marker.ref, line: marker.line })), + }; +} + +/** How an `--as-of` value was resolved. `commit`/`committedAt` are present for a git ref. */ +export interface ExplainAsOfResolution { + /** Exactly what the user typed. */ + requested: string; + /** The UTC calendar date the corpus is placed on. */ + date: string; + resolvedFrom: 'date' | 'ref'; + commit?: string; + committedAt?: string; +} + +/** + * The `asOf` block, present only when `--as-of` was given. Additive: the present-tense + * `governing`, `activeProposals`, and `history` keys beside it keep their meaning (ADR-0039). + */ +export interface ExplainAsOf { + requested: string; + date: string; + resolvedFrom: 'date' | 'ref'; + commit?: string; + committedAt?: string; + governing: DecisionAsOf[]; + activeProposals: DecisionAsOf[]; + history: DecisionAsOf[]; + notYetRecorded: DecisionAsOf[]; + undetermined: DecisionAsOf[]; +} + +export interface ExplainReport { + path: string; + asOf?: ExplainAsOf; + governedBy: GoverningDecision[]; + governing: GoverningDecision[]; + activeProposals: GoverningDecision[]; + history: GoverningDecision[]; + markers: ExplainMarkerScan; + findings: Finding[]; +} + +export interface ExplainReportInput { + path: string; + /** The inbound-marker scan of `path`, reported on both branches. */ + scan: SourceMarkerScan; + /** + * Every decision reaching `path`, in `mergeSourceDeclarations` order. Omit on the + * corpus-error branch, where the report answers with empty groups. + */ + governedBy?: GoverningDecision[]; + /** The `--as-of` resolution, when the flag was given. */ + asOf?: ExplainAsOfResolution; + /** + * The as-of view of `governedBy`. Omitted with `asOf` set means the corpus did not parse: + * the resolution is still reported, with empty groups, because what was asked is a fact + * independent of whether it could be answered. + */ + asOfView?: DecisionsAsOf; + /** Findings in canonical order: the corpus's on the error branch, else resolution's. */ + findings: Finding[]; +} + +function explainAsOf(resolved: ExplainAsOfResolution, view: DecisionsAsOf | undefined): ExplainAsOf { + return { + requested: resolved.requested, + date: resolved.date, + resolvedFrom: resolved.resolvedFrom, + ...(resolved.commit === undefined ? {} : { commit: resolved.commit }), + ...(resolved.committedAt === undefined ? {} : { committedAt: resolved.committedAt }), + governing: view?.governing ?? [], + activeProposals: view?.activeProposals ?? [], + history: view?.history ?? [], + notYetRecorded: view?.notYetRecorded ?? [], + undetermined: view?.undetermined ?? [], + }; +} + +export function buildExplainReport(input: ExplainReportInput): ExplainReport { + const governedBy = input.governedBy ?? []; + const buckets = bucketDecisions(governedBy); + return { + path: input.path, + ...(input.asOf === undefined ? {} : { asOf: explainAsOf(input.asOf, input.asOfView) }), + governedBy, + governing: buckets.governing, + activeProposals: buckets.activeProposals, + history: buckets.history, + markers: explainMarkerScan(input.scan), + findings: input.findings, + }; +} + +export function formatExplainReportJson(report: ExplainReport): string { + return `${JSON.stringify(report, null, 2)}\n`; +} diff --git a/packages/core/src/scaffold/new.ts b/packages/core/src/scaffold/new.ts index b0fbbb49..c364747c 100644 --- a/packages/core/src/scaffold/new.ts +++ b/packages/core/src/scaffold/new.ts @@ -2,6 +2,7 @@ import { mkdir, writeFile } from 'node:fs/promises'; import { basename, dirname, resolve } from 'node:path'; import { Status, SCHEMA_VERSION, AdrFrontmatter } from '../schema/adr.schema.ts'; import { discoverAdrFiles, normalizeDisplayPath } from '../load/corpus.ts'; +import { todayUtcDate } from '../queue/as-of.ts'; export interface NewAdrOptions { title: string; @@ -28,10 +29,6 @@ export class ScaffoldError extends Error { } } -function todayIsoDate(): string { - return new Date().toISOString().slice(0, 10); -} - export function slugifyTitle(title: string): string { const slug = title .normalize('NFKD') @@ -190,7 +187,7 @@ export async function createAdr(options: NewAdrOptions): Promise { const fileName = `${id}-${slugifyTitle(title)}.md`; const path = resolve(cwd, dir, fileName); const displayPath = normalizeDisplayPath(path, cwd); - const content = renderAdrRecord({ id, title, status, date: options.date ?? todayIsoDate() }); + const content = renderAdrRecord({ id, title, status, date: options.date ?? todayUtcDate() }); if (options.write !== false) { await mkdir(dirname(path), { recursive: true }); diff --git a/packages/core/test/conformance/golden/.gitattributes b/packages/core/test/conformance/golden/.gitattributes new file mode 100644 index 00000000..f45a9849 --- /dev/null +++ b/packages/core/test/conformance/golden/.gitattributes @@ -0,0 +1,3 @@ +# The conformance fixture is compared byte for byte (queue.json carries a corpus +# fingerprint over the record bytes), so a CRLF checkout must not rewrite it. +* text eol=lf diff --git a/packages/core/test/conformance/golden/README.md b/packages/core/test/conformance/golden/README.md new file mode 100644 index 00000000..0636dfa1 --- /dev/null +++ b/packages/core/test/conformance/golden/README.md @@ -0,0 +1,40 @@ +# adrkit conformance fixture + +A small fixed corpus and the golden CLI JSON it produces: the conformance fixture that +[ADR-0030](../../../../../docs/adr/0030-keep-extension-surfaces-that-carry-a-dependency-tree-outside-this-repository.md) +clause 5 names and +[ADR-0031](../../../../../docs/adr/0031-publish-a-narrow-consumer-sdk-as-the-contract-and-document-the-cli-json-as-its-s.md) +action item 6 assigns to `@adrkit/sdk` as its own test fixture. + +| File | What it is | +|---|---| +| `repo/` | A repository root: `docs/adr/` (seven records plus one nested file discovery cannot see) and `src/` files, two of them carrying inbound `@adr` markers. | +| `manifest.json` | The exact invocations, run with `repo/` as the working directory, and their exit codes. | +| `queue.json` | `adr queue --format json --as-of 2026-07-15` (QueueReport v1). | +| `check.json` | `adr check --json` over five paths, one of which does not exist. | + +What the corpus exercises: an accepted record governing by pattern, one governing only by +marker, a supersession whose replaced record is still named by a marker (`stale-marker`), a +`proposed` record that is queued, overdue on the pinned date, with two approvals that are one +identity under casefold and one open objection, a `proposed` record with no review block, a +`draft`, `relatesTo` and `conflictsWith` edges, a skipped nested file (`warn`), and an absent +path in the check. + +## Using it downstream + +Run each `manifest.json` command with `repo/` as the working directory against the version of +`adr` you pin, and compare stdout byte for byte with the golden, and the exit code with +`exitCode`. A difference is a change to the CLI JSON contract that the version you pin +carries. The fixture is checked out with LF line endings (`.gitattributes`), because +`queue.json`'s `corpusFingerprint` is computed over the record bytes. + +## In this repository + +- `packages/cli/test/conformance.test.ts` runs the CLI over the fixture and requires both + goldens byte for byte. +- `packages/sdk/test/conformance.test.ts` runs `@adrkit/sdk` over the same corpus and requires + its `queue()` and `governing()` to describe the same decisions as the goldens. + +The goldens are a contract, not a snapshot: regenerate them only in the change that +deliberately alters the CLI JSON, and say so in the CHANGELOG. To regenerate, run the commands +in `manifest.json` from `repo/` with `bun ../../../../../cli/src/index.ts > ../`. diff --git a/packages/core/test/conformance/golden/check.json b/packages/core/test/conformance/golden/check.json new file mode 100644 index 00000000..f1b9d767 --- /dev/null +++ b/packages/core/test/conformance/golden/check.json @@ -0,0 +1,197 @@ +{ + "changedFiles": [ + "src/cache/lru.ts", + "src/sync/missing.ts", + "src/sync/protocol.ts", + "src/sync/retry.ts", + "src/sync/stale.ts" + ], + "governedBy": [ + { + "recordId": "0001", + "title": "Use the sync protocol", + "status": "accepted", + "bucket": "governing", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ] + }, + { + "recordId": "0002", + "title": "Retry on transient failure", + "status": "accepted", + "bucket": "governing", + "firedMatchers": [], + "declaredBy": [ + { + "path": "src/sync/retry.ts", + "line": 1, + "ref": "0002" + } + ] + }, + { + "recordId": "0003", + "title": "Poll for changes", + "status": "superseded", + "bucket": "history", + "supersededBy": "0004", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ], + "declaredBy": [ + { + "path": "src/sync/stale.ts", + "line": 1, + "ref": "0003" + } + ] + }, + { + "recordId": "0004", + "title": "Push changes over websockets", + "status": "accepted", + "bucket": "governing", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/protocol.ts" + } + ] + }, + { + "recordId": "0005", + "title": "Batch sync writes", + "status": "proposed", + "bucket": "activeProposals", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ] + } + ], + "governing": [ + { + "recordId": "0001", + "title": "Use the sync protocol", + "status": "accepted", + "bucket": "governing", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ] + }, + { + "recordId": "0002", + "title": "Retry on transient failure", + "status": "accepted", + "bucket": "governing", + "firedMatchers": [], + "declaredBy": [ + { + "path": "src/sync/retry.ts", + "line": 1, + "ref": "0002" + } + ] + }, + { + "recordId": "0004", + "title": "Push changes over websockets", + "status": "accepted", + "bucket": "governing", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/protocol.ts" + } + ] + } + ], + "activeProposals": [ + { + "recordId": "0005", + "title": "Batch sync writes", + "status": "proposed", + "bucket": "activeProposals", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ] + } + ], + "history": [ + { + "recordId": "0003", + "title": "Poll for changes", + "status": "superseded", + "bucket": "history", + "supersededBy": "0004", + "firedMatchers": [ + { + "type": "path", + "pattern": "src/sync/**" + } + ], + "declaredBy": [ + { + "path": "src/sync/stale.ts", + "line": 1, + "ref": "0003" + } + ] + } + ], + "changedRecords": [], + "findings": [ + { + "rule": "stale-marker", + "severity": "warn", + "message": "Source marker \"@adr 0003\" in src/sync/stale.ts:1 names superseded ADR 0003; update it to \"@adr 0004\" or re-affirm 0003", + "path": "src/sync/stale.ts", + "field": "marker", + "pattern": "0003" + } + ], + "markerScan": { + "totalCandidates": 5, + "limit": 3000, + "counts": { + "scanned": 4, + "absent": 1, + "unreadable": 0, + "out-of-tree": 0, + "truncated": 0, + "skipped": 0 + }, + "absentPaths": [ + "src/sync/missing.ts" + ], + "unreadablePaths": [], + "outOfTreePaths": [], + "truncatedPaths": [], + "skippedPaths": [], + "declarations": { + "total": 2, + "retained": 2, + "omitted": 0, + "perFileOmitted": 0, + "batchOmitted": 0, + "perFileLimit": 64, + "batchLimit": 10000 + } + }, + "ok": true +} diff --git a/packages/core/test/conformance/golden/manifest.json b/packages/core/test/conformance/golden/manifest.json new file mode 100644 index 00000000..0ca5ce74 --- /dev/null +++ b/packages/core/test/conformance/golden/manifest.json @@ -0,0 +1,17 @@ +{ + "fixture": "adrkit-conformance", + "version": 1, + "cwd": "repo", + "commands": { + "queue": { + "args": ["queue", "--format", "json", "--as-of", "2026-07-15"], + "exitCode": 0, + "golden": "queue.json" + }, + "check": { + "args": ["check", "--json", "src/sync/retry.ts", "src/sync/protocol.ts", "src/sync/stale.ts", "src/cache/lru.ts", "src/sync/missing.ts"], + "exitCode": 0, + "golden": "check.json" + } + } +} diff --git a/packages/core/test/conformance/golden/queue.json b/packages/core/test/conformance/golden/queue.json new file mode 100644 index 00000000..4f5454c4 --- /dev/null +++ b/packages/core/test/conformance/golden/queue.json @@ -0,0 +1,67 @@ +{ + "version": "1", + "asOf": "2026-07-15", + "corpusFingerprint": "f2ff9be638fafd1b5d94aba420d571044dbc146d9b5f1a78563d663da81f8bd6", + "totalItems": 2, + "totalCorpusFindings": 1, + "itemsWithFindings": 1, + "items": [ + { + "id": "0005", + "title": "Batch sync writes", + "sourcePath": "docs/adr/0005-batch-sync-writes.md", + "tier": "async", + "tierLabel": "asynchronous human review", + "queuedAt": "2026-07-01T00:00:00.000Z", + "slaDays": 10, + "reviewBy": null, + "slaState": "overdue", + "deadlineDate": "2026-07-11", + "routingTargets": [], + "quorum": 2, + "approvalCount": 1, + "unresolvedObjectionCount": 1, + "resolvedObjectionCount": 1, + "escalatedAt": null, + "decidedAt": null, + "itemFindings": [ + { + "code": "item.deciders-empty", + "severity": "info", + "message": "deciders is empty; routing targets cannot be determined — add at least one decider identity to this record" + } + ] + }, + { + "id": "0006", + "title": "Adopt a queue", + "sourcePath": "docs/adr/0006-adopt-a-queue.md", + "tier": null, + "tierLabel": null, + "queuedAt": null, + "slaDays": null, + "reviewBy": null, + "slaState": "not-queued", + "deadlineDate": null, + "routingTargets": [ + "@dave", + "@erin" + ], + "quorum": null, + "approvalCount": 0, + "unresolvedObjectionCount": 0, + "resolvedObjectionCount": 0, + "escalatedAt": null, + "decidedAt": null, + "itemFindings": [] + } + ], + "corpusFindings": [ + { + "sourcePath": "docs/adr/notes/0008-nested-record.md", + "code": "corpus.file-skipped", + "severity": "warn", + "message": "Markdown file is in a subdirectory of the corpus, and discovery reads only the top level of the corpus directory; move it to the corpus root as -.md for it to be linted and enforced" + } + ] +} diff --git a/packages/core/test/conformance/golden/repo/docs/adr/0001-use-the-sync-protocol.md b/packages/core/test/conformance/golden/repo/docs/adr/0001-use-the-sync-protocol.md new file mode 100644 index 00000000..a4079913 --- /dev/null +++ b/packages/core/test/conformance/golden/repo/docs/adr/0001-use-the-sync-protocol.md @@ -0,0 +1,20 @@ +--- +schemaVersion: 0.1.0 +id: "0001" +title: Use the sync protocol +status: accepted +date: 2026-07-18 +deciders: ["@tester"] +tags: [] +scope: component +reversibility: unknown +blastRadius: component +relatesTo: [] +affects: + - type: path + pattern: "src/sync/**" +provenance: + authoredBy: human +--- + +# ADR-0001: Use the sync protocol diff --git a/packages/core/test/conformance/golden/repo/docs/adr/0002-retry-on-transient-failure.md b/packages/core/test/conformance/golden/repo/docs/adr/0002-retry-on-transient-failure.md new file mode 100644 index 00000000..ba40f488 --- /dev/null +++ b/packages/core/test/conformance/golden/repo/docs/adr/0002-retry-on-transient-failure.md @@ -0,0 +1,18 @@ +--- +schemaVersion: 0.1.0 +id: "0002" +title: Retry on transient failure +status: accepted +date: 2026-07-18 +deciders: ["@tester"] +tags: [] +scope: component +reversibility: unknown +blastRadius: component +relatesTo: ["0001"] +affects: [] +provenance: + authoredBy: human +--- + +# ADR-0002: Retry on transient failure diff --git a/packages/core/test/conformance/golden/repo/docs/adr/0003-poll-for-changes.md b/packages/core/test/conformance/golden/repo/docs/adr/0003-poll-for-changes.md new file mode 100644 index 00000000..eb2ce515 --- /dev/null +++ b/packages/core/test/conformance/golden/repo/docs/adr/0003-poll-for-changes.md @@ -0,0 +1,21 @@ +--- +schemaVersion: 0.1.0 +id: "0003" +title: Poll for changes +status: superseded +supersededBy: "0004" +date: 2026-07-18 +deciders: ["@tester"] +tags: [] +scope: component +reversibility: unknown +blastRadius: component +relatesTo: [] +affects: + - type: path + pattern: "src/sync/**" +provenance: + authoredBy: human +--- + +# ADR-0003: Poll for changes diff --git a/packages/core/test/conformance/golden/repo/docs/adr/0004-push-changes-over-websockets.md b/packages/core/test/conformance/golden/repo/docs/adr/0004-push-changes-over-websockets.md new file mode 100644 index 00000000..59c8e70e --- /dev/null +++ b/packages/core/test/conformance/golden/repo/docs/adr/0004-push-changes-over-websockets.md @@ -0,0 +1,21 @@ +--- +schemaVersion: 0.1.0 +id: "0004" +title: Push changes over websockets +status: accepted +date: 2026-07-18 +deciders: ["@tester"] +tags: [] +scope: component +reversibility: unknown +blastRadius: component +relatesTo: [] +affects: + - type: path + pattern: "src/sync/protocol.ts" +provenance: + authoredBy: human +supersedes: ["0003"] +--- + +# ADR-0004: Push changes over websockets diff --git a/packages/core/test/conformance/golden/repo/docs/adr/0005-batch-sync-writes.md b/packages/core/test/conformance/golden/repo/docs/adr/0005-batch-sync-writes.md new file mode 100644 index 00000000..a18a73b8 --- /dev/null +++ b/packages/core/test/conformance/golden/repo/docs/adr/0005-batch-sync-writes.md @@ -0,0 +1,33 @@ +--- +schemaVersion: 0.1.0 +id: "0005" +title: Batch sync writes +status: proposed +date: 2026-07-18 +deciders: [] +tags: [] +scope: component +reversibility: unknown +blastRadius: component +relatesTo: [] +affects: + - type: path + pattern: "src/sync/**" +provenance: + authoredBy: human +conflictsWith: ["0004"] +review: + tier: async + queuedAt: 2026-07-01T00:00:00Z + slaDays: 10 + quorum: 2 + approvals: ["@alice", "@Alice"] + objections: + - by: "@bob" + summary: Batching hides write failures + resolved: true + - by: "@carol" + summary: Needs a size cap +--- + +# ADR-0005: Batch sync writes diff --git a/packages/core/test/conformance/golden/repo/docs/adr/0006-adopt-a-queue.md b/packages/core/test/conformance/golden/repo/docs/adr/0006-adopt-a-queue.md new file mode 100644 index 00000000..9e277f80 --- /dev/null +++ b/packages/core/test/conformance/golden/repo/docs/adr/0006-adopt-a-queue.md @@ -0,0 +1,18 @@ +--- +schemaVersion: 0.1.0 +id: "0006" +title: Adopt a queue +status: proposed +date: 2026-07-18 +deciders: ["@dave", "@erin"] +tags: [] +scope: component +reversibility: unknown +blastRadius: component +relatesTo: [] +affects: [] +provenance: + authoredBy: human +--- + +# ADR-0006: Adopt a queue diff --git a/packages/core/test/conformance/golden/repo/docs/adr/0007-sketch-offline-mode.md b/packages/core/test/conformance/golden/repo/docs/adr/0007-sketch-offline-mode.md new file mode 100644 index 00000000..a1f20b82 --- /dev/null +++ b/packages/core/test/conformance/golden/repo/docs/adr/0007-sketch-offline-mode.md @@ -0,0 +1,18 @@ +--- +schemaVersion: 0.1.0 +id: "0007" +title: Sketch offline mode +status: draft +date: 2026-07-18 +deciders: [] +tags: [offline, sync] +scope: component +reversibility: unknown +blastRadius: component +relatesTo: [] +affects: [] +provenance: + authoredBy: human +--- + +# ADR-0007: Sketch offline mode diff --git a/packages/core/test/conformance/golden/repo/docs/adr/notes/0008-nested-record.md b/packages/core/test/conformance/golden/repo/docs/adr/notes/0008-nested-record.md new file mode 100644 index 00000000..e3f1ee03 --- /dev/null +++ b/packages/core/test/conformance/golden/repo/docs/adr/notes/0008-nested-record.md @@ -0,0 +1,18 @@ +--- +schemaVersion: 0.1.0 +id: "0008" +title: Hide below the corpus root +status: accepted +date: 2026-07-18 +deciders: ["@tester"] +tags: [] +scope: component +reversibility: unknown +blastRadius: component +relatesTo: [] +affects: [] +provenance: + authoredBy: human +--- + +# ADR-0008: Hide below the corpus root diff --git a/packages/core/test/conformance/golden/repo/src/cache/lru.ts b/packages/core/test/conformance/golden/repo/src/cache/lru.ts new file mode 100644 index 00000000..86838087 --- /dev/null +++ b/packages/core/test/conformance/golden/repo/src/cache/lru.ts @@ -0,0 +1 @@ +export const lru = new Map(); diff --git a/packages/core/test/conformance/golden/repo/src/sync/protocol.ts b/packages/core/test/conformance/golden/repo/src/sync/protocol.ts new file mode 100644 index 00000000..a5d12007 --- /dev/null +++ b/packages/core/test/conformance/golden/repo/src/sync/protocol.ts @@ -0,0 +1 @@ +export const protocol = 1; diff --git a/packages/core/test/conformance/golden/repo/src/sync/retry.ts b/packages/core/test/conformance/golden/repo/src/sync/retry.ts new file mode 100644 index 00000000..622fe40a --- /dev/null +++ b/packages/core/test/conformance/golden/repo/src/sync/retry.ts @@ -0,0 +1,2 @@ +// @adr 0002 +export const retry = true; diff --git a/packages/core/test/conformance/golden/repo/src/sync/stale.ts b/packages/core/test/conformance/golden/repo/src/sync/stale.ts new file mode 100644 index 00000000..0edcdd36 --- /dev/null +++ b/packages/core/test/conformance/golden/repo/src/sync/stale.ts @@ -0,0 +1,2 @@ +// @adr 0003 +export const stale = true; diff --git a/packages/core/test/queue/as-of.test.ts b/packages/core/test/queue/as-of.test.ts index 03505121..e70f4e10 100644 --- a/packages/core/test/queue/as-of.test.ts +++ b/packages/core/test/queue/as-of.test.ts @@ -14,7 +14,9 @@ */ import { describe, expect, test } from 'bun:test'; -import { resolveAsOf } from '../../src/queue/as-of.ts'; +import { readdir, readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { resolveAsOf, todayUtcDate } from '../../src/queue/as-of.ts'; describe('resolveAsOf — accepted inputs', () => { test('a bare YYYY-MM-DD resolves to itself', () => { @@ -100,3 +102,71 @@ describe('resolveAsOf — reachable as a published contract', () => { expect(core.resolveAsOf('2026-01-08')).toEqual({ ok: true, date: '2026-01-08' }); }); }); + +/** + * `todayUtcDate` — the absent-input clause of `cli-contract.md §As-Of Resolution` + * (ADR-0031 action item 8, ADR-0053 action item 3). + * + * Observed failing (ADR-0016): restoring `new Date().toISOString().slice(0, 10)` in + * `packages/sdk/src/decision-set.ts` failed "no consumer re-inlines the default" for that + * file, and dropping `todayUtcDate` from the entry point failed the reachability test, while + * every behavioural case still passed — the same shape as item 7's defect: an unreachable rule + * is not a wrong answer. + */ +describe('todayUtcDate — the absent-input default', () => { + test('answers the UTC calendar date of the instant, not the local one', () => { + // 23:30 at -05:00 on the 8th is 04:30Z on the 9th. A local-date default answers the 8th + // here and disagrees with CI for the five hours before UTC midnight. + expect(todayUtcDate(new Date('2026-01-08T23:30:00-05:00'))).toBe('2026-01-09'); + expect(todayUtcDate(new Date('2026-01-08T00:00:00Z'))).toBe('2026-01-08'); + }); + + test('reads the clock only when no instant is supplied', () => { + const before = new Date().toISOString().slice(0, 10); + const today = todayUtcDate(); + const after = new Date().toISOString().slice(0, 10); + expect([before, after]).toContain(today); + expect(resolveAsOf(today)).toEqual({ ok: true, date: today }); + }); + + test('refuses an instant outside years 0000-9999 rather than returning a non-date', () => { + expect(() => todayUtcDate(new Date('+010000-01-01T00:00:00Z'))).toThrow(RangeError); + }); + + test('it is exported from the package entry point, not only from its module', async () => { + const core = await import('../../src/index.ts'); + expect(typeof core.todayUtcDate).toBe('function'); + expect(core.todayUtcDate(new Date('2026-03-01T12:00:00Z'))).toBe('2026-03-01'); + }); + + // The four call sites ADR-0031 action item 8 named. Each must take the default from core and + // none may spell the expression again: a fifth copy is how the midnight divergence returns. + const REPO = resolve(import.meta.dir, '../../../..'); + const CONSUMERS = [ + 'packages/cli/src/queue.ts', + 'packages/ci/src/queue-action-entrypoint.ts', + 'packages/core/src/scaffold/new.ts', + 'packages/sdk/src/decision-set.ts', + ]; + for (const path of CONSUMERS) { + test(`no consumer re-inlines the default: ${path}`, async () => { + const source = await readFile(resolve(REPO, path), 'utf8'); + expect(source).not.toMatch(/new Date\(\)\.toISOString\(\)\.slice\(0,\s*10\)/); + expect(source).toMatch(/\btodayUtcDate\(\)/); + }); + } + + // The static portal may import only `@adrkit/sdk`, so it cannot call `todayUtcDate` itself; it + // takes the SDK's echoed default (`handle.queue().asOf`). It must not compute its own today + // either, which is how it once carried a fifth copy. Observed failing against that copy in + // `packages/portal/src/generate.ts` before it was removed. + test('the portal computes no today of its own', async () => { + const dir = resolve(REPO, 'packages/portal/src'); + const files = (await readdir(dir, { recursive: true })).filter((name) => /\.(ts|mjs|js)$/.test(String(name))); + expect(files.length).toBeGreaterThan(0); + for (const name of files) { + const source = await readFile(resolve(dir, String(name)), 'utf8'); + expect({ name, inline: /\.toISOString\(\)\.slice\(0,\s*10\)/.test(source) }).toEqual({ name, inline: false }); + } + }); +}); diff --git a/packages/core/test/report.test.ts b/packages/core/test/report.test.ts new file mode 100644 index 00000000..be32e52d --- /dev/null +++ b/packages/core/test/report.test.ts @@ -0,0 +1,103 @@ +/** + * Core's report builders for `adr lint`, `adr new`, and `adr explain` JSON (ADR-0031 action + * item 4). The CLI goldens in `packages/cli/test/json-report.test.ts` pin the bytes; these + * cases pin what a golden cannot: the git-ref provenance keys of an `--as-of` block (the + * commit moves, so no golden can hold it) and the reachability of the builders from the + * package entry point. + * + * ADR-0016: observed failing. Emitting `committedAt` before `commit` failed the ref case, and + * removing the report exports from `src/index.ts` failed the reachability case; both reverted. + */ + +import { describe, expect, test } from 'bun:test'; +import { + buildExplainReport, + buildLintReport, + buildNewAdrReport, + formatExplainReportJson, + formatLintReportJson, + formatNewAdrReportJson, +} from '../src/report/index.ts'; +import type { SourceMarkerScan } from '../src/markers/read.ts'; + +const SCAN: SourceMarkerScan = { path: 'src/a.ts', state: 'absent', markers: [], truncated: false }; + +describe('report builders', () => { + test('lint: checked, then findings', () => { + const report = buildLintReport({ checked: 3 }, []); + expect(Object.keys(report)).toEqual(['checked', 'findings']); + expect(formatLintReportJson(report)).toBe('{\n "checked": 3,\n "findings": []\n}\n'); + }); + + test('new: id and path, never the scaffolded content', () => { + const report = buildNewAdrReport({ id: '0009', path: 'docs/adr/0009-x.md', content: '---\n' } as never); + expect(report).toEqual({ id: '0009', path: 'docs/adr/0009-x.md' }); + expect(formatNewAdrReportJson(report)).toBe('{\n "id": "0009",\n "path": "docs/adr/0009-x.md"\n}\n'); + }); + + test('explain: a git-ref --as-of carries commit then committedAt, before the groups', () => { + const report = buildExplainReport({ + path: 'src/a.ts', + scan: SCAN, + asOf: { + requested: 'HEAD', + date: '2026-10-10', + resolvedFrom: 'ref', + commit: 'a'.repeat(40), + committedAt: '2026-10-10T17:12:42-04:00', + }, + findings: [], + }); + expect(Object.keys(report)).toEqual([ + 'path', + 'asOf', + 'governedBy', + 'governing', + 'activeProposals', + 'history', + 'markers', + 'findings', + ]); + expect(Object.keys(report.asOf ?? {})).toEqual([ + 'requested', + 'date', + 'resolvedFrom', + 'commit', + 'committedAt', + 'governing', + 'activeProposals', + 'history', + 'notYetRecorded', + 'undetermined', + ]); + expect(formatExplainReportJson(report).endsWith('\n')).toBe(true); + }); + + test('explain: no --as-of means no asOf key, and an absent file has no extent keys', () => { + const report = buildExplainReport({ path: 'src/a.ts', scan: SCAN, findings: [] }); + expect('asOf' in report).toBe(false); + expect(Object.keys(report.markers)).toEqual([ + 'state', + 'windowBytes', + 'truncated', + 'declarationLimit', + 'totalDeclarations', + 'omittedDeclarations', + 'declared', + ]); + }); + + test('the builders and formatters are reachable from the package entry point', async () => { + const core = await import('../src/index.ts'); + for (const name of [ + 'buildExplainReport', + 'buildLintReport', + 'buildNewAdrReport', + 'formatExplainReportJson', + 'formatLintReportJson', + 'formatNewAdrReportJson', + ] as const) { + expect(typeof core[name]).toBe('function'); + } + }); +}); diff --git a/packages/core/test/surface.test.ts b/packages/core/test/surface.test.ts index f93cfaf4..0a032d0c 100644 --- a/packages/core/test/surface.test.ts +++ b/packages/core/test/surface.test.ts @@ -41,6 +41,9 @@ const PUBLIC_RUNTIME_EXPORTS = [ 'bucketDecisions', 'buildAdrGraph', 'buildDecisionWindows', + 'buildExplainReport', + 'buildLintReport', + 'buildNewAdrReport', 'buildQueueReport', 'canonicalStringify', 'checkChanges', @@ -61,10 +64,14 @@ const PUBLIC_RUNTIME_EXPORTS = [ 'emitJsonSchema', 'exitCodeForFindings', 'expandRecordInputs', + 'explainMarkerScan', 'extractMadrBodyFields', 'filterAdrGraph', 'fingerprintOf', 'fingerprintSourceBody', + 'formatExplainReportJson', + 'formatLintReportJson', + 'formatNewAdrReportJson', 'formatQueueReportJson', 'formatQueueReportMarkdown', 'identityKey', @@ -117,6 +124,7 @@ const PUBLIC_RUNTIME_EXPORTS = [ 'standingAsOf', 'stringifyJsonSchema', 'toGoverningDecisions', + 'todayUtcDate', 'validateAdrFrontmatter', 'validateAffectsRepoQualifiers', 'validateCorpusInvariants', diff --git a/packages/portal/src/generate.ts b/packages/portal/src/generate.ts index f2eb7bda..1e147b36 100644 --- a/packages/portal/src/generate.ts +++ b/packages/portal/src/generate.ts @@ -3,7 +3,9 @@ * ([ADR-0054](../../../docs/adr/0054-publish-a-static-decision-portal-for-github-pages-built-outside-the-cli-from-the.md) * clauses 3, 6, 9). * - * It opens the corpus through `@adrkit/sdk`, resolves `asOf` (today, UTC, when not given), + * It opens the corpus through `@adrkit/sdk`, resolves `asOf` through the SDK's `queue()` (which + * accepts what `adr queue --as-of` accepts, defaults to today in UTC, and echoes the resolved + * date, so no clock is read and no date grammar is restated here), * resolves the commit and the tracked files through `git`, builds the path table from the * SDK's `governing(path)` for every tracked file, and writes the page bundle with * `portal-data.json` into `out`, replacing it whole: the bundle is built in a sibling staging @@ -57,14 +59,16 @@ export interface GeneratePortalOptions { readonly out?: string; /** Working directory. Defaults to the process cwd. Inside a git work tree, the corpus is opened from its top level. */ readonly cwd?: string; - /** UTC calendar date `YYYY-MM-DD` for SLA states. Defaults to today (UTC). A datetime is a usage error. */ + /** + * The date for SLA states, in the grammar `adr queue --as-of` accepts: `YYYY-MM-DD`, or an ISO + * datetime with an explicit timezone, resolved to its UTC calendar date by the SDK's `queue()`. + * A timezone-less datetime or an impossible date is a usage error. Defaults to today (UTC). + */ readonly asOf?: string; /** Absolute `http:`/`https:` URL the site is served at; feeds absolute links only (clause 8). */ readonly baseUrl?: string; /** Path table caps; each defaults to {@link DEFAULT_PATH_TABLE_CAPS}. */ readonly pathTableCaps?: Partial; - /** Clock, for tests. Only read when `asOf` is absent. */ - readonly now?: () => Date; /** Most concurrent `governing(path)` resolutions (each reads one file header). Defaults to 16. */ readonly concurrency?: number; /** File writes, for tests that inject a failure. Defaults to `node:fs/promises`. */ @@ -98,14 +102,6 @@ function usage(message: string): GeneratePortalResult { return { exitCode: 2, dataFile: null, data: null, errors: [], message }; } -function isCalendarDate(value: string): boolean { - const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); - if (!match) return false; - const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])]; - const date = new Date(Date.UTC(year, month - 1, day)); - return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day; -} - function toPosix(path: string): string { return path.split(sep).join('/'); } @@ -116,13 +112,6 @@ export async function generatePortal(options: GeneratePortalOptions = {}): Promi const given = resolve(options.cwd ?? process.cwd()); const cwd = await realpath(given).catch(() => given); - let asOf: string; - if (options.asOf !== undefined) { - if (!isCalendarDate(options.asOf)) return usage(`asOf must be a UTC calendar date YYYY-MM-DD; got "${options.asOf}".`); - asOf = options.asOf; - } else { - asOf = (options.now ?? (() => new Date()))().toISOString().slice(0, 10); - } let baseUrl: string | null = null; if (options.baseUrl !== undefined && options.baseUrl !== '') { @@ -188,6 +177,23 @@ export async function generatePortal(options: GeneratePortalOptions = {}): Promi throw error; } + // `asOf` is resolved by the SDK's `queue()`, which takes exactly `adr queue --as-of`'s grammar + // and defaults to core's `todayUtcDate()`, and echoes the resolved UTC date. The portal reads + // no clock and restates no date rule, so it cannot drift from CI. It is resolved after the + // corpus opens, because the resolver is a handle method; nothing has been written yet, so a + // bad value is still exit 2 with nothing written. The builder stays pure and takes the date. + let asOf: string; + try { + asOf = handle.queue(options.asOf === undefined ? {} : { asOf: options.asOf }).asOf; + } catch (error) { + if (error instanceof RangeError) { + return usage( + `asOf must be YYYY-MM-DD or an ISO datetime with an explicit timezone (e.g. 2026-01-08T00:00:00Z); got "${options.asOf}".`, + ); + } + throw error; + } + const errorIssues = handle.issues.filter((issue) => issue.severity === 'error'); // The corpus comes from the working tree and the path list from the index, so a bundle is // stamped with HEAD only when the checkout matches it (see `checkoutMatchesHead`). diff --git a/packages/portal/test/generate.test.ts b/packages/portal/test/generate.test.ts index 514e4ba0..1bc6562d 100644 --- a/packages/portal/test/generate.test.ts +++ b/packages/portal/test/generate.test.ts @@ -29,6 +29,7 @@ import { cleanupTestDir, resetTestDir, writeText } from '../../core/test/helpers import { writeBrokenCorpus, writeHealthyCorpus } from '../../sdk/test/fixture.ts'; import type { PortalPathTableAvailable } from '../src/data/contract.ts'; import { generatePortal } from '../src/generate.ts'; +import { openDecisions } from '../../sdk/src/index.ts'; const HEALTHY = 'portal-generate-healthy'; const BROKEN = 'portal-generate-broken'; @@ -127,9 +128,24 @@ describe('exit 0: a healthy corpus in a git repository', () => { expect(await readFile(second.dataFile!, 'utf8')).toBe(await readFile(first.dataFile!, 'utf8')); }); - test('asOf defaults to today in UTC, read from the clock', async () => { - const result = await generatePortal({ cwd: healthyRoot, out: 'site-d', now: () => new Date('2026-07-09T23:30:00-05:00') }); - expect(result.data!.asOf).toBe('2026-07-10'); + test('asOf defaults to the SDK\'s own default: today in UTC, echoed by queue()', async () => { + // The portal reads no clock: it takes `handle.queue().asOf`, which the SDK computes with + // core's `todayUtcDate()`. The test brackets the call, since a UTC midnight may fall in it. + const before = new Date().toISOString().slice(0, 10); + const result = await generatePortal({ cwd: healthyRoot, out: 'site-d' }); + const sdkDefault = (await openDecisions({ cwd: healthyRoot })).queue().asOf; + // Sampled after both calls, so the window covers every clock read either of them made. + const after = new Date().toISOString().slice(0, 10); + expect([before, after]).toContain(result.data!.asOf); + expect([before, after]).toContain(sdkDefault); + if (before === after) expect(result.data!.asOf).toBe(sdkDefault); + }); + + test('a zoned datetime asOf is accepted, as `adr queue --as-of` accepts it, and resolves to its UTC date', async () => { + const result = await generatePortal({ cwd: healthyRoot, out: 'site-dt', asOf: '2026-07-04T23:30:00-05:00' }); + expect(result.exitCode).toBe(0); + expect(result.data!.asOf).toBe('2026-07-05'); + expect(JSON.parse(await readFile(result.dataFile!, 'utf8')).asOf).toBe('2026-07-05'); }); test('from a subdirectory, the corpus opens from the top level and the table covers the whole tree', async () => { @@ -185,7 +201,7 @@ describe('no table where there is nothing to enumerate', () => { describe('exit 2: usage errors and an unreadable corpus write nothing', () => { const cases: Array<[string, Parameters[0]]> = [ ['an unreadable corpus directory', { dir: 'no/such/dir' }], - ['a datetime asOf', { asOf: '2026-07-05T00:00:00Z' }], + ['a timezone-less datetime asOf', { asOf: '2026-07-05T00:00:00' }], ['an impossible asOf', { asOf: '2026-02-30' }], ['a non-http base-url', { baseUrl: 'javascript:alert(1)' }], ['a relative base-url', { baseUrl: '/docs' }], diff --git a/packages/sdk/src/decision-set.ts b/packages/sdk/src/decision-set.ts index 8122f6ef..18544a3e 100644 --- a/packages/sdk/src/decision-set.ts +++ b/packages/sdk/src/decision-set.ts @@ -32,6 +32,7 @@ import { resolveAsOf, resolveSourceMarkers, sortFindings, + todayUtcDate, toGoverningDecisions, type Adr, type CorpusFinding, @@ -185,8 +186,6 @@ export function issueFromMarkerScan(scan: SourceMarkerScan): CorpusIssue | undef * The handle. * ------------------------------------------------------------------ */ -const BARE_CALENDAR_DATE = /^\d{4}-\d{2}-\d{2}$/; - /** `adr graph --kind`'s vocabulary, in the order a canonical cache key lists it. */ const EDGE_KINDS: readonly DecisionEdgeKind[] = ['supersedes', 'relatesTo', 'conflictsWith']; const DEFAULT_EDGE_KINDS: readonly DecisionEdgeKind[] = ['supersedes']; @@ -212,29 +211,23 @@ function resolveEdgeKinds(kinds: readonly DecisionEdgeKind[] | undefined): reado return EDGE_KINDS.filter((kind) => kinds.includes(kind)); } -/** - * Today's UTC calendar date — `cli-contract.md` §As-Of Resolution's absent-input clause. - * - * Inlined, as it is in `packages/cli/src/queue.ts`, `packages/ci/src/queue-action-entrypoint.ts`, - * and `packages/core/src/scaffold/new.ts`. This is the fourth copy, and ADR-0031 action item 8 - * (a publication gate for this package) exists to replace all four with one core export. It is - * the one ambient read in the handle, and it happens only when the caller omits `asOf`. - */ -function todayUtc(): string { - return new Date().toISOString().slice(0, 10); -} - function resolveQueueAsOf(asOf: string | undefined): string { - if (asOf === undefined) return todayUtc(); - // The contract is a bare calendar date only. `resolveAsOf` also accepts zoned datetimes - // for the CLI flag; the SDK deliberately does not, so the timezone question is answered - // once, by the caller, rather than silently here. - if (typeof asOf !== 'string' || !BARE_CALENDAR_DATE.test(asOf) || !resolveAsOf(asOf).ok) { - throw new RangeError( - `queue({ asOf }) must be a real UTC calendar date in YYYY-MM-DD form; received ${JSON.stringify(asOf)}.`, - ); + // Absent: today's UTC date, from core's one export of `cli-contract.md` §As-Of Resolution's + // absent-input clause — the same function `adr queue` and the queue Action call. It is the + // one ambient read in the handle, and it happens only when the caller omits `asOf`. + if (asOf === undefined) return todayUtcDate(); + // Supplied: exactly `adr queue --as-of`'s grammar, through the same core rule — a bare + // calendar date, or an ISO datetime with an explicit zone, resolved to its UTC calendar date. + // A timezone-less datetime is refused rather than guessed, as the CLI refuses it. + const resolution = typeof asOf === 'string' ? resolveAsOf(asOf) : undefined; + if (resolution === undefined || !resolution.ok) { + const reason = + resolution?.ok === false && resolution.code === 'tzless' + ? 'a datetime needs an explicit timezone (Z or an offset such as +05:00)' + : 'expected YYYY-MM-DD or an ISO datetime with an explicit timezone'; + throw new RangeError(`queue({ asOf }) received ${JSON.stringify(asOf)}: ${reason}.`); } - return asOf; + return resolution.date; } function emptyScan(path: string): SourceMarkerScan { diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index c7c08ca9..975286c4 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -317,16 +317,17 @@ export interface CorpusIssue { /** Options for {@link DecisionSet.queue}. */ export interface QueueOptions { /** - * UTC calendar date, `YYYY-MM-DD`, that SLA state is computed against. Defaults to today. + * The date SLA state is computed against, in exactly the grammar `adr queue --as-of` accepts + * (`cli-contract.md` §As-Of Resolution): a bare `YYYY-MM-DD`, or an ISO datetime with an + * explicit timezone (`2026-01-08T00:00:00Z`, `2026-01-08T23:30:00-05:00`), which resolves to its + * UTC calendar date. {@link QueueView.asOf} echoes the resolved date, so a consumer and CI agree + * on the calendar date for the same input without resolving it themselves. Defaults to today's + * UTC date, by the same function `adr queue` uses when `--as-of` is absent. * - * A bare calendar date only — no datetime, and no `Date`. Both alternatives carry a timezone - * question, and this contract answers it once here instead of at every call site. A caller - * holding a datetime resolves it first with `resolveAsOf` from `@adrkit/core`, which is the - * same rule the CLI applies to `--as-of` — so a consumer and CI agree on the calendar date for - * the same input. (That resolver was unexported when this surface was enumerated; ADR-0031 - * action item 7 closed it.) Anything else — a datetime, or an impossible date such as - * `2026-02-30` — makes {@link DecisionSet.queue} throw a `RangeError`: it is a caller error, not - * a corpus issue. + * A timezone-less datetime is refused rather than guessed, because the date it lands on depends + * on the reader's zone. It, an impossible date such as `2026-02-30`, a non-string, or anything + * else outside the grammar makes {@link DecisionSet.queue} throw a `RangeError`: it is a caller + * error, not a corpus issue. */ readonly asOf?: string; } @@ -338,6 +339,7 @@ export interface QueueOptions { * against, and a consumer that caches this view needs to know what it cached. */ export interface QueueView { + /** The resolved UTC calendar date, `YYYY-MM-DD`, even when a datetime was supplied. */ readonly asOf: string; readonly entries: readonly QueueEntry[]; /** Corpus issues that prevented records from reaching {@link entries}. */ diff --git a/packages/sdk/test/cli-equivalence.test.ts b/packages/sdk/test/cli-equivalence.test.ts index 0594bc99..691796b5 100644 --- a/packages/sdk/test/cli-equivalence.test.ts +++ b/packages/sdk/test/cli-equivalence.test.ts @@ -192,6 +192,16 @@ for (const corpus of CORPORA) { ); }); + test('queue with a zoned datetime ≡ adr queue --as-of with the same datetime', async () => { + const datetime = '2026-07-04T23:30:00-05:00'; + const { json } = await adrJson(['queue', '--format', 'json', '--as-of', datetime], corpus.cwd()); + const view = corpus.set().queue({ asOf: datetime }); + expect(view.asOf).toBe(json.asOf); + expect(view.entries.map((entry) => [entry.id, entry.slaState, entry.deadline])).toEqual( + json.items.map((item: any) => [item.id, item.slaState, item.deadlineDate]), + ); + }); + // Nodes are every record, as unfiltered `adr graph` prints them; `--kind supersedes` // would also drop the records no supersession edge touches, which a consumer browsing // the corpus still needs. Edges are the supersession edges `--kind supersedes` prints. diff --git a/packages/sdk/test/conformance.test.ts b/packages/sdk/test/conformance.test.ts new file mode 100644 index 00000000..19450e5c --- /dev/null +++ b/packages/sdk/test/conformance.test.ts @@ -0,0 +1,137 @@ +/** + * `@adrkit/sdk` against the conformance fixture (ADR-0031 action item 6, ADR-0030 action + * item 5, ADR-0053 action item 5): the committed corpus and the golden `adr queue --format + * json` / `adr check --json` pair in `packages/core/test/conformance/golden/`. + * + * `packages/cli/test/conformance.test.ts` holds the CLI to the goldens byte for byte. This file + * holds the SDK to the same goldens, so one artifact pins both contracts and a drift between + * them — ADR-0031's "two contracts can drift" — fails here without a subprocess. + * + * The goldens are projected into the SDK's shapes field by field here; the SDK's own mappers + * are not reused, because a test that maps both sides with the same code would pass however + * wrong that code was. + * + * ADR-0016: observed failing. Mapping `deadline` from `queuedAt` failed the queue entries case; + * dropping marker evidence in `governingFromExplained` failed the evidence case; and making + * `governing()` skip `resolveSourceMarkers` failed the standing, evidence, and findings cases. Each + * mutation in `src/decision-set.ts` was reverted. + */ + +import { beforeAll, describe, expect, test } from 'bun:test'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { openDecisions, type DecisionSet, type DecisionStanding, type QueueEntry } from '../src/index.ts'; + +const FIXTURE = resolve(process.cwd(), 'packages/core/test/conformance/golden'); + +let manifest: { cwd: string; commands: Record<'queue' | 'check', { args: string[] }> }; +let queueGolden: any; +let checkGolden: any; +let set: DecisionSet; +let asOf: string; +let checkPaths: string[]; + +beforeAll(async () => { + manifest = JSON.parse(await readFile(resolve(FIXTURE, 'manifest.json'), 'utf8')); + queueGolden = JSON.parse(await readFile(resolve(FIXTURE, 'queue.json'), 'utf8')); + checkGolden = JSON.parse(await readFile(resolve(FIXTURE, 'check.json'), 'utf8')); + const queueArgs = manifest.commands.queue.args; + asOf = queueArgs[queueArgs.indexOf('--as-of') + 1] ?? ''; + // `check --json `: every argument after the flags is a path. + checkPaths = manifest.commands.check.args.filter((arg, index) => index > 0 && !arg.startsWith('--')); + set = await openDecisions({ cwd: resolve(FIXTURE, manifest.cwd) }); +}); + +describe('conformance fixture — queue()', () => { + test('echoes the pinned as-of date', () => { + expect(asOf).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(set.queue({ asOf }).asOf).toBe(queueGolden.asOf); + }); + + test('entries are queue.json items, field by field', () => { + const expected: QueueEntry[] = queueGolden.items.map((item: any) => ({ + id: item.id, + title: item.title, + path: item.sourcePath, + tier: item.tier, + slaState: item.slaState, + deadline: item.deadlineDate, + approvalCount: item.approvalCount, + quorum: item.quorum, + unresolvedObjectionCount: item.unresolvedObjectionCount, + resolvedObjectionCount: item.resolvedObjectionCount, + routingTargets: item.routingTargets, + itemFindingCount: item.itemFindings.length, + })); + expect(expected.length).toBeGreaterThan(0); + expect(set.queue({ asOf }).entries).toEqual(expected); + }); + + test('issues are queue.json corpusFindings', () => { + const expected = queueGolden.corpusFindings.map((finding: any) => ({ + code: finding.code, + severity: finding.severity, + message: finding.message, + ...(finding.sourcePath === '' ? {} : { path: finding.sourcePath }), + })); + expect(set.queue({ asOf }).issues).toEqual(expected); + }); +}); + +describe('conformance fixture — governing() over check.json\'s paths', () => { + async function governedByPath() { + return Promise.all(checkPaths.map(async (path) => ({ path, view: await set.governing(path) }))); + } + + test('the same records land in the same standing', async () => { + const views = await governedByPath(); + for (const standing of ['governing', 'activeProposals', 'history'] as DecisionStanding[]) { + const sdk = new Set(views.flatMap(({ view }) => view[standing].map((decision) => decision.id))); + const cli = new Set(checkGolden[standing].map((decision: any) => decision.recordId)); + expect([...sdk].sort()).toEqual([...cli].sort()); + } + }); + + test('the same evidence reaches each record: affects matchers and marker lines', async () => { + const views = await governedByPath(); + const sdk = new Map>(); + for (const { path, view } of views) { + for (const decision of [...view.governing, ...view.activeProposals, ...view.history]) { + const evidence = sdk.get(decision.id) ?? new Set(); + for (const item of decision.matchedBy) { + evidence.add(item.kind === 'affects' ? `affects ${item.type} ${item.pattern}` : `marker ${path}:${item.line}`); + } + sdk.set(decision.id, evidence); + } + } + const cli = new Map>(); + for (const decision of checkGolden.governedBy) { + cli.set( + decision.recordId, + new Set([ + ...decision.firedMatchers.map((matcher: any) => `affects ${matcher.type} ${matcher.pattern}`), + ...(decision.declaredBy ?? []).map((declaration: any) => `marker ${declaration.path}:${declaration.line}`), + ]), + ); + } + expect([...sdk.keys()].sort()).toEqual([...cli.keys()].sort()); + for (const [id, evidence] of cli) expect([...(sdk.get(id) ?? [])].sort()).toEqual([...evidence].sort()); + }); + + test('check.json\'s findings are reported by governing() for the same path', async () => { + const views = await governedByPath(); + expect(checkGolden.findings.length).toBeGreaterThan(0); + for (const finding of checkGolden.findings) { + const view = views.find(({ path }) => path === finding.path)?.view; + expect(view?.issues).toContainEqual({ code: finding.rule, severity: finding.severity, message: finding.message, path: finding.path }); + } + }); + + test('a path check.json reports absent is an issue, not silence', async () => { + expect(checkGolden.markerScan.absentPaths.length).toBeGreaterThan(0); + for (const path of checkGolden.markerScan.absentPaths) { + const view = await set.governing(path); + expect(view.issues.map((issue) => issue.code)).toContain('marker-scan-absent'); + } + }); +}); diff --git a/packages/sdk/test/open-decisions.test.ts b/packages/sdk/test/open-decisions.test.ts index 7b5af5ac..2f7c044f 100644 --- a/packages/sdk/test/open-decisions.test.ts +++ b/packages/sdk/test/open-decisions.test.ts @@ -285,10 +285,18 @@ describe('queue', () => { expect([before, after]).toContain(asOf); }); - test('refuses anything but a real bare calendar date', () => { - for (const asOf of ['2026-02-30', '2026-07-05T00:00:00Z', '2026-07-05T00:00:00', 'today', '']) { + test('accepts what `adr queue --as-of` accepts, and echoes the resolved UTC date', () => { + expect(healthy.queue({ asOf: '2026-01-08' }).asOf).toBe('2026-01-08'); + expect(healthy.queue({ asOf: '2026-01-08T00:00:00Z' }).asOf).toBe('2026-01-08'); + // 23:30 at -05:00 on the 8th is 04:30Z on the 9th: the zone is honoured, not sliced off. + expect(healthy.queue({ asOf: '2026-01-08T23:30:00-05:00' }).asOf).toBe('2026-01-09'); + }); + + test('refuses what `adr queue --as-of` refuses, including a timezone-less datetime', () => { + for (const asOf of ['2026-02-30', '2026-02-30T00:00:00Z', '2026-07-05T00:00:00', 'today', '']) { expect(() => healthy.queue({ asOf })).toThrow(RangeError); } + expect(() => healthy.queue({ asOf: 20260705 as unknown as string })).toThrow(RangeError); }); });