@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.
- package/dist/lib/api-surface/__fixtures__/serverless-repo/serverless/serverless.yml +4 -4
- package/dist/lib/api-surface/analyzers/serverless.js +1 -1
- package/dist/lib/api-surface/analyzers/spring.js +1 -1
- package/dist/lib/briefing/render.js +1 -2
- package/dist/lib/briefing/variables.js +1 -2
- package/dist/lib/catalogue/archetype/wireframe.js +1 -1
- package/dist/lib/catalogue/build.js +17 -23
- package/dist/lib/catalogue/builders/application-conformance-coverage.js +1 -1
- package/dist/lib/catalogue/builders/c4-components.js +4 -5
- package/dist/lib/catalogue/builders/c4-containers.js +3 -3
- package/dist/lib/catalogue/builders/c4-entity-rollups.js +1 -1
- package/dist/lib/catalogue/builders/c4-external-systems.js +1 -1
- package/dist/lib/catalogue/builders/c4-l1-candidates.js +1 -1
- package/dist/lib/catalogue/builders/capabilities.js +2 -2
- package/dist/lib/catalogue/builders/capability-edges.js +4 -4
- package/dist/lib/catalogue/builders/capability-spec-walker.js +3 -3
- package/dist/lib/catalogue/builders/container-dependency.js +18 -19
- package/dist/lib/catalogue/builders/contracts.js +5 -6
- package/dist/lib/catalogue/builders/data-store-mapping.js +3 -3
- package/dist/lib/catalogue/builders/edge-label-merge.js +2 -2
- package/dist/lib/catalogue/builders/entities-manifest.js +1 -1
- package/dist/lib/catalogue/builders/entities-mermaid.js +3 -3
- package/dist/lib/catalogue/builders/entity-slug.js +11 -12
- package/dist/lib/catalogue/builders/event-catalog-walker.js +3 -3
- package/dist/lib/catalogue/builders/event-edges.js +1 -1
- package/dist/lib/catalogue/builders/evidence-class.js +2 -2
- package/dist/lib/catalogue/builders/external-system-exemption.js +1 -1
- package/dist/lib/catalogue/builders/feature-contract.js +1 -3
- package/dist/lib/catalogue/builders/feature-grounding.js +1 -2
- package/dist/lib/catalogue/builders/feature-groups.js +0 -1
- package/dist/lib/catalogue/builders/feature-subscription-channels.js +2 -3
- package/dist/lib/catalogue/builders/feature-tree-walker.js +1 -1
- package/dist/lib/catalogue/builders/features.js +3 -4
- package/dist/lib/catalogue/builders/glossary-xref.js +1 -1
- package/dist/lib/catalogue/builders/persona-c4system.js +7 -7
- package/dist/lib/catalogue/builders/persona-container.js +14 -14
- package/dist/lib/catalogue/builders/personas.js +1 -2
- package/dist/lib/catalogue/builders/product-platform-dependencies.js +2 -2
- package/dist/lib/catalogue/builders/quality-scores.js +1 -1
- package/dist/lib/catalogue/builders/repositories.js +2 -2
- package/dist/lib/catalogue/builders/screen-tree.js +1 -1
- package/dist/lib/catalogue/builders/screens.js +3 -4
- package/dist/lib/catalogue/builders/system-external-dependency.js +7 -7
- package/dist/lib/catalogue/canonical-schema.yaml +23 -23
- package/dist/lib/catalogue/coverage-predicates.js +4 -4
- package/dist/lib/catalogue/coverage-run.js +1 -1
- package/dist/lib/catalogue/dedupe-keyed-tables.js +2 -3
- package/dist/lib/catalogue/ext-id.js +4 -4
- package/dist/lib/catalogue/feature-grounding-aliases-loader.js +1 -1
- package/dist/lib/catalogue/finding-identity.js +3 -3
- package/dist/lib/catalogue/freshness.js +1 -1
- package/dist/lib/catalogue/manifest-loader.js +2 -2
- package/dist/lib/catalogue/scaffold-delta.js +1 -1
- package/dist/lib/catalogue/schema-compat.js +1 -2
- package/dist/lib/catalogue/screen-validation-loader.js +1 -1
- package/dist/lib/catalogue/staleness-verdicts.js +2 -2
- package/dist/lib/catalogue/traceability-loader.js +2 -3
- package/dist/lib/conformance/index.js +29 -8
- package/dist/lib/lock-file.js +1 -1
- package/dist/lib/package-version.js +1 -1
- package/dist/lib/snapshot/manifest.js +1 -1
- package/dist/lib/snapshot/targets.js +1 -1
- package/dist/lib/validate/behaviour-store.js +351 -0
- package/dist/lib/validate/dispatch.js +18 -0
- package/dist/lib/validate/index.js +1 -1
- package/dist/lib/validate/plan.js +3 -1
- package/dist/lib/validate/yaml-parse.js +1 -1
- package/dist/maintain-version.json +2 -2
- package/dist/verbs/plan.js +3 -3
- package/dist/verify-version.json +2 -2
- package/package.json +4 -6
- package/scripts/validate-app-dependencies.sh +5 -5
- package/scripts/validate-integration-docs.sh +1 -1
- package/LICENSE +0 -201
- 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
|
|
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):
|
|
26
|
-
* (0.30.2) and
|
|
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
|
|
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 (
|
|
19
|
-
* most SDD ids are hyphenated, and `
|
|
20
|
-
* same identity the adjudicator resolves for it (`
|
|
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
|
-
*
|
|
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
|
|
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-
|
|
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-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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: [
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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:
|
|
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: ` + "`
|
|
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: `
|
|
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. `
|
|
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
|
-
|
|
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:
|
|
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)
|
package/dist/lib/lock-file.js
CHANGED
|
@@ -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.
|
|
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
|
|
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 (`
|
|
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 (`
|
|
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
|