@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.
- package/dist/lib/behaviour-store/archive.js +13 -0
- package/dist/lib/catalogue/aggregate.js +62 -0
- package/dist/lib/catalogue/build.js +12 -0
- package/dist/lib/catalogue/builders/capability-spec.js +1 -0
- package/dist/lib/catalogue/builders/feature-grounding.js +1 -0
- package/dist/lib/catalogue/builders/features.js +23 -2
- package/dist/lib/catalogue/builders/journey-requirement.js +144 -0
- package/dist/lib/catalogue/builders/requirements.js +43 -16
- package/dist/lib/catalogue/canonical-schema.yaml +81 -14
- package/dist/lib/catalogue/manifest-loader.js +13 -0
- package/dist/lib/catalogue/rubric-coverage-map.yaml +18 -0
- package/dist/lib/catalogue/schema-compat.js +1 -1
- package/dist/lib/intents/format.js +77 -0
- package/dist/lib/metamodel/tql-subset-parser.js +35 -2
- package/dist/lib/validate/agreement-evidence.js +204 -0
- package/dist/lib/validate/behaviour-store.js +475 -28
- package/dist/lib/validate/dispatch.js +21 -0
- package/dist/lib/validate/feature-spec-pointers.js +175 -0
- package/dist/lib/validate/intents.js +76 -0
- package/dist/lib/validate/journey-impact.js +193 -0
- package/dist/maintain-version.json +2 -2
- package/dist/verify-version.json +2 -2
- package/package.json +2 -2
|
@@ -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
|
|
@@ -89,9 +89,10 @@ export async function buildFeatures(inputs) {
|
|
|
89
89
|
parent_feature_id: "",
|
|
90
90
|
description,
|
|
91
91
|
maturity: "",
|
|
92
|
-
status:
|
|
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:
|
|
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
|
|
11
|
-
// requirement
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
|
227
|
-
|
|
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
|
-
|
|
282
|
-
|
|
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
|
}
|