@sdd-method/author-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/build.js +45 -2
- package/dist/lib/catalogue/builders/adr-governs.js +39 -0
- package/dist/lib/catalogue/builders/adrs.js +11 -0
- package/dist/lib/catalogue/builders/capability-spec.js +1 -0
- package/dist/lib/catalogue/builders/contract-revisions.js +126 -0
- package/dist/lib/catalogue/builders/data-model.js +316 -0
- package/dist/lib/catalogue/builders/entities-mermaid.js +18 -85
- package/dist/lib/catalogue/builders/feature-grounding.js +1 -0
- package/dist/lib/catalogue/builders/features.js +23 -2
- package/dist/lib/catalogue/builders/integration-components.js +49 -5
- package/dist/lib/catalogue/builders/journey-requirement.js +144 -0
- package/dist/lib/catalogue/builders/quality-scores.js +17 -0
- package/dist/lib/catalogue/builders/requirements.js +168 -32
- package/dist/lib/catalogue/canonical-schema.yaml +845 -28
- package/dist/lib/catalogue/manifest-loader.js +13 -0
- package/dist/lib/catalogue/retired-catalogues.js +25 -0
- package/dist/lib/catalogue/rubric-coverage-map.yaml +108 -2
- package/dist/lib/catalogue/schema-compat.js +1 -1
- package/dist/lib/catalogue/schema-loader.js +3 -0
- package/dist/lib/catalogue/staleness-verdicts.js +75 -6
- package/dist/lib/intents/migrate.js +35 -9
- package/dist/lib/metamodel/tql-subset-parser.js +35 -2
- package/dist/lib/structure/catalogue.js +183 -0
- package/dist/lib/structure/compose.js +174 -0
- package/dist/lib/structure/effects.js +36 -0
- package/dist/lib/structure/model.js +212 -0
- package/dist/lib/structure/render.js +242 -0
- package/dist/lib/structure/view.js +301 -0
- package/dist/lib/validate/agreement-evidence.js +216 -0
- package/dist/lib/validate/behaviour-store.js +516 -28
- package/dist/lib/validate/dispatch.js +21 -0
- package/dist/lib/validate/feature-spec-pointers.js +175 -0
- package/dist/lib/validate/journey-impact.js +193 -0
- package/dist/lib/validate/structural-model.js +265 -0
- package/dist/maintain-version.json +2 -2
- package/dist/verify-version.json +2 -2
- package/package.json +2 -2
|
@@ -14,20 +14,19 @@
|
|
|
14
14
|
// - Files may mix `erDiagram` with other Mermaid diagram types (flowchart,
|
|
15
15
|
// sequenceDiagram). Each fenced block is independently checked for the
|
|
16
16
|
// erDiagram opener; non-ER blocks are skipped.
|
|
17
|
+
// The file is read by the data-model parser (data-model.ts, schema 6.0.0), the
|
|
18
|
+
// one parser every reader of a domain model shares; an entity on one line
|
|
19
|
+
// (`NAME { type a; type b }`) is an entity too, and a `data-model.tql` in the
|
|
20
|
+
// documented TypeQL subset gives its entities the same way.
|
|
17
21
|
import { readFile } from "node:fs/promises";
|
|
22
|
+
import { parseMermaidDataModel, parseTypeqlDataModel } from "./data-model.js";
|
|
18
23
|
import { entitySlug } from "./entity-slug.js";
|
|
19
24
|
import { extractFileEvidenceClass } from "./evidence-class.js";
|
|
20
|
-
const RE_MERMAID_OPEN = /^```\s*mermaid\s*$/i;
|
|
21
|
-
const RE_FENCE_CLOSE = /^```\s*$/;
|
|
22
|
-
const RE_COMMENT = /^\s*%%/;
|
|
23
|
-
const RE_ENTITY_OPEN = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\{\s*$/;
|
|
24
|
-
const RE_REL_LINE = /^\s*[A-Za-z_][A-Za-z0-9_]*\s+\|?\|?[o|\-]+\{?\s+[A-Za-z]/;
|
|
25
|
-
const MERMAID_KEYWORDS = new Set(["erdiagram", "subgraph", "classdef", "style", "class"]);
|
|
26
25
|
export async function buildEntitiesFromMermaid(inputs) {
|
|
27
26
|
const rows = [];
|
|
28
27
|
const seenAcrossFiles = new Map(); // id → originating doc_path
|
|
29
28
|
for (const dm of inputs.domainModels) {
|
|
30
|
-
if (!dm.doc_path.endsWith(".mermaid.md"))
|
|
29
|
+
if (!dm.doc_path.endsWith(".mermaid.md") && !dm.doc_path.endsWith(".tql"))
|
|
31
30
|
continue;
|
|
32
31
|
const docPath = dm.doc_path.startsWith("/")
|
|
33
32
|
? dm.doc_path
|
|
@@ -74,87 +73,21 @@ export async function buildEntitiesFromMermaid(inputs) {
|
|
|
74
73
|
return rows;
|
|
75
74
|
}
|
|
76
75
|
function parseFile(text, dm, findings) {
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
let isEr = false;
|
|
82
|
-
let firstKeywordSeen = false;
|
|
83
|
-
let inEntity = false;
|
|
84
|
-
let entityName = null;
|
|
85
|
-
let entityColumns = 0;
|
|
86
|
-
for (let i = 0; i < lines.length; i++) {
|
|
87
|
-
const raw = lines[i] ?? "";
|
|
88
|
-
if (!inMermaid) {
|
|
89
|
-
if (RE_MERMAID_OPEN.test(raw)) {
|
|
90
|
-
inMermaid = true;
|
|
91
|
-
isEr = false;
|
|
92
|
-
firstKeywordSeen = false;
|
|
93
|
-
}
|
|
94
|
-
continue;
|
|
95
|
-
}
|
|
96
|
-
// in_mermaid == true
|
|
97
|
-
if (RE_FENCE_CLOSE.test(raw)) {
|
|
98
|
-
inMermaid = false;
|
|
99
|
-
isEr = false;
|
|
100
|
-
firstKeywordSeen = false;
|
|
101
|
-
// If we exit mid-entity, drop it silently — malformed block.
|
|
102
|
-
inEntity = false;
|
|
103
|
-
entityName = null;
|
|
104
|
-
continue;
|
|
105
|
-
}
|
|
106
|
-
const stripped = raw.trim();
|
|
107
|
-
if (!firstKeywordSeen && stripped !== "" && !RE_COMMENT.test(raw)) {
|
|
108
|
-
firstKeywordSeen = true;
|
|
109
|
-
isEr = stripped.toLowerCase().startsWith("erdiagram");
|
|
110
|
-
}
|
|
111
|
-
if (!isEr)
|
|
112
|
-
continue;
|
|
113
|
-
if (inEntity) {
|
|
114
|
-
if (stripped.startsWith("}")) {
|
|
115
|
-
if (entityName !== null) {
|
|
116
|
-
const slug = entitySlug(entityName);
|
|
117
|
-
if (!seenInFile.has(slug)) {
|
|
118
|
-
seenInFile.add(slug);
|
|
119
|
-
out.push({
|
|
120
|
-
name: entityName,
|
|
121
|
-
domainId: dm.domain_id,
|
|
122
|
-
sourceDomainModelId: dm.id,
|
|
123
|
-
columnCount: entityColumns,
|
|
124
|
-
});
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
inEntity = false;
|
|
128
|
-
entityName = null;
|
|
129
|
-
entityColumns = 0;
|
|
130
|
-
continue;
|
|
131
|
-
}
|
|
132
|
-
if (RE_COMMENT.test(raw))
|
|
133
|
-
continue;
|
|
134
|
-
if (stripped)
|
|
135
|
-
entityColumns++;
|
|
136
|
-
continue;
|
|
137
|
-
}
|
|
138
|
-
// Not in an entity block — look for an opener.
|
|
139
|
-
if (RE_COMMENT.test(raw))
|
|
140
|
-
continue;
|
|
141
|
-
if (RE_REL_LINE.test(raw))
|
|
142
|
-
continue;
|
|
143
|
-
const m = RE_ENTITY_OPEN.exec(raw);
|
|
144
|
-
if (m) {
|
|
145
|
-
const ident = m[1] ?? "";
|
|
146
|
-
if (MERMAID_KEYWORDS.has(ident.toLowerCase()))
|
|
147
|
-
continue;
|
|
148
|
-
entityName = ident;
|
|
149
|
-
inEntity = true;
|
|
150
|
-
entityColumns = 0;
|
|
151
|
-
}
|
|
76
|
+
const parsed = dm.doc_path.endsWith(".tql") ? parseTypeqlDataModel(text, dm.doc_path) : parseMermaidDataModel(text, dm.doc_path);
|
|
77
|
+
if ("refused" in parsed) {
|
|
78
|
+
findings.add("extraction", `entities: ${dm.doc_path} is outside the documented TypeQL subset, so none of its entities is catalogued: ${parsed.refused}`, "warn", dm.doc_path);
|
|
79
|
+
return [];
|
|
152
80
|
}
|
|
153
81
|
// Defensive: if a block ended without closing an entity, log it once.
|
|
154
|
-
if (
|
|
155
|
-
findings.add("convention", `entities-mermaid: ${dm.doc_path}: unclosed entity block for "${
|
|
82
|
+
if (parsed.unclosed !== null) {
|
|
83
|
+
findings.add("convention", `entities-mermaid: ${dm.doc_path}: unclosed entity block for "${parsed.unclosed}" — discarded`);
|
|
156
84
|
}
|
|
157
|
-
return
|
|
85
|
+
return parsed.entities.map((e) => ({
|
|
86
|
+
name: e.name,
|
|
87
|
+
domainId: dm.domain_id,
|
|
88
|
+
sourceDomainModelId: dm.id,
|
|
89
|
+
columnCount: e.columnCount,
|
|
90
|
+
}));
|
|
158
91
|
}
|
|
159
92
|
function resolveGlossaryTermBySlug(entityName, domainId, knownGlossaryIds) {
|
|
160
93
|
const slug = entitySlug(entityName);
|
|
@@ -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))
|
|
@@ -176,9 +176,8 @@ export async function buildIntegrationComponents(inputs) {
|
|
|
176
176
|
}
|
|
177
177
|
// SECOND HOME (§9.1): a full `integration-manifest.yaml` per component repo under
|
|
178
178
|
// `repos/<component>/`. Read after the array form so `seen` already holds the
|
|
179
|
-
// array-form ids
|
|
180
|
-
//
|
|
181
|
-
// independently and nothing stops them meeting.
|
|
179
|
+
// array-form ids: a component in both homes that agrees on every tier field is
|
|
180
|
+
// one declaration stated twice, and one that disagrees is a divergence (below).
|
|
182
181
|
rows.push(...(await readComponentManifests({
|
|
183
182
|
sddPath: inputs.sddPath,
|
|
184
183
|
findings: inputs.findings,
|
|
@@ -186,6 +185,7 @@ export async function buildIntegrationComponents(inputs) {
|
|
|
186
185
|
knownRepoIds: inputs.knownRepoIds,
|
|
187
186
|
knownServiceIds: inputs.knownServiceIds,
|
|
188
187
|
seen,
|
|
188
|
+
arrayRows: new Map(rows.map((r) => [String(r["id"]), r])),
|
|
189
189
|
})));
|
|
190
190
|
checkAggregate({
|
|
191
191
|
findings: inputs.findings,
|
|
@@ -196,6 +196,43 @@ export async function buildIntegrationComponents(inputs) {
|
|
|
196
196
|
});
|
|
197
197
|
return rows;
|
|
198
198
|
}
|
|
199
|
+
/** The fields the component tier reads, compared when a component is declared in both homes. */
|
|
200
|
+
const TIER_FIELDS = [
|
|
201
|
+
"component_type",
|
|
202
|
+
"direction",
|
|
203
|
+
"frequency",
|
|
204
|
+
"transport",
|
|
205
|
+
"format",
|
|
206
|
+
"auth",
|
|
207
|
+
"ingestion_pattern",
|
|
208
|
+
"invocation_pattern",
|
|
209
|
+
"pattern_qualifier",
|
|
210
|
+
"primary_entities",
|
|
211
|
+
];
|
|
212
|
+
function tierValue(m, f) {
|
|
213
|
+
switch (f) {
|
|
214
|
+
case "component_type":
|
|
215
|
+
return (m.componentType ?? "").trim();
|
|
216
|
+
case "direction":
|
|
217
|
+
return (m.dataFlow?.direction ?? "").trim();
|
|
218
|
+
case "frequency":
|
|
219
|
+
return (m.dataFlow?.frequency ?? "").trim();
|
|
220
|
+
case "transport":
|
|
221
|
+
return (m.protocol?.transport ?? "").trim();
|
|
222
|
+
case "format":
|
|
223
|
+
return (m.protocol?.format ?? "").trim();
|
|
224
|
+
case "auth":
|
|
225
|
+
return (m.protocol?.auth ?? "").trim();
|
|
226
|
+
case "ingestion_pattern":
|
|
227
|
+
return (m.ingestionPattern ?? "").trim();
|
|
228
|
+
case "invocation_pattern":
|
|
229
|
+
return (m.invocationPattern ?? "").trim();
|
|
230
|
+
case "pattern_qualifier":
|
|
231
|
+
return (m.patternQualifier ?? "").trim();
|
|
232
|
+
case "primary_entities":
|
|
233
|
+
return (m.dataFlow?.primaryEntities ?? []).join(";");
|
|
234
|
+
}
|
|
235
|
+
}
|
|
199
236
|
/** `repos/` — where the per-component manifests live (M-ADR 0171 §9.1, second home). */
|
|
200
237
|
const REPOS_DIR = "repos";
|
|
201
238
|
/**
|
|
@@ -261,8 +298,15 @@ async function readComponentManifests(args) {
|
|
|
261
298
|
const componentId = (parsed.data.integrationId ?? "").trim() || dir;
|
|
262
299
|
const id = `${args.integrationId}__${componentId}`;
|
|
263
300
|
if (args.seen.has(id)) {
|
|
264
|
-
|
|
265
|
-
|
|
301
|
+
// The reverse template has the array mirror the per-component manifests, so a component in both homes is
|
|
302
|
+
// expected. A mirror that agrees on every field the tier reads is one declaration stated twice, and is not a
|
|
303
|
+
// finding; one that disagrees is, and the array's declaration is kept.
|
|
304
|
+
const mirrored = args.arrayRows.get(id);
|
|
305
|
+
const differs = mirrored === undefined ? ["(not an array row)"] : TIER_FIELDS.filter((f) => String(mirrored[f] ?? "") !== tierValue(parsed.data, f));
|
|
306
|
+
if (differs.length > 0) {
|
|
307
|
+
args.findings.add("divergence", `integration_component: component id="${id}" is declared in both §9.1 homes (components array and ${rel}) and they disagree on ${differs.join(", ")}; ` +
|
|
308
|
+
`the components array's declaration is kept. Correct whichever is stale.`);
|
|
309
|
+
}
|
|
266
310
|
continue;
|
|
267
311
|
}
|
|
268
312
|
args.seen.add(id);
|
|
@@ -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
|
|
@@ -118,6 +118,19 @@ const num = (v) => {
|
|
|
118
118
|
}
|
|
119
119
|
return null;
|
|
120
120
|
};
|
|
121
|
+
/**
|
|
122
|
+
* The review's other assessed inputs (M-ADR 0230 §4.9, Proposed): `inputs:` as a list of paths, or of `{path}`
|
|
123
|
+
* entries, each read at the review's corpus_ref. ';'-joined; null when the review names none.
|
|
124
|
+
*/
|
|
125
|
+
function inputPaths(v) {
|
|
126
|
+
if (!Array.isArray(v))
|
|
127
|
+
return null;
|
|
128
|
+
const paths = v
|
|
129
|
+
.map((x) => (typeof x === "string" ? x : isRecord(x) && typeof x["path"] === "string" ? x["path"] : ""))
|
|
130
|
+
.map((x) => x.trim())
|
|
131
|
+
.filter((x) => x !== "");
|
|
132
|
+
return paths.length > 0 ? paths.join(";") : null;
|
|
133
|
+
}
|
|
121
134
|
/**
|
|
122
135
|
* Count raised flags across the two authored dialects:
|
|
123
136
|
* `flags: { over_scope_risk: true, ... }` -> count the true ones
|
|
@@ -258,6 +271,7 @@ function bandedRows(block, ctx) {
|
|
|
258
271
|
band_total: num(block["total"]),
|
|
259
272
|
scored_at: ctx.scoredAt,
|
|
260
273
|
corpus_ref: ctx.corpusRef,
|
|
274
|
+
input_paths: ctx.inputPaths,
|
|
261
275
|
staleness_basis: stalenessBasis,
|
|
262
276
|
review_run_id: null,
|
|
263
277
|
selection_method: null,
|
|
@@ -371,6 +385,7 @@ export async function buildQualityScores(inputs) {
|
|
|
371
385
|
targetPath,
|
|
372
386
|
scoredAt: fileScoredAt,
|
|
373
387
|
corpusRef,
|
|
388
|
+
inputPaths: inputPaths(unitVal["inputs"]),
|
|
374
389
|
assessedBy: fileAssessedBy,
|
|
375
390
|
sddId,
|
|
376
391
|
});
|
|
@@ -396,6 +411,7 @@ export async function buildQualityScores(inputs) {
|
|
|
396
411
|
targetPath: str(unitVal["doc_path"]),
|
|
397
412
|
scoredAt: str(current["scored_at"]) ?? fileScoredAt,
|
|
398
413
|
corpusRef,
|
|
414
|
+
inputPaths: inputPaths(current["inputs"] ?? unitVal["inputs"]),
|
|
399
415
|
assessedBy: fileAssessedBy,
|
|
400
416
|
sddId,
|
|
401
417
|
});
|
|
@@ -540,6 +556,7 @@ export async function buildQualityScores(inputs) {
|
|
|
540
556
|
band_total: null,
|
|
541
557
|
scored_at: str(current["scored_at"]) ?? str(entryVal["scored_at"]),
|
|
542
558
|
corpus_ref: corpusRef,
|
|
559
|
+
input_paths: inputPaths(current["inputs"] ?? entryVal["inputs"]),
|
|
543
560
|
staleness_basis: stalenessBasis,
|
|
544
561
|
review_run_id: str(current["review_run_id"]),
|
|
545
562
|
selection_method: selectionMethod,
|