@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.
@@ -1,6 +1,9 @@
1
1
  import { checkArchetypeCitations } from "./archetype-citations.js";
2
2
  import { checkBehaviourStore, checkBehaviourStoreOpenspec } from "./behaviour-store.js";
3
3
  import { checkBundleCoherence } from "./bundle-coherence.js";
4
+ import { checkFeatureSpecPointers } from "./feature-spec-pointers.js";
5
+ import { checkIntents } from "./intents.js";
6
+ import { checkJourneyImpact } from "./journey-impact.js";
4
7
  import { checkYamlParse } from "./yaml-parse.js";
5
8
  // The behaviour store (M-ADR 0221-0223) can sit in an SDD of any profile, so every profile carries
6
9
  // its two checks; without a store both report [N/A], an empty population, not a pass.
@@ -21,6 +24,24 @@ const BEHAVIOUR_STORE_VALIDATORS = [
21
24
  source: { kind: "native", run: async (ctx) => checkArchetypeCitations(ctx.targetRepo) },
22
25
  build: () => ({ status: "run", args: [] }),
23
26
  },
27
+ // Intents (M-ADR 0227) can sit in an SDD of any profile; without any the check reports [N/A].
28
+ {
29
+ name: "intents",
30
+ source: { kind: "native", run: async (ctx) => checkIntents(ctx.targetRepo) },
31
+ build: () => ({ status: "run", args: [] }),
32
+ },
33
+ // Once behaviour is in the living specs, the feature spec points at it (M-ADR 0221 §4.3). Advisory; [N/A] without a store.
34
+ {
35
+ name: "feature-spec-pointers",
36
+ source: { kind: "native", run: async (ctx) => checkFeatureSpecPointers(ctx.targetRepo) },
37
+ build: () => ({ status: "run", args: [] }),
38
+ },
39
+ // The journeys a change can break, and whether its proposal names them (M-ADR 0229 §4.5, Proposed). Report only; [N/A] without journeys.
40
+ {
41
+ name: "journey-impact",
42
+ source: { kind: "native", run: async (ctx) => checkJourneyImpact(ctx.targetRepo) },
43
+ build: () => ({ status: "run", args: [] }),
44
+ },
24
45
  ];
25
46
  // Validators that accept --app-root / --repo-root flags work fine from the
26
47
  // bundled location because they override their internal ROOT_DIR via the
@@ -0,0 +1,175 @@
1
+ // feature-spec-pointers: once behaviour is in the living specs, the feature spec points at it and no longer states
2
+ // it (M-ADR 0221 §4.3). Advisory: every finding is a [WARN] and the check exits 0.
3
+ //
4
+ // The population is the store's, never the feature specs': each living spec (openspec/specs/<feature-id>/) should
5
+ // be pointed at by a feature spec, one naming `openspec/specs/<feature-id>/`. A living spec no feature spec points
6
+ // at is reported, whatever its feature spec says, because the feature spec that should point is the one not yet
7
+ // converted.
8
+ //
9
+ // Each feature spec that points at the store is then read whole, not one heading of it, since behaviour is
10
+ // restated in stories, API, Events and Security as well as under Behavior. Reported, as shipped behaviour still
11
+ // stated in the feature spec:
12
+ // criteria list lines of an **Acceptance Criteria** block (a story's criteria are behaviour; its narrative is
13
+ // intent and stays);
14
+ // gherkin Given/When/Then lines anywhere else;
15
+ // sections content lines under a heading that states behaviour: Happy Path, Edge Cases, Error Handling,
16
+ // Business Rules, Validation Rules, Acceptance Criteria.
17
+ // Intent not yet shipped is allowed where it is marked. A heading reading "Not yet shipped or agreed" covers its
18
+ // section, to the next heading of the same or a higher level. A bold line reading so covers what follows it, to the
19
+ // next heading of any level. Open Questions' checkboxes are not
20
+ // criteria. A check cannot tell shipped from unshipped text by reading it, so the marking is what it reads.
21
+ //
22
+ // Feature specs live under docs/domains/<domain>/product/features/ and, in an application SDD, features/. With no
23
+ // living specs the check reports [N/A], an empty population, not a pass.
24
+ import { readdir, readFile } from "node:fs/promises";
25
+ import { join, relative } from "node:path";
26
+ const SPECS_DIR = join("openspec", "specs");
27
+ const BEHAVIOUR_HEADING = /^(happy path|edge cases?|error handling|business rules|validation rules|acceptance criteria)$/;
28
+ const NOT_YET = /not yet (shipped|agreed)/i;
29
+ const CRITERIA_START = /^\*\*Acceptance Criteria:?\*\*/i;
30
+ const GHERKIN = /^\s*(?:[-*]\s+(?:\[[ xX]\]\s+)?)?(?:\*\*)?(Given|When|Then)\b/;
31
+ const LIST_LINE = /^\s*(?:[-*]|\d+\.)\s+\S/;
32
+ /** "## 3. Edge Cases (Revised)" -> "edge cases". */
33
+ function headingKey(h) {
34
+ return h.replace(/^\s*\d+(\.\d+)*\.?\s*/, "").replace(/\((revised|updated)\)\s*$/i, "").trim().toLowerCase();
35
+ }
36
+ /** Line numbers (1-based) of shipped behaviour still stated in a feature spec, by kind. */
37
+ export function scanRestated(text) {
38
+ const out = { criteria: [], gherkin: [], sections: [] };
39
+ const stack = [];
40
+ let inCriteria = false;
41
+ let notYetBlock = false; // after a bold "Not yet shipped or agreed" line, to the next heading
42
+ let fence = false;
43
+ const lines = text.split("\n");
44
+ for (let i = 0; i < lines.length; i++) {
45
+ const line = lines[i];
46
+ const n = i + 1;
47
+ if (/^\s*```/.test(line)) {
48
+ fence = !fence;
49
+ continue;
50
+ }
51
+ const h = fence ? null : /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line);
52
+ if (h) {
53
+ const level = h[1].length;
54
+ while (stack.length && stack[stack.length - 1].level >= level)
55
+ stack.pop();
56
+ stack.push({ level, key: headingKey(h[2]), notYet: NOT_YET.test(h[2]) });
57
+ inCriteria = false;
58
+ notYetBlock = false;
59
+ continue;
60
+ }
61
+ if (!fence && /^\*\*[^*]+\*\*:?\s*$/.test(line.trim()) && NOT_YET.test(line)) {
62
+ notYetBlock = true;
63
+ inCriteria = false;
64
+ continue;
65
+ }
66
+ if (notYetBlock || stack.some((s) => s.notYet))
67
+ continue;
68
+ if (!fence && CRITERIA_START.test(line.trim())) {
69
+ inCriteria = true;
70
+ continue;
71
+ }
72
+ if (!line.trim() || /^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(line))
73
+ continue; // blank, or a table separator
74
+ if (stack.some((s) => BEHAVIOUR_HEADING.test(s.key))) {
75
+ out.sections.push(n);
76
+ continue;
77
+ }
78
+ if (inCriteria && LIST_LINE.test(line)) {
79
+ out.criteria.push(n);
80
+ continue;
81
+ }
82
+ if (!fence && GHERKIN.test(line) && !stack.some((s) => s.key === "open questions"))
83
+ out.gherkin.push(n);
84
+ }
85
+ return out;
86
+ }
87
+ async function subdirs(p) {
88
+ try {
89
+ return (await readdir(p, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name).sort();
90
+ }
91
+ catch {
92
+ return [];
93
+ }
94
+ }
95
+ async function markdownUnder(dir) {
96
+ const out = [];
97
+ let entries;
98
+ try {
99
+ entries = await readdir(dir, { withFileTypes: true });
100
+ }
101
+ catch {
102
+ return out;
103
+ }
104
+ for (const e of entries) {
105
+ const p = join(dir, e.name);
106
+ if (e.isDirectory())
107
+ out.push(...(await markdownUnder(p)));
108
+ else if (e.isFile() && e.name.endsWith(".md") && e.name.toLowerCase() !== "readme.md")
109
+ out.push(p);
110
+ }
111
+ return out;
112
+ }
113
+ async function featureSpecs(repo) {
114
+ const files = [];
115
+ for (const domain of await subdirs(join(repo, "docs", "domains"))) {
116
+ files.push(...(await markdownUnder(join(repo, "docs", "domains", domain, "product", "features"))));
117
+ }
118
+ files.push(...(await markdownUnder(join(repo, "features"))));
119
+ return files.sort();
120
+ }
121
+ function firstLines(ns) {
122
+ return ns.slice(0, 3).join(", ") + (ns.length > 3 ? ", ..." : "");
123
+ }
124
+ export async function checkFeatureSpecPointers(repo) {
125
+ const living = [];
126
+ for (const id of await subdirs(join(repo, SPECS_DIR))) {
127
+ try {
128
+ await readFile(join(repo, SPECS_DIR, id, "spec.md"));
129
+ living.push(id);
130
+ }
131
+ catch {
132
+ // a directory without a spec is not a living spec
133
+ }
134
+ }
135
+ if (living.length === 0)
136
+ return { exitCode: 0, stdout: "[N/A] no living specs (openspec/specs/<feature>/spec.md) in this repository\n" };
137
+ const pointedBy = new Map(living.map((id) => [id, []]));
138
+ const warnings = [];
139
+ let pointing = 0;
140
+ let restating = 0;
141
+ for (const file of await featureSpecs(repo)) {
142
+ const text = await readFile(file, "utf8");
143
+ const rel = relative(repo, file);
144
+ const targets = living.filter((id) => text.includes(`openspec/specs/${id}/`));
145
+ if (targets.length === 0)
146
+ continue;
147
+ pointing++;
148
+ for (const id of targets)
149
+ pointedBy.get(id).push(rel);
150
+ const r = scanRestated(text);
151
+ const parts = [];
152
+ if (r.criteria.length)
153
+ parts.push(`${r.criteria.length} acceptance-criteria line(s) (from line ${firstLines(r.criteria)})`);
154
+ if (r.gherkin.length)
155
+ parts.push(`${r.gherkin.length} Given/When/Then line(s) (from line ${firstLines(r.gherkin)})`);
156
+ if (r.sections.length)
157
+ parts.push(`${r.sections.length} line(s) under a behaviour heading (from line ${firstLines(r.sections)})`);
158
+ if (parts.length) {
159
+ restating++;
160
+ warnings.push(`${rel}: points at the store but still states behaviour outside "Not yet shipped or agreed": ${parts.join("; ")}. Point shipped behaviour at its requirements, and mark intent not yet shipped (M-ADR 0221 §4.3)`);
161
+ }
162
+ }
163
+ const unpointed = living.filter((id) => pointedBy.get(id).length === 0);
164
+ for (const id of unpointed) {
165
+ warnings.unshift(`${SPECS_DIR}/${id}: no feature spec points at this living spec; its feature spec should name openspec/specs/${id}/ and stop stating what shipped (M-ADR 0221 §4.3)`);
166
+ }
167
+ const head = `feature-spec-pointers: ${living.length} living spec(s), ${living.length - unpointed.length} pointed at by a feature spec; ${pointing} feature spec(s) point at the store, ${restating} still state shipped behaviour\n`;
168
+ if (warnings.length === 0)
169
+ return { exitCode: 0, stdout: `${head}[PASS] feature-spec-pointers: every living spec is pointed at, and no pointing feature spec restates it\n` };
170
+ return {
171
+ exitCode: 0,
172
+ stdout: `${head}${warnings.map((w) => `[WARN] ${w}\n`).join("")}[WARN] feature-spec-pointers: ${warnings.length} finding(s), advisory: reported, not failed\n`,
173
+ };
174
+ }
175
+ //# sourceMappingURL=feature-spec-pointers.js.map
@@ -0,0 +1,76 @@
1
+ // intents: every intent file (M-ADR 0227 §4.1) has the template's shape, carries no planning state, names its
2
+ // work item, and every path its "Where it went" names in this SDD exists.
3
+ //
4
+ // Read from content/method/intents/ and content/product/intents/. A path line in "Where it went" is
5
+ // "- `<path>` (<sdd-id>; <source>)"; only paths labelled with this SDD's id are checked here, since another
6
+ // SDD's paths resolve in that SDD. Commit and plan lines are records, not paths, and are not checked. An
7
+ // intent with nothing yet in "Where it went" is captured but not explored: counted, not failed. With no
8
+ // intents the check reports [N/A], an empty population, not a pass.
9
+ import { existsSync } from "node:fs";
10
+ import { readdir, readFile } from "node:fs/promises";
11
+ import { basename, join } from "node:path";
12
+ import { parseDocument } from "yaml";
13
+ import { INTENT_DIRS, INTENT_SECTIONS, PLANNING_FIELDS, WHERE_PATH_LINE, parseMarkdown } from "../intents/format.js";
14
+ async function sddId(repo) {
15
+ try {
16
+ const m = parseDocument(await readFile(join(repo, ".sdd/sdd-manifest.yaml"), "utf8")).toJS();
17
+ if (typeof m?.sdd_id === "string" && m.sdd_id)
18
+ return m.sdd_id;
19
+ }
20
+ catch {
21
+ // fall through
22
+ }
23
+ return basename(repo);
24
+ }
25
+ export async function checkIntents(repo) {
26
+ const files = [];
27
+ for (const d of INTENT_DIRS) {
28
+ try {
29
+ for (const e of await readdir(join(repo, d), { withFileTypes: true })) {
30
+ if (e.isFile() && e.name.endsWith(".md") && e.name.toLowerCase() !== "readme.md")
31
+ files.push(`${d}/${e.name}`);
32
+ }
33
+ }
34
+ catch {
35
+ // no such directory
36
+ }
37
+ }
38
+ if (files.length === 0)
39
+ return { exitCode: 0, stdout: "[N/A] no intents (content/{method,product}/intents/*.md) in this repository\n" };
40
+ const self = await sddId(repo);
41
+ const problems = [];
42
+ let unexplored = 0;
43
+ let pathsChecked = 0;
44
+ for (const rel of files.sort()) {
45
+ const text = await readFile(join(repo, rel), "utf8");
46
+ const doc = parseMarkdown(text);
47
+ if (!/^Intent:\s*\S/.test(doc.title))
48
+ problems.push(`${rel}: no "# Intent: <title>" line`);
49
+ for (const s of INTENT_SECTIONS)
50
+ if (!doc.sections.has(s.toLowerCase()))
51
+ problems.push(`${rel}: no "## ${s}" section`);
52
+ const wi = doc.fields.get("Work item")?.trim() ?? "";
53
+ if (!wi || /^<.*>$/.test(wi))
54
+ problems.push(`${rel}: no work item named (**Work item:**)`);
55
+ for (const f of PLANNING_FIELDS)
56
+ if (doc.fields.has(f))
57
+ problems.push(`${rel}: planning field **${f}:** belongs in the work item (M-ADR 0227 §4.3)`);
58
+ const where = (doc.sections.get("where it went") ?? "").split("\n").map((l) => l.trim()).filter((l) => l.startsWith("- "));
59
+ if (where.length === 0)
60
+ unexplored++;
61
+ for (const line of where) {
62
+ const m = WHERE_PATH_LINE.exec(line);
63
+ if (!m || m[2].trim() !== self)
64
+ continue;
65
+ pathsChecked++;
66
+ const p = m[1].replace(/[#?].*$/, "");
67
+ if (!existsSync(join(repo, p)))
68
+ problems.push(`${rel}: "Where it went" names ${p}, which does not exist`);
69
+ }
70
+ }
71
+ const head = `intents: ${files.length} intent(s); ${pathsChecked} "Where it went" path(s) in this SDD checked; ${unexplored} not yet explored\n`;
72
+ if (problems.length === 0)
73
+ return { exitCode: 0, stdout: `${head}[PASS] intents: ok\n` };
74
+ return { exitCode: 1, stdout: `${head}${problems.map((p) => `[FAIL] ${p}\n`).join("")}[FAIL] intents: ${problems.length} problem(s)\n` };
75
+ }
76
+ //# sourceMappingURL=intents.js.map
@@ -0,0 +1,193 @@
1
+ // journey-impact: the journeys a change can break, and whether its proposal names them (M-ADR 0229 §4.5, Proposed).
2
+ // Report only (M-ADR 0229 §4.5, Proposed): every finding is a [WARN] and the check exits 0. Gate 0 carries this report; nothing
3
+ // blocks on it, and the judge does not fail a change for it.
4
+ //
5
+ // For each open change in the behaviour store (openspec/changes/<change>/, not the archive):
6
+ // listed every journey whose journey_requirement rows name a requirement the change's deltas MODIFY, REMOVE
7
+ // or RENAME (by its former name), and every untraced journey (no journey_requirement row) whose
8
+ // journey_feature rows touch a feature the change has a delta for. The population is the catalogue's,
9
+ // never the proposal's.
10
+ // unnamed a listed journey the proposal's `## Affected Journeys` section does not name.
11
+ // unshown a journey named `altered` with nothing to show it: no delta in the change to a feature of kind journey
12
+ // whose id is the journey's id or ends with it (option B), and no task naming it (option A).
13
+ // A proposal line is `- <journey-id>: altered ...` or `- <journey-id>: unaffected, <reason>`; an unaffected line
14
+ // with no reason is reported too.
15
+ //
16
+ // Also reported: a journey citing a requirement of a feature its journey_feature rows do not name (one record or
17
+ // the other is wrong, M-ADR 0229 §4.4, Proposed); and an SDD with catalogued journeys whose manifest declares no journey master and that names no
18
+ // platform it inherits from (an unresolved root, M-ADR 0229 §4.2, Proposed). A member that names its platform inherits the
19
+ // root's declaration, which is checked at the root.
20
+ //
21
+ // Reads the aggregated catalogue (catalogues-aggregated/*.csv) where this SDD has one, since a root's changes can
22
+ // break journeys its members catalogue and only the aggregate joins the two; otherwise the SDD's own catalogues/.
23
+ // Without journey_requirement.csv the catalogue predates schema 4.4.0 and the check is unresolved, not passed. Without a store or open changes it reports [N/A] for the change
24
+ // part, an empty population.
25
+ import { readdir, readFile } from "node:fs/promises";
26
+ import { join } from "node:path";
27
+ import { parseDocument } from "yaml";
28
+ import { parseRequirements, renamedFrom } from "../behaviour-store/archive.js";
29
+ import { parseCatalogueCsv } from "../catalogue/csv-reader.js";
30
+ const CHANGES_DIR = join("openspec", "changes");
31
+ async function readText(p) {
32
+ try {
33
+ return await readFile(p, "utf8");
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ }
39
+ async function subdirs(p) {
40
+ try {
41
+ return (await readdir(p, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name).sort();
42
+ }
43
+ catch {
44
+ return [];
45
+ }
46
+ }
47
+ async function catalogueDir(repo) {
48
+ return (await readText(join(repo, "catalogues-aggregated", "user_journeys.csv"))) !== null ? "catalogues-aggregated" : "catalogues";
49
+ }
50
+ async function csv(repo, name, dir = "catalogues") {
51
+ const text = await readText(join(repo, dir, `${name}.csv`));
52
+ return text === null ? null : parseCatalogueCsv(text).rows;
53
+ }
54
+ /** The `## Affected Journeys` section: journey id -> what the proposal says of it. */
55
+ export function parseAffectedJourneys(proposal) {
56
+ const out = new Map();
57
+ let inSection = false;
58
+ for (const line of proposal.split("\n")) {
59
+ const h = /^##\s+(.+?)\s*$/.exec(line);
60
+ if (h) {
61
+ inSection = /^affected journeys$/i.test(h[1].trim());
62
+ continue;
63
+ }
64
+ if (!inSection)
65
+ continue;
66
+ const m = /^\s*[-*]\s+`?([a-z0-9][a-z0-9-]*)`?\s*:\s*(altered|unaffected)?\b[\s,;:.—-]*(.*)$/i.exec(line);
67
+ if (!m)
68
+ continue;
69
+ const verdict = m[2]?.toLowerCase() ?? "other";
70
+ out.set(m[1], { verdict, rest: (m[3] ?? "").trim() });
71
+ }
72
+ return out;
73
+ }
74
+ export async function checkJourneyImpact(repo) {
75
+ const lines = [];
76
+ const warnings = [];
77
+ const dir = await catalogueDir(repo);
78
+ const journeys = await csv(repo, "user_journeys", dir);
79
+ if (journeys === null || journeys.length === 0) {
80
+ return { exitCode: 0, stdout: "[N/A] journey-impact: no catalogued user journeys (catalogues/ or catalogues-aggregated/ user_journeys.csv) in this repository\n" };
81
+ }
82
+ // The journey master (§4.2).
83
+ const manifest = (await readText(join(repo, ".sdd", "sdd-manifest.yaml"))) ?? "";
84
+ let declared = false;
85
+ let platform = "";
86
+ try {
87
+ const m = parseDocument(manifest).toJS();
88
+ declared = typeof m?.journeys?.master === "string";
89
+ platform = String(m?.platform_sdd_id ?? m?.ecosystem_root ?? "");
90
+ }
91
+ catch {
92
+ // an unparseable manifest is the manifest check's to report
93
+ }
94
+ if (declared)
95
+ lines.push("journey master: declared in this SDD's manifest");
96
+ else if (platform)
97
+ lines.push(`journey master: not declared here; inherited from platform ${platform}, where it is checked`);
98
+ else
99
+ warnings.push(`.sdd/sdd-manifest.yaml: ${journeys.length} catalogued journey(s) and no journey master (journeys.master: test-file or store), and no platform to inherit one from: unresolved (M-ADR 0229 §4.2, Proposed)`);
100
+ const jr = await csv(repo, "journey_requirement", dir);
101
+ const jf = (await csv(repo, "journey_feature", dir)) ?? [];
102
+ const featureKinds = new Map(((await csv(repo, "features", dir)) ?? []).map((f) => [f["id"], f["kind"] ?? ""]));
103
+ lines.push(`catalogue: ${dir}/`);
104
+ if (jr === null) {
105
+ warnings.push(`${dir}/journey_requirement.csv: missing, so the catalogue predates schema 4.4.0; rebuild it. The journeys a change can break are unresolved`);
106
+ }
107
+ const byRequirement = new Map();
108
+ const traced = new Set();
109
+ for (const r of jr ?? []) {
110
+ traced.add(r["journey_id"]);
111
+ const s = byRequirement.get(r["requirement_id"]) ?? new Set();
112
+ s.add(r["journey_id"]);
113
+ byRequirement.set(r["requirement_id"], s);
114
+ }
115
+ const untracedByFeature = new Map();
116
+ for (const r of jf) {
117
+ if (traced.has(r["journey_id"]))
118
+ continue;
119
+ const s = untracedByFeature.get(r["feature_id"]) ?? new Set();
120
+ s.add(r["journey_id"]);
121
+ untracedByFeature.set(r["feature_id"], s);
122
+ }
123
+ const touched = new Map();
124
+ for (const r of jf) {
125
+ const s = touched.get(r["journey_id"]) ?? new Set();
126
+ s.add(r["feature_id"]);
127
+ touched.set(r["journey_id"], s);
128
+ }
129
+ const mismatched = new Set();
130
+ for (const r of jr ?? []) {
131
+ const feature = r["requirement_id"].split("::")[0];
132
+ const key = `${r["journey_id"]}|${feature}`;
133
+ if (touched.get(r["journey_id"])?.has(feature) || mismatched.has(key))
134
+ continue;
135
+ mismatched.add(key);
136
+ warnings.push(`journey ${r["journey_id"]} cites requirements of ${feature}, which its journey_feature rows do not name: the journey's related features or its tags are wrong (M-ADR 0229 §4.4, Proposed)`);
137
+ }
138
+ const untracedCount = journeys.filter((j) => !traced.has(j["id"])).length;
139
+ lines.push(`journeys: ${journeys.length} catalogued, ${journeys.length - untracedCount} traced to requirements, ${untracedCount} untraced`);
140
+ const changes = (await subdirs(join(repo, CHANGES_DIR))).filter((c) => c !== "archive");
141
+ if (changes.length === 0)
142
+ lines.push("[N/A] changes: no open changes in openspec/changes/");
143
+ for (const change of changes) {
144
+ const dir = join(repo, CHANGES_DIR, change);
145
+ const listed = new Map(); // journey -> why
146
+ for (const feature of await subdirs(join(dir, "specs"))) {
147
+ const text = (await readText(join(dir, "specs", feature, "spec.md"))) ?? "";
148
+ const targets = new Set();
149
+ for (const r of parseRequirements(text))
150
+ if (r.operation === "MODIFIED" || r.operation === "REMOVED")
151
+ targets.add(r.name);
152
+ for (const from of renamedFrom(text))
153
+ targets.add(from);
154
+ for (const name of targets) {
155
+ for (const j of byRequirement.get(`${feature}::${name}`) ?? [])
156
+ if (!listed.has(j))
157
+ listed.set(j, `cites ${feature}::${name}`);
158
+ }
159
+ for (const j of untracedByFeature.get(feature) ?? [])
160
+ if (!listed.has(j))
161
+ listed.set(j, `untraced, touches ${feature}`);
162
+ }
163
+ if (listed.size === 0) {
164
+ lines.push(`${change}: no journey cites what it changes`);
165
+ continue;
166
+ }
167
+ const proposal = (await readText(join(dir, "proposal.md"))) ?? "";
168
+ const tasks = (await readText(join(dir, "tasks.md"))) ?? "";
169
+ const named = parseAffectedJourneys(proposal);
170
+ const deltaFeatures = new Set(await subdirs(join(dir, "specs")));
171
+ lines.push(`${change}: ${listed.size} journey(s) listed (${[...listed.entries()].map(([j, why]) => `${j}: ${why}`).join("; ")})`);
172
+ for (const [j, why] of listed) {
173
+ const n = named.get(j);
174
+ if (!n || n.verdict === "other") {
175
+ warnings.push(`${CHANGES_DIR}/${change}/proposal.md: journey ${j} is listed (${why}) and the proposal's ## Affected Journeys does not name it as altered or unaffected`);
176
+ continue;
177
+ }
178
+ if (n.verdict === "unaffected" && n.rest === "")
179
+ warnings.push(`${CHANGES_DIR}/${change}/proposal.md: journey ${j} is named unaffected without a reason`);
180
+ if (n.verdict === "altered") {
181
+ const journeyDelta = [...deltaFeatures].some((f) => featureKinds.get(f) === "journey" && (f === j || f.endsWith(`-${j}`)));
182
+ const shown = journeyDelta || tasks.includes(j);
183
+ if (!shown)
184
+ warnings.push(`${CHANGES_DIR}/${change}/proposal.md: journey ${j} is named altered, and the change shows nothing for it: no delta to its journey feature and no task naming it`);
185
+ }
186
+ }
187
+ }
188
+ const body = lines.map((l) => `${l}\n`).join("");
189
+ if (warnings.length === 0)
190
+ return { exitCode: 0, stdout: `journey-impact:\n${body}[PASS] journey-impact: every listed journey is named\n` };
191
+ return { exitCode: 0, stdout: `journey-impact:\n${body}${warnings.map((w) => `[WARN] ${w}\n`).join("")}[WARN] journey-impact: ${warnings.length} finding(s), report only (M-ADR 0229 §4.5, Proposed)\n` };
192
+ }
193
+ //# sourceMappingURL=journey-impact.js.map
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.10.0",
3
- "built_against_sdd_cli": "2.4.0"
2
+ "version": "0.12.0",
3
+ "built_against_sdd_cli": "3.0.0"
4
4
  }
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "2.4.0",
3
- "built_against_sdd_cli": "2.4.0"
2
+ "version": "3.0.0",
3
+ "built_against_sdd_cli": "3.0.0"
4
4
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sdd-method/maintain-cli",
3
- "version": "0.10.0",
4
- "sddCliVersion": "2.4.0",
3
+ "version": "0.12.0",
4
+ "sddCliVersion": "3.0.0",
5
5
  "description": "Bring an SDD repository current and rebuild its derived model — sync, briefing, catalogue build and aggregate, pack build, context pull — plus every read-only verify verb. Ships only the code those verbs reach; it cannot author specification content. Authoring is in @sdd-method/sdd-cli.",
6
6
  "license": "UNLICENSED",
7
7
  "type": "module",