@sdd-method/maintain-cli 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lib/behaviour-store/archive.js +13 -0
- package/dist/lib/catalogue/aggregate.js +62 -0
- package/dist/lib/catalogue/build.js +12 -0
- package/dist/lib/catalogue/builders/capability-spec.js +1 -0
- package/dist/lib/catalogue/builders/feature-grounding.js +1 -0
- package/dist/lib/catalogue/builders/features.js +23 -2
- package/dist/lib/catalogue/builders/journey-requirement.js +144 -0
- package/dist/lib/catalogue/builders/requirements.js +43 -16
- package/dist/lib/catalogue/canonical-schema.yaml +81 -14
- package/dist/lib/catalogue/manifest-loader.js +13 -0
- package/dist/lib/catalogue/rubric-coverage-map.yaml +18 -0
- package/dist/lib/catalogue/schema-compat.js +1 -1
- package/dist/lib/intents/format.js +77 -0
- package/dist/lib/metamodel/tql-subset-parser.js +35 -2
- package/dist/lib/validate/agreement-evidence.js +204 -0
- package/dist/lib/validate/behaviour-store.js +475 -28
- package/dist/lib/validate/dispatch.js +21 -0
- package/dist/lib/validate/feature-spec-pointers.js +175 -0
- package/dist/lib/validate/intents.js +76 -0
- package/dist/lib/validate/journey-impact.js +193 -0
- package/dist/maintain-version.json +2 -2
- package/dist/verify-version.json +2 -2
- package/package.json +2 -2
|
@@ -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
|
package/dist/verify-version.json
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdd-method/maintain-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"sddCliVersion": "
|
|
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",
|