@sdd-method/verify-cli 2.3.0 → 2.5.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/behaviour-store/archive.js +28 -1
- package/dist/lib/behaviour-store/schema.js +85 -0
- package/dist/lib/catalogue/canonical-schema.yaml +138 -2
- package/dist/lib/catalogue/rubric-coverage-map.yaml +28 -0
- package/dist/lib/intents/format.js +77 -0
- package/dist/lib/repo-kind.js +1 -9
- package/dist/lib/scaffold/archetype-behaviour.js +6 -4
- package/dist/lib/validate/archetype-citations.js +37 -2
- package/dist/lib/validate/behaviour-store.js +137 -6
- package/dist/lib/validate/dispatch.js +7 -0
- package/dist/lib/validate/intents.js +76 -0
- package/dist/maintain-version.json +2 -2
- package/dist/verify-version.json +2 -2
- package/package.json +2 -2
|
@@ -64,6 +64,33 @@ export function renamedFrom(text) {
|
|
|
64
64
|
}
|
|
65
65
|
return out;
|
|
66
66
|
}
|
|
67
|
+
/** The FROM -> TO pairs of a RENAMED section, in file order. */
|
|
68
|
+
export function renamePairs(text) {
|
|
69
|
+
const out = [];
|
|
70
|
+
let op = null;
|
|
71
|
+
let from = null;
|
|
72
|
+
for (const line of text.split("\n")) {
|
|
73
|
+
const section = /^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements\b/.exec(line);
|
|
74
|
+
if (section) {
|
|
75
|
+
op = section[1];
|
|
76
|
+
from = null;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (op !== "RENAMED")
|
|
80
|
+
continue;
|
|
81
|
+
const f = /^\s*-\s*FROM:\s*`?###\s+Requirement:\s*(.+?)`?\s*$/.exec(line);
|
|
82
|
+
if (f) {
|
|
83
|
+
from = f[1].trim();
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const t = /^\s*-\s*TO:\s*`?###\s+Requirement:\s*(.+?)`?\s*$/.exec(line);
|
|
87
|
+
if (t && from !== null) {
|
|
88
|
+
out.push({ from, to: t[1].trim() });
|
|
89
|
+
from = null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
67
94
|
/** Each archive directory's position in the SDD's history: the index of the first commit that added a file under it. */
|
|
68
95
|
async function archiveHistory(sddPath, archiveRel) {
|
|
69
96
|
let stdout;
|
|
@@ -100,7 +127,7 @@ export async function orderArchives(sddPath, archiveRel, names) {
|
|
|
100
127
|
if (date(a) === date(b) && position(a) === position(b))
|
|
101
128
|
ties.push([a, b]);
|
|
102
129
|
}
|
|
103
|
-
return { order, ties };
|
|
130
|
+
return { order, ties, history: history !== null };
|
|
104
131
|
}
|
|
105
132
|
/** The repositories a baseline was observed in: its evidence file's `**Observed in:** repo[@sha], repo[@sha]` line. */
|
|
106
133
|
export function observedIn(evidence) {
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// The method's OpenSpec change schema (M-ADR 0223 §4.2) and its copy in a behaviour store.
|
|
2
|
+
//
|
|
3
|
+
// The method ships the schema inert, at docs/method/foundations/openspec/sdd-change/, because
|
|
4
|
+
// sync writes an SDD's managed paths and a store is created only when its SDD first needs one
|
|
5
|
+
// (M-ADR 0221 §4.2): a managed path at openspec/ would create a store in every SDD.
|
|
6
|
+
// OpenSpec reads a project schema only from <store>/openspec/schemas/<name>/, so the store
|
|
7
|
+
// holds a copy. The copy is method-managed: sync refreshes it, scaffold writes it, and the
|
|
8
|
+
// behaviour-store validator fails when it differs. Adopter guidance goes in the store's
|
|
9
|
+
// config.yaml (`context:`, per-artifact `rules:`), never in the copy.
|
|
10
|
+
import { cp, mkdir, readdir, readFile, rm } from "node:fs/promises";
|
|
11
|
+
import { join, relative } from "node:path";
|
|
12
|
+
import { parseDocument } from "yaml";
|
|
13
|
+
export const SCHEMA_NAME = "sdd-change";
|
|
14
|
+
export const SHIPPED_SCHEMA_DIR = "docs/method/foundations/openspec/sdd-change";
|
|
15
|
+
export const SHIPPED_CONFIG_TEMPLATE = "docs/method/foundations/openspec/config-template.yaml";
|
|
16
|
+
export const STORE_SCHEMA_DIR = `openspec/schemas/${SCHEMA_NAME}`;
|
|
17
|
+
export const STORE_CONFIG = "openspec/config.yaml";
|
|
18
|
+
async function files(dir) {
|
|
19
|
+
const out = [];
|
|
20
|
+
async function walk(d) {
|
|
21
|
+
for (const e of await readdir(d, { withFileTypes: true })) {
|
|
22
|
+
const p = join(d, e.name);
|
|
23
|
+
if (e.isDirectory())
|
|
24
|
+
await walk(p);
|
|
25
|
+
else if (e.isFile())
|
|
26
|
+
out.push(relative(dir, p));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
await walk(dir);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
return out.sort();
|
|
36
|
+
}
|
|
37
|
+
async function read(p) {
|
|
38
|
+
try {
|
|
39
|
+
return await readFile(p);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** The schema the store's config.yaml selects: its name, null without a config, or "" without a `schema:` key. */
|
|
46
|
+
export async function storeSchemaName(repo) {
|
|
47
|
+
const text = await read(join(repo, STORE_CONFIG));
|
|
48
|
+
if (text === null)
|
|
49
|
+
return null;
|
|
50
|
+
const v = parseDocument(text.toString("utf8")).toJS()?.schema;
|
|
51
|
+
return typeof v === "string" ? v : "";
|
|
52
|
+
}
|
|
53
|
+
/** Compare the store's copy of the schema with the one the SDD's method bundle shipped, file by file. */
|
|
54
|
+
export async function compareStoreSchema(repo) {
|
|
55
|
+
const shipped = await files(join(repo, SHIPPED_SCHEMA_DIR));
|
|
56
|
+
if (shipped === null || shipped.length === 0)
|
|
57
|
+
return { shipped: false, missing: [], differing: [], extra: [] };
|
|
58
|
+
const store = (await files(join(repo, STORE_SCHEMA_DIR))) ?? [];
|
|
59
|
+
const missing = shipped.filter((f) => !store.includes(f));
|
|
60
|
+
const extra = store.filter((f) => !shipped.includes(f));
|
|
61
|
+
const differing = [];
|
|
62
|
+
for (const f of shipped.filter((x) => store.includes(x))) {
|
|
63
|
+
const a = await read(join(repo, SHIPPED_SCHEMA_DIR, f));
|
|
64
|
+
const b = await read(join(repo, STORE_SCHEMA_DIR, f));
|
|
65
|
+
if (a === null || b === null || !a.equals(b))
|
|
66
|
+
differing.push(f);
|
|
67
|
+
}
|
|
68
|
+
return { shipped: true, missing, differing, extra };
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Replace the store's copy of the schema with the shipped one. Returns what changed, as a
|
|
72
|
+
* comparison taken before the write; a store already current returns empty lists.
|
|
73
|
+
*/
|
|
74
|
+
export async function writeStoreSchema(repo) {
|
|
75
|
+
const before = await compareStoreSchema(repo);
|
|
76
|
+
if (!before.shipped)
|
|
77
|
+
return before;
|
|
78
|
+
if (before.missing.length + before.differing.length + before.extra.length === 0)
|
|
79
|
+
return before;
|
|
80
|
+
await rm(join(repo, STORE_SCHEMA_DIR), { recursive: true, force: true });
|
|
81
|
+
await mkdir(join(repo, STORE_SCHEMA_DIR), { recursive: true });
|
|
82
|
+
await cp(join(repo, SHIPPED_SCHEMA_DIR), join(repo, STORE_SCHEMA_DIR), { recursive: true });
|
|
83
|
+
return before;
|
|
84
|
+
}
|
|
85
|
+
//# sourceMappingURL=schema.js.map
|
|
@@ -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.2.0"
|
|
11
11
|
schema_owner: "sdd-method"
|
|
12
12
|
schema_status: "generated"
|
|
13
13
|
enums:
|
|
@@ -643,6 +643,31 @@ enums:
|
|
|
643
643
|
- value: observed
|
|
644
644
|
description: Independent evidence from this repository's code matches (a baseline's characterization tests or a reverse snapshot).
|
|
645
645
|
description: A requirement's status in one implementing repository (M-ADR 0221 §4.5).
|
|
646
|
+
change_status:
|
|
647
|
+
values:
|
|
648
|
+
- value: proposed
|
|
649
|
+
description: Open, with a proposed agreement record or none yet.
|
|
650
|
+
- value: agreed
|
|
651
|
+
description: "Open, with an agreed record: being built."
|
|
652
|
+
- value: superseded
|
|
653
|
+
description: Open, its record superseded by another change.
|
|
654
|
+
- value: withdrawn
|
|
655
|
+
description: Open, its record withdrawn.
|
|
656
|
+
- value: archived
|
|
657
|
+
description: Archived into the living specs.
|
|
658
|
+
description: Where a change stands (M-ADR 0222 §4.6, M-ADR 0223 §4.6).
|
|
659
|
+
judge_verdict:
|
|
660
|
+
values:
|
|
661
|
+
- value: PASS
|
|
662
|
+
- value: FAIL
|
|
663
|
+
description: The judge's verdict on a change (M-ADR 0223 §4.5).
|
|
664
|
+
work_order_status:
|
|
665
|
+
values:
|
|
666
|
+
- value: agreed
|
|
667
|
+
description: Agreed; the repository's work is not yet judged.
|
|
668
|
+
- value: completed
|
|
669
|
+
description: Marked completed by a passing judge.
|
|
670
|
+
description: A work order's status in its change's agreement record (M-ADR 0223 §4.2, §4.6).
|
|
646
671
|
catalogues:
|
|
647
672
|
domains:
|
|
648
673
|
primary_key: id
|
|
@@ -4200,7 +4225,7 @@ catalogues:
|
|
|
4200
4225
|
- name: change_id
|
|
4201
4226
|
type: string
|
|
4202
4227
|
nullable: false
|
|
4203
|
-
description: The archived change's directory name (its archive date prefix included).
|
|
4228
|
+
description: The archived change's directory name (its archive date prefix included); a changes.id.
|
|
4204
4229
|
- name: change_kind
|
|
4205
4230
|
type: string
|
|
4206
4231
|
nullable: false
|
|
@@ -4226,6 +4251,9 @@ catalogues:
|
|
|
4226
4251
|
- column: requirement_id
|
|
4227
4252
|
references: requirements.id
|
|
4228
4253
|
nullable: false
|
|
4254
|
+
- column: change_id
|
|
4255
|
+
references: changes.id
|
|
4256
|
+
nullable: false
|
|
4229
4257
|
column_enums:
|
|
4230
4258
|
change_kind: enums.requirement_change_kind
|
|
4231
4259
|
operation: enums.requirement_operation
|
|
@@ -4269,3 +4297,111 @@ catalogues:
|
|
|
4269
4297
|
nullable: false
|
|
4270
4298
|
column_enums:
|
|
4271
4299
|
status: enums.requirement_status_value
|
|
4300
|
+
changes:
|
|
4301
|
+
primary_key: id
|
|
4302
|
+
columns:
|
|
4303
|
+
- name: id
|
|
4304
|
+
type: string
|
|
4305
|
+
nullable: false
|
|
4306
|
+
description: "The change's directory name: the open change's name, or the archived change's directory (its archive date prefix included). requirement_lineage.change_id references it."
|
|
4307
|
+
- name: name
|
|
4308
|
+
type: string
|
|
4309
|
+
nullable: false
|
|
4310
|
+
description: "The change's name without the archive date prefix: the same before and after archive, so a work item that cites the change keeps its link."
|
|
4311
|
+
- name: change_kind
|
|
4312
|
+
type: string
|
|
4313
|
+
nullable: false
|
|
4314
|
+
description: "Bound to requirement_change_kind: baseline (it carries baseline-evidence.md) or change."
|
|
4315
|
+
- name: status
|
|
4316
|
+
type: string
|
|
4317
|
+
nullable: false
|
|
4318
|
+
description: "Bound to change_status: archived for an archived change; otherwise the agreement record's status, or proposed when there is no record yet."
|
|
4319
|
+
- name: archived_on
|
|
4320
|
+
type: string
|
|
4321
|
+
nullable: true
|
|
4322
|
+
description: The archive date (YYYY-MM-DD) from the archived change's directory name; empty for an open change.
|
|
4323
|
+
- name: verdict
|
|
4324
|
+
type: string
|
|
4325
|
+
nullable: true
|
|
4326
|
+
description: "Bound to judge_verdict: the agreement record's verdict result (M-ADR 0223 §4.6). Empty for a baseline, and for a change not yet judged."
|
|
4327
|
+
- name: source_sdd_id
|
|
4328
|
+
type: string
|
|
4329
|
+
nullable: true
|
|
4330
|
+
description: Originating SDD (stamped at aggregation; empty in a per-SDD build).
|
|
4331
|
+
- name: platform_sdd_id
|
|
4332
|
+
type: string
|
|
4333
|
+
nullable: true
|
|
4334
|
+
description: The platform SDD the row belongs to (stamped at aggregation).
|
|
4335
|
+
description: "A change in the SDD's behaviour store (M-ADR 0223 §4.2), open or archived, including baseline changes. Derived, never authored: from the store's change directories and their agreement records (M-ADR 0222 §4.6)."
|
|
4336
|
+
column_enums:
|
|
4337
|
+
change_kind: enums.requirement_change_kind
|
|
4338
|
+
status: enums.change_status
|
|
4339
|
+
verdict: enums.judge_verdict
|
|
4340
|
+
change_work_order:
|
|
4341
|
+
primary_key: id
|
|
4342
|
+
columns:
|
|
4343
|
+
- name: id
|
|
4344
|
+
type: string
|
|
4345
|
+
nullable: false
|
|
4346
|
+
description: <change_id>__<repo_id>.
|
|
4347
|
+
- name: change_id
|
|
4348
|
+
type: string
|
|
4349
|
+
nullable: false
|
|
4350
|
+
- name: repo_id
|
|
4351
|
+
type: string
|
|
4352
|
+
nullable: false
|
|
4353
|
+
description: The repository the work order is for.
|
|
4354
|
+
- name: status
|
|
4355
|
+
type: string
|
|
4356
|
+
nullable: false
|
|
4357
|
+
description: "Bound to work_order_status: the record's status for the work order; a judge PASS marks it completed (M-ADR 0223 §4.6)."
|
|
4358
|
+
- name: source_sdd_id
|
|
4359
|
+
type: string
|
|
4360
|
+
nullable: true
|
|
4361
|
+
description: Originating SDD (stamped at aggregation; empty in a per-SDD build).
|
|
4362
|
+
- name: platform_sdd_id
|
|
4363
|
+
type: string
|
|
4364
|
+
nullable: true
|
|
4365
|
+
description: The platform SDD the row belongs to (stamped at aggregation).
|
|
4366
|
+
description: "Edge: a change's work order for one implementing repository (M-ADR 0223 §4.2), from the change's agreement record. Derived, never authored."
|
|
4367
|
+
foreign_keys:
|
|
4368
|
+
- column: change_id
|
|
4369
|
+
references: changes.id
|
|
4370
|
+
nullable: false
|
|
4371
|
+
- column: repo_id
|
|
4372
|
+
references: repositories.id
|
|
4373
|
+
nullable: false
|
|
4374
|
+
column_enums:
|
|
4375
|
+
status: enums.work_order_status
|
|
4376
|
+
change_work_item:
|
|
4377
|
+
primary_key: id
|
|
4378
|
+
columns:
|
|
4379
|
+
- name: id
|
|
4380
|
+
type: string
|
|
4381
|
+
nullable: false
|
|
4382
|
+
description: "<change_name>__<work_item_id>: keyed by the change's undated name, so the row is the same before and after archive."
|
|
4383
|
+
- name: change_id
|
|
4384
|
+
type: string
|
|
4385
|
+
nullable: false
|
|
4386
|
+
description: "The change as it stands now: its open name, or its archive directory."
|
|
4387
|
+
- name: change_name
|
|
4388
|
+
type: string
|
|
4389
|
+
nullable: false
|
|
4390
|
+
description: The change's undated name.
|
|
4391
|
+
- name: work_item_id
|
|
4392
|
+
type: string
|
|
4393
|
+
nullable: false
|
|
4394
|
+
description: The work item's id in the adopter's work-item tool, as the proposal's Work Items section gives it.
|
|
4395
|
+
- name: source_sdd_id
|
|
4396
|
+
type: string
|
|
4397
|
+
nullable: true
|
|
4398
|
+
description: Originating SDD (stamped at aggregation; empty in a per-SDD build).
|
|
4399
|
+
- name: platform_sdd_id
|
|
4400
|
+
type: string
|
|
4401
|
+
nullable: true
|
|
4402
|
+
description: The platform SDD the row belongs to (stamped at aggregation).
|
|
4403
|
+
description: "Annotation: a work item a change's proposal says it delivers (M-ADR 0223 §4.2), giving the roll-up from work item to changes to requirements. The work item's id is the adopter's work-item tool's (M-ADR 0035), so it has no foreign key."
|
|
4404
|
+
foreign_keys:
|
|
4405
|
+
- column: change_id
|
|
4406
|
+
references: changes.id
|
|
4407
|
+
nullable: false
|
|
@@ -905,3 +905,31 @@ coverage:
|
|
|
905
905
|
kind: enum
|
|
906
906
|
scoring: not-applicable
|
|
907
907
|
reason: closed vocabulary, not an artefact
|
|
908
|
+
changes:
|
|
909
|
+
kind: entity
|
|
910
|
+
scoring: not-applicable
|
|
911
|
+
reason: projected from the behaviour store, whose change gate and judge hold its quality (M-ADR 0222, M-ADR 0223)
|
|
912
|
+
change_work_order:
|
|
913
|
+
kind: edge
|
|
914
|
+
scoring: derived-edge
|
|
915
|
+
class: realisation
|
|
916
|
+
impact_flow: both
|
|
917
|
+
reason: derived from agreement records and verdicts
|
|
918
|
+
change_work_item:
|
|
919
|
+
kind: annotation
|
|
920
|
+
scoring: derived-edge
|
|
921
|
+
class: traceability
|
|
922
|
+
impact_flow: none
|
|
923
|
+
reason: derived from each change's proposal
|
|
924
|
+
change_status:
|
|
925
|
+
kind: enum
|
|
926
|
+
scoring: not-applicable
|
|
927
|
+
reason: closed vocabulary, not an artefact
|
|
928
|
+
judge_verdict:
|
|
929
|
+
kind: enum
|
|
930
|
+
scoring: not-applicable
|
|
931
|
+
reason: closed vocabulary, not an artefact
|
|
932
|
+
work_order_status:
|
|
933
|
+
kind: enum
|
|
934
|
+
scoring: not-applicable
|
|
935
|
+
reason: closed vocabulary, not an artefact
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// The intent artefact's shape (M-ADR 0227 §4.1), shared by the migration that writes intents and the check
|
|
2
|
+
// that reads them. The section names and header lines are the shipped template's
|
|
3
|
+
// (docs/method/methods/templates/intent-template.md), so a change there is a change here.
|
|
4
|
+
export const INTENT_DIRS = ["content/method/intents", "content/product/intents"];
|
|
5
|
+
export const INTENT_SECTIONS = [
|
|
6
|
+
"Problem",
|
|
7
|
+
"Proposed outcome",
|
|
8
|
+
"Affected users and systems",
|
|
9
|
+
"Constraints",
|
|
10
|
+
"Open questions",
|
|
11
|
+
"Decision to resolve in exploration",
|
|
12
|
+
"ADR requirement",
|
|
13
|
+
"Where it went",
|
|
14
|
+
];
|
|
15
|
+
/** Header fields of the retired Idea and the idea map: planning state, which belongs in the work item (§4.3). */
|
|
16
|
+
export const PLANNING_FIELDS = [
|
|
17
|
+
"Status",
|
|
18
|
+
"Priority",
|
|
19
|
+
"Blocked by",
|
|
20
|
+
"Enables",
|
|
21
|
+
"Parent Idea",
|
|
22
|
+
"Child Ideas",
|
|
23
|
+
"Idea ID",
|
|
24
|
+
"Epic ID",
|
|
25
|
+
"Theme",
|
|
26
|
+
];
|
|
27
|
+
/** A "Where it went" line naming a path: "- `<path>` (<sdd-id>; <source>)". */
|
|
28
|
+
export const WHERE_PATH_LINE = /^-\s+`([^`]+)`\s+\(([^;)]+);\s*([^)]+)\)\s*$/;
|
|
29
|
+
/** "## 3. Why Explore This (Revised)" -> "why explore this". */
|
|
30
|
+
export function normaliseHeading(h) {
|
|
31
|
+
return h
|
|
32
|
+
.replace(/^\s*\d+[A-Za-z]?(\.\d+)*\.?\s*/, "")
|
|
33
|
+
.replace(/\((revised|updated)\)\s*$/i, "")
|
|
34
|
+
.trim()
|
|
35
|
+
.toLowerCase();
|
|
36
|
+
}
|
|
37
|
+
export function parseMarkdown(text) {
|
|
38
|
+
const lines = text.split("\n");
|
|
39
|
+
let title = "";
|
|
40
|
+
const fields = new Map();
|
|
41
|
+
const sections = new Map();
|
|
42
|
+
const headings = [];
|
|
43
|
+
let current = null;
|
|
44
|
+
let buf = [];
|
|
45
|
+
const flush = () => {
|
|
46
|
+
if (current !== null) {
|
|
47
|
+
const prev = sections.get(current);
|
|
48
|
+
const body = buf.join("\n").trim();
|
|
49
|
+
sections.set(current, prev ? `${prev}\n\n${body}` : body);
|
|
50
|
+
}
|
|
51
|
+
buf = [];
|
|
52
|
+
};
|
|
53
|
+
for (const line of lines) {
|
|
54
|
+
const h1 = /^#\s+(.+?)\s*$/.exec(line);
|
|
55
|
+
if (h1 && !title && current === null) {
|
|
56
|
+
title = h1[1];
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const h2 = /^##\s+(.+?)\s*$/.exec(line);
|
|
60
|
+
if (h2) {
|
|
61
|
+
flush();
|
|
62
|
+
headings.push(h2[1]);
|
|
63
|
+
current = normaliseHeading(h2[1]);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (current === null) {
|
|
67
|
+
const f = /^\*\*([^*:]+):\*\*\s*(.*?)\s*$/.exec(line);
|
|
68
|
+
if (f)
|
|
69
|
+
fields.set(f[1].trim(), f[2]);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
buf.push(line);
|
|
73
|
+
}
|
|
74
|
+
flush();
|
|
75
|
+
return { title, fields, sections, headings };
|
|
76
|
+
}
|
|
77
|
+
//# sourceMappingURL=format.js.map
|
package/dist/lib/repo-kind.js
CHANGED
|
@@ -14,13 +14,6 @@ const FORWARD_BASE_BUNDLES = new Set([
|
|
|
14
14
|
"reverse-baseline",
|
|
15
15
|
]);
|
|
16
16
|
export const REPO_KIND_FILE = ".sdd-repo-kind";
|
|
17
|
-
/**
|
|
18
|
-
* Legacy marker filename from the pre-rebrand era. Read-only fallback during
|
|
19
|
-
* the rebrand transition: `readRepoKind` accepts either filename; `sync`
|
|
20
|
-
* auto-migrates legacy → canonical. New writes always use `REPO_KIND_FILE`.
|
|
21
|
-
* Drop after one minor-version overlap.
|
|
22
|
-
*/
|
|
23
|
-
export const LEGACY_REPO_KIND_FILE = ".iteraitive-repo-kind";
|
|
24
17
|
const PROFILE_BY_REPO_KIND = {
|
|
25
18
|
"platform-product": "platform",
|
|
26
19
|
"application-product": "application",
|
|
@@ -103,8 +96,7 @@ export function isBundleName(value) {
|
|
|
103
96
|
return ALL_BUNDLE_NAMES.has(value);
|
|
104
97
|
}
|
|
105
98
|
export async function readRepoKind(repoRoot) {
|
|
106
|
-
|
|
107
|
-
for (const filename of [REPO_KIND_FILE, LEGACY_REPO_KIND_FILE]) {
|
|
99
|
+
for (const filename of [REPO_KIND_FILE]) {
|
|
108
100
|
const path = join(repoRoot, filename);
|
|
109
101
|
let raw;
|
|
110
102
|
try {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Archetype behaviour, seeded once and then cited
|
|
1
|
+
// Archetype behaviour, seeded once and then cited.
|
|
2
2
|
//
|
|
3
3
|
// A reference-architecture archetype ships its behaviour as canonical rows (behaviour.tql, rows of the
|
|
4
4
|
// behaviour schema in the M-ADR 0210 subset). An estate does not sync them: it takes them ONCE, when it
|
|
@@ -131,7 +131,7 @@ async function writeAt(path, content) {
|
|
|
131
131
|
export async function seedArchetypeBehaviour(input) {
|
|
132
132
|
const store = join(input.targetRepo, "openspec");
|
|
133
133
|
if (!(await exists(store)))
|
|
134
|
-
return { filesWritten: [], skipped: "no behaviour store (openspec/) in this repository" };
|
|
134
|
+
return { filesWritten: [], skipped: "no behaviour store (openspec/) in this repository; `sdd-cli scaffold behaviour-store` creates one" };
|
|
135
135
|
const reqs = await loadArchetypeBehaviour(input.bundleCacheDir, input.seeds);
|
|
136
136
|
if (!reqs || reqs.length === 0)
|
|
137
137
|
return { filesWritten: [], skipped: `archetype ${input.archetypeId} ships no behaviour rows` };
|
|
@@ -154,8 +154,10 @@ export async function seedArchetypeBehaviour(input) {
|
|
|
154
154
|
`# ${CITATIONS_FILE} — the ${input.domain} domain's requirements that answer to reference-architecture archetype requirements.`,
|
|
155
155
|
"# Seeded once from the archetype; the requirements are this estate's. Per citation: `requirement` is this estate's",
|
|
156
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
|
|
158
|
-
"# `
|
|
157
|
+
"# derived | extended | narrowed | departed (departed needs `reason` and `decision`; narrowed and extended may give",
|
|
158
|
+
"# a `reason`). Keep `digest` quoted. An archetype requirement this estate decides not to take goes under",
|
|
159
|
+
"# `not_adopted:` as {cites, reason, decision}. Checked by `sdd-cli validate --validator archetype-citations`: a",
|
|
160
|
+
"# changed source is reported until reviewed and re-pinned, and requirements neither cited nor not adopted are listed.",
|
|
159
161
|
"",
|
|
160
162
|
].join("\n");
|
|
161
163
|
const files = [
|
|
@@ -11,6 +11,13 @@
|
|
|
11
11
|
// (move `digest` and `version`), or record that the estate departs.
|
|
12
12
|
// A departed citation is never stale: departing is a decision, recorded with its reason, not drift. With no
|
|
13
13
|
// citations anywhere the check reports [N/A], an empty population, not a pass.
|
|
14
|
+
//
|
|
15
|
+
// A digest must be a quoted string: YAML reads an unquoted all-digit value, or digits around one `e`, as a
|
|
16
|
+
// number, which would compare as a false stale. A narrowed or extended citation may say why in `reason`
|
|
17
|
+
// (`note` is read as the same). `not_adopted` records archetype requirements the estate has decided not to
|
|
18
|
+
// take, each with its reason and decision; every id must resolve, and none may be both cited and not adopted.
|
|
19
|
+
// A coverage line names the archetype requirements neither cited nor recorded as not adopted: the gaps, which
|
|
20
|
+
// are reported, not failed, since an estate may be part-way through adopting.
|
|
14
21
|
import { readdir, readFile } from "node:fs/promises";
|
|
15
22
|
import { join } from "node:path";
|
|
16
23
|
import { parse as parseYaml } from "yaml";
|
|
@@ -92,12 +99,34 @@ export async function checkArchetypeCitations(repo) {
|
|
|
92
99
|
const byId = new Map((reqs ?? []).map((r) => [r.id, r]));
|
|
93
100
|
const at = cachedVersion(archetype.bundleCacheDir) ?? "the cached bundle";
|
|
94
101
|
const cites = Array.isArray(doc.citations) ? doc.citations : [];
|
|
95
|
-
|
|
102
|
+
const notAdopted = Array.isArray(doc.not_adopted) ? doc.not_adopted : [];
|
|
103
|
+
if (cites.length === 0 && notAdopted.length === 0)
|
|
96
104
|
bad("unresolved", `${rel}: lists no citations`);
|
|
105
|
+
const citedIds = new Set(cites.map((c) => String(c.cites)));
|
|
106
|
+
for (const n of notAdopted) {
|
|
107
|
+
total++;
|
|
108
|
+
const tag = `${rel}: not adopted ${String(n.cites)}`;
|
|
109
|
+
if (!byId.has(String(n.cites))) {
|
|
110
|
+
bad("unresolved", `${tag}: ${archetype.bundleName} ${at} has no archetype requirement ${String(n.cites)}`);
|
|
111
|
+
}
|
|
112
|
+
else if (!n.reason || !n.decision) {
|
|
113
|
+
bad("unresolved", `${tag}: a non-adoption needs its reason and the decision that took it`);
|
|
114
|
+
}
|
|
115
|
+
else if (citedIds.has(String(n.cites))) {
|
|
116
|
+
bad("unresolved", `${tag}: it is also cited; an archetype requirement is either answered or not adopted`);
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
lines.push(`[ok] ${tag} (${String(n.reason)})`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
97
122
|
for (const c of cites) {
|
|
98
123
|
total++;
|
|
99
124
|
const tag = `${rel}: '${String(c.requirement)}' -> ${String(c.cites)}`;
|
|
100
125
|
const relation = String(c.relation);
|
|
126
|
+
if (typeof c.digest !== "string") {
|
|
127
|
+
bad("unresolved", `${tag}: digest must be a quoted string (YAML read ${String(c.digest)} as a ${typeof c.digest}); quote it`);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
101
130
|
if (!RELATIONS.includes(relation)) {
|
|
102
131
|
bad("unresolved", `${tag}: relation must be one of ${RELATIONS.join(", ")} (got ${relation})`);
|
|
103
132
|
continue;
|
|
@@ -122,9 +151,15 @@ export async function checkArchetypeCitations(repo) {
|
|
|
122
151
|
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
152
|
}
|
|
124
153
|
else {
|
|
125
|
-
|
|
154
|
+
const why = c.reason ?? c.note;
|
|
155
|
+
lines.push(`[ok] ${tag} (${relation}${why && relation !== "derived" ? `: ${String(why)}` : ""})`);
|
|
126
156
|
}
|
|
127
157
|
}
|
|
158
|
+
const answered = new Set([...citedIds, ...notAdopted.map((n) => String(n.cites))]);
|
|
159
|
+
const gaps = [...byId.keys()].filter((id) => !answered.has(id));
|
|
160
|
+
lines.push(`[coverage] ${rel}: ${byId.size - gaps.length} of ${byId.size} ${archetype.id} requirements answered ` +
|
|
161
|
+
`(${[...citedIds].filter((id) => byId.has(id)).length} cited, ${notAdopted.length} not adopted)` +
|
|
162
|
+
(gaps.length ? `; not yet answered: ${gaps.join(", ")}` : ""));
|
|
128
163
|
}
|
|
129
164
|
const verdict = problems
|
|
130
165
|
? `[FAIL] archetype citations: ${problems} of ${total} unresolved or stale`
|
|
@@ -25,6 +25,11 @@
|
|
|
25
25
|
// build finding; this check makes it a failure, so a shipped requirement cannot vanish from the
|
|
26
26
|
// catalogue with every check green. Without catalogues/features.csv the check is unresolved, not
|
|
27
27
|
// passed.
|
|
28
|
+
// 8. overlap: no requirement of a living spec is named by two open, agreed changes, whether added,
|
|
29
|
+
// modified, removed or renamed (M-ADR 0223 §4.10). Proposals may overlap.
|
|
30
|
+
// 9. schema: the store's config.yaml selects the method's sdd-change schema, and the store's copy of it
|
|
31
|
+
// (openspec/schemas/sdd-change/) is identical to the one the SDD's bundle shipped
|
|
32
|
+
// (docs/method/foundations/openspec/sdd-change/). Sync refreshes the copy (M-ADR 0223 §4.2).
|
|
28
33
|
// 6. scope: a baseline ADDS requirements; it REMOVES, MODIFIES or RENAMES only a requirement whose
|
|
29
34
|
// latest archived change was a baseline, which is how a corrective baseline withdraws
|
|
30
35
|
// what a baseline admitted wrongly. A requirement a judged change shipped is changed only
|
|
@@ -34,14 +39,18 @@
|
|
|
34
39
|
//
|
|
35
40
|
// OPENSPEC'S OWN VALIDATION is the separate validator `behaviour-store-openspec`, because it needs
|
|
36
41
|
// the OpenSpec CLI at OPENSPEC_VERSION (the Method Validation module installs it). Without a store
|
|
37
|
-
// both report [N/A], an empty population, never a pass.
|
|
42
|
+
// both report [N/A], an empty population, never a pass. It also archives each open, agreed change in a
|
|
43
|
+
// scratch copy of the store and validates the specs it would leave (M-ADR 0223 §4.5): a change's own
|
|
44
|
+
// --strict run exempts its MODIFIED requirements from the 500-character limit the specs are held to.
|
|
38
45
|
import { execFile } from "node:child_process";
|
|
39
|
-
import { access, readdir, readFile } from "node:fs/promises";
|
|
46
|
+
import { access, cp, mkdtemp, readdir, readFile, rm } from "node:fs/promises";
|
|
47
|
+
import { tmpdir } from "node:os";
|
|
40
48
|
import { basename, join } from "node:path";
|
|
41
49
|
import { promisify } from "node:util";
|
|
42
50
|
import { parseDocument } from "yaml";
|
|
43
51
|
import { observedIn, orderArchives, parseRequirements, parseSurfaces, renamedFrom, surfaceMatches } from "../behaviour-store/archive.js";
|
|
44
52
|
import { parseCatalogueCsv } from "../catalogue/csv-reader.js";
|
|
53
|
+
import { compareStoreSchema, SCHEMA_NAME, SHIPPED_SCHEMA_DIR, STORE_CONFIG, STORE_SCHEMA_DIR, storeSchemaName } from "../behaviour-store/schema.js";
|
|
45
54
|
const execFileAsync = promisify(execFile);
|
|
46
55
|
/** The OpenSpec release the method depends on (M-ADR 0223 ruling 2). The module installs exactly this. */
|
|
47
56
|
export const OPENSPEC_VERSION = "1.14.3";
|
|
@@ -241,7 +250,9 @@ export async function checkBehaviourStore(repo) {
|
|
|
241
250
|
const record = records.get(change);
|
|
242
251
|
if (tasks !== null && TASK_DONE_RE.test(tasks)) {
|
|
243
252
|
started++;
|
|
244
|
-
if (!record)
|
|
253
|
+
if (!record && recordFiles.some((f) => f.replace(/\.ya?ml$/, "") === change))
|
|
254
|
+
failures.push(`${dir}: has completed tasks but its agreement record (${AGREEMENTS_DIR}/${change}.yaml) is malformed; see its record failures`);
|
|
255
|
+
else if (!record)
|
|
245
256
|
failures.push(`${dir}: has completed tasks but no agreement record (${AGREEMENTS_DIR}/${change}.yaml)`);
|
|
246
257
|
else if (record.status !== "agreed")
|
|
247
258
|
failures.push(`${dir}: has completed tasks but its agreement record is ${record.status}, not agreed`);
|
|
@@ -264,6 +275,29 @@ export async function checkBehaviourStore(repo) {
|
|
|
264
275
|
}
|
|
265
276
|
lines.push(`agreement: ${open.length} open change(s), ${started} with completed tasks`);
|
|
266
277
|
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`);
|
|
278
|
+
// 8. overlap: two open, agreed changes never name the same requirement of one living spec (M-ADR 0223 §4.10).
|
|
279
|
+
// A name counts whether the delta adds, modifies, removes or renames it (both FROM and TO). Proposals may overlap.
|
|
280
|
+
const claimed = new Map();
|
|
281
|
+
let agreedOpen = 0;
|
|
282
|
+
for (const change of open) {
|
|
283
|
+
if (records.get(change)?.status !== "agreed")
|
|
284
|
+
continue;
|
|
285
|
+
agreedOpen++;
|
|
286
|
+
const specsDir = join(repo, STORE_DIR, "changes", change, "specs");
|
|
287
|
+
for (const spec of await dirs(specsDir)) {
|
|
288
|
+
const text = (await readText(join(specsDir, spec, "spec.md"))) ?? "";
|
|
289
|
+
const names = new Set([...parseRequirements(text).filter((r) => r.operation).map((r) => r.name), ...renamedFrom(text)]);
|
|
290
|
+
for (const name of names) {
|
|
291
|
+
const key = `${spec}::${name}`;
|
|
292
|
+
const other = claimed.get(key);
|
|
293
|
+
if (other)
|
|
294
|
+
failures.push(`${STORE_DIR}/changes/${change}: OVERLAP: requirement "${name}" of ${spec} is also named by agreed change ${other}; one waits for the other to archive, or the two become one change (M-ADR 0223 §4.10)`);
|
|
295
|
+
else
|
|
296
|
+
claimed.set(key, change);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
lines.push(agreedOpen < 2 ? `overlap: vacuous (${agreedOpen} open agreed change)` : `overlap: ${claimed.size} requirement name(s) across ${agreedOpen} open agreed changes`);
|
|
267
301
|
// 4. archived changes
|
|
268
302
|
const archived = await dirs(join(repo, STORE_DIR, "changes", "archive"));
|
|
269
303
|
let baselines = 0;
|
|
@@ -290,7 +324,31 @@ export async function checkBehaviourStore(repo) {
|
|
|
290
324
|
lines.push(`verdicts: ${archived.length} archived change(s), ${baselines} baseline(s), ${archived.length - baselines} needing a PASS verdict`);
|
|
291
325
|
// 5. surfaces, and 6. what each baseline may change, in archive order
|
|
292
326
|
const archiveRel = `${STORE_DIR}/changes/archive`;
|
|
293
|
-
const { order, ties } = await orderArchives(repo, archiveRel, archived);
|
|
327
|
+
const { order, ties, history } = await orderArchives(repo, archiveRel, archived);
|
|
328
|
+
// Same-day archives the history cannot order sit in one group. A scope verdict that a group peer could reverse
|
|
329
|
+
// (it touches the same requirement) depends on an order nobody knows, so it is unresolved, not a violation. Without
|
|
330
|
+
// git history (a worktree upload, a plain copy, an export) every same-day pair is such a group.
|
|
331
|
+
const peers = new Map();
|
|
332
|
+
for (const [a, b] of ties) {
|
|
333
|
+
const group = peers.get(a) ?? peers.get(b) ?? new Set();
|
|
334
|
+
group.add(a).add(b);
|
|
335
|
+
peers.set(a, group).set(b, group);
|
|
336
|
+
}
|
|
337
|
+
const touches = new Map();
|
|
338
|
+
for (const name of peers.keys()) {
|
|
339
|
+
const keys = new Set();
|
|
340
|
+
for (const feature of await dirs(join(repo, archiveRel, name, "specs"))) {
|
|
341
|
+
const text = (await readText(join(repo, archiveRel, name, "specs", feature, "spec.md"))) ?? "";
|
|
342
|
+
for (const r of parseRequirements(text))
|
|
343
|
+
if (r.operation)
|
|
344
|
+
keys.add(`${feature}::${r.name}`);
|
|
345
|
+
for (const from of renamedFrom(text))
|
|
346
|
+
keys.add(`${feature}::${from}`);
|
|
347
|
+
}
|
|
348
|
+
touches.set(name, keys);
|
|
349
|
+
}
|
|
350
|
+
const orderDecides = (name, key) => [...(peers.get(name) ?? [])].some((p) => p !== name && touches.get(p)?.has(key));
|
|
351
|
+
let scopeUnresolved = 0;
|
|
294
352
|
const servedBy = baselines > 0 ? await catalogueContracts(repo) : null;
|
|
295
353
|
let surfacesChecked = 0;
|
|
296
354
|
const lastTouch = new Map();
|
|
@@ -338,7 +396,11 @@ export async function checkBehaviourStore(repo) {
|
|
|
338
396
|
if (isBaseline) {
|
|
339
397
|
scopeChecked++;
|
|
340
398
|
const last = lastTouch.get(key);
|
|
341
|
-
if (last !== "baseline") {
|
|
399
|
+
if (last !== "baseline" && orderDecides(name, key)) {
|
|
400
|
+
scopeUnresolved++;
|
|
401
|
+
failures.push(`${dir}: scope unresolved for "${t}" (${feature}): the verdict depends on the order of same-day archives the history cannot give${history ? "" : " (no git history here: run from a clone)"}`);
|
|
402
|
+
}
|
|
403
|
+
else if (last !== "baseline") {
|
|
342
404
|
failures.push(`${dir}: a baseline changes "${t}" (${feature}), which ${last === "change" ? "a judged change last shipped" : "no earlier archived change admitted"}; a baseline may remove or modify only what a baseline admitted, and anything else takes a change (M-ADR 0222 §4.4)`);
|
|
343
405
|
}
|
|
344
406
|
}
|
|
@@ -357,6 +419,10 @@ export async function checkBehaviourStore(repo) {
|
|
|
357
419
|
const unseen = servedBy === null ? "" : `; repositories with no catalogue contracts: ${[...new Set(await observedInAll(repo, archiveRel, order))].filter((r) => (servedBy.get(r) ?? []).length === 0).join(", ") || "none"}`;
|
|
358
420
|
lines.push(baselines === 0 ? "surfaces: vacuous (no baseline)" : `surfaces: ${baselines} baseline(s), ${surfacesChecked} served contract(s) checked against catalogues/repo_contract.csv${unseen}`);
|
|
359
421
|
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`);
|
|
422
|
+
if (!history && archived.length > 0)
|
|
423
|
+
lines.push("order: no git history (not a git work tree, or git log failed), so same-day archives cannot be ordered");
|
|
424
|
+
if (scopeUnresolved > 0)
|
|
425
|
+
lines.push(`scope: ${scopeUnresolved} verdict(s) unresolved, depending on an order the history cannot give`);
|
|
360
426
|
for (const [a, b] of ties)
|
|
361
427
|
lines.push(`order: ${a} and ${b} were archived the same day and the history does not order them; ordered by name`);
|
|
362
428
|
// 7. living specs keyed by a catalogue feature
|
|
@@ -378,6 +444,30 @@ export async function checkBehaviourStore(repo) {
|
|
|
378
444
|
else {
|
|
379
445
|
lines.push("features: vacuous (no living spec)");
|
|
380
446
|
}
|
|
447
|
+
// 9. schema: the store uses the method's sdd-change schema, and its copy is the one the SDD's bundle
|
|
448
|
+
// shipped (M-ADR 0223 §4.2). OpenSpec reads the copy, so an edited or stale copy changes what every
|
|
449
|
+
// change is asked for, with nothing else noticing.
|
|
450
|
+
const selected = await storeSchemaName(repo);
|
|
451
|
+
if (selected !== SCHEMA_NAME) {
|
|
452
|
+
failures.push(`${STORE_CONFIG}: ${selected === null ? "missing" : selected === "" ? "names no schema" : `selects schema "${selected}"`}; a behaviour store uses the method's ${SCHEMA_NAME} schema (M-ADR 0223 §4.2)`);
|
|
453
|
+
}
|
|
454
|
+
else {
|
|
455
|
+
const c = await compareStoreSchema(repo);
|
|
456
|
+
if (!c.shipped) {
|
|
457
|
+
failures.push(`schema unresolved: this SDD holds no shipped ${SCHEMA_NAME} schema (${SHIPPED_SCHEMA_DIR}/); sync a method bundle that carries it`);
|
|
458
|
+
}
|
|
459
|
+
else {
|
|
460
|
+
const fix = "sdd-cli sync refreshes it; put adopter guidance in the store's config.yaml rules, not in the schema";
|
|
461
|
+
for (const f of c.missing)
|
|
462
|
+
failures.push(`${STORE_SCHEMA_DIR}/${f}: missing from the store's copy of the schema; ${fix}`);
|
|
463
|
+
for (const f of c.differing)
|
|
464
|
+
failures.push(`${STORE_SCHEMA_DIR}/${f}: differs from the shipped ${SHIPPED_SCHEMA_DIR}/${f}; ${fix}`);
|
|
465
|
+
for (const f of c.extra)
|
|
466
|
+
failures.push(`${STORE_SCHEMA_DIR}/${f}: not in the shipped schema; ${fix}`);
|
|
467
|
+
const drift = c.missing.length + c.differing.length + c.extra.length;
|
|
468
|
+
lines.push(drift === 0 ? `schema: ${SCHEMA_NAME}, identical to the shipped copy` : `schema: ${SCHEMA_NAME}, ${drift} file(s) differ from the shipped copy`);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
381
471
|
const population = recordFiles.length + open.length + archived.length;
|
|
382
472
|
const body = lines.map((l) => ` ${l}`).join("\n") + "\n";
|
|
383
473
|
if (failures.length > 0) {
|
|
@@ -385,7 +475,7 @@ export async function checkBehaviourStore(repo) {
|
|
|
385
475
|
}
|
|
386
476
|
if (population === 0)
|
|
387
477
|
return { exitCode: 0, stdout: body + `[N/A] the behaviour store has no changes and no agreement records yet\n` };
|
|
388
|
-
return { exitCode: 0, stdout: body + "[PASS] behaviour store: records, agreement, stale, verdict, surfaces, scope and
|
|
478
|
+
return { exitCode: 0, stdout: body + "[PASS] behaviour store: records, agreement, stale, overlap, verdict, surfaces, scope, feature and schema checks hold\n" };
|
|
389
479
|
}
|
|
390
480
|
/** Per repository, the contracts the catalogue says it serves (repo_contract ⋈ contracts.spec_path); null when either file is missing. */
|
|
391
481
|
async function catalogueContracts(repo) {
|
|
@@ -476,6 +566,47 @@ export async function checkBehaviourStoreOpenspec(repo) {
|
|
|
476
566
|
if (r.code !== 0 && (t.failed ?? 0) === 0)
|
|
477
567
|
failures.push(`${label}: OpenSpec exited ${r.code} with no failed item reported`);
|
|
478
568
|
}
|
|
569
|
+
// Each open, agreed change, archived in a scratch copy: the specs it would leave must validate --strict.
|
|
570
|
+
let dryArchived = 0;
|
|
571
|
+
for (const change of (await dirs(join(repo, STORE_DIR, "changes"))).filter((d) => d !== "archive")) {
|
|
572
|
+
const rel = `${AGREEMENTS_DIR}/${change}.yaml`;
|
|
573
|
+
const text = await readText(join(repo, rel));
|
|
574
|
+
if (text === null || parseAgreementRecord(rel, text).record?.status !== "agreed")
|
|
575
|
+
continue;
|
|
576
|
+
const scratch = await mkdtemp(join(tmpdir(), "sdd-dry-archive-"));
|
|
577
|
+
try {
|
|
578
|
+
await cp(join(repo, STORE_DIR), join(scratch, STORE_DIR), { recursive: true });
|
|
579
|
+
const label = `specs after archiving ${change} (dry, in a copy)`;
|
|
580
|
+
const a = await runOpenspec(scratch, ["archive", change, "--yes", "--json"]);
|
|
581
|
+
if (a.code !== 0) {
|
|
582
|
+
const said = [...a.raw.matchAll(/"(?:message|error)":\s*"((?:[^"\\]|\\.)*)"/g)].map((m) => m[1]);
|
|
583
|
+
failures.push(`${label}: the change does not archive (exit ${a.code}): ${said.length > 0 ? said.join("; ") : a.raw.trim().split("\n").slice(-3).join(" | ")}`);
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
dryArchived++;
|
|
587
|
+
const v = await runOpenspec(scratch, ["validate", "--specs", "--strict", "--json", "--no-interactive"]);
|
|
588
|
+
const t = v.json?.summary?.totals;
|
|
589
|
+
if (!t) {
|
|
590
|
+
failures.push(`${label}: OpenSpec returned no report (exit ${v.code}): ${v.raw.trim().split("\n").slice(-3).join(" | ")}`);
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
lines.push(`${label}: ${t.items ?? 0} spec(s), ${t.passed ?? 0} passed, ${t.failed ?? 0} failed`);
|
|
594
|
+
for (const item of v.json?.items ?? []) {
|
|
595
|
+
if (item.valid)
|
|
596
|
+
continue;
|
|
597
|
+
for (const i of item.issues ?? [])
|
|
598
|
+
failures.push(`${label}: ${item.id}${i.path ? ` (${i.path})` : ""}: ${i.level} ${i.message}`);
|
|
599
|
+
if ((item.issues ?? []).length === 0)
|
|
600
|
+
failures.push(`${label}: ${item.id}: invalid`);
|
|
601
|
+
}
|
|
602
|
+
if (v.code !== 0 && (t.failed ?? 0) === 0)
|
|
603
|
+
failures.push(`${label}: OpenSpec exited ${v.code} with no failed spec reported`);
|
|
604
|
+
}
|
|
605
|
+
finally {
|
|
606
|
+
await rm(scratch, { recursive: true, force: true });
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
lines.push(`dry archive: ${dryArchived} open, agreed change(s) archived in a copy`);
|
|
479
610
|
const body = [` OpenSpec ${version}`, ...lines.map((l) => ` ${l}`)].join("\n") + "\n";
|
|
480
611
|
if (failures.length > 0)
|
|
481
612
|
return { exitCode: 1, stdout: body + failures.map((f) => `[FAIL] ${f}`).join("\n") + "\n" };
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { checkArchetypeCitations } from "./archetype-citations.js";
|
|
2
2
|
import { checkBehaviourStore, checkBehaviourStoreOpenspec } from "./behaviour-store.js";
|
|
3
3
|
import { checkBundleCoherence } from "./bundle-coherence.js";
|
|
4
|
+
import { checkIntents } from "./intents.js";
|
|
4
5
|
import { checkYamlParse } from "./yaml-parse.js";
|
|
5
6
|
// The behaviour store (M-ADR 0221-0223) can sit in an SDD of any profile, so every profile carries
|
|
6
7
|
// its two checks; without a store both report [N/A], an empty population, not a pass.
|
|
@@ -21,6 +22,12 @@ const BEHAVIOUR_STORE_VALIDATORS = [
|
|
|
21
22
|
source: { kind: "native", run: async (ctx) => checkArchetypeCitations(ctx.targetRepo) },
|
|
22
23
|
build: () => ({ status: "run", args: [] }),
|
|
23
24
|
},
|
|
25
|
+
// Intents (M-ADR 0227) can sit in an SDD of any profile; without any the check reports [N/A].
|
|
26
|
+
{
|
|
27
|
+
name: "intents",
|
|
28
|
+
source: { kind: "native", run: async (ctx) => checkIntents(ctx.targetRepo) },
|
|
29
|
+
build: () => ({ status: "run", args: [] }),
|
|
30
|
+
},
|
|
24
31
|
];
|
|
25
32
|
// Validators that accept --app-root / --repo-root flags work fine from the
|
|
26
33
|
// bundled location because they override their internal ROOT_DIR via the
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// intents: every intent file (M-ADR 0227 §4.1) has the template's shape, carries no planning state, names its
|
|
2
|
+
// work item, and every path its "Where it went" names in this SDD exists.
|
|
3
|
+
//
|
|
4
|
+
// Read from content/method/intents/ and content/product/intents/. A path line in "Where it went" is
|
|
5
|
+
// "- `<path>` (<sdd-id>; <source>)"; only paths labelled with this SDD's id are checked here, since another
|
|
6
|
+
// SDD's paths resolve in that SDD. Commit and plan lines are records, not paths, and are not checked. An
|
|
7
|
+
// intent with nothing yet in "Where it went" is captured but not explored: counted, not failed. With no
|
|
8
|
+
// intents the check reports [N/A], an empty population, not a pass.
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
10
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
11
|
+
import { basename, join } from "node:path";
|
|
12
|
+
import { parseDocument } from "yaml";
|
|
13
|
+
import { INTENT_DIRS, INTENT_SECTIONS, PLANNING_FIELDS, WHERE_PATH_LINE, parseMarkdown } from "../intents/format.js";
|
|
14
|
+
async function sddId(repo) {
|
|
15
|
+
try {
|
|
16
|
+
const m = parseDocument(await readFile(join(repo, ".sdd/sdd-manifest.yaml"), "utf8")).toJS();
|
|
17
|
+
if (typeof m?.sdd_id === "string" && m.sdd_id)
|
|
18
|
+
return m.sdd_id;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
// fall through
|
|
22
|
+
}
|
|
23
|
+
return basename(repo);
|
|
24
|
+
}
|
|
25
|
+
export async function checkIntents(repo) {
|
|
26
|
+
const files = [];
|
|
27
|
+
for (const d of INTENT_DIRS) {
|
|
28
|
+
try {
|
|
29
|
+
for (const e of await readdir(join(repo, d), { withFileTypes: true })) {
|
|
30
|
+
if (e.isFile() && e.name.endsWith(".md") && e.name.toLowerCase() !== "readme.md")
|
|
31
|
+
files.push(`${d}/${e.name}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// no such directory
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (files.length === 0)
|
|
39
|
+
return { exitCode: 0, stdout: "[N/A] no intents (content/{method,product}/intents/*.md) in this repository\n" };
|
|
40
|
+
const self = await sddId(repo);
|
|
41
|
+
const problems = [];
|
|
42
|
+
let unexplored = 0;
|
|
43
|
+
let pathsChecked = 0;
|
|
44
|
+
for (const rel of files.sort()) {
|
|
45
|
+
const text = await readFile(join(repo, rel), "utf8");
|
|
46
|
+
const doc = parseMarkdown(text);
|
|
47
|
+
if (!/^Intent:\s*\S/.test(doc.title))
|
|
48
|
+
problems.push(`${rel}: no "# Intent: <title>" line`);
|
|
49
|
+
for (const s of INTENT_SECTIONS)
|
|
50
|
+
if (!doc.sections.has(s.toLowerCase()))
|
|
51
|
+
problems.push(`${rel}: no "## ${s}" section`);
|
|
52
|
+
const wi = doc.fields.get("Work item")?.trim() ?? "";
|
|
53
|
+
if (!wi || /^<.*>$/.test(wi))
|
|
54
|
+
problems.push(`${rel}: no work item named (**Work item:**)`);
|
|
55
|
+
for (const f of PLANNING_FIELDS)
|
|
56
|
+
if (doc.fields.has(f))
|
|
57
|
+
problems.push(`${rel}: planning field **${f}:** belongs in the work item (M-ADR 0227 §4.3)`);
|
|
58
|
+
const where = (doc.sections.get("where it went") ?? "").split("\n").map((l) => l.trim()).filter((l) => l.startsWith("- "));
|
|
59
|
+
if (where.length === 0)
|
|
60
|
+
unexplored++;
|
|
61
|
+
for (const line of where) {
|
|
62
|
+
const m = WHERE_PATH_LINE.exec(line);
|
|
63
|
+
if (!m || m[2].trim() !== self)
|
|
64
|
+
continue;
|
|
65
|
+
pathsChecked++;
|
|
66
|
+
const p = m[1].replace(/[#?].*$/, "");
|
|
67
|
+
if (!existsSync(join(repo, p)))
|
|
68
|
+
problems.push(`${rel}: "Where it went" names ${p}, which does not exist`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const head = `intents: ${files.length} intent(s); ${pathsChecked} "Where it went" path(s) in this SDD checked; ${unexplored} not yet explored\n`;
|
|
72
|
+
if (problems.length === 0)
|
|
73
|
+
return { exitCode: 0, stdout: `${head}[PASS] intents: ok\n` };
|
|
74
|
+
return { exitCode: 1, stdout: `${head}${problems.map((p) => `[FAIL] ${p}\n`).join("")}[FAIL] intents: ${problems.length} problem(s)\n` };
|
|
75
|
+
}
|
|
76
|
+
//# sourceMappingURL=intents.js.map
|
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.5.0",
|
|
4
|
+
"sddCliVersion": "2.5.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",
|