@sdd-method/maintain-cli 0.17.0 → 0.17.2
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 +24 -5
- package/dist/lib/catalogue/builders/features.js +34 -4
- package/dist/lib/catalogue/builders/requirements.js +1 -1
- package/dist/lib/validate/behaviour-store.js +27 -1
- package/dist/lib/validate/feature-spec-pointers.js +7 -0
- package/dist/maintain-version.json +2 -2
- package/dist/verify-version.json +2 -2
- package/package.json +2 -2
- package/scripts/validate-app-docs.sh +18 -2
|
@@ -129,10 +129,17 @@ export async function orderArchives(sddPath, archiveRel, names) {
|
|
|
129
129
|
}
|
|
130
130
|
return { order, ties, history: history !== null };
|
|
131
131
|
}
|
|
132
|
+
/**
|
|
133
|
+
* Whether the evidence's `**Observed in:**` line is the typed claim `none`: a baseline that admits no behaviour (no
|
|
134
|
+
* spec deltas), such as a structure-only removal, observed no repository serving any.
|
|
135
|
+
*/
|
|
136
|
+
export function observedNone(evidence) {
|
|
137
|
+
return /^\s*\*\*Observed in:\*\*\s*none\b/im.test(evidence);
|
|
138
|
+
}
|
|
132
139
|
/** The repositories a baseline was observed in: its evidence file's `**Observed in:** repo[@sha], repo[@sha]` line. */
|
|
133
140
|
export function observedIn(evidence) {
|
|
134
141
|
const m = /^\s*\*\*Observed in:\*\*\s*(.+)$/m.exec(evidence);
|
|
135
|
-
if (!m)
|
|
142
|
+
if (!m || observedNone(evidence))
|
|
136
143
|
return [];
|
|
137
144
|
return m[1]
|
|
138
145
|
.split(",")
|
|
@@ -148,18 +155,30 @@ export function observedIn(evidence) {
|
|
|
148
155
|
* `**Surfaces:**` line is read, so an earlier prose section under the same label does not shadow the list.
|
|
149
156
|
* Covered needs evidence that exercises the surface as the service exposes it; evidence below the transport is
|
|
150
157
|
* not coverage, so `covered at handler` is a problem, not an entry. A wildcard may only exclude.
|
|
158
|
+
*
|
|
159
|
+
* `**Surfaces:** none` on one line is the typed claim that the baseline lists no surface, which only a baseline that
|
|
160
|
+
* admits no behaviour may make (the caller decides; `none` is true and the list is empty).
|
|
151
161
|
*/
|
|
152
162
|
export function parseSurfaces(evidence) {
|
|
153
163
|
const lines = evidence.split("\n");
|
|
154
164
|
// The LAST standalone line: an evidence file may carry an earlier prose section under the same label, and
|
|
155
165
|
// the list is what the file ends up asserting.
|
|
156
166
|
let at = -1;
|
|
167
|
+
let none = false;
|
|
157
168
|
lines.forEach((l, i) => {
|
|
158
|
-
if (/^\s*\*\*Surfaces:\*\*\s*$/.test(l))
|
|
169
|
+
if (/^\s*\*\*Surfaces:\*\*\s*$/.test(l)) {
|
|
159
170
|
at = i;
|
|
171
|
+
none = false;
|
|
172
|
+
}
|
|
173
|
+
else if (/^\s*\*\*Surfaces:\*\*\s*none\b/i.test(l)) {
|
|
174
|
+
at = i;
|
|
175
|
+
none = true;
|
|
176
|
+
}
|
|
160
177
|
});
|
|
161
178
|
if (at < 0)
|
|
162
|
-
return { present: false, entries: [], problems: [] };
|
|
179
|
+
return { present: false, none: false, entries: [], problems: [] };
|
|
180
|
+
if (none)
|
|
181
|
+
return { present: true, none: true, entries: [], problems: [] };
|
|
163
182
|
const entries = [];
|
|
164
183
|
const problems = [];
|
|
165
184
|
for (const line of lines.slice(at + 1)) {
|
|
@@ -193,8 +212,8 @@ export function parseSurfaces(evidence) {
|
|
|
193
212
|
}
|
|
194
213
|
}
|
|
195
214
|
if (entries.length + problems.length === 0)
|
|
196
|
-
problems.push(
|
|
197
|
-
return { present: true, entries, problems };
|
|
215
|
+
problems.push('the **Surfaces:** line lists no contract; a baseline that admits no behaviour writes "**Surfaces:** none"');
|
|
216
|
+
return { present: true, none: false, entries, problems };
|
|
198
217
|
}
|
|
199
218
|
/**
|
|
200
219
|
* A structural delta's `moved: {contracts: [{from, to, from_path?}]}` block. A contract's id is derived from its path,
|
|
@@ -24,9 +24,18 @@
|
|
|
24
24
|
//
|
|
25
25
|
// Dedup ordering matters: walker (1) runs first (capability features),
|
|
26
26
|
// then walker (2) (standalone specs), then walker (3) (bullets). When
|
|
27
|
-
// a bullet's id is already
|
|
28
|
-
//
|
|
29
|
-
//
|
|
27
|
+
// a bullet's id is already claimed by a standalone app spec (2), the two
|
|
28
|
+
// are one feature and make one row (sdm-mbhi): the feature tree is the home
|
|
29
|
+
// of its placement and inventory facts (parent_group_id, maturity, runway,
|
|
30
|
+
// description), the spec of its lifecycle (status, kind), and source stays
|
|
31
|
+
// feature-spec, since a spec exists. A spec does not restate the tree's
|
|
32
|
+
// fields, and its H1 title is a name, not a description. A bullet whose id a
|
|
33
|
+
// capability feature (1) already claims is still a divergence.
|
|
34
|
+
//
|
|
35
|
+
// A standalone spec's stem is the feature's slug, without the product
|
|
36
|
+
// prefix: features/<group>/alerts.md is <productId>-alerts. A stem that
|
|
37
|
+
// already starts with "<productId>-" would double the prefix, so it is
|
|
38
|
+
// refused with a convention finding naming the stem to use.
|
|
30
39
|
import { readdir, readFile } from "node:fs/promises";
|
|
31
40
|
import { basename, join, relative, sep } from "node:path";
|
|
32
41
|
import { featureGroupId, findFeatureTreeFile, walkFeatureTree, } from "./feature-tree-walker.js";
|
|
@@ -103,10 +112,15 @@ export async function buildFeatures(inputs) {
|
|
|
103
112
|
// These features are owned by the SDD's product (not by a capability) —
|
|
104
113
|
// capability_id is left empty; product_id carries the link.
|
|
105
114
|
if (inputs.profile === "application-product") {
|
|
115
|
+
const appSpecRow = new Map();
|
|
106
116
|
const appFeatures = await collectAppFeatureSpecs(inputs.sddPath);
|
|
107
117
|
for (const path of appFeatures) {
|
|
108
118
|
const relPath = relative(inputs.sddPath, path);
|
|
109
119
|
const stem = basename(relPath).replace(/\.md$/i, "");
|
|
120
|
+
if (stem.startsWith(`${inputs.productId}-`)) {
|
|
121
|
+
inputs.findings.add("convention", `${relPath}: feature spec stem "${stem}" repeats the product prefix, so its id would read "${inputs.productId}-${stem}"; name the file after the feature's slug ("${stem.slice(inputs.productId.length + 1)}.md"), the slug feature-tree.md uses`);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
110
124
|
const id = `${inputs.productId}-${stem}`;
|
|
111
125
|
if (seen.has(id)) {
|
|
112
126
|
inputs.findings.add("divergence", `${relPath}: feature id "${id}" already emitted by an earlier file`);
|
|
@@ -133,6 +147,7 @@ export async function buildFeatures(inputs) {
|
|
|
133
147
|
idByPath.set(relPath, id);
|
|
134
148
|
if (!idBySlug.has(stem))
|
|
135
149
|
idBySlug.set(stem, id);
|
|
150
|
+
appSpecRow.set(id, rows.length - 1);
|
|
136
151
|
}
|
|
137
152
|
// Bullet branch — feature-tree.md.
|
|
138
153
|
await emitBulletRows({
|
|
@@ -143,6 +158,7 @@ export async function buildFeatures(inputs) {
|
|
|
143
158
|
seen,
|
|
144
159
|
rows,
|
|
145
160
|
idBySlug,
|
|
161
|
+
appSpecRow,
|
|
146
162
|
});
|
|
147
163
|
}
|
|
148
164
|
return { rows, idByPath, idBySlug };
|
|
@@ -178,7 +194,8 @@ async function emitBulletRows(ctx) {
|
|
|
178
194
|
continue;
|
|
179
195
|
bulletCount++;
|
|
180
196
|
const id = `${ctx.productId}-${ev.slug}`;
|
|
181
|
-
|
|
197
|
+
const specIndex = ctx.appSpecRow.get(id);
|
|
198
|
+
if (ctx.seen.has(id) && specIndex === undefined) {
|
|
182
199
|
ctx.findings.add("divergence", `${relPath}: feature-tree.md bullet "${ev.slug}" superseded by an earlier feature spec with id "${id}"`);
|
|
183
200
|
continue;
|
|
184
201
|
}
|
|
@@ -196,6 +213,19 @@ async function emitBulletRows(ctx) {
|
|
|
196
213
|
const description = ev.description.length > DESCRIPTION_MAX
|
|
197
214
|
? ev.description.slice(0, DESCRIPTION_MAX)
|
|
198
215
|
: ev.description;
|
|
216
|
+
if (specIndex !== undefined) {
|
|
217
|
+
// One feature, one row: the tree's placement and inventory facts, the spec's lifecycle.
|
|
218
|
+
ctx.rows[specIndex] = {
|
|
219
|
+
...ctx.rows[specIndex],
|
|
220
|
+
parent_group_id: parentGroupId,
|
|
221
|
+
description,
|
|
222
|
+
maturity: maturityFromRunway(ev.runway),
|
|
223
|
+
runway: ev.runway,
|
|
224
|
+
};
|
|
225
|
+
if (!ctx.idBySlug.has(ev.slug))
|
|
226
|
+
ctx.idBySlug.set(ev.slug, id);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
199
229
|
ctx.rows.push({
|
|
200
230
|
id,
|
|
201
231
|
name: ev.slug,
|
|
@@ -276,7 +276,7 @@ export async function buildRequirements(inputs) {
|
|
|
276
276
|
structureEffects.push(effectRow(scoped(dir), "structure", e.element, e.operation, e.base ?? "", e.target ?? ""));
|
|
277
277
|
if (evidence !== null) {
|
|
278
278
|
const repos = observedIn(evidence);
|
|
279
|
-
if (repos.length === 0) {
|
|
279
|
+
if (repos.length === 0 && delta.length > 0) {
|
|
280
280
|
findings.add("convention", `${rel(sddPath, join(changeDir, "baseline-evidence.md"))}: no "**Observed in:**" line, so its requirements carry no observed status`);
|
|
281
281
|
}
|
|
282
282
|
for (const repo of repos)
|
|
@@ -838,6 +838,7 @@ export async function checkBehaviourStore(repo, options = {}) {
|
|
|
838
838
|
openBaselines.push(change);
|
|
839
839
|
const servedBy = baselines + openBaselines.length > 0 ? await catalogueContracts(repo) : null;
|
|
840
840
|
let surfacesChecked = 0;
|
|
841
|
+
let structureOnly = 0;
|
|
841
842
|
// The contract moves each archived change records, in archive order. A contract's id derives from its path, so a
|
|
842
843
|
// move changes it; an archived baseline's list names the contract as it was, and is resolved through the moves
|
|
843
844
|
// archived after it rather than edited (M-ADR 0222 §4.4). A surface that vanished with no move still fails.
|
|
@@ -900,10 +901,35 @@ export async function checkBehaviourStore(repo, options = {}) {
|
|
|
900
901
|
}
|
|
901
902
|
return out;
|
|
902
903
|
};
|
|
904
|
+
/**
|
|
905
|
+
* Whether a baseline admits behaviour: any requirement in its spec deltas. One that admits none (a structure-only
|
|
906
|
+
* baseline, such as the removal of structure carved into another SDD) observes no served behaviour, so it has no
|
|
907
|
+
* surface to account for, and says so with the typed claim "**Surfaces:** none".
|
|
908
|
+
*/
|
|
909
|
+
const admitsBehaviour = async (dir) => {
|
|
910
|
+
for (const feature of await dirs(join(repo, dir, "specs"))) {
|
|
911
|
+
if (parseRequirements((await readText(join(repo, dir, "specs", feature, "spec.md"))) ?? "").length > 0)
|
|
912
|
+
return true;
|
|
913
|
+
}
|
|
914
|
+
return false;
|
|
915
|
+
};
|
|
903
916
|
/** What is wrong with a baseline's Surfaces list against the catalogue (M-ADR 0222 §4.3, §4.4). */
|
|
904
917
|
const surfaceProblems = async (dir, evidence, after = -1) => {
|
|
905
918
|
const out = [];
|
|
906
919
|
const parsed = parseSurfaces(evidence);
|
|
920
|
+
const admits = await admitsBehaviour(dir);
|
|
921
|
+
if (!admits) {
|
|
922
|
+
if (parsed.none) {
|
|
923
|
+
structureOnly++;
|
|
924
|
+
return out;
|
|
925
|
+
}
|
|
926
|
+
if (parsed.entries.length === 0) {
|
|
927
|
+
return [`${dir}/baseline-evidence.md: the baseline admits no behaviour (no requirement in its spec deltas), so no surface is observed: write "**Surfaces:** none" (and "**Observed in:** none" where no repository is observed)`];
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
else if (parsed.none) {
|
|
931
|
+
return [`${dir}/baseline-evidence.md: **Surfaces:** none, but the baseline admits behaviour (requirements in ${dir}/specs/); name every contract the observed repositories serve, covered at transport or excluded with a reason`];
|
|
932
|
+
}
|
|
907
933
|
if (!parsed.present)
|
|
908
934
|
out.push(`${dir}/baseline-evidence.md: no **Surfaces:** list; name every contract the observed repositories serve, covered at transport or excluded with a reason`);
|
|
909
935
|
for (const p of parsed.problems)
|
|
@@ -1022,7 +1048,7 @@ export async function checkBehaviourStore(repo, options = {}) {
|
|
|
1022
1048
|
if (openBaselines.length > 0)
|
|
1023
1049
|
lines.push(`open baselines: ${openBaselines.length}, asked the archive's questions as warnings`);
|
|
1024
1050
|
const unseen = servedBy === null ? "" : `; repositories with no catalogue contracts: ${[...new Set(await observedInAll(repo, archiveRel, order))].filter((r) => (servedBy.get(r)?.served ?? []).length === 0).join(", ") || "none"}`;
|
|
1025
|
-
lines.push(baselines === 0 ? "surfaces: vacuous (no baseline)" : `surfaces: ${baselines} baseline(s), ${surfacesChecked} served contract(s) checked against catalogues/repo_contract.csv less what service_contract records only consumed${unseen}${historical.size ? `; ${historical.size} historical baseline(s) not checked (every requirement they baselined has left the living specs)` : ""}`);
|
|
1051
|
+
lines.push(baselines === 0 ? "surfaces: vacuous (no baseline)" : `surfaces: ${baselines} baseline(s), ${surfacesChecked} served contract(s) checked against catalogues/repo_contract.csv less what service_contract records only consumed${unseen}${structureOnly ? `; ${structureOnly} structure-only baseline(s) admitting no behaviour, Surfaces: none` : ""}${historical.size ? `; ${historical.size} historical baseline(s) not checked (every requirement they baselined has left the living specs)` : ""}`);
|
|
1026
1052
|
lines.push(scopeChecked === 0 ? "scope: vacuous (no baseline removes, modifies or renames a requirement)" : `scope: ${scopeChecked} requirement(s) a baseline removes, modifies or renames, checked against their lineage`);
|
|
1027
1053
|
if (!history && archived.length > 0)
|
|
1028
1054
|
lines.push("order: no git history (not a git work tree, or git log failed), so same-day archives cannot be ordered");
|
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
// gherkin Given/When/Then lines anywhere else;
|
|
15
15
|
// sections content lines under a heading that states behaviour: Happy Path, Edge Cases, Error Handling,
|
|
16
16
|
// Business Rules, Validation Rules, Acceptance Criteria.
|
|
17
|
+
// A pointer is not a restatement: under a behaviour heading, a line naming `openspec/specs/<feature-id>/` is the
|
|
18
|
+
// pointer the template asks for there ("## Acceptance Criteria" with "shipped, see openspec/specs/…: <names>"), unless
|
|
19
|
+
// it is itself a criterion (a checkbox) or a Given/When/Then line.
|
|
17
20
|
// Intent not yet shipped is allowed where it is marked. A heading reading "Not yet shipped or agreed" covers its
|
|
18
21
|
// section, to the next heading of the same or a higher level. A bold line reading so covers what follows it, to the
|
|
19
22
|
// next heading of any level. Open Questions' checkboxes are not
|
|
@@ -29,6 +32,8 @@ const NOT_YET = /not yet (shipped|agreed)/i;
|
|
|
29
32
|
const CRITERIA_START = /^\*\*Acceptance Criteria:?\*\*/i;
|
|
30
33
|
const GHERKIN = /^\s*(?:[-*]\s+(?:\[[ xX]\]\s+)?)?(?:\*\*)?(Given|When|Then)\b/;
|
|
31
34
|
const LIST_LINE = /^\s*(?:[-*]|\d+\.)\s+\S/;
|
|
35
|
+
const POINTER_LINE = /openspec\/specs\/[^/\s`)]+/;
|
|
36
|
+
const CHECKBOX = /^\s*(?:[-*]|\d+\.)\s+\[[ xX]\]/;
|
|
32
37
|
/** "## 3. Edge Cases (Revised)" -> "edge cases". */
|
|
33
38
|
function headingKey(h) {
|
|
34
39
|
return h.replace(/^\s*\d+(\.\d+)*\.?\s*/, "").replace(/\((revised|updated)\)\s*$/i, "").trim().toLowerCase();
|
|
@@ -72,6 +77,8 @@ export function scanRestated(text) {
|
|
|
72
77
|
if (!line.trim() || /^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(line))
|
|
73
78
|
continue; // blank, or a table separator
|
|
74
79
|
if (stack.some((s) => BEHAVIOUR_HEADING.test(s.key))) {
|
|
80
|
+
if (POINTER_LINE.test(line) && !CHECKBOX.test(line) && !GHERKIN.test(line))
|
|
81
|
+
continue; // the pointer itself
|
|
75
82
|
out.sections.push(n);
|
|
76
83
|
continue;
|
|
77
84
|
}
|
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.17.
|
|
4
|
-
"sddCliVersion": "5.3.
|
|
3
|
+
"version": "0.17.2",
|
|
4
|
+
"sddCliVersion": "5.3.2",
|
|
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",
|
|
@@ -8,10 +8,16 @@
|
|
|
8
8
|
# 1. service-architecture.md is present
|
|
9
9
|
# 2. feature-tree.md is present (may be at docs/ or repo root)
|
|
10
10
|
# 3. app-platform-dependencies.yaml is present
|
|
11
|
-
# 4. Feature spec files under features
|
|
11
|
+
# 4. Feature spec files under features/ have required sections:
|
|
12
12
|
# - ## Overview
|
|
13
|
-
# - ## Acceptance Criteria
|
|
13
|
+
# - ## Acceptance Criteria (a prefix: the template's heading is
|
|
14
|
+
# "## Acceptance Criteria Summary"; once the feature ships it holds the
|
|
15
|
+
# pointer to its living spec, M-ADR 0221 §4.3)
|
|
14
16
|
# - ## Platform Contract Dependencies
|
|
17
|
+
# 5. Feature specs sit at features/<feature>/<feature>.md, one directory per
|
|
18
|
+
# feature with its story files beside it (the app-feature-spec skill's
|
|
19
|
+
# path). A spec stored flat at features/<name>.md is reported as [WARN],
|
|
20
|
+
# advisory: it does not fail the run.
|
|
15
21
|
#
|
|
16
22
|
# Does NOT check:
|
|
17
23
|
# - Glossary alignment (no domain glossary in app repos)
|
|
@@ -142,6 +148,7 @@ check_feature_specs() {
|
|
|
142
148
|
local specs_found=0
|
|
143
149
|
local specs_failed=0
|
|
144
150
|
local specs_passed=0
|
|
151
|
+
local specs_flat=0
|
|
145
152
|
|
|
146
153
|
# Discover feature specs under features/*/
|
|
147
154
|
local feature_spec
|
|
@@ -157,6 +164,12 @@ check_feature_specs() {
|
|
|
157
164
|
fi
|
|
158
165
|
done
|
|
159
166
|
|
|
167
|
+
if [[ "$(dirname "$feature_spec")" == "$APP_ROOT/features" ]]; then
|
|
168
|
+
local name; name="$(basename "$feature_spec" .md)"
|
|
169
|
+
log_warn "$rel: stored flat under features/; a feature spec's path is features/${name}/${name}.md, one directory per feature with its story files beside it (app-feature-spec skill). Advisory: reported, not failed"
|
|
170
|
+
specs_flat=$((specs_flat + 1))
|
|
171
|
+
fi
|
|
172
|
+
|
|
160
173
|
if [[ "$file_failures" -eq 0 ]]; then
|
|
161
174
|
log_pass "$rel: all required sections present"
|
|
162
175
|
specs_passed=$((specs_passed + 1))
|
|
@@ -172,6 +185,9 @@ check_feature_specs() {
|
|
|
172
185
|
fi
|
|
173
186
|
|
|
174
187
|
log_info "Group 2: $specs_passed/$specs_found feature specs passed, $specs_failed failed"
|
|
188
|
+
if [[ "$specs_flat" -gt 0 ]]; then
|
|
189
|
+
log_warn "Group 2: $specs_flat of $specs_found feature spec(s) stored flat under features/ (advisory)"
|
|
190
|
+
fi
|
|
175
191
|
}
|
|
176
192
|
|
|
177
193
|
# ──────────────────────────────────────────────────────────────────────────────
|