From ee9021af901c5c061b31aa5576e5c19b1b309c78 Mon Sep 17 00:00:00 2001 From: Mark Beacom Date: Sat, 10 Oct 2026 17:58:50 -0400 Subject: [PATCH 1/3] feat(core,cli,sdk): close the SDK's remaining publication gates without publishing it (#295) - ADR-0031 item 8 / ADR-0053 item 3: core exports todayUtcDate, the absent-input `--as-of` default, and replaces every inline copy: adr queue, the queue Action (bundle regenerated), adr new's scaffold, and the SDK's queue(). The portal now takes the SDK's echoed default (queue().asOf) instead of computing its own. Tests ban the inline expression in all of them. - ADR-0031 item 4 / ADR-0053 item 8: `adr explain`, `adr lint`, and `adr new --json` now come from core formatters (packages/core/src/ report). Old and new CLIs produced byte-identical stdout and exit codes on 53 command lines over this corpus and the SDK fixtures; committed goldens hold it. - ADR-0031 item 6 / ADR-0053 item 5: a conformance fixture under packages/core/test/conformance/golden: the CLI must reproduce its queue and check goldens byte for byte, and the SDK must agree with them. - ADR-0031 item 5: docs/RELEASING.md documents the SDK and CLI JSON consumer contracts. Nothing is published: RELEASE_PACKAGES, exports, files, and the no-release assertions are untouched. packages/ci/dist rebuilt under linux/amd64 Bun 1.4.2. Every new test was observed failing first (ADR-0016). Ticked: ADR-0031 items 4, 5, 6, 8 and ADR-0053 items 3, 5, 8. Signed-off-by: Mark Beacom --- CHANGELOG.md | 28 ++ docs/RELEASING.md | 55 +++ ...ract-and-document-the-cli-json-as-its-s.md | 34 +- ...and-accept-an-in-repo-consumer-as-its-r.md | 6 +- docs/sdk-surface.md | 20 +- packages/ci/dist/portal-action.js | 373 ++++++++---------- packages/ci/dist/queue-action.js | 76 +--- packages/ci/src/queue-action-entrypoint.ts | 4 +- packages/cli/src/index.ts | 119 +----- packages/cli/src/queue.ts | 3 +- packages/cli/test/conformance.test.ts | 69 ++++ .../test/fixtures/json-report/.gitattributes | 1 + .../json-report/explain-as-of-lru.json | 29 ++ .../json-report/explain-as-of-missing.json | 159 ++++++++ .../json-report/explain-as-of-protocol.json | 202 ++++++++++ .../json-report/explain-as-of-retry.json | 213 ++++++++++ .../json-report/explain-as-of-stale.json | 196 +++++++++ .../json-report/explain-broken-as-of.json | 42 ++ .../fixtures/json-report/explain-broken.json | 32 ++ .../fixtures/json-report/explain-lru.json | 19 + .../fixtures/json-report/explain-missing.json | 95 +++++ .../json-report/explain-protocol.json | 121 ++++++ .../fixtures/json-report/explain-retry.json | 130 ++++++ .../fixtures/json-report/explain-stale.json | 125 ++++++ .../fixtures/json-report/lint-broken.json | 12 + .../cli/test/fixtures/json-report/lint.json | 11 + .../cli/test/fixtures/json-report/new.json | 4 + packages/cli/test/json-report.test.ts | 112 ++++++ packages/core/src/index.ts | 18 +- packages/core/src/queue/as-of.ts | 22 ++ packages/core/src/report/index.ts | 201 ++++++++++ packages/core/src/scaffold/new.ts | 7 +- .../test/conformance/golden/.gitattributes | 3 + .../core/test/conformance/golden/README.md | 40 ++ .../core/test/conformance/golden/check.json | 197 +++++++++ .../test/conformance/golden/manifest.json | 17 + .../core/test/conformance/golden/queue.json | 67 ++++ .../docs/adr/0001-use-the-sync-protocol.md | 20 + .../adr/0002-retry-on-transient-failure.md | 18 + .../repo/docs/adr/0003-poll-for-changes.md | 21 + .../adr/0004-push-changes-over-websockets.md | 21 + .../repo/docs/adr/0005-batch-sync-writes.md | 33 ++ .../repo/docs/adr/0006-adopt-a-queue.md | 18 + .../repo/docs/adr/0007-sketch-offline-mode.md | 18 + .../repo/docs/adr/notes/0008-nested-record.md | 18 + .../conformance/golden/repo/src/cache/lru.ts | 1 + .../golden/repo/src/sync/protocol.ts | 1 + .../conformance/golden/repo/src/sync/retry.ts | 2 + .../conformance/golden/repo/src/sync/stale.ts | 2 + packages/core/test/queue/as-of.test.ts | 72 +++- packages/core/test/report.test.ts | 103 +++++ packages/core/test/surface.test.ts | 8 + packages/portal/src/generate.ts | 21 +- packages/portal/test/generate.test.ts | 13 +- packages/sdk/src/decision-set.ts | 18 +- packages/sdk/src/index.ts | 3 +- packages/sdk/test/conformance.test.ts | 137 +++++++ 57 files changed, 2984 insertions(+), 426 deletions(-) create mode 100644 packages/cli/test/conformance.test.ts create mode 100644 packages/cli/test/fixtures/json-report/.gitattributes create mode 100644 packages/cli/test/fixtures/json-report/explain-as-of-lru.json create mode 100644 packages/cli/test/fixtures/json-report/explain-as-of-missing.json create mode 100644 packages/cli/test/fixtures/json-report/explain-as-of-protocol.json create mode 100644 packages/cli/test/fixtures/json-report/explain-as-of-retry.json create mode 100644 packages/cli/test/fixtures/json-report/explain-as-of-stale.json create mode 100644 packages/cli/test/fixtures/json-report/explain-broken-as-of.json create mode 100644 packages/cli/test/fixtures/json-report/explain-broken.json create mode 100644 packages/cli/test/fixtures/json-report/explain-lru.json create mode 100644 packages/cli/test/fixtures/json-report/explain-missing.json create mode 100644 packages/cli/test/fixtures/json-report/explain-protocol.json create mode 100644 packages/cli/test/fixtures/json-report/explain-retry.json create mode 100644 packages/cli/test/fixtures/json-report/explain-stale.json create mode 100644 packages/cli/test/fixtures/json-report/lint-broken.json create mode 100644 packages/cli/test/fixtures/json-report/lint.json create mode 100644 packages/cli/test/fixtures/json-report/new.json create mode 100644 packages/cli/test/json-report.test.ts create mode 100644 packages/core/src/report/index.ts create mode 100644 packages/core/test/conformance/golden/.gitattributes create mode 100644 packages/core/test/conformance/golden/README.md create mode 100644 packages/core/test/conformance/golden/check.json create mode 100644 packages/core/test/conformance/golden/manifest.json create mode 100644 packages/core/test/conformance/golden/queue.json create mode 100644 packages/core/test/conformance/golden/repo/docs/adr/0001-use-the-sync-protocol.md create mode 100644 packages/core/test/conformance/golden/repo/docs/adr/0002-retry-on-transient-failure.md create mode 100644 packages/core/test/conformance/golden/repo/docs/adr/0003-poll-for-changes.md create mode 100644 packages/core/test/conformance/golden/repo/docs/adr/0004-push-changes-over-websockets.md create mode 100644 packages/core/test/conformance/golden/repo/docs/adr/0005-batch-sync-writes.md create mode 100644 packages/core/test/conformance/golden/repo/docs/adr/0006-adopt-a-queue.md create mode 100644 packages/core/test/conformance/golden/repo/docs/adr/0007-sketch-offline-mode.md create mode 100644 packages/core/test/conformance/golden/repo/docs/adr/notes/0008-nested-record.md create mode 100644 packages/core/test/conformance/golden/repo/src/cache/lru.ts create mode 100644 packages/core/test/conformance/golden/repo/src/sync/protocol.ts create mode 100644 packages/core/test/conformance/golden/repo/src/sync/retry.ts create mode 100644 packages/core/test/conformance/golden/repo/src/sync/stale.ts create mode 100644 packages/core/test/report.test.ts create mode 100644 packages/sdk/test/conformance.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 59b813be..8ad835e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -100,6 +100,34 @@ 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. 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..a450ed47 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,12 @@ 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`), and keeps its bare-date rule for an `asOf` a caller supplies. 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..06d101f6 100644 --- a/docs/sdk-surface.md +++ b/docs/sdk-surface.md @@ -210,9 +210,11 @@ 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. + The static portal still carries its own copy, because it may import only `@adrkit/sdk` and the + SDK exposes no date helper; it also keeps a bare-date `asOf` rule for the same reason. 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 +340,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..dc8e5c27 100644 --- a/packages/ci/dist/portal-action.js +++ b/packages/ci/dist/portal-action.js @@ -41128,27 +41128,15 @@ function validateParsedAdr(parsed) { } // ../core/src/validate/corpus-invariants.ts function addReferenceFinding(findings, record, field, ref, severity) { - const crossLog = parseAdrRef(ref).log !== undefined; findings.push({ rule: `dangling-${field}`, severity, - message: crossLog ? `Reference "${ref}" in ${field} names another decision log; cross-repository supersession is not supported` : `Reference "${ref}" in ${field} does not resolve to a record in the corpus`, + message: `Reference "${ref}" in ${field} does not resolve to a record in the corpus`, path: record.path, id: record.frontmatter.id, field }); } -function addFederatedReferenceFinding(findings, record, field, ref, log) { - findings.push({ - rule: "federated-ref-unresolved", - severity: "info", - message: `Reference "${ref}" in ${field} names another decision log ("${log}") and is inert against this corpus`, - path: record.path, - id: record.frontmatter.id, - field, - pattern: ref - }); -} function validateCorpusInvariants(records) { const findings = []; const byId = new Map; @@ -41182,17 +41170,11 @@ function validateCorpusInvariants(records) { addReferenceFinding(findings, record, "supersededBy", record.frontmatter.supersededBy, "error"); } for (const ref of record.frontmatter.relatesTo) { - const { log } = parseAdrRef(ref); - if (log !== undefined) - addFederatedReferenceFinding(findings, record, "relatesTo", ref, log); - else if (!ids.has(ref)) + if (!ids.has(ref)) addReferenceFinding(findings, record, "relatesTo", ref, "error"); } for (const ref of record.frontmatter.conflictsWith) { - const { log } = parseAdrRef(ref); - if (log !== undefined) { - addFederatedReferenceFinding(findings, record, "conflictsWith", ref, log); - } else if (!ids.has(ref)) { + if (!ids.has(ref)) { addReferenceFinding(findings, record, "conflictsWith", ref, "warn"); } else if (record.frontmatter.status === "accepted") { findings.push({ @@ -41227,6 +41209,156 @@ function validateImportIncomplete(records) { } return findings; } +// ../core/src/validate/index.ts +import { stat as stat3 } from "node:fs/promises"; +import { isAbsolute as isAbsolute2, resolve as resolve3 } from "node:path"; +function parseErrorFinding(error, path) { + if (error instanceof FrontmatterError) { + return { + rule: error.code === "invalid-yaml" ? "frontmatter-parse" : "frontmatter-fence", + severity: "error", + message: error.message, + path, + field: "frontmatter" + }; + } + return { + rule: "file-read", + severity: "error", + message: error instanceof Error ? error.message : String(error), + path + }; +} +function skippedFileFinding(skipped, cwd) { + const message = skipped.reason === "nested" ? "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" : "Markdown file in the corpus directory is not a discoverable ADR record and was skipped; rename it to -.md (four or more leading digits) for it to be linted and enforced"; + return { + rule: "corpus-file-skipped", + severity: "warn", + message, + path: normalizeDisplayPath(skipped.path, cwd) + }; +} +async function isDirectory2(path) { + return stat3(path).then((info) => info.isDirectory(), () => false); +} +async function scannedDirectories(paths, dir, cwd) { + if (!paths || paths.length === 0) + return [dir]; + const directories = new Set; + for (const path of paths) { + const absolutePath = isAbsolute2(path) ? path : resolve3(cwd, path); + if (await isDirectory2(absolutePath)) + directories.add(absolutePath); + } + return [...directories].sort((a, b) => compareByDisplayPath(a, b, cwd)); +} +async function lintCorpus(options = {}) { + const cwd = options.cwd ?? process.cwd(); + const dir = options.dir ?? "docs/adr"; + const files = await expandRecordInputs(options.paths, dir, cwd); + const records = []; + const findings = []; + for (const file of files) { + const displayPath = normalizeDisplayPath(file, cwd); + try { + const parsed = await parseAdrFile(file, cwd); + const result = validateParsedAdr(parsed); + findings.push(...result.findings); + if (result.record) { + records.push(result.record); + } + } catch (error) { + findings.push(parseErrorFinding(error, displayPath)); + } + } + const checkedPaths = new Set(files); + for (const scanned of await scannedDirectories(options.paths, dir, cwd)) { + for (const skipped of await discoverSkippedMarkdownFiles(scanned, cwd)) { + if (!checkedPaths.has(skipped.path)) + findings.push(skippedFileFinding(skipped, cwd)); + } + } + findings.push(...validateImportIncomplete(records)); + findings.push(...validateCorpusInvariants(records)); + return { + checked: files.length, + findings: sortFindings(findings), + 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}`; +} +function pushEdge(edges, ids, edge) { + if (!ids.has(edge.from) || !ids.has(edge.to)) + return; + edges.set(edgeKey(edge), edge); +} +function buildAdrGraph(records) { + const sortedRecords = [...records].sort((a, b) => a.frontmatter.id.localeCompare(b.frontmatter.id)); + const ids = new Set(sortedRecords.map((record) => record.frontmatter.id)); + const edges = new Map; + for (const record of sortedRecords) { + for (const superseded of record.frontmatter.supersedes) { + pushEdge(edges, ids, { from: record.frontmatter.id, to: superseded, kind: "supersedes" }); + } + if (record.frontmatter.supersededBy) { + pushEdge(edges, ids, { + from: record.frontmatter.supersededBy, + to: record.frontmatter.id, + kind: "supersedes" + }); + } + for (const related of record.frontmatter.relatesTo) { + pushEdge(edges, ids, { from: record.frontmatter.id, to: related, kind: "relatesTo" }); + } + for (const conflict of record.frontmatter.conflictsWith) { + pushEdge(edges, ids, { from: record.frontmatter.id, to: conflict, kind: "conflictsWith" }); + } + } + return { + nodes: sortedRecords.map((record) => ({ + id: record.frontmatter.id, + title: record.frontmatter.title, + status: record.frontmatter.status + })), + edges: [...edges.values()].sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to) || a.kind.localeCompare(b.kind)) + }; +} // ../core/src/affects/inert.ts var import_picomatch = __toESM(require_picomatch2(), 1); function compilePattern(pattern) { @@ -41331,7 +41463,6 @@ function matchPathPattern(pattern, changedFiles) { } // ../core/src/affects/index.ts -var AFFECTS_REPO_NOT_APPLICABLE = "affects-repo-not-applicable"; function compareFiredMatcher(a, b) { return compareCodeUnits(a.type, b.type) || compareCodeUnits(a.pattern, b.pattern); } @@ -41353,15 +41484,8 @@ function affectsFinding(record, matcher, rule, severity, message) { pattern: matcher.pattern }; } -function matcherAppliesToMember(matcher, member) { - if (!matcher.repo) - return true; - if (member === undefined) - return false; - return matcher.repo === member.repo || matcher.repo === member.log; -} -function repoNotApplicableFinding(record, matcher) { - return affectsFinding(record, matcher, AFFECTS_REPO_NOT_APPLICABLE, "info", `${matcher.type} matcher "${matcher.pattern}" is qualified with repo "${matcher.repo}" and cannot apply in a single-repository corpus; it was skipped, and is kept for a federation that includes that repository.`); +function matcherAppliesToLog(matcher, log) { + return !matcher.repo || matcher.repo === log; } function evaluateMatcher(record, matcher, changedFiles, snapshots) { if (matcher.type === "path") { @@ -41430,11 +41554,8 @@ function resolveAffects(input) { const firedMatchers = []; let suppressed = false; for (const matcher of record.frontmatter.affects ?? []) { - if (!matcherAppliesToMember(matcher, input.member)) { - if (input.member === undefined) - findings.push(repoNotApplicableFinding(record, matcher)); + if (!matcherAppliesToLog(matcher, input.log)) continue; - } const result = evaluateMatcher(record, matcher, input.changedFiles, input.snapshots); findings.push(...result.findings); if (!result.matched) @@ -41457,146 +41578,6 @@ function resolveAffects(input) { findings: sortFindings(findings) }; } - -// ../core/src/validate/affects-repo-qualifier.ts -function validateAffectsRepoQualifiers(records) { - const findings = []; - for (const record of records) { - const qualified = (record.frontmatter.affects ?? []).filter((matcher) => Boolean(matcher.repo)); - if (qualified.length === 0) - continue; - const repos = [...new Set(qualified.map((matcher) => matcher.repo))].sort(compareCodeUnits); - const count = qualified.length; - const noun = count === 1 ? "affects matcher is" : "affects matchers are"; - findings.push({ - rule: AFFECTS_REPO_NOT_APPLICABLE, - severity: "info", - message: `${count} ${noun} qualified with another repository (repo ${repos.map((repo) => `"${repo}"`).join(", ")}) ` + "and cannot apply in a single-repository corpus; adr check and adr explain skip them, " + "and they are kept for a federation that includes that repository.", - path: record.path, - id: record.frontmatter.id, - field: "affects" - }); - } - return findings; -} -// ../core/src/validate/index.ts -import { stat as stat3 } from "node:fs/promises"; -import { isAbsolute as isAbsolute2, resolve as resolve3 } from "node:path"; -function parseErrorFinding(error, path) { - if (error instanceof FrontmatterError) { - return { - rule: error.code === "invalid-yaml" ? "frontmatter-parse" : "frontmatter-fence", - severity: "error", - message: error.message, - path, - field: "frontmatter" - }; - } - return { - rule: "file-read", - severity: "error", - message: error instanceof Error ? error.message : String(error), - path - }; -} -function skippedFileFinding(skipped, cwd) { - const message = skipped.reason === "nested" ? "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" : "Markdown file in the corpus directory is not a discoverable ADR record and was skipped; rename it to -.md (four or more leading digits) for it to be linted and enforced"; - return { - rule: "corpus-file-skipped", - severity: "warn", - message, - path: normalizeDisplayPath(skipped.path, cwd) - }; -} -async function isDirectory2(path) { - return stat3(path).then((info) => info.isDirectory(), () => false); -} -async function scannedDirectories(paths, dir, cwd) { - if (!paths || paths.length === 0) - return [dir]; - const directories = new Set; - for (const path of paths) { - const absolutePath = isAbsolute2(path) ? path : resolve3(cwd, path); - if (await isDirectory2(absolutePath)) - directories.add(absolutePath); - } - return [...directories].sort((a, b) => compareByDisplayPath(a, b, cwd)); -} -async function lintCorpus(options = {}) { - const cwd = options.cwd ?? process.cwd(); - const dir = options.dir ?? "docs/adr"; - const files = await expandRecordInputs(options.paths, dir, cwd); - const records = []; - const findings = []; - for (const file of files) { - const displayPath = normalizeDisplayPath(file, cwd); - try { - const parsed = await parseAdrFile(file, cwd); - const result = validateParsedAdr(parsed); - findings.push(...result.findings); - if (result.record) { - records.push(result.record); - } - } catch (error) { - findings.push(parseErrorFinding(error, displayPath)); - } - } - const checkedPaths = new Set(files); - for (const scanned of await scannedDirectories(options.paths, dir, cwd)) { - for (const skipped of await discoverSkippedMarkdownFiles(scanned, cwd)) { - if (!checkedPaths.has(skipped.path)) - findings.push(skippedFileFinding(skipped, cwd)); - } - } - findings.push(...validateImportIncomplete(records)); - findings.push(...validateAffectsRepoQualifiers(records)); - findings.push(...validateCorpusInvariants(records)); - return { - checked: files.length, - findings: sortFindings(findings), - records: [...records].sort((a, b) => compareCodeUnits(a.frontmatter.id, b.frontmatter.id)) - }; -} -// ../core/src/graph/build.ts -function edgeKey(edge) { - return `${edge.from}\x00${edge.to}\x00${edge.kind}`; -} -function pushEdge(edges, ids, edge) { - if (!ids.has(edge.from) || !ids.has(edge.to)) - return; - edges.set(edgeKey(edge), edge); -} -function buildAdrGraph(records) { - const sortedRecords = [...records].sort((a, b) => a.frontmatter.id.localeCompare(b.frontmatter.id)); - const ids = new Set(sortedRecords.map((record) => record.frontmatter.id)); - const edges = new Map; - for (const record of sortedRecords) { - for (const superseded of record.frontmatter.supersedes) { - pushEdge(edges, ids, { from: record.frontmatter.id, to: superseded, kind: "supersedes" }); - } - if (record.frontmatter.supersededBy) { - pushEdge(edges, ids, { - from: record.frontmatter.supersededBy, - to: record.frontmatter.id, - kind: "supersedes" - }); - } - for (const related of record.frontmatter.relatesTo) { - pushEdge(edges, ids, { from: record.frontmatter.id, to: related, kind: "relatesTo" }); - } - for (const conflict of record.frontmatter.conflictsWith) { - pushEdge(edges, ids, { from: record.frontmatter.id, to: conflict, kind: "conflictsWith" }); - } - } - return { - nodes: sortedRecords.map((record) => ({ - id: record.frontmatter.id, - title: record.frontmatter.title, - status: record.frontmatter.status - })), - edges: [...edges.values()].sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to) || a.kind.localeCompare(b.kind)) - }; -} // ../core/src/markers/scan.ts var MARKER_HEADER_WINDOW_BYTES = 8192; var MARKER_DECLARATION_FILE_CAP = 64; @@ -42236,33 +42217,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"]); @@ -42390,12 +42344,9 @@ 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(); + return todayUtcDate(); 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)}.`); } @@ -44284,13 +44235,8 @@ function toPosix(path) { 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); + if (options.asOf !== undefined && !isCalendarDate2(options.asOf)) { + return usage(`asOf must be a UTC calendar date YYYY-MM-DD; got "${options.asOf}".`); } let baseUrl = null; if (options.baseUrl !== undefined && options.baseUrl !== "") { @@ -44347,6 +44293,7 @@ async function generatePortal(options = {}) { } throw error; } + const asOf = options.asOf ?? handle.queue().asOf; 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..40088844 100644 --- a/packages/ci/dist/queue-action.js +++ b/packages/ci/dist/queue-action.js @@ -37408,13 +37408,6 @@ var AdrFrontmatter = strictObject2({ }); // ../core/src/schema/emit.ts var ADR_SCHEMA_ID = `https://adrkit.dev/schema/adr/v${SCHEMA_VERSION}/adr.schema.json`; -// ../core/src/schema/ref.ts -function parseAdrRef(ref) { - const idx = ref.indexOf(":"); - if (idx <= 0) - return { id: ref }; - return { log: ref.slice(0, idx), id: ref.slice(idx + 1) }; -} // ../../node_modules/.bun/yaml@2.9.1/node_modules/yaml/dist/index.js var composer = require_composer(); var Document = require_Document(); @@ -37703,27 +37696,15 @@ function validateParsedAdr(parsed) { } // ../core/src/validate/corpus-invariants.ts function addReferenceFinding(findings, record, field, ref, severity) { - const crossLog = parseAdrRef(ref).log !== undefined; findings.push({ rule: `dangling-${field}`, severity, - message: crossLog ? `Reference "${ref}" in ${field} names another decision log; cross-repository supersession is not supported` : `Reference "${ref}" in ${field} does not resolve to a record in the corpus`, + message: `Reference "${ref}" in ${field} does not resolve to a record in the corpus`, path: record.path, id: record.frontmatter.id, field }); } -function addFederatedReferenceFinding(findings, record, field, ref, log) { - findings.push({ - rule: "federated-ref-unresolved", - severity: "info", - message: `Reference "${ref}" in ${field} names another decision log ("${log}") and is inert against this corpus`, - path: record.path, - id: record.frontmatter.id, - field, - pattern: ref - }); -} function validateCorpusInvariants(records) { const findings = []; const byId = new Map; @@ -37757,17 +37738,11 @@ function validateCorpusInvariants(records) { addReferenceFinding(findings, record, "supersededBy", record.frontmatter.supersededBy, "error"); } for (const ref of record.frontmatter.relatesTo) { - const { log } = parseAdrRef(ref); - if (log !== undefined) - addFederatedReferenceFinding(findings, record, "relatesTo", ref, log); - else if (!ids.has(ref)) + if (!ids.has(ref)) addReferenceFinding(findings, record, "relatesTo", ref, "error"); } for (const ref of record.frontmatter.conflictsWith) { - const { log } = parseAdrRef(ref); - if (log !== undefined) { - addFederatedReferenceFinding(findings, record, "conflictsWith", ref, log); - } else if (!ids.has(ref)) { + if (!ids.has(ref)) { addReferenceFinding(findings, record, "conflictsWith", ref, "warn"); } else if (record.frontmatter.status === "accepted") { findings.push({ @@ -37802,33 +37777,6 @@ function validateImportIncomplete(records) { } return findings; } -// ../core/src/affects/matchers/package.ts -var import_semver = __toESM(require_semver2(), 1); - -// ../core/src/affects/index.ts -var AFFECTS_REPO_NOT_APPLICABLE = "affects-repo-not-applicable"; - -// ../core/src/validate/affects-repo-qualifier.ts -function validateAffectsRepoQualifiers(records) { - const findings = []; - for (const record of records) { - const qualified = (record.frontmatter.affects ?? []).filter((matcher) => Boolean(matcher.repo)); - if (qualified.length === 0) - continue; - const repos = [...new Set(qualified.map((matcher) => matcher.repo))].sort(compareCodeUnits); - const count = qualified.length; - const noun = count === 1 ? "affects matcher is" : "affects matchers are"; - findings.push({ - rule: AFFECTS_REPO_NOT_APPLICABLE, - severity: "info", - message: `${count} ${noun} qualified with another repository (repo ${repos.map((repo) => `"${repo}"`).join(", ")}) ` + "and cannot apply in a single-repository corpus; adr check and adr explain skip them, " + "and they are kept for a federation that includes that repository.", - path: record.path, - id: record.frontmatter.id, - field: "affects" - }); - } - return findings; -} // ../core/src/validate/index.ts import { stat as stat3 } from "node:fs/promises"; import { isAbsolute as isAbsolute2, resolve as resolve2 } from "node:path"; @@ -37899,7 +37847,6 @@ async function lintCorpus(options = {}) { } } findings.push(...validateImportIncomplete(records)); - findings.push(...validateAffectsRepoQualifiers(records)); findings.push(...validateCorpusInvariants(records)); return { checked: files.length, @@ -37907,6 +37854,21 @@ 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/affects/matchers/package.ts +var import_semver = __toESM(require_semver2(), 1); // ../core/src/import/status.ts var MADR_RECOGNIZED_STATUSES = [ "draft", @@ -38368,7 +38330,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/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/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..46375c52 100644 --- a/packages/portal/src/generate.ts +++ b/packages/portal/src/generate.ts @@ -3,7 +3,8 @@ * ([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`, takes `asOf` from the SDK's own default when not + * given (today, UTC — `queue()` echoes the date it used, so no clock is read 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 @@ -63,8 +64,6 @@ export interface GeneratePortalOptions { 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`. */ @@ -116,12 +115,10 @@ 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); + // A supplied `asOf` is validated before anything is read, so a usage error is not reported + // behind a corpus that may itself fail to load. An absent one is taken from the SDK below. + if (options.asOf !== undefined && !isCalendarDate(options.asOf)) { + return usage(`asOf must be a UTC calendar date YYYY-MM-DD; got "${options.asOf}".`); } let baseUrl: string | null = null; @@ -188,6 +185,12 @@ export async function generatePortal(options: GeneratePortalOptions = {}): Promi throw error; } + // Absent `asOf`: the SDK's default, which is core's `todayUtcDate()` — the one implementation + // `adr queue` and the queue Action also use. `queue()` echoes the date it computed against, + // so the portal reads no clock of its own and cannot drift from CI near UTC midnight. The + // builder stays pure and still takes the date as an argument. + const asOf = options.asOf ?? handle.queue().asOf; + 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..45f986a9 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,15 @@ 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 after = new Date().toISOString().slice(0, 10); + expect([before, after]).toContain(result.data!.asOf); + const sdkDefault = (await openDecisions({ cwd: healthyRoot })).queue().asOf; + if (before === after) expect(result.data!.asOf).toBe(sdkDefault); }); test('from a subdirectory, the corpus opens from the top level and the table covers the whole tree', async () => { diff --git a/packages/sdk/src/decision-set.ts b/packages/sdk/src/decision-set.ts index 8122f6ef..18f738f6 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, @@ -212,20 +213,11 @@ 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(); + // 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(); // 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. diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index c7c08ca9..6f840679 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -317,7 +317,8 @@ 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. + * UTC calendar date, `YYYY-MM-DD`, that SLA state is computed against. 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 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'); + } + }); +}); From d167c53596311df9f1d1e38bf42c406b509c0839 Mon Sep 17 00:00:00 2001 From: Mark Beacom Date: Sat, 10 Oct 2026 19:34:06 -0400 Subject: [PATCH 2/3] fix(sdk,portal): accept the CLI's --as-of grammar in queue(), so the portal takes ISO datetimes Review on #312 found three gaps: - #295 promised the portal would accept ISO datetimes once the default moved to core, but it still refused them. The SDK's queue({ asOf }) now resolves a supplied value through core's resolveAsOf, the grammar `adr queue --as-of` uses (a bare date, or an ISO datetime with an explicit zone), and echoes the resolved UTC date in QueueView.asOf; anything else throws RangeError. The portal passes the caller's asOf to queue() and builds from view.asOf, so it accepts datetimes while importing only the SDK. No new SDK symbol; the doc no longer tells callers to reach into core. - The portal's default-date test could fail across UTC midnight; it now brackets both readings and compares only when no midnight passed. - docs/sdk-surface.md and the ADR-0031 item 8 note still described the portal's removed copy and bare-date rule. New tests were observed failing first (ADR-0016), including SDK/CLI equivalence for a datetime asOf on all four corpora. Signed-off-by: Mark Beacom --- CHANGELOG.md | 7 ++- ...ract-and-document-the-cli-json-as-its-s.md | 3 +- docs/sdk-surface.md | 14 ++++-- packages/ci/portal/action.yml | 5 ++- packages/portal/src/generate.ts | 45 ++++++++++--------- packages/portal/test/generate.test.ts | 13 +++++- packages/sdk/src/decision-set.ts | 21 ++++----- packages/sdk/src/index.ts | 19 ++++---- packages/sdk/test/cli-equivalence.test.ts | 10 +++++ packages/sdk/test/open-decisions.test.ts | 12 ++++- 10 files changed, 97 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ad835e8..26aa1acc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,7 +106,12 @@ Until `1.0.0`, minor releases may include breaking changes `--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. A library consumer can now default the + 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) 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 a450ed47..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 @@ -343,4 +343,5 @@ recorded as its own wrongness signal. 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`), and keeps its bare-date rule for an `asOf` a caller supplies. + `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/sdk-surface.md b/docs/sdk-surface.md index 06d101f6..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 @@ -213,8 +213,14 @@ Five points where the declared surface left a choice, and how each was settled: - **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. - The static portal still carries its own copy, because it may import only `@adrkit/sdk` and the - SDK exposes no date helper; it also keeps a bare-date `asOf` rule for the same reason. +- **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, 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/portal/src/generate.ts b/packages/portal/src/generate.ts index 46375c52..1e147b36 100644 --- a/packages/portal/src/generate.ts +++ b/packages/portal/src/generate.ts @@ -3,8 +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`, takes `asOf` from the SDK's own default when not - * given (today, UTC — `queue()` echoes the date it used, so no clock is read here), + * 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 @@ -58,7 +59,11 @@ 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; @@ -97,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('/'); } @@ -115,11 +112,6 @@ export async function generatePortal(options: GeneratePortalOptions = {}): Promi const given = resolve(options.cwd ?? process.cwd()); const cwd = await realpath(given).catch(() => given); - // A supplied `asOf` is validated before anything is read, so a usage error is not reported - // behind a corpus that may itself fail to load. An absent one is taken from the SDK below. - if (options.asOf !== undefined && !isCalendarDate(options.asOf)) { - return usage(`asOf must be a UTC calendar date YYYY-MM-DD; got "${options.asOf}".`); - } let baseUrl: string | null = null; if (options.baseUrl !== undefined && options.baseUrl !== '') { @@ -185,11 +177,22 @@ export async function generatePortal(options: GeneratePortalOptions = {}): Promi throw error; } - // Absent `asOf`: the SDK's default, which is core's `todayUtcDate()` — the one implementation - // `adr queue` and the queue Action also use. `queue()` echoes the date it computed against, - // so the portal reads no clock of its own and cannot drift from CI near UTC midnight. The - // builder stays pure and still takes the date as an argument. - const asOf = options.asOf ?? handle.queue().asOf; + // `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 diff --git a/packages/portal/test/generate.test.ts b/packages/portal/test/generate.test.ts index 45f986a9..1bc6562d 100644 --- a/packages/portal/test/generate.test.ts +++ b/packages/portal/test/generate.test.ts @@ -133,12 +133,21 @@ describe('exit 0: a healthy corpus in a git repository', () => { // 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); - const sdkDefault = (await openDecisions({ cwd: healthyRoot })).queue().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 () => { const result = await generatePortal({ cwd: join(healthyRoot, 'docs'), dir: 'adr', out: '../site-e', asOf: AS_OF }); expect(result.exitCode).toBe(0); @@ -192,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 18f738f6..18544a3e 100644 --- a/packages/sdk/src/decision-set.ts +++ b/packages/sdk/src/decision-set.ts @@ -186,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']; @@ -218,15 +216,18 @@ function resolveQueueAsOf(asOf: string | undefined): string { // 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(); - // 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)}.`, - ); + // 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 6f840679..975286c4 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -317,17 +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's + * 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; } @@ -339,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/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); }); }); From 3eab9469e52ce40dc3058831b0dbbfe169a66432 Mon Sep 17 00:00:00 2001 From: Mark Beacom Date: Sat, 10 Oct 2026 19:40:35 -0400 Subject: [PATCH 3/3] build(ci): rebuild the Action bundles after rebasing onto main, and test the portal bundle's datetime as-of packages/ci/dist rebuilt under linux/amd64 Bun 1.4.2 on top of main (after #307, #308, #314). The portal bundle now takes the SDK's --as-of grammar: a new bundle test resolves 2026-01-08T23:30:00-05:00 to 2026-01-09, and the usage-error test uses a timezone-less datetime with core's message. Both failed against main's bundle (ADR-0016). Signed-off-by: Mark Beacom --- packages/ci/dist/portal-action.js | 394 ++++++++++++++----------- packages/ci/dist/queue-action.js | 61 +++- packages/ci/test/portal-bundle.test.ts | 15 +- 3 files changed, 291 insertions(+), 179 deletions(-) diff --git a/packages/ci/dist/portal-action.js b/packages/ci/dist/portal-action.js index dc8e5c27..c71c6739 100644 --- a/packages/ci/dist/portal-action.js +++ b/packages/ci/dist/portal-action.js @@ -41128,15 +41128,27 @@ function validateParsedAdr(parsed) { } // ../core/src/validate/corpus-invariants.ts function addReferenceFinding(findings, record, field, ref, severity) { + const crossLog = parseAdrRef(ref).log !== undefined; findings.push({ rule: `dangling-${field}`, severity, - message: `Reference "${ref}" in ${field} does not resolve to a record in the corpus`, + message: crossLog ? `Reference "${ref}" in ${field} names another decision log; cross-repository supersession is not supported` : `Reference "${ref}" in ${field} does not resolve to a record in the corpus`, path: record.path, id: record.frontmatter.id, field }); } +function addFederatedReferenceFinding(findings, record, field, ref, log) { + findings.push({ + rule: "federated-ref-unresolved", + severity: "info", + message: `Reference "${ref}" in ${field} names another decision log ("${log}") and is inert against this corpus`, + path: record.path, + id: record.frontmatter.id, + field, + pattern: ref + }); +} function validateCorpusInvariants(records) { const findings = []; const byId = new Map; @@ -41170,11 +41182,17 @@ function validateCorpusInvariants(records) { addReferenceFinding(findings, record, "supersededBy", record.frontmatter.supersededBy, "error"); } for (const ref of record.frontmatter.relatesTo) { - if (!ids.has(ref)) + const { log } = parseAdrRef(ref); + if (log !== undefined) + addFederatedReferenceFinding(findings, record, "relatesTo", ref, log); + else if (!ids.has(ref)) addReferenceFinding(findings, record, "relatesTo", ref, "error"); } for (const ref of record.frontmatter.conflictsWith) { - if (!ids.has(ref)) { + const { log } = parseAdrRef(ref); + if (log !== undefined) { + addFederatedReferenceFinding(findings, record, "conflictsWith", ref, log); + } else if (!ids.has(ref)) { addReferenceFinding(findings, record, "conflictsWith", ref, "warn"); } else if (record.frontmatter.status === "accepted") { findings.push({ @@ -41209,156 +41227,6 @@ function validateImportIncomplete(records) { } return findings; } -// ../core/src/validate/index.ts -import { stat as stat3 } from "node:fs/promises"; -import { isAbsolute as isAbsolute2, resolve as resolve3 } from "node:path"; -function parseErrorFinding(error, path) { - if (error instanceof FrontmatterError) { - return { - rule: error.code === "invalid-yaml" ? "frontmatter-parse" : "frontmatter-fence", - severity: "error", - message: error.message, - path, - field: "frontmatter" - }; - } - return { - rule: "file-read", - severity: "error", - message: error instanceof Error ? error.message : String(error), - path - }; -} -function skippedFileFinding(skipped, cwd) { - const message = skipped.reason === "nested" ? "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" : "Markdown file in the corpus directory is not a discoverable ADR record and was skipped; rename it to -.md (four or more leading digits) for it to be linted and enforced"; - return { - rule: "corpus-file-skipped", - severity: "warn", - message, - path: normalizeDisplayPath(skipped.path, cwd) - }; -} -async function isDirectory2(path) { - return stat3(path).then((info) => info.isDirectory(), () => false); -} -async function scannedDirectories(paths, dir, cwd) { - if (!paths || paths.length === 0) - return [dir]; - const directories = new Set; - for (const path of paths) { - const absolutePath = isAbsolute2(path) ? path : resolve3(cwd, path); - if (await isDirectory2(absolutePath)) - directories.add(absolutePath); - } - return [...directories].sort((a, b) => compareByDisplayPath(a, b, cwd)); -} -async function lintCorpus(options = {}) { - const cwd = options.cwd ?? process.cwd(); - const dir = options.dir ?? "docs/adr"; - const files = await expandRecordInputs(options.paths, dir, cwd); - const records = []; - const findings = []; - for (const file of files) { - const displayPath = normalizeDisplayPath(file, cwd); - try { - const parsed = await parseAdrFile(file, cwd); - const result = validateParsedAdr(parsed); - findings.push(...result.findings); - if (result.record) { - records.push(result.record); - } - } catch (error) { - findings.push(parseErrorFinding(error, displayPath)); - } - } - const checkedPaths = new Set(files); - for (const scanned of await scannedDirectories(options.paths, dir, cwd)) { - for (const skipped of await discoverSkippedMarkdownFiles(scanned, cwd)) { - if (!checkedPaths.has(skipped.path)) - findings.push(skippedFileFinding(skipped, cwd)); - } - } - findings.push(...validateImportIncomplete(records)); - findings.push(...validateCorpusInvariants(records)); - return { - checked: files.length, - findings: sortFindings(findings), - 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}`; -} -function pushEdge(edges, ids, edge) { - if (!ids.has(edge.from) || !ids.has(edge.to)) - return; - edges.set(edgeKey(edge), edge); -} -function buildAdrGraph(records) { - const sortedRecords = [...records].sort((a, b) => a.frontmatter.id.localeCompare(b.frontmatter.id)); - const ids = new Set(sortedRecords.map((record) => record.frontmatter.id)); - const edges = new Map; - for (const record of sortedRecords) { - for (const superseded of record.frontmatter.supersedes) { - pushEdge(edges, ids, { from: record.frontmatter.id, to: superseded, kind: "supersedes" }); - } - if (record.frontmatter.supersededBy) { - pushEdge(edges, ids, { - from: record.frontmatter.supersededBy, - to: record.frontmatter.id, - kind: "supersedes" - }); - } - for (const related of record.frontmatter.relatesTo) { - pushEdge(edges, ids, { from: record.frontmatter.id, to: related, kind: "relatesTo" }); - } - for (const conflict of record.frontmatter.conflictsWith) { - pushEdge(edges, ids, { from: record.frontmatter.id, to: conflict, kind: "conflictsWith" }); - } - } - return { - nodes: sortedRecords.map((record) => ({ - id: record.frontmatter.id, - title: record.frontmatter.title, - status: record.frontmatter.status - })), - edges: [...edges.values()].sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to) || a.kind.localeCompare(b.kind)) - }; -} // ../core/src/affects/inert.ts var import_picomatch = __toESM(require_picomatch2(), 1); function compilePattern(pattern) { @@ -41463,6 +41331,7 @@ function matchPathPattern(pattern, changedFiles) { } // ../core/src/affects/index.ts +var AFFECTS_REPO_NOT_APPLICABLE = "affects-repo-not-applicable"; function compareFiredMatcher(a, b) { return compareCodeUnits(a.type, b.type) || compareCodeUnits(a.pattern, b.pattern); } @@ -41484,8 +41353,15 @@ function affectsFinding(record, matcher, rule, severity, message) { pattern: matcher.pattern }; } -function matcherAppliesToLog(matcher, log) { - return !matcher.repo || matcher.repo === log; +function matcherAppliesToMember(matcher, member) { + if (!matcher.repo) + return true; + if (member === undefined) + return false; + return matcher.repo === member.repo || matcher.repo === member.log; +} +function repoNotApplicableFinding(record, matcher) { + return affectsFinding(record, matcher, AFFECTS_REPO_NOT_APPLICABLE, "info", `${matcher.type} matcher "${matcher.pattern}" is qualified with repo "${matcher.repo}" and cannot apply in a single-repository corpus; it was skipped, and is kept for a federation that includes that repository.`); } function evaluateMatcher(record, matcher, changedFiles, snapshots) { if (matcher.type === "path") { @@ -41554,8 +41430,11 @@ function resolveAffects(input) { const firedMatchers = []; let suppressed = false; for (const matcher of record.frontmatter.affects ?? []) { - if (!matcherAppliesToLog(matcher, input.log)) + if (!matcherAppliesToMember(matcher, input.member)) { + if (input.member === undefined) + findings.push(repoNotApplicableFinding(record, matcher)); continue; + } const result = evaluateMatcher(record, matcher, input.changedFiles, input.snapshots); findings.push(...result.findings); if (!result.matched) @@ -41578,6 +41457,179 @@ function resolveAffects(input) { findings: sortFindings(findings) }; } + +// ../core/src/validate/affects-repo-qualifier.ts +function validateAffectsRepoQualifiers(records) { + const findings = []; + for (const record of records) { + const qualified = (record.frontmatter.affects ?? []).filter((matcher) => Boolean(matcher.repo)); + if (qualified.length === 0) + continue; + const repos = [...new Set(qualified.map((matcher) => matcher.repo))].sort(compareCodeUnits); + const count = qualified.length; + const noun = count === 1 ? "affects matcher is" : "affects matchers are"; + findings.push({ + rule: AFFECTS_REPO_NOT_APPLICABLE, + severity: "info", + message: `${count} ${noun} qualified with another repository (repo ${repos.map((repo) => `"${repo}"`).join(", ")}) ` + "and cannot apply in a single-repository corpus; adr check and adr explain skip them, " + "and they are kept for a federation that includes that repository.", + path: record.path, + id: record.frontmatter.id, + field: "affects" + }); + } + return findings; +} +// ../core/src/validate/index.ts +import { stat as stat3 } from "node:fs/promises"; +import { isAbsolute as isAbsolute2, resolve as resolve3 } from "node:path"; +function parseErrorFinding(error, path) { + if (error instanceof FrontmatterError) { + return { + rule: error.code === "invalid-yaml" ? "frontmatter-parse" : "frontmatter-fence", + severity: "error", + message: error.message, + path, + field: "frontmatter" + }; + } + return { + rule: "file-read", + severity: "error", + message: error instanceof Error ? error.message : String(error), + path + }; +} +function skippedFileFinding(skipped, cwd) { + const message = skipped.reason === "nested" ? "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" : "Markdown file in the corpus directory is not a discoverable ADR record and was skipped; rename it to -.md (four or more leading digits) for it to be linted and enforced"; + return { + rule: "corpus-file-skipped", + severity: "warn", + message, + path: normalizeDisplayPath(skipped.path, cwd) + }; +} +async function isDirectory2(path) { + return stat3(path).then((info) => info.isDirectory(), () => false); +} +async function scannedDirectories(paths, dir, cwd) { + if (!paths || paths.length === 0) + return [dir]; + const directories = new Set; + for (const path of paths) { + const absolutePath = isAbsolute2(path) ? path : resolve3(cwd, path); + if (await isDirectory2(absolutePath)) + directories.add(absolutePath); + } + return [...directories].sort((a, b) => compareByDisplayPath(a, b, cwd)); +} +async function lintCorpus(options = {}) { + const cwd = options.cwd ?? process.cwd(); + const dir = options.dir ?? "docs/adr"; + const files = await expandRecordInputs(options.paths, dir, cwd); + const records = []; + const findings = []; + for (const file of files) { + const displayPath = normalizeDisplayPath(file, cwd); + try { + const parsed = await parseAdrFile(file, cwd); + const result = validateParsedAdr(parsed); + findings.push(...result.findings); + if (result.record) { + records.push(result.record); + } + } catch (error) { + findings.push(parseErrorFinding(error, displayPath)); + } + } + const checkedPaths = new Set(files); + for (const scanned of await scannedDirectories(options.paths, dir, cwd)) { + for (const skipped of await discoverSkippedMarkdownFiles(scanned, cwd)) { + if (!checkedPaths.has(skipped.path)) + findings.push(skippedFileFinding(skipped, cwd)); + } + } + findings.push(...validateImportIncomplete(records)); + findings.push(...validateAffectsRepoQualifiers(records)); + findings.push(...validateCorpusInvariants(records)); + return { + checked: files.length, + findings: sortFindings(findings), + 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}`; +} +function pushEdge(edges, ids, edge) { + if (!ids.has(edge.from) || !ids.has(edge.to)) + return; + edges.set(edgeKey(edge), edge); +} +function buildAdrGraph(records) { + const sortedRecords = [...records].sort((a, b) => a.frontmatter.id.localeCompare(b.frontmatter.id)); + const ids = new Set(sortedRecords.map((record) => record.frontmatter.id)); + const edges = new Map; + for (const record of sortedRecords) { + for (const superseded of record.frontmatter.supersedes) { + pushEdge(edges, ids, { from: record.frontmatter.id, to: superseded, kind: "supersedes" }); + } + if (record.frontmatter.supersededBy) { + pushEdge(edges, ids, { + from: record.frontmatter.supersededBy, + to: record.frontmatter.id, + kind: "supersedes" + }); + } + for (const related of record.frontmatter.relatesTo) { + pushEdge(edges, ids, { from: record.frontmatter.id, to: related, kind: "relatesTo" }); + } + for (const conflict of record.frontmatter.conflictsWith) { + pushEdge(edges, ids, { from: record.frontmatter.id, to: conflict, kind: "conflictsWith" }); + } + } + return { + nodes: sortedRecords.map((record) => ({ + id: record.frontmatter.id, + title: record.frontmatter.title, + status: record.frontmatter.status + })), + edges: [...edges.values()].sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to) || a.kind.localeCompare(b.kind)) + }; +} // ../core/src/markers/scan.ts var MARKER_HEADER_WINDOW_BYTES = 8192; var MARKER_DECLARATION_FILE_CAP = 64; @@ -42328,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) { @@ -42347,10 +42398,12 @@ function resolveEdgeKinds(kinds) { function resolveQueueAsOf(asOf) { if (asOf === undefined) return todayUtcDate(); - 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)}.`); + 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 }; @@ -44221,23 +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); - if (options.asOf !== undefined && !isCalendarDate2(options.asOf)) { - return usage(`asOf must be a UTC calendar date YYYY-MM-DD; got "${options.asOf}".`); - } let baseUrl = null; if (options.baseUrl !== undefined && options.baseUrl !== "") { let parsed = null; @@ -44293,7 +44335,15 @@ async function generatePortal(options = {}) { } throw error; } - const asOf = options.asOf ?? handle.queue().asOf; + 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 40088844..d9640de5 100644 --- a/packages/ci/dist/queue-action.js +++ b/packages/ci/dist/queue-action.js @@ -37408,6 +37408,13 @@ var AdrFrontmatter = strictObject2({ }); // ../core/src/schema/emit.ts var ADR_SCHEMA_ID = `https://adrkit.dev/schema/adr/v${SCHEMA_VERSION}/adr.schema.json`; +// ../core/src/schema/ref.ts +function parseAdrRef(ref) { + const idx = ref.indexOf(":"); + if (idx <= 0) + return { id: ref }; + return { log: ref.slice(0, idx), id: ref.slice(idx + 1) }; +} // ../../node_modules/.bun/yaml@2.9.1/node_modules/yaml/dist/index.js var composer = require_composer(); var Document = require_Document(); @@ -37696,15 +37703,27 @@ function validateParsedAdr(parsed) { } // ../core/src/validate/corpus-invariants.ts function addReferenceFinding(findings, record, field, ref, severity) { + const crossLog = parseAdrRef(ref).log !== undefined; findings.push({ rule: `dangling-${field}`, severity, - message: `Reference "${ref}" in ${field} does not resolve to a record in the corpus`, + message: crossLog ? `Reference "${ref}" in ${field} names another decision log; cross-repository supersession is not supported` : `Reference "${ref}" in ${field} does not resolve to a record in the corpus`, path: record.path, id: record.frontmatter.id, field }); } +function addFederatedReferenceFinding(findings, record, field, ref, log) { + findings.push({ + rule: "federated-ref-unresolved", + severity: "info", + message: `Reference "${ref}" in ${field} names another decision log ("${log}") and is inert against this corpus`, + path: record.path, + id: record.frontmatter.id, + field, + pattern: ref + }); +} function validateCorpusInvariants(records) { const findings = []; const byId = new Map; @@ -37738,11 +37757,17 @@ function validateCorpusInvariants(records) { addReferenceFinding(findings, record, "supersededBy", record.frontmatter.supersededBy, "error"); } for (const ref of record.frontmatter.relatesTo) { - if (!ids.has(ref)) + const { log } = parseAdrRef(ref); + if (log !== undefined) + addFederatedReferenceFinding(findings, record, "relatesTo", ref, log); + else if (!ids.has(ref)) addReferenceFinding(findings, record, "relatesTo", ref, "error"); } for (const ref of record.frontmatter.conflictsWith) { - if (!ids.has(ref)) { + const { log } = parseAdrRef(ref); + if (log !== undefined) { + addFederatedReferenceFinding(findings, record, "conflictsWith", ref, log); + } else if (!ids.has(ref)) { addReferenceFinding(findings, record, "conflictsWith", ref, "warn"); } else if (record.frontmatter.status === "accepted") { findings.push({ @@ -37777,6 +37802,33 @@ function validateImportIncomplete(records) { } return findings; } +// ../core/src/affects/matchers/package.ts +var import_semver = __toESM(require_semver2(), 1); + +// ../core/src/affects/index.ts +var AFFECTS_REPO_NOT_APPLICABLE = "affects-repo-not-applicable"; + +// ../core/src/validate/affects-repo-qualifier.ts +function validateAffectsRepoQualifiers(records) { + const findings = []; + for (const record of records) { + const qualified = (record.frontmatter.affects ?? []).filter((matcher) => Boolean(matcher.repo)); + if (qualified.length === 0) + continue; + const repos = [...new Set(qualified.map((matcher) => matcher.repo))].sort(compareCodeUnits); + const count = qualified.length; + const noun = count === 1 ? "affects matcher is" : "affects matchers are"; + findings.push({ + rule: AFFECTS_REPO_NOT_APPLICABLE, + severity: "info", + message: `${count} ${noun} qualified with another repository (repo ${repos.map((repo) => `"${repo}"`).join(", ")}) ` + "and cannot apply in a single-repository corpus; adr check and adr explain skip them, " + "and they are kept for a federation that includes that repository.", + path: record.path, + id: record.frontmatter.id, + field: "affects" + }); + } + return findings; +} // ../core/src/validate/index.ts import { stat as stat3 } from "node:fs/promises"; import { isAbsolute as isAbsolute2, resolve as resolve2 } from "node:path"; @@ -37847,6 +37899,7 @@ async function lintCorpus(options = {}) { } } findings.push(...validateImportIncomplete(records)); + findings.push(...validateAffectsRepoQualifiers(records)); findings.push(...validateCorpusInvariants(records)); return { checked: files.length, @@ -37867,8 +37920,6 @@ function todayUtcDate(now = new Date) { throw new RangeError(`todayUtcDate: ${now.toISOString()} is outside years 0000-9999.`); return date; } -// ../core/src/affects/matchers/package.ts -var import_semver = __toESM(require_semver2(), 1); // ../core/src/import/status.ts var MADR_RECOGNIZED_STATUSES = [ "draft", 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'); + }); });