@sdd-method/verify-cli 2.1.0 → 2.3.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/catalogue/canonical-schema.yaml +3 -2
- package/dist/lib/catalogue/coverage-render.js +15 -15
- package/dist/lib/catalogue/csv-writer.js +1 -1
- package/dist/lib/catalogue/findings.js +1 -1
- package/dist/lib/catalogue/fk-validator.js +2 -2
- package/dist/lib/catalogue/manifest-loader.js +3 -3
- package/dist/lib/catalogue/schema-compat.js +2 -2
- package/dist/lib/catalogue/schema-loader.js +1 -1
- package/dist/lib/metamodel/tql-subset-parser.js +280 -0
- package/dist/lib/pack/schema.js +2 -2
- package/dist/lib/pack/seed.js +2 -2
- package/dist/lib/pack/validate.js +1 -1
- package/dist/lib/repo-kind.js +3 -3
- package/dist/lib/scaffold/archetype-behaviour.js +171 -0
- package/dist/lib/scaffold/registry.js +136 -0
- package/dist/lib/sync/bundle-cache.js +109 -0
- package/dist/lib/validate/archetype-citations.js +134 -0
- package/dist/lib/validate/behaviour-store.js +25 -1
- package/dist/lib/validate/dispatch.js +8 -1
- package/dist/maintain-version.json +2 -2
- package/dist/verbs/list.js +1 -1
- package/dist/verify-program.js +2 -2
- package/dist/verify-version.json +2 -2
- package/package.json +2 -2
- package/scripts/validate-app-dependencies.sh +1 -1
- package/scripts/validate-integration-docs.sh +1 -1
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
# Emitted by sdd-cli's YAML emitter with no line folding; the three header scalars are quoted for the
|
|
8
8
|
# textual readers downstream. The one deliberate difference from the authored form is schema_status:
|
|
9
9
|
# the authored form says canonical; a projection never does.
|
|
10
|
-
schema_version: "4.
|
|
10
|
+
schema_version: "4.1.0"
|
|
11
11
|
schema_owner: "sdd-method"
|
|
12
12
|
schema_status: "generated"
|
|
13
13
|
enums:
|
|
@@ -361,6 +361,7 @@ enums:
|
|
|
361
361
|
- value: graphql
|
|
362
362
|
- value: rpc
|
|
363
363
|
- value: authz
|
|
364
|
+
- value: cli
|
|
364
365
|
description: Contract kind. Bound at 3.13.0 from the column descriptions; measured against both reference estates before binding (M-ADR 0205 §4.4).
|
|
365
366
|
contract_status:
|
|
366
367
|
values:
|
|
@@ -1552,7 +1553,7 @@ catalogues:
|
|
|
1552
1553
|
- name: kind
|
|
1553
1554
|
type: string
|
|
1554
1555
|
nullable: false
|
|
1555
|
-
description: rest | graphql | rpc | event | authz. rpc covers Proto/gRPC; event covers Avro schemas; authz covers authorization/policy schemas (SpiceDB .zed and equivalents), added because an adopter contract agreement gate certified .zed schemas as contracts while no kind could represent them.
|
|
1556
|
+
description: rest | graphql | rpc | event | authz | cli. rpc covers Proto/gRPC; event covers Avro schemas; authz covers authorization/policy schemas (SpiceDB .zed and equivalents), added because an adopter contract agreement gate certified .zed schemas as contracts while no kind could represent them; cli covers a command-line interface's command manifest (its commands, options and exit codes), added at 4.1.0 because a command-line tool often serves no REST, RPC, event or authorization contract, so its behaviour baselines had nothing to name as their surface.
|
|
1556
1557
|
- name: version
|
|
1557
1558
|
type: string
|
|
1558
1559
|
nullable: true
|
|
@@ -281,47 +281,47 @@ export function renderContractCoverage(res, tier) {
|
|
|
281
281
|
}
|
|
282
282
|
// ─── the nine Phase-0 gate checks ────────────────────────────────────────
|
|
283
283
|
export function renderEventCatalogueCheck(gaps) {
|
|
284
|
-
return gaps.map((g) => `[finding] coverage: domain ${g.domain} event-catalog.yaml declares ${g.declared} events but events.csv contains ${g.emitted} rows for that domain (per ADR 0156 Phase 0)`);
|
|
284
|
+
return gaps.map((g) => `[finding] coverage: domain ${g.domain} event-catalog.yaml declares ${g.declared} events but events.csv contains ${g.emitted} rows for that domain (per M-ADR 0156 Phase 0)`);
|
|
285
285
|
}
|
|
286
286
|
export function renderRepoEdgesCheck(res) {
|
|
287
287
|
const lines = [];
|
|
288
288
|
if (res.repoCapabilityGap) {
|
|
289
289
|
const g = res.repoCapabilityGap;
|
|
290
|
-
lines.push(`[finding] coverage: repo_capability.csv is empty but ${g.capabilityService} capability_service rows + ${g.servicesWithRepo} services with primary_repo_id present — reverse-method derivation likely missing (per ADR 0157 §4.3, Phase 0)`);
|
|
290
|
+
lines.push(`[finding] coverage: repo_capability.csv is empty but ${g.capabilityService} capability_service rows + ${g.servicesWithRepo} services with primary_repo_id present — reverse-method derivation likely missing (per M-ADR 0157 §4.3, Phase 0)`);
|
|
291
291
|
}
|
|
292
292
|
if (res.repoContractForwardGap) {
|
|
293
293
|
const g = res.repoContractForwardGap;
|
|
294
|
-
lines.push(`[finding] coverage: repo_contract.csv is empty but ${g.contracts} contracts and ${g.repoConfigsWithContracts} repo-configs with contracts: blocks present — forward-method derivation likely missing (per ADR 0157 §4.2, Phase 0)`);
|
|
294
|
+
lines.push(`[finding] coverage: repo_contract.csv is empty but ${g.contracts} contracts and ${g.repoConfigsWithContracts} repo-configs with contracts: blocks present — forward-method derivation likely missing (per M-ADR 0157 §4.2, Phase 0)`);
|
|
295
295
|
}
|
|
296
296
|
if (res.repoContractPathXGap) {
|
|
297
297
|
const g = res.repoContractPathXGap;
|
|
298
|
-
lines.push(`[finding] coverage: repo_contract.csv is empty but ${g.serviceContracts} service_contract rows + ${g.servicesWithRepo} services with primary_repo_id present — reverse-method Path X (service_contract ⋈ services.primary_repo_id) derivation likely missing (per ADR 0157 §4.2, Phase 0)`);
|
|
298
|
+
lines.push(`[finding] coverage: repo_contract.csv is empty but ${g.serviceContracts} service_contract rows + ${g.servicesWithRepo} services with primary_repo_id present — reverse-method Path X (service_contract ⋈ services.primary_repo_id) derivation likely missing (per M-ADR 0157 §4.2, Phase 0)`);
|
|
299
299
|
}
|
|
300
300
|
if (res.repoContractReverseGap) {
|
|
301
301
|
const g = res.repoContractReverseGap;
|
|
302
|
-
lines.push(`[finding] coverage: repo_contract.csv is empty with ${g.contracts} contracts present and no repo-config.contracts: blocks or service_contract rows — reverse-method derivation via contracts.source_repo_id likely missing (per ADR 0157 §4.2, Phase 0)`);
|
|
302
|
+
lines.push(`[finding] coverage: repo_contract.csv is empty with ${g.contracts} contracts present and no repo-config.contracts: blocks or service_contract rows — reverse-method derivation via contracts.source_repo_id likely missing (per M-ADR 0157 §4.2, Phase 0)`);
|
|
303
303
|
}
|
|
304
304
|
return lines;
|
|
305
305
|
}
|
|
306
306
|
export function renderGlossaryXrefCheck(finding) {
|
|
307
307
|
return finding
|
|
308
308
|
? [
|
|
309
|
-
`[finding] coverage: docs/platform/reference/glossary-alignment-manifest.yaml exists but glossary_xref.csv emits 0 rows — multi-source derivation likely missing (per ADR 0158 §4.5, Phase 0)`,
|
|
309
|
+
`[finding] coverage: docs/platform/reference/glossary-alignment-manifest.yaml exists but glossary_xref.csv emits 0 rows — multi-source derivation likely missing (per M-ADR 0158 §4.5, Phase 0)`,
|
|
310
310
|
]
|
|
311
311
|
: [];
|
|
312
312
|
}
|
|
313
313
|
export function renderDataModelEntitiesCheck(res) {
|
|
314
314
|
const lines = [];
|
|
315
315
|
if (res.entitiesGap) {
|
|
316
|
-
lines.push(`[finding] coverage: ${res.entitiesGap.domainModels} domain-model files catalogued but entities.csv emits 0 rows — reverse-method entity extraction missing (per ADR 0159 Phase 0)`);
|
|
316
|
+
lines.push(`[finding] coverage: ${res.entitiesGap.domainModels} domain-model files catalogued but entities.csv emits 0 rows — reverse-method entity extraction missing (per M-ADR 0159 Phase 0)`);
|
|
317
317
|
}
|
|
318
318
|
if (res.serviceEntityGap) {
|
|
319
319
|
const g = res.serviceEntityGap;
|
|
320
|
-
lines.push(`[finding] coverage: ${g.entities} entities + ${g.services} services catalogued but service_entity.csv emits 0 rows — Data Store Mapping ingestion missing (per ADR 0159 Phase 0)`);
|
|
320
|
+
lines.push(`[finding] coverage: ${g.entities} entities + ${g.services} services catalogued but service_entity.csv emits 0 rows — Data Store Mapping ingestion missing (per M-ADR 0159 Phase 0)`);
|
|
321
321
|
}
|
|
322
322
|
if (res.containerEntityGap) {
|
|
323
323
|
const g = res.containerEntityGap;
|
|
324
|
-
lines.push(`[finding] coverage: ${g.containersWithService} containers map to services + ${g.serviceEntity} service_entity edges present but c4_container_entity.csv emits 0 rows — Phase 3 derivation missing (per ADR 0159 Phase 0)`);
|
|
324
|
+
lines.push(`[finding] coverage: ${g.containersWithService} containers map to services + ${g.serviceEntity} service_entity edges present but c4_container_entity.csv emits 0 rows — Phase 3 derivation missing (per M-ADR 0159 Phase 0)`);
|
|
325
325
|
}
|
|
326
326
|
return lines;
|
|
327
327
|
}
|
|
@@ -329,24 +329,24 @@ export function renderCapabilityEventCheck(res) {
|
|
|
329
329
|
const lines = [];
|
|
330
330
|
if (res.derivableGap) {
|
|
331
331
|
const g = res.derivableGap;
|
|
332
|
-
lines.push(`[finding] coverage: service_event (${g.serviceEvent}) + capability_service (${g.capabilityService}) populated but capability_event emits 0 rows — reverse-derivable edge missing (per ADR 0160 Phase 0)`);
|
|
332
|
+
lines.push(`[finding] coverage: service_event (${g.serviceEvent}) + capability_service (${g.capabilityService}) populated but capability_event emits 0 rows — reverse-derivable edge missing (per M-ADR 0160 Phase 0)`);
|
|
333
333
|
}
|
|
334
334
|
if (res.authoredGap) {
|
|
335
|
-
lines.push(`[finding] coverage: capability_event has ${res.authoredGap.derived} derived rows but 0 authored rows — no traceability.events[] or traceability.contracts.{published,consumed}_events declarations are landing in the catalogue. Governance queries filtering to source=authored will return empty (per ADR 0160 Phase 0)`);
|
|
335
|
+
lines.push(`[finding] coverage: capability_event has ${res.authoredGap.derived} derived rows but 0 authored rows — no traceability.events[] or traceability.contracts.{published,consumed}_events declarations are landing in the catalogue. Governance queries filtering to source=authored will return empty (per M-ADR 0160 Phase 0)`);
|
|
336
336
|
}
|
|
337
337
|
return lines;
|
|
338
338
|
}
|
|
339
339
|
export function renderFeatureEventCheck(res) {
|
|
340
340
|
return res.gap
|
|
341
341
|
? [
|
|
342
|
-
`[finding] coverage: service_event (${res.gap.serviceEvent}) + feature_service (${res.gap.featureService}) populated but feature_event emits 0 rows — reverse-derivable edge missing (per ADR 0161 Phase 0)`,
|
|
342
|
+
`[finding] coverage: service_event (${res.gap.serviceEvent}) + feature_service (${res.gap.featureService}) populated but feature_event emits 0 rows — reverse-derivable edge missing (per M-ADR 0161 Phase 0)`,
|
|
343
343
|
]
|
|
344
344
|
: [];
|
|
345
345
|
}
|
|
346
346
|
export function renderPlatformDependenciesCheck(finding) {
|
|
347
347
|
return finding
|
|
348
348
|
? [
|
|
349
|
-
`[finding] coverage: app-platform-dependencies.yaml declares a platform_dependencies block but product_platform_dependencies.csv emits 0 rows — forward-method ingestion missing (per ADR 0162 Phase 0). Resolves once the builder reads the forward source.`,
|
|
349
|
+
`[finding] coverage: app-platform-dependencies.yaml declares a platform_dependencies block but product_platform_dependencies.csv emits 0 rows — forward-method ingestion missing (per M-ADR 0162 Phase 0). Resolves once the builder reads the forward source.`,
|
|
350
350
|
]
|
|
351
351
|
: [];
|
|
352
352
|
}
|
|
@@ -358,7 +358,7 @@ export function renderFeatureSpecPromotionCheck(res) {
|
|
|
358
358
|
`[finding] coverage: ${f.intermediate} of ${f.total} features are reverse-intermediate ` +
|
|
359
359
|
`(capability-spec/feature-grounding) not yet promoted to feature-spec — ` +
|
|
360
360
|
`unresolvable as realises:/FK targets (${f.pctResolvable}% resolvable, ${f.note}; ` +
|
|
361
|
-
`per ADR 0177 §4.2)`,
|
|
361
|
+
`per M-ADR 0177 §4.2)`,
|
|
362
362
|
];
|
|
363
363
|
}
|
|
364
364
|
export function renderFeatureSurfaceDuplicationCheck(res) {
|
|
@@ -370,7 +370,7 @@ export function renderFeatureSurfaceDuplicationCheck(res) {
|
|
|
370
370
|
`[finding] coverage: ${f.pairs} near-duplicate feature surface(s) across ` +
|
|
371
371
|
`${f.capabilities} ${capWord} — a capability-spec (rollup) and feature-grounding ` +
|
|
372
372
|
`(detailed) slug naming the same surface; reconcile to one slug per surface, ` +
|
|
373
|
-
`detailed slug canonical (per ADR 0177 §4.3)`,
|
|
373
|
+
`detailed slug canonical (per M-ADR 0177 §4.3)`,
|
|
374
374
|
];
|
|
375
375
|
}
|
|
376
376
|
// ─── Scores coverage (M-ADR 0205 §4.5, B2 — the fourteenth check) ─────────
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Deterministic CSV writer for catalogues.
|
|
3
3
|
*
|
|
4
|
-
* Emits headers + sorted rows per ADR 0134 builder contract:
|
|
4
|
+
* Emits headers + sorted rows per M-ADR 0134 builder contract:
|
|
5
5
|
* - column order matches the canonical schema exactly,
|
|
6
6
|
* - rows sorted by primary-key value,
|
|
7
7
|
* - cells with embedded comma/quote/newline are RFC-4180 quoted,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Findings collector for the canonical builder per ADR 0134 / builder-spec
|
|
2
|
+
* Findings collector for the canonical builder per M-ADR 0134 / builder-spec
|
|
3
3
|
* §"Emit findings as it parses".
|
|
4
4
|
*
|
|
5
5
|
* Findings are human-readable AND machine-parseable. Format per finding:
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* FK validator per ADR 0134 builder-spec §"Run internal FK validation
|
|
2
|
+
* FK validator per M-ADR 0134 builder-spec §"Run internal FK validation
|
|
3
3
|
* before exiting" + §"External FK handling".
|
|
4
4
|
*
|
|
5
5
|
* For each catalogue's foreign keys:
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* - External FKs (external=true, or external for the build's profile via
|
|
10
10
|
* `externalForProfiles` — schema 2.23.0): every non-empty value is logged
|
|
11
11
|
* as a `[finding] external` line and skipped from validation. The
|
|
12
|
-
* aggregator resolves these post-merge per ADR 0134 §4.5. A required
|
|
12
|
+
* aggregator resolves these post-merge per M-ADR 0134 §4.5. A required
|
|
13
13
|
* (non-nullable) external FK still fails when EMPTY — the value must be
|
|
14
14
|
* named even if it resolves cross-SDD.
|
|
15
15
|
*
|
|
@@ -223,8 +223,8 @@ const ManifestSchema = z.object({
|
|
|
223
223
|
// shape here. Whether the value resolves against the active vocabulary
|
|
224
224
|
// (method ∪ RA-bundle ∪ adopter) is checked in the build step, which has
|
|
225
225
|
// the bundle cache. Absent reads as "unclassified" (with a finding). Named
|
|
226
|
-
// application_archetype to stay distinct from the ADR 0173 domain archetype
|
|
227
|
-
// and the ADR 0169 openui archetype.
|
|
226
|
+
// application_archetype to stay distinct from the M-ADR 0173 domain archetype
|
|
227
|
+
// and the M-ADR 0169 openui archetype.
|
|
228
228
|
application_archetype: z
|
|
229
229
|
.string()
|
|
230
230
|
.regex(/^[a-z][a-z0-9-]*$/, {
|
|
@@ -298,7 +298,7 @@ export async function loadSddManifest(sddPath, options) {
|
|
|
298
298
|
const code = err.code;
|
|
299
299
|
if (code === "ENOENT") {
|
|
300
300
|
throw new Error(`SDD manifest not found at ${sourcePath}\n\n` +
|
|
301
|
-
`Every SDD repo MUST declare an .sdd/sdd-manifest.yaml per ADR 0134 §4.6.\n` +
|
|
301
|
+
`Every SDD repo MUST declare an .sdd/sdd-manifest.yaml per M-ADR 0134 §4.6.\n` +
|
|
302
302
|
`Schema: docs/method/methods/sdd-manifest-schema.yaml in sdd-method.\n` +
|
|
303
303
|
`Bootstrap: \`sdd-cli init\` (when migrated to support manifest authoring).`);
|
|
304
304
|
}
|
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
* release; migration tooling (`sdd-cli migrate <schema> --to <v>`,
|
|
24
24
|
* planned) covers downgrade paths.
|
|
25
25
|
*
|
|
26
|
-
* See ADR 0132 (catalogue-integrity gate's schema-drift digest hook) +
|
|
27
|
-
* ADR 0134 §4.6 (canonical builder + aggregator).
|
|
26
|
+
* See M-ADR 0132 (catalogue-integrity gate's schema-drift digest hook) +
|
|
27
|
+
* M-ADR 0134 §4.6 (canonical builder + aggregator).
|
|
28
28
|
*/
|
|
29
29
|
export const SCHEMA_COMPAT = {
|
|
30
30
|
catalogue_schema: "^4.0.0",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Loader for the canonical catalogue-schema.yaml vendored from sdd-method.
|
|
3
3
|
*
|
|
4
|
-
* Per ADR 0134 §4.6 the canonical builder reads the schema rather than
|
|
4
|
+
* Per M-ADR 0134 §4.6 the canonical builder reads the schema rather than
|
|
5
5
|
* hardcoding column shapes or FK rules. This loader is the only place in
|
|
6
6
|
* the CLI that knows the schema file's location.
|
|
7
7
|
*/
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// The documented TypeQL subset — PARSER (M-ADR 0210 §4.2).
|
|
2
|
+
//
|
|
3
|
+
// This module reads the authored form of a fact-based model and nothing else: it knows
|
|
4
|
+
// the subset's grammar and refuses, with a located error, anything outside it. It knows
|
|
5
|
+
// nothing about catalogues, YAML or projections — that is `schema-projection.ts`. The split
|
|
6
|
+
// is deliberate: a store extending the subset extends the parser; the method's projection
|
|
7
|
+
// is one consumer of the parsed model among possible others.
|
|
8
|
+
//
|
|
9
|
+
// Fail-closed (M-ADR 0200 §4.3): a construct the parser does not recognise is a ParseError,
|
|
10
|
+
// never a skip. The five blindnesses measured in the prototype this replaces — `@values`,
|
|
11
|
+
// an `@card` form outside the subset, a role with two players, a value-type/family
|
|
12
|
+
// contradiction, a content change that must be visible — are each refused here or made
|
|
13
|
+
// visible to the projection, and the round-trip gate proves that with mutation controls
|
|
14
|
+
// before every verdict.
|
|
15
|
+
//
|
|
16
|
+
// Grammar (one declaration per line; `#` outside a string literal starts a comment):
|
|
17
|
+
// define
|
|
18
|
+
// attribute <label>, value <string|integer|double|boolean|date|datetime>;
|
|
19
|
+
// entity <label>[, owns <attr> [@key] [@card(1..1)|@card(0..1)|@card(0..)]][, plays <rel>:<role> [...]] ...;
|
|
20
|
+
// relation <label>[, relates <role> [@card(1..1)|@card(0..1)|@card(0..)]][, owns ...][, plays ...] ...;
|
|
21
|
+
// Meta rows (the second file):
|
|
22
|
+
// insert $m isa <meta-type>, has <key> <"string"|int|true|false>[, has ...];
|
|
23
|
+
export class ParseError extends Error {
|
|
24
|
+
constructor(message) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = "ParseError";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export const VALUE_TYPES = new Set(["string", "integer", "double", "boolean", "date", "datetime"]);
|
|
30
|
+
export const META_TYPES = new Set([
|
|
31
|
+
"schema-meta",
|
|
32
|
+
"catalogue-meta",
|
|
33
|
+
"column-meta",
|
|
34
|
+
"fk-meta",
|
|
35
|
+
"column-enum-meta",
|
|
36
|
+
"enum-meta",
|
|
37
|
+
"enum-value-meta",
|
|
38
|
+
"enum-value-property",
|
|
39
|
+
"coverage-meta",
|
|
40
|
+
"relationship-meta",
|
|
41
|
+
"vocabulary-meta",
|
|
42
|
+
"rule-meta",
|
|
43
|
+
]);
|
|
44
|
+
/** Meta keys whose values are lists (repeated `has` on one row). */
|
|
45
|
+
const LIST_KEYS = new Set(["m-external-for-profile", "m-reads"]);
|
|
46
|
+
function unlit(s, where) {
|
|
47
|
+
let out = "";
|
|
48
|
+
for (let i = 0; i < s.length; i++) {
|
|
49
|
+
const c = s[i];
|
|
50
|
+
if (c === "\\" && i + 1 < s.length) {
|
|
51
|
+
const n = s[i + 1];
|
|
52
|
+
const map = { n: "\n", t: "\t", '"': '"', "\\": "\\" };
|
|
53
|
+
const r = map[n];
|
|
54
|
+
if (r === undefined)
|
|
55
|
+
throw new ParseError(`${where}: bad escape \\${n}`);
|
|
56
|
+
out += r;
|
|
57
|
+
i++;
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
out += c;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
/** A `#` outside a string literal starts a comment. */
|
|
66
|
+
export function stripComment(line) {
|
|
67
|
+
let out = "";
|
|
68
|
+
let q = false;
|
|
69
|
+
for (let i = 0; i < line.length; i++) {
|
|
70
|
+
const c = line[i];
|
|
71
|
+
if (c === '"' && (i === 0 || line[i - 1] !== "\\"))
|
|
72
|
+
q = !q;
|
|
73
|
+
if (c === "#" && !q)
|
|
74
|
+
break;
|
|
75
|
+
out += c;
|
|
76
|
+
}
|
|
77
|
+
return out.trim();
|
|
78
|
+
}
|
|
79
|
+
const CARD_RE = /^@card\((\d+)\.\.(\d*)\)$/;
|
|
80
|
+
function parseAnnotations(tokens, where) {
|
|
81
|
+
let key = false;
|
|
82
|
+
let required = false;
|
|
83
|
+
let multi = false;
|
|
84
|
+
let optional = false;
|
|
85
|
+
for (const t of tokens) {
|
|
86
|
+
if (t === "@key") {
|
|
87
|
+
key = true;
|
|
88
|
+
}
|
|
89
|
+
else if (t.startsWith("@card(")) {
|
|
90
|
+
const m = CARD_RE.exec(t);
|
|
91
|
+
if (!m)
|
|
92
|
+
throw new ParseError(`${where}: unparseable cardinality ${JSON.stringify(t)}`);
|
|
93
|
+
const lo = m[1];
|
|
94
|
+
const hi = m[2];
|
|
95
|
+
if (lo === "1" && hi === "1")
|
|
96
|
+
required = true;
|
|
97
|
+
else if (lo === "0" && hi === "")
|
|
98
|
+
multi = true;
|
|
99
|
+
else if (lo === "0" && hi === "1")
|
|
100
|
+
optional = true;
|
|
101
|
+
else
|
|
102
|
+
throw new ParseError(`${where}: cardinality ${t} cannot be projected onto the catalogue schema (only @card(1..1), @card(0..1) or @card(0..) are in the subset)`);
|
|
103
|
+
}
|
|
104
|
+
else if (t.startsWith("@values")) {
|
|
105
|
+
throw new ParseError(`${where}: @values(...) is not admitted in this projection — vocabularies bind through column_enums meta rows or a recorded absence (M-ADR 0210 §4.5)`);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
throw new ParseError(`${where}: unknown token ${JSON.stringify(t)}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return { key, required, multi, optional };
|
|
112
|
+
}
|
|
113
|
+
function splitClauses(body, where) {
|
|
114
|
+
// refuse before the naive split can be fooled by a comma inside the parenthesis
|
|
115
|
+
if (body.includes("@values")) {
|
|
116
|
+
throw new ParseError(`${where}: @values(...) is not admitted in this projection (M-ADR 0210 §4.5)`);
|
|
117
|
+
}
|
|
118
|
+
return body.split(",").map((c) => c.trim());
|
|
119
|
+
}
|
|
120
|
+
const ATTR_RE = /^attribute ([a-z][a-z0-9-]*), value ([a-z]+)$/;
|
|
121
|
+
const TYPE_RE = /^(entity|relation) ([a-z][a-z0-9-]*)(?:, (.*))?$/;
|
|
122
|
+
/** Parse the type-system file. `fileLabel` names the file in error locations. */
|
|
123
|
+
export function parseTypes(text, fileLabel = "catalogue-schema.tql", options = {}) {
|
|
124
|
+
const singlePlayer = options.singlePlayer ?? true;
|
|
125
|
+
const attrs = new Map();
|
|
126
|
+
const entities = new Map();
|
|
127
|
+
const relations = new Map();
|
|
128
|
+
const seen = new Set();
|
|
129
|
+
const lines = text.split(/\r?\n/);
|
|
130
|
+
for (let ln = 1; ln <= lines.length; ln++) {
|
|
131
|
+
let line = stripComment(lines[ln - 1]);
|
|
132
|
+
if (line === "" || line === "define")
|
|
133
|
+
continue;
|
|
134
|
+
const where = `${fileLabel}:${ln}`;
|
|
135
|
+
if (!line.endsWith(";"))
|
|
136
|
+
throw new ParseError(`${where}: declaration must end with ';' — one declaration per line`);
|
|
137
|
+
line = line.slice(0, -1);
|
|
138
|
+
const am = ATTR_RE.exec(line);
|
|
139
|
+
if (am) {
|
|
140
|
+
const lab = am[1];
|
|
141
|
+
const vt = am[2];
|
|
142
|
+
if (!VALUE_TYPES.has(vt))
|
|
143
|
+
throw new ParseError(`${where}: unknown value type ${JSON.stringify(vt)}`);
|
|
144
|
+
if (seen.has(lab))
|
|
145
|
+
throw new ParseError(`${where}: duplicate declaration of ${JSON.stringify(lab)}`);
|
|
146
|
+
seen.add(lab);
|
|
147
|
+
attrs.set(lab, vt);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const tm = TYPE_RE.exec(line);
|
|
151
|
+
if (!tm)
|
|
152
|
+
throw new ParseError(`${where}: not in the subset: ${JSON.stringify(line.slice(0, 100))}`);
|
|
153
|
+
const kind = tm[1];
|
|
154
|
+
const lab = tm[2];
|
|
155
|
+
const body = tm[3];
|
|
156
|
+
if (seen.has(lab))
|
|
157
|
+
throw new ParseError(`${where}: duplicate declaration of ${JSON.stringify(lab)}`);
|
|
158
|
+
seen.add(lab);
|
|
159
|
+
const decl = { owns: new Map(), plays: new Map(), relates: new Map() };
|
|
160
|
+
for (const clause of body !== undefined ? splitClauses(body, where) : []) {
|
|
161
|
+
const w = clause.split(/\s+/).filter((x) => x !== "");
|
|
162
|
+
if (w.length === 0)
|
|
163
|
+
throw new ParseError(`${where}: empty clause`);
|
|
164
|
+
const head = w[0];
|
|
165
|
+
if (head === "owns") {
|
|
166
|
+
if (w.length < 2)
|
|
167
|
+
throw new ParseError(`${where}: owns without attribute`);
|
|
168
|
+
if (decl.owns.has(w[1]))
|
|
169
|
+
throw new ParseError(`${where}: ${lab} owns ${w[1]} twice`);
|
|
170
|
+
decl.owns.set(w[1], parseAnnotations(w.slice(2), where));
|
|
171
|
+
}
|
|
172
|
+
else if (head === "plays") {
|
|
173
|
+
if (w.length < 2 || !w[1].includes(":"))
|
|
174
|
+
throw new ParseError(`${where}: plays needs <relation>:<role>`);
|
|
175
|
+
if (decl.plays.has(w[1]))
|
|
176
|
+
throw new ParseError(`${where}: ${lab} plays ${w[1]} twice`);
|
|
177
|
+
decl.plays.set(w[1], parseAnnotations(w.slice(2), where));
|
|
178
|
+
}
|
|
179
|
+
else if (head === "relates") {
|
|
180
|
+
if (kind !== "relation")
|
|
181
|
+
throw new ParseError(`${where}: only a relation relates`);
|
|
182
|
+
if (w.length < 2)
|
|
183
|
+
throw new ParseError(`${where}: relates without role`);
|
|
184
|
+
if (decl.relates.has(w[1]))
|
|
185
|
+
throw new ParseError(`${where}: ${lab} relates ${w[1]} twice`);
|
|
186
|
+
decl.relates.set(w[1], parseAnnotations(w.slice(2), where));
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
throw new ParseError(`${where}: unknown clause ${JSON.stringify(head)}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
(kind === "entity" ? entities : relations).set(lab, decl);
|
|
193
|
+
}
|
|
194
|
+
// every owned attribute must be declared; every role must exist; a role has at most ONE player
|
|
195
|
+
const playersAll = new Map();
|
|
196
|
+
for (const [lab, d] of [...entities, ...relations]) {
|
|
197
|
+
for (const a of d.owns.keys()) {
|
|
198
|
+
if (!attrs.has(a))
|
|
199
|
+
throw new ParseError(`${lab} owns undeclared attribute ${JSON.stringify(a)}`);
|
|
200
|
+
}
|
|
201
|
+
for (const rr of d.plays.keys()) {
|
|
202
|
+
const idx = rr.indexOf(":");
|
|
203
|
+
const rel = rr.slice(0, idx);
|
|
204
|
+
const role = rr.slice(idx + 1);
|
|
205
|
+
const r = relations.get(rel);
|
|
206
|
+
if (!r)
|
|
207
|
+
throw new ParseError(`${lab} plays role of undeclared relation ${JSON.stringify(rel)}`);
|
|
208
|
+
if (!r.relates.has(role))
|
|
209
|
+
throw new ParseError(`${lab} plays undeclared role ${JSON.stringify(rr)}`);
|
|
210
|
+
const list = playersAll.get(rr) ?? [];
|
|
211
|
+
list.push(lab);
|
|
212
|
+
playersAll.set(rr, list);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const players = new Map();
|
|
216
|
+
for (const [rr, ps] of playersAll) {
|
|
217
|
+
if (ps.length > 1 && !singlePlayer)
|
|
218
|
+
continue;
|
|
219
|
+
if (ps.length > 1) {
|
|
220
|
+
throw new ParseError(`role ${rr} has ${ps.length} players (${ps.join(", ")}); the projection can carry exactly one and refuses to pick`);
|
|
221
|
+
}
|
|
222
|
+
players.set(rr, ps[0]);
|
|
223
|
+
}
|
|
224
|
+
return { attrs, entities, relations, players, playersAll };
|
|
225
|
+
}
|
|
226
|
+
const ROW_RE = /^insert \$m isa ([a-z][a-z0-9-]*), (.*);$/;
|
|
227
|
+
const TOK_RE = /has ([a-z][a-z0-9-]*) ("(?:[^"\\]|\\.)*"|-?\d+|true|false)/y;
|
|
228
|
+
export function parseMeta(text, fileLabel = "catalogue-schema-meta.tql", options = {}) {
|
|
229
|
+
const metaTypes = options.metaTypes ?? META_TYPES;
|
|
230
|
+
const rows = new Map();
|
|
231
|
+
const lines = text.split(/\r?\n/);
|
|
232
|
+
for (let ln = 1; ln <= lines.length; ln++) {
|
|
233
|
+
const line = stripComment(lines[ln - 1]);
|
|
234
|
+
if (line === "")
|
|
235
|
+
continue;
|
|
236
|
+
const where = `${fileLabel}:${ln}`;
|
|
237
|
+
const m = ROW_RE.exec(line);
|
|
238
|
+
if (!m)
|
|
239
|
+
throw new ParseError(`${where}: not in the subset: ${JSON.stringify(line.slice(0, 100))}`);
|
|
240
|
+
const typ = m[1];
|
|
241
|
+
const body = m[2];
|
|
242
|
+
if (!metaTypes.has(typ))
|
|
243
|
+
throw new ParseError(`${where}: unknown meta type ${JSON.stringify(typ)}`);
|
|
244
|
+
const acc = new Map();
|
|
245
|
+
let i = 0;
|
|
246
|
+
for (;;) {
|
|
247
|
+
TOK_RE.lastIndex = i;
|
|
248
|
+
const t = TOK_RE.exec(body);
|
|
249
|
+
if (!t)
|
|
250
|
+
throw new ParseError(`${where}: meta row body not in the subset at offset ${i}: ${JSON.stringify(body.slice(i, i + 60))}`);
|
|
251
|
+
const k = t[1];
|
|
252
|
+
const v = t[2];
|
|
253
|
+
let val;
|
|
254
|
+
if (v.startsWith('"'))
|
|
255
|
+
val = unlit(v.slice(1, -1), where);
|
|
256
|
+
else if (v === "true" || v === "false")
|
|
257
|
+
val = v === "true";
|
|
258
|
+
else
|
|
259
|
+
val = Number.parseInt(v, 10);
|
|
260
|
+
const list = acc.get(k) ?? [];
|
|
261
|
+
list.push(val);
|
|
262
|
+
acc.set(k, list);
|
|
263
|
+
i = TOK_RE.lastIndex;
|
|
264
|
+
if (i === body.length)
|
|
265
|
+
break;
|
|
266
|
+
if (body.startsWith(", ", i))
|
|
267
|
+
i += 2;
|
|
268
|
+
else
|
|
269
|
+
throw new ParseError(`${where}: expected ', has ...' at offset ${i}: ${JSON.stringify(body.slice(i, i + 40))}`);
|
|
270
|
+
}
|
|
271
|
+
const row = {};
|
|
272
|
+
for (const [k, vs] of acc)
|
|
273
|
+
row[k] = LIST_KEYS.has(k) ? vs : vs[0];
|
|
274
|
+
const list = rows.get(typ) ?? [];
|
|
275
|
+
list.push(row);
|
|
276
|
+
rows.set(typ, list);
|
|
277
|
+
}
|
|
278
|
+
return rows;
|
|
279
|
+
}
|
|
280
|
+
//# sourceMappingURL=tql-subset-parser.js.map
|
package/dist/lib/pack/schema.js
CHANGED
|
@@ -26,7 +26,7 @@ export const PackSeedSchema = z.object({
|
|
|
26
26
|
scope: z
|
|
27
27
|
.string()
|
|
28
28
|
.regex(/^[a-z0-9-]+$/, "scope must be lower-kebab-case slug"),
|
|
29
|
-
// Optional per ADR 0176 §4.6: absent = profile-agnostic pack.
|
|
29
|
+
// Optional per M-ADR 0176 §4.6: absent = profile-agnostic pack.
|
|
30
30
|
forwardBase: z
|
|
31
31
|
.object({
|
|
32
32
|
bundle: z.enum(KNOWN_FORWARD_BASES),
|
|
@@ -66,7 +66,7 @@ export const PackSeedSchema = z.object({
|
|
|
66
66
|
.array(z.string().min(1))
|
|
67
67
|
.min(1, "managed_paths must have at least one entry"),
|
|
68
68
|
forbiddenPaths: z.array(z.string()).optional(),
|
|
69
|
-
// ADR 0176 §4.3: scaffold-source path prefixes; each must cover at
|
|
69
|
+
// M-ADR 0176 §4.3: scaffold-source path prefixes; each must cover at
|
|
70
70
|
// least one managed_paths entry (checked in the superRefine below).
|
|
71
71
|
scaffoldSourcePaths: z.array(z.string().min(1)).optional(),
|
|
72
72
|
requiresPack: z
|
package/dist/lib/pack/seed.js
CHANGED
|
@@ -2,7 +2,7 @@ import { readFile } from "node:fs/promises";
|
|
|
2
2
|
import { dirname, isAbsolute, resolve } from "node:path";
|
|
3
3
|
import { parse as parseYaml } from "yaml";
|
|
4
4
|
/**
|
|
5
|
-
* Adopter pack seed file. Per ADR 0151 + context-pack-seed-schema.yaml.
|
|
5
|
+
* Adopter pack seed file. Per M-ADR 0151 + context-pack-seed-schema.yaml.
|
|
6
6
|
*
|
|
7
7
|
* Loaded by `sdd-cli pack build` and `sdd-cli pack validate` from
|
|
8
8
|
* `orchestration/distribution/{pack-name}-seed.yaml` in adopter repos.
|
|
@@ -120,7 +120,7 @@ async function loadSeedRecursive(absPath, visited) {
|
|
|
120
120
|
const seed = {
|
|
121
121
|
version: asString(own.version, "version", absPath),
|
|
122
122
|
scope: asString(own.scope, "scope", absPath),
|
|
123
|
-
// forward_base optional per ADR 0176 §4.6 (profile-agnostic packs omit it)
|
|
123
|
+
// forward_base optional per M-ADR 0176 §4.6 (profile-agnostic packs omit it)
|
|
124
124
|
...(own.forward_base && {
|
|
125
125
|
forwardBase: {
|
|
126
126
|
bundle: asString(own.forward_base.bundle, "forward_base.bundle", absPath),
|
|
@@ -36,7 +36,7 @@ export async function runPackValidate(opts, stdout = (s) => process.stdout.write
|
|
|
36
36
|
stdout(pass(`Seed valid: ${seed.scope} v${seed.version}`));
|
|
37
37
|
stdout(info(seed.forwardBase
|
|
38
38
|
? `Forward base: ${seed.forwardBase.bundle} >= ${seed.forwardBase.minimumVersion}`
|
|
39
|
-
: "Forward base: none (profile-agnostic pack, ADR 0176 §4.6)"));
|
|
39
|
+
: "Forward base: none (profile-agnostic pack, M-ADR 0176 §4.6)"));
|
|
40
40
|
if (seed.scaffoldSourcePaths) {
|
|
41
41
|
stdout(info(`Scaffold-source prefixes: ${seed.scaffoldSourcePaths.join(", ")}`));
|
|
42
42
|
}
|
package/dist/lib/repo-kind.js
CHANGED
|
@@ -2,7 +2,7 @@ import { readFile, writeFile } from "node:fs/promises";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
/**
|
|
4
4
|
* Forward-base bundles set `.sdd-repo-kind` and qualify as the base
|
|
5
|
-
* layer that additive extensions can compose on top of (per ADR 0135).
|
|
5
|
+
* layer that additive extensions can compose on top of (per M-ADR 0135).
|
|
6
6
|
* The legacy self-contained `reverse-baseline` is retained as a
|
|
7
7
|
* forward base for compat with adopters who haven't migrated to the
|
|
8
8
|
* `reverse-extension-baseline` additive shape yet.
|
|
@@ -59,7 +59,7 @@ export function repoKindFromProfile(profile) {
|
|
|
59
59
|
/**
|
|
60
60
|
* Returns the profile a bundle authoritatively claims, or null for
|
|
61
61
|
* additive bundles that don't claim a profile of their own. Accepts
|
|
62
|
-
* arbitrary strings (adopter pack names per ADR 0151) and returns null
|
|
62
|
+
* arbitrary strings (adopter pack names per M-ADR 0151) and returns null
|
|
63
63
|
* for any name not in the closed sdd-method bundle set.
|
|
64
64
|
*/
|
|
65
65
|
export function profileFromBundleName(name) {
|
|
@@ -73,7 +73,7 @@ export function bundleNameFromProfile(profile) {
|
|
|
73
73
|
/**
|
|
74
74
|
* Forward-base bundles set `.sdd-repo-kind` and qualify as the base
|
|
75
75
|
* layer that additive extensions compose on top of. Additive bundles
|
|
76
|
-
* (sdd-method's `reverse-extension-baseline` and ADR 0151 adopter
|
|
76
|
+
* (sdd-method's `reverse-extension-baseline` and M-ADR 0151 adopter
|
|
77
77
|
* packs) return false. Accepts arbitrary strings; unknown names return
|
|
78
78
|
* false.
|
|
79
79
|
*/
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Archetype behaviour, seeded once and then cited (reference architecture sdm-d93u.4.1).
|
|
2
|
+
//
|
|
3
|
+
// A reference-architecture archetype ships its behaviour as canonical rows (behaviour.tql, rows of the
|
|
4
|
+
// behaviour schema in the M-ADR 0210 subset). An estate does not sync them: it takes them ONCE, when it
|
|
5
|
+
// scaffolds the domain, as an OpenSpec change that ADDS the archetype's requirements to its behaviour store,
|
|
6
|
+
// to agree and build through its own process like any other change (M-ADR 0221-0223). From then on the
|
|
7
|
+
// estate owns its requirements and CITES the archetype's: docs/domains/<domain>/archetype-citations.yaml
|
|
8
|
+
// records, per requirement, the archetype requirement it answers to, the bundle version and a digest of that
|
|
9
|
+
// requirement there, and how the estate's relates to it (derived, extended, narrowed, or departed with a
|
|
10
|
+
// reason and the decision that took it). The validator `archetype-citations` reports a citation whose source
|
|
11
|
+
// has changed since, so evolution is visible rather than silent; re-aligning is a re-pin.
|
|
12
|
+
//
|
|
13
|
+
// The generated OpenSpec spec the reference architecture keeps beside its rows is never read here: the rows
|
|
14
|
+
// are the source, and two homes for one behaviour is what M-ADR 0221 rules out.
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
import { stringify as stringifyYaml } from "yaml";
|
|
19
|
+
import { parseMeta } from "../metamodel/tql-subset-parser.js";
|
|
20
|
+
export const BEHAVIOUR_FILE = "behaviour.tql";
|
|
21
|
+
export const CITATIONS_FILE = "archetype-citations.yaml";
|
|
22
|
+
export const RELATIONS = ["derived", "extended", "narrowed", "departed"];
|
|
23
|
+
const BEHAVIOUR_META = new Set(["requirement-meta", "scenario-meta", "scenario-step-meta", "cite-meta"]);
|
|
24
|
+
const str = (r, k) => String(r[k] ?? "");
|
|
25
|
+
const num = (r, k) => Number(r[k] ?? 0);
|
|
26
|
+
/** The archetype's requirements, in position order, from its behaviour rows. */
|
|
27
|
+
export function parseArchetypeBehaviour(text, label) {
|
|
28
|
+
const rows = parseMeta(text, label, { metaTypes: BEHAVIOUR_META });
|
|
29
|
+
const reqs = [...(rows.get("requirement-meta") ?? [])].sort((a, b) => num(a, "m-position") - num(b, "m-position"));
|
|
30
|
+
const scen = rows.get("scenario-meta") ?? [];
|
|
31
|
+
const steps = rows.get("scenario-step-meta") ?? [];
|
|
32
|
+
const cites = rows.get("cite-meta") ?? [];
|
|
33
|
+
return reqs.map((r) => {
|
|
34
|
+
const id = str(r, "m-requirement");
|
|
35
|
+
const scenarios = scen
|
|
36
|
+
.filter((x) => str(x, "m-requirement") === id)
|
|
37
|
+
.sort((a, b) => num(a, "m-position") - num(b, "m-position"))
|
|
38
|
+
.map((x) => {
|
|
39
|
+
const sid = str(x, "m-scenario");
|
|
40
|
+
return {
|
|
41
|
+
id: sid,
|
|
42
|
+
title: str(x, "m-title"),
|
|
43
|
+
steps: steps
|
|
44
|
+
.filter((t) => str(t, "m-scenario") === sid)
|
|
45
|
+
.sort((a, b) => num(a, "m-position") - num(b, "m-position"))
|
|
46
|
+
.map((t) => ({ keyword: str(t, "m-keyword"), text: str(t, "m-text") })),
|
|
47
|
+
cites: cites.filter((c) => str(c, "m-scenario") === sid).map((c) => str(c, "m-cites")),
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
const req = {
|
|
51
|
+
id,
|
|
52
|
+
title: str(r, "m-title"),
|
|
53
|
+
statement: str(r, "m-statement"),
|
|
54
|
+
tier: str(r, "m-tier"),
|
|
55
|
+
ruleFile: str(r, "m-rule-file"),
|
|
56
|
+
ruleLabel: str(r, "m-rule-label"),
|
|
57
|
+
scenarios,
|
|
58
|
+
};
|
|
59
|
+
return { ...req, digest: requirementDigest(req) };
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
/** Digest of everything a citation answers to: wording, tier, source rule and scenarios, not position. */
|
|
63
|
+
export function requirementDigest(r) {
|
|
64
|
+
const canon = JSON.stringify([r.id, r.title, r.statement, r.tier, r.ruleFile, r.ruleLabel,
|
|
65
|
+
r.scenarios.map((s) => [s.id, s.title, s.steps.map((t) => [t.keyword, t.text]), [...s.cites].sort()])]);
|
|
66
|
+
return createHash("sha256").update(canon).digest("hex").slice(0, 12);
|
|
67
|
+
}
|
|
68
|
+
/** The archetype's behaviour rows in a cached bundle: <bundle cache>/<seed prefix>/behaviour.tql, if it ships them. */
|
|
69
|
+
export async function loadArchetypeBehaviour(bundleCacheDir, seeds) {
|
|
70
|
+
for (const seed of seeds) {
|
|
71
|
+
const file = join(bundleCacheDir, seed, BEHAVIOUR_FILE);
|
|
72
|
+
let text;
|
|
73
|
+
try {
|
|
74
|
+
text = await readFile(file, "utf8");
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
return parseArchetypeBehaviour(text, file);
|
|
80
|
+
}
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
/** `<bundle>@<version>` cache directory names carry the version the estate locked. */
|
|
84
|
+
export function cachedVersion(bundleCacheDir) {
|
|
85
|
+
const name = bundleCacheDir.replace(/[\\/]+$/, "").split(/[\\/]/).pop() ?? "";
|
|
86
|
+
const at = name.lastIndexOf("@");
|
|
87
|
+
return at > 0 ? name.slice(at + 1) : undefined;
|
|
88
|
+
}
|
|
89
|
+
function renderChange(domain, archetypeId, bundle, version, reqs) {
|
|
90
|
+
const proposal = [
|
|
91
|
+
"## Why", "",
|
|
92
|
+
`The ${domain} domain was scaffolded from the reference architecture's ${archetypeId} archetype (${bundle} ${version}). ` +
|
|
93
|
+
"The archetype prescribes behaviour every conforming estate needs; this change adds it to the behaviour store once. " +
|
|
94
|
+
"From here the requirements are this estate's: the citations in " +
|
|
95
|
+
`docs/domains/${domain}/${CITATIONS_FILE} record which archetype requirement each one answers to.`, "",
|
|
96
|
+
"## What Changes", "",
|
|
97
|
+
`- Adds ${reqs.length} requirements to the ${domain} spec, one per archetype rule, each with its scenarios.`,
|
|
98
|
+
"- Before agreeing the change, narrow, extend or remove requirements to fit this estate: an optional requirement whose condition " +
|
|
99
|
+
"the estate does not declare is removed; one the estate meets differently is reworded. Record each such choice in the citations " +
|
|
100
|
+
"(narrowed, extended, or departed with its reason and the decision that took it).",
|
|
101
|
+
"",
|
|
102
|
+
].join("\n");
|
|
103
|
+
const spec = ["## ADDED Requirements"];
|
|
104
|
+
for (const r of reqs) {
|
|
105
|
+
spec.push("", `### Requirement: ${r.title}`, "", r.statement, "", `Seeded from archetype requirement ${r.id} (${archetypeId}, ${r.tier}), rule "${r.ruleLabel}".`, "");
|
|
106
|
+
for (const s of r.scenarios) {
|
|
107
|
+
spec.push(`#### Scenario: ${s.title}`, "", ...s.steps.map((t) => `- **${t.keyword}** ${t.text}`), "");
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const tasks = ["## 1. Fit", "", "- [ ] 1.1 Review each seeded requirement against this estate and record the relation in the citations", ""].join("\n");
|
|
111
|
+
return { proposal, spec: spec.join("\n").replace(/\n+$/, "") + "\n", tasks };
|
|
112
|
+
}
|
|
113
|
+
async function exists(p) {
|
|
114
|
+
try {
|
|
115
|
+
await access(p);
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
async function writeAt(path, content) {
|
|
123
|
+
await mkdir(dirname(path), { recursive: true });
|
|
124
|
+
await writeFile(path, content, "utf8");
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Seed an estate's behaviour from an archetype, once: an OpenSpec change adding the archetype's requirements,
|
|
128
|
+
* and the domain's citations. Writes nothing when the estate has no behaviour store, when the archetype ships
|
|
129
|
+
* no rows, or when either the change or the citations already exist.
|
|
130
|
+
*/
|
|
131
|
+
export async function seedArchetypeBehaviour(input) {
|
|
132
|
+
const store = join(input.targetRepo, "openspec");
|
|
133
|
+
if (!(await exists(store)))
|
|
134
|
+
return { filesWritten: [], skipped: "no behaviour store (openspec/) in this repository" };
|
|
135
|
+
const reqs = await loadArchetypeBehaviour(input.bundleCacheDir, input.seeds);
|
|
136
|
+
if (!reqs || reqs.length === 0)
|
|
137
|
+
return { filesWritten: [], skipped: `archetype ${input.archetypeId} ships no behaviour rows` };
|
|
138
|
+
const version = cachedVersion(input.bundleCacheDir) ?? "unknown";
|
|
139
|
+
const change = `seed-${input.domain}-from-archetype`;
|
|
140
|
+
const changeDir = join(store, "changes", change);
|
|
141
|
+
const citations = join(input.targetRepo, "docs", "domains", input.domain, CITATIONS_FILE);
|
|
142
|
+
if (await exists(changeDir))
|
|
143
|
+
return { filesWritten: [], skipped: `openspec/changes/${change} already exists (seeded once)` };
|
|
144
|
+
if (await exists(citations))
|
|
145
|
+
return { filesWritten: [], skipped: `docs/domains/${input.domain}/${CITATIONS_FILE} already exists (seeded once)` };
|
|
146
|
+
const { proposal, spec, tasks } = renderChange(input.domain, input.archetypeId, input.bundleName, version, reqs);
|
|
147
|
+
const doc = {
|
|
148
|
+
archetype: input.archetypeId,
|
|
149
|
+
bundle: input.bundleName,
|
|
150
|
+
version,
|
|
151
|
+
citations: reqs.map((r) => ({ requirement: r.title, cites: r.id, digest: r.digest, relation: "derived" })),
|
|
152
|
+
};
|
|
153
|
+
const header = [
|
|
154
|
+
`# ${CITATIONS_FILE} — the ${input.domain} domain's requirements that answer to reference-architecture archetype requirements.`,
|
|
155
|
+
"# Seeded once from the archetype; the requirements are this estate's. Per citation: `requirement` is this estate's",
|
|
156
|
+
"# requirement title, `cites` the archetype requirement id, `digest` that requirement at `version`, and `relation` one of",
|
|
157
|
+
"# derived | extended | narrowed | departed (departed needs `reason` and `decision`). Checked by",
|
|
158
|
+
"# `sdd-cli validate --validator archetype-citations`: a changed source is reported until reviewed and re-pinned.",
|
|
159
|
+
"",
|
|
160
|
+
].join("\n");
|
|
161
|
+
const files = [
|
|
162
|
+
[join(changeDir, "proposal.md"), proposal],
|
|
163
|
+
[join(changeDir, "tasks.md"), tasks],
|
|
164
|
+
[join(changeDir, "specs", input.domain, "spec.md"), spec],
|
|
165
|
+
[citations, header + stringifyYaml(doc, { lineWidth: 0 })],
|
|
166
|
+
];
|
|
167
|
+
for (const [p, c] of files)
|
|
168
|
+
await writeAt(p, c);
|
|
169
|
+
return { filesWritten: files.map(([p]) => p.slice(input.targetRepo.length + 1)) };
|
|
170
|
+
}
|
|
171
|
+
//# sourceMappingURL=archetype-behaviour.js.map
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { parse as parseYaml } from "yaml";
|
|
4
|
+
import { BUNDLE_CACHE_REL, REGISTRY_FILE } from "../sync/bundle-cache.js";
|
|
5
|
+
/**
|
|
6
|
+
* Scan the bundle cache for registries and return every archetype found.
|
|
7
|
+
* Bundles without any registry.yaml are skipped silently (vendor packs
|
|
8
|
+
* etc.). A registry may sit at the bundle's payload root or nested
|
|
9
|
+
* (multi-bundle producer repos keep one registry per bundle subtree,
|
|
10
|
+
* e.g. a vertical overlay's `verticals/fleet/registry.yaml`) — all are
|
|
11
|
+
* read; seed paths stay payload-root-relative regardless of where the
|
|
12
|
+
* declaring registry sits.
|
|
13
|
+
*/
|
|
14
|
+
export async function discoverArchetypes(targetRepo) {
|
|
15
|
+
const cacheRoot = join(targetRepo, BUNDLE_CACHE_REL);
|
|
16
|
+
let entries;
|
|
17
|
+
try {
|
|
18
|
+
entries = await readdir(cacheRoot, { withFileTypes: true });
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
const out = [];
|
|
24
|
+
for (const e of entries) {
|
|
25
|
+
if (!e.isDirectory())
|
|
26
|
+
continue;
|
|
27
|
+
const bundleCacheDir = join(cacheRoot, e.name);
|
|
28
|
+
const at = e.name.lastIndexOf("@");
|
|
29
|
+
const bundleName = at > 0 ? e.name.slice(0, at) : e.name;
|
|
30
|
+
for (const registryPath of await findRegistryFiles(bundleCacheDir)) {
|
|
31
|
+
let raw;
|
|
32
|
+
try {
|
|
33
|
+
raw = await readFile(registryPath, "utf8");
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
let parsed;
|
|
39
|
+
try {
|
|
40
|
+
parsed = parseYaml(raw);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
continue; // malformed registry — skip rather than break scaffold
|
|
44
|
+
}
|
|
45
|
+
if (parsed === null || typeof parsed !== "object")
|
|
46
|
+
continue;
|
|
47
|
+
if (!Array.isArray(parsed.archetypes))
|
|
48
|
+
continue;
|
|
49
|
+
const tier = typeof parsed.tier === "string" ? parsed.tier : "unknown";
|
|
50
|
+
for (const a of parsed.archetypes) {
|
|
51
|
+
if (a === null || typeof a !== "object")
|
|
52
|
+
continue;
|
|
53
|
+
const rec = a;
|
|
54
|
+
const id = typeof rec.id === "string" ? rec.id : undefined;
|
|
55
|
+
const seeds = Array.isArray(rec.seeds)
|
|
56
|
+
? rec.seeds.filter((s) => typeof s === "string")
|
|
57
|
+
: [];
|
|
58
|
+
if (!id || seeds.length === 0)
|
|
59
|
+
continue;
|
|
60
|
+
out.push({
|
|
61
|
+
id,
|
|
62
|
+
tier,
|
|
63
|
+
seeds,
|
|
64
|
+
bundleName,
|
|
65
|
+
bundleCacheDir,
|
|
66
|
+
...(typeof rec.layer === "string" && { layer: rec.layer }),
|
|
67
|
+
...(typeof rec.domain_name === "string" && { domainName: rec.domain_name }),
|
|
68
|
+
...(typeof rec.status === "string" && { status: rec.status }),
|
|
69
|
+
...(typeof rec.notes === "string" && { notes: rec.notes }),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
/** Depth cap for nested-registry discovery — bundle payloads are shallow. */
|
|
77
|
+
const REGISTRY_SCAN_MAX_DEPTH = 4;
|
|
78
|
+
/**
|
|
79
|
+
* Find every registry.yaml within a cached bundle (root + nested),
|
|
80
|
+
* depth-capped. Returns absolute paths in a stable (sorted) order.
|
|
81
|
+
*/
|
|
82
|
+
async function findRegistryFiles(dir, depth = 0) {
|
|
83
|
+
let entries;
|
|
84
|
+
try {
|
|
85
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
const found = [];
|
|
91
|
+
for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
92
|
+
if (e.isFile() && e.name === REGISTRY_FILE) {
|
|
93
|
+
found.push(join(dir, e.name));
|
|
94
|
+
}
|
|
95
|
+
else if (e.isDirectory() && depth < REGISTRY_SCAN_MAX_DEPTH) {
|
|
96
|
+
found.push(...(await findRegistryFiles(join(dir, e.name), depth + 1)));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return found;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Resolve an archetype reference against the cache. Accepts bare ids
|
|
103
|
+
* (`core/identity`) and bundle-qualified ids
|
|
104
|
+
* (`ref-arch-core-b2b-saas:core/identity`).
|
|
105
|
+
*/
|
|
106
|
+
export async function resolveArchetype(targetRepo, reference) {
|
|
107
|
+
const available = await discoverArchetypes(targetRepo);
|
|
108
|
+
let bundleFilter;
|
|
109
|
+
let id = reference;
|
|
110
|
+
const colon = reference.indexOf(":");
|
|
111
|
+
if (colon > 0) {
|
|
112
|
+
bundleFilter = reference.slice(0, colon);
|
|
113
|
+
id = reference.slice(colon + 1);
|
|
114
|
+
}
|
|
115
|
+
const matches = available.filter((a) => a.id === id && (bundleFilter === undefined || a.bundleName === bundleFilter));
|
|
116
|
+
if (matches.length === 1) {
|
|
117
|
+
return { ok: true, archetype: matches[0], available };
|
|
118
|
+
}
|
|
119
|
+
if (matches.length === 0) {
|
|
120
|
+
const known = available.map((a) => `${a.bundleName}:${a.id}`).join(", ");
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
available,
|
|
124
|
+
error: available.length === 0
|
|
125
|
+
? `No archetype registries found in ${BUNDLE_CACHE_REL}/ — sync an archetype-bearing bundle first (M-ADR 0176).`
|
|
126
|
+
: `Archetype '${reference}' not found in cached registries. Available: ${known}`,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const collisions = matches.map((a) => `${a.bundleName}:${a.id}`).join(", ");
|
|
130
|
+
return {
|
|
131
|
+
ok: false,
|
|
132
|
+
available,
|
|
133
|
+
error: `Archetype id '${id}' is declared by multiple bundles (${collisions}). Use the qualified form '<bundle>:<id>'.`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
//# sourceMappingURL=registry.js.map
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { appendFile, copyFile, mkdir, readFile, readdir, rm } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Consumer-side bundle cache (M-ADR 0176 §4.2).
|
|
5
|
+
*
|
|
6
|
+
* Every consumed non-forward-base bundle is extracted to
|
|
7
|
+
* `<targetRepo>/.sdd/bundle-cache/<bundleName>@<version>/` — the
|
|
8
|
+
* resolution substrate for `sdd-cli scaffold domain --archetype` and
|
|
9
|
+
* the staging source for materialise-mode paths. The cache directory
|
|
10
|
+
* is gitignored (ensured at sync time).
|
|
11
|
+
*
|
|
12
|
+
* Cache layout per bundle: the bundle's payload tree, verbatim (so
|
|
13
|
+
* registry.yaml and scaffold-source files sit at their shipped paths).
|
|
14
|
+
* Stale versions of the same bundle name are pruned on refresh.
|
|
15
|
+
*/
|
|
16
|
+
export const BUNDLE_CACHE_REL = ".sdd/bundle-cache";
|
|
17
|
+
/**
|
|
18
|
+
* The registry file name (M-ADR 0176 §4.1) — always cache-only. A bundle may
|
|
19
|
+
* ship its registry at the payload root or nested (a multi-bundle producer
|
|
20
|
+
* repo keeps one registry per bundle subtree, e.g. a vertical overlay's
|
|
21
|
+
* `verticals/fleet/registry.yaml`, because the producer-layout rule forbids
|
|
22
|
+
* path mapping and the root name can only be claimed once).
|
|
23
|
+
*/
|
|
24
|
+
export const REGISTRY_FILE = "registry.yaml";
|
|
25
|
+
/**
|
|
26
|
+
* The application-archetype vocabulary file name (M-ADR 0191 §4.2) — always
|
|
27
|
+
* cache-only, like the registry. Carries the seedless classification
|
|
28
|
+
* vocabulary a bundle contributes (resolution data, not adopter-editable
|
|
29
|
+
* content). May sit at the payload root (core layer) or nested (a vertical
|
|
30
|
+
* overlay's `verticals/<v>/application-archetypes.yaml`), same as the
|
|
31
|
+
* registry.
|
|
32
|
+
*/
|
|
33
|
+
export const VOCAB_FILE = "application-archetypes.yaml";
|
|
34
|
+
/**
|
|
35
|
+
* Split a bundle's managed paths into the set to materialise into the
|
|
36
|
+
* repo tree vs the set that stays cache-only (M-ADR 0176 §4.3):
|
|
37
|
+
* scaffold-source-prefixed paths, registry files, and the archetype
|
|
38
|
+
* vocabulary file are cache-only.
|
|
39
|
+
*/
|
|
40
|
+
export function splitConsumptionModes(managedPaths, scaffoldSourcePaths) {
|
|
41
|
+
const prefixes = scaffoldSourcePaths ?? [];
|
|
42
|
+
const materialise = [];
|
|
43
|
+
const cacheOnly = [];
|
|
44
|
+
for (const p of managedPaths) {
|
|
45
|
+
const isScaffoldSource = prefixes.some((prefix) => p.startsWith(prefix));
|
|
46
|
+
const isRegistry = p === REGISTRY_FILE || p.endsWith(`/${REGISTRY_FILE}`);
|
|
47
|
+
const isVocab = p === VOCAB_FILE || p.endsWith(`/${VOCAB_FILE}`);
|
|
48
|
+
if (isScaffoldSource || isRegistry || isVocab) {
|
|
49
|
+
cacheOnly.push(p);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
materialise.push(p);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { materialise, cacheOnly };
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Extract a bundle's payload into the cache, replacing any previously
|
|
59
|
+
* cached version of the same bundle name (one cached version per
|
|
60
|
+
* bundle — the lock pins exactly one).
|
|
61
|
+
*/
|
|
62
|
+
export async function cacheBundle(input) {
|
|
63
|
+
const cacheRoot = join(input.targetRepo, BUNDLE_CACHE_REL);
|
|
64
|
+
await prunePreviousVersions(cacheRoot, input.bundleName);
|
|
65
|
+
const dest = join(cacheRoot, `${input.bundleName}@${input.bundleVersion}`);
|
|
66
|
+
for (const p of input.managedPaths) {
|
|
67
|
+
const src = join(input.payloadDir, p);
|
|
68
|
+
const dst = join(dest, p);
|
|
69
|
+
await mkdir(dirname(dst), { recursive: true });
|
|
70
|
+
await copyFile(src, dst);
|
|
71
|
+
}
|
|
72
|
+
return dest;
|
|
73
|
+
}
|
|
74
|
+
async function prunePreviousVersions(cacheRoot, bundleName) {
|
|
75
|
+
let entries;
|
|
76
|
+
try {
|
|
77
|
+
entries = await readdir(cacheRoot, { withFileTypes: true });
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return; // no cache yet
|
|
81
|
+
}
|
|
82
|
+
for (const e of entries) {
|
|
83
|
+
if (e.isDirectory() && e.name.startsWith(`${bundleName}@`)) {
|
|
84
|
+
await rm(join(cacheRoot, e.name), { recursive: true, force: true });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Ensure the consumer's .gitignore excludes the bundle cache. Appends
|
|
90
|
+
* a marked line when absent; leaves adopter content untouched otherwise.
|
|
91
|
+
*/
|
|
92
|
+
export async function ensureCacheGitignored(targetRepo) {
|
|
93
|
+
const giPath = join(targetRepo, ".gitignore");
|
|
94
|
+
const line = `${BUNDLE_CACHE_REL}/`;
|
|
95
|
+
let existing = "";
|
|
96
|
+
try {
|
|
97
|
+
existing = await readFile(giPath, "utf8");
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// no .gitignore yet — create one below
|
|
101
|
+
}
|
|
102
|
+
if (existing.split("\n").some((l) => l.trim() === line || l.trim() === BUNDLE_CACHE_REL)) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
const prefixNewline = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
|
|
106
|
+
await appendFile(giPath, `${prefixNewline}# sdd-cli bundle cache (M-ADR 0176) — extracted bundle payloads, not source\n${line}\n`);
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
//# sourceMappingURL=bundle-cache.js.map
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// archetype-citations: an estate's citations of reference-architecture archetype behaviour still say what is true
|
|
2
|
+
// (seed once, then cite; scaffold/archetype-behaviour.ts writes them).
|
|
3
|
+
//
|
|
4
|
+
// For each docs/domains/<domain>/archetype-citations.yaml, each citation is resolved against the archetype's
|
|
5
|
+
// behaviour rows in the bundle the estate has cached (its locked version):
|
|
6
|
+
// unresolved the citations name no cached bundle or archetype, the cited requirement is gone from the
|
|
7
|
+
// archetype, the relation is not one of the four, a departure has no reason or decision, or the
|
|
8
|
+
// estate requirement it names exists in neither the living specs nor an open change;
|
|
9
|
+
// stale a derived, extended or narrowed citation whose archetype requirement no longer has the digest
|
|
10
|
+
// recorded: the archetype changed since the estate took or re-aligned it. Review it and re-pin
|
|
11
|
+
// (move `digest` and `version`), or record that the estate departs.
|
|
12
|
+
// A departed citation is never stale: departing is a decision, recorded with its reason, not drift. With no
|
|
13
|
+
// citations anywhere the check reports [N/A], an empty population, not a pass.
|
|
14
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { parse as parseYaml } from "yaml";
|
|
17
|
+
import { cachedVersion, CITATIONS_FILE, loadArchetypeBehaviour, RELATIONS } from "../scaffold/archetype-behaviour.js";
|
|
18
|
+
import { discoverArchetypes } from "../scaffold/registry.js";
|
|
19
|
+
const REQ_RE = /^###\s+Requirement:\s*(.+?)\s*$/gm;
|
|
20
|
+
async function list(dir) {
|
|
21
|
+
try {
|
|
22
|
+
return (await readdir(dir, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Every requirement title in the living specs and in open changes (a seeded change not yet archived counts). */
|
|
29
|
+
async function estateRequirements(repo) {
|
|
30
|
+
const titles = new Set();
|
|
31
|
+
const specFiles = [];
|
|
32
|
+
for (const d of await list(join(repo, "openspec", "specs")))
|
|
33
|
+
specFiles.push(join(repo, "openspec", "specs", d, "spec.md"));
|
|
34
|
+
for (const c of await list(join(repo, "openspec", "changes"))) {
|
|
35
|
+
if (c === "archive")
|
|
36
|
+
continue;
|
|
37
|
+
for (const d of await list(join(repo, "openspec", "changes", c, "specs"))) {
|
|
38
|
+
specFiles.push(join(repo, "openspec", "changes", c, "specs", d, "spec.md"));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
for (const f of specFiles) {
|
|
42
|
+
let text;
|
|
43
|
+
try {
|
|
44
|
+
text = await readFile(f, "utf8");
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
for (const m of text.matchAll(REQ_RE))
|
|
50
|
+
titles.add(m[1]);
|
|
51
|
+
}
|
|
52
|
+
return titles;
|
|
53
|
+
}
|
|
54
|
+
export async function checkArchetypeCitations(repo) {
|
|
55
|
+
const domains = await list(join(repo, "docs", "domains"));
|
|
56
|
+
const files = [];
|
|
57
|
+
for (const d of domains) {
|
|
58
|
+
const p = join(repo, "docs", "domains", d, CITATIONS_FILE);
|
|
59
|
+
try {
|
|
60
|
+
files.push([`docs/domains/${d}/${CITATIONS_FILE}`, await readFile(p, "utf8")]);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// no citations for this domain
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (files.length === 0)
|
|
67
|
+
return { exitCode: 0, stdout: "[N/A] no archetype citations (docs/domains/*/archetype-citations.yaml) in this repository\n" };
|
|
68
|
+
const archetypes = await discoverArchetypes(repo);
|
|
69
|
+
const titles = await estateRequirements(repo);
|
|
70
|
+
const lines = [];
|
|
71
|
+
let problems = 0;
|
|
72
|
+
let total = 0;
|
|
73
|
+
const bad = (tag, msg) => {
|
|
74
|
+
lines.push(`[${tag}] ${msg}`);
|
|
75
|
+
problems++;
|
|
76
|
+
};
|
|
77
|
+
for (const [rel, text] of files) {
|
|
78
|
+
let doc;
|
|
79
|
+
try {
|
|
80
|
+
doc = (parseYaml(text) ?? {});
|
|
81
|
+
}
|
|
82
|
+
catch (e) {
|
|
83
|
+
bad("unresolved", `${rel}: does not parse: ${e.message.split("\n")[0]}`);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const archetype = archetypes.find((a) => a.id === doc.archetype && a.bundleName === doc.bundle);
|
|
87
|
+
if (!archetype) {
|
|
88
|
+
bad("unresolved", `${rel}: archetype ${String(doc.archetype)} of bundle ${String(doc.bundle)} is not in the bundle cache (sync the bundle)`);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const reqs = await loadArchetypeBehaviour(archetype.bundleCacheDir, archetype.seeds);
|
|
92
|
+
const byId = new Map((reqs ?? []).map((r) => [r.id, r]));
|
|
93
|
+
const at = cachedVersion(archetype.bundleCacheDir) ?? "the cached bundle";
|
|
94
|
+
const cites = Array.isArray(doc.citations) ? doc.citations : [];
|
|
95
|
+
if (cites.length === 0)
|
|
96
|
+
bad("unresolved", `${rel}: lists no citations`);
|
|
97
|
+
for (const c of cites) {
|
|
98
|
+
total++;
|
|
99
|
+
const tag = `${rel}: '${String(c.requirement)}' -> ${String(c.cites)}`;
|
|
100
|
+
const relation = String(c.relation);
|
|
101
|
+
if (!RELATIONS.includes(relation)) {
|
|
102
|
+
bad("unresolved", `${tag}: relation must be one of ${RELATIONS.join(", ")} (got ${relation})`);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (relation === "departed" && (!c.reason || !c.decision)) {
|
|
106
|
+
bad("unresolved", `${tag}: a departure needs its reason and the decision that took it`);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (relation !== "departed" && !titles.has(String(c.requirement))) {
|
|
110
|
+
bad("unresolved", `${tag}: this estate has no requirement titled so (living specs or open changes)`);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const src = byId.get(String(c.cites));
|
|
114
|
+
if (!src) {
|
|
115
|
+
bad("unresolved", `${tag}: ${archetype.bundleName} ${at} has no archetype requirement ${String(c.cites)}`);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (relation === "departed") {
|
|
119
|
+
lines.push(`[ok] ${tag} (departed: ${String(c.reason)})`);
|
|
120
|
+
}
|
|
121
|
+
else if (src.digest !== String(c.digest)) {
|
|
122
|
+
bad("stale", `${tag}: the archetype requirement changed (${String(c.digest)} at ${String(doc.version)} -> ${src.digest} at ${at}); review it and re-pin, or record a departure`);
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
lines.push(`[ok] ${tag} (${relation})`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const verdict = problems
|
|
130
|
+
? `[FAIL] archetype citations: ${problems} of ${total} unresolved or stale`
|
|
131
|
+
: `[PASS] archetype citations: ${total} resolve, none stale`;
|
|
132
|
+
return { exitCode: problems ? 1 : 0, stdout: lines.join("\n") + "\n" + verdict + "\n" };
|
|
133
|
+
}
|
|
134
|
+
//# sourceMappingURL=archetype-citations.js.map
|
|
@@ -20,6 +20,11 @@
|
|
|
20
20
|
// never against itself (M-ADR 0222 §4.3, §4.4). A list the catalogue gives nothing to
|
|
21
21
|
// check against (no Observed in line, or a repository with no catalogue contracts) fails:
|
|
22
22
|
// a cross-check of zero contracts is unresolved, not a pass.
|
|
23
|
+
// 7. features: every living spec is keyed by a catalogue feature (M-ADR 0221 §4.7). A spec keyed by no
|
|
24
|
+
// feature is dropped from the catalogue by the requirements builder, which reports it only as a
|
|
25
|
+
// build finding; this check makes it a failure, so a shipped requirement cannot vanish from the
|
|
26
|
+
// catalogue with every check green. Without catalogues/features.csv the check is unresolved, not
|
|
27
|
+
// passed.
|
|
23
28
|
// 6. scope: a baseline ADDS requirements; it REMOVES, MODIFIES or RENAMES only a requirement whose
|
|
24
29
|
// latest archived change was a baseline, which is how a corrective baseline withdraws
|
|
25
30
|
// what a baseline admitted wrongly. A requirement a judged change shipped is changed only
|
|
@@ -354,6 +359,25 @@ export async function checkBehaviourStore(repo) {
|
|
|
354
359
|
lines.push(scopeChecked === 0 ? "scope: vacuous (no baseline removes, modifies or renames a requirement)" : `scope: ${scopeChecked} requirement(s) a baseline removes, modifies or renames, checked against their lineage`);
|
|
355
360
|
for (const [a, b] of ties)
|
|
356
361
|
lines.push(`order: ${a} and ${b} were archived the same day and the history does not order them; ordered by name`);
|
|
362
|
+
// 7. living specs keyed by a catalogue feature
|
|
363
|
+
const livingSpecs = await dirs(join(repo, STORE_DIR, "specs"));
|
|
364
|
+
if (livingSpecs.length > 0) {
|
|
365
|
+
const featuresCsv = await readText(join(repo, "catalogues", "features.csv"));
|
|
366
|
+
if (featuresCsv === null) {
|
|
367
|
+
failures.push(`${STORE_DIR}/specs: living specs cannot be checked against catalogue features: catalogues/features.csv is missing; build the catalogue`);
|
|
368
|
+
}
|
|
369
|
+
else {
|
|
370
|
+
const featureIds = new Set(parseCatalogueCsv(featuresCsv).rows.map((r) => r["id"] ?? ""));
|
|
371
|
+
const unkeyed = livingSpecs.filter((f) => !featureIds.has(f));
|
|
372
|
+
for (const f of unkeyed) {
|
|
373
|
+
failures.push(`${STORE_DIR}/specs/${f}: no catalogue feature has the id "${f}", so its requirements are not catalogued; give the behaviour a feature first (M-ADR 0222 §4.4)`);
|
|
374
|
+
}
|
|
375
|
+
lines.push(`features: ${livingSpecs.length} living spec(s), ${livingSpecs.length - unkeyed.length} keyed by a catalogue feature`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
lines.push("features: vacuous (no living spec)");
|
|
380
|
+
}
|
|
357
381
|
const population = recordFiles.length + open.length + archived.length;
|
|
358
382
|
const body = lines.map((l) => ` ${l}`).join("\n") + "\n";
|
|
359
383
|
if (failures.length > 0) {
|
|
@@ -361,7 +385,7 @@ export async function checkBehaviourStore(repo) {
|
|
|
361
385
|
}
|
|
362
386
|
if (population === 0)
|
|
363
387
|
return { exitCode: 0, stdout: body + `[N/A] the behaviour store has no changes and no agreement records yet\n` };
|
|
364
|
-
return { exitCode: 0, stdout: body + "[PASS] behaviour store: records, agreement, stale, verdict, surfaces and
|
|
388
|
+
return { exitCode: 0, stdout: body + "[PASS] behaviour store: records, agreement, stale, verdict, surfaces, scope and feature checks hold\n" };
|
|
365
389
|
}
|
|
366
390
|
/** Per repository, the contracts the catalogue says it serves (repo_contract ⋈ contracts.spec_path); null when either file is missing. */
|
|
367
391
|
async function catalogueContracts(repo) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { checkArchetypeCitations } from "./archetype-citations.js";
|
|
1
2
|
import { checkBehaviourStore, checkBehaviourStoreOpenspec } from "./behaviour-store.js";
|
|
2
3
|
import { checkBundleCoherence } from "./bundle-coherence.js";
|
|
3
4
|
import { checkYamlParse } from "./yaml-parse.js";
|
|
@@ -14,6 +15,12 @@ const BEHAVIOUR_STORE_VALIDATORS = [
|
|
|
14
15
|
source: { kind: "native", run: async (ctx) => checkBehaviourStoreOpenspec(ctx.targetRepo) },
|
|
15
16
|
build: () => ({ status: "run", args: [] }),
|
|
16
17
|
},
|
|
18
|
+
// Seed once, then cite: requirements an estate took from a reference-architecture archetype still answer to it.
|
|
19
|
+
{
|
|
20
|
+
name: "archetype-citations",
|
|
21
|
+
source: { kind: "native", run: async (ctx) => checkArchetypeCitations(ctx.targetRepo) },
|
|
22
|
+
build: () => ({ status: "run", args: [] }),
|
|
23
|
+
},
|
|
17
24
|
];
|
|
18
25
|
// Validators that accept --app-root / --repo-root flags work fine from the
|
|
19
26
|
// bundled location because they override their internal ROOT_DIR via the
|
|
@@ -92,7 +99,7 @@ const APP_VALIDATORS = [
|
|
|
92
99
|
...BEHAVIOUR_STORE_VALIDATORS,
|
|
93
100
|
// Note: validate-runway-classification is intentionally NOT in the app set.
|
|
94
101
|
// It requires platform-level files (docs/platform/feature-tree.md,
|
|
95
|
-
// architecture-runway-index.md) that app repos don't own. Per ADR 0089,
|
|
102
|
+
// architecture-runway-index.md) that app repos don't own. Per M-ADR 0089,
|
|
96
103
|
// the reduced app suite is app-specific validators + shared foundations,
|
|
97
104
|
// not platform validators.
|
|
98
105
|
];
|
package/dist/verbs/list.js
CHANGED
|
@@ -7,7 +7,7 @@ const VALID_PROFILES = [
|
|
|
7
7
|
export function registerList(program) {
|
|
8
8
|
program
|
|
9
9
|
.command("list")
|
|
10
|
-
.description("Enumerate SDD repositories discoverable in the current workspace, including adopter pack provenance (ADR 0151)")
|
|
10
|
+
.description("Enumerate SDD repositories discoverable in the current workspace, including adopter pack provenance (M-ADR 0151)")
|
|
11
11
|
.option("--profile <profile>", "filter by profile: platform | application | integration")
|
|
12
12
|
.option("--workspace-root <path>", "workspace directory to walk (repeatable; defaults to cwd)", collectRoots, [])
|
|
13
13
|
.option("--format <format>", "output format: text | json", "text")
|
package/dist/verify-program.js
CHANGED
|
@@ -109,11 +109,11 @@ export function registerVerifySurface(program) {
|
|
|
109
109
|
registerList(program);
|
|
110
110
|
const pack = program
|
|
111
111
|
.command("pack")
|
|
112
|
-
.description("Validate adopter-shipped context-pack seeds (ADR 0151) — read-only");
|
|
112
|
+
.description("Validate adopter-shipped context-pack seeds (M-ADR 0151) — read-only");
|
|
113
113
|
registerPackValidate(pack);
|
|
114
114
|
const catalogue = program
|
|
115
115
|
.command("catalogue")
|
|
116
|
-
.description("Validate and query per-SDD catalogues (ADR 0134) — read-only, never rebuilds");
|
|
116
|
+
.description("Validate and query per-SDD catalogues (M-ADR 0134) — read-only, never rebuilds");
|
|
117
117
|
registerCatalogueValidate(catalogue);
|
|
118
118
|
registerCatalogueImpact(catalogue);
|
|
119
119
|
registerCatalogueBaselineCheck(catalogue);
|
package/dist/verify-version.json
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdd-method/verify-cli",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"sddCliVersion": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
|
+
"sddCliVersion": "2.3.0",
|
|
5
5
|
"description": "Verify an SDD repository without changing it — validate, list, and read-only catalogue and pack checks. Ships only the code those verbs reach; it cannot write to the repository it is pointed at. Authoring and maintenance are in @sdd-method/sdd-cli.",
|
|
6
6
|
"license": "UNLICENSED",
|
|
7
7
|
"type": "module",
|
|
@@ -149,7 +149,7 @@ fi
|
|
|
149
149
|
APP_DEPS_FILE="$APP_ROOT/app-platform-dependencies.yaml"
|
|
150
150
|
|
|
151
151
|
# TWO AUTHORING SOURCES, NOT ONE.
|
|
152
|
-
# The catalogue builder does multi-source ingestion under the ADR 0157 parity
|
|
152
|
+
# The catalogue builder does multi-source ingestion under the M-ADR 0157 parity
|
|
153
153
|
# principle: `app-platform-dependencies.yaml` AND every
|
|
154
154
|
# `reverse-evidence/**/feature-grounding-evidence.yaml`'s platform_dependencies
|
|
155
155
|
# block, merged by primary key with forward winning. They are equal-status
|
|
@@ -160,7 +160,7 @@ check_manifest() {
|
|
|
160
160
|
;;
|
|
161
161
|
esac
|
|
162
162
|
|
|
163
|
-
# Interaction patterns (ADR 0171) — optional fields, validated only when present.
|
|
163
|
+
# Interaction patterns (M-ADR 0171) — optional fields, validated only when present.
|
|
164
164
|
# Existing manifests predate the field; absence is not a failure. Directionality
|
|
165
165
|
# mismatch (pattern present but the matching capability flag is false) is a warning.
|
|
166
166
|
local ingestion invocation
|