@sdd-method/author-cli 0.11.0 → 0.12.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.
@@ -14,20 +14,19 @@
14
14
  // - Files may mix `erDiagram` with other Mermaid diagram types (flowchart,
15
15
  // sequenceDiagram). Each fenced block is independently checked for the
16
16
  // erDiagram opener; non-ER blocks are skipped.
17
+ // The file is read by the data-model parser (data-model.ts, schema 6.0.0), the
18
+ // one parser every reader of a domain model shares; an entity on one line
19
+ // (`NAME { type a; type b }`) is an entity too, and a `data-model.tql` in the
20
+ // documented TypeQL subset gives its entities the same way.
17
21
  import { readFile } from "node:fs/promises";
22
+ import { parseMermaidDataModel, parseTypeqlDataModel } from "./data-model.js";
18
23
  import { entitySlug } from "./entity-slug.js";
19
24
  import { extractFileEvidenceClass } from "./evidence-class.js";
20
- const RE_MERMAID_OPEN = /^```\s*mermaid\s*$/i;
21
- const RE_FENCE_CLOSE = /^```\s*$/;
22
- const RE_COMMENT = /^\s*%%/;
23
- const RE_ENTITY_OPEN = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\{\s*$/;
24
- const RE_REL_LINE = /^\s*[A-Za-z_][A-Za-z0-9_]*\s+\|?\|?[o|\-]+\{?\s+[A-Za-z]/;
25
- const MERMAID_KEYWORDS = new Set(["erdiagram", "subgraph", "classdef", "style", "class"]);
26
25
  export async function buildEntitiesFromMermaid(inputs) {
27
26
  const rows = [];
28
27
  const seenAcrossFiles = new Map(); // id → originating doc_path
29
28
  for (const dm of inputs.domainModels) {
30
- if (!dm.doc_path.endsWith(".mermaid.md"))
29
+ if (!dm.doc_path.endsWith(".mermaid.md") && !dm.doc_path.endsWith(".tql"))
31
30
  continue;
32
31
  const docPath = dm.doc_path.startsWith("/")
33
32
  ? dm.doc_path
@@ -74,87 +73,21 @@ export async function buildEntitiesFromMermaid(inputs) {
74
73
  return rows;
75
74
  }
76
75
  function parseFile(text, dm, findings) {
77
- const lines = text.split(/\r?\n/);
78
- const out = [];
79
- const seenInFile = new Set();
80
- let inMermaid = false;
81
- let isEr = false;
82
- let firstKeywordSeen = false;
83
- let inEntity = false;
84
- let entityName = null;
85
- let entityColumns = 0;
86
- for (let i = 0; i < lines.length; i++) {
87
- const raw = lines[i] ?? "";
88
- if (!inMermaid) {
89
- if (RE_MERMAID_OPEN.test(raw)) {
90
- inMermaid = true;
91
- isEr = false;
92
- firstKeywordSeen = false;
93
- }
94
- continue;
95
- }
96
- // in_mermaid == true
97
- if (RE_FENCE_CLOSE.test(raw)) {
98
- inMermaid = false;
99
- isEr = false;
100
- firstKeywordSeen = false;
101
- // If we exit mid-entity, drop it silently — malformed block.
102
- inEntity = false;
103
- entityName = null;
104
- continue;
105
- }
106
- const stripped = raw.trim();
107
- if (!firstKeywordSeen && stripped !== "" && !RE_COMMENT.test(raw)) {
108
- firstKeywordSeen = true;
109
- isEr = stripped.toLowerCase().startsWith("erdiagram");
110
- }
111
- if (!isEr)
112
- continue;
113
- if (inEntity) {
114
- if (stripped.startsWith("}")) {
115
- if (entityName !== null) {
116
- const slug = entitySlug(entityName);
117
- if (!seenInFile.has(slug)) {
118
- seenInFile.add(slug);
119
- out.push({
120
- name: entityName,
121
- domainId: dm.domain_id,
122
- sourceDomainModelId: dm.id,
123
- columnCount: entityColumns,
124
- });
125
- }
126
- }
127
- inEntity = false;
128
- entityName = null;
129
- entityColumns = 0;
130
- continue;
131
- }
132
- if (RE_COMMENT.test(raw))
133
- continue;
134
- if (stripped)
135
- entityColumns++;
136
- continue;
137
- }
138
- // Not in an entity block — look for an opener.
139
- if (RE_COMMENT.test(raw))
140
- continue;
141
- if (RE_REL_LINE.test(raw))
142
- continue;
143
- const m = RE_ENTITY_OPEN.exec(raw);
144
- if (m) {
145
- const ident = m[1] ?? "";
146
- if (MERMAID_KEYWORDS.has(ident.toLowerCase()))
147
- continue;
148
- entityName = ident;
149
- inEntity = true;
150
- entityColumns = 0;
151
- }
76
+ const parsed = dm.doc_path.endsWith(".tql") ? parseTypeqlDataModel(text, dm.doc_path) : parseMermaidDataModel(text, dm.doc_path);
77
+ if ("refused" in parsed) {
78
+ findings.add("extraction", `entities: ${dm.doc_path} is outside the documented TypeQL subset, so none of its entities is catalogued: ${parsed.refused}`, "warn", dm.doc_path);
79
+ return [];
152
80
  }
153
81
  // Defensive: if a block ended without closing an entity, log it once.
154
- if (inEntity && entityName !== null) {
155
- findings.add("convention", `entities-mermaid: ${dm.doc_path}: unclosed entity block for "${entityName}" — discarded`);
82
+ if (parsed.unclosed !== null) {
83
+ findings.add("convention", `entities-mermaid: ${dm.doc_path}: unclosed entity block for "${parsed.unclosed}" — discarded`);
156
84
  }
157
- return out;
85
+ return parsed.entities.map((e) => ({
86
+ name: e.name,
87
+ domainId: dm.domain_id,
88
+ sourceDomainModelId: dm.id,
89
+ columnCount: e.columnCount,
90
+ }));
158
91
  }
159
92
  function resolveGlossaryTermBySlug(entityName, domainId, knownGlossaryIds) {
160
93
  const slug = entitySlug(entityName);
@@ -176,9 +176,8 @@ export async function buildIntegrationComponents(inputs) {
176
176
  }
177
177
  // SECOND HOME (§9.1): a full `integration-manifest.yaml` per component repo under
178
178
  // `repos/<component>/`. Read after the array form so `seen` already holds the
179
- // array-form ids and a collision across the two homes is caught rather than
180
- // silently duplicated — measured disjoint today, but the two forms are authored
181
- // independently and nothing stops them meeting.
179
+ // array-form ids: a component in both homes that agrees on every tier field is
180
+ // one declaration stated twice, and one that disagrees is a divergence (below).
182
181
  rows.push(...(await readComponentManifests({
183
182
  sddPath: inputs.sddPath,
184
183
  findings: inputs.findings,
@@ -186,6 +185,7 @@ export async function buildIntegrationComponents(inputs) {
186
185
  knownRepoIds: inputs.knownRepoIds,
187
186
  knownServiceIds: inputs.knownServiceIds,
188
187
  seen,
188
+ arrayRows: new Map(rows.map((r) => [String(r["id"]), r])),
189
189
  })));
190
190
  checkAggregate({
191
191
  findings: inputs.findings,
@@ -196,6 +196,43 @@ export async function buildIntegrationComponents(inputs) {
196
196
  });
197
197
  return rows;
198
198
  }
199
+ /** The fields the component tier reads, compared when a component is declared in both homes. */
200
+ const TIER_FIELDS = [
201
+ "component_type",
202
+ "direction",
203
+ "frequency",
204
+ "transport",
205
+ "format",
206
+ "auth",
207
+ "ingestion_pattern",
208
+ "invocation_pattern",
209
+ "pattern_qualifier",
210
+ "primary_entities",
211
+ ];
212
+ function tierValue(m, f) {
213
+ switch (f) {
214
+ case "component_type":
215
+ return (m.componentType ?? "").trim();
216
+ case "direction":
217
+ return (m.dataFlow?.direction ?? "").trim();
218
+ case "frequency":
219
+ return (m.dataFlow?.frequency ?? "").trim();
220
+ case "transport":
221
+ return (m.protocol?.transport ?? "").trim();
222
+ case "format":
223
+ return (m.protocol?.format ?? "").trim();
224
+ case "auth":
225
+ return (m.protocol?.auth ?? "").trim();
226
+ case "ingestion_pattern":
227
+ return (m.ingestionPattern ?? "").trim();
228
+ case "invocation_pattern":
229
+ return (m.invocationPattern ?? "").trim();
230
+ case "pattern_qualifier":
231
+ return (m.patternQualifier ?? "").trim();
232
+ case "primary_entities":
233
+ return (m.dataFlow?.primaryEntities ?? []).join(";");
234
+ }
235
+ }
199
236
  /** `repos/` — where the per-component manifests live (M-ADR 0171 §9.1, second home). */
200
237
  const REPOS_DIR = "repos";
201
238
  /**
@@ -261,8 +298,15 @@ async function readComponentManifests(args) {
261
298
  const componentId = (parsed.data.integrationId ?? "").trim() || dir;
262
299
  const id = `${args.integrationId}__${componentId}`;
263
300
  if (args.seen.has(id)) {
264
- args.findings.add("divergence", `integration_component: component id="${id}" declared in BOTH §9.1 homes (components array and ${rel}) — ` +
265
- `one component cannot be declared twice; the per-component manifest is dropped. Remove whichever declaration is stale.`);
301
+ // The reverse template has the array mirror the per-component manifests, so a component in both homes is
302
+ // expected. A mirror that agrees on every field the tier reads is one declaration stated twice, and is not a
303
+ // finding; one that disagrees is, and the array's declaration is kept.
304
+ const mirrored = args.arrayRows.get(id);
305
+ const differs = mirrored === undefined ? ["(not an array row)"] : TIER_FIELDS.filter((f) => String(mirrored[f] ?? "") !== tierValue(parsed.data, f));
306
+ if (differs.length > 0) {
307
+ args.findings.add("divergence", `integration_component: component id="${id}" is declared in both §9.1 homes (components array and ${rel}) and they disagree on ${differs.join(", ")}; ` +
308
+ `the components array's declaration is kept. Correct whichever is stale.`);
309
+ }
266
310
  continue;
267
311
  }
268
312
  args.seen.add(id);
@@ -118,6 +118,19 @@ const num = (v) => {
118
118
  }
119
119
  return null;
120
120
  };
121
+ /**
122
+ * The review's other assessed inputs (M-ADR 0230 §4.9, Proposed): `inputs:` as a list of paths, or of `{path}`
123
+ * entries, each read at the review's corpus_ref. ';'-joined; null when the review names none.
124
+ */
125
+ function inputPaths(v) {
126
+ if (!Array.isArray(v))
127
+ return null;
128
+ const paths = v
129
+ .map((x) => (typeof x === "string" ? x : isRecord(x) && typeof x["path"] === "string" ? x["path"] : ""))
130
+ .map((x) => x.trim())
131
+ .filter((x) => x !== "");
132
+ return paths.length > 0 ? paths.join(";") : null;
133
+ }
121
134
  /**
122
135
  * Count raised flags across the two authored dialects:
123
136
  * `flags: { over_scope_risk: true, ... }` -> count the true ones
@@ -258,6 +271,7 @@ function bandedRows(block, ctx) {
258
271
  band_total: num(block["total"]),
259
272
  scored_at: ctx.scoredAt,
260
273
  corpus_ref: ctx.corpusRef,
274
+ input_paths: ctx.inputPaths,
261
275
  staleness_basis: stalenessBasis,
262
276
  review_run_id: null,
263
277
  selection_method: null,
@@ -371,6 +385,7 @@ export async function buildQualityScores(inputs) {
371
385
  targetPath,
372
386
  scoredAt: fileScoredAt,
373
387
  corpusRef,
388
+ inputPaths: inputPaths(unitVal["inputs"]),
374
389
  assessedBy: fileAssessedBy,
375
390
  sddId,
376
391
  });
@@ -396,6 +411,7 @@ export async function buildQualityScores(inputs) {
396
411
  targetPath: str(unitVal["doc_path"]),
397
412
  scoredAt: str(current["scored_at"]) ?? fileScoredAt,
398
413
  corpusRef,
414
+ inputPaths: inputPaths(current["inputs"] ?? unitVal["inputs"]),
399
415
  assessedBy: fileAssessedBy,
400
416
  sddId,
401
417
  });
@@ -540,6 +556,7 @@ export async function buildQualityScores(inputs) {
540
556
  band_total: null,
541
557
  scored_at: str(current["scored_at"]) ?? str(entryVal["scored_at"]),
542
558
  corpus_ref: corpusRef,
559
+ input_paths: inputPaths(current["inputs"] ?? entryVal["inputs"]),
543
560
  staleness_basis: stalenessBasis,
544
561
  review_run_id: str(current["review_run_id"]),
545
562
  selection_method: selectionMethod,
@@ -18,10 +18,17 @@
18
18
  // change_work_order per (change, repository): the record's work order, agreed or completed.
19
19
  // change_work_item per (change, work item): the ids its proposal's `## Work Items` section lists, keyed by
20
20
  // the undated name so the row survives archive.
21
- // requirement_status per (requirement, repository): observed from a baseline's `**Observed in:**` line;
21
+ // requirement_revisions one row per distinct text of a requirement (schema 6.0.0, M-ADR 0230 §4.7, Proposed): the
22
+ // living spec's is current, an agreed open change's ADDED or MODIFIED block intended, an
23
+ // archived change's block the living spec no longer holds history. Identified by its digest.
24
+ // requirement_assertions per (revision, repository): observed from a baseline's `**Observed in:**` line;
22
25
  // declared-shipped from an archived change whose agreement record's verdict is PASS, through
23
- // its work orders' `implements` lists; agreed from an open change whose record is agreed.
24
- // The latest event wins, archived changes in archive order, then open changes.
26
+ // its work orders' `implements` lists; each about the revision that change's delta states, so
27
+ // evidence about one revision is never read as evidence about another. The latest wins, in
28
+ // archive order. An agreed change is not an assertion: it is an intended revision.
29
+ // change_effects per (change, element): each requirement a change's delta writes, from its base revision to
30
+ // its target, open and archived changes alike; and each structural element its structural
31
+ // deltas write (an archived change's from its effect record, structure/applied.yaml).
25
32
  //
26
33
  // Archive order is ../../behaviour-store/archive.ts's; same-day archives the history cannot order are a finding.
27
34
  //
@@ -29,8 +36,25 @@
29
36
  import { readdir, readFile, stat } from "node:fs/promises";
30
37
  import { join } from "node:path";
31
38
  import { parse as parseYaml } from "yaml";
39
+ import { createHash } from "node:crypto";
32
40
  import { observedIn, orderArchives, parseRequirements, renamePairs, resolveImplements } from "../../behaviour-store/archive.js";
41
+ import { effectsOf, parseAppliedRecord } from "../../structure/effects.js";
42
+ import { APPLIED_FILE, loadChangeStructure, loadModel } from "../../structure/model.js";
43
+ import { viewOf } from "../../structure/view.js";
44
+ import { requirementBlock } from "../../validate/agreement-evidence.js";
33
45
  export { observedIn, parseRequirements, resolveImplements };
46
+ /** `sha256:` of a requirement block, its lines trimmed and blank lines dropped (M-ADR 0230 §4.7). */
47
+ export function requirementDigest(spec, name) {
48
+ const block = requirementBlock(spec, name);
49
+ if (block === null)
50
+ return undefined;
51
+ const normal = block.split("\n").map((l) => l.trim()).filter((l) => l !== "").join("\n");
52
+ return `sha256:${createHash("sha256").update(normal, "utf8").digest("hex")}`;
53
+ }
54
+ /** A revision's id: <requirement_id>@<the digest's first 12 hex digits>. */
55
+ export function revisionId(requirementId, digest) {
56
+ return `${requirementId}@${digest.slice("sha256:".length, "sha256:".length + 12)}`;
57
+ }
34
58
  const STORE = "openspec";
35
59
  const AGREEMENTS = join("docs", "agreements");
36
60
  async function isDir(p) {
@@ -65,8 +89,10 @@ async function deltaOf(changeDir) {
65
89
  if (text === null)
66
90
  continue;
67
91
  for (const r of parseRequirements(text)) {
68
- if (r.operation)
69
- out.push({ feature, name: r.name, operation: r.operation, file, scenarios: r.scenarios });
92
+ if (!r.operation)
93
+ continue;
94
+ const digest = r.operation === "ADDED" || r.operation === "MODIFIED" ? requirementDigest(text, r.name) : undefined;
95
+ out.push({ feature, name: r.name, operation: r.operation, file, scenarios: r.scenarios, ...(digest ? { digest } : {}) });
70
96
  }
71
97
  }
72
98
  return out;
@@ -126,14 +152,24 @@ export function parseWorkItems(proposal) {
126
152
  return ids;
127
153
  }
128
154
  const rel = (sddPath, p) => p.slice(sddPath.length + 1).split("\\").join("/");
155
+ /** One effect per (change, element): a change that renames and modifies a requirement records MODIFIED, as its lineage does. */
156
+ function dedupeEffects(rows) {
157
+ const byId = new Map();
158
+ for (const r of rows) {
159
+ const prior = byId.get(String(r["id"]));
160
+ byId.set(String(r["id"]), prior && r["operation"] === "RENAMED" ? prior : r);
161
+ }
162
+ return [...byId.values()];
163
+ }
129
164
  export async function buildRequirements(inputs) {
130
165
  const { sddPath, findings } = inputs;
131
166
  const scoped = (change) => `${inputs.sddId}::${change}`;
132
- const empty = { requirements: [], lineage: [], status: [], changes: [], workOrders: [], workItems: [] };
167
+ const empty = { requirements: [], lineage: [], revisions: [], assertions: [], effects: [], changes: [], workOrders: [], workItems: [] };
133
168
  const store = join(sddPath, STORE);
134
169
  if (!(await isDir(store)))
135
170
  return empty;
136
171
  const requirements = new Map();
172
+ const livingDigest = new Map();
137
173
  const unknownFeatures = new Set();
138
174
  const known = (feature) => {
139
175
  if (inputs.knownFeatureIds.has(feature))
@@ -154,9 +190,26 @@ export async function buildRequirements(inputs) {
154
190
  continue;
155
191
  }
156
192
  requirements.set(id, { id, feature_id: feature, name: r.name, stage: "living", spec_path: rel(sddPath, file), scenario_count: r.scenarios, source_sdd_id: "", platform_sdd_id: "" });
193
+ const digest = requirementDigest(text, r.name);
194
+ if (digest)
195
+ livingDigest.set(id, digest);
157
196
  }
158
197
  }
159
198
  const events = [];
199
+ const revisions = new Map();
200
+ const requirementEffects = [];
201
+ const structureEffects = [];
202
+ const effectRow = (changeId, kind, element, operation, base, target) => ({
203
+ id: `${changeId}__${kind}__${element}`,
204
+ change_id: changeId,
205
+ element_kind: kind,
206
+ element_id: element,
207
+ operation,
208
+ base_revision: base,
209
+ target_revision: target,
210
+ source_sdd_id: "",
211
+ platform_sdd_id: "",
212
+ });
160
213
  const lineage = [];
161
214
  const unknownRepos = new Set();
162
215
  const changes = [];
@@ -214,7 +267,13 @@ export async function buildRequirements(inputs) {
214
267
  lineage: { change_id: scoped(dir), change_kind: kind, operation: d.operation, archived_on: archivedOn, source_sdd_id: "", platform_sdd_id: "" },
215
268
  ...(d.operation === "REMOVED" ? { removedIn: rel(sddPath, d.file) } : {}),
216
269
  });
270
+ archived.push({ index, feature: d.feature, name: d.name, effect: { change: scoped(dir), operation: d.operation, ...(d.digest ? { digest: d.digest } : {}) } });
217
271
  }
272
+ // Structural effects, from the effect record the method's archive wrote (M-ADR 0230 §4.6).
273
+ const applied = await readText(join(changeDir, "structure", APPLIED_FILE));
274
+ const rec = applied === null ? null : parseAppliedRecord(applied);
275
+ for (const e of rec?.effects ?? [])
276
+ structureEffects.push(effectRow(scoped(dir), "structure", e.element, e.operation, e.base ?? "", e.target ?? ""));
218
277
  if (evidence !== null) {
219
278
  const repos = observedIn(evidence);
220
279
  if (repos.length === 0) {
@@ -222,8 +281,8 @@ export async function buildRequirements(inputs) {
222
281
  }
223
282
  for (const repo of repos)
224
283
  for (const d of delta)
225
- if (d.operation !== "REMOVED")
226
- archived.push({ index, feature: d.feature, name: d.name, event: { repo, status: "observed", basis: scoped(dir) } });
284
+ if (d.digest)
285
+ archived.push({ index, feature: d.feature, name: d.name, event: { repo, status: "observed", basis: scoped(dir), digest: d.digest } });
227
286
  continue;
228
287
  }
229
288
  const record = await agreementRecord(sddPath, change);
@@ -242,7 +301,8 @@ export async function buildRequirements(inputs) {
242
301
  findings.add("convention", `${AGREEMENTS}/${change}.yaml: work order for ${wo.repo} implements "${name}", which the change's delta does not name`);
243
302
  }
244
303
  for (const d of match)
245
- archived.push({ index, feature: d.feature, name: d.name, event: { repo: wo.repo, status: "declared-shipped", basis: scoped(dir) } });
304
+ if (d.digest)
305
+ archived.push({ index, feature: d.feature, name: d.name, event: { repo: wo.repo, status: "declared-shipped", basis: scoped(dir), digest: d.digest } });
246
306
  }
247
307
  }
248
308
  }
@@ -266,8 +326,25 @@ export async function buildRequirements(inputs) {
266
326
  // One lineage row per (requirement, change): a change that renames and modifies a requirement records MODIFIED,
267
327
  // the behaviour change, and its delta still shows the rename.
268
328
  const lineageById = new Map();
329
+ // Revisions in archive order: each ADDED or MODIFIED block is a revision its change introduced; the last one of a
330
+ // requirement is the base of the next change's effect on it.
331
+ const latest = new Map();
269
332
  for (const a of archived) {
270
333
  const requirementId = current(a.feature, a.name, a.index);
334
+ if (a.effect) {
335
+ if (!requirements.has(requirementId))
336
+ continue;
337
+ const base = latest.get(requirementId);
338
+ const target = a.effect.digest ? revisionId(requirementId, a.effect.digest) : undefined;
339
+ if (a.effect.digest && target) {
340
+ revisions.set(target, { id: target, requirement_id: requirementId, digest: a.effect.digest, state: "history", change_id: a.effect.change, source_sdd_id: "", platform_sdd_id: "" });
341
+ latest.set(requirementId, target);
342
+ }
343
+ if (a.effect.operation === "REMOVED")
344
+ latest.delete(requirementId);
345
+ requirementEffects.push(effectRow(a.effect.change, "requirement", requirementId, a.effect.operation, base ?? "", a.effect.operation === "RENAMED" ? base ?? "" : target ?? ""));
346
+ continue;
347
+ }
271
348
  if (a.event)
272
349
  pushEvent({ requirementId, ...a.event });
273
350
  else if (a.lineage && requirements.has(requirementId)) {
@@ -278,6 +355,12 @@ export async function buildRequirements(inputs) {
278
355
  }
279
356
  }
280
357
  lineage.push(...lineageById.values());
358
+ // The living text is the current revision, whichever change introduced it (none, for an edit no change made).
359
+ for (const [id, digest] of livingDigest) {
360
+ const rid = revisionId(id, digest);
361
+ const prior = revisions.get(rid);
362
+ revisions.set(rid, { id: rid, requirement_id: id, digest, state: "current", change_id: prior?.["change_id"] ?? "", source_sdd_id: "", platform_sdd_id: "" });
363
+ }
281
364
  // 3. Open changes whose agreement record is agreed.
282
365
  for (const dir of (await dirs(join(store, "changes"))).filter((d) => d !== "archive")) {
283
366
  const record = await agreementRecord(sddPath, dir);
@@ -288,44 +371,70 @@ export async function buildRequirements(inputs) {
288
371
  openStatus = "proposed";
289
372
  }
290
373
  await recordChange(dir, dir, openKind, openStatus, "", join(store, "changes", dir), record);
291
- if (record?.status !== "agreed")
292
- continue;
293
374
  const delta = await deltaOf(join(store, "changes", dir));
375
+ const agreed = record?.status === "agreed";
294
376
  for (const d of delta) {
295
377
  const id = `${d.feature}::${d.name}`;
296
- if (d.operation === "ADDED" && !requirements.has(id) && known(d.feature)) {
378
+ if (agreed && d.operation === "ADDED" && !requirements.has(id) && known(d.feature)) {
297
379
  requirements.set(id, { id, feature_id: d.feature, name: d.name, stage: "agreed", spec_path: rel(sddPath, d.file), scenario_count: d.scenarios, source_sdd_id: "", platform_sdd_id: "" });
298
380
  }
381
+ // An effect for every element the change writes: a requirement a change not yet agreed ADDs has no
382
+ // requirements row yet, and still has its effect (change_effects.element_id names no foreign key).
383
+ if (!requirements.has(id) && !(d.operation === "ADDED" && known(d.feature)))
384
+ continue;
385
+ const living = livingDigest.get(id);
386
+ const base = living ? revisionId(id, living) : "";
387
+ const target = d.digest ? revisionId(id, d.digest) : "";
388
+ // An agreed change's text is an intended revision; one not yet agreed has an effect and no revision row.
389
+ if (agreed && d.digest && !revisions.has(target)) {
390
+ revisions.set(target, { id: target, requirement_id: id, digest: d.digest, state: "intended", change_id: scoped(dir), source_sdd_id: "", platform_sdd_id: "" });
391
+ }
392
+ requirementEffects.push(effectRow(scoped(dir), "requirement", id, d.operation, d.operation === "ADDED" ? "" : base, d.operation === "REMOVED" ? "" : d.operation === "RENAMED" ? base : target));
299
393
  }
394
+ if (!agreed)
395
+ continue;
300
396
  for (const wo of record.work_orders ?? []) {
301
397
  if (!wo.repo)
302
398
  continue;
303
399
  for (const name of wo.implements ?? []) {
304
- const { matches, ambiguous } = resolveImplements(delta.filter((x) => x.operation !== "REMOVED"), name);
400
+ const { ambiguous } = resolveImplements(delta.filter((x) => x.operation !== "REMOVED"), name);
305
401
  if (ambiguous.length > 0) {
306
- findings.add("divergence", `${AGREEMENTS}/${dir}.yaml: work order for ${wo.repo} implements "${name}", a name ${ambiguous.length} features in the change share (${ambiguous.join(", ")}); qualify it as <feature>::<name>. No status is derived from it`);
307
- }
308
- for (const d of matches) {
309
- pushEvent({ requirementId: `${d.feature}::${d.name}`, repo: wo.repo, status: "agreed", basis: scoped(dir) });
402
+ findings.add("divergence", `${AGREEMENTS}/${dir}.yaml: work order for ${wo.repo} implements "${name}", a name ${ambiguous.length} features in the change share (${ambiguous.join(", ")}); qualify it as <feature>::<name>`);
310
403
  }
311
404
  }
312
405
  }
313
406
  }
407
+ // Open changes' structural effects, against the model as it stands (M-ADR 0230 §4.4, §4.7).
408
+ {
409
+ const problems = [];
410
+ const files = await loadModel(sddPath, problems);
411
+ const view = viewOf(files ?? [], problems);
412
+ for (const dir of (await dirs(join(store, "changes"))).filter((d) => d !== "archive")) {
413
+ const s = await loadChangeStructure(sddPath, dir, problems);
414
+ for (const e of effectsOf(s, view))
415
+ structureEffects.push(effectRow(scoped(dir), "structure", e.element, e.operation, e.base ?? "", e.target ?? ""));
416
+ }
417
+ }
314
418
  for (const f of unknownFeatures)
315
419
  findings.add("convention", `${STORE}/: a spec keyed "${f}" names no catalogue feature, so its requirements are not catalogued (M-ADR 0221 §4.7)`);
316
420
  for (const r of unknownRepos)
317
- findings.add("convention", `${STORE}/: a status names repository "${r}", which the catalogue does not hold; no status row`);
318
- // Latest event per (requirement, repository) wins.
319
- const status = new Map();
421
+ findings.add("convention", `${STORE}/: an assertion names repository "${r}", which the catalogue does not hold; no assertion row`);
422
+ // Latest assertion per (revision, repository) wins.
423
+ const assertions = new Map();
320
424
  for (const e of events) {
321
- const id = `${e.requirementId}__${e.repo}`;
322
- status.set(id, { id, requirement_id: e.requirementId, repo_id: e.repo, status: e.status, basis: e.basis, source_sdd_id: "", platform_sdd_id: "" });
425
+ const rid = revisionId(e.requirementId, e.digest);
426
+ if (!revisions.has(rid))
427
+ continue;
428
+ const id = `${rid}__${e.repo}`;
429
+ assertions.set(id, { id, revision_id: rid, requirement_id: e.requirementId, repo_id: e.repo, kind: e.status, basis: e.basis, source_sdd_id: "", platform_sdd_id: "" });
323
430
  }
324
431
  const byId = (a, b) => String(a["id"]).localeCompare(String(b["id"]));
325
432
  return {
326
433
  requirements: [...requirements.values()].sort(byId),
327
434
  lineage,
328
- status: [...status.values()].sort(byId),
435
+ revisions: [...revisions.values()].sort(byId),
436
+ assertions: [...assertions.values()].sort(byId),
437
+ effects: [...dedupeEffects(requirementEffects), ...structureEffects].sort(byId),
329
438
  changes: changes.sort(byId),
330
439
  workOrders: workOrders.sort(byId),
331
440
  workItems: workItems.sort(byId),