@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.
Files changed (38) hide show
  1. package/dist/lib/behaviour-store/archive.js +13 -0
  2. package/dist/lib/catalogue/build.js +45 -2
  3. package/dist/lib/catalogue/builders/adr-governs.js +39 -0
  4. package/dist/lib/catalogue/builders/adrs.js +11 -0
  5. package/dist/lib/catalogue/builders/capability-spec.js +1 -0
  6. package/dist/lib/catalogue/builders/contract-revisions.js +126 -0
  7. package/dist/lib/catalogue/builders/data-model.js +316 -0
  8. package/dist/lib/catalogue/builders/entities-mermaid.js +18 -85
  9. package/dist/lib/catalogue/builders/feature-grounding.js +1 -0
  10. package/dist/lib/catalogue/builders/features.js +23 -2
  11. package/dist/lib/catalogue/builders/integration-components.js +49 -5
  12. package/dist/lib/catalogue/builders/journey-requirement.js +144 -0
  13. package/dist/lib/catalogue/builders/quality-scores.js +17 -0
  14. package/dist/lib/catalogue/builders/requirements.js +168 -32
  15. package/dist/lib/catalogue/canonical-schema.yaml +845 -28
  16. package/dist/lib/catalogue/manifest-loader.js +13 -0
  17. package/dist/lib/catalogue/retired-catalogues.js +25 -0
  18. package/dist/lib/catalogue/rubric-coverage-map.yaml +108 -2
  19. package/dist/lib/catalogue/schema-compat.js +1 -1
  20. package/dist/lib/catalogue/schema-loader.js +3 -0
  21. package/dist/lib/catalogue/staleness-verdicts.js +75 -6
  22. package/dist/lib/intents/migrate.js +35 -9
  23. package/dist/lib/metamodel/tql-subset-parser.js +35 -2
  24. package/dist/lib/structure/catalogue.js +183 -0
  25. package/dist/lib/structure/compose.js +174 -0
  26. package/dist/lib/structure/effects.js +36 -0
  27. package/dist/lib/structure/model.js +212 -0
  28. package/dist/lib/structure/render.js +242 -0
  29. package/dist/lib/structure/view.js +301 -0
  30. package/dist/lib/validate/agreement-evidence.js +216 -0
  31. package/dist/lib/validate/behaviour-store.js +516 -28
  32. package/dist/lib/validate/dispatch.js +21 -0
  33. package/dist/lib/validate/feature-spec-pointers.js +175 -0
  34. package/dist/lib/validate/journey-impact.js +193 -0
  35. package/dist/lib/validate/structural-model.js +265 -0
  36. package/dist/maintain-version.json +2 -2
  37. package/dist/verify-version.json +2 -2
  38. 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 lines = text.split(/\r?\n/);
78
- const out = [];
79
- const seenInFile = new Set();
80
- let inMermaid = false;
81
- let isEr = false;
82
- let firstKeywordSeen = false;
83
- let inEntity = false;
84
- let entityName = null;
85
- let entityColumns = 0;
86
- for (let i = 0; i < lines.length; i++) {
87
- const raw = lines[i] ?? "";
88
- if (!inMermaid) {
89
- if (RE_MERMAID_OPEN.test(raw)) {
90
- inMermaid = true;
91
- isEr = false;
92
- firstKeywordSeen = false;
93
- }
94
- continue;
95
- }
96
- // in_mermaid == true
97
- if (RE_FENCE_CLOSE.test(raw)) {
98
- inMermaid = false;
99
- isEr = false;
100
- firstKeywordSeen = false;
101
- // If we exit mid-entity, drop it silently — malformed block.
102
- inEntity = false;
103
- entityName = null;
104
- continue;
105
- }
106
- const stripped = raw.trim();
107
- if (!firstKeywordSeen && stripped !== "" && !RE_COMMENT.test(raw)) {
108
- firstKeywordSeen = true;
109
- isEr = stripped.toLowerCase().startsWith("erdiagram");
110
- }
111
- if (!isEr)
112
- continue;
113
- if (inEntity) {
114
- if (stripped.startsWith("}")) {
115
- if (entityName !== null) {
116
- const slug = entitySlug(entityName);
117
- if (!seenInFile.has(slug)) {
118
- seenInFile.add(slug);
119
- out.push({
120
- name: entityName,
121
- domainId: dm.domain_id,
122
- sourceDomainModelId: dm.id,
123
- columnCount: entityColumns,
124
- });
125
- }
126
- }
127
- inEntity = false;
128
- entityName = null;
129
- entityColumns = 0;
130
- continue;
131
- }
132
- if (RE_COMMENT.test(raw))
133
- continue;
134
- if (stripped)
135
- entityColumns++;
136
- continue;
137
- }
138
- // Not in an entity block — look for an opener.
139
- if (RE_COMMENT.test(raw))
140
- continue;
141
- if (RE_REL_LINE.test(raw))
142
- continue;
143
- const m = RE_ENTITY_OPEN.exec(raw);
144
- if (m) {
145
- const ident = m[1] ?? "";
146
- if (MERMAID_KEYWORDS.has(ident.toLowerCase()))
147
- continue;
148
- entityName = ident;
149
- inEntity = true;
150
- entityColumns = 0;
151
- }
76
+ const parsed = dm.doc_path.endsWith(".tql") ? parseTypeqlDataModel(text, dm.doc_path) : parseMermaidDataModel(text, dm.doc_path);
77
+ if ("refused" in parsed) {
78
+ findings.add("extraction", `entities: ${dm.doc_path} is outside the documented TypeQL subset, so none of its entities is catalogued: ${parsed.refused}`, "warn", dm.doc_path);
79
+ return [];
152
80
  }
153
81
  // Defensive: if a block ended without closing an entity, log it once.
154
- if (inEntity && entityName !== null) {
155
- findings.add("convention", `entities-mermaid: ${dm.doc_path}: unclosed entity block for "${entityName}" — discarded`);
82
+ if (parsed.unclosed !== null) {
83
+ findings.add("convention", `entities-mermaid: ${dm.doc_path}: unclosed entity block for "${parsed.unclosed}" — discarded`);
156
84
  }
157
- return out;
85
+ return parsed.entities.map((e) => ({
86
+ name: e.name,
87
+ domainId: dm.domain_id,
88
+ sourceDomainModelId: dm.id,
89
+ columnCount: e.columnCount,
90
+ }));
158
91
  }
159
92
  function resolveGlossaryTermBySlug(entityName, domainId, knownGlossaryIds) {
160
93
  const slug = entitySlug(entityName);
@@ -153,6 +153,7 @@ export async function buildFeatureGrounding(inputs) {
153
153
  status: "active",
154
154
  source: "feature-grounding",
155
155
  runway: "",
156
+ kind: "feature",
156
157
  source_sdd_id: "",
157
158
  });
158
159
  const cfId = `${capabilityId}__${featureId}`;
@@ -89,9 +89,10 @@ export async function buildFeatures(inputs) {
89
89
  parent_feature_id: "",
90
90
  description,
91
91
  maturity: "",
92
- status: "active",
92
+ status: featureStatus(text),
93
93
  source: "feature-spec",
94
94
  runway: "",
95
+ kind: featureKind(text),
95
96
  source_sdd_id: "",
96
97
  });
97
98
  idByPath.set(relPath, id);
@@ -123,9 +124,10 @@ export async function buildFeatures(inputs) {
123
124
  parent_feature_id: "",
124
125
  description,
125
126
  maturity: "",
126
- status: "active",
127
+ status: featureStatus(text),
127
128
  source: "feature-spec",
128
129
  runway: "",
130
+ kind: featureKind(text),
129
131
  source_sdd_id: "",
130
132
  });
131
133
  idByPath.set(relPath, id);
@@ -145,6 +147,24 @@ export async function buildFeatures(inputs) {
145
147
  }
146
148
  return { rows, idByPath, idBySlug };
147
149
  }
150
+ /**
151
+ * A feature spec's catalogue status: `retired` when the spec says `**Status:** retired` (its behaviour retired by
152
+ * an archived change, the feature kept in the catalogue so its retired requirements still resolve), else
153
+ * `active`. A retired feature stays where it is; it does not have to move to withdrawn/ first.
154
+ */
155
+ export function featureStatus(text) {
156
+ const m = /^\*\*Status:\*\*\s*([A-Za-z-]+)/m.exec(text) ?? /^>.*\bStatus:\s*([A-Za-z-]+)/m.exec(text);
157
+ return m?.[1]?.toLowerCase() === "retired" ? "retired" : "active";
158
+ }
159
+ /**
160
+ * A feature spec's kind (schema 4.4.0): the value of its `**Kind:**` line, lower-cased, else `feature`. An
161
+ * obligation (M-ADR 0226, Proposed) and a journey feature (M-ADR 0229 §4.2, Proposed) say so; any other value is written as given, so
162
+ * the catalogue's vocabulary gate names the spec rather than a default hiding the mistake.
163
+ */
164
+ export function featureKind(text) {
165
+ const m = /^\*\*Kind:\*\*\s*([A-Za-z-]+)/m.exec(text);
166
+ return m?.[1]?.toLowerCase() ?? "feature";
167
+ }
148
168
  async function emitBulletRows(ctx) {
149
169
  const featureTreePath = await findFeatureTreeFile(ctx.sddPath);
150
170
  if (!featureTreePath)
@@ -188,6 +208,7 @@ async function emitBulletRows(ctx) {
188
208
  status: "active",
189
209
  source: "feature-tree",
190
210
  runway: ev.runway,
211
+ kind: "feature",
191
212
  source_sdd_id: "",
192
213
  });
193
214
  if (!ctx.idBySlug.has(ev.slug))
@@ -176,9 +176,8 @@ export async function buildIntegrationComponents(inputs) {
176
176
  }
177
177
  // SECOND HOME (§9.1): a full `integration-manifest.yaml` per component repo under
178
178
  // `repos/<component>/`. Read after the array form so `seen` already holds the
179
- // array-form ids and a collision across the two homes is caught rather than
180
- // silently duplicated — measured disjoint today, but the two forms are authored
181
- // independently and nothing stops them meeting.
179
+ // array-form ids: a component in both homes that agrees on every tier field is
180
+ // one declaration stated twice, and one that disagrees is a divergence (below).
182
181
  rows.push(...(await readComponentManifests({
183
182
  sddPath: inputs.sddPath,
184
183
  findings: inputs.findings,
@@ -186,6 +185,7 @@ export async function buildIntegrationComponents(inputs) {
186
185
  knownRepoIds: inputs.knownRepoIds,
187
186
  knownServiceIds: inputs.knownServiceIds,
188
187
  seen,
188
+ arrayRows: new Map(rows.map((r) => [String(r["id"]), r])),
189
189
  })));
190
190
  checkAggregate({
191
191
  findings: inputs.findings,
@@ -196,6 +196,43 @@ export async function buildIntegrationComponents(inputs) {
196
196
  });
197
197
  return rows;
198
198
  }
199
+ /** The fields the component tier reads, compared when a component is declared in both homes. */
200
+ const TIER_FIELDS = [
201
+ "component_type",
202
+ "direction",
203
+ "frequency",
204
+ "transport",
205
+ "format",
206
+ "auth",
207
+ "ingestion_pattern",
208
+ "invocation_pattern",
209
+ "pattern_qualifier",
210
+ "primary_entities",
211
+ ];
212
+ function tierValue(m, f) {
213
+ switch (f) {
214
+ case "component_type":
215
+ return (m.componentType ?? "").trim();
216
+ case "direction":
217
+ return (m.dataFlow?.direction ?? "").trim();
218
+ case "frequency":
219
+ return (m.dataFlow?.frequency ?? "").trim();
220
+ case "transport":
221
+ return (m.protocol?.transport ?? "").trim();
222
+ case "format":
223
+ return (m.protocol?.format ?? "").trim();
224
+ case "auth":
225
+ return (m.protocol?.auth ?? "").trim();
226
+ case "ingestion_pattern":
227
+ return (m.ingestionPattern ?? "").trim();
228
+ case "invocation_pattern":
229
+ return (m.invocationPattern ?? "").trim();
230
+ case "pattern_qualifier":
231
+ return (m.patternQualifier ?? "").trim();
232
+ case "primary_entities":
233
+ return (m.dataFlow?.primaryEntities ?? []).join(";");
234
+ }
235
+ }
199
236
  /** `repos/` — where the per-component manifests live (M-ADR 0171 §9.1, second home). */
200
237
  const REPOS_DIR = "repos";
201
238
  /**
@@ -261,8 +298,15 @@ async function readComponentManifests(args) {
261
298
  const componentId = (parsed.data.integrationId ?? "").trim() || dir;
262
299
  const id = `${args.integrationId}__${componentId}`;
263
300
  if (args.seen.has(id)) {
264
- args.findings.add("divergence", `integration_component: component id="${id}" declared in BOTH §9.1 homes (components array and ${rel}) — ` +
265
- `one component cannot be declared twice; the per-component manifest is dropped. Remove whichever declaration is stale.`);
301
+ // The reverse template has the array mirror the per-component manifests, so a component in both homes is
302
+ // expected. A mirror that agrees on every field the tier reads is one declaration stated twice, and is not a
303
+ // finding; one that disagrees is, and the array's declaration is kept.
304
+ const mirrored = args.arrayRows.get(id);
305
+ const differs = mirrored === undefined ? ["(not an array row)"] : TIER_FIELDS.filter((f) => String(mirrored[f] ?? "") !== tierValue(parsed.data, f));
306
+ if (differs.length > 0) {
307
+ args.findings.add("divergence", `integration_component: component id="${id}" is declared in both §9.1 homes (components array and ${rel}) and they disagree on ${differs.join(", ")}; ` +
308
+ `the components array's declaration is kept. Correct whichever is stale.`);
309
+ }
266
310
  continue;
267
311
  }
268
312
  args.seen.add(id);
@@ -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,