@sdd-method/author-cli 0.9.0 → 0.11.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/author-program.js +1 -0
- package/dist/lib/behaviour-store/archive.js +13 -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/intents/migrate.js +670 -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/verbs/migrate.js +44 -0
- package/dist/verify-version.json +2 -2
- package/package.json +2 -2
package/dist/author-program.js
CHANGED
|
@@ -47,6 +47,7 @@ export const AUTHOR_ONLY_SURFACE = [
|
|
|
47
47
|
"context pull",
|
|
48
48
|
"catalogue check",
|
|
49
49
|
"migrate vocabulary",
|
|
50
|
+
"migrate intents",
|
|
50
51
|
];
|
|
51
52
|
/** The full surface: verify's, plus the author's own. */
|
|
52
53
|
export const AUTHOR_SURFACE = [...VERIFY_SURFACE, ...AUTHOR_ONLY_SURFACE];
|
|
@@ -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
|
|
@@ -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
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
# Emitted by sdd-cli's YAML emitter with no line folding; the three header scalars are quoted for the
|
|
8
8
|
# textual readers downstream. The one deliberate difference from the authored form is schema_status:
|
|
9
9
|
# the authored form says canonical; a projection never does.
|
|
10
|
-
schema_version: "
|
|
10
|
+
schema_version: "5.0.0"
|
|
11
11
|
schema_owner: "sdd-method"
|
|
12
12
|
schema_status: "generated"
|
|
13
13
|
enums:
|
|
@@ -620,6 +620,8 @@ enums:
|
|
|
620
620
|
description: "In the living spec: shipped behaviour."
|
|
621
621
|
- value: agreed
|
|
622
622
|
description: Added by an open change whose agreement record is agreed; not yet shipped.
|
|
623
|
+
- value: retired
|
|
624
|
+
description: "Removed by an archived change's REMOVED delta: kept so its lineage shows the removal; no status."
|
|
623
625
|
description: Where a catalogued requirement stands (M-ADR 0221 §4.4).
|
|
624
626
|
requirement_change_kind:
|
|
625
627
|
values:
|
|
@@ -633,7 +635,8 @@ enums:
|
|
|
633
635
|
- value: ADDED
|
|
634
636
|
- value: MODIFIED
|
|
635
637
|
- value: RENAMED
|
|
636
|
-
|
|
638
|
+
- value: REMOVED
|
|
639
|
+
description: How a change's delta treated a requirement.
|
|
637
640
|
requirement_status_value:
|
|
638
641
|
values:
|
|
639
642
|
- value: agreed
|
|
@@ -664,10 +667,33 @@ enums:
|
|
|
664
667
|
work_order_status:
|
|
665
668
|
values:
|
|
666
669
|
- value: agreed
|
|
667
|
-
description: Agreed; the
|
|
670
|
+
description: Agreed; the change has not yet archived on a passing judge.
|
|
668
671
|
- value: completed
|
|
669
|
-
description:
|
|
672
|
+
description: "Completed: the change archived on a passing judge, after every repository merged its judged commit."
|
|
670
673
|
description: A work order's status in its change's agreement record (M-ADR 0223 §4.2, §4.6).
|
|
674
|
+
feature_status:
|
|
675
|
+
values:
|
|
676
|
+
- value: active
|
|
677
|
+
description: The feature's behaviour is live or being built.
|
|
678
|
+
- value: retired
|
|
679
|
+
description: Its behaviour was retired by an archived change; kept so its retired requirements resolve.
|
|
680
|
+
description: A catalogued feature's status (M-ADR 0221 §4.5).
|
|
681
|
+
feature_kind:
|
|
682
|
+
values:
|
|
683
|
+
- value: feature
|
|
684
|
+
description: "A product or capability feature: the default."
|
|
685
|
+
- value: obligation
|
|
686
|
+
description: Behaviour every feature in a population depends on, owned as a feature of its own (M-ADR 0226, Proposed).
|
|
687
|
+
- value: journey
|
|
688
|
+
description: A user journey's joint behaviour, held in the behaviour store (M-ADR 0229 §4.2, option B; Proposed).
|
|
689
|
+
description: What a catalogued feature is (M-ADR 0226 and M-ADR 0229, both Proposed).
|
|
690
|
+
journey_requirement_basis:
|
|
691
|
+
values:
|
|
692
|
+
- value: tag
|
|
693
|
+
description: A @req tag in the journey's end-to-end test file (option A).
|
|
694
|
+
- value: citation
|
|
695
|
+
description: A citation in the journey feature's living spec (option B).
|
|
696
|
+
description: Where a journey's citation of a requirement was read (M-ADR 0229 §4.4, Proposed).
|
|
671
697
|
catalogues:
|
|
672
698
|
domains:
|
|
673
699
|
primary_key: id
|
|
@@ -1454,7 +1480,7 @@ catalogues:
|
|
|
1454
1480
|
- name: status
|
|
1455
1481
|
type: string
|
|
1456
1482
|
nullable: false
|
|
1457
|
-
description:
|
|
1483
|
+
description: "Bound to feature_status: active, or retired when the feature spec says **Status:** retired (its behaviour retired by an archived change)."
|
|
1458
1484
|
- name: source
|
|
1459
1485
|
type: string
|
|
1460
1486
|
nullable: false
|
|
@@ -1463,6 +1489,10 @@ catalogues:
|
|
|
1463
1489
|
type: string
|
|
1464
1490
|
nullable: true
|
|
1465
1491
|
description: Optional adopter-owned lifecycle / migration marker. Free string at method level — the schema declares no enum and ships no controlled vocabulary. Adopters MAY declare a per-SDD `feature-runway-vocabulary.yaml` at their SDD root; when present, the canonical builder validates captured values against it and emits a `convention` finding for unknown values. Empty when the adopter does not track migration state. Comparability across SDDs requires ecosystem-level vocabulary alignment as an explicit pre-step. Added in schema 2.2.0.
|
|
1492
|
+
- name: kind
|
|
1493
|
+
type: string
|
|
1494
|
+
nullable: false
|
|
1495
|
+
description: "Bound to feature_kind: feature (the default), obligation (M-ADR 0226, Proposed) or journey (M-ADR 0229 §4.2, Proposed), from the feature spec's **Kind:** line."
|
|
1466
1496
|
- name: source_sdd_id
|
|
1467
1497
|
type: string
|
|
1468
1498
|
nullable: true
|
|
@@ -1485,6 +1515,8 @@ catalogues:
|
|
|
1485
1515
|
references: features.id
|
|
1486
1516
|
nullable: true
|
|
1487
1517
|
column_enums:
|
|
1518
|
+
status: enums.feature_status
|
|
1519
|
+
kind: enums.feature_kind
|
|
1488
1520
|
source: enums.feature_source
|
|
1489
1521
|
maturity: enums.artefact_maturity
|
|
1490
1522
|
personas:
|
|
@@ -4188,7 +4220,7 @@ catalogues:
|
|
|
4188
4220
|
- name: stage
|
|
4189
4221
|
type: string
|
|
4190
4222
|
nullable: false
|
|
4191
|
-
description: "Bound to requirement_stage: living (in the living spec)
|
|
4223
|
+
description: "Bound to requirement_stage: living (in the living spec), agreed (added by an open change whose agreement record is agreed) or retired (removed by an archived change)."
|
|
4192
4224
|
- name: spec_path
|
|
4193
4225
|
type: string
|
|
4194
4226
|
nullable: false
|
|
@@ -4225,7 +4257,7 @@ catalogues:
|
|
|
4225
4257
|
- name: change_id
|
|
4226
4258
|
type: string
|
|
4227
4259
|
nullable: false
|
|
4228
|
-
description: The archived change's directory
|
|
4260
|
+
description: "The archived change's id: <sdd_id>::<archive directory> (its archive date prefix included); a changes.id."
|
|
4229
4261
|
- name: change_kind
|
|
4230
4262
|
type: string
|
|
4231
4263
|
nullable: false
|
|
@@ -4233,7 +4265,7 @@ catalogues:
|
|
|
4233
4265
|
- name: operation
|
|
4234
4266
|
type: string
|
|
4235
4267
|
nullable: false
|
|
4236
|
-
description: "Bound to requirement_operation: how the change's delta treated the requirement."
|
|
4268
|
+
description: "Bound to requirement_operation: how the change's delta treated the requirement; REMOVED retires it."
|
|
4237
4269
|
- name: archived_on
|
|
4238
4270
|
type: string
|
|
4239
4271
|
nullable: false
|
|
@@ -4278,7 +4310,7 @@ catalogues:
|
|
|
4278
4310
|
- name: basis
|
|
4279
4311
|
type: string
|
|
4280
4312
|
nullable: false
|
|
4281
|
-
description: "The change that produced the status:
|
|
4313
|
+
description: "The change that produced the status: its changes.id, <sdd_id>::<directory>."
|
|
4282
4314
|
- name: source_sdd_id
|
|
4283
4315
|
type: string
|
|
4284
4316
|
nullable: true
|
|
@@ -4287,7 +4319,7 @@ catalogues:
|
|
|
4287
4319
|
type: string
|
|
4288
4320
|
nullable: true
|
|
4289
4321
|
description: The platform SDD the row belongs to (stamped at aggregation).
|
|
4290
|
-
description: "Edge: a requirement's status in a repository that implements it (M-ADR 0221 §4.5; M-ADR 0223 §4.7). Derived, never authored: agreed from an open agreed change's work orders, declared-shipped from an archived change's PASS verdict and its work orders, observed from a baseline's evidence. The latest event in archive order wins."
|
|
4322
|
+
description: "Edge: a requirement's status in a repository that implements it (M-ADR 0221 §4.5; M-ADR 0223 §4.7). Derived, never authored: agreed from an open agreed change's work orders, declared-shipped from an archived change's PASS verdict and its work orders, observed from a baseline's evidence. The latest event in archive order wins. Limitation: a requirement and repository hold one status. An agreed open change's status replaces the evidence about the revision that is live until the change archives, and evidence is not bound to the requirement revision it concerns."
|
|
4291
4323
|
foreign_keys:
|
|
4292
4324
|
- column: requirement_id
|
|
4293
4325
|
references: requirements.id
|
|
@@ -4303,7 +4335,7 @@ catalogues:
|
|
|
4303
4335
|
- name: id
|
|
4304
4336
|
type: string
|
|
4305
4337
|
nullable: false
|
|
4306
|
-
description: "
|
|
4338
|
+
description: "<sdd_id>::<directory>: the SDD the store belongs to and the change's directory name there, the open change's name or the archived change's directory (its archive date prefix included). A change name is its store's own, so the SDD is part of its identity: two SDDs' changes of one name are two changes. requirement_lineage.change_id references it."
|
|
4307
4339
|
- name: name
|
|
4308
4340
|
type: string
|
|
4309
4341
|
nullable: false
|
|
@@ -4354,7 +4386,7 @@ catalogues:
|
|
|
4354
4386
|
- name: status
|
|
4355
4387
|
type: string
|
|
4356
4388
|
nullable: false
|
|
4357
|
-
description: "Bound to work_order_status: the record's status for the work order; a judge PASS marks it completed (M-ADR 0223 §4.6)."
|
|
4389
|
+
description: "Bound to work_order_status: the record's status for the work order; the change archiving on a judge PASS marks it completed (M-ADR 0223 §4.6)."
|
|
4358
4390
|
- name: source_sdd_id
|
|
4359
4391
|
type: string
|
|
4360
4392
|
nullable: true
|
|
@@ -4379,11 +4411,11 @@ catalogues:
|
|
|
4379
4411
|
- name: id
|
|
4380
4412
|
type: string
|
|
4381
4413
|
nullable: false
|
|
4382
|
-
description: "<change_name>__<work_item_id>: keyed by the change's undated name, so the row is the same before and after archive."
|
|
4414
|
+
description: "<sdd_id>::<change_name>__<work_item_id>: keyed by the SDD and the change's undated name, so the row is the same before and after archive."
|
|
4383
4415
|
- name: change_id
|
|
4384
4416
|
type: string
|
|
4385
4417
|
nullable: false
|
|
4386
|
-
description: "The change as it stands now: its open name
|
|
4418
|
+
description: "The change as it stands now (a changes.id): <sdd_id>:: with its open name or its archive directory."
|
|
4387
4419
|
- name: change_name
|
|
4388
4420
|
type: string
|
|
4389
4421
|
nullable: false
|
|
@@ -4405,3 +4437,38 @@ catalogues:
|
|
|
4405
4437
|
- column: change_id
|
|
4406
4438
|
references: changes.id
|
|
4407
4439
|
nullable: false
|
|
4440
|
+
journey_requirement:
|
|
4441
|
+
primary_key: id
|
|
4442
|
+
columns:
|
|
4443
|
+
- name: id
|
|
4444
|
+
type: string
|
|
4445
|
+
nullable: false
|
|
4446
|
+
description: <journey_id>__<requirement_id>.
|
|
4447
|
+
- name: journey_id
|
|
4448
|
+
type: string
|
|
4449
|
+
nullable: false
|
|
4450
|
+
- name: requirement_id
|
|
4451
|
+
type: string
|
|
4452
|
+
nullable: false
|
|
4453
|
+
- name: basis
|
|
4454
|
+
type: string
|
|
4455
|
+
nullable: false
|
|
4456
|
+
description: "Bound to journey_requirement_basis: where the citation was read."
|
|
4457
|
+
- name: source_sdd_id
|
|
4458
|
+
type: string
|
|
4459
|
+
nullable: true
|
|
4460
|
+
description: Originating SDD (stamped at aggregation; empty in a per-SDD build).
|
|
4461
|
+
- name: platform_sdd_id
|
|
4462
|
+
type: string
|
|
4463
|
+
nullable: true
|
|
4464
|
+
description: The platform SDD the row belongs to (stamped at aggregation).
|
|
4465
|
+
description: "Edge: a requirement a user journey cites for one of its steps (M-ADR 0229 §4.4, Proposed). Derived, never authored: from the @req tags in the journey's end-to-end test file, or from the citations in its journey feature's living spec. A journey with no row is untraced."
|
|
4466
|
+
foreign_keys:
|
|
4467
|
+
- column: journey_id
|
|
4468
|
+
references: user_journeys.id
|
|
4469
|
+
nullable: false
|
|
4470
|
+
- column: requirement_id
|
|
4471
|
+
references: requirements.id
|
|
4472
|
+
nullable: false
|
|
4473
|
+
column_enums:
|
|
4474
|
+
basis: enums.journey_requirement_basis
|