@sdd-method/author-cli 0.12.1 → 0.14.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/README.md +1 -1
- package/dist/author-program.js +10 -2
- package/dist/lib/catalogue/build.js +72 -23
- package/dist/lib/catalogue/builders/adr-governs.js +1 -1
- package/dist/lib/catalogue/builders/adrs.js +2 -2
- package/dist/lib/catalogue/builders/c4-containers.js +6 -2
- package/dist/lib/catalogue/builders/contract-revisions.js +45 -7
- package/dist/lib/catalogue/builders/contracts.js +4 -11
- package/dist/lib/catalogue/builders/data-model.js +1 -1
- package/dist/lib/catalogue/builders/entities-manifest.js +10 -4
- package/dist/lib/catalogue/builders/entity-archetype.js +121 -0
- package/dist/lib/catalogue/builders/entity-merge.js +90 -0
- package/dist/lib/catalogue/builders/features.js +1 -1
- package/dist/lib/catalogue/builders/glossary-xref.js +11 -5
- package/dist/lib/catalogue/builders/journey-requirement.js +5 -5
- package/dist/lib/catalogue/builders/quality-scores.js +1 -1
- package/dist/lib/catalogue/builders/requirements.js +1 -1
- package/dist/lib/catalogue/builders/service-contract-consumes.js +2 -2
- package/dist/lib/catalogue/builders/service-entity-tables.js +1 -1
- package/dist/lib/catalogue/canonical-schema.yaml +50 -30
- package/dist/lib/catalogue/contract-id.js +49 -12
- package/dist/lib/catalogue/manifest-loader.js +1 -1
- package/dist/lib/catalogue/retired-catalogues.js +1 -1
- package/dist/lib/catalogue/rubric-coverage-map.yaml +7 -3
- package/dist/lib/catalogue/schema-compat.js +1 -1
- package/dist/lib/catalogue/staleness-verdicts.js +3 -3
- package/dist/lib/structure/apply.js +71 -0
- package/dist/lib/structure/archive.js +330 -0
- package/dist/lib/structure/catalogue.js +31 -10
- package/dist/lib/structure/compose.js +1 -1
- package/dist/lib/structure/effects.js +1 -1
- package/dist/lib/structure/extract.js +123 -0
- package/dist/lib/structure/intended.js +194 -0
- package/dist/lib/structure/model.js +1 -1
- package/dist/lib/structure/render.js +1 -1
- package/dist/lib/structure/view.js +8 -3
- package/dist/lib/validate/agreement-evidence.js +2 -2
- package/dist/lib/validate/behaviour-store.js +49 -12
- package/dist/lib/validate/dispatch.js +2 -2
- package/dist/lib/validate/journey-impact.js +7 -7
- package/dist/lib/validate/structural-model.js +48 -35
- package/dist/maintain-version.json +2 -2
- package/dist/verbs/change.js +15 -0
- package/dist/verbs/migrate-structure.js +36 -0
- package/dist/verify-version.json +2 -2
- package/package.json +2 -2
|
@@ -7,7 +7,7 @@ import { rm, stat } from "node:fs/promises";
|
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
/** name -> why it went, and what replaced it. */
|
|
9
9
|
export const RETIRED_CATALOGUES = new Map([
|
|
10
|
-
["requirement_status", "retired in catalogue schema 6.0.0: evidence about one revision of a requirement is requirement_assertions (M-ADR 0230 §4.7
|
|
10
|
+
["requirement_status", "retired in catalogue schema 6.0.0: evidence about one revision of a requirement is requirement_assertions (M-ADR 0230 §4.7)"],
|
|
11
11
|
]);
|
|
12
12
|
/** Remove each retired catalogue's file from `dir`. Returns the file names removed. */
|
|
13
13
|
export async function removeRetiredCatalogues(dir, findings, label) {
|
|
@@ -954,7 +954,7 @@ coverage:
|
|
|
954
954
|
entity_attributes:
|
|
955
955
|
kind: entity
|
|
956
956
|
scoring: scored-at-source
|
|
957
|
-
instrument: an element of the structural model (M-ADR 0230
|
|
957
|
+
instrument: an element of the structural model (M-ADR 0230); scored with its domain model by rubrics/domain-architecture-rubric.md
|
|
958
958
|
relationships:
|
|
959
959
|
entity_id:
|
|
960
960
|
class: membership
|
|
@@ -962,7 +962,7 @@ coverage:
|
|
|
962
962
|
entity_relationships:
|
|
963
963
|
kind: entity
|
|
964
964
|
scoring: scored-at-source
|
|
965
|
-
instrument: an element of the structural model (M-ADR 0230
|
|
965
|
+
instrument: an element of the structural model (M-ADR 0230); scored with its domain model by rubrics/domain-architecture-rubric.md
|
|
966
966
|
relationships:
|
|
967
967
|
from_entity_id:
|
|
968
968
|
class: membership
|
|
@@ -973,7 +973,7 @@ coverage:
|
|
|
973
973
|
domain_invariants:
|
|
974
974
|
kind: entity
|
|
975
975
|
scoring: scored-at-source
|
|
976
|
-
instrument: an element of the structural model (M-ADR 0230
|
|
976
|
+
instrument: an element of the structural model (M-ADR 0230); scored with its domain model by rubrics/domain-architecture-rubric.md
|
|
977
977
|
relationships:
|
|
978
978
|
entity_id:
|
|
979
979
|
class: membership
|
|
@@ -1039,3 +1039,7 @@ coverage:
|
|
|
1039
1039
|
kind: enum
|
|
1040
1040
|
scoring: not-applicable
|
|
1041
1041
|
reason: closed vocabulary, not an artefact
|
|
1042
|
+
ppt_archetype:
|
|
1043
|
+
kind: enum
|
|
1044
|
+
scoring: not-applicable
|
|
1045
|
+
reason: closed vocabulary, not an artefact
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
// none — already reported by scores-coverage as unknown currency; not
|
|
41
41
|
// re-reported here.
|
|
42
42
|
//
|
|
43
|
-
// ASSESSED INPUTS (M-ADR 0230 §4.9
|
|
43
|
+
// ASSESSED INPUTS (M-ADR 0230 §4.9): a review that names its other inputs (`input_paths`) is read with
|
|
44
44
|
// each of them, once its target is current. An input that moved stales the score, with the input named. So does an
|
|
45
45
|
// input gone from HEAD, and one the corpus did not hold at the anchor: unlike a missing target, the score can still
|
|
46
46
|
// be re-reviewed against its target, and a review cannot have read an input that was not there, so both prescribe a
|
|
@@ -171,7 +171,7 @@ export async function runStalenessVerdicts(session, options) {
|
|
|
171
171
|
};
|
|
172
172
|
}
|
|
173
173
|
const asStr = (v) => v === null || v === undefined ? "" : String(v).trim();
|
|
174
|
-
// input_paths (schema 6.0.0, M-ADR 0230 §4.9
|
|
174
|
+
// input_paths (schema 6.0.0, M-ADR 0230 §4.9): a catalogue built before it has no such column, and its
|
|
175
175
|
// scores are read as naming no other input.
|
|
176
176
|
const query = (inputs) => `SELECT rubric_id, target_ref, target_path, corpus_ref, scored_at, staleness_basis, ${inputs}
|
|
177
177
|
FROM "quality_scores"
|
|
@@ -191,7 +191,7 @@ export async function runStalenessVerdicts(session, options) {
|
|
|
191
191
|
const rubricId = asStr(rubricIdRaw);
|
|
192
192
|
const targetRef = asStr(targetRefRaw);
|
|
193
193
|
const basis = asStr(basisRaw);
|
|
194
|
-
// The review's other assessed inputs (M-ADR 0230 §4.9
|
|
194
|
+
// The review's other assessed inputs (M-ADR 0230 §4.9): read with the target, so an edit to any of
|
|
195
195
|
// them makes the score stale as an edit to the target does.
|
|
196
196
|
const inputs = asStr(inputPathsRaw).split(";").map((x) => x.trim()).filter((x) => x !== "");
|
|
197
197
|
// Counted for BOTH bases, before the branch. The first cut of this counter
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Writing a change's structural deltas into the model files, and the record of what they did (M-ADR 0230 §4.6).
|
|
2
|
+
// The files are edited as YAML documents, so their comments and element order survive: a modified element
|
|
3
|
+
// is replaced where it stands, a removed one is deleted, an added one is appended to its catalogue's list, and a
|
|
4
|
+
// partition the model does not have yet gets a file. Writes files; the archive operation calls it in a scratch copy.
|
|
5
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { isMap, isSeq, parseDocument, stringify } from "yaml";
|
|
8
|
+
import { elementKey, FORMAT_VERSION, STRUCTURE_DIR } from "./model.js";
|
|
9
|
+
export { APPLIED_FILE } from "./model.js";
|
|
10
|
+
export { effectsOf, parseAppliedRecord } from "./effects.js";
|
|
11
|
+
export function appliedRecord(change, baseCommit, effects) {
|
|
12
|
+
return stringify({ schema_version: FORMAT_VERSION, change, base_commit: baseCommit, effects: effects.map((e) => ({ ...e })) });
|
|
13
|
+
}
|
|
14
|
+
function itemIndex(seq, id) {
|
|
15
|
+
return seq.items.findIndex((it) => isMap(it) && String(it.get("id")) === id);
|
|
16
|
+
}
|
|
17
|
+
function catalogueSeq(doc, catalogue) {
|
|
18
|
+
let elements = doc.get("elements");
|
|
19
|
+
if (!isMap(elements)) {
|
|
20
|
+
doc.set("elements", doc.createNode({}));
|
|
21
|
+
elements = doc.get("elements");
|
|
22
|
+
}
|
|
23
|
+
const map = elements;
|
|
24
|
+
let seq = map.get(catalogue);
|
|
25
|
+
if (!isSeq(seq)) {
|
|
26
|
+
map.set(catalogue, doc.createNode([]));
|
|
27
|
+
seq = map.get(catalogue);
|
|
28
|
+
}
|
|
29
|
+
return seq;
|
|
30
|
+
}
|
|
31
|
+
/** Apply the change's deltas to the model files under `sddPath`, partition by partition. Returns the files written. */
|
|
32
|
+
export async function applyToModel(sddPath, s) {
|
|
33
|
+
const written = [];
|
|
34
|
+
for (const d of s.deltas) {
|
|
35
|
+
const rel = join(STRUCTURE_DIR, `${d.partition}.yaml`);
|
|
36
|
+
const path = join(sddPath, rel);
|
|
37
|
+
let text;
|
|
38
|
+
try {
|
|
39
|
+
text = await readFile(path, "utf8");
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
text = stringify({ schema_version: FORMAT_VERSION, partition: d.partition, elements: {} });
|
|
43
|
+
}
|
|
44
|
+
const doc = parseDocument(text);
|
|
45
|
+
const node = (e) => doc.createNode({ id: e.id, ...e.columns });
|
|
46
|
+
for (const e of d.modified) {
|
|
47
|
+
const seq = catalogueSeq(doc, e.catalogue);
|
|
48
|
+
const i = itemIndex(seq, e.id);
|
|
49
|
+
if (i < 0)
|
|
50
|
+
throw new Error(`${rel}: MODIFIED ${elementKey(e.catalogue, e.id)} is not in this partition`);
|
|
51
|
+
seq.items[i] = node(e);
|
|
52
|
+
}
|
|
53
|
+
for (const r of d.removed) {
|
|
54
|
+
const seq = catalogueSeq(doc, r.catalogue);
|
|
55
|
+
const i = itemIndex(seq, r.id);
|
|
56
|
+
if (i < 0)
|
|
57
|
+
throw new Error(`${rel}: REMOVED ${elementKey(r.catalogue, r.id)} is not in this partition`);
|
|
58
|
+
seq.items.splice(i, 1);
|
|
59
|
+
}
|
|
60
|
+
for (const e of d.added) {
|
|
61
|
+
const seq = catalogueSeq(doc, e.catalogue);
|
|
62
|
+
if (itemIndex(seq, e.id) >= 0)
|
|
63
|
+
throw new Error(`${rel}: ADDED ${elementKey(e.catalogue, e.id)} is already in this partition`);
|
|
64
|
+
seq.add(node(e));
|
|
65
|
+
}
|
|
66
|
+
await writeFile(path, doc.toString(), "utf8");
|
|
67
|
+
written.push(rel);
|
|
68
|
+
}
|
|
69
|
+
return written;
|
|
70
|
+
}
|
|
71
|
+
//# sourceMappingURL=apply.js.map
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
// The method's archive operation (M-ADR 0230 §4.6): behaviour and structure published as one unit.
|
|
2
|
+
// 1. prepare the full candidate in a scratch worktree at the store's current commit: OpenSpec archives the
|
|
3
|
+
// change there (behaviour), and its structural deltas are applied to the model with a record of
|
|
4
|
+
// their effect (structure/applied.yaml in the archived change)
|
|
5
|
+
// 2. validate the candidate as a whole: the change's deltas still apply (§4.5 again at archive), the model as it
|
|
6
|
+
// would stand fails nowhere the current model does not, and the behaviour-store check finds nothing
|
|
7
|
+
// wrong with the archived change (its agreed content, its verdict)
|
|
8
|
+
// 3. publish one commit, fast-forwarded onto the store only if the store has not moved since preparation; the
|
|
9
|
+
// model's generated views (docs/structure/) are rendered into it, since they are required and derived
|
|
10
|
+
// (M-ADR 0230 §4.3, at its acceptance)
|
|
11
|
+
// Readers take a published commit, so they see the old state or the new. A retry is idempotent: an archived change
|
|
12
|
+
// whose effect record holds at the commit that wrote it is reported as already archived. Recovery works from what
|
|
13
|
+
// the store holds: an archived change with structural deltas and no effect record (a raw `openspec archive`) has its
|
|
14
|
+
// deltas applied only if each element it writes is still at the revision it was written against.
|
|
15
|
+
//
|
|
16
|
+
// The store is the SDD's own `openspec/` in a git repository. Writes; never part of the verify-only CLI.
|
|
17
|
+
import { execFile as execFileCb } from "node:child_process";
|
|
18
|
+
import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
19
|
+
import { tmpdir } from "node:os";
|
|
20
|
+
import { dirname, join, relative } from "node:path";
|
|
21
|
+
import { promisify } from "node:util";
|
|
22
|
+
import { parseRequirements, resolveImplements } from "../behaviour-store/archive.js";
|
|
23
|
+
import { buildRequirements } from "../catalogue/builders/requirements.js";
|
|
24
|
+
import { parseCatalogueCsv } from "../catalogue/csv-reader.js";
|
|
25
|
+
import { FindingsLog } from "../catalogue/findings.js";
|
|
26
|
+
import { loadCanonicalSchema } from "../catalogue/schema-loader.js";
|
|
27
|
+
import { checkBehaviourStore, parseAgreementRecord } from "../validate/behaviour-store.js";
|
|
28
|
+
import { appliedRecord, applyToModel, effectsOf, parseAppliedRecord } from "./apply.js";
|
|
29
|
+
import { APPLIED_FILE, loadChangeStructure, loadModel } from "./model.js";
|
|
30
|
+
import { renderViews } from "./render.js";
|
|
31
|
+
import { revisionOf, validateDeltas, validateView, viewOf } from "./view.js";
|
|
32
|
+
const execFile = promisify(execFileCb);
|
|
33
|
+
const CHANGES = join("openspec", "changes");
|
|
34
|
+
/** Exit codes: 0 archived or already archived, 1 refused, 75 interrupted (tests). */
|
|
35
|
+
export const INTERRUPTED = 75;
|
|
36
|
+
async function git(cwd, ...args) {
|
|
37
|
+
return (await execFile("git", ["-C", cwd, ...args], { maxBuffer: 64 * 1024 * 1024 })).stdout.trim();
|
|
38
|
+
}
|
|
39
|
+
async function exists(p) {
|
|
40
|
+
return stat(p).then(() => true, () => false);
|
|
41
|
+
}
|
|
42
|
+
async function subdirs(p) {
|
|
43
|
+
try {
|
|
44
|
+
return (await readdir(p, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name).sort();
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
async function archivedDir(sddPath, change) {
|
|
51
|
+
const found = (await subdirs(join(sddPath, CHANGES, "archive"))).filter((d) => d.replace(/^\d{4}-\d{2}-\d{2}-/, "") === change);
|
|
52
|
+
return found.length > 0 ? found[found.length - 1] : null;
|
|
53
|
+
}
|
|
54
|
+
async function defaultOpenspecArchive(cwd, change) {
|
|
55
|
+
try {
|
|
56
|
+
const r = await execFile("openspec", ["archive", change, "--yes"], { cwd, env: { ...process.env, OPENSPEC_TELEMETRY: "0" }, maxBuffer: 16 * 1024 * 1024 });
|
|
57
|
+
return { code: 0, output: r.stdout + r.stderr };
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
const e = err;
|
|
61
|
+
return { code: typeof e.code === "number" ? e.code : 127, output: `${e.stdout ?? ""}${e.stderr ?? ""}` };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async function defaultBehaviourFindings(sddPath, archiveDir) {
|
|
65
|
+
const r = await checkBehaviourStore(sddPath);
|
|
66
|
+
return r.stdout.split("\n").filter((l) => l.startsWith("[FAIL]") && l.includes(archiveDir));
|
|
67
|
+
}
|
|
68
|
+
function viewProblems(schema, view) {
|
|
69
|
+
const out = [];
|
|
70
|
+
validateView(schema, view, () => null, out, new Set());
|
|
71
|
+
return new Set(out);
|
|
72
|
+
}
|
|
73
|
+
/** Does the effect record hold at `commit`? Each added or modified element at its target, each removed one absent. */
|
|
74
|
+
async function verifyAt(root, relSdd, commit, effects) {
|
|
75
|
+
const scratch = await mkdtemp(join(tmpdir(), "sdd-archive-verify-"));
|
|
76
|
+
try {
|
|
77
|
+
await git(root, "worktree", "add", "--detach", "-q", scratch, commit);
|
|
78
|
+
const problems = [];
|
|
79
|
+
const files = (await loadModel(join(scratch, relSdd), problems)) ?? [];
|
|
80
|
+
const view = viewOf(files, problems);
|
|
81
|
+
const bad = [];
|
|
82
|
+
for (const e of effects) {
|
|
83
|
+
const [catalogue, ...rest] = e.element.split("/");
|
|
84
|
+
const el = view.get(catalogue)?.get(rest.join("/"));
|
|
85
|
+
if (e.operation === "REMOVED") {
|
|
86
|
+
if (el)
|
|
87
|
+
bad.push(`${e.element} is still present`);
|
|
88
|
+
}
|
|
89
|
+
else if (!el)
|
|
90
|
+
bad.push(`${e.element} is absent`);
|
|
91
|
+
else if (revisionOf(el) !== e.target)
|
|
92
|
+
bad.push(`${e.element} is at ${revisionOf(el).slice(0, 19)}…, not the recorded ${String(e.target).slice(0, 19)}…`);
|
|
93
|
+
}
|
|
94
|
+
return bad;
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
await git(root, "worktree", "remove", "--force", scratch).catch(() => undefined);
|
|
98
|
+
await rm(scratch, { recursive: true, force: true });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
export async function archiveChange(o) {
|
|
102
|
+
const say = (s) => o.write(`${s}\n`);
|
|
103
|
+
const refuse = (s) => {
|
|
104
|
+
say(`[FAIL] change archive ${o.change}: ${s}`);
|
|
105
|
+
return 1;
|
|
106
|
+
};
|
|
107
|
+
let root;
|
|
108
|
+
try {
|
|
109
|
+
root = await git(o.sddPath, "rev-parse", "--show-toplevel");
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return refuse(`${o.sddPath} is not in a git repository; the archive is published as a commit`);
|
|
113
|
+
}
|
|
114
|
+
const relSdd = relative(root, o.sddPath) || ".";
|
|
115
|
+
const schema = o.schema ?? (await loadCanonicalSchema());
|
|
116
|
+
const openDir = join(CHANGES, o.change);
|
|
117
|
+
const isOpen = await exists(join(o.sddPath, openDir));
|
|
118
|
+
const archived = await archivedDir(o.sddPath, o.change);
|
|
119
|
+
// Already archived: verify, or recover a raw archive.
|
|
120
|
+
if (!isOpen && archived !== null) {
|
|
121
|
+
const archRel = join(CHANGES, "archive", archived);
|
|
122
|
+
const problems = [];
|
|
123
|
+
const s = await loadChangeStructure(o.sddPath, join("archive", archived), problems);
|
|
124
|
+
if (problems.length > 0)
|
|
125
|
+
return refuse(`its archived structural artifact is malformed: ${problems.join("; ")}`);
|
|
126
|
+
if (s.deltas.length === 0) {
|
|
127
|
+
say(`[PASS] change archive ${o.change}: already archived (${archRel}); it carries no structural deltas`);
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
130
|
+
const recordPath = join(o.sddPath, archRel, "structure", APPLIED_FILE);
|
|
131
|
+
const text = await readFile(recordPath, "utf8").catch(() => null);
|
|
132
|
+
if (text !== null) {
|
|
133
|
+
const rec = parseAppliedRecord(text);
|
|
134
|
+
if (rec === null)
|
|
135
|
+
return refuse(`${join(archRel, "structure", APPLIED_FILE)} is not a valid effect record`);
|
|
136
|
+
const at = await git(root, "log", "-1", "--format=%H", "--", relative(root, recordPath));
|
|
137
|
+
if (at === "")
|
|
138
|
+
return refuse(`${join(archRel, "structure", APPLIED_FILE)} is not committed; the archive was not published`);
|
|
139
|
+
const bad = await verifyAt(root, relSdd, at, rec.effects);
|
|
140
|
+
if (bad.length > 0)
|
|
141
|
+
return refuse(`its effect record does not hold at ${at.slice(0, 12)}, the commit that wrote it: ${bad.join("; ")}`);
|
|
142
|
+
say(`[PASS] change archive ${o.change}: already archived at ${at.slice(0, 12)}; its ${rec.effects.length} structural effect(s) verified there`);
|
|
143
|
+
return 0;
|
|
144
|
+
}
|
|
145
|
+
// A raw archive: the behaviour moved, the structure did not. Apply it, guarded by each written element's base.
|
|
146
|
+
return publish(o, root, relSdd, schema, s, archRel, "recover");
|
|
147
|
+
}
|
|
148
|
+
if (!isOpen)
|
|
149
|
+
return refuse(`no open change ${openDir}, and none archived`);
|
|
150
|
+
// An open change: preconditions.
|
|
151
|
+
const baseline = await exists(join(o.sddPath, openDir, "baseline-evidence.md"));
|
|
152
|
+
if (!baseline) {
|
|
153
|
+
const recFile = join("docs", "agreements", `${o.change}.yaml`);
|
|
154
|
+
const recText = await readFile(join(o.sddPath, recFile), "utf8").catch(() => null);
|
|
155
|
+
if (recText === null)
|
|
156
|
+
return refuse(`no agreement record (${recFile}); a change archives on its judge's PASS`);
|
|
157
|
+
const { record } = parseAgreementRecord(recFile, recText);
|
|
158
|
+
if (!record)
|
|
159
|
+
return refuse(`${recFile} does not parse as an agreement record`);
|
|
160
|
+
if (record.status !== "agreed")
|
|
161
|
+
return refuse(`${recFile} says status ${record.status}, not agreed`);
|
|
162
|
+
if (String(record.verdict?.result ?? "") !== "PASS")
|
|
163
|
+
return refuse(`${recFile} has no verdict.result PASS; a change archives on its judge's PASS`);
|
|
164
|
+
const left = await leftOut(o.sddPath, o.change, record.workOrders, record.noLongerImplements);
|
|
165
|
+
if (left.length > 0)
|
|
166
|
+
return refuse(left.join("\n "));
|
|
167
|
+
}
|
|
168
|
+
const problems = [];
|
|
169
|
+
const s = await loadChangeStructure(o.sddPath, o.change, problems);
|
|
170
|
+
if (problems.length > 0)
|
|
171
|
+
return refuse(`its structural artifact is malformed: ${problems.join("; ")}`);
|
|
172
|
+
for (const dep of new Set(s.deltas.flatMap((d) => [...d.dependsOn]))) {
|
|
173
|
+
if ((await archivedDir(o.sddPath, dep)) === null)
|
|
174
|
+
return refuse(`it depends on ${dep}, which has not archived; a change archives after its dependencies`);
|
|
175
|
+
}
|
|
176
|
+
return publish(o, root, relSdd, schema, s, openDir, "archive");
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* M-ADR 0230 §4.7: when a change moves a requirement to a new revision, each repository with evidence about the
|
|
180
|
+
* current one implements the new one too, through a work order, or the record says it no longer implements it
|
|
181
|
+
* (`no_longer_implements`). Otherwise the archive would leave that repository's evidence about a revision that is no
|
|
182
|
+
* longer live, and nothing about the one that is. Reads the store as the catalogue builder does.
|
|
183
|
+
*/
|
|
184
|
+
async function leftOut(sddPath, change, workOrders, noLonger) {
|
|
185
|
+
// Only a change that modifies a requirement can leave a repository behind; any change can name a requirement
|
|
186
|
+
// ambiguously.
|
|
187
|
+
const delta = [];
|
|
188
|
+
let modifies = false;
|
|
189
|
+
for (const feature of await subdirs(join(sddPath, CHANGES, change, "specs"))) {
|
|
190
|
+
const text = await readFile(join(sddPath, CHANGES, change, "specs", feature, "spec.md"), "utf8").catch(() => "");
|
|
191
|
+
for (const x of parseRequirements(text)) {
|
|
192
|
+
if (x.operation === "MODIFIED")
|
|
193
|
+
modifies = true;
|
|
194
|
+
if (x.operation !== "REMOVED")
|
|
195
|
+
delta.push({ feature, name: x.name });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// A requirement named in a work order or under no_longer_implements: `<feature>::<name>`, or a bare name only one
|
|
199
|
+
// feature in the change has, as the record schema says. An ambiguous bare name names neither, and is refused.
|
|
200
|
+
const ambiguous = [];
|
|
201
|
+
const resolve = (entry, where) => {
|
|
202
|
+
const r = resolveImplements(delta, entry);
|
|
203
|
+
if (r.ambiguous.length > 0)
|
|
204
|
+
ambiguous.push(`${where} names "${entry}", a requirement ${r.ambiguous.length} features in the change share (${r.ambiguous.join(", ")}); qualify it as <feature>::<name>`);
|
|
205
|
+
return new Set(r.matches.map((d) => `${d.feature}::${d.name}`));
|
|
206
|
+
};
|
|
207
|
+
const implementsOf = workOrders.map((w) => ({ repo: w.repo, ids: new Set(w.implements.flatMap((i) => [...resolve(i, `the work order for ${w.repo}`)])) }));
|
|
208
|
+
const excusedOf = noLonger.map((n) => ({ repo: n.repo, ids: resolve(n.requirement, `no_longer_implements for ${n.repo}`) }));
|
|
209
|
+
if (ambiguous.length > 0)
|
|
210
|
+
return ambiguous;
|
|
211
|
+
if (!modifies)
|
|
212
|
+
return [];
|
|
213
|
+
const ids = async (catalogue) => {
|
|
214
|
+
const text = await readFile(join(sddPath, "catalogues", `${catalogue}.csv`), "utf8").catch(() => null);
|
|
215
|
+
return text === null ? null : new Set(parseCatalogueCsv(text).rows.map((r) => String(r["id"])));
|
|
216
|
+
};
|
|
217
|
+
const features = await ids("features");
|
|
218
|
+
const repos = await ids("repositories");
|
|
219
|
+
if (features === null || repos === null)
|
|
220
|
+
return ["the catalogue (catalogues/features.csv and repositories.csv) is needed to tell which repositories implement the requirements this change modifies; build it first"];
|
|
221
|
+
const r = await buildRequirements({ sddPath, findings: new FindingsLog(), knownFeatureIds: features, knownRepoIds: repos, sddId: "local" });
|
|
222
|
+
const state = new Map(r.revisions.map((x) => [String(x["id"]), String(x["state"])]));
|
|
223
|
+
const modified = r.effects
|
|
224
|
+
.filter((e) => e["change_id"] === `local::${change}` && e["element_kind"] === "requirement" && e["operation"] === "MODIFIED")
|
|
225
|
+
.map((e) => String(e["element_id"]));
|
|
226
|
+
const out = [];
|
|
227
|
+
for (const req of modified) {
|
|
228
|
+
const implementers = new Set(r.assertions.filter((a) => a["requirement_id"] === req && state.get(String(a["revision_id"])) === "current").map((a) => String(a["repo_id"])));
|
|
229
|
+
const covered = new Set(implementsOf.filter((w) => w.ids.has(req)).map((w) => w.repo));
|
|
230
|
+
const excused = new Set(excusedOf.filter((n) => n.ids.has(req)).map((n) => n.repo));
|
|
231
|
+
const missing = [...implementers].filter((repo) => !covered.has(repo) && !excused.has(repo)).sort();
|
|
232
|
+
if (missing.length > 0) {
|
|
233
|
+
out.push(`${req}: ${missing.join(", ")} ${missing.length === 1 ? "implements" : "implement"} its current revision, and no work order of this change implements the new one; add a work order for ${missing.length === 1 ? "it" : "each"}, or record it under no_longer_implements`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
async function publish(o, root, relSdd, schema, s, changeRel, mode) {
|
|
239
|
+
const say = (x) => o.write(`${x}\n`);
|
|
240
|
+
const refuse = (x) => {
|
|
241
|
+
say(`[FAIL] change archive ${o.change}: ${x}`);
|
|
242
|
+
return 1;
|
|
243
|
+
};
|
|
244
|
+
const storeRel = join(relSdd, "openspec");
|
|
245
|
+
const viewsRel = join(relSdd, "docs", "structure");
|
|
246
|
+
const dirty = await git(root, "status", "--porcelain", "--", storeRel, viewsRel);
|
|
247
|
+
if (dirty !== "")
|
|
248
|
+
return refuse(`the store has uncommitted changes (${dirty.split("\n").length} path(s) under ${storeRel} or ${viewsRel}); the archive is published as one commit onto a clean store`);
|
|
249
|
+
const base = await git(root, "rev-parse", "HEAD");
|
|
250
|
+
// §4.5 again, against the model as it now is.
|
|
251
|
+
const problems = [];
|
|
252
|
+
const current = viewOf((await loadModel(o.sddPath, problems)) ?? [], problems);
|
|
253
|
+
const before = viewProblems(schema, current);
|
|
254
|
+
const deltaProblems = [];
|
|
255
|
+
validateDeltas(schema, current, s.deltas, new Map(), deltaProblems);
|
|
256
|
+
if (deltaProblems.length > 0)
|
|
257
|
+
return refuse(`its structural deltas no longer apply to the model as it stands:\n ${deltaProblems.join("\n ")}`);
|
|
258
|
+
const effects = effectsOf(s, current);
|
|
259
|
+
const scratch = await mkdtemp(join(tmpdir(), "sdd-archive-"));
|
|
260
|
+
let added = false;
|
|
261
|
+
try {
|
|
262
|
+
await git(root, "worktree", "add", "--detach", "-q", scratch, base);
|
|
263
|
+
added = true;
|
|
264
|
+
const scratchSdd = join(scratch, relSdd);
|
|
265
|
+
// 1. Prepare.
|
|
266
|
+
let archiveRel = changeRel;
|
|
267
|
+
if (mode === "archive") {
|
|
268
|
+
const beforeDirs = new Set(await subdirs(join(scratchSdd, CHANGES, "archive")));
|
|
269
|
+
const r = await (o.openspecArchive ?? defaultOpenspecArchive)(scratchSdd, o.change);
|
|
270
|
+
if (r.code !== 0)
|
|
271
|
+
return refuse(`openspec archive failed (exit ${r.code}): ${r.output.trim().split("\n").slice(-3).join(" ")}`);
|
|
272
|
+
const fresh = (await subdirs(join(scratchSdd, CHANGES, "archive"))).filter((d) => !beforeDirs.has(d));
|
|
273
|
+
if (fresh.length !== 1)
|
|
274
|
+
return refuse(`openspec archive left ${fresh.length} new archive folder(s), not one`);
|
|
275
|
+
archiveRel = join(CHANGES, "archive", fresh[0]);
|
|
276
|
+
}
|
|
277
|
+
await applyToModel(scratchSdd, s);
|
|
278
|
+
if (s.deltas.length > 0)
|
|
279
|
+
await writeFile(join(scratchSdd, archiveRel, "structure", APPLIED_FILE), appliedRecord(o.change, base, effects), "utf8");
|
|
280
|
+
// 2. Validate the candidate as a whole.
|
|
281
|
+
const cp = [];
|
|
282
|
+
const candidate = viewOf((await loadModel(scratchSdd, cp)) ?? [], cp);
|
|
283
|
+
const after = viewProblems(schema, candidate);
|
|
284
|
+
const worse = [...cp, ...[...after].filter((p) => !before.has(p))];
|
|
285
|
+
if (worse.length > 0)
|
|
286
|
+
return refuse(`the model as it would stand fails where it did not:\n ${worse.join("\n ")}`);
|
|
287
|
+
if (mode === "archive") {
|
|
288
|
+
const bf = await (o.behaviourFindings ?? defaultBehaviourFindings)(scratchSdd, archiveRel.split("/").pop());
|
|
289
|
+
if (bf.length > 0)
|
|
290
|
+
return refuse(`the behaviour-store check refuses the archived change:\n ${bf.join("\n ")}`);
|
|
291
|
+
}
|
|
292
|
+
if (o.interrupt === "prepared")
|
|
293
|
+
return INTERRUPTED;
|
|
294
|
+
// The generated views move with the model they render, in the same commit.
|
|
295
|
+
if (s.deltas.length > 0) {
|
|
296
|
+
for (const [path, text] of renderViews(candidate)) {
|
|
297
|
+
await mkdir(dirname(join(scratchSdd, path)), { recursive: true });
|
|
298
|
+
await writeFile(join(scratchSdd, path), text, "utf8");
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
// 3. Publish: one commit, fast-forwarded only onto the commit it was prepared from.
|
|
302
|
+
const withViews = await stat(join(scratch, viewsRel)).then(() => true, () => false);
|
|
303
|
+
await git(scratch, "add", "-A", "--", storeRel, ...(withViews ? [viewsRel] : []));
|
|
304
|
+
const message = mode === "archive"
|
|
305
|
+
? `Archive ${o.change}: its behaviour and structure (M-ADR 0230 §4.6)`
|
|
306
|
+
: `Apply the structure of archived ${o.change}, which was archived without it (M-ADR 0230 §4.6)`;
|
|
307
|
+
await git(scratch, "commit", "-q", "-m", message);
|
|
308
|
+
const commit = await git(scratch, "rev-parse", "HEAD");
|
|
309
|
+
if (o.interrupt === "committed")
|
|
310
|
+
return INTERRUPTED;
|
|
311
|
+
if ((await git(root, "rev-parse", "HEAD")) !== base)
|
|
312
|
+
return refuse(`the store moved since the candidate was prepared (from ${base.slice(0, 12)}); run it again`);
|
|
313
|
+
try {
|
|
314
|
+
await git(root, "merge", "--ff-only", "-q", commit);
|
|
315
|
+
}
|
|
316
|
+
catch (err) {
|
|
317
|
+
return refuse(`the store moved since the candidate was prepared, so nothing was published: ${err.message.split("\n")[0]}`);
|
|
318
|
+
}
|
|
319
|
+
if (o.interrupt === "published")
|
|
320
|
+
return INTERRUPTED;
|
|
321
|
+
say(`[PASS] change archive ${o.change}: ${mode === "archive" ? "archived" : "its structure applied"} in ${commit.slice(0, 12)}, ${effects.length} structural effect(s)${s.deltas.length > 0 ? `, recorded in ${join(archiveRel, "structure", APPLIED_FILE)}` : ""}`);
|
|
322
|
+
return 0;
|
|
323
|
+
}
|
|
324
|
+
finally {
|
|
325
|
+
if (added)
|
|
326
|
+
await git(root, "worktree", "remove", "--force", scratch).catch(() => undefined);
|
|
327
|
+
await rm(scratch, { recursive: true, force: true });
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
//# sourceMappingURL=archive.js.map
|
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
// The catalogue from the structural model, and the model a built catalogue implies (M-ADR 0230 §4.3, §5.3
|
|
2
|
-
//
|
|
1
|
+
// The catalogue from the structural model, and the model a built catalogue implies (M-ADR 0230 §4.3, §5.3).
|
|
2
|
+
// Where an SDD has a model, it is the record of structure, so every structure catalogue is built from it whole:
|
|
3
3
|
// a row per element of the current view, with the model's structure columns;
|
|
4
4
|
// its other columns (every class but structure: derived, declaration, evidence, intent, execution) from the
|
|
5
5
|
// source-built row with the same id;
|
|
6
6
|
// for an element only the model holds, the derived columns the model itself settles (MODEL_DERIVED).
|
|
7
7
|
// What the sources hold and the model does not, and a structure column where they disagree, is reported once per
|
|
8
|
-
// catalogue. The model wins: either finding means the sources moved outside a change.
|
|
8
|
+
// catalogue. The model wins: either finding means the sources moved outside a change. A source row that states an
|
|
9
|
+
// element as an agreed open change adds or modifies it is that change's intended element, not a disagreement: it is
|
|
10
|
+
// listed apart, and catalogued once the change archives. Pure: no file is read or written.
|
|
9
11
|
import { stringify } from "yaml";
|
|
10
|
-
import { FORMAT_VERSION } from "./model.js";
|
|
12
|
+
import { elementKey, FORMAT_VERSION } from "./model.js";
|
|
11
13
|
import { STRUCTURE } from "./view.js";
|
|
12
14
|
/** The view each generated file renders, for a derived path column of an element only the model holds. */
|
|
13
15
|
export const VIEW_PATHS = {
|
|
@@ -44,8 +46,10 @@ function sample(ids) {
|
|
|
44
46
|
/**
|
|
45
47
|
* Replace every structure catalogue's rows with the view's. Rows of other catalogues are returned as they are. Source
|
|
46
48
|
* rows sharing an id are merged first, later values filling earlier gaps, as the keyed-table dedupe would.
|
|
49
|
+
* `intended` holds the elements agreed open changes add or modify (intendedWrites); a source row agreeing with one is
|
|
50
|
+
* pushed to `intendedOut` as `<key> (<change>)` instead of being reported.
|
|
47
51
|
*/
|
|
48
|
-
export function overlayModel(schema, view, rows, findings) {
|
|
52
|
+
export function overlayModel(schema, view, rows, findings, intended = new Map(), intendedOut = []) {
|
|
49
53
|
const out = new Map(rows);
|
|
50
54
|
for (const cat of schema.catalogues) {
|
|
51
55
|
if (cat.sourceOfTruth !== STRUCTURE)
|
|
@@ -66,6 +70,20 @@ export function overlayModel(schema, view, rows, findings) {
|
|
|
66
70
|
const elements = view.get(cat.name) ?? new Map();
|
|
67
71
|
const built = [];
|
|
68
72
|
const differ = [];
|
|
73
|
+
const disagree = (from, e) => structural.filter((c) => cell(from[c]) !== "" && cell(from[c]) !== cell(e.columns[c]));
|
|
74
|
+
// The source row states the element as an agreed open change does; one stating it otherwise is reported as such.
|
|
75
|
+
const differIntended = [];
|
|
76
|
+
const asIntended = (id, from) => {
|
|
77
|
+
const w = intended.get(elementKey(cat.name, id));
|
|
78
|
+
if (w === undefined)
|
|
79
|
+
return false;
|
|
80
|
+
const cols = disagree(from, w.element);
|
|
81
|
+
if (cols.length > 0)
|
|
82
|
+
differIntended.push(`${id} (${cols.join(", ")}; ${w.change})`);
|
|
83
|
+
else
|
|
84
|
+
intendedOut.push(`${elementKey(cat.name, id)} (${w.change})`);
|
|
85
|
+
return true;
|
|
86
|
+
};
|
|
69
87
|
for (const e of elements.values()) {
|
|
70
88
|
const from = source.get(e.id);
|
|
71
89
|
const row = {};
|
|
@@ -73,8 +91,8 @@ export function overlayModel(schema, view, rows, findings) {
|
|
|
73
91
|
for (const [k, v] of Object.entries(from))
|
|
74
92
|
if (!structural.includes(k))
|
|
75
93
|
row[k] = v;
|
|
76
|
-
const cols =
|
|
77
|
-
if (cols.length > 0)
|
|
94
|
+
const cols = disagree(from, e);
|
|
95
|
+
if (cols.length > 0 && !asIntended(e.id, from))
|
|
78
96
|
differ.push(`${e.id} (${cols.join(", ")})`);
|
|
79
97
|
}
|
|
80
98
|
else {
|
|
@@ -85,12 +103,15 @@ export function overlayModel(schema, view, rows, findings) {
|
|
|
85
103
|
row[c] = e.columns[c];
|
|
86
104
|
built.push(row);
|
|
87
105
|
}
|
|
88
|
-
const missing = [...source.keys()].filter((id) => id !== "" && !elements.has(id)).sort();
|
|
106
|
+
const missing = [...source.keys()].filter((id) => id !== "" && !elements.has(id) && !asIntended(id, source.get(id))).sort();
|
|
89
107
|
if (missing.length > 0) {
|
|
90
|
-
findings.add("divergence", `${cat.name}: the sources hold ${missing.length} row(s) the structural model does not (${sample(missing)}); the model is the record of structure, so they are not catalogued. Add them through a change's structural deltas, or remove them from the sources (M-ADR 0230 §4.3
|
|
108
|
+
findings.add("divergence", `${cat.name}: the sources hold ${missing.length} row(s) the structural model does not (${sample(missing)}); the model is the record of structure, so they are not catalogued. Add them through a change's structural deltas, or remove them from the sources (M-ADR 0230 §4.3)`, "warn", `catalogue:${cat.name}`);
|
|
109
|
+
}
|
|
110
|
+
if (differIntended.length > 0) {
|
|
111
|
+
findings.add("divergence", `${cat.name}: ${differIntended.length} source row(s) state an element an agreed open change adds or modifies, and disagree with it: ${sample(differIntended.sort())}. The change's delta is what archives; bring the source into line with it, or the delta with the source (M-ADR 0230 §4.3, §4.7)`, "warn", `catalogue:${cat.name}`);
|
|
91
112
|
}
|
|
92
113
|
if (differ.length > 0) {
|
|
93
|
-
findings.add("divergence", `${cat.name}: ${differ.length} element(s) where the sources and the structural model disagree, catalogued as the model states them: ${sample(differ.sort())}. A source that is a generated view is rendered again; any other is brought into line through a change (M-ADR 0230 §4.3
|
|
114
|
+
findings.add("divergence", `${cat.name}: ${differ.length} element(s) where the sources and the structural model disagree, catalogued as the model states them: ${sample(differ.sort())}. A source that is a generated view is rendered again; any other is brought into line through a change (M-ADR 0230 §4.3)`, "warn", `catalogue:${cat.name}`);
|
|
94
115
|
}
|
|
95
116
|
out.set(cat.name, built);
|
|
96
117
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Views over the structural model, and how changes compose (M-ADR 0230 §4.5, §4.7
|
|
1
|
+
// Views over the structural model, and how changes compose (M-ADR 0230 §4.5, §4.7).
|
|
2
2
|
// current the model as published
|
|
3
3
|
// at a change current with that change's deltas and those of its dependency closure, dependencies first
|
|
4
4
|
// intended current with every agreed change's deltas
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// A change's structural effect, and the effect record an archive leaves (M-ADR 0230 §4.6, §4.7
|
|
1
|
+
// A change's structural effect, and the effect record an archive leaves (M-ADR 0230 §4.6, §4.7). Pure reads,
|
|
2
2
|
// so the catalogue builder and the verify-only CLI can carry them; writing the record is apply.ts's.
|
|
3
3
|
import { parseDocument } from "yaml";
|
|
4
4
|
import { elementKey, revision } from "./model.js";
|