@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
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// An SDD's first structural model, extracted from its sources (M-ADR 0230 §5.3, §6 step 1). The builders
|
|
2
|
+
// already parse the sources: the diagrams, data models, service tables, configuration, traceability manifests,
|
|
3
|
+
// feature trees and groundings. So extraction builds the catalogue from the sources into a scratch directory, and
|
|
4
|
+
// projects the model it implies (modelFromRows). It writes the partition files and an extraction report beside
|
|
5
|
+
// them, and touches no source file. From the moment openspec/structure/ exists, committed or not, the catalogue is
|
|
6
|
+
// built from the model, so writing it is the admission: review the extraction against the code first, with --dry-run
|
|
7
|
+
// (which writes nothing) or in a scratch copy, and keep the originals until that review is complete.
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
9
|
+
import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { runCatalogueBuild } from "../catalogue/build.js";
|
|
13
|
+
import { readCatalogueCsv } from "../catalogue/csv-reader.js";
|
|
14
|
+
import { loadCanonicalSchema } from "../catalogue/schema-loader.js";
|
|
15
|
+
import { modelFileText, modelFromRows } from "./catalogue.js";
|
|
16
|
+
import { STRUCTURE_DIR } from "./model.js";
|
|
17
|
+
import { STRUCTURE } from "./view.js";
|
|
18
|
+
export const REPORT_FILE = "extraction-report.md";
|
|
19
|
+
/** Derived path columns of structure catalogues: the source files the elements were read from. */
|
|
20
|
+
const SOURCE_PATH_COLUMNS = ["doc_path", "diagram_path", "spec_path"];
|
|
21
|
+
function head(sddPath) {
|
|
22
|
+
try {
|
|
23
|
+
const sha = execFileSync("git", ["-C", sddPath, "rev-parse", "--short", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
24
|
+
const dirty = execFileSync("git", ["-C", sddPath, "status", "--porcelain"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() !== "";
|
|
25
|
+
return dirty ? `${sha}, with uncommitted changes` : sha;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return "not a git repository";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function report(sddId, at, schemaVersion, x, sources, otherFindings) {
|
|
32
|
+
const lines = [
|
|
33
|
+
"# Structural model extraction",
|
|
34
|
+
"",
|
|
35
|
+
`Extracted by \`sdd-cli migrate structure\` from ${sddId}'s sources at ${at}, against catalogue schema ${schemaVersion} (M-ADR 0230).`,
|
|
36
|
+
"",
|
|
37
|
+
"**Writing this model admits it as the baseline** (M-ADR 0230 §6 step 1). From the moment `openspec/structure/` exists, committed or not, it is the record of structure: the catalogue is built from it, and the structural-model check asks every open change for its structural deltas or a `none.yaml`, and fails a change that has neither. Review it against the code before it is written (`sdd-cli migrate structure --dry-run` prints this report and writes nothing), and keep the sources listed below until that review is complete; nothing here changed them. Removing `openspec/structure/` withdraws a model written too soon. After admission, structure changes only through a change's structural deltas, and the diagrams and tables are generated by `sdd-cli structure render`.",
|
|
38
|
+
"",
|
|
39
|
+
"## Elements",
|
|
40
|
+
"",
|
|
41
|
+
"| Catalogue | Elements |",
|
|
42
|
+
"| --- | --- |",
|
|
43
|
+
...[...x.counts].map(([c, n]) => `| ${c} | ${n} |`),
|
|
44
|
+
"",
|
|
45
|
+
`Partitions: ${[...x.files.keys()].map((p) => `\`${p}\``).join(", ") || "none"}.`,
|
|
46
|
+
"",
|
|
47
|
+
"## What the extraction could not carry",
|
|
48
|
+
"",
|
|
49
|
+
];
|
|
50
|
+
if (x.loss.length === 0 && x.extraction.length === 0)
|
|
51
|
+
lines.push("Nothing: every structural fact the sources state that a builder reads is in the model.", "");
|
|
52
|
+
for (const l of x.loss)
|
|
53
|
+
lines.push(`- ${l}`);
|
|
54
|
+
for (const f of x.extraction)
|
|
55
|
+
lines.push(`- ${f.summary}`);
|
|
56
|
+
if (x.loss.length + x.extraction.length > 0)
|
|
57
|
+
lines.push("");
|
|
58
|
+
lines.push("Prose, and anything a builder does not read, stays in the original files only. A diagram's layout and styling are not structure, and the generated views do not reproduce them.", "", "## Sources the elements were read from", "", ...(sources.length > 0 ? sources.map((s) => `- \`${s}\``) : ["- (no element names its source file)"]), "", "## Other findings of the build", "", ...(otherFindings.size > 0 ? [...otherFindings].map(([c, n]) => `- ${c}: ${n}`) : ["- none"]), "", "These are the build's findings about the sources. They are reported by `sdd-cli catalogue build` as before, and extraction does not change them.", "");
|
|
59
|
+
return lines.join("\n");
|
|
60
|
+
}
|
|
61
|
+
/** Extract the model. Writes nothing; `writeExtraction` writes it. `build` is the catalogue build, replaceable in tests. */
|
|
62
|
+
export async function extractStructure(sddPath, build = runCatalogueBuild) {
|
|
63
|
+
const none = { files: new Map(), counts: new Map(), loss: [], extraction: [], report: "" };
|
|
64
|
+
try {
|
|
65
|
+
await readdir(join(sddPath, STRUCTURE_DIR));
|
|
66
|
+
return { ...none, refused: `${STRUCTURE_DIR}/ already exists; an SDD's first model is extracted once, and after that structure changes through a change's structural deltas` };
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// no model yet
|
|
70
|
+
}
|
|
71
|
+
const schema = await loadCanonicalSchema();
|
|
72
|
+
const scratch = await mkdtemp(join(tmpdir(), "sdd-migrate-structure-"));
|
|
73
|
+
try {
|
|
74
|
+
const built = await build({ sddPath, outDir: scratch }, () => undefined);
|
|
75
|
+
if (built.exitCode !== 0) {
|
|
76
|
+
return { ...none, refused: `the sources do not build cleanly (sdd-cli catalogue build exits ${built.exitCode}: ${built.internalFkPass ? "" : "dangling references, "}${built.pkConflicts} key conflict(s)); a model extracted from them would carry the same faults. Fix the build first` };
|
|
77
|
+
}
|
|
78
|
+
const rows = new Map();
|
|
79
|
+
for (const c of schema.catalogues) {
|
|
80
|
+
if (c.sourceOfTruth !== STRUCTURE)
|
|
81
|
+
continue;
|
|
82
|
+
rows.set(c.name, (await readCatalogueCsv(join(scratch, `${c.name}.csv`)).catch(() => ({ rows: [] }))).rows);
|
|
83
|
+
}
|
|
84
|
+
const loss = [];
|
|
85
|
+
const model = modelFromRows(schema, rows, { platform: built.profile === "platform-profile", sddId: built.sddId }, loss);
|
|
86
|
+
const files = new Map([...model].sort(([a], [b]) => a.localeCompare(b)).map(([p, els]) => [p, modelFileText(p, els)]));
|
|
87
|
+
const counts = new Map();
|
|
88
|
+
for (const els of model.values())
|
|
89
|
+
for (const [c, list] of Object.entries(els))
|
|
90
|
+
counts.set(c, (counts.get(c) ?? 0) + list.length);
|
|
91
|
+
const sortedCounts = new Map([...counts].sort(([a], [b]) => a.localeCompare(b)));
|
|
92
|
+
const sources = new Set();
|
|
93
|
+
for (const list of rows.values())
|
|
94
|
+
for (const r of list)
|
|
95
|
+
for (const col of SOURCE_PATH_COLUMNS)
|
|
96
|
+
if (r[col])
|
|
97
|
+
sources.add(String(r[col]));
|
|
98
|
+
const extraction = built.findings.filter((f) => f.category === "extraction");
|
|
99
|
+
const other = new Map();
|
|
100
|
+
for (const f of built.findings)
|
|
101
|
+
if (f.category !== "extraction")
|
|
102
|
+
other.set(String(f.category), (other.get(String(f.category)) ?? 0) + 1);
|
|
103
|
+
const x = { files, counts: sortedCounts, loss, extraction };
|
|
104
|
+
return { ...x, refused: null, report: report(built.sddId, head(sddPath), schema.schemaVersion, x, [...sources].sort(), new Map([...other].sort())) };
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
await rm(scratch, { recursive: true, force: true });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** Write the partition files and the report under openspec/structure/. Returns the paths written. */
|
|
111
|
+
export async function writeExtraction(sddPath, x) {
|
|
112
|
+
const dir = join(sddPath, STRUCTURE_DIR);
|
|
113
|
+
await mkdir(dir, { recursive: true });
|
|
114
|
+
const written = [];
|
|
115
|
+
for (const [p, text] of x.files) {
|
|
116
|
+
await writeFile(join(dir, `${p}.yaml`), text, "utf8");
|
|
117
|
+
written.push(join(STRUCTURE_DIR, `${p}.yaml`));
|
|
118
|
+
}
|
|
119
|
+
await writeFile(join(dir, REPORT_FILE), x.report, "utf8");
|
|
120
|
+
written.push(join(STRUCTURE_DIR, REPORT_FILE));
|
|
121
|
+
return written;
|
|
122
|
+
}
|
|
123
|
+
//# sourceMappingURL=extract.js.map
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// What an open change adds, for the references its own content makes (M-ADR 0230 §4.7, §4.11 proof 3).
|
|
2
|
+
// A reference made by an open change resolves against the current view plus that change's additions: the elements its
|
|
3
|
+
// structural deltas ADD, those of the changes in its depends_on closure, and the contracts its agreement record agrees
|
|
4
|
+
// as new files. A reference made by anything else resolves against the current view only, so a current reference that
|
|
5
|
+
// resolves only against an addition is still refused.
|
|
6
|
+
// What ties content to a change is never the reference being resolved:
|
|
7
|
+
// a model element the change's own delta (validateDeltas, viewAt)
|
|
8
|
+
// a source-built row with a change_id that change
|
|
9
|
+
// a contract row the change whose record agrees its file as new
|
|
10
|
+
// a row naming such a contract that change
|
|
11
|
+
// Pure but for reading agreement records.
|
|
12
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { parseDocument } from "yaml";
|
|
15
|
+
import { closure } from "./compose.js";
|
|
16
|
+
import { elementKey, loadChangeStructure } from "./model.js";
|
|
17
|
+
export const CONTRACTS = "contracts";
|
|
18
|
+
const LIFECYCLES = ["proposed", "agreed", "withdrawn", "superseded"];
|
|
19
|
+
/** The change's agreement record (docs/agreements/<change>.yaml): its status, or `unrecorded`, and its new contract files. */
|
|
20
|
+
export async function readAgreement(repo, change) {
|
|
21
|
+
let text;
|
|
22
|
+
try {
|
|
23
|
+
text = await readFile(join(repo, "docs", "agreements", `${change}.yaml`), "utf8");
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return { status: "unrecorded", newContracts: [] };
|
|
27
|
+
}
|
|
28
|
+
let record;
|
|
29
|
+
try {
|
|
30
|
+
record = parseDocument(text).toJS()?.agreement_record;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return { status: "unrecorded", newContracts: [] };
|
|
34
|
+
}
|
|
35
|
+
const status = LIFECYCLES.find((s) => s === String(record?.["status"] ?? "")) ?? "unrecorded";
|
|
36
|
+
const newContracts = [];
|
|
37
|
+
const entries = Array.isArray(record?.["contracts"]) ? record["contracts"] : [];
|
|
38
|
+
for (const raw of entries) {
|
|
39
|
+
if (raw === null || typeof raw !== "object")
|
|
40
|
+
continue;
|
|
41
|
+
const e = raw;
|
|
42
|
+
if (e["amended_by_this_change"] !== true)
|
|
43
|
+
continue;
|
|
44
|
+
const files = Array.isArray(e["files"]) ? e["files"].map(String) : e["file"] !== undefined ? [String(e["file"])] : [];
|
|
45
|
+
// agreement-record schema 1.2.0 and later: a file the change relies on carries relies_on, so an agreed file
|
|
46
|
+
// without one is new (1.3.0 states it). An entry with no agrees predates the digests and says nothing either way.
|
|
47
|
+
const digest = (field, f) => {
|
|
48
|
+
const v = e[field];
|
|
49
|
+
return v !== null && typeof v === "object" ? String(v[f] ?? "") : String(v ?? "");
|
|
50
|
+
};
|
|
51
|
+
for (const f of files)
|
|
52
|
+
if (digest("agrees", f) !== "" && digest("relies_on", f) === "")
|
|
53
|
+
newContracts.push(`${String(e["repo"] ?? "")}/${f}`);
|
|
54
|
+
}
|
|
55
|
+
return { status, newContracts };
|
|
56
|
+
}
|
|
57
|
+
/** The open changes (openspec/changes/<change>, not archive/), with their agreement records. */
|
|
58
|
+
export async function loadOpenChanges(repo, problems) {
|
|
59
|
+
let names;
|
|
60
|
+
try {
|
|
61
|
+
names = (await readdir(join(repo, "openspec", "changes"), { withFileTypes: true }))
|
|
62
|
+
.filter((e) => e.isDirectory() && e.name !== "archive")
|
|
63
|
+
.map((e) => e.name)
|
|
64
|
+
.sort();
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
names = [];
|
|
68
|
+
}
|
|
69
|
+
const open = new Map();
|
|
70
|
+
const newContracts = new Map();
|
|
71
|
+
for (const name of names) {
|
|
72
|
+
const agreement = await readAgreement(repo, name);
|
|
73
|
+
open.set(name, { structure: await loadChangeStructure(repo, name, problems), status: agreement.status });
|
|
74
|
+
newContracts.set(name, agreement.newContracts);
|
|
75
|
+
}
|
|
76
|
+
return { open, newContracts };
|
|
77
|
+
}
|
|
78
|
+
const cell = (v) => (v === null || v === undefined ? "" : String(v));
|
|
79
|
+
/**
|
|
80
|
+
* Each open change's additions, and the change that adds each. A contract is identified by its built row: the row
|
|
81
|
+
* whose spec_path is `<repo>/<file>` as the record names it. A new contract with no built row adds nothing, and a
|
|
82
|
+
* reference to it stays unresolved.
|
|
83
|
+
*/
|
|
84
|
+
export class Additions {
|
|
85
|
+
open;
|
|
86
|
+
own = new Map();
|
|
87
|
+
adder = new Map();
|
|
88
|
+
constructor(open, newContracts, contractRows) {
|
|
89
|
+
this.open = open;
|
|
90
|
+
const idByPath = new Map();
|
|
91
|
+
for (const r of contractRows)
|
|
92
|
+
if (cell(r["spec_path"]) !== "")
|
|
93
|
+
idByPath.set(cell(r["spec_path"]), cell(r["id"]));
|
|
94
|
+
for (const [name, oc] of open) {
|
|
95
|
+
const keys = new Set();
|
|
96
|
+
for (const d of oc.structure.deltas)
|
|
97
|
+
for (const e of d.added)
|
|
98
|
+
keys.add(elementKey(e.catalogue, e.id));
|
|
99
|
+
for (const p of newContracts.get(name) ?? []) {
|
|
100
|
+
const id = idByPath.get(p);
|
|
101
|
+
if (id !== undefined && id !== "")
|
|
102
|
+
keys.add(elementKey(CONTRACTS, id));
|
|
103
|
+
}
|
|
104
|
+
this.own.set(name, keys);
|
|
105
|
+
for (const k of keys)
|
|
106
|
+
if (!this.adder.has(k))
|
|
107
|
+
this.adder.set(k, name);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** The open change that adds the element or contract, if one does. */
|
|
111
|
+
addedBy(catalogue, id) {
|
|
112
|
+
return this.adder.get(elementKey(catalogue, id));
|
|
113
|
+
}
|
|
114
|
+
/** Whether the element or contract is the change's own addition or one of its dependency closure's. */
|
|
115
|
+
within(change, catalogue, id) {
|
|
116
|
+
const key = elementKey(catalogue, id);
|
|
117
|
+
return closure(change, this.open).some((c) => this.own.get(c)?.has(key) ?? false);
|
|
118
|
+
}
|
|
119
|
+
/** The contracts the given changes add. */
|
|
120
|
+
contracts(changes) {
|
|
121
|
+
const out = new Set();
|
|
122
|
+
for (const c of changes)
|
|
123
|
+
for (const k of this.own.get(c) ?? [])
|
|
124
|
+
if (k.startsWith(`${CONTRACTS}/`))
|
|
125
|
+
out.add(k.slice(CONTRACTS.length + 1));
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
/** The open changes a source-built row belongs to: its change_id, or a new contract it is or names. */
|
|
129
|
+
ownersOf(schema, catalogue, row, sddId) {
|
|
130
|
+
const out = new Set();
|
|
131
|
+
const changeId = cell(row["change_id"]);
|
|
132
|
+
if (changeId !== "") {
|
|
133
|
+
const name = changeId.startsWith(`${sddId}::`) ? changeId.slice(sddId.length + 2) : changeId;
|
|
134
|
+
if (this.open.has(name))
|
|
135
|
+
out.add(name);
|
|
136
|
+
}
|
|
137
|
+
const contractOwner = (id) => {
|
|
138
|
+
const by = this.adder.get(elementKey(CONTRACTS, id));
|
|
139
|
+
if (by !== undefined)
|
|
140
|
+
out.add(by);
|
|
141
|
+
};
|
|
142
|
+
const cat = schema.catalogues.find((c) => c.name === catalogue);
|
|
143
|
+
if (catalogue === CONTRACTS && cat)
|
|
144
|
+
contractOwner(cell(row[cat.primaryKey]));
|
|
145
|
+
for (const fk of cat?.foreignKeys ?? [])
|
|
146
|
+
if (fk.references === `${CONTRACTS}.id` && cell(row[fk.column]) !== "")
|
|
147
|
+
contractOwner(cell(row[fk.column]));
|
|
148
|
+
return [...out].sort();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Sort a build's dangling references: a row an open change owns, naming that change's addition, resolves in that
|
|
153
|
+
* change's view. Every other stays dangling, including a row nothing ties to the change that adds its target.
|
|
154
|
+
*/
|
|
155
|
+
export function splitDangling(schema, rowsByCatalogue, failures, additions, sddId) {
|
|
156
|
+
const intended = [];
|
|
157
|
+
const dangling = [];
|
|
158
|
+
const index = new Map();
|
|
159
|
+
const rowOf = (catalogue, pk) => {
|
|
160
|
+
let byPk = index.get(catalogue);
|
|
161
|
+
if (!byPk) {
|
|
162
|
+
const key = schema.catalogues.find((c) => c.name === catalogue)?.primaryKey ?? "id";
|
|
163
|
+
byPk = new Map((rowsByCatalogue.get(catalogue) ?? []).map((r) => [cell(r[key]), r]));
|
|
164
|
+
index.set(catalogue, byPk);
|
|
165
|
+
}
|
|
166
|
+
return byPk.get(pk);
|
|
167
|
+
};
|
|
168
|
+
for (const f of failures) {
|
|
169
|
+
const [target, column] = f.target.split(".");
|
|
170
|
+
const targetPk = schema.catalogues.find((c) => c.name === target)?.primaryKey;
|
|
171
|
+
const row = rowOf(f.catalogue, f.referencingRow);
|
|
172
|
+
const owner = f.value === "" || column !== targetPk || row === undefined
|
|
173
|
+
? undefined
|
|
174
|
+
: additions.ownersOf(schema, f.catalogue, row, sddId).find((c) => additions.within(c, target, f.value));
|
|
175
|
+
if (owner !== undefined)
|
|
176
|
+
intended.push({ failure: f, change: owner });
|
|
177
|
+
else {
|
|
178
|
+
const by = f.value === "" ? undefined : additions.addedBy(target, f.value);
|
|
179
|
+
dangling.push(by === undefined ? { failure: f } : { failure: f, addedBy: by });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return { intended, dangling };
|
|
183
|
+
}
|
|
184
|
+
/** Element key -> the agreed change that adds or modifies it, and the element as that change states it. */
|
|
185
|
+
export function intendedWrites(open, agreed) {
|
|
186
|
+
const out = new Map();
|
|
187
|
+
for (const c of agreed) {
|
|
188
|
+
for (const d of open.get(c)?.structure.deltas ?? [])
|
|
189
|
+
for (const e of [...d.added, ...d.modified])
|
|
190
|
+
out.set(elementKey(e.catalogue, e.id), { change: c, element: e });
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
//# sourceMappingURL=intended.js.map
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The structural model and a change's structural deltas (M-ADR 0230 §4.3, §4.4
|
|
1
|
+
// The structural model and a change's structural deltas (M-ADR 0230 §4.3, §4.4). Pure reads, so the
|
|
2
2
|
// verify-only CLI can carry them. The format is docs/method/methods/guides/structural-model-format.md:
|
|
3
3
|
// openspec/structure/<partition>.yaml the model, one file per partition
|
|
4
4
|
// openspec/changes/<change>/structure/<partition>.yaml a change's delta for that partition
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The generated views of the structural model (M-ADR 0230 §4.3
|
|
1
|
+
// The generated views of the structural model (M-ADR 0230 §4.3): projections, never edited. Each file is
|
|
2
2
|
// rendered from the current view alone, in id order, so the same model renders the same bytes. Its first line names
|
|
3
3
|
// the generator and the digest of the body below it, which tells three states apart:
|
|
4
4
|
// missing the file is absent
|
|
@@ -121,7 +121,12 @@ function naturalKey(cat, e) {
|
|
|
121
121
|
* catalogue of another class, against its built rows), no cycle through a self-reference, natural keys unique, and
|
|
122
122
|
* domain invariants naming their entity's own attributes and relationships.
|
|
123
123
|
*/
|
|
124
|
-
export function validateView(schema, view, built, problems, unresolved) {
|
|
124
|
+
export function validateView(schema, view, built, problems, unresolved, addedBy = () => undefined) {
|
|
125
|
+
// A reference to an open change's addition, from outside that change and its dependants (§4.11 proof 3).
|
|
126
|
+
const outside = (catalogue, id) => {
|
|
127
|
+
const by = addedBy(catalogue, id);
|
|
128
|
+
return by === undefined ? "" : `; open change ${by} adds it, and only that change and those depending on it can name it`;
|
|
129
|
+
};
|
|
125
130
|
for (const [catalogue, byId] of view) {
|
|
126
131
|
const cat = catalogueOf(schema, catalogue);
|
|
127
132
|
for (const e of byId.values()) {
|
|
@@ -133,14 +138,14 @@ export function validateView(schema, view, built, problems, unresolved) {
|
|
|
133
138
|
for (const r of referencesOf(schema, e)) {
|
|
134
139
|
if (r.structural) {
|
|
135
140
|
if (!find(view, r.catalogue, r.targetColumn, r.value))
|
|
136
|
-
problems.push(`${e.file}: ${elementKey(catalogue, e.id)}: ${r.column} ${JSON.stringify(r.value)} names no ${r.catalogue} element in this view (${elementKey(r.catalogue, r.value)})`);
|
|
141
|
+
problems.push(`${e.file}: ${elementKey(catalogue, e.id)}: ${r.column} ${JSON.stringify(r.value)} names no ${r.catalogue} element in this view (${elementKey(r.catalogue, r.value)})${r.targetColumn === "id" ? outside(r.catalogue, r.value) : ""}`);
|
|
137
142
|
}
|
|
138
143
|
else {
|
|
139
144
|
const rows = built(r.catalogue, r.targetColumn);
|
|
140
145
|
if (rows === null)
|
|
141
146
|
unresolved.add(r.catalogue);
|
|
142
147
|
else if (!rows.has(r.value))
|
|
143
|
-
problems.push(`${e.file}: ${elementKey(catalogue, e.id)}: ${r.column} ${JSON.stringify(r.value)} names no row of ${r.catalogue} as built`);
|
|
148
|
+
problems.push(`${e.file}: ${elementKey(catalogue, e.id)}: ${r.column} ${JSON.stringify(r.value)} names no row of ${r.catalogue} as built${outside(r.catalogue, r.value)}`);
|
|
144
149
|
}
|
|
145
150
|
}
|
|
146
151
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
//
|
|
4
4
|
// agreed content the change's proposal, deltas, Gate 0, design, tasks (checkboxes ignored), the test plan's
|
|
5
5
|
// scenario-to-test rows (its Before and After cells ignored) and its structural deltas with their
|
|
6
|
-
// base revisions (M-ADR 0230 §4.4
|
|
6
|
+
// base revisions (M-ADR 0230 §4.4; report only until it is accepted) are what was approved. Each is compared
|
|
7
7
|
// with its state at the record's pinned commit: the latest amendment's commit, else sdd.commit.
|
|
8
8
|
// requirement a requirement's block in a spec: its heading to the next requirement or section heading. The
|
|
9
9
|
// stale check compares the blocks a change targets, not the whole file.
|
|
@@ -16,7 +16,7 @@ export function boundFile(rel) {
|
|
|
16
16
|
return /^(proposal|design|gate-0|tasks|test-plan)\.md$/.test(rel) || /^specs\/.+\/spec\.md$/.test(rel) || structuralFile(rel);
|
|
17
17
|
}
|
|
18
18
|
/**
|
|
19
|
-
* A change's structural artifact (M-ADR 0230 §4.4
|
|
19
|
+
* A change's structural artifact (M-ADR 0230 §4.4): a partition delta or the no-op claim, both agreed
|
|
20
20
|
* content, so a delta's base revisions are pinned with it. The effect record (structure/applied.yaml) is written by
|
|
21
21
|
* the archive, after agreement, and is not.
|
|
22
22
|
*/
|
|
@@ -16,10 +16,12 @@
|
|
|
16
16
|
// check is vacuous, and says so.
|
|
17
17
|
// agreed content: the change's proposal, deltas, Gate 0, design, tasks (checkboxes ignored), test
|
|
18
18
|
// plan (its execution results ignored) and structural deltas equal their state at that commit
|
|
19
|
-
// (M-ADR 0222 §4.6; a structural delta's change
|
|
19
|
+
// (M-ADR 0222 §4.6; a structural delta's change fails like the rest, a [WARN] only with ENFORCED false).
|
|
20
20
|
// kinds: on a record held to 1.1.0, each test-plan row's Kind (changed or carried) is the one computed
|
|
21
|
-
// from the deltas and the living spec,
|
|
22
|
-
//
|
|
21
|
+
// from the deltas and the living spec, or repaired (declared, on a carried scenario); a row
|
|
22
|
+
// naming no scenario of the deltas fails; and a filled Before, read as whole-word results, is a
|
|
23
|
+
// fail for changed, a pass for carried, and a fail citing the test double's correcting commit
|
|
24
|
+
// for repaired (M-ADR 0223 §4.4).
|
|
23
25
|
// 4. verdicts: every archived change except a baseline has a verdict.md whose verdict line agrees with
|
|
24
26
|
// its record's PASS (M-ADR 0223 §4.6). A record held to 1.1.0 (by its own history, or by the
|
|
25
27
|
// schema the SDD shipped when the change archived) also needs: an agreed record; archived
|
|
@@ -186,6 +188,16 @@ export function parseAgreementRecord(file, text) {
|
|
|
186
188
|
noLongerImplements.push({ repo: e.repo, requirement: e.requirement });
|
|
187
189
|
}
|
|
188
190
|
}
|
|
191
|
+
// Record 1.3.0 (M-ADR 0230 §4.2): a contracts entry may deprecate its version, stating the sunset. A
|
|
192
|
+
// deprecation that names no date states no runway, so it is refused rather than read as a deprecation without one.
|
|
193
|
+
for (const [i, x] of (Array.isArray(r.contracts) ? r.contracts : []).entries()) {
|
|
194
|
+
const d = x?.deprecates;
|
|
195
|
+
if (d === undefined)
|
|
196
|
+
continue;
|
|
197
|
+
const sunset = d?.sunset;
|
|
198
|
+
if (typeof sunset !== "string" || !isCalendarDate(sunset))
|
|
199
|
+
problems.push(`${file}: contracts[${i}].deprecates must be {sunset: YYYY-MM-DD}, a calendar date as a string; found ${JSON.stringify(d)}`);
|
|
200
|
+
}
|
|
189
201
|
if (problems.length > 0)
|
|
190
202
|
return { problems };
|
|
191
203
|
return {
|
|
@@ -205,6 +217,13 @@ export function parseAgreementRecord(file, text) {
|
|
|
205
217
|
problems,
|
|
206
218
|
};
|
|
207
219
|
}
|
|
220
|
+
/** A YYYY-MM-DD string naming a day that exists. */
|
|
221
|
+
function isCalendarDate(s) {
|
|
222
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(s))
|
|
223
|
+
return false;
|
|
224
|
+
const t = new Date(`${s}T00:00:00Z`);
|
|
225
|
+
return !Number.isNaN(t.getTime()) && t.toISOString().slice(0, 10) === s;
|
|
226
|
+
}
|
|
208
227
|
/** Every pinned commit in a record: any key named `commit` or ending `_commit`, and each value under verdict.merged and current. */
|
|
209
228
|
function commitFields(r) {
|
|
210
229
|
const out = [];
|
|
@@ -489,7 +508,7 @@ export async function checkBehaviourStore(repo, options = {}) {
|
|
|
489
508
|
if (a === null || b === null || normaliseBound(f, a) !== normaliseBound(f, b)) {
|
|
490
509
|
const what = `${dir}: AGREED CONTENT CHANGED: ${f} ${a === null ? "was added" : b === null ? "was removed" : "differs"} since ${base}, with no amendment recording a commit and an approver; amend the agreement or renew it (M-ADR 0222 §4.6)`;
|
|
491
510
|
if (structuralFile(f) && !STRUCTURE_ENFORCED)
|
|
492
|
-
warnings.push(`${what}; a structural delta,
|
|
511
|
+
warnings.push(`${what}; a structural delta, reported only because ENFORCED is off`);
|
|
493
512
|
else
|
|
494
513
|
failures.push(what);
|
|
495
514
|
}
|
|
@@ -497,14 +516,14 @@ export async function checkBehaviourStore(repo, options = {}) {
|
|
|
497
516
|
}
|
|
498
517
|
}
|
|
499
518
|
}
|
|
500
|
-
// A test plan's kinds (M-ADR 0223 §4.4 as amended): changed scenarios go red then green, carried ones stay green.
|
|
519
|
+
// A test plan's kinds (M-ADR 0223 §4.4 as amended): changed and repaired scenarios go red then green, carried ones stay green.
|
|
501
520
|
if (versionAtLeast(record?.schemaVersion, "1.1.0") && record?.status === "agreed") {
|
|
502
521
|
const plan = await readText(join(repo, dir, "test-plan.md"));
|
|
503
522
|
if (plan !== null) {
|
|
504
523
|
const header = testPlanHeader(plan);
|
|
505
524
|
const col = (name) => header.indexOf(name);
|
|
506
525
|
if (col("kind") < 0)
|
|
507
|
-
failures.push(`${dir}/test-plan.md: the scenario table has no Kind column (changed | carried)`);
|
|
526
|
+
failures.push(`${dir}/test-plan.md: the scenario table has no Kind column (changed | carried | repaired)`);
|
|
508
527
|
else {
|
|
509
528
|
for (const row of testPlanMapping(plan)) {
|
|
510
529
|
const cell = row[col("requirement")] ?? "";
|
|
@@ -533,13 +552,31 @@ export async function checkBehaviourStore(repo, options = {}) {
|
|
|
533
552
|
continue;
|
|
534
553
|
}
|
|
535
554
|
kindsChecked++;
|
|
536
|
-
|
|
555
|
+
// `repaired` is declared, never computed: a carried scenario whose earlier pass was false-green.
|
|
556
|
+
const repaired = kind === "repaired";
|
|
557
|
+
if (repaired && expected === "changed")
|
|
558
|
+
failures.push(`${dir}/test-plan.md: "${req}" / "${scen}" is changed against the living spec, so it cannot be repaired: repaired is a scenario carried as the living spec states it, whose code did not meet it`);
|
|
559
|
+
else if (!repaired && kind !== expected)
|
|
537
560
|
failures.push(`${dir}/test-plan.md: "${req}" / "${scen}" is ${expected} against the living spec, and the plan says ${JSON.stringify(kind)}`);
|
|
538
561
|
const before = col("before") >= 0 ? (row[col("before")] ?? "") : "";
|
|
539
|
-
if (before !== ""
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
562
|
+
if (before !== "") {
|
|
563
|
+
// The cell's results, as whole words: "PASS at x; FAIL at y" holds both, and "setup required" or "bypass" holds neither.
|
|
564
|
+
const pass = /\b(?:pass(?:es|ed)?|green)\b/i.test(before);
|
|
565
|
+
const fail = /\b(?:fail(?:s|ed)?|red)\b/i.test(before);
|
|
566
|
+
const said = `the scenario table's Before cell says ${JSON.stringify(before)}`;
|
|
567
|
+
if (repaired) {
|
|
568
|
+
if (!fail)
|
|
569
|
+
failures.push(`${dir}/test-plan.md: "${req}" / "${scen}" is repaired, so its test fails before the code; ${said}`);
|
|
570
|
+
else if (!/\b[0-9a-f]{7,40}\b/i.test(before))
|
|
571
|
+
failures.push(`${dir}/test-plan.md: "${req}" / "${scen}" is repaired, so its Before cell cites the commit that corrects the test double, with its own red run; ${said}`);
|
|
572
|
+
}
|
|
573
|
+
else if (expected === "changed" && (!fail || pass)) {
|
|
574
|
+
failures.push(`${dir}/test-plan.md: "${req}" / "${scen}" is changed, so its test fails before the code${pass && fail ? ", and a cell holding both results is not a result" : ""}; ${said}`);
|
|
575
|
+
}
|
|
576
|
+
else if (expected === "carried" && (!pass || fail)) {
|
|
577
|
+
failures.push(`${dir}/test-plan.md: "${req}" / "${scen}" is carried, so its test passes before the code as regression${fail ? "; if its earlier pass was false-green, declare it repaired and cite the commit that corrects the test double" : ""}; ${said}`);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
543
580
|
}
|
|
544
581
|
}
|
|
545
582
|
}
|
|
@@ -640,7 +677,7 @@ export async function checkBehaviourStore(repo, options = {}) {
|
|
|
640
677
|
if (a === null || b === null || normaliseBound(f, a) !== normaliseBound(f, b)) {
|
|
641
678
|
const what = `${dir}: AGREED CONTENT CHANGED: ${f} ${a === null ? "was added" : b === null ? "was removed" : "differs"} from what was agreed at ${base}, with no amendment recording a commit and an approver (M-ADR 0222 §4.6)`;
|
|
642
679
|
if (structuralFile(f) && !STRUCTURE_ENFORCED)
|
|
643
|
-
warnings.push(`${what}; a structural delta,
|
|
680
|
+
warnings.push(`${what}; a structural delta, reported only because ENFORCED is off`);
|
|
644
681
|
else
|
|
645
682
|
failures.push(what);
|
|
646
683
|
}
|
|
@@ -37,13 +37,13 @@ const BEHAVIOUR_STORE_VALIDATORS = [
|
|
|
37
37
|
source: { kind: "native", run: async (ctx) => checkFeatureSpecPointers(ctx.targetRepo) },
|
|
38
38
|
build: () => ({ status: "run", args: [] }),
|
|
39
39
|
},
|
|
40
|
-
// The journeys a change can break, and whether its proposal names them (M-ADR 0229 §4.5
|
|
40
|
+
// The journeys a change can break, and whether its proposal names them (M-ADR 0229 §4.5). Report only; [N/A] without journeys.
|
|
41
41
|
{
|
|
42
42
|
name: "journey-impact",
|
|
43
43
|
source: { kind: "native", run: async (ctx) => checkJourneyImpact(ctx.targetRepo) },
|
|
44
44
|
build: () => ({ status: "run", args: [] }),
|
|
45
45
|
},
|
|
46
|
-
// The structural model and each open change's structural artifact (M-ADR 0230
|
|
46
|
+
// The structural model and each open change's structural artifact (M-ADR 0230). Report only; [N/A] without a model.
|
|
47
47
|
{
|
|
48
48
|
name: "structural-model",
|
|
49
49
|
source: { kind: "native", run: async (ctx) => checkStructuralModel(ctx.targetRepo) },
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// journey-impact: the journeys a change can break, and whether its proposal names them (M-ADR 0229 §4.5
|
|
2
|
-
// Report only (M-ADR 0229 §4.5
|
|
1
|
+
// journey-impact: the journeys a change can break, and whether its proposal names them (M-ADR 0229 §4.5).
|
|
2
|
+
// Report only (M-ADR 0229 §4.5): every finding is a [WARN] and the check exits 0. Gate 0 carries this report; nothing
|
|
3
3
|
// blocks on it, and the judge does not fail a change for it.
|
|
4
4
|
//
|
|
5
5
|
// For each open change in the behaviour store (openspec/changes/<change>/, not the archive):
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
// with no reason is reported too.
|
|
15
15
|
//
|
|
16
16
|
// Also reported: a journey citing a requirement of a feature its journey_feature rows do not name (one record or
|
|
17
|
-
// the other is wrong, M-ADR 0229 §4.4
|
|
18
|
-
// platform it inherits from (an unresolved root, M-ADR 0229 §4.2
|
|
17
|
+
// the other is wrong, M-ADR 0229 §4.4); and an SDD with catalogued journeys whose manifest declares no journey master and that names no
|
|
18
|
+
// platform it inherits from (an unresolved root, M-ADR 0229 §4.2). A member that names its platform inherits the
|
|
19
19
|
// root's declaration, which is checked at the root.
|
|
20
20
|
//
|
|
21
21
|
// Reads the aggregated catalogue (catalogues-aggregated/*.csv) where this SDD has one, since a root's changes can
|
|
@@ -100,7 +100,7 @@ export async function checkJourneyImpact(repo) {
|
|
|
100
100
|
else if (platform)
|
|
101
101
|
lines.push(`journey master: not declared here; inherited from platform ${platform}, where it is checked`);
|
|
102
102
|
else
|
|
103
|
-
warnings.push(`.sdd/sdd-manifest.yaml: ${journeys.length} catalogued journey(s) and no journey master (journeys.master: test-file or store), and no platform to inherit one from: unresolved (M-ADR 0229 §4.2
|
|
103
|
+
warnings.push(`.sdd/sdd-manifest.yaml: ${journeys.length} catalogued journey(s) and no journey master (journeys.master: test-file or store), and no platform to inherit one from: unresolved (M-ADR 0229 §4.2)`);
|
|
104
104
|
const jr = await csv(repo, "journey_requirement", dir);
|
|
105
105
|
const jf = (await csv(repo, "journey_feature", dir)) ?? [];
|
|
106
106
|
const featureKinds = new Map(((await csv(repo, "features", dir)) ?? []).map((f) => [f["id"], f["kind"] ?? ""]));
|
|
@@ -137,7 +137,7 @@ export async function checkJourneyImpact(repo) {
|
|
|
137
137
|
if (touched.get(r["journey_id"])?.has(feature) || mismatched.has(key))
|
|
138
138
|
continue;
|
|
139
139
|
mismatched.add(key);
|
|
140
|
-
warnings.push(`journey ${r["journey_id"]} cites requirements of ${feature}, which its journey_feature rows do not name: the journey's related features or its tags are wrong (M-ADR 0229 §4.4
|
|
140
|
+
warnings.push(`journey ${r["journey_id"]} cites requirements of ${feature}, which its journey_feature rows do not name: the journey's related features or its tags are wrong (M-ADR 0229 §4.4)`);
|
|
141
141
|
}
|
|
142
142
|
const untracedCount = journeys.filter((j) => !traced.has(j["id"])).length;
|
|
143
143
|
lines.push(`journeys: ${journeys.length} catalogued, ${journeys.length - untracedCount} traced to requirements, ${untracedCount} untraced`);
|
|
@@ -203,6 +203,6 @@ export async function checkJourneyImpact(repo) {
|
|
|
203
203
|
const body = lines.map((l) => `${l}\n`).join("");
|
|
204
204
|
if (warnings.length === 0)
|
|
205
205
|
return { exitCode: 0, stdout: `journey-impact:\n${body}[PASS] journey-impact: every listed journey is named\n` };
|
|
206
|
-
return { exitCode: 0, stdout: `journey-impact:\n${body}${warnings.map((w) => `[WARN] ${w}\n`).join("")}[WARN] journey-impact: ${warnings.length} finding(s), report only (M-ADR 0229 §4.5
|
|
206
|
+
return { exitCode: 0, stdout: `journey-impact:\n${body}${warnings.map((w) => `[WARN] ${w}\n`).join("")}[WARN] journey-impact: ${warnings.length} finding(s), report only (M-ADR 0229 §4.5)\n` };
|
|
207
207
|
}
|
|
208
208
|
//# sourceMappingURL=journey-impact.js.map
|