@sdd-method/author-cli 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/dist/lib/api-surface/__fixtures__/serverless-repo/serverless/serverless.yml +4 -4
  2. package/dist/lib/api-surface/analyzers/serverless.js +1 -1
  3. package/dist/lib/api-surface/analyzers/spring.js +1 -1
  4. package/dist/lib/briefing/render.js +1 -2
  5. package/dist/lib/briefing/variables.js +1 -2
  6. package/dist/lib/catalogue/archetype/wireframe.js +1 -1
  7. package/dist/lib/catalogue/build.js +17 -23
  8. package/dist/lib/catalogue/builders/application-conformance-coverage.js +1 -1
  9. package/dist/lib/catalogue/builders/c4-components.js +4 -5
  10. package/dist/lib/catalogue/builders/c4-containers.js +3 -3
  11. package/dist/lib/catalogue/builders/c4-entity-rollups.js +1 -1
  12. package/dist/lib/catalogue/builders/c4-external-systems.js +1 -1
  13. package/dist/lib/catalogue/builders/c4-l1-candidates.js +1 -1
  14. package/dist/lib/catalogue/builders/capabilities.js +2 -2
  15. package/dist/lib/catalogue/builders/capability-edges.js +4 -4
  16. package/dist/lib/catalogue/builders/capability-spec-walker.js +3 -3
  17. package/dist/lib/catalogue/builders/container-dependency.js +18 -19
  18. package/dist/lib/catalogue/builders/contracts.js +5 -6
  19. package/dist/lib/catalogue/builders/data-store-mapping.js +3 -3
  20. package/dist/lib/catalogue/builders/edge-label-merge.js +2 -2
  21. package/dist/lib/catalogue/builders/entities-manifest.js +1 -1
  22. package/dist/lib/catalogue/builders/entities-mermaid.js +3 -3
  23. package/dist/lib/catalogue/builders/entity-slug.js +11 -12
  24. package/dist/lib/catalogue/builders/event-catalog-walker.js +3 -3
  25. package/dist/lib/catalogue/builders/event-edges.js +1 -1
  26. package/dist/lib/catalogue/builders/evidence-class.js +2 -2
  27. package/dist/lib/catalogue/builders/external-system-exemption.js +1 -1
  28. package/dist/lib/catalogue/builders/feature-contract.js +1 -3
  29. package/dist/lib/catalogue/builders/feature-grounding.js +1 -2
  30. package/dist/lib/catalogue/builders/feature-groups.js +0 -1
  31. package/dist/lib/catalogue/builders/feature-subscription-channels.js +2 -3
  32. package/dist/lib/catalogue/builders/feature-tree-walker.js +1 -1
  33. package/dist/lib/catalogue/builders/features.js +3 -4
  34. package/dist/lib/catalogue/builders/glossary-xref.js +1 -1
  35. package/dist/lib/catalogue/builders/persona-c4system.js +7 -7
  36. package/dist/lib/catalogue/builders/persona-container.js +14 -14
  37. package/dist/lib/catalogue/builders/personas.js +1 -2
  38. package/dist/lib/catalogue/builders/product-platform-dependencies.js +2 -2
  39. package/dist/lib/catalogue/builders/quality-scores.js +1 -1
  40. package/dist/lib/catalogue/builders/repositories.js +2 -2
  41. package/dist/lib/catalogue/builders/screen-tree.js +1 -1
  42. package/dist/lib/catalogue/builders/screens.js +3 -4
  43. package/dist/lib/catalogue/builders/system-external-dependency.js +7 -7
  44. package/dist/lib/catalogue/canonical-schema.yaml +23 -23
  45. package/dist/lib/catalogue/coverage-predicates.js +4 -4
  46. package/dist/lib/catalogue/coverage-run.js +1 -1
  47. package/dist/lib/catalogue/dedupe-keyed-tables.js +2 -3
  48. package/dist/lib/catalogue/ext-id.js +4 -4
  49. package/dist/lib/catalogue/feature-grounding-aliases-loader.js +1 -1
  50. package/dist/lib/catalogue/finding-identity.js +3 -3
  51. package/dist/lib/catalogue/freshness.js +1 -1
  52. package/dist/lib/catalogue/manifest-loader.js +2 -2
  53. package/dist/lib/catalogue/scaffold-delta.js +1 -1
  54. package/dist/lib/catalogue/schema-compat.js +1 -2
  55. package/dist/lib/catalogue/screen-validation-loader.js +1 -1
  56. package/dist/lib/catalogue/staleness-verdicts.js +2 -2
  57. package/dist/lib/catalogue/traceability-loader.js +2 -3
  58. package/dist/lib/conformance/index.js +29 -8
  59. package/dist/lib/lock-file.js +1 -1
  60. package/dist/lib/package-version.js +1 -1
  61. package/dist/lib/snapshot/manifest.js +1 -1
  62. package/dist/lib/snapshot/targets.js +1 -1
  63. package/dist/lib/validate/behaviour-store.js +351 -0
  64. package/dist/lib/validate/dispatch.js +18 -0
  65. package/dist/lib/validate/index.js +1 -1
  66. package/dist/lib/validate/plan.js +3 -1
  67. package/dist/lib/validate/yaml-parse.js +1 -1
  68. package/dist/maintain-version.json +2 -2
  69. package/dist/verbs/plan.js +3 -3
  70. package/dist/verify-version.json +2 -2
  71. package/package.json +4 -6
  72. package/scripts/validate-app-dependencies.sh +5 -5
  73. package/scripts/validate-integration-docs.sh +1 -1
  74. package/LICENSE +0 -201
  75. package/NOTICE +0 -17
@@ -89,7 +89,7 @@ export async function runCatalogueCoverage(inputs, write) {
89
89
  const available = new Set(schema.catalogues.map((c) => c.name).filter((n) => present(n)));
90
90
  lines.push(...renderScoresCoverage(await runScoresCoverage(session, { tier, available, sddRoot }), tier));
91
91
  // Staleness VERDICTS — the second half of the fourteenth check's §4.5
92
- // scope (ledger §3o), member-tier only with the aggregate skip stated.
92
+ // scope, member-tier only with the aggregate skip stated.
93
93
  // Same coverage class, same reporting-only staging as the rest of the
94
94
  // scores family.
95
95
  lines.push(...renderStalenessVerdicts(await runStalenessVerdicts(session, { tier, available, sddRoot }), tier));
@@ -20,10 +20,9 @@
20
20
  * `build` (where the offending SDD is unambiguous) instead of only at
21
21
  * `aggregate`.
22
22
  *
23
- * Background: the method's coordination record feature-edge-cross-source-duplicate.
24
23
  * Sibling precedents (the unconditional-emit fixes whose per-walker dedup this
25
- * generalises across sources): `feature-grounding-collision-edges-coordination.md`
26
- * (0.30.2) and `capability-spec-collision-edges-coordination.md` (0.30.3).
24
+ * generalises across sources): the feature-grounding collision-edge fix
25
+ * (0.30.2) and the capability-spec collision-edge fix (0.30.3).
27
26
  */
28
27
  /**
29
28
  * Render a cell exactly as the CSV writer will, so two rows count as identical
@@ -12,12 +12,12 @@
12
12
  // carries (`name`), so the old→new map is a build artefact, not an authored
13
13
  // migration: `sdd-cli catalogue migration-map` emits it, and the exemption
14
14
  // loader consults it during the cut window (an authored exemption keyed by
15
- // a mapped old id is regenerable, NOT drift — the F30 rule at schema scale).
15
+ // a mapped old id is regenerable, NOT drift — the regenerability rule at schema scale).
16
16
  /**
17
17
  * Normalise a declared diagram entry id to slug form. Camel humps split
18
- * BEFORE lowercasing (the F11a rule, matching the aggregator's resolver):
19
- * most SDD ids are hyphenated, and `bvfmGatewayFramework` must derive the
20
- * same identity the adjudicator resolves for it (`bvfm-gateway-framework`).
18
+ * BEFORE lowercasing (matching the aggregator's resolver):
19
+ * most SDD ids are hyphenated, and `posGatewayFramework` must derive the
20
+ * same identity the adjudicator resolves for it (`pos-gateway-framework`).
21
21
  */
22
22
  export function normaliseEntryId(entryId) {
23
23
  return entryId
@@ -14,7 +14,7 @@
14
14
  * AuthHandler: example-service-api
15
15
  * web-ui: example-web-ui
16
16
  * repo_aliases:
17
- * camera-bff: global-camera-bff
17
+ * stock-bff: northwind-stock-bff
18
18
  *
19
19
  * Either block is optional. Empty / missing file = no aliases (every
20
20
  * reference must resolve directly against the canonical id).
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Finding IDENTITY — the stable key a findings baseline is compared on, per
3
- * M-ADR 0132 §4.3 as amended, delivering the ruling recorded at ledger §3p.
3
+ * M-ADR 0132 §4.3 as amended.
4
4
  *
5
5
  * THE PROBLEM THIS EXISTS FOR, measured rather than asserted. A findings
6
6
  * baseline stores whole `[finding]` lines, so improving a message invalidates
@@ -17,7 +17,7 @@
17
17
  * files, 74 non-empty, 1,677 body lines — that works for 1,434 lines (85.5%)
18
18
  * and fails for 243 (14.5%), which embed their subject in prose:
19
19
  *
20
- * [finding] orphan: repo integration-calamp resolves to no capability ...
20
+ * [finding] orphan: repo integration-acme resolves to no capability ...
21
21
  *
22
22
  * One renderer template, all orphan class, spread across 62 repos. Recovering
23
23
  * identity there means teaching the comparator the renderer's message
@@ -30,7 +30,7 @@
30
30
  * baseline is reviewed by people and a side-channel nobody sees is a side
31
31
  * channel nobody maintains:
32
32
  *
33
- * [finding] orphan (repo:integration-calamp): repo integration-calamp ...
33
+ * [finding] orphan (repo:integration-acme): repo integration-acme ...
34
34
  * [finding] divergence (docs/domains/asset/product/x.traceability.yaml): ...
35
35
  *
36
36
  * A finding with no declared subject renders EXACTLY as it does today. That is
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * `catalogue build --check`: are the committed catalogues what the committed specs build to?
3
- * (M-ADR 0218 §5, bead sdm-jts.6.)
3
+ * (M-ADR 0218 §5.)
4
4
  *
5
5
  * Builds into a temporary directory and compares, so it writes nothing into the SDD. Only what
6
6
  * the build PRODUCES is compared: a hand-written file that happens to sit in catalogues/ (a
@@ -123,7 +123,7 @@ const DataPlaneSchema = z.object({
123
123
  bindings: z.array(DataPlaneBindingSchema),
124
124
  deviations: z.array(DataPlaneDeviationSchema).optional(),
125
125
  });
126
- // Conformance coverage (M-ADR 0191 §4.3, second overlay element — WS4). An
126
+ // Conformance coverage (M-ADR 0191 §4.3, second overlay element). An
127
127
  // application-product asserts which of its archetype's conformance expectations
128
128
  // its own conformance suite exercises (`coverage`), and declares a rationale for
129
129
  // any expected tag it deliberately does not cover (`deviations`). The method
@@ -249,7 +249,7 @@ const ManifestSchema = z.object({
249
249
  // optional declared deviations from the archetype default, each carrying a
250
250
  // rationale so the mismatch is sanctioned variance, not silent drift.
251
251
  data_plane: DataPlaneSchema.optional(),
252
- // Conformance coverage declaration (M-ADR 0191 §4.3 WS4, sdd-manifest-schema
252
+ // Conformance coverage declaration (M-ADR 0191 §4.3, sdd-manifest-schema
253
253
  // 1.5.0). Which archetype conformance expectations the product's suite
254
254
  // exercises, plus rationales for any it deliberately does not.
255
255
  conformance: ConformanceSchema.optional(),
@@ -216,7 +216,7 @@ export async function runScaffoldDelta(session, options) {
216
216
  }
217
217
  /**
218
218
  * The delta's DEFECT classes are gate-acting (`divergence`), PROMOTED by a
219
- * recorded governance act after the M-ADR 0207 §4.9 pilot completed with no
219
+ * recorded method act after the M-ADR 0207 §4.9 pilot completed with no
220
220
  * surprise in any gated figure (2026-08-08) — the deliberate later act the
221
221
  * scores-coverage precedent reserves, performed. `scaffold-stale` stays
222
222
  * `coverage` with its staleness family: a stale anchor is a re-declare
@@ -1,8 +1,7 @@
1
1
  /**
2
2
  * Per-schema compatibility ranges declared by this sdd-cli build.
3
3
  *
4
- * Per resolution of adopt-canonical-catalogue-builder.md §7.5, the three
5
- * sdd-method-distributed schemas version independently. The CLI declares
4
+ * The three sdd-method-distributed schemas version independently. The CLI declares
6
5
  * the version range it accepts for each and fails fast at read time on
7
6
  * out-of-range manifests.
8
7
  *
@@ -15,7 +15,7 @@
15
15
  * - Divider
16
16
  * archetype_overlays: # per-platform overlay (ADR 0169 §4.3)
17
17
  * web-generic:
18
- * strip_prefixes: [Mdc, Mde] # render-only: stripped when humanising labels
18
+ * strip_prefixes: [Nwc, Nwe] # render-only: stripped when humanising labels
19
19
  * overrides:
20
20
  * VBespokeGrid: grid
21
21
  * swiftui:
@@ -1,5 +1,5 @@
1
- // Staleness VERDICTS — the second half of M-ADR 0205 §4.5's staleness scope
2
- // (ledger §3o): "staleness meaning corpus_ref older than the target's last
1
+ // Staleness VERDICTS — the second half of M-ADR 0205 §4.5's staleness scope:
2
+ // "staleness meaning corpus_ref older than the target's last
3
3
  // content change". The scores-coverage predicate computes each row's BASIS;
4
4
  // this module computes the VERDICT the basis supports. A stale score is worse
5
5
  // than a missing one, because it reads as coverage — the predicate otherwise
@@ -262,8 +262,7 @@ function extractContracts(raw) {
262
262
  // entries used to raise disappeared — the declarations became invisible
263
263
  // to row emission and to finding emission at once. The builder had been
264
264
  // taught the object form (contracts.ts) while its only feeder had not:
265
- // the F36 shape again, one surface ahead of the pipeline that supplies
266
- // it. `spicedb` was likewise never extracted, so `.zed` declarations
265
+ // a builder one surface ahead of the pipeline that supplies it. `spicedb` was likewise never extracted, so `.zed` declarations
267
266
  // never reached any builder even as bare strings.
268
267
  const arr = (key) => {
269
268
  const v = o[key];
@@ -282,7 +281,7 @@ function extractContracts(raw) {
282
281
  const graphql = arr("graphql");
283
282
  // `provenance` (3.5.0) MUST be extracted here or the builder that reads it
284
283
  // sees nothing — this function reconstructs the block key-by-key rather than
285
- // passing the parsed object through, which is precisely the F36 shape the
284
+ // passing the parsed object through, which is precisely the shape the
286
285
  // comment above records. It was re-committed once during 3.5.0's own
287
286
  // implementation: the builder was taught to read provenance while this feeder
288
287
  // still dropped it, and only an end-to-end catalogue build caught it, because
@@ -19,7 +19,7 @@ const markdownProviderSignals = async (path, spec) => {
19
19
  const out = new Set();
20
20
  // `section` (ADR 0180 delta-pilot extension) scopes the scan to one heading block and accepts a plain
21
21
  // "Field" header, so a check can read ONE entity table inside a multi-table data-model doc (e.g.
22
- // data-model.mermaid.md "### Collection: feature") without duplicating the field list into a bespoke
22
+ // data-model.mermaid.md "### Collection: orders") without duplicating the field list into a bespoke
23
23
  // surface file. With no `section` the behaviour is byte-for-byte the legacy one (whole file, strict
24
24
  // header, no `*` stripping) — existing gateway bindings are unaffected.
25
25
  const section = spec?.section;
@@ -35,7 +35,7 @@ const markdownProviderSignals = async (path, spec) => {
35
35
  for (const raw of (await readFile(path, "utf-8")).split("\n")) {
36
36
  const s = raw.trim();
37
37
  if (section && s.startsWith("#")) {
38
- // strip leading #'s and any markdown code/emphasis in the title (`### Collection: ` + "`feature`")
38
+ // strip leading #'s and any markdown code/emphasis in the title (`### Collection: ` + "`orders`")
39
39
  inSection = s.replace(/^#+\s*/, "").replace(/[`*]/g, "").includes(section);
40
40
  inTable = false;
41
41
  continue;
@@ -61,7 +61,7 @@ const markdownProviderSignals = async (path, spec) => {
61
61
  if (!sig || sig === "—" || sig.startsWith("Computed") || sig.startsWith("MB "))
62
62
  continue;
63
63
  sig = sig.replace(/\([^)]*\)/g, "").replace(/\[\]/g, "").trim(); // drop inline "(...)" notes + array markers
64
- // Range shorthand: `datacoldTemp1..6` / `setpointTemp1..3` -> one declared name per index.
64
+ // Range shorthand: `sensor1..6` / `zone1..3` -> one declared name per index.
65
65
  const range = sig.match(/^(.*?)(\d+)\.\.(\d+)$/);
66
66
  if (range?.[1] !== undefined) {
67
67
  const base = range[1];
@@ -369,7 +369,7 @@ const REGISTRY = {
369
369
  function norm(s, mode) {
370
370
  if (mode === "leaf") {
371
371
  // Match a nested/dotted declared path against a leaf code identifier: take the last dotted
372
- // segment, drop array markers, then lower-nosep. `statusInfo.truStatus` ~ `truStatus`.
372
+ // segment, drop array markers, then lower-nosep. `order.status.code` ~ `code`.
373
373
  const leaf = s.split(".").pop() ?? s;
374
374
  return leaf.replace(/\[\]/g, "").replace(/[-_./ ]/g, "").toLowerCase();
375
375
  }
@@ -474,7 +474,28 @@ function printHuman(r, out) {
474
474
  out(` - ${m}\n`);
475
475
  }
476
476
  }
477
- function printReconcile(chk, r, out) {
477
+ /**
478
+ * Where the regenerated `produces` entries live: the spec's schema_dir, else the directory the binding's own
479
+ * declared entries already use (so a regenerated block keeps the adopter's layout), else `events`.
480
+ */
481
+ async function reconcileSchemaDir(targetRepo, rc) {
482
+ if (rc.schema_dir)
483
+ return rc.schema_dir.replace(/\/+$/, "");
484
+ const path = isAbsolute(rc.binding) ? rc.binding : resolve(targetRepo, rc.binding);
485
+ try {
486
+ const doc = parseYaml(await readFile(path, "utf-8"));
487
+ for (const p of doc?.events?.produces ?? []) {
488
+ const s = p?.schema;
489
+ if (typeof s === "string" && s.includes("/"))
490
+ return s.slice(0, s.lastIndexOf("/"));
491
+ }
492
+ }
493
+ catch {
494
+ /* an unreadable binding falls through to the default */
495
+ }
496
+ return "events";
497
+ }
498
+ function printReconcile(chk, r, schemaDir, out) {
478
499
  if (!chk.reconcile || r.target !== "binding-vs-reality")
479
500
  return;
480
501
  const rc = chk.reconcile;
@@ -486,7 +507,7 @@ function printReconcile(chk, r, out) {
486
507
  out(`${rc.block.split(".")[0]}:\n`);
487
508
  out(` produces: # ${full.length} schemas (was ${r.declared} declared)\n`);
488
509
  for (const name of full)
489
- out(` - schema: party/events/${name}.avsc\n`);
510
+ out(` - schema: ${schemaDir}/${name}.avsc\n`);
490
511
  if (r.declaredNotObserved.length) {
491
512
  out(` # DROP (dangling — no such schema in code/contracts): ${r.declaredNotObserved.join(", ")}\n`);
492
513
  }
@@ -546,8 +567,8 @@ export async function runConformance(opts, stdout = (line) => process.stdout.wri
546
567
  printHuman(r, stdout);
547
568
  if (opts.reconcile) {
548
569
  const chk = reconcileByline.get(r.id);
549
- if (chk)
550
- printReconcile(chk, r, stdout);
570
+ if (chk?.reconcile)
571
+ printReconcile(chk, r, await reconcileSchemaDir(opts.targetRepo, chk.reconcile), stdout);
551
572
  }
552
573
  }
553
574
  for (const id of skipped)
@@ -215,7 +215,7 @@ function parseVersioned(raw) {
215
215
  // a YAML reader parses 113 of 325 entries in one real lock as MAPPINGS
216
216
  // rather than strings — silently, with no error. Quoting on write is the
217
217
  // fix; reading both forms ships first so no lock can be written that an
218
- // older reader cannot parse. See method-open-items §3l.
218
+ // older reader cannot parse.
219
219
  const entry = stripQuotes(line.slice(" - ".length));
220
220
  const withDigest = DIGEST_ENTRY_RE.exec(entry);
221
221
  if (withDigest) {
@@ -83,7 +83,7 @@ export function getVerifyCliVersion() {
83
83
  }
84
84
  /**
85
85
  * The @sdd-method/maintain-cli version released WITH this sdd-cli. The managed CI workflow's
86
- * catalogue freshness step installs exactly this version (M-ADR 0218 §5; ruled 2026-10-15, sdm-jts.6).
86
+ * catalogue freshness step installs exactly this version (M-ADR 0218 §5).
87
87
  * Read the same way as the verify version, for the same reason (dist/ copy, never scripts/ live).
88
88
  */
89
89
  export function getMaintainCliVersion() {
@@ -82,7 +82,7 @@ export async function readManifest(areaRoot, id) {
82
82
  }
83
83
  if (!Array.isArray(raw.members))
84
84
  problems.push("members must be a list");
85
- // A hand-written revision of digits only (`50834756022`) parses as a NUMBER, and
85
+ // A hand-written revision of digits only (`12345678901`) parses as a NUMBER, and
86
86
  // a strict comparison with the folder's string suffix then calls an unchanged
87
87
  // member changed. Identity fields are strings, whatever YAML made of them.
88
88
  for (const m of raw.members ?? []) {
@@ -88,7 +88,7 @@ export function routeFinding(f, artefacts, shared = new Set()) {
88
88
  return [...targets.values()].sort((a, b) => (a.grain === b.grain ? a.artefact.localeCompare(b.artefact) : a.grain === "file" ? -1 : 1));
89
89
  }
90
90
  /**
91
- * Artefacts that mention a changed file by BASENAME only (`CollectorActor.java:49`) —
91
+ * Artefacts that mention a changed file by BASENAME only (`OrderHandler.java:49`) —
92
92
  * too ambiguous to route, too likely to matter to drop silently. Reported, not routed.
93
93
  */
94
94
  export function basenameOnly(f, artefacts, routed) {
@@ -0,0 +1,351 @@
1
+ // Behaviour-store checks (M-ADR 0221, 0222, 0223): the gates OpenSpec itself does not enforce.
2
+ //
3
+ // An SDD's behaviour store is an OpenSpec project; in the inside-SDD shape it is `openspec/` at the
4
+ // SDD root, with one agreement record per change at `docs/agreements/<change>.yaml`
5
+ // (agreement-record-schema.yaml). OpenSpec checks the format of specs and deltas. It does not check
6
+ // that a change was agreed before it was built, that the requirements it modifies stayed as they
7
+ // were agreed, or that an archived change was judged. These are those checks:
8
+ //
9
+ // 1. records: every agreement record has the shape the checks below read.
10
+ // 2. agreement: an open change with any completed task has an agreed record and a Gate 0 that
11
+ // ends CLEAR (the pre-apply check, M-ADR 0223 §4.3).
12
+ // 3. stale: for each open, agreed change that MODIFIES, REMOVES or RENAMES requirements, the
13
+ // targeted living specs are unchanged since the agreed commit (M-ADR 0223 §4.5).
14
+ // With no such targets the check is vacuous, and says so.
15
+ // 4. verdicts: every archived change except a baseline has a verdict.md and a record whose
16
+ // verdict is PASS and names it (M-ADR 0223 §4.6).
17
+ //
18
+ // A sibling-repository store (M-ADR 0221 §4.2) is checked in that repository, not here.
19
+ //
20
+ // OPENSPEC'S OWN VALIDATION is the separate validator `behaviour-store-openspec`, because it needs
21
+ // the OpenSpec CLI at OPENSPEC_VERSION (the Method Validation module installs it). Without a store
22
+ // both report [N/A], an empty population, never a pass.
23
+ import { execFile } from "node:child_process";
24
+ import { access, readdir, readFile } from "node:fs/promises";
25
+ import { basename, join } from "node:path";
26
+ import { promisify } from "node:util";
27
+ import { parseDocument } from "yaml";
28
+ const execFileAsync = promisify(execFile);
29
+ /** The OpenSpec release the method depends on (M-ADR 0223 ruling 2). The module installs exactly this. */
30
+ export const OPENSPEC_VERSION = "1.14.3";
31
+ /** Telemetry off for every OpenSpec run the method makes. */
32
+ export const OPENSPEC_ENV = { OPENSPEC_TELEMETRY: "0", DO_NOT_TRACK: "1" };
33
+ export const STORE_DIR = "openspec";
34
+ export const AGREEMENTS_DIR = "docs/agreements";
35
+ const AGREEMENT_STATUSES = ["proposed", "agreed", "superseded", "withdrawn"];
36
+ const COMMIT_RE = /^[0-9a-f]{7,40}$/;
37
+ // OpenSpec counts a task done when its box holds only `x`, either case, any spacing.
38
+ const TASK_DONE_RE = /^\s*-\s*\[\s*[xX]\s*\]/m;
39
+ const TARGET_HEADING_RE = /^##\s+(MODIFIED|REMOVED|RENAMED)\s+Requirements\b/m;
40
+ async function exists(p) {
41
+ try {
42
+ await access(p);
43
+ return true;
44
+ }
45
+ catch {
46
+ return false;
47
+ }
48
+ }
49
+ async function dirs(p) {
50
+ try {
51
+ return (await readdir(p, { withFileTypes: true })).filter((d) => d.isDirectory()).map((d) => d.name).sort();
52
+ }
53
+ catch {
54
+ return [];
55
+ }
56
+ }
57
+ async function readText(p) {
58
+ try {
59
+ return await readFile(p, "utf8");
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ }
65
+ /** Read and shape-check one agreement record. Returns the record, or the problems found. */
66
+ export function parseAgreementRecord(file, text) {
67
+ const problems = [];
68
+ const doc = parseDocument(text);
69
+ if (doc.errors.length > 0) {
70
+ return { problems: doc.errors.map((e) => `${file}: does not parse: ${e.message.split("\n")[0]}`) };
71
+ }
72
+ const root = doc.toJS();
73
+ const r = root?.agreement_record;
74
+ if (!r || typeof r !== "object")
75
+ return { problems: [`${file}: no top-level agreement_record`] };
76
+ const expected = basename(file).replace(/\.ya?ml$/, "");
77
+ if (r.change !== expected)
78
+ problems.push(`${file}: change is ${JSON.stringify(r.change)}, expected "${expected}" (the file name)`);
79
+ const status = typeof r.status === "string" ? r.status : "";
80
+ if (!AGREEMENT_STATUSES.includes(status)) {
81
+ problems.push(`${file}: status ${JSON.stringify(r.status)} is not one of ${AGREEMENT_STATUSES.join(" | ")}`);
82
+ }
83
+ const sdd = (r.sdd ?? {});
84
+ const gates = Array.isArray(r.gates) ? r.gates : [];
85
+ const gate0 = gates.find((g) => g && g.name === "gate-0");
86
+ if (status === "agreed") {
87
+ if (r.agreed_on === undefined || r.agreed_on === null)
88
+ problems.push(`${file}: agreed, but no agreed_on`);
89
+ if (typeof r.approved_by !== "string" || r.approved_by.trim() === "")
90
+ problems.push(`${file}: agreed, but no approved_by`);
91
+ if (typeof sdd.commit !== "string") {
92
+ problems.push(`${file}: sdd.commit must be a quoted string, found ${sdd.commit === undefined ? "nothing" : `${typeof sdd.commit} ${String(sdd.commit)}`}` +
93
+ (typeof sdd.commit === "number" ? " (unquoted, YAML parsed the SHA as a number)" : ""));
94
+ }
95
+ else if (!COMMIT_RE.test(sdd.commit)) {
96
+ problems.push(`${file}: sdd.commit "${sdd.commit}" is not 7-40 hex characters`);
97
+ }
98
+ if (!gate0)
99
+ problems.push(`${file}: agreed, but no gates entry named gate-0`);
100
+ else if (gate0.result !== "CLEAR")
101
+ problems.push(`${file}: agreed, but gate-0 result is ${JSON.stringify(gate0.result)}, not CLEAR`);
102
+ }
103
+ // Every commit a record pins must survive parsing as a string. YAML 1.2 reads an unquoted
104
+ // all-digit abbreviation as an integer and one shaped like `1234e567` or `123e456` as a float
105
+ // (Infinity), and the pin is then lost; checking the PARSED type is the only test that catches
106
+ // both, since a pattern applied after coercion sees "Infinity" or a number.
107
+ for (const [path, value] of commitFields(r)) {
108
+ if (path === "sdd.commit")
109
+ continue;
110
+ if (typeof value !== "string") {
111
+ problems.push(`${file}: ${path} must be a quoted string, found ${typeof value} ${String(value)} (unquoted, YAML parsed the SHA as a number)`);
112
+ }
113
+ }
114
+ const verdict = r.verdict && typeof r.verdict === "object" ? r.verdict : undefined;
115
+ if (problems.length > 0)
116
+ return { problems };
117
+ return {
118
+ record: {
119
+ file,
120
+ change: expected,
121
+ status,
122
+ ...(typeof sdd.commit === "string" ? { commit: sdd.commit } : {}),
123
+ ...(typeof gate0?.result === "string" ? { gate0: gate0.result } : {}),
124
+ ...(verdict ? { verdict } : {}),
125
+ },
126
+ problems,
127
+ };
128
+ }
129
+ /** Every pinned commit in a record: any key named `commit` or ending `_commit`, and each value under verdict.merged and current. */
130
+ function commitFields(r) {
131
+ const out = [];
132
+ const walk = (v, path, pinMap) => {
133
+ if (Array.isArray(v))
134
+ v.forEach((x, i) => walk(x, `${path}[${i}]`, false));
135
+ else if (v && typeof v === "object") {
136
+ for (const [k, x] of Object.entries(v)) {
137
+ const p = path === "" ? k : `${path}.${k}`;
138
+ const isPin = pinMap || k === "commit" || k.endsWith("_commit");
139
+ if (isPin && (x === null || typeof x !== "object"))
140
+ out.push([p, x]);
141
+ else
142
+ walk(x, p, p === "verdict.merged" || p === "current");
143
+ }
144
+ }
145
+ };
146
+ walk(r, "", false);
147
+ return out;
148
+ }
149
+ function lastGateLine(text) {
150
+ const lines = text.split("\n").map((l) => l.trim()).filter((l) => l !== "");
151
+ return lines.at(-1) ?? "";
152
+ }
153
+ async function gitDiffQuiet(repo, commit, path) {
154
+ try {
155
+ await execFileAsync("git", ["-C", repo, "cat-file", "-e", `${commit}^{commit}`]);
156
+ }
157
+ catch {
158
+ return "unresolved";
159
+ }
160
+ try {
161
+ await execFileAsync("git", ["-C", repo, "diff", "--quiet", commit, "HEAD", "--", path]);
162
+ return "same";
163
+ }
164
+ catch (err) {
165
+ return err.code === 1 ? "changed" : "unresolved";
166
+ }
167
+ }
168
+ /** The living specs an open change's deltas modify, remove or rename: openspec/specs/<capability>/spec.md. */
169
+ async function targetedSpecs(repo, changeDir) {
170
+ const out = [];
171
+ const walk = async (rel) => {
172
+ const abs = join(repo, changeDir, "specs", rel);
173
+ let entries;
174
+ try {
175
+ entries = await readdir(abs, { withFileTypes: true });
176
+ }
177
+ catch {
178
+ return;
179
+ }
180
+ for (const e of entries) {
181
+ const childRel = rel === "" ? e.name : `${rel}/${e.name}`;
182
+ if (e.isDirectory())
183
+ await walk(childRel);
184
+ else if (e.name === "spec.md") {
185
+ const text = (await readText(join(abs, e.name))) ?? "";
186
+ if (TARGET_HEADING_RE.test(text))
187
+ out.push(`${STORE_DIR}/specs/${rel}/spec.md`);
188
+ }
189
+ }
190
+ };
191
+ await walk("");
192
+ return out.sort();
193
+ }
194
+ export async function checkBehaviourStore(repo) {
195
+ if (!(await exists(join(repo, STORE_DIR)))) {
196
+ return { exitCode: 0, stdout: `[N/A] no behaviour store (${STORE_DIR}/) in this repository\n` };
197
+ }
198
+ const lines = [];
199
+ const failures = [];
200
+ // 1. records
201
+ const records = new Map();
202
+ let recordFiles = [];
203
+ try {
204
+ recordFiles = (await readdir(join(repo, AGREEMENTS_DIR))).filter((f) => /\.ya?ml$/.test(f)).sort();
205
+ }
206
+ catch {
207
+ recordFiles = [];
208
+ }
209
+ for (const f of recordFiles) {
210
+ const rel = `${AGREEMENTS_DIR}/${f}`;
211
+ const { record, problems } = parseAgreementRecord(rel, (await readText(join(repo, rel))) ?? "");
212
+ failures.push(...problems);
213
+ if (record)
214
+ records.set(record.change, record);
215
+ }
216
+ lines.push(`records: ${recordFiles.length} agreement record(s), ${recordFiles.length - records.size} malformed`);
217
+ // 2 and 3. open changes
218
+ const open = (await dirs(join(repo, STORE_DIR, "changes"))).filter((d) => d !== "archive");
219
+ let started = 0;
220
+ let staleChecked = 0;
221
+ for (const change of open) {
222
+ const dir = `${STORE_DIR}/changes/${change}`;
223
+ const tasks = await readText(join(repo, dir, "tasks.md"));
224
+ const record = records.get(change);
225
+ if (tasks !== null && TASK_DONE_RE.test(tasks)) {
226
+ started++;
227
+ if (!record)
228
+ failures.push(`${dir}: has completed tasks but no agreement record (${AGREEMENTS_DIR}/${change}.yaml)`);
229
+ else if (record.status !== "agreed")
230
+ failures.push(`${dir}: has completed tasks but its agreement record is ${record.status}, not agreed`);
231
+ const gate = await readText(join(repo, dir, "gate-0.md"));
232
+ if (gate === null)
233
+ failures.push(`${dir}: has completed tasks but no gate-0.md`);
234
+ else if (lastGateLine(gate) !== "GATE: CLEAR")
235
+ failures.push(`${dir}: has completed tasks but gate-0.md ends "${lastGateLine(gate)}", not "GATE: CLEAR"`);
236
+ }
237
+ if (record?.status === "agreed" && record.commit) {
238
+ for (const spec of await targetedSpecs(repo, dir)) {
239
+ staleChecked++;
240
+ const r = await gitDiffQuiet(repo, record.commit, spec);
241
+ if (r === "changed")
242
+ failures.push(`${dir}: STALE: ${spec} changed since the agreed commit ${record.commit}`);
243
+ if (r === "unresolved")
244
+ failures.push(`${dir}: stale check unresolved: commit ${record.commit} is not in this clone's history (a shallow clone cannot run it)`);
245
+ }
246
+ }
247
+ }
248
+ lines.push(`agreement: ${open.length} open change(s), ${started} with completed tasks`);
249
+ lines.push(staleChecked === 0 ? "stale: vacuous (no open agreed change modifies, removes or renames a requirement)" : `stale: ${staleChecked} targeted living spec(s) checked against their agreed commits`);
250
+ // 4. archived changes
251
+ const archived = await dirs(join(repo, STORE_DIR, "changes", "archive"));
252
+ let baselines = 0;
253
+ for (const name of archived) {
254
+ const dir = `${STORE_DIR}/changes/archive/${name}`;
255
+ if (await exists(join(repo, dir, "baseline-evidence.md"))) {
256
+ baselines++;
257
+ continue;
258
+ }
259
+ const change = name.replace(/^\d{4}-\d{2}-\d{2}-/, "");
260
+ if (!(await exists(join(repo, dir, "verdict.md"))))
261
+ failures.push(`${dir}: archived without verdict.md`);
262
+ const record = records.get(change);
263
+ if (!record) {
264
+ failures.push(`${dir}: archived without an agreement record (${AGREEMENTS_DIR}/${change}.yaml)`);
265
+ continue;
266
+ }
267
+ if (record.verdict?.result !== "PASS")
268
+ failures.push(`${record.file}: change is archived but verdict.result is ${JSON.stringify(record.verdict?.result)}, not PASS`);
269
+ const vr = record.verdict?.record;
270
+ if (typeof vr !== "string" || !(await exists(join(repo, vr))))
271
+ failures.push(`${record.file}: verdict.record ${JSON.stringify(vr)} does not name an existing file`);
272
+ }
273
+ lines.push(`verdicts: ${archived.length} archived change(s), ${baselines} baseline(s), ${archived.length - baselines} needing a PASS verdict`);
274
+ const population = recordFiles.length + open.length + archived.length;
275
+ const body = lines.map((l) => ` ${l}`).join("\n") + "\n";
276
+ if (failures.length > 0) {
277
+ return { exitCode: 1, stdout: body + failures.map((f) => `[FAIL] ${f}`).join("\n") + "\n" };
278
+ }
279
+ if (population === 0)
280
+ return { exitCode: 0, stdout: body + `[N/A] the behaviour store has no changes and no agreement records yet\n` };
281
+ return { exitCode: 0, stdout: body + "[PASS] behaviour store: records, agreement, stale and verdict checks hold\n" };
282
+ }
283
+ async function runOpenspec(repo, args) {
284
+ try {
285
+ const { stdout } = await execFileAsync("openspec", args, { cwd: repo, env: { ...process.env, ...OPENSPEC_ENV }, maxBuffer: 16 * 1024 * 1024 });
286
+ return { code: 0, json: JSON.parse(stdout), raw: stdout };
287
+ }
288
+ catch (err) {
289
+ const e = err;
290
+ let json;
291
+ try {
292
+ json = JSON.parse(e.stdout ?? "");
293
+ }
294
+ catch {
295
+ json = undefined;
296
+ }
297
+ return { code: typeof e.code === "number" ? e.code : 127, ...(json ? { json } : {}), raw: (e.stdout ?? "") + (e.stderr ?? "") };
298
+ }
299
+ }
300
+ /** OpenSpec's own strict validation of the store: specs and open changes, and archived changes' tasks. */
301
+ export async function checkBehaviourStoreOpenspec(repo) {
302
+ if (!(await exists(join(repo, STORE_DIR)))) {
303
+ return { exitCode: 0, stdout: `[N/A] no behaviour store (${STORE_DIR}/) in this repository\n` };
304
+ }
305
+ let version = "";
306
+ try {
307
+ version = (await execFileAsync("openspec", ["--version"], { env: { ...process.env, ...OPENSPEC_ENV } })).stdout.trim();
308
+ }
309
+ catch {
310
+ return {
311
+ exitCode: 1,
312
+ stdout: `[FAIL] the OpenSpec CLI is not on PATH; this repository has a behaviour store, so its validation cannot run (install @fission-ai/openspec@${OPENSPEC_VERSION}; the Method Validation module does)\n`,
313
+ };
314
+ }
315
+ if (version !== OPENSPEC_VERSION) {
316
+ return { exitCode: 1, stdout: `[FAIL] OpenSpec ${version} is installed; the method depends on exactly ${OPENSPEC_VERSION}\n` };
317
+ }
318
+ const lines = [];
319
+ const failures = [];
320
+ let measured = 0;
321
+ for (const [label, args] of [
322
+ ["specs and open changes (--all --strict)", ["validate", "--all", "--strict", "--json", "--no-interactive"]],
323
+ ["archived changes' tasks (--archived)", ["validate", "--archived", "--json", "--no-interactive"]],
324
+ ]) {
325
+ const r = await runOpenspec(repo, [...args]);
326
+ const t = r.json?.summary?.totals;
327
+ if (!t) {
328
+ failures.push(`${label}: OpenSpec returned no report (exit ${r.code}): ${r.raw.trim().split("\n").slice(-3).join(" | ")}`);
329
+ continue;
330
+ }
331
+ measured += t.items ?? 0;
332
+ lines.push(`${label}: ${t.items ?? 0} item(s), ${t.passed ?? 0} passed, ${t.failed ?? 0} failed`);
333
+ for (const item of r.json?.items ?? []) {
334
+ if (item.valid)
335
+ continue;
336
+ for (const i of item.issues ?? [])
337
+ failures.push(`${label}: ${item.id}${i.path ? ` (${i.path})` : ""}: ${i.level} ${i.message}`);
338
+ if ((item.issues ?? []).length === 0)
339
+ failures.push(`${label}: ${item.id}: invalid`);
340
+ }
341
+ if (r.code !== 0 && (t.failed ?? 0) === 0)
342
+ failures.push(`${label}: OpenSpec exited ${r.code} with no failed item reported`);
343
+ }
344
+ const body = [` OpenSpec ${version}`, ...lines.map((l) => ` ${l}`)].join("\n") + "\n";
345
+ if (failures.length > 0)
346
+ return { exitCode: 1, stdout: body + failures.map((f) => `[FAIL] ${f}`).join("\n") + "\n" };
347
+ if (measured === 0)
348
+ return { exitCode: 0, stdout: body + "[N/A] OpenSpec found no specs and no changes to validate\n" };
349
+ return { exitCode: 0, stdout: body + "[PASS] OpenSpec strict validation holds\n" };
350
+ }
351
+ //# sourceMappingURL=behaviour-store.js.map