@sdd-method/maintain-cli 0.15.1 → 0.16.1
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/build.js +13 -1
- package/dist/lib/catalogue/builders/contract-revisions.js +20 -5
- package/dist/lib/catalogue/builders/data-model.js +8 -3
- package/dist/lib/catalogue/canonical-schema.yaml +1 -1
- package/dist/lib/contracts-availability/index.js +277 -0
- package/dist/lib/metamodel/tql-subset-parser.js +4 -5
- package/dist/lib/validate/behaviour-store.js +11 -1
- package/dist/maintain-version.json +2 -2
- package/dist/verify-version.json +2 -2
- package/package.json +2 -2
|
@@ -90,7 +90,7 @@ export async function runCatalogueBuild(inputs, write) {
|
|
|
90
90
|
const intendedRows = [];
|
|
91
91
|
const rowsByCatalogue = modelFiles === null
|
|
92
92
|
? collected
|
|
93
|
-
: overlayModel(schema, viewOf(modelFiles, modelProblems), collected, findings, intendedWrites(open, agreedOrder(open)), intendedRows);
|
|
93
|
+
: withC4Rollups(overlayModel(schema, viewOf(modelFiles, modelProblems), collected, findings, intendedWrites(open, agreedOrder(open)), intendedRows));
|
|
94
94
|
for (const p of modelProblems)
|
|
95
95
|
findings.add("convention", `${p} (M-ADR 0230)`);
|
|
96
96
|
// 3a. Normalise every keyed catalogue to PK-unique rows. Multi-source edge
|
|
@@ -1517,6 +1517,18 @@ async function collectRows(schema, manifest, findings, sddPath, snapshotStore) {
|
|
|
1517
1517
|
});
|
|
1518
1518
|
return out;
|
|
1519
1519
|
}
|
|
1520
|
+
/**
|
|
1521
|
+
* The C4 roll-ups joined again over the catalogues the structural model settles (M-ADR 0159 §4.8, M-ADR 0230 §4.3).
|
|
1522
|
+
* collectRows joins them over the sources, which can state an element an agreed open change adds; the overlay holds
|
|
1523
|
+
* that element back until the change archives, and a roll-up row reaching it would be a current row referencing it.
|
|
1524
|
+
*/
|
|
1525
|
+
function withC4Rollups(rows) {
|
|
1526
|
+
const c4Containers = rows.get("c4_containers") ?? [];
|
|
1527
|
+
const containerEntityRows = buildC4ContainerEntity({ serviceEntityRows: rows.get("service_entity") ?? [], c4Containers });
|
|
1528
|
+
rows.set("c4_container_entity", containerEntityRows);
|
|
1529
|
+
rows.set("c4_system_entity", buildC4SystemEntity({ containerEntityRows, c4Containers }));
|
|
1530
|
+
return rows;
|
|
1531
|
+
}
|
|
1520
1532
|
const CONTEXT_CROSS_CUTTING = "cross-cutting";
|
|
1521
1533
|
/**
|
|
1522
1534
|
* Convention check (schema 2.21.0): where a screen carries `context` and the
|
|
@@ -13,10 +13,17 @@
|
|
|
13
13
|
// a file matching no contract, or several, is a finding and produces no row. `relies_on` and `agrees` are a digest
|
|
14
14
|
// beside `file:`, or a map from each file to its digest beside `files:`. A `relies_on` that no longer matches the
|
|
15
15
|
// file's content is reported: the change relies on a revision that has moved, and is re-agreed.
|
|
16
|
+
//
|
|
17
|
+
// A contract declared outside the SDD has no file here. Its current content is what .sdd/contract-availability.yaml
|
|
18
|
+
// attests (M-ADR 0202): the entry whose path is the contract's spec_path as the declaration writes it, so a fresh clone
|
|
19
|
+
// with no sibling checkout builds the same rows. A contract neither here nor attested has no current revision, and an
|
|
20
|
+
// archived change's revision of it is not classified: retired is a comparison with the current content, never the
|
|
21
|
+
// absence of one.
|
|
16
22
|
import { createHash } from "node:crypto";
|
|
17
23
|
import { readdir, readFile } from "node:fs/promises";
|
|
18
24
|
import { join } from "node:path";
|
|
19
25
|
import { parse as parseYaml } from "yaml";
|
|
26
|
+
import { readAvailability } from "../../contracts-availability/index.js";
|
|
20
27
|
const AGREEMENTS = join("docs", "agreements");
|
|
21
28
|
const DIGEST = /^sha256:[0-9a-f]{64}$/;
|
|
22
29
|
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
@@ -71,21 +78,29 @@ export async function buildContractRevisions(inputs) {
|
|
|
71
78
|
}
|
|
72
79
|
/**
|
|
73
80
|
* The revision rows, and every contract an archived change deprecated. The second is kept apart because a contract
|
|
74
|
-
* declared outside this SDD
|
|
81
|
+
* declared outside this SDD and not attested has no revision row, yet its version is still deprecated.
|
|
75
82
|
*/
|
|
76
83
|
export async function contractRevisionsAndDeprecations(inputs) {
|
|
77
84
|
const { sddPath, findings } = inputs;
|
|
78
85
|
const rows = new Map();
|
|
79
86
|
const current = new Map(); // contract id -> current digest
|
|
87
|
+
const attested = new Map(); // spec_path -> each digest attested for it
|
|
88
|
+
for (const e of await readAvailability(sddPath)) {
|
|
89
|
+
if (!/^[0-9a-f]{64}$/.test(e.sha256))
|
|
90
|
+
continue;
|
|
91
|
+
attested.set(e.path, (attested.get(e.path) ?? new Set()).add(`sha256:${e.sha256}`));
|
|
92
|
+
}
|
|
80
93
|
for (const c of inputs.contracts) {
|
|
81
94
|
const path = String(c["spec_path"] ?? "");
|
|
82
95
|
if (path === "")
|
|
83
96
|
continue;
|
|
84
97
|
const bytes = await readFile(join(sddPath, path)).catch(() => null);
|
|
85
|
-
|
|
98
|
+
const claims = attested.get(path);
|
|
99
|
+
// Two entries attesting different content for one path say nothing about which is current.
|
|
100
|
+
const digest = bytes !== null ? contentDigest(bytes) : claims?.size === 1 ? [...claims][0] : undefined;
|
|
101
|
+
if (digest === undefined)
|
|
86
102
|
continue;
|
|
87
103
|
const id = String(c["id"]);
|
|
88
|
-
const digest = contentDigest(bytes);
|
|
89
104
|
current.set(id, digest);
|
|
90
105
|
const rid = revisionId(id, digest);
|
|
91
106
|
rows.set(rid, { id: rid, contract_id: id, digest, state: c["status"] === "deprecated" ? "deprecated" : "active", sunset_date: "", change_id: "", source_sdd_id: "", platform_sdd_id: "" });
|
|
@@ -145,14 +160,14 @@ export async function contractRevisionsAndDeprecations(inputs) {
|
|
|
145
160
|
}
|
|
146
161
|
if (state === "agreed")
|
|
147
162
|
rows.set(rid, { id: rid, contract_id: contract, digest: p.agrees, state: "agreed", sunset_date: "", change_id: changeId, source_sdd_id: "", platform_sdd_id: "" });
|
|
148
|
-
else if (!existing)
|
|
163
|
+
else if (!existing && now)
|
|
149
164
|
rows.set(rid, { id: rid, contract_id: contract, digest: p.agrees, state: "retired", sunset_date: "", change_id: changeId, source_sdd_id: "", platform_sdd_id: "" });
|
|
150
165
|
}
|
|
151
166
|
}
|
|
152
167
|
const deprecated = new Map();
|
|
153
168
|
for (const [contract, d] of deprecations) {
|
|
154
169
|
deprecated.set(contract, { sunset: d.sunset, changeId: d.changeId });
|
|
155
|
-
// A contract
|
|
170
|
+
// A contract outside this SDD with no attestation has no revision row to mark.
|
|
156
171
|
const now = current.get(contract);
|
|
157
172
|
const rid = now ? revisionId(contract, now) : undefined;
|
|
158
173
|
const row = rid ? rows.get(rid) : undefined;
|
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
// `..` (non-identifying) is read as `--`, and reported
|
|
11
11
|
// TypeQL, in the documented subset (M-ADR 0210 §4.2, `data-model.tql`), through the fail-closed parseTypes:
|
|
12
12
|
// an entity's owned attributes (@key, @card), and each binary relation as a relationship between its two roles'
|
|
13
|
-
// players, roles kept.
|
|
14
|
-
//
|
|
13
|
+
// players, roles kept. An end's cardinality is the other player's participation: B plays R:b @card(1..1) puts
|
|
14
|
+
// exactly-one at A's end (each B has exactly one A), and an unannotated plays is zero-or-more (TypeDB's default).
|
|
15
|
+
// A role's own @card is a cardinality invariant on the relationship.
|
|
15
16
|
// The keys become invariants: a primary key (PK, @key) is one unique invariant over its attributes, which are not
|
|
16
17
|
// nullable; a UK is a unique invariant over its attribute. What a parser reads and cannot hold is an `extraction`
|
|
17
18
|
// finding, never a silent skip.
|
|
@@ -194,9 +195,13 @@ export function parseTypeqlDataModel(text, where) {
|
|
|
194
195
|
return false;
|
|
195
196
|
});
|
|
196
197
|
const cards = [...d.relates].flatMap(([role, ann]) => ann.required ? [`each ${label} relates exactly one ${role}`] : ann.optional ? [`each ${label} relates at most one ${role}`] : []);
|
|
198
|
+
const participation = (player, role) => {
|
|
199
|
+
const ann = t.entities.get(player)?.plays.get(`${label}:${role}`);
|
|
200
|
+
return ann?.required ? "exactly-one" : ann?.optional ? "zero-or-one" : "zero-or-more";
|
|
201
|
+
};
|
|
197
202
|
for (const from of playersOf(a)) {
|
|
198
203
|
for (const to of playersOf(b)) {
|
|
199
|
-
relationships.push({ from, to, name: label, fromCardinality:
|
|
204
|
+
relationships.push({ from, to, name: label, fromCardinality: participation(to, b), toCardinality: participation(from, a), fromRole: a, toRole: b, roleCards: cards });
|
|
200
205
|
}
|
|
201
206
|
}
|
|
202
207
|
}
|
|
@@ -721,7 +721,7 @@ enums:
|
|
|
721
721
|
- value: zero-or-one
|
|
722
722
|
- value: one-or-more
|
|
723
723
|
- value: zero-or-more
|
|
724
|
-
description: "How many entities one end of a relationship holds for one entity at the other end (M-ADR 0230 §4.3): a Mermaid ER end marker (|| exactly one, |o or o| zero or one, }| or |{ one or more, }o or o{ zero or more). A TypeQL relationship is
|
|
724
|
+
description: "How many entities one end of a relationship holds for one entity at the other end (M-ADR 0230 §4.3): a Mermaid ER end marker (|| exactly one, |o or o| zero or one, }| or |{ one or more, }o or o{ zero or more). A TypeQL relationship's end is the other player's participation: B plays R:b @card(1..1) makes A's end exactly-one, @card(0..1) zero-or-one, and an unannotated plays zero-or-more; a role's @card is a cardinality invariant (domain_invariants). Replaced entity_relationship_cardinality in schema 6.0.0."
|
|
725
725
|
domain_invariant_kind:
|
|
726
726
|
values:
|
|
727
727
|
- value: unique
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract availability manifest (M-ADR 0202).
|
|
3
|
+
*
|
|
4
|
+
* `.sdd/contract-availability.yaml` — the committed attestation that lets an
|
|
5
|
+
* instrument distinguish "absent because wrong" from "absent because not
|
|
6
|
+
* checked out" when an SDD references contract artefacts living in sibling
|
|
7
|
+
* repositories (the M-ADR 0110 contracts-* pattern).
|
|
8
|
+
*
|
|
9
|
+
* Two modes, per the format spec (docs/method/foundations/
|
|
10
|
+
* contract-availability-manifest.md in the method baseline):
|
|
11
|
+
* --refresh runs where siblings ARE checked out: resolves every collected
|
|
12
|
+
* reference, writes/updates entries with content digest and
|
|
13
|
+
* repo@commit coordinate. Preserves entries it cannot currently
|
|
14
|
+
* see (still referenced, artefact absent here); drops entries no
|
|
15
|
+
* longer referenced anywhere, and says so.
|
|
16
|
+
* --verify runs anywhere: present artefacts are digest-compared (drift);
|
|
17
|
+
* absent-but-attested is satisfied; referenced-but-unattested-
|
|
18
|
+
* and-absent is the failure class.
|
|
19
|
+
*
|
|
20
|
+
* References are collected from BOTH observed forms (M-ADR 0200 §4.5 — read
|
|
21
|
+
* what the estate writes):
|
|
22
|
+
* 1. traceability.contracts declarations (string and object entries), when
|
|
23
|
+
* the declared path does not resolve inside the repo;
|
|
24
|
+
* 2. markdown links under docs/ whose target escapes the repo root.
|
|
25
|
+
*/
|
|
26
|
+
import { execFile as execFileCb } from "node:child_process";
|
|
27
|
+
import { createHash } from "node:crypto";
|
|
28
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
29
|
+
import { existsSync } from "node:fs";
|
|
30
|
+
import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
|
|
31
|
+
import { promisify } from "node:util";
|
|
32
|
+
import { parse as parseYaml } from "yaml";
|
|
33
|
+
import { loadTraceabilityManifests } from "../catalogue/traceability-loader.js";
|
|
34
|
+
import { FindingsLog } from "../catalogue/findings.js";
|
|
35
|
+
const execFile = promisify(execFileCb);
|
|
36
|
+
export const AVAILABILITY_MANIFEST_REL_PATH = ".sdd/contract-availability.yaml";
|
|
37
|
+
export async function runAttest(opts) {
|
|
38
|
+
const log = [];
|
|
39
|
+
const repo = resolve(opts.targetRepo);
|
|
40
|
+
const codeRoot = resolve(opts.codeRoot ?? join(repo, ".."));
|
|
41
|
+
const manifestAbs = join(repo, AVAILABILITY_MANIFEST_REL_PATH);
|
|
42
|
+
const refs = await collectReferences(repo);
|
|
43
|
+
const existing = await readManifest(manifestAbs);
|
|
44
|
+
const existingByPath = new Map(existing.map((e) => [e.path, e]));
|
|
45
|
+
const refSet = new Set(refs.map((r) => r.ref));
|
|
46
|
+
if (opts.mode === "refresh") {
|
|
47
|
+
const entries = [];
|
|
48
|
+
const unresolvable = [];
|
|
49
|
+
const preserved = [];
|
|
50
|
+
for (const r of refs) {
|
|
51
|
+
const abs = resolveRef(r, repo, codeRoot);
|
|
52
|
+
if (abs !== null) {
|
|
53
|
+
entries.push(await attestOne(r.ref, abs));
|
|
54
|
+
}
|
|
55
|
+
else if (existingByPath.has(r.ref)) {
|
|
56
|
+
// Still referenced; artefact not visible from this machine. Keep the
|
|
57
|
+
// prior attestation rather than silently discarding evidence this
|
|
58
|
+
// run cannot re-establish.
|
|
59
|
+
entries.push(existingByPath.get(r.ref));
|
|
60
|
+
preserved.push(r.ref);
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
unresolvable.push(r.ref);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const dropped = existing.filter((e) => !refSet.has(e.path)).map((e) => e.path);
|
|
67
|
+
await writeManifest(manifestAbs, entries);
|
|
68
|
+
log.push(`references collected: ${refs.length}`);
|
|
69
|
+
log.push(`attested: ${entries.length - preserved.length}`);
|
|
70
|
+
report(log, "preserved (referenced; artefact not visible here)", preserved);
|
|
71
|
+
report(log, "unresolvable on this machine (NOT attested)", unresolvable);
|
|
72
|
+
report(log, "dropped (no longer referenced anywhere)", dropped);
|
|
73
|
+
return { exitCode: 0, log };
|
|
74
|
+
}
|
|
75
|
+
// verify
|
|
76
|
+
const drift = [];
|
|
77
|
+
const attestedAbsent = [];
|
|
78
|
+
const presentClean = [];
|
|
79
|
+
const stale = [];
|
|
80
|
+
for (const e of existing) {
|
|
81
|
+
if (!refSet.has(e.path)) {
|
|
82
|
+
stale.push(e.path);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const abs = resolveRefPath(e.path, repo, codeRoot);
|
|
86
|
+
if (abs === null) {
|
|
87
|
+
attestedAbsent.push(e.path);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const digest = await sha256File(abs);
|
|
91
|
+
if (digest === e.sha256)
|
|
92
|
+
presentClean.push(e.path);
|
|
93
|
+
else
|
|
94
|
+
drift.push(`${e.path} (manifest ${e.sha256.slice(0, 12)}… vs present ${digest.slice(0, 12)}…)`);
|
|
95
|
+
}
|
|
96
|
+
const unattested = refs
|
|
97
|
+
.filter((r) => !existingByPath.has(r.ref) && resolveRef(r, repo, codeRoot) === null)
|
|
98
|
+
.map((r) => r.ref);
|
|
99
|
+
log.push(`entries: ${existing.length}, references: ${refs.length}`);
|
|
100
|
+
report(log, "present and clean", presentClean);
|
|
101
|
+
report(log, "attested (absent here, satisfied by manifest)", attestedAbsent);
|
|
102
|
+
report(log, "DRIFT (present artefact disagrees with attestation)", drift);
|
|
103
|
+
report(log, "UNATTESTED (referenced, absent, no entry)", unattested);
|
|
104
|
+
report(log, "stale entries (no longer referenced — remove)", stale);
|
|
105
|
+
const exitCode = drift.length > 0 || unattested.length > 0 ? 1 : 0;
|
|
106
|
+
return { exitCode, log };
|
|
107
|
+
}
|
|
108
|
+
async function collectReferences(repo) {
|
|
109
|
+
const out = new Map();
|
|
110
|
+
// 1. traceability.contracts declarations that do not resolve in-repo.
|
|
111
|
+
const manifests = await loadTraceabilityManifests({
|
|
112
|
+
sddPath: repo,
|
|
113
|
+
findings: new FindingsLog(),
|
|
114
|
+
});
|
|
115
|
+
for (const m of manifests) {
|
|
116
|
+
const block = m.traceability.contracts;
|
|
117
|
+
if (!block)
|
|
118
|
+
continue;
|
|
119
|
+
for (const list of [block.openapi, block.proto, block.avro, block.spicedb, block.graphql, block.cli]) {
|
|
120
|
+
if (!Array.isArray(list))
|
|
121
|
+
continue;
|
|
122
|
+
for (const entry of list) {
|
|
123
|
+
const p = typeof entry === "string" ? entry : entry.path;
|
|
124
|
+
if (typeof p !== "string" || p === "")
|
|
125
|
+
continue;
|
|
126
|
+
if (existsSync(join(repo, p)))
|
|
127
|
+
continue;
|
|
128
|
+
out.set(p, { ref: p });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// 2. markdown links under docs/ escaping the repo root.
|
|
133
|
+
const files = await walkMd(join(repo, "docs"));
|
|
134
|
+
const LINK = /(?<!!)\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
|
|
135
|
+
for (const f of files) {
|
|
136
|
+
const txt = await readFile(f, "utf8").catch(() => "");
|
|
137
|
+
for (const match of txt.matchAll(LINK)) {
|
|
138
|
+
const raw = decodeURIComponent((match[1] ?? "").split("#")[0] ?? "").trim();
|
|
139
|
+
if (raw === "" || /^(https?|mailto|tel):/i.test(raw) || raw.startsWith("#"))
|
|
140
|
+
continue;
|
|
141
|
+
if (isAbsolute(raw) || raw.includes("{") || raw.includes("<"))
|
|
142
|
+
continue;
|
|
143
|
+
const abs = normalize(join(dirname(f), raw));
|
|
144
|
+
if (!relative(repo, abs).startsWith(".."))
|
|
145
|
+
continue;
|
|
146
|
+
out.set(raw, { ref: raw, relTo: dirname(f) });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return [...out.values()];
|
|
150
|
+
}
|
|
151
|
+
function resolveRef(r, repo, codeRoot) {
|
|
152
|
+
if (r.relTo !== undefined) {
|
|
153
|
+
const abs = normalize(join(r.relTo, r.ref));
|
|
154
|
+
return existsSync(abs) ? abs : null;
|
|
155
|
+
}
|
|
156
|
+
return resolveRefPath(r.ref, repo, codeRoot);
|
|
157
|
+
}
|
|
158
|
+
/** Repo-root-relative declaration: in-repo first, then under codeRoot. Doc-relative refs (../…) resolve against repo root's parent chain via normalize. */
|
|
159
|
+
function resolveRefPath(ref, repo, codeRoot) {
|
|
160
|
+
for (const base of [repo, codeRoot]) {
|
|
161
|
+
const abs = normalize(join(base, ref));
|
|
162
|
+
if (existsSync(abs))
|
|
163
|
+
return abs;
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
async function attestOne(ref, abs) {
|
|
168
|
+
const sha256 = await sha256File(abs);
|
|
169
|
+
const source = await sourceCoordinate(abs);
|
|
170
|
+
const kind = inferKind(ref);
|
|
171
|
+
return { path: ref, sha256, source, ...(kind ? { kind } : {}) };
|
|
172
|
+
}
|
|
173
|
+
async function sha256File(abs) {
|
|
174
|
+
const buf = await readFile(abs);
|
|
175
|
+
return createHash("sha256").update(buf).digest("hex");
|
|
176
|
+
}
|
|
177
|
+
async function sourceCoordinate(abs) {
|
|
178
|
+
try {
|
|
179
|
+
const dir = dirname(abs);
|
|
180
|
+
const { stdout: top } = await execFile("git", ["-C", dir, "rev-parse", "--show-toplevel"]);
|
|
181
|
+
const { stdout: head } = await execFile("git", ["-C", dir, "rev-parse", "--short", "HEAD"]);
|
|
182
|
+
const name = top.trim().split(sep).pop() ?? "unknown";
|
|
183
|
+
return `${name}@${head.trim()}`;
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return "unversioned";
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
export function inferKind(ref) {
|
|
190
|
+
if (ref.endsWith(".avsc"))
|
|
191
|
+
return "event";
|
|
192
|
+
if (ref.endsWith(".proto"))
|
|
193
|
+
return "rpc";
|
|
194
|
+
if (ref.endsWith(".zed"))
|
|
195
|
+
return "authz";
|
|
196
|
+
if (/\.graphqls?$/.test(ref))
|
|
197
|
+
return "graphql";
|
|
198
|
+
if (/openapi/.test(ref) && /\.(ya?ml|json)$/.test(ref))
|
|
199
|
+
return "rest";
|
|
200
|
+
// A command manifest lives under a `cli/` contracts folder. Tested after rest, so an OpenAPI file in a
|
|
201
|
+
// repository whose own name ends in -cli stays rest.
|
|
202
|
+
if (/(^|\/)cli\/.*\.ya?ml$/.test(ref))
|
|
203
|
+
return "cli";
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
/** The SDD's attested external artefacts (`.sdd/contract-availability.yaml`); none when it has no manifest. */
|
|
207
|
+
export async function readAvailability(repo) {
|
|
208
|
+
return readManifest(join(repo, AVAILABILITY_MANIFEST_REL_PATH));
|
|
209
|
+
}
|
|
210
|
+
async function readManifest(abs) {
|
|
211
|
+
if (!existsSync(abs))
|
|
212
|
+
return [];
|
|
213
|
+
const raw = await readFile(abs, "utf8");
|
|
214
|
+
const doc = parseYaml(raw);
|
|
215
|
+
if (!doc || !Array.isArray(doc.artefacts))
|
|
216
|
+
return [];
|
|
217
|
+
const out = [];
|
|
218
|
+
for (const a of doc.artefacts) {
|
|
219
|
+
if (a === null || typeof a !== "object")
|
|
220
|
+
continue;
|
|
221
|
+
const o = a;
|
|
222
|
+
if (typeof o["path"] !== "string" || typeof o["sha256"] !== "string")
|
|
223
|
+
continue;
|
|
224
|
+
out.push({
|
|
225
|
+
path: o["path"],
|
|
226
|
+
sha256: o["sha256"],
|
|
227
|
+
source: typeof o["source"] === "string" ? o["source"] : "unversioned",
|
|
228
|
+
...(typeof o["kind"] === "string" ? { kind: o["kind"] } : {}),
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
async function writeManifest(abs, entries) {
|
|
234
|
+
const sorted = [...entries].sort((a, b) => a.path.localeCompare(b.path));
|
|
235
|
+
const lines = ['schema_version: "1.0"', "artefacts:"];
|
|
236
|
+
for (const e of sorted) {
|
|
237
|
+
lines.push(` - path: ${JSON.stringify(e.path)}`);
|
|
238
|
+
lines.push(` sha256: "${e.sha256}"`);
|
|
239
|
+
lines.push(` source: ${JSON.stringify(e.source)}`);
|
|
240
|
+
if (e.kind)
|
|
241
|
+
lines.push(` kind: "${e.kind}"`);
|
|
242
|
+
}
|
|
243
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
244
|
+
await writeFile(abs, lines.join("\n") + "\n", "utf8");
|
|
245
|
+
}
|
|
246
|
+
async function walkMd(root) {
|
|
247
|
+
const { readdir } = await import("node:fs/promises");
|
|
248
|
+
const out = [];
|
|
249
|
+
async function rec(dir) {
|
|
250
|
+
let names;
|
|
251
|
+
try {
|
|
252
|
+
names = await readdir(dir, { withFileTypes: true });
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
for (const d of names) {
|
|
258
|
+
const p = join(dir, d.name);
|
|
259
|
+
if (d.isDirectory()) {
|
|
260
|
+
if (d.name === "node_modules" || d.name === ".git")
|
|
261
|
+
continue;
|
|
262
|
+
await rec(p);
|
|
263
|
+
}
|
|
264
|
+
else if (d.name.endsWith(".md"))
|
|
265
|
+
out.push(p);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
await rec(root);
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
function report(log, label, items) {
|
|
272
|
+
// Count AND list, always (M-ADR 0200 §4.3).
|
|
273
|
+
log.push(`${label}: ${items.length}`);
|
|
274
|
+
for (const i of items)
|
|
275
|
+
log.push(` - ${i}`);
|
|
276
|
+
}
|
|
277
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -175,11 +175,10 @@ export function parseTypes(text, fileLabel = "catalogue-schema.tql", options = {
|
|
|
175
175
|
if (decl.plays.has(w[1]))
|
|
176
176
|
throw new ParseError(`${where}: ${lab} plays ${w[1]} twice`);
|
|
177
177
|
const ann = parseAnnotations(w.slice(2), where);
|
|
178
|
-
// A plays clause's cardinality constrains participation,
|
|
179
|
-
//
|
|
180
|
-
if (ann.key
|
|
181
|
-
throw new ParseError(`${where}: ${lab} plays ${w[1]}
|
|
182
|
-
}
|
|
178
|
+
// A plays clause's cardinality constrains participation, and is kept: a reader that can hold it does, and a
|
|
179
|
+
// projection that cannot refuses it there (schema-projection), never here, so no reader drops it silently.
|
|
180
|
+
if (ann.key)
|
|
181
|
+
throw new ParseError(`${where}: ${lab} plays ${w[1]} @key; a role is not a key`);
|
|
183
182
|
decl.plays.set(w[1], ann);
|
|
184
183
|
}
|
|
185
184
|
else if (head === "relates") {
|
|
@@ -86,6 +86,8 @@ const COMMIT_RE = /^[0-9a-f]{7,40}$/;
|
|
|
86
86
|
// OpenSpec counts a task done when its box holds only `x`, either case, any spacing.
|
|
87
87
|
const TASK_DONE_RE = /^\s*-\s*\[\s*[xX]\s*\]/m;
|
|
88
88
|
const TARGET_HEADING_RE = /^##\s+(MODIFIED|REMOVED|RENAMED)\s+Requirements\b/m;
|
|
89
|
+
// A tasks.md group headed by one word, `## <n>. <word>`, is that repository's work order (taskGroups).
|
|
90
|
+
const GROUP_REPO = (repo) => `tasks.md has a work order for ${repo} and the record's work_orders do not; its evidence cannot be checked (a group heading of one word, \`## <n>. ${repo}\`, names the group's repository)`;
|
|
89
91
|
async function exists(p) {
|
|
90
92
|
try {
|
|
91
93
|
await access(p);
|
|
@@ -516,6 +518,14 @@ export async function checkBehaviourStore(repo, options = {}) {
|
|
|
516
518
|
}
|
|
517
519
|
}
|
|
518
520
|
}
|
|
521
|
+
// The archive check reads a task group's repository from its heading; once the record declares work orders, a
|
|
522
|
+
// heading it would read as a repository the record lacks fails here, while the change can still be amended.
|
|
523
|
+
if (versionAtLeast(record?.schemaVersion, "1.1.0") && record?.status === "agreed" && record.workOrders.length > 0 && tasks !== null) {
|
|
524
|
+
const recorded = new Set(record.workOrders.map((w) => w.repo));
|
|
525
|
+
for (const g of taskGroups(tasks))
|
|
526
|
+
if (!recorded.has(g.repo))
|
|
527
|
+
failures.push(`${record.file}: ${GROUP_REPO(g.repo)}`);
|
|
528
|
+
}
|
|
519
529
|
// A test plan's kinds (M-ADR 0223 §4.4 as amended): changed and repaired scenarios go red then green, carried ones stay green.
|
|
520
530
|
if (versionAtLeast(record?.schemaVersion, "1.1.0") && record?.status === "agreed") {
|
|
521
531
|
const plan = await readText(join(repo, dir, "test-plan.md"));
|
|
@@ -717,7 +727,7 @@ export async function checkBehaviourStore(repo, options = {}) {
|
|
|
717
727
|
const recorded = new Set(record.workOrders.map((w) => w.repo));
|
|
718
728
|
for (const g of groups)
|
|
719
729
|
if (!recorded.has(g.repo))
|
|
720
|
-
failures.push(`${record.file}:
|
|
730
|
+
failures.push(`${record.file}: ${GROUP_REPO(g.repo)}`);
|
|
721
731
|
// Requirement names are unique within a feature, not across a change: a work order names `<feature>::<name>`,
|
|
722
732
|
// or a bare name only one feature in the change has (schema 5.0.0). An ambiguous bare name implements nothing.
|
|
723
733
|
const built = [];
|
package/dist/verify-version.json
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdd-method/maintain-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"sddCliVersion": "5.
|
|
3
|
+
"version": "0.16.1",
|
|
4
|
+
"sddCliVersion": "5.2.1",
|
|
5
5
|
"description": "Bring an SDD repository current and rebuild its derived model — sync, briefing, catalogue build and aggregate, pack build, context pull — plus every read-only verify verb. Ships only the code those verbs reach; it cannot author specification content. Authoring is in @sdd-method/sdd-cli.",
|
|
6
6
|
"license": "UNLICENSED",
|
|
7
7
|
"type": "module",
|