@sdd-method/maintain-cli 0.10.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.
@@ -204,4 +204,17 @@ export function surfaceMatches(entry, contractId, specPath) {
204
204
  const re = new RegExp(`(^|/)${e.split("*").map((p) => p.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join("[^/]*")}$`);
205
205
  return re.test(specPath.split("\\").join("/"));
206
206
  }
207
+ /**
208
+ * The delta entries a work order's `implements` entry names (schema 5.0.0). `<feature>::<name>` names exactly one
209
+ * requirement. A bare name resolves only when one feature in the change has a requirement of that name; across
210
+ * two features it is ambiguous, and names none.
211
+ */
212
+ export function resolveImplements(delta, entry) {
213
+ const q = /^(.+?)::(.+)$/.exec(entry);
214
+ if (q)
215
+ return { matches: delta.filter((d) => d.feature === q[1] && d.name === q[2]), ambiguous: [] };
216
+ const matches = delta.filter((d) => d.name === entry);
217
+ const features = [...new Set(matches.map((d) => d.feature))];
218
+ return features.length > 1 ? { matches: [], ambiguous: features } : { matches, ambiguous: [] };
219
+ }
207
220
  //# sourceMappingURL=archive.js.map
@@ -40,6 +40,7 @@ import { buildCapabilitySpec } from "./builders/capability-spec.js";
40
40
  import { buildFeatureGrounding } from "./builders/feature-grounding.js";
41
41
  import { loadFeatureGroundingAliases } from "./feature-grounding-aliases-loader.js";
42
42
  import { buildFeatureContract } from "./builders/feature-contract.js";
43
+ import { buildJourneyRequirement } from "./builders/journey-requirement.js";
43
44
  import { buildCapabilityEvent } from "./builders/event-edges.js";
44
45
  import { discoverVocabulary } from "./application-archetype/vocabulary.js";
45
46
  import { checkDataPlaneOverlayAtAggregation } from "./application-archetype/expectations.js";
@@ -342,6 +343,67 @@ export async function runCatalogueAggregate(inputs, write) {
342
343
  findings.add("coverage", `feature_repo: ${added} row(s) re-derived at aggregate tier for features that had NO anchor at member tier — evidence tokens naming a repo registered by another contributor resolve only against the merged repositories; already-anchored features are not widened (M-ADR 0198 §4.1 as amended; ${contributors.length} contributor doc trees scanned)`);
343
344
  }
344
345
  }
346
+ // M-ADR 0229 §4.4 (Proposed): journey_requirement re-derived over the merged rows. A journey is often catalogued
347
+ // in an application SDD and the requirements its steps cite in the platform's behaviour store, so neither member
348
+ // holds both join inputs; each reports the tag as external. Each contributor's tagged end-to-end files (its own
349
+ // manifest's journeys.test_paths) and its journey features' living specs are read again here against the MERGED
350
+ // features, requirements and journeys. Fills absences only (rederiveAppend). A tag naming another contributor's
351
+ // feature that the merged rows still do not resolve is reported here; everything the member build already reported
352
+ // (its own features' tags, its test paths, its files' journey tags and journey specs) is not repeated.
353
+ {
354
+ const contributors = [{ sddPath, sddId: rootManifest.sddId }];
355
+ for (const sib of aggManifest.siblings) {
356
+ if (sib.level === "organisation")
357
+ continue;
358
+ if (crossCheck.siblingProfiles.get(sib.sddId) === undefined)
359
+ continue;
360
+ contributors.push({ sddPath: sib.sddRoot, sddId: sib.sddId });
361
+ }
362
+ const merged = {
363
+ features: rowsByCatalogue.get("features") ?? [],
364
+ requirements: rowsByCatalogue.get("requirements") ?? [],
365
+ knownJourneyIds: new Set((rowsByCatalogue.get("user_journeys") ?? []).map((r) => String(r["id"]))),
366
+ };
367
+ const rederived = [];
368
+ const seen = new Set();
369
+ const unresolved = [];
370
+ for (const c of contributors) {
371
+ let testPaths = [];
372
+ try {
373
+ testPaths = (await loadSddManifest(c.sddPath, { cliVersion: CLI_VERSION })).journeys?.testPaths ?? [];
374
+ }
375
+ catch {
376
+ // the member's own build reports an unreadable manifest
377
+ }
378
+ const scratch = new FindingsLog();
379
+ // The contributor's own features: its member build already reported on tags naming them.
380
+ const reportedFeatureIds = new Set(merged.features.filter((f) => String(f["source_sdd_id"] ?? "") === c.sddId).map((f) => String(f["id"])));
381
+ for (const row of await buildJourneyRequirement({ sddPath: c.sddPath, findings: scratch, testPaths, reportedFeatureIds, ...merged })) {
382
+ const id = String(row["id"]);
383
+ if (seen.has(id))
384
+ continue;
385
+ seen.add(id);
386
+ rederived.push({ ...row, source_sdd_id: c.sddId });
387
+ }
388
+ // Still external against the merged rows: unresolved. An orphan or divergence about another contributor's feature
389
+ // (a tag naming a platform requirement that does not exist, or that two of its requirements share) was invisible
390
+ // to the member build, so it is reported here.
391
+ for (const f of scratch.all()) {
392
+ if (f.category === "external")
393
+ unresolved.push(`${c.sddId}: ${f.summary.replace(/; resolves at aggregation.*$/, "")}`);
394
+ else if (f.category === "orphan" || f.category === "divergence")
395
+ unresolved.push(`${c.sddId}: ${f.summary}`);
396
+ }
397
+ }
398
+ const before = (rowsByCatalogue.get("journey_requirement") ?? []).length;
399
+ rederiveAppend(rowsByCatalogue, "journey_requirement", rederived);
400
+ const added = (rowsByCatalogue.get("journey_requirement") ?? []).length - before;
401
+ if (added > 0) {
402
+ findings.add("coverage", `journey_requirement: ${added} row(s) derived at aggregate tier, where a journey and the requirements it cites are catalogued in different SDDs (M-ADR 0229 §4.4, Proposed; ${contributors.length} contributor(s) read)`);
403
+ }
404
+ for (const u of unresolved)
405
+ findings.add("orphan", `journey_requirement: unresolved against the merged catalogue: ${u}`);
406
+ }
345
407
  const repoEdges = buildRepoEdges({
346
408
  findings,
347
409
  configs: [],
@@ -35,6 +35,7 @@ import { buildDataItems, buildFeatureDemand, buildIntegrationSupply } from "./bu
35
35
  import { buildEvidenceTargetRepos } from "./builders/evidence-target-repo.js";
36
36
  import { buildRepoDependencies } from "./builders/repo-dependency.js";
37
37
  import { buildRequirements } from "./builders/requirements.js";
38
+ import { buildJourneyRequirement } from "./builders/journey-requirement.js";
38
39
  import { buildDomains, buildProducts, buildApplicationDataPlaneBindings, buildApplicationDataPlaneDeviations, buildApplicationConformanceCoverage, buildImplementations, buildRepositories, buildServices, buildCapabilities, buildCapabilityService, buildCapabilityAdr, buildCapabilityFeature, buildCapabilityContract, buildAdrs, buildAdrSupersedes, buildFeatures, buildFeatureGroups, buildFeatureGrounding, buildCapabilitySpec, buildContracts, buildDeclaredExternalContracts, buildRepoEdges, deriveFeatureRepoFromServices, buildRepoCapabilityExemption, buildServiceCapabilityExemption, buildEvents, buildServiceEvent, buildCapabilityEvent, buildFeatureEvent, buildServiceContract, buildServiceContractConsumes, buildFeatureContract, buildDomainModels, buildCapabilityDomainModel, buildFeatureDomainModel, buildFeatureSubscriptionChannels, buildProductPlatformDependencies, buildGlossaryItems, backfillGlossaryRelations, buildGlossaryXref, buildC4Systems, buildC4Containers, buildC4Components, buildC4ExternalSystems, buildExternalSystemExemption, buildContainerDependency, reportEntryIdReferentCollisions, buildSystemExternalDependency, buildPersonaC4System, buildPersonaContainer, buildPersonas, buildQualityScores, buildScaffolds, buildScreens, buildUserJourneys, buildPersonaJourney, buildPersonaFeature, buildJourneyFeature, buildProductEdges, buildIntegrations, entitySlug, buildEntitiesFromManifest, buildEntitiesFromMermaid, buildServiceEntityFromTables, enrichEntityPersistenceKind, buildC4ContainerEntity, buildC4SystemEntity, } from "./builders/index.js";
39
40
  import { buildIntegrationComponents } from "./builders/integration-components.js";
40
41
  import { loadReverseProductQuality } from "./reverse-product-quality.js";
@@ -1163,6 +1164,7 @@ async function collectRows(schema, manifest, findings, sddPath, snapshotStore) {
1163
1164
  findings,
1164
1165
  knownFeatureIds: new Set((out.get("features") ?? []).map((row) => String(row["id"]))),
1165
1166
  knownRepoIds: knownRepoIdsFinal,
1167
+ sddId: manifest.sddId,
1166
1168
  });
1167
1169
  out.set("requirements", r.requirements);
1168
1170
  out.set("requirement_lineage", r.lineage);
@@ -1170,6 +1172,16 @@ async function collectRows(schema, manifest, findings, sddPath, snapshotStore) {
1170
1172
  out.set("changes", r.changes);
1171
1173
  out.set("change_work_order", r.workOrders);
1172
1174
  out.set("change_work_item", r.workItems);
1175
+ // M-ADR 0229 §4.4 (Proposed): the requirements each journey cites, from its tagged end-to-end files or its
1176
+ // journey feature's living spec.
1177
+ out.set("journey_requirement", await buildJourneyRequirement({
1178
+ sddPath,
1179
+ findings,
1180
+ testPaths: manifest.journeys?.testPaths ?? [],
1181
+ features: out.get("features") ?? [],
1182
+ requirements: r.requirements,
1183
+ knownJourneyIds: new Set((out.get("user_journeys") ?? []).map((row) => String(row["id"]))),
1184
+ }));
1173
1185
  }
1174
1186
  // 15. C4 systems + containers + components (L1 + L2 + L3). The L3 walker
1175
1187
  // is the forward-method authoring path reading
@@ -133,6 +133,7 @@ export async function buildCapabilitySpec(inputs) {
133
133
  status: "active",
134
134
  source: "capability-spec",
135
135
  runway: "",
136
+ kind: "feature",
136
137
  source_sdd_id: "",
137
138
  });
138
139
  }
@@ -153,6 +153,7 @@ export async function buildFeatureGrounding(inputs) {
153
153
  status: "active",
154
154
  source: "feature-grounding",
155
155
  runway: "",
156
+ kind: "feature",
156
157
  source_sdd_id: "",
157
158
  });
158
159
  const cfId = `${capabilityId}__${featureId}`;
@@ -89,9 +89,10 @@ export async function buildFeatures(inputs) {
89
89
  parent_feature_id: "",
90
90
  description,
91
91
  maturity: "",
92
- status: "active",
92
+ status: featureStatus(text),
93
93
  source: "feature-spec",
94
94
  runway: "",
95
+ kind: featureKind(text),
95
96
  source_sdd_id: "",
96
97
  });
97
98
  idByPath.set(relPath, id);
@@ -123,9 +124,10 @@ export async function buildFeatures(inputs) {
123
124
  parent_feature_id: "",
124
125
  description,
125
126
  maturity: "",
126
- status: "active",
127
+ status: featureStatus(text),
127
128
  source: "feature-spec",
128
129
  runway: "",
130
+ kind: featureKind(text),
129
131
  source_sdd_id: "",
130
132
  });
131
133
  idByPath.set(relPath, id);
@@ -145,6 +147,24 @@ export async function buildFeatures(inputs) {
145
147
  }
146
148
  return { rows, idByPath, idBySlug };
147
149
  }
150
+ /**
151
+ * A feature spec's catalogue status: `retired` when the spec says `**Status:** retired` (its behaviour retired by
152
+ * an archived change, the feature kept in the catalogue so its retired requirements still resolve), else
153
+ * `active`. A retired feature stays where it is; it does not have to move to withdrawn/ first.
154
+ */
155
+ export function featureStatus(text) {
156
+ const m = /^\*\*Status:\*\*\s*([A-Za-z-]+)/m.exec(text) ?? /^>.*\bStatus:\s*([A-Za-z-]+)/m.exec(text);
157
+ return m?.[1]?.toLowerCase() === "retired" ? "retired" : "active";
158
+ }
159
+ /**
160
+ * A feature spec's kind (schema 4.4.0): the value of its `**Kind:**` line, lower-cased, else `feature`. An
161
+ * obligation (M-ADR 0226, Proposed) and a journey feature (M-ADR 0229 §4.2, Proposed) say so; any other value is written as given, so
162
+ * the catalogue's vocabulary gate names the spec rather than a default hiding the mistake.
163
+ */
164
+ export function featureKind(text) {
165
+ const m = /^\*\*Kind:\*\*\s*([A-Za-z-]+)/m.exec(text);
166
+ return m?.[1]?.toLowerCase() ?? "feature";
167
+ }
148
168
  async function emitBulletRows(ctx) {
149
169
  const featureTreePath = await findFeatureTreeFile(ctx.sddPath);
150
170
  if (!featureTreePath)
@@ -188,6 +208,7 @@ async function emitBulletRows(ctx) {
188
208
  status: "active",
189
209
  source: "feature-tree",
190
210
  runway: ev.runway,
211
+ kind: "feature",
191
212
  source_sdd_id: "",
192
213
  });
193
214
  if (!ctx.idBySlug.has(ev.slug))
@@ -0,0 +1,144 @@
1
+ // journey_requirement: the requirements a user journey cites for its steps (M-ADR 0229 §4.4, Proposed; schema 4.4.0).
2
+ //
3
+ // Derived, never authored, from either master (§4.2):
4
+ // tag option A: the end-to-end `.feature` files under the manifest's `journeys.test_paths`. A file tagged
5
+ // `@journey:<journey-id>` cites every `@req:<feature-id>/<requirement-slug>` tag it carries.
6
+ // citation option B: the living spec (openspec/specs/<feature-id>/spec.md) of a feature of kind journey names its
7
+ // journey with the same `@journey:<journey-id>` tag and cites each step's requirement with `@req:`.
8
+ // One tag grammar for both, so the derivation needs only the files and the catalogue's rows. Both are read whenever
9
+ // present, so an estate moving from A to B shows both while it moves.
10
+ //
11
+ // A requirement tag cannot carry spaces, so it names the requirement by slug: its name lower-cased, each run of
12
+ // anything but a-z and 0-9 turned into one hyphen. A tag resolves when exactly one requirement of that feature,
13
+ // living or agreed (not retired), has that slug; a tag naming a retired requirement names none, so it is a finding. A tag that resolves to none or to several, a file with two journey tags, or a test
14
+ // path that does not exist is a finding and produces no row, so no foreign key dangles. A tag naming a feature or a
15
+ // journey this catalogue does not hold is reported as external: journeys are often catalogued in application SDDs
16
+ // and requirements in the platform's store, so the row is derived at aggregation, where both are merged
17
+ // (catalogue aggregate re-runs this builder over each contributor). A journey with no row is untraced, which the
18
+ // journey-impact check reports.
19
+ import { readdir, readFile, stat } from "node:fs/promises";
20
+ import { join, relative } from "node:path";
21
+ const REQ_TAG = /@req:([a-z0-9][a-z0-9-]*)\/([a-z0-9][a-z0-9-]*)/g;
22
+ const JOURNEY_TAG = /@journey:([a-z0-9][a-z0-9-]*)/g;
23
+ /** "Order shipped (REST)" -> "order-shipped-rest". */
24
+ export function requirementSlug(name) {
25
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
26
+ }
27
+ async function featureFiles(dir) {
28
+ const out = [];
29
+ let entries;
30
+ try {
31
+ entries = await readdir(dir, { withFileTypes: true });
32
+ }
33
+ catch {
34
+ return out;
35
+ }
36
+ for (const e of entries) {
37
+ if (e.name === "node_modules" || e.name.startsWith("."))
38
+ continue;
39
+ const p = join(dir, e.name);
40
+ if (e.isDirectory())
41
+ out.push(...(await featureFiles(p)));
42
+ else if (e.isFile() && e.name.endsWith(".feature"))
43
+ out.push(p);
44
+ }
45
+ return out.sort();
46
+ }
47
+ export async function buildJourneyRequirement(inputs) {
48
+ // At aggregation the member builds have already reported every finding about their own files and features.
49
+ const atAggregate = inputs.reportedFeatureIds !== undefined;
50
+ const { sddPath, findings } = inputs;
51
+ const bySlug = new Map();
52
+ for (const r of inputs.requirements) {
53
+ if (String(r["stage"] ?? "") === "retired")
54
+ continue; // a journey step cannot rely on behaviour that has been removed
55
+ const key = `${String(r["feature_id"])}/${requirementSlug(String(r["name"]))}`;
56
+ const ids = bySlug.get(key) ?? [];
57
+ if (!ids.includes(String(r["id"])))
58
+ ids.push(String(r["id"]));
59
+ bySlug.set(key, ids);
60
+ }
61
+ const rows = new Map();
62
+ const knownFeatures = new Set(inputs.features.map((f) => String(f["id"])));
63
+ const cite = (journeyId, text, where, basis) => {
64
+ for (const m of text.matchAll(REQ_TAG)) {
65
+ const quiet = atAggregate && inputs.reportedFeatureIds.has(m[1]);
66
+ if (!knownFeatures.has(m[1])) {
67
+ findings.add("external", `${where}: @req:${m[1]}/${m[2]} names a feature this catalogue does not hold; resolves at aggregation (M-ADR 0229 §4.4, Proposed)`);
68
+ continue;
69
+ }
70
+ const ids = bySlug.get(`${m[1]}/${m[2]}`) ?? [];
71
+ if (ids.length !== 1) {
72
+ if (!quiet)
73
+ findings.add("orphan", `${where}: @req:${m[1]}/${m[2]} ${ids.length === 0 ? "names no requirement of that feature" : `matches ${ids.length} requirements of that feature`}; no journey_requirement row (M-ADR 0229 §4.4, Proposed)`);
74
+ continue;
75
+ }
76
+ const id = `${journeyId}__${ids[0]}`;
77
+ if (!rows.has(id))
78
+ rows.set(id, { id, journey_id: journeyId, requirement_id: ids[0], basis, source_sdd_id: "", platform_sdd_id: "" });
79
+ }
80
+ };
81
+ // Option A: tagged end-to-end files.
82
+ for (const rel of inputs.testPaths) {
83
+ const dir = join(sddPath, rel);
84
+ const exists = await stat(dir).then((s) => s.isDirectory(), () => false);
85
+ if (!exists) {
86
+ if (!atAggregate)
87
+ findings.add("orphan", `.sdd/sdd-manifest.yaml: journeys.test_paths names ${rel}, which is not a directory in this SDD`);
88
+ continue;
89
+ }
90
+ for (const file of await featureFiles(dir)) {
91
+ const where = relative(sddPath, file);
92
+ const text = await readFile(file, "utf8");
93
+ const journeys = [...new Set([...text.matchAll(JOURNEY_TAG)].map((m) => m[1]))];
94
+ if (journeys.length === 0)
95
+ continue;
96
+ if (journeys.length > 1) {
97
+ if (!atAggregate)
98
+ findings.add("divergence", `${where}: tagged with ${journeys.length} journeys (${journeys.join(", ")}); one file is one journey's master (M-ADR 0229 §4.2, Proposed)`);
99
+ continue;
100
+ }
101
+ const journeyId = journeys[0];
102
+ if (!inputs.knownJourneyIds.has(journeyId)) {
103
+ findings.add("external", `${where}: @journey:${journeyId} is not a user journey this catalogue holds; resolves at aggregation`);
104
+ continue;
105
+ }
106
+ cite(journeyId, text, where, "tag");
107
+ }
108
+ }
109
+ // Option B: journey features in the store, each naming its journey in its living spec.
110
+ const kindById = new Map(inputs.features.map((f) => [String(f["id"]), String(f["kind"] ?? "")]));
111
+ let specDirs = [];
112
+ try {
113
+ specDirs = (await readdir(join(sddPath, "openspec", "specs"), { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name).sort();
114
+ }
115
+ catch {
116
+ // no store here
117
+ }
118
+ for (const featureId of specDirs) {
119
+ if (kindById.get(featureId) !== "journey")
120
+ continue;
121
+ const where = join("openspec", "specs", featureId, "spec.md");
122
+ let spec;
123
+ try {
124
+ spec = await readFile(join(sddPath, where), "utf8");
125
+ }
126
+ catch {
127
+ continue;
128
+ }
129
+ const journeys = [...new Set([...spec.matchAll(JOURNEY_TAG)].map((m) => m[1]))];
130
+ if (journeys.length !== 1) {
131
+ if (!atAggregate)
132
+ findings.add(journeys.length === 0 ? "orphan" : "divergence", `${where}: a journey feature's living spec ${journeys.length === 0 ? "names no journey (@journey:<journey-id>)" : `names ${journeys.length} journeys`}; one journey feature masters one journey (M-ADR 0229 §4.2, Proposed)`);
133
+ continue;
134
+ }
135
+ const journeyId = journeys[0];
136
+ if (!inputs.knownJourneyIds.has(journeyId)) {
137
+ findings.add("external", `${where}: @journey:${journeyId} is not a user journey this catalogue holds; resolves at aggregation`);
138
+ continue;
139
+ }
140
+ cite(journeyId, spec, where, "citation");
141
+ }
142
+ return [...rows.values()].sort((a, b) => String(a["id"]).localeCompare(String(b["id"])));
143
+ }
144
+ //# sourceMappingURL=journey-requirement.js.map
@@ -7,9 +7,12 @@
7
7
  // requirements one row per `### Requirement:` in a living spec (stage living), keyed by the spec's
8
8
  // directory name, which is the catalogue feature id; plus one per requirement an open,
9
9
  // agreed change ADDS (stage agreed).
10
- // requirement_lineage each archived change, in archive order, whose delta ADDED, MODIFIED or RENAMED a
11
- // requirement that is current now, under any name a later RENAMED gave it. A change carrying
10
+ // requirement_lineage each archived change, in archive order, whose delta ADDED, MODIFIED, RENAMED or REMOVED a
11
+ // requirement, under any name a later RENAMED gave it. A change carrying
12
12
  // baseline-evidence.md is a baseline. Statuses follow renames the same way.
13
+ // A requirement REMOVED by an archived change and not in a living spec now is kept in
14
+ // requirements as stage retired (schema 4.3.0), so its lineage ends in the REMOVED row;
15
+ // it carries no status.
13
16
  // changes each change in the store, open or archived (M-ADR 0223 §4.2): its directory name, its
14
17
  // undated name, kind, status (archived, or its agreement record's) and verdict.
15
18
  // change_work_order per (change, repository): the record's work order, agreed or completed.
@@ -26,8 +29,8 @@
26
29
  import { readdir, readFile, stat } from "node:fs/promises";
27
30
  import { join } from "node:path";
28
31
  import { parse as parseYaml } from "yaml";
29
- import { observedIn, orderArchives, parseRequirements, renamePairs } from "../../behaviour-store/archive.js";
30
- export { observedIn, parseRequirements };
32
+ import { observedIn, orderArchives, parseRequirements, renamePairs, resolveImplements } from "../../behaviour-store/archive.js";
33
+ export { observedIn, parseRequirements, resolveImplements };
31
34
  const STORE = "openspec";
32
35
  const AGREEMENTS = join("docs", "agreements");
33
36
  async function isDir(p) {
@@ -125,6 +128,7 @@ export function parseWorkItems(proposal) {
125
128
  const rel = (sddPath, p) => p.slice(sddPath.length + 1).split("\\").join("/");
126
129
  export async function buildRequirements(inputs) {
127
130
  const { sddPath, findings } = inputs;
131
+ const scoped = (change) => `${inputs.sddId}::${change}`;
128
132
  const empty = { requirements: [], lineage: [], status: [], changes: [], workOrders: [], workItems: [] };
129
133
  const store = join(sddPath, STORE);
130
134
  if (!(await isDir(store)))
@@ -159,8 +163,9 @@ export async function buildRequirements(inputs) {
159
163
  const workOrders = [];
160
164
  const workItems = [];
161
165
  // The change itself, its work orders and its work items (M-ADR 0223 §4.2).
162
- const recordChange = async (id, name, kind, status, archivedOn, changeDir, record) => {
166
+ const recordChange = async (localId, name, kind, status, archivedOn, changeDir, record) => {
163
167
  const verdict = record?.verdict?.result === "PASS" || record?.verdict?.result === "FAIL" ? record.verdict.result : "";
168
+ const id = scoped(localId);
164
169
  changes.push({ id, name, change_kind: kind, status, archived_on: archivedOn, verdict, source_sdd_id: "", platform_sdd_id: "" });
165
170
  for (const wo of record?.work_orders ?? []) {
166
171
  if (!wo.repo)
@@ -174,7 +179,7 @@ export async function buildRequirements(inputs) {
174
179
  }
175
180
  const proposal = await readText(join(changeDir, "proposal.md"));
176
181
  for (const item of proposal === null ? [] : parseWorkItems(proposal)) {
177
- workItems.push({ id: `${name}__${item}`, change_id: id, change_name: name, work_item_id: item, source_sdd_id: "", platform_sdd_id: "" });
182
+ workItems.push({ id: `${scoped(name)}__${item}`, change_id: id, change_name: name, work_item_id: item, source_sdd_id: "", platform_sdd_id: "" });
178
183
  }
179
184
  };
180
185
  const pushEvent = (e) => {
@@ -182,7 +187,8 @@ export async function buildRequirements(inputs) {
182
187
  unknownRepos.add(e.repo);
183
188
  return;
184
189
  }
185
- if (requirements.has(e.requirementId))
190
+ // A retired requirement has no status: it is not where any repository's behaviour is.
191
+ if (requirements.has(e.requirementId) && requirements.get(e.requirementId)["stage"] !== "retired")
186
192
  events.push(e);
187
193
  };
188
194
  // 2. Archived changes, in archive order. A requirement is named as it was when each change archived, so its
@@ -201,9 +207,13 @@ export async function buildRequirements(inputs) {
201
207
  await recordChange(dir, change, kind, "archived", archivedOn, changeDir, await agreementRecord(sddPath, change));
202
208
  const delta = await deltaOf(changeDir);
203
209
  for (const d of delta) {
204
- if (d.operation === "REMOVED")
205
- continue;
206
- archived.push({ index, feature: d.feature, name: d.name, lineage: { change_id: dir, change_kind: kind, operation: d.operation, archived_on: archivedOn, source_sdd_id: "", platform_sdd_id: "" } });
210
+ archived.push({
211
+ index,
212
+ feature: d.feature,
213
+ name: d.name,
214
+ lineage: { change_id: scoped(dir), change_kind: kind, operation: d.operation, archived_on: archivedOn, source_sdd_id: "", platform_sdd_id: "" },
215
+ ...(d.operation === "REMOVED" ? { removedIn: rel(sddPath, d.file) } : {}),
216
+ });
207
217
  }
208
218
  if (evidence !== null) {
209
219
  const repos = observedIn(evidence);
@@ -213,7 +223,7 @@ export async function buildRequirements(inputs) {
213
223
  for (const repo of repos)
214
224
  for (const d of delta)
215
225
  if (d.operation !== "REMOVED")
216
- archived.push({ index, feature: d.feature, name: d.name, event: { repo, status: "observed", basis: dir } });
226
+ archived.push({ index, feature: d.feature, name: d.name, event: { repo, status: "observed", basis: scoped(dir) } });
217
227
  continue;
218
228
  }
219
229
  const record = await agreementRecord(sddPath, change);
@@ -223,12 +233,16 @@ export async function buildRequirements(inputs) {
223
233
  if (!wo.repo)
224
234
  continue;
225
235
  for (const name of wo.implements ?? []) {
226
- const match = delta.filter((d) => d.name === name && d.operation !== "REMOVED");
227
- if (match.length === 0) {
236
+ const { matches, ambiguous } = resolveImplements(delta.filter((d) => d.operation !== "REMOVED"), name);
237
+ const match = matches;
238
+ if (ambiguous.length > 0) {
239
+ findings.add("divergence", `${AGREEMENTS}/${change}.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`);
240
+ }
241
+ else if (match.length === 0) {
228
242
  findings.add("convention", `${AGREEMENTS}/${change}.yaml: work order for ${wo.repo} implements "${name}", which the change's delta does not name`);
229
243
  }
230
244
  for (const d of match)
231
- archived.push({ index, feature: d.feature, name: d.name, event: { repo: wo.repo, status: "declared-shipped", basis: dir } });
245
+ archived.push({ index, feature: d.feature, name: d.name, event: { repo: wo.repo, status: "declared-shipped", basis: scoped(dir) } });
232
246
  }
233
247
  }
234
248
  }
@@ -240,6 +254,15 @@ export async function buildRequirements(inputs) {
240
254
  n = r.to;
241
255
  return `${feature}::${n}`;
242
256
  };
257
+ // A requirement removed and not living now is retired: kept, so its lineage can end in the removal.
258
+ for (const a of archived) {
259
+ if (!a.removedIn)
260
+ continue;
261
+ const id = current(a.feature, a.name, a.index);
262
+ if (requirements.has(id) || !known(a.feature))
263
+ continue;
264
+ requirements.set(id, { id, feature_id: a.feature, name: id.slice(a.feature.length + 2), stage: "retired", spec_path: a.removedIn, scenario_count: 0, source_sdd_id: "", platform_sdd_id: "" });
265
+ }
243
266
  // One lineage row per (requirement, change): a change that renames and modifies a requirement records MODIFIED,
244
267
  // the behaviour change, and its delta still shows the rename.
245
268
  const lineageById = new Map();
@@ -278,8 +301,12 @@ export async function buildRequirements(inputs) {
278
301
  if (!wo.repo)
279
302
  continue;
280
303
  for (const name of wo.implements ?? []) {
281
- for (const d of delta.filter((x) => x.name === name && x.operation !== "REMOVED")) {
282
- pushEvent({ requirementId: `${d.feature}::${d.name}`, repo: wo.repo, status: "agreed", basis: dir });
304
+ const { matches, ambiguous } = resolveImplements(delta.filter((x) => x.operation !== "REMOVED"), name);
305
+ 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) });
283
310
  }
284
311
  }
285
312
  }