@sdd-method/author-cli 0.16.0 → 0.16.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/snapshot/land.js +35 -3
- 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/verbs/snapshot.js +2 -0
- 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)
|
|
@@ -6,7 +6,11 @@
|
|
|
6
6
|
// - a member observed after the set date;
|
|
7
7
|
// - a prior member absent from the export and not declared carried (with a
|
|
8
8
|
// reason) or removed (with a reason) — never left to be inferred;
|
|
9
|
-
// - a member re-exported at the same revision whose bytes differ
|
|
9
|
+
// - a member re-exported at the same revision whose bytes differ, unless the only
|
|
10
|
+
// difference is files under a path declared --not-source (with a reason) that the
|
|
11
|
+
// prior export holds and the new one does not: analysis a tool wrote into the old
|
|
12
|
+
// export, not the repository. The prior baseline is never edited, the excused files
|
|
13
|
+
// and the reason are recorded, and a declaration that excuses nothing is refused;
|
|
10
14
|
// - no recorded credential scan, or one with findings but no disposition —
|
|
11
15
|
// a snapshot owner often cannot remediate upstream code, so findings land
|
|
12
16
|
// with the disposition on the record, never as a false `clean`;
|
|
@@ -68,6 +72,17 @@ export async function land(inputs, write) {
|
|
|
68
72
|
const s = splitFolder(m.exported_folder_name);
|
|
69
73
|
return s ? [[s.stem, m.canonical_repo_key]] : [];
|
|
70
74
|
}));
|
|
75
|
+
const notSource = new Map();
|
|
76
|
+
for (const [raw, reason] of inputs.notSource ?? []) {
|
|
77
|
+
const p = raw.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
|
|
78
|
+
if (p === "" || p.startsWith("/") || p.split("/").includes(".."))
|
|
79
|
+
throw new SnapshotError(`--not-source '${raw}' must be a relative path inside a member folder`);
|
|
80
|
+
if (!reason.trim())
|
|
81
|
+
throw new SnapshotError(`--not-source ${raw} needs a reason: --not-source <path>=<why it is not source>`);
|
|
82
|
+
notSource.set(p, reason.trim());
|
|
83
|
+
}
|
|
84
|
+
const declaredPath = (f) => [...notSource.keys()].find((p) => f === p || f.startsWith(`${p}/`));
|
|
85
|
+
const excusedBy = new Map();
|
|
71
86
|
const known = inputs.sddPath ? await knownRepositoryIds(inputs.sddPath) : new Set();
|
|
72
87
|
const keyProblems = [];
|
|
73
88
|
const members = [];
|
|
@@ -105,18 +120,35 @@ export async function land(inputs, write) {
|
|
|
105
120
|
const before = await hashTree(memberBytesDir(areaRoot, prior, p));
|
|
106
121
|
const after = await hashTree(join(dir, folder));
|
|
107
122
|
const d = diffTrees(before, after);
|
|
108
|
-
|
|
123
|
+
// Only a file the prior export holds and the new one lacks, under a declared path, is excused.
|
|
124
|
+
const excused = d.removed.filter((f) => declaredPath(f) !== undefined);
|
|
125
|
+
const changed = d.added.length + d.modified.length + d.removed.length - excused.length;
|
|
109
126
|
if (changed > 0) {
|
|
110
|
-
throw new SnapshotError(`${key} was re-exported at the same revision ${revision} as ${prior.snapshot_id}, but ${changed} file(s) differ — one of the two exports is not what its name says`
|
|
127
|
+
throw new SnapshotError(`${key} was re-exported at the same revision ${revision} as ${prior.snapshot_id}, but ${changed} file(s) differ — one of the two exports is not what its name says` +
|
|
128
|
+
(notSource.size > 0 ? ` (--not-source excuses only files the prior export holds and this one does not, under a declared path)` : ""));
|
|
111
129
|
}
|
|
112
130
|
entry.reverified_at = md ?? id;
|
|
113
131
|
entry.reverification = `re-exported at the same revision as ${prior.snapshot_id}; ${after.size} file(s) byte-identical`;
|
|
132
|
+
if (excused.length > 0) {
|
|
133
|
+
const byPath = new Map();
|
|
134
|
+
for (const f of excused) {
|
|
135
|
+
const p = declaredPath(f);
|
|
136
|
+
byPath.set(p, (byPath.get(p) ?? 0) + 1);
|
|
137
|
+
excusedBy.set(p, (excusedBy.get(p) ?? 0) + 1);
|
|
138
|
+
}
|
|
139
|
+
entry.reverification += `; ${[...byPath].map(([p, n]) => `${n} file(s) under ${p}/ in ${prior.snapshot_id} only, not source: ${notSource.get(p)}`).join("; ")}`;
|
|
140
|
+
write(`note: ${key}: ${excused.length} file(s) under a --not-source path excused (prior export only)\n`);
|
|
141
|
+
}
|
|
114
142
|
}
|
|
115
143
|
members.push(entry);
|
|
116
144
|
}
|
|
117
145
|
if (keyProblems.length > 0) {
|
|
118
146
|
throw new SnapshotError(`${keyProblems.length} derived key(s) are not repository ids:\n - ${keyProblems.join("\n - ")}`);
|
|
119
147
|
}
|
|
148
|
+
const unused = [...notSource.keys()].filter((p) => !excusedBy.has(p));
|
|
149
|
+
if (unused.length > 0) {
|
|
150
|
+
throw new SnapshotError(`--not-source ${unused.join(", ")} excused nothing: no same-revision member's prior export holds files there that this one lacks; drop the declaration`);
|
|
151
|
+
}
|
|
120
152
|
const removed = [];
|
|
121
153
|
const unaccounted = [];
|
|
122
154
|
for (const p of prior?.members ?? []) {
|
|
@@ -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/verbs/snapshot.js
CHANGED
|
@@ -72,6 +72,7 @@ export function registerSnapshot(program) {
|
|
|
72
72
|
.option("--role-hint <key=role>", "role_hint for a member; continuing members otherwise keep the prior baseline's (repeatable)", collectPairs, new Map())
|
|
73
73
|
.option("--credential-scan <record>", "tool=…,version=…,date=YYYY-MM-DD,result=clean — or result=<findings>,disposition=<what, and why it stands>")
|
|
74
74
|
.option("--notes <text>", "coherence caveats for the manifest")
|
|
75
|
+
.option("--not-source <path=reason>", "a path inside member folders that is not source (a folder a tool wrote into the prior export), and why: a same-revision re-export may lack it, recorded on the member; nothing else is excused (repeatable)", collectPairs, new Map())
|
|
75
76
|
.action(async (area, o) => {
|
|
76
77
|
const scan = record(o.credentialScan);
|
|
77
78
|
exit(await runSnapshotLand({
|
|
@@ -85,6 +86,7 @@ export function registerSnapshot(program) {
|
|
|
85
86
|
removed: o.removed,
|
|
86
87
|
keys: o.key,
|
|
87
88
|
roleHints: o.roleHint,
|
|
89
|
+
notSource: o.notSource,
|
|
88
90
|
...(scan ? { credentialScan: scan } : {}),
|
|
89
91
|
...(o.notes !== undefined ? { notes: o.notes } : {}),
|
|
90
92
|
}, out));
|
package/dist/verify-version.json
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdd-method/author-cli",
|
|
3
|
-
"version": "0.16.
|
|
4
|
-
"sddCliVersion": "5.3.
|
|
3
|
+
"version": "0.16.2",
|
|
4
|
+
"sddCliVersion": "5.3.2",
|
|
5
5
|
"description": "Re-author one SDD's content — scaffold, plan, contracts, conformance, api-surface, snapshots, briefings, catalogue check — plus every read-only verify verb. Ships only the code those verbs reach; it cannot sync the baseline or rebuild the catalogues. Maintenance is in @sdd-method/maintain-cli, administration 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
|
# ──────────────────────────────────────────────────────────────────────────────
|