@sabaiway/agent-workflow-kit 7.1.0 → 7.3.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/CHANGELOG.md +73 -0
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/contracts.md +1 -1
- package/references/modes/bootstrap.md +1 -1
- package/references/modes/mcp.md +2 -0
- package/references/modes/worktrees.md +16 -1
- package/references/scripts/check-docs-size.mjs +59 -62
- package/references/scripts/check-docs-size.test.mjs +79 -15
- package/references/scripts/spec-schema.mjs +355 -0
- package/references/scripts/spec-schema.test.mjs +212 -0
- package/references/templates/AGENTS.md +2 -2
- package/references/templates/SPEC_TEMPLATE.md +45 -0
- package/references/templates/agent_rules.md +5 -3
- package/references/templates/specs/index.md +21 -0
- package/tools/known-footprint.mjs +9 -1
- package/tools/mcp-registration.mjs +70 -0
- package/tools/mcp.mjs +26 -5
- package/tools/worktrees.mjs +105 -5
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// spec-schema.mjs — the ONE reader that DEFINES a well-formed spec document under docs/ai/specs/.
|
|
3
|
+
//
|
|
4
|
+
// Pure text in, verdict out: readSpecDocument(text, rel) never touches the filesystem and imports
|
|
5
|
+
// nothing, so it seeds layout-free into any deployment. The navigator's collapse (check-docs-size.mjs)
|
|
6
|
+
// and the future spec-check read through THIS module — "malformed" has one definition, never two.
|
|
7
|
+
// `rel` is the path INSIDE docs/ai/specs/ (the store root navigator is `index.md`).
|
|
8
|
+
//
|
|
9
|
+
// SPEC_SCHEMA carries the frozen values; the engine canon (references/specs.md) is pinned against it.
|
|
10
|
+
// A refusal names exactly one rule id per defect, so a fixture corpus can be read rule by rule.
|
|
11
|
+
|
|
12
|
+
export const SPEC_SCHEMA = Object.freeze({
|
|
13
|
+
storePrefix: 'docs/ai/specs/',
|
|
14
|
+
navigatorFile: 'index.md',
|
|
15
|
+
upLink: 'technical_specification.md',
|
|
16
|
+
upLinkLine: '> Up: [technical_specification.md](../technical_specification.md)',
|
|
17
|
+
type: 'spec',
|
|
18
|
+
substrateKeys: Object.freeze(['type', 'lastUpdated', 'scope', 'staleAfter', 'owner', 'maxLines']),
|
|
19
|
+
kinds: Object.freeze(['index', 'spec', 'part']),
|
|
20
|
+
statuses: Object.freeze(['draft', 'live', 'retired']),
|
|
21
|
+
transitions: Object.freeze([Object.freeze(['draft', 'live']), Object.freeze(['live', 'retired'])]),
|
|
22
|
+
maxLines: Object.freeze({ index: 80, spec: 150, part: 150 }),
|
|
23
|
+
fanOutMax: 30,
|
|
24
|
+
slugPattern: '^[a-z0-9]+(-[a-z0-9]+)*$',
|
|
25
|
+
emptyMarker: '*(empty)*',
|
|
26
|
+
unboundMarker: 'unbound',
|
|
27
|
+
titlePrefix: Object.freeze({ index: '# ', spec: '# Spec: ', part: '# Part: ' }),
|
|
28
|
+
requiredSections: Object.freeze({
|
|
29
|
+
index: Object.freeze(['## Children']),
|
|
30
|
+
spec: Object.freeze(['## Contract', '## Scenarios', '## Out of scope', '## Module']),
|
|
31
|
+
part: Object.freeze([]),
|
|
32
|
+
}),
|
|
33
|
+
optionalSections: Object.freeze({ spec: Object.freeze(['## Parts', '## Links']) }),
|
|
34
|
+
rootOwnedKeys: Object.freeze(['status', 'revision']),
|
|
35
|
+
rootOwnedSections: Object.freeze(['## Scenarios', '## Out of scope', '## Module', '## Parts']),
|
|
36
|
+
scenarioGrammar: '- S<N> <name> :: <repo-relative test path> :: spec:<slug>/S<N> | - S<N> <name> :: unbound',
|
|
37
|
+
rules: Object.freeze([
|
|
38
|
+
'frontmatter', 'frontmatter-key', 'substrate-key', 'type', 'kind', 'maxlines', 'status', 'revision',
|
|
39
|
+
'root-owns', 'slug', 'kind-path', 'root-uplink', 'title', 'section-missing', 'section-order',
|
|
40
|
+
'section-forbidden', 'fence', 'children-link', 'children-duplicate', 'fan-out', 'scenario-line',
|
|
41
|
+
'scenario-number', 'scenario-marker', 'scenario-path', 'out-of-scope', 'module-line', 'module-empty',
|
|
42
|
+
'module-traversal', 'module-absolute', 'module-backslash', 'module-glob', 'module-mix', 'parts',
|
|
43
|
+
]),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// The descriptor check-docs-size.mjs joins to its ADR group: rows under `prefix` whose reader verdict
|
|
47
|
+
// is clean collapse into ONE navigator row linking `navPath`; a row with reader errors stays visible.
|
|
48
|
+
export const SPECS_COLLAPSE = Object.freeze({
|
|
49
|
+
prefix: SPEC_SCHEMA.storePrefix,
|
|
50
|
+
navPath: `${SPEC_SCHEMA.storePrefix}${SPEC_SCHEMA.navigatorFile}`,
|
|
51
|
+
label: 'specs/',
|
|
52
|
+
type: SPEC_SCHEMA.type,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n?/;
|
|
56
|
+
const FIELD_RE = /^([a-zA-Z][a-zA-Z0-9_]*):\s*(.*)$/;
|
|
57
|
+
const SLUG_RE = new RegExp(SPEC_SCHEMA.slugPattern);
|
|
58
|
+
const REVISION_RE = /^[1-9][0-9]*$/;
|
|
59
|
+
const CHILD_LINK_RE = /^- \[([^\]]+)\]\(\.\/([^/)]+)(\/index)?\.md\)$/;
|
|
60
|
+
const PART_LINK_RE = /^- \[([^\]]+)\]\(\.\/([^/)]+)\.md\)$/;
|
|
61
|
+
const SCENARIO_HEAD_RE = /^S([0-9]+) (.+)$/;
|
|
62
|
+
const GLOB_RE = /[*?[\]{}]/;
|
|
63
|
+
const WINDOWS_DRIVE_RE = /^[A-Za-z]:/;
|
|
64
|
+
const SEPARATOR = ' :: ';
|
|
65
|
+
const MD_SUFFIX = '.md';
|
|
66
|
+
|
|
67
|
+
const FORBIDDEN_SECTIONS = Object.freeze({
|
|
68
|
+
index: SPEC_SCHEMA.rootOwnedSections,
|
|
69
|
+
spec: Object.freeze(['## Children']),
|
|
70
|
+
part: Object.freeze([...SPEC_SCHEMA.rootOwnedSections, '## Children']),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const KNOWN_KEYS = Object.freeze([...SPEC_SCHEMA.substrateKeys, 'kind', ...SPEC_SCHEMA.rootOwnedKeys]);
|
|
74
|
+
|
|
75
|
+
// The frontmatter is a closed key set: an unknown key, a repeated key or a line that is not
|
|
76
|
+
// `key: value` is a defect (`frontmatter-key`), never silently dropped or last-one-wins.
|
|
77
|
+
const parseFrontmatter = (text) => {
|
|
78
|
+
const match = text.match(FRONTMATTER_RE);
|
|
79
|
+
if (!match) return null;
|
|
80
|
+
const fields = {};
|
|
81
|
+
const defects = [];
|
|
82
|
+
for (const line of match[1].split('\n')) {
|
|
83
|
+
const m = line.match(FIELD_RE);
|
|
84
|
+
if (!m) defects.push(`"${line}" is not \`key: value\``);
|
|
85
|
+
else if (!KNOWN_KEYS.includes(m[1])) defects.push(`unknown key ${m[1]}`);
|
|
86
|
+
else if (m[1] in fields) defects.push(`duplicate key ${m[1]}`);
|
|
87
|
+
else fields[m[1]] = m[2].trim();
|
|
88
|
+
}
|
|
89
|
+
return { fields, defects, body: text.slice(match[0].length) };
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const FENCE_RE = /^ {0,3}(`{3,}|~{3,})/;
|
|
93
|
+
|
|
94
|
+
// The body as { title, sections: [{ heading, lines }], preamble, fenced } — the title is the FIRST
|
|
95
|
+
// `# ` line, every `## ` line opens a section, the lines before the first section are the preamble.
|
|
96
|
+
// The reader parses NO markdown code: a fence line is recorded (the `fence` refusal) rather than
|
|
97
|
+
// modelled, so a spec carries no code sample and no line is ever ambiguous between code and structure.
|
|
98
|
+
const parseBody = (body) => {
|
|
99
|
+
const sections = [];
|
|
100
|
+
const preamble = [];
|
|
101
|
+
const fenced = [];
|
|
102
|
+
const state = { title: null, current: null, sectionBeforeTitle: false };
|
|
103
|
+
for (const raw of body.split('\n')) {
|
|
104
|
+
const line = raw.replace(/\s+$/, '');
|
|
105
|
+
if (FENCE_RE.test(line)) fenced.push(line);
|
|
106
|
+
if (line.startsWith('## ')) {
|
|
107
|
+
if (state.title === null) state.sectionBeforeTitle = true;
|
|
108
|
+
state.current = { heading: line, lines: [] };
|
|
109
|
+
sections.push(state.current);
|
|
110
|
+
} else if (state.title === null && line.startsWith('# ')) {
|
|
111
|
+
state.title = line;
|
|
112
|
+
} else {
|
|
113
|
+
(state.current ? state.current.lines : preamble).push(line);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return { title: state.title, sections, preamble, fenced, sectionBeforeTitle: state.sectionBeforeTitle };
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// Lexical classification of a repo-relative path field — the same vocabulary for module roots and
|
|
120
|
+
// scenario bindings. Realpath/symlink containment needs the filesystem and is the checker's duty.
|
|
121
|
+
export const classifyPath = (path) => {
|
|
122
|
+
if (path.trim() === '') return 'empty';
|
|
123
|
+
if (path.includes('\\')) return 'backslash';
|
|
124
|
+
if (path.startsWith('/') || WINDOWS_DRIVE_RE.test(path)) return 'absolute';
|
|
125
|
+
if (path.split('/').includes('..')) return 'traversal';
|
|
126
|
+
if (GLOB_RE.test(path)) return 'glob';
|
|
127
|
+
return path.endsWith('/') ? 'dir' : 'file';
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const MODULE_PATH_RULES = Object.freeze({
|
|
131
|
+
empty: 'module-empty',
|
|
132
|
+
backslash: 'module-backslash',
|
|
133
|
+
absolute: 'module-absolute',
|
|
134
|
+
traversal: 'module-traversal',
|
|
135
|
+
glob: 'module-glob',
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// A bare `-` is a bullet with a blank payload (trailing whitespace is stripped before parsing).
|
|
139
|
+
const isBullet = (line) => line === '-' || line.startsWith('- ');
|
|
140
|
+
const bulletsOf = (lines) => lines.filter(isBullet).map((line) => line.slice(2));
|
|
141
|
+
const contentOf = (lines) => lines.filter((line) => line.trim() !== '');
|
|
142
|
+
|
|
143
|
+
// The slug a document owns: the file stem for a flat file, the folder name for an index.md.
|
|
144
|
+
const describeRel = (rel) => {
|
|
145
|
+
const segments = rel.split('/');
|
|
146
|
+
const file = segments[segments.length - 1];
|
|
147
|
+
const dirs = segments.slice(0, -1);
|
|
148
|
+
const stem = file.endsWith(MD_SUFFIX) ? file.slice(0, -MD_SUFFIX.length) : file;
|
|
149
|
+
const isIndexFile = file === SPEC_SCHEMA.navigatorFile;
|
|
150
|
+
const slug = isIndexFile ? dirs[dirs.length - 1] ?? null : stem;
|
|
151
|
+
const slugSegments = [...dirs, ...(isIndexFile ? [] : [stem])];
|
|
152
|
+
return { dirs, stem, isIndexFile, slug, slugSegments, isStoreRoot: rel === SPEC_SCHEMA.navigatorFile };
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const checkFrontmatter = (fields, kind, errors) => {
|
|
156
|
+
const missing = SPEC_SCHEMA.substrateKeys.filter((key) => !(key in fields));
|
|
157
|
+
if (missing.length > 0) errors.push({ rule: 'substrate-key', message: `frontmatter is missing ${missing.join(', ')}` });
|
|
158
|
+
if ('type' in fields && fields.type !== SPEC_SCHEMA.type) errors.push({ rule: 'type', message: `type must be ${SPEC_SCHEMA.type}` });
|
|
159
|
+
if ('maxLines' in fields && fields.maxLines !== String(SPEC_SCHEMA.maxLines[kind])) {
|
|
160
|
+
errors.push({ rule: 'maxlines', message: `a ${kind} carries maxLines: ${SPEC_SCHEMA.maxLines[kind]}` });
|
|
161
|
+
}
|
|
162
|
+
if (kind === 'spec') {
|
|
163
|
+
if (!SPEC_SCHEMA.statuses.includes(fields.status)) errors.push({ rule: 'status', message: `status must be one of ${SPEC_SCHEMA.statuses.join('|')}` });
|
|
164
|
+
if (!REVISION_RE.test(fields.revision ?? '')) errors.push({ rule: 'revision', message: 'revision must be an integer >= 1' });
|
|
165
|
+
} else {
|
|
166
|
+
const carried = SPEC_SCHEMA.rootOwnedKeys.filter((key) => key in fields);
|
|
167
|
+
if (carried.length > 0) errors.push({ rule: 'root-owns', message: `a ${kind} never carries ${carried.join(', ')} — the contract root owns them` });
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const checkPath = (rel, kind, errors) => {
|
|
172
|
+
const at = describeRel(rel);
|
|
173
|
+
const badSegment = at.slugSegments.find((segment) => !SLUG_RE.test(segment));
|
|
174
|
+
if (badSegment !== undefined) errors.push({ rule: 'slug', message: `"${badSegment}" is not a slug (${SPEC_SCHEMA.slugPattern})` });
|
|
175
|
+
if (kind === 'index' && !at.isIndexFile) errors.push({ rule: 'kind-path', message: 'a kind: index document is an index.md' });
|
|
176
|
+
if (kind === 'part' && (at.isIndexFile || at.dirs.length === 0)) errors.push({ rule: 'kind-path', message: 'a kind: part document is a <name>.md beside a promoted root, never an index.md or a store-root file' });
|
|
177
|
+
if (kind === 'spec' && at.isIndexFile && at.slug === null) errors.push({ rule: 'kind-path', message: 'the store root is the navigator, never a contract root' });
|
|
178
|
+
return at;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const checkSections = (parsed, kind, errors) => {
|
|
182
|
+
const headings = parsed.sections.map((section) => section.heading);
|
|
183
|
+
const prefix = SPEC_SCHEMA.titlePrefix[kind];
|
|
184
|
+
if (parsed.title === null || parsed.sectionBeforeTitle || !parsed.title.startsWith(prefix) || parsed.title.slice(prefix.length).trim() === '') {
|
|
185
|
+
errors.push({ rule: 'title', message: `the FIRST heading is \`${prefix}<title>\`, before every section` });
|
|
186
|
+
}
|
|
187
|
+
const required = SPEC_SCHEMA.requiredSections[kind];
|
|
188
|
+
const missing = required.filter((heading) => !headings.includes(heading));
|
|
189
|
+
if (missing.length > 0) errors.push({ rule: 'section-missing', message: `missing ${missing.join(', ')}` });
|
|
190
|
+
const ordered = [...required, ...(SPEC_SCHEMA.optionalSections[kind] ?? [])];
|
|
191
|
+
const positions = ordered.filter((heading) => headings.includes(heading)).map((heading) => headings.indexOf(heading));
|
|
192
|
+
if (missing.length === 0 && positions.some((position, i) => i > 0 && position < positions[i - 1])) {
|
|
193
|
+
errors.push({ rule: 'section-order', message: `sections run ${ordered.join(', ')}` });
|
|
194
|
+
}
|
|
195
|
+
const forbidden = FORBIDDEN_SECTIONS[kind].filter((heading) => headings.includes(heading));
|
|
196
|
+
if (forbidden.length > 0) errors.push({ rule: 'section-forbidden', message: `a ${kind} never carries ${forbidden.join(', ')}` });
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const sectionLines = (parsed, heading) => parsed.sections.find((section) => section.heading === heading)?.lines ?? null;
|
|
200
|
+
|
|
201
|
+
const checkChildren = (parsed, errors) => {
|
|
202
|
+
const lines = sectionLines(parsed, '## Children');
|
|
203
|
+
if (lines === null) return;
|
|
204
|
+
const targets = [];
|
|
205
|
+
for (const line of contentOf(lines)) {
|
|
206
|
+
const m = line.match(CHILD_LINK_RE);
|
|
207
|
+
if (!m || !SLUG_RE.test(m[2])) {
|
|
208
|
+
errors.push({ rule: 'children-link', message: `"${line}" is not \`- [name](./<child>.md)\` or \`- [name](./<child>/index.md)\`` });
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
targets.push(m[2]);
|
|
212
|
+
}
|
|
213
|
+
const duplicate = targets.find((target, i) => targets.indexOf(target) !== i);
|
|
214
|
+
if (duplicate !== undefined) errors.push({ rule: 'children-duplicate', message: `child "${duplicate}" is listed twice` });
|
|
215
|
+
if (targets.length > SPEC_SCHEMA.fanOutMax) errors.push({ rule: 'fan-out', message: `${targets.length} children > ${SPEC_SCHEMA.fanOutMax} — subdivide along slice boundaries` });
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const parseScenario = (line) => {
|
|
219
|
+
if (!line.startsWith('- ')) return null;
|
|
220
|
+
const fields = line.slice(2).split(SEPARATOR);
|
|
221
|
+
const head = fields[0].match(SCENARIO_HEAD_RE);
|
|
222
|
+
if (!head) return null;
|
|
223
|
+
const base = { n: Number(head[1]), name: head[2] };
|
|
224
|
+
if (fields.length === 2 && fields[1] === SPEC_SCHEMA.unboundMarker) return { ...base, bound: false };
|
|
225
|
+
if (fields.length === 3) return { ...base, bound: true, path: fields[1], marker: fields[2] };
|
|
226
|
+
return null;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const checkScenarios = (parsed, slug, status, errors, warnings) => {
|
|
230
|
+
const lines = sectionLines(parsed, '## Scenarios');
|
|
231
|
+
if (lines === null) return;
|
|
232
|
+
const scenarios = [];
|
|
233
|
+
for (const line of contentOf(lines)) {
|
|
234
|
+
const scenario = parseScenario(line);
|
|
235
|
+
if (scenario === null) {
|
|
236
|
+
errors.push({ rule: 'scenario-line', message: `"${line}" does not match \`${SPEC_SCHEMA.scenarioGrammar}\`` });
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
scenarios.push(scenario);
|
|
240
|
+
}
|
|
241
|
+
const gap = scenarios.findIndex((scenario, i) => scenario.n !== i + 1);
|
|
242
|
+
if (gap !== -1) errors.push({ rule: 'scenario-number', message: `scenario ${gap + 1} is numbered S${scenarios[gap].n} — N runs contiguously from 1` });
|
|
243
|
+
for (const scenario of scenarios) {
|
|
244
|
+
if (!scenario.bound) {
|
|
245
|
+
if (status === 'live') warnings.push({ rule: 'unbound', message: `S${scenario.n} is unbound on a live spec` });
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const expected = `spec:${slug}/S${scenario.n}`;
|
|
249
|
+
if (scenario.marker !== expected) errors.push({ rule: 'scenario-marker', message: `S${scenario.n} marker "${scenario.marker}" must be "${expected}"` });
|
|
250
|
+
if (classifyPath(scenario.path) !== 'file') errors.push({ rule: 'scenario-path', message: `S${scenario.n} test path "${scenario.path}" is not a repo-relative file` });
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const checkOutOfScope = (parsed, errors) => {
|
|
255
|
+
const lines = sectionLines(parsed, '## Out of scope');
|
|
256
|
+
if (lines === null) return;
|
|
257
|
+
const content = contentOf(lines);
|
|
258
|
+
const exclusions = bulletsOf(content).filter((text) => text.trim() !== '');
|
|
259
|
+
if (exclusions.length === 0 && !(content.length === 1 && content[0] === SPEC_SCHEMA.emptyMarker)) {
|
|
260
|
+
errors.push({ rule: 'out-of-scope', message: `at least one non-blank \`- \` bullet, or exactly \`${SPEC_SCHEMA.emptyMarker}\`` });
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const checkModule = (parsed, status, errors) => {
|
|
265
|
+
const lines = sectionLines(parsed, '## Module');
|
|
266
|
+
if (lines === null) return;
|
|
267
|
+
const content = contentOf(lines);
|
|
268
|
+
const isEmptyMarker = content.length === 1 && content[0] === SPEC_SCHEMA.emptyMarker;
|
|
269
|
+
const prose = isEmptyMarker ? undefined : content.find((line) => !isBullet(line));
|
|
270
|
+
if (prose !== undefined) {
|
|
271
|
+
errors.push({ rule: 'module-line', message: `"${prose}" — every ## Module line is a \`- <path>\` bullet` });
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const paths = bulletsOf(content);
|
|
275
|
+
if (paths.length === 0) {
|
|
276
|
+
if (!(status === 'retired' && isEmptyMarker)) {
|
|
277
|
+
errors.push({ rule: 'module-empty', message: `a module root is required (\`${SPEC_SCHEMA.emptyMarker}\` only on a retired spec)` });
|
|
278
|
+
}
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const kinds = paths.map(classifyPath);
|
|
282
|
+
const offending = kinds.map((kind, i) => (kind in MODULE_PATH_RULES ? { rule: MODULE_PATH_RULES[kind], path: paths[i] } : null)).filter(Boolean);
|
|
283
|
+
if (offending.length > 0) {
|
|
284
|
+
for (const { rule, path } of offending) errors.push({ rule, message: `module path "${path}" refused` });
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const dirs = kinds.filter((kind) => kind === 'dir').length;
|
|
288
|
+
if (!(dirs === 1 && paths.length === 1) && dirs !== 0) {
|
|
289
|
+
errors.push({ rule: 'module-mix', message: 'the module is ONE `dir/` root OR a literal file list' });
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const checkParts = (parsed, at, errors) => {
|
|
294
|
+
const lines = sectionLines(parsed, '## Parts');
|
|
295
|
+
if (lines === null) return;
|
|
296
|
+
if (!at.isIndexFile) {
|
|
297
|
+
errors.push({ rule: 'parts', message: 'only a promoted root (<slug>/index.md) carries ## Parts' });
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const names = [];
|
|
301
|
+
for (const line of contentOf(lines)) {
|
|
302
|
+
const m = line.match(PART_LINK_RE);
|
|
303
|
+
if (!m || !SLUG_RE.test(m[2])) {
|
|
304
|
+
errors.push({ rule: 'parts', message: `"${line}" is not \`- [name](./<part>.md)\`` });
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
names.push(m[2]);
|
|
308
|
+
}
|
|
309
|
+
const duplicate = names.find((name, i) => names.indexOf(name) !== i);
|
|
310
|
+
if (duplicate !== undefined) errors.push({ rule: 'parts', message: `part "${duplicate}" is listed twice` });
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
// The verdict: { kind, status, revision, errors: [{ rule, message }], warnings: [{ rule, message }] }.
|
|
314
|
+
// Errors are collected past the first defect wherever later checks stay meaningful; a missing or
|
|
315
|
+
// defective frontmatter and an unknown kind each end the read, because no shape can be judged without them.
|
|
316
|
+
export const readSpecDocument = (text, rel) => {
|
|
317
|
+
const errors = [];
|
|
318
|
+
const warnings = [];
|
|
319
|
+
const verdict = (kind, status, revision) => ({ kind, status, revision, errors, warnings });
|
|
320
|
+
const front = parseFrontmatter(text.replace(/\r\n/g, '\n'));
|
|
321
|
+
if (front === null) {
|
|
322
|
+
errors.push({ rule: 'frontmatter', message: 'missing YAML frontmatter' });
|
|
323
|
+
return verdict(null, null, null);
|
|
324
|
+
}
|
|
325
|
+
const { fields, defects, body } = front;
|
|
326
|
+
if (defects.length > 0) {
|
|
327
|
+
errors.push({ rule: 'frontmatter-key', message: defects.join('; ') });
|
|
328
|
+
return verdict(null, null, null);
|
|
329
|
+
}
|
|
330
|
+
const kind = fields.kind;
|
|
331
|
+
if (!SPEC_SCHEMA.kinds.includes(kind)) {
|
|
332
|
+
errors.push({ rule: 'kind', message: `kind must be one of ${SPEC_SCHEMA.kinds.join('|')}` });
|
|
333
|
+
return verdict(null, null, null);
|
|
334
|
+
}
|
|
335
|
+
checkFrontmatter(fields, kind, errors);
|
|
336
|
+
const at = checkPath(rel, kind, errors);
|
|
337
|
+
const parsed = parseBody(body);
|
|
338
|
+
if (parsed.fenced.length > 0) errors.push({ rule: 'fence', message: `"${parsed.fenced[0]}" — a spec document carries no code fence` });
|
|
339
|
+
checkSections(parsed, kind, errors);
|
|
340
|
+
if (kind === 'index') {
|
|
341
|
+
checkChildren(parsed, errors);
|
|
342
|
+
if (at.isStoreRoot && !parsed.preamble.includes(SPEC_SCHEMA.upLinkLine)) {
|
|
343
|
+
errors.push({ rule: 'root-uplink', message: `the store root carries the line \`${SPEC_SCHEMA.upLinkLine}\` before its first section` });
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (kind === 'spec') {
|
|
347
|
+
checkScenarios(parsed, at.slug, fields.status, errors, warnings);
|
|
348
|
+
checkOutOfScope(parsed, errors);
|
|
349
|
+
checkModule(parsed, fields.status, errors);
|
|
350
|
+
checkParts(parsed, at, errors);
|
|
351
|
+
}
|
|
352
|
+
const status = kind === 'spec' ? fields.status ?? null : null;
|
|
353
|
+
const revision = kind === 'spec' && REVISION_RE.test(fields.revision ?? '') ? Number(fields.revision) : null;
|
|
354
|
+
return verdict(kind, status, revision);
|
|
355
|
+
};
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import { expect } from './_expect-shim.mjs';
|
|
3
|
+
|
|
4
|
+
// Dynamic import: the suite LOADS without the module (red-proof observes it failing pre-fix).
|
|
5
|
+
const reader = await import('./spec-schema.mjs').catch(() => ({}));
|
|
6
|
+
const { readSpecDocument, classifyPath, SPEC_SCHEMA, SPECS_COLLAPSE } = reader;
|
|
7
|
+
|
|
8
|
+
// Inline fixtures ONLY — this suite runs inside a deployed project's scripts/ where no corpus exists.
|
|
9
|
+
// The repo-only corpus (engine test/fixtures/specs) is the durable record; this is the unit pin.
|
|
10
|
+
|
|
11
|
+
const frontmatter = (fields) =>
|
|
12
|
+
`---\n${Object.entries(fields).map(([key, value]) => `${key}: ${value}`).join('\n')}\n---\n`;
|
|
13
|
+
|
|
14
|
+
const SUBSTRATE = { type: 'spec', lastUpdated: '2026-08-23', scope: 'permanent', staleAfter: '90d', owner: 'none' };
|
|
15
|
+
const UPLINK_LINE = '> Up: [technical_specification.md](../technical_specification.md)';
|
|
16
|
+
|
|
17
|
+
const specDoc = ({ fields = {}, drop = [], title = '# Spec: Login', scenarios, outOfScope = '- Password reset', module = '- src/login/', extra = '' } = {}) => {
|
|
18
|
+
const all = { ...SUBSTRATE, maxLines: '150', kind: 'spec', status: 'draft', revision: '1', ...fields };
|
|
19
|
+
for (const key of drop) delete all[key];
|
|
20
|
+
const lines = scenarios ?? ['- S1 happy path :: test/login.test.mjs :: spec:login/S1', '- S2 lockout :: unbound'];
|
|
21
|
+
return `${frontmatter(all)}\n${title}\n\n## Contract\n\nAccepts a credential pair.\n\n## Scenarios\n\n${lines.join('\n')}\n\n## Out of scope\n\n${outOfScope}\n\n## Module\n\n${module}\n${extra}`;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const indexDoc = ({ fields = {}, children = ['- [login](./login.md)', '- [billing](./billing/index.md)'], preamble = '', title = '# Auth' } = {}) =>
|
|
25
|
+
`${frontmatter({ ...SUBSTRATE, maxLines: '80', kind: 'index', ...fields })}\n${title}\n${preamble}\n## Children\n\n${children.join('\n')}\n`;
|
|
26
|
+
|
|
27
|
+
const partDoc = ({ fields = {}, title = '# Part: Sessions', extra = '' } = {}) =>
|
|
28
|
+
`${frontmatter({ ...SUBSTRATE, maxLines: '150', kind: 'part', ...fields })}\n${title}\n\nSession details.\n${extra}`;
|
|
29
|
+
|
|
30
|
+
const rulesOf = (verdict) => verdict.errors.map((e) => e.rule);
|
|
31
|
+
const refuses = (text, rel, rule) => {
|
|
32
|
+
const verdict = readSpecDocument(text, rel);
|
|
33
|
+
expect(rulesOf(verdict)).toEqual([rule]);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
describe('readSpecDocument — accept', () => {
|
|
37
|
+
it('a flat draft spec reads clean with kind/status/revision', () => {
|
|
38
|
+
const verdict = readSpecDocument(specDoc(), 'login.md');
|
|
39
|
+
expect(verdict.errors).toEqual([]);
|
|
40
|
+
expect(verdict.warnings).toEqual([]);
|
|
41
|
+
expect({ kind: verdict.kind, status: verdict.status, revision: verdict.revision }).toEqual({ kind: 'spec', status: 'draft', revision: 1 });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('a promoted root under <slug>/index.md with ## Parts and ## Links reads clean', () => {
|
|
45
|
+
const text = specDoc({ extra: '\n## Parts\n\n- [sessions](./sessions.md)\n\n## Links\n\n- [[AD-112]]\n' });
|
|
46
|
+
expect(readSpecDocument(text, 'auth/login/index.md').errors).toEqual([]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('a domain index, the store root (with the up-link) and a part read clean', () => {
|
|
50
|
+
expect(readSpecDocument(indexDoc(), 'auth/index.md').errors).toEqual([]);
|
|
51
|
+
const root = indexDoc({ preamble: `\n${UPLINK_LINE}\n`, children: [] });
|
|
52
|
+
const rootVerdict = readSpecDocument(root, 'index.md');
|
|
53
|
+
expect(rootVerdict.errors).toEqual([]);
|
|
54
|
+
expect(rootVerdict.kind).toBe('index');
|
|
55
|
+
expect(readSpecDocument(partDoc(), 'auth/login/sessions.md').errors).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('a retired spec may carry *(empty)* as its module; a live spec with an unbound scenario WARNS, never refuses', () => {
|
|
59
|
+
const retired = specDoc({ fields: { status: 'retired' }, module: '*(empty)*', scenarios: ['- S1 gone :: unbound'] });
|
|
60
|
+
expect(readSpecDocument(retired, 'login.md').errors).toEqual([]);
|
|
61
|
+
const live = readSpecDocument(specDoc({ fields: { status: 'live', revision: '3' } }), 'login.md');
|
|
62
|
+
expect(live.errors).toEqual([]);
|
|
63
|
+
expect(live.warnings.map((w) => w.rule)).toEqual(['unbound']);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('CRLF line endings read identically', () => {
|
|
67
|
+
expect(readSpecDocument(specDoc().replace(/\n/g, '\r\n'), 'login.md').errors).toEqual([]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('*(empty)* is a legal Out of scope; a file-list module is legal', () => {
|
|
71
|
+
const text = specDoc({ outOfScope: '*(empty)*', module: '- src/a.mjs\n- src/b.mjs' });
|
|
72
|
+
expect(readSpecDocument(text, 'login.md').errors).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('readSpecDocument — refuse, exactly one rule per defect', () => {
|
|
77
|
+
it('frontmatter / substrate-key / type / kind / maxlines', () => {
|
|
78
|
+
refuses('# Spec: Login\n', 'login.md', 'frontmatter');
|
|
79
|
+
refuses(specDoc({ drop: ['owner'] }), 'login.md', 'substrate-key');
|
|
80
|
+
refuses(specDoc({ fields: { type: 'reference' } }), 'login.md', 'type');
|
|
81
|
+
refuses(specDoc({ fields: { kind: 'feature' } }), 'login.md', 'kind');
|
|
82
|
+
refuses(specDoc({ drop: ['kind'] }), 'login.md', 'kind');
|
|
83
|
+
refuses(specDoc({ fields: { maxLines: '400' } }), 'login.md', 'maxlines');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('status / revision / root-owns', () => {
|
|
87
|
+
refuses(specDoc({ fields: { status: 'approved' } }), 'login.md', 'status');
|
|
88
|
+
refuses(specDoc({ drop: ['status'] }), 'login.md', 'status');
|
|
89
|
+
refuses(specDoc({ fields: { revision: '0' } }), 'login.md', 'revision');
|
|
90
|
+
refuses(specDoc({ fields: { revision: '1.5' } }), 'login.md', 'revision');
|
|
91
|
+
refuses(indexDoc({ fields: { status: 'draft' } }), 'auth/index.md', 'root-owns');
|
|
92
|
+
refuses(partDoc({ fields: { revision: '2' } }), 'auth/login/sessions.md', 'root-owns');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('slug / kind-path / root-uplink', () => {
|
|
96
|
+
refuses(specDoc({ scenarios: ['- S1 x :: unbound'] }), 'Login_Page.md', 'slug');
|
|
97
|
+
refuses(indexDoc(), 'auth/Auth Stuff/index.md', 'slug');
|
|
98
|
+
refuses(indexDoc(), 'auth/overview.md', 'kind-path');
|
|
99
|
+
refuses(partDoc(), 'auth/login/index.md', 'kind-path');
|
|
100
|
+
refuses(specDoc({ scenarios: ['- S1 x :: unbound'] }), 'index.md', 'kind-path');
|
|
101
|
+
refuses(indexDoc({ children: [] }), 'index.md', 'root-uplink');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('title / section-missing / section-order / section-forbidden', () => {
|
|
105
|
+
refuses(specDoc({ title: '# Login' }), 'login.md', 'title');
|
|
106
|
+
refuses(specDoc({ title: '# Spec: ' }), 'login.md', 'title');
|
|
107
|
+
refuses(partDoc({ title: '# Sessions' }), 'auth/login/sessions.md', 'title');
|
|
108
|
+
refuses(specDoc().replace('## Out of scope', '## Out-of-scope'), 'login.md', 'section-missing');
|
|
109
|
+
refuses(specDoc().replace('## Contract\n\nAccepts a credential pair.\n\n', '').concat('\n## Contract\n\nlate\n'), 'login.md', 'section-order');
|
|
110
|
+
refuses(indexDoc().concat('\n## Module\n\n- src/\n'), 'auth/index.md', 'section-forbidden');
|
|
111
|
+
refuses(partDoc({ extra: '\n## Scenarios\n\n- S1 x :: unbound\n' }), 'auth/login/sessions.md', 'section-forbidden');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('children-link / children-duplicate / fan-out', () => {
|
|
115
|
+
refuses(indexDoc({ children: ['- login'] }), 'auth/index.md', 'children-link');
|
|
116
|
+
refuses(indexDoc({ children: ['- [login](../login.md)'] }), 'auth/index.md', 'children-link');
|
|
117
|
+
refuses(indexDoc({ children: ['- [login](./login.md)', '- [login again](./login.md)'] }), 'auth/index.md', 'children-duplicate');
|
|
118
|
+
const many = Array.from({ length: SPEC_SCHEMA.fanOutMax + 1 }, (_, i) => `- [c${i}](./c${i}.md)`);
|
|
119
|
+
refuses(indexDoc({ children: many }), 'auth/index.md', 'fan-out');
|
|
120
|
+
expect(readSpecDocument(indexDoc({ children: many.slice(0, SPEC_SCHEMA.fanOutMax) }), 'auth/index.md').errors).toEqual([]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('scenario-line / scenario-number / scenario-marker / scenario-path', () => {
|
|
124
|
+
refuses(specDoc({ scenarios: ['- S1 no binding'] }), 'login.md', 'scenario-line');
|
|
125
|
+
refuses(specDoc({ scenarios: ['S1 happy :: unbound'] }), 'login.md', 'scenario-line');
|
|
126
|
+
refuses(specDoc({ scenarios: ['- S1 a :: unbound', '- S3 b :: unbound'] }), 'login.md', 'scenario-number');
|
|
127
|
+
refuses(specDoc({ scenarios: ['- S2 a :: unbound'] }), 'login.md', 'scenario-number');
|
|
128
|
+
refuses(specDoc({ scenarios: ['- S1 a :: test/login.test.mjs :: spec:login/S2'] }), 'login.md', 'scenario-marker');
|
|
129
|
+
refuses(specDoc({ scenarios: ['- S1 a :: test/login.test.mjs :: spec:signup/S1'] }), 'login.md', 'scenario-marker');
|
|
130
|
+
refuses(specDoc({ scenarios: ['- S1 a :: ../test/login.test.mjs :: spec:login/S1'] }), 'login.md', 'scenario-path');
|
|
131
|
+
refuses(specDoc({ scenarios: ['- S1 a :: /abs/login.test.mjs :: spec:login/S1'] }), 'login.md', 'scenario-path');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('out-of-scope / module-empty / module-* path refusals / module-mix / parts', () => {
|
|
135
|
+
refuses(specDoc({ outOfScope: '' }), 'login.md', 'out-of-scope');
|
|
136
|
+
refuses(specDoc({ outOfScope: 'nothing excluded' }), 'login.md', 'out-of-scope');
|
|
137
|
+
refuses(specDoc({ outOfScope: '- ' }), 'login.md', 'out-of-scope');
|
|
138
|
+
refuses(specDoc({ module: '' }), 'login.md', 'module-empty');
|
|
139
|
+
refuses(specDoc({ module: '*(empty)*' }), 'login.md', 'module-empty');
|
|
140
|
+
refuses(specDoc({ module: '- ../src/' }), 'login.md', 'module-traversal');
|
|
141
|
+
refuses(specDoc({ module: '- /src/login/' }), 'login.md', 'module-absolute');
|
|
142
|
+
refuses(specDoc({ module: '- C:/src/login/' }), 'login.md', 'module-absolute');
|
|
143
|
+
refuses(specDoc({ module: '- src\\login\\' }), 'login.md', 'module-backslash');
|
|
144
|
+
refuses(specDoc({ module: '- src/**/*.mjs' }), 'login.md', 'module-glob');
|
|
145
|
+
refuses(specDoc({ module: '- src/login/\n- src/login/a.mjs' }), 'login.md', 'module-mix');
|
|
146
|
+
refuses(specDoc({ module: '- src/login/\n- src/signup/' }), 'login.md', 'module-mix');
|
|
147
|
+
refuses(specDoc({ extra: '\n## Parts\n\n- [sessions](./sessions.md)\n' }), 'login.md', 'parts');
|
|
148
|
+
refuses(specDoc({ extra: '\n## Parts\n\n- sessions\n' }), 'auth/login/index.md', 'parts');
|
|
149
|
+
refuses(specDoc({ extra: '\n## Parts\n\n- [a](./a.md)\n- [b](./a.md)\n' }), 'auth/login/index.md', 'parts');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('frontmatter-key: an unknown key, a duplicate key and a malformed line each refuse; an absent type is substrate-key ALONE', () => {
|
|
153
|
+
refuses(specDoc({ fields: { priority: 'high' } }), 'login.md', 'frontmatter-key');
|
|
154
|
+
refuses(specDoc().replace('owner: none\n', 'owner: none\nowner: none\n'), 'login.md', 'frontmatter-key');
|
|
155
|
+
refuses(specDoc().replace('owner: none\n', 'owner: none\n- a list item\n'), 'login.md', 'frontmatter-key');
|
|
156
|
+
refuses(specDoc({ drop: ['type'] }), 'login.md', 'substrate-key');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('a part at the store root (no folder) is kind-path; a blank binding path is scenario-path; a blank module bullet is module-empty', () => {
|
|
160
|
+
refuses(partDoc(), 'orphan.md', 'kind-path');
|
|
161
|
+
refuses(specDoc({ scenarios: ['- S1 a :: :: spec:login/S1'] }), 'login.md', 'scenario-path');
|
|
162
|
+
refuses(specDoc({ module: '- ' }), 'login.md', 'module-empty');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('module-line: prose beside a module bullet refuses; root-uplink needs the exact markdown link, not a mention', () => {
|
|
166
|
+
refuses(specDoc({ module: 'the root is\n- src/login/' }), 'login.md', 'module-line');
|
|
167
|
+
refuses(indexDoc({ preamble: '\nSee technical_specification.md for the top spec.\n', children: [] }), 'index.md', 'root-uplink');
|
|
168
|
+
expect(readSpecDocument(indexDoc({ preamble: `\n${UPLINK_LINE}\n`, children: [] }), 'index.md').errors).toEqual([]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('the reader parses no markdown code: a fence line refuses `fence`; the up-link is a whole exact line, so an inline span or a trailing note is root-uplink', () => {
|
|
172
|
+
refuses(indexDoc({ preamble: `\n${UPLINK_LINE}\n\n\`\`\`\n${UPLINK_LINE}\n\`\`\`\n`, children: [] }), 'index.md', 'fence');
|
|
173
|
+
refuses(partDoc({ extra: '\n~~~js\nconst sample = 1;\n~~~\n' }), 'auth/login/sessions.md', 'fence');
|
|
174
|
+
refuses(indexDoc({ preamble: `\nWrite \`\`${UPLINK_LINE}\`\` here.\n`, children: [] }), 'index.md', 'root-uplink');
|
|
175
|
+
refuses(indexDoc({ preamble: `\n${UPLINK_LINE} — the top spec\n`, children: [] }), 'index.md', 'root-uplink');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('a frontmatter defect ENDS the read with frontmatter-key alone (a malformed kind line is not a missing kind)', () => {
|
|
179
|
+
refuses(specDoc().replace('kind: spec\n', 'kind spec\n'), 'login.md', 'frontmatter-key');
|
|
180
|
+
refuses(specDoc({ fields: { priority: 'high', kind: 'feature' } }), 'login.md', 'frontmatter-key');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('the title precedes every section; prose-only Module is module-line; a blank Module on a retired spec is module-empty', () => {
|
|
184
|
+
refuses(specDoc().replace('# Spec: Login\n', '## Surprise\n\nx\n\n# Spec: Login\n'), 'login.md', 'title');
|
|
185
|
+
refuses(specDoc({ module: 'the root is src' }), 'login.md', 'module-line');
|
|
186
|
+
refuses(specDoc({ fields: { status: 'retired' }, module: '' }), 'login.md', 'module-empty');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('every rule id a refusal names is declared in SPEC_SCHEMA.rules', () => {
|
|
190
|
+
for (const rule of ['frontmatter', 'frontmatter-key', 'fence', 'kind', 'module-mix', 'module-line', 'scenario-path', 'parts', 'root-uplink']) {
|
|
191
|
+
expect(SPEC_SCHEMA.rules.includes(rule)).toBe(true);
|
|
192
|
+
}
|
|
193
|
+
expect(new Set(SPEC_SCHEMA.rules).size).toBe(SPEC_SCHEMA.rules.length);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe('classifyPath + the frozen constants', () => {
|
|
198
|
+
it('classifies the lexical path forms', () => {
|
|
199
|
+
expect(['src/', 'src/a.mjs', '../x', '/x', 'C:/x', 'a\\b', 'src/*.mjs'].map(classifyPath)).toEqual([
|
|
200
|
+
'dir', 'file', 'traversal', 'absolute', 'absolute', 'backslash', 'glob',
|
|
201
|
+
]);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('SPEC_SCHEMA carries the frozen numbers and SPECS_COLLAPSE joins the store root', () => {
|
|
205
|
+
expect(SPEC_SCHEMA.fanOutMax).toBe(30);
|
|
206
|
+
expect(SPEC_SCHEMA.maxLines).toEqual({ index: 80, spec: 150, part: 150 });
|
|
207
|
+
expect(SPEC_SCHEMA.statuses).toEqual(['draft', 'live', 'retired']);
|
|
208
|
+
expect(SPEC_SCHEMA.kinds).toEqual(['index', 'spec', 'part']);
|
|
209
|
+
expect(Object.isFrozen(SPEC_SCHEMA)).toBe(true);
|
|
210
|
+
expect(SPECS_COLLAPSE).toEqual({ prefix: 'docs/ai/specs/', navPath: 'docs/ai/specs/index.md', label: 'specs/', type: 'spec' });
|
|
211
|
+
});
|
|
212
|
+
});
|
|
@@ -37,7 +37,7 @@ All project knowledge lives in `docs/ai/`. Layered, lazy-loaded context:
|
|
|
37
37
|
| [`docs/ai/handover.md`](./docs/ai/handover.md) | **Start of every session** | End of session if context changed |
|
|
38
38
|
| [`docs/ai/active_plan.md`](./docs/ai/active_plan.md) | Picking next task | Completing a task |
|
|
39
39
|
| [`docs/ai/current_state.md`](./docs/ai/current_state.md) | Need system overview | After feature completion |
|
|
40
|
-
| [`docs/ai/technical_specification.md`](./docs/ai/technical_specification.md) | App overview & data models | Data-model changes |
|
|
40
|
+
| [`docs/ai/technical_specification.md`](./docs/ai/technical_specification.md) | App overview & data models; feature contracts live under `docs/ai/specs/` | Data-model or contract changes |
|
|
41
41
|
| [`docs/ai/pages/index.md`](./docs/ai/pages/index.md) | Understanding a page | Page behaviour changes |
|
|
42
42
|
| [`docs/ai/architecture.md`](./docs/ai/architecture.md) | Understanding structure | Architecture changes |
|
|
43
43
|
| [`docs/ai/known_issues.md`](./docs/ai/known_issues.md) | Debugging | New issue discovered |
|
|
@@ -72,7 +72,7 @@ Start-of-session, during-work, and task-completion procedures live in [`docs/ai/
|
|
|
72
72
|
| Interactive elements semantic (button/link, not div+onClick) | Linter / a11y |
|
|
73
73
|
| No business logic in components → hooks/services | Architecture review |
|
|
74
74
|
| No changes without tests (TDD) | Required |
|
|
75
|
-
| Check page docs before changes; update them after | Process |
|
|
75
|
+
| Check the governing spec(s) + page docs before changes; update them after | Process |
|
|
76
76
|
| Ask user before committing | Process |
|
|
77
77
|
| Every page has an HTML-validity / a11y E2E test | Required |
|
|
78
78
|
| **No silent failures** — structured logging on every rejected action | Required |
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: spec
|
|
3
|
+
lastUpdated: {{DATE}}
|
|
4
|
+
scope: permanent
|
|
5
|
+
staleAfter: 90d
|
|
6
|
+
owner: none
|
|
7
|
+
maxLines: 150
|
|
8
|
+
kind: spec
|
|
9
|
+
status: draft
|
|
10
|
+
revision: 1
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# Spec: example-feature
|
|
14
|
+
|
|
15
|
+
> Authoring reference for a `kind: spec` contract root — copy it to `docs/ai/specs/<slug>.md`
|
|
16
|
+
> (the slug mirrors the feature-slice or module name, `^[a-z0-9]+(-[a-z0-9]+)*$`) and replace every
|
|
17
|
+
> section. NOT deployed on bootstrap — it stays in the skill home like `adr-record.md`. The full
|
|
18
|
+
> schema (kinds, statuses, transitions, promotion, precedence) is the engine canon `references/specs.md`;
|
|
19
|
+
> the deployed reader `scripts/spec-schema.mjs` is what decides well-formed.
|
|
20
|
+
|
|
21
|
+
## Contract
|
|
22
|
+
|
|
23
|
+
What the feature accepts and returns, stated as invariants a test can pin. A `draft` spec is authored
|
|
24
|
+
WITH the plan that lands the feature and exists at plan review; approval of that plan confirms the
|
|
25
|
+
contract (no separate stop). It becomes `live` on the plan's landing row; `revision` increments by one
|
|
26
|
+
per live contract change; `retired` on the removal row — never backwards.
|
|
27
|
+
|
|
28
|
+
## Scenarios
|
|
29
|
+
|
|
30
|
+
- S1 accepts a well-formed request :: test/example-feature.test.mjs :: spec:example-feature/S1
|
|
31
|
+
- S2 refuses a malformed request with a typed error :: unbound
|
|
32
|
+
|
|
33
|
+
## Out of scope
|
|
34
|
+
|
|
35
|
+
- The neighbouring feature this one is often confused with (name it — exclusions are the core value)
|
|
36
|
+
- Retry, caching and observability concerns owned by their own slices
|
|
37
|
+
|
|
38
|
+
## Module
|
|
39
|
+
|
|
40
|
+
- src/example-feature/
|
|
41
|
+
|
|
42
|
+
## Links
|
|
43
|
+
|
|
44
|
+
- [[AD-NNN]] — the decision that shaped this contract
|
|
45
|
+
- `pages/<page>.md` — the page spec this feature's view layer is described in (a subordinate view)
|