@sabaiway/agent-workflow-memory 4.5.4 → 4.6.1
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 +63 -0
- package/SKILL.md +27 -8
- package/capability.json +1 -1
- package/package.json +1 -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 +4 -3
- package/references/templates/specs/index.md +21 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,69 @@ All notable changes to the memory substrate. Versions are this **package's** npm
|
|
|
4
4
|
they are distinct from the **deployment-lineage** stamp written into a project's
|
|
5
5
|
`docs/ai/.memory-version` (which tracks the shared `agent-workflow` lineage, head `3.0.0`).
|
|
6
6
|
|
|
7
|
+
## 4.6.1 — the standalone upgrade delivers the spec layer behind a checker it can prove (AD-113)
|
|
8
|
+
|
|
9
|
+
4.6.0 shipped the feature-spec layer for FRESH bootstraps; an EXISTING deployment at lineage head
|
|
10
|
+
`3.0.0` had no lane to it. The standalone (skill-only) upgrade now closes that gap in prose, with no
|
|
11
|
+
digest catalog and no change to the shipped payload:
|
|
12
|
+
|
|
13
|
+
- **SKILL.md upgrade step 2 gains the spec-layer twin.** The reader pair (`scripts/spec-schema.mjs`
|
|
14
|
+
+ its test) seeds create-only if missing. The store root `docs/ai/specs/index.md` seeds ONLY when
|
|
15
|
+
BOTH deployed pairs — reader and checker — are regular files byte-equal to the bundled copies
|
|
16
|
+
(checked via lstat; a dangling symlink anywhere means nothing is written), with the template's
|
|
17
|
+
date placeholder rendered. Every other state — a custom or partial pair, reader or checker — is
|
|
18
|
+
reported as is, with the composition root's upgrade named as the refresh lane and NO store root
|
|
19
|
+
written.
|
|
20
|
+
- **`standalone-bootstrap.test.mjs` models the equal-head upgrade both ways:** both pairs
|
|
21
|
+
bundle-equal -> store seeded and the installed pre-commit hook exits 0 over a seeded spec; a
|
|
22
|
+
custom or partial pair -> reported, no store.
|
|
23
|
+
|
|
24
|
+
PATCH: prose + the E2E model only.
|
|
25
|
+
|
|
26
|
+
## 4.6.0 — the feature-spec layer: a store under `docs/ai/specs/`, one text-only reader, a counted navigator row (AD-112)
|
|
27
|
+
|
|
28
|
+
A deployed project gains a middle altitude of durable knowledge between the whole-project
|
|
29
|
+
`technical_specification.md` and the ephemeral plan: one contract per feature — what it accepts and
|
|
30
|
+
returns, which scenarios pin it, and, the part the family actually suffers from, what is OUT of its
|
|
31
|
+
scope. This is an adapted layer in the substrate's own vocabulary, not a methodology import: the
|
|
32
|
+
existing plan-approval checkpoint confirms a contract, the existing `maxLines` caps bound every file,
|
|
33
|
+
the existing `[[AD-NNN]]` convention links the why, and hidden mode covers the store with no new
|
|
34
|
+
machinery (`/docs/ai/` already subsumes it).
|
|
35
|
+
|
|
36
|
+
- **`references/scripts/spec-schema.mjs`** (+ its deploy-payload test) — the ONE reader that DEFINES
|
|
37
|
+
a well-formed spec. Pure text in (`readSpecDocument(text, rel)`), a verdict out; it imports nothing
|
|
38
|
+
and opens no file, so it seeds layout-free into any deployment. It carries the frozen schema
|
|
39
|
+
(`SPEC_SCHEMA`: kinds `index|spec|part`, statuses `draft|live|retired`, fan-out 30, promotion at
|
|
40
|
+
`maxLines: 150`, the kebab slug, the scenario-binding grammar, the module-root grammar) and names
|
|
41
|
+
exactly ONE rule id per defect — 33 rules, each pinned by a repo-only fixture. It models no markdown
|
|
42
|
+
code: a fence line refuses, a spec carries no code sample.
|
|
43
|
+
- **`references/scripts/check-docs-size.mjs`** — the ADR-only collapse became a GROUPS loop the spec
|
|
44
|
+
store joins: every file the reader accepts folds into ONE `specs/` navigator row with live counts
|
|
45
|
+
(specs / parts / indexes), so adding or removing a valid spec changes the generated index and
|
|
46
|
+
`--check-index` sees it; a file the reader refuses keeps its own visible row and its schema
|
|
47
|
+
refusal surfaces as `spec <rule>:` WARNINGS — advisory (the structural checker is a later slice);
|
|
48
|
+
the substrate's own frontmatter and `maxLines` errors stay blocking, as for every `docs/ai` file.
|
|
49
|
+
The 591-line ratchet held by trimming prose, not by raising a record.
|
|
50
|
+
- **`references/templates/specs/index.md`** — the seed store root (`kind: index`, the exact up-link
|
|
51
|
+
line to `technical_specification.md`, an empty `## Children`); deployed by the bootstrap like the
|
|
52
|
+
`adr/` seed. **`references/templates/SPEC_TEMPLATE.md`** — the `kind: spec` authoring reference
|
|
53
|
+
with one bound and one unbound scenario; skill-home only, excluded by NAME like `adr-record.md` —
|
|
54
|
+
in the SKILL prose AND in the bootstrap E2E copy loop, which also runs the real installed
|
|
55
|
+
pre-commit hook over a seeded spec and asserts the one counted row.
|
|
56
|
+
- **`references/templates/agent_rules.md`** §1.2/§1.3 — name the governing spec(s) before a
|
|
57
|
+
feature (zero, one or many; page-only coverage governs as an adoption shim), the revision lands
|
|
58
|
+
with the code; the `### 2.6` lens region carries the engine's new `Spec-first` bullet.
|
|
59
|
+
**`references/templates/AGENTS.md`** — the `technical_specification.md` Memory-Map row now points
|
|
60
|
+
at the contracts under `docs/ai/specs/` (96 lines; the cap is 100).
|
|
61
|
+
- **Scale is a release gate.** 1000 valid specs in a 30-per-folder tree: both hook runs sum to a
|
|
62
|
+
median of 843 ms against a 1500 ms budget (pre-slice 0.75 s for 1161 docs); over budget blocks the
|
|
63
|
+
release.
|
|
64
|
+
|
|
65
|
+
**Reaches a FRESH bootstrap only.** An existing deployment keeps its old checker (which never imports
|
|
66
|
+
the reader) and has no `docs/ai/specs/` to collapse; delivery to existing deployments — the `specs`
|
|
67
|
+
ensure op, the layout-free reader seed, the prior-matching checker refresh, an upgrade E2E — is slice
|
|
68
|
+
1b of the same series.
|
|
69
|
+
|
|
7
70
|
## 4.5.4 — the deployed `agent_rules.md` carries the state-table clause (AD-111)
|
|
8
71
|
|
|
9
72
|
Template-only follow-up to engine 3.2.0: the rendered lens block in
|
package/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: agent-workflow-memory
|
|
|
3
3
|
description: Deploy or upgrade a portable AI-agent memory substrate in any project — an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) and a structured `docs/ai/` context store with cap/archive/index enforcement. Use when the user wants to bootstrap `docs/ai/`, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-memory` / `/agent-workflow-memory upgrade`. Triggers on "set up the memory system", "deploy the AI memory here", "bootstrap docs/ai", "upgrade the memory substrate". This is the substrate only — the workflow methodology (plan→execute→review, queue, Cleanup) is owned elsewhere and injected into AGENTS.md by the family composition root.
|
|
4
4
|
disable-model-invocation: true
|
|
5
5
|
metadata:
|
|
6
|
-
version: '4.
|
|
6
|
+
version: '4.6.1'
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# agent-workflow-memory
|
|
@@ -102,11 +102,13 @@ bootstrapping over a live system, but the user makes the final call.
|
|
|
102
102
|
(`ln -s AGENTS.md CLAUDE.md`). **Leave ALL THREE pointer slots (`workflow:methodology` +
|
|
103
103
|
`workflow:orchestration` + `workflow:autonomy`) exactly as shipped — empty.** Filling them is the
|
|
104
104
|
composition root's job.
|
|
105
|
-
6. **Deploy `docs/ai/`.** Create the files + `pages/` + the seed `adr/` store
|
|
106
|
-
`${CLAUDE_SKILL_DIR}/references/templates/` (every
|
|
107
|
-
the latter
|
|
108
|
-
`docs/ai/decisions.md` **and** the seed navigator
|
|
109
|
-
in wholesale),
|
|
105
|
+
6. **Deploy `docs/ai/`.** Create the files + `pages/` + the seed `adr/` store + the seed `specs/`
|
|
106
|
+
store from `${CLAUDE_SKILL_DIR}/references/templates/` (every template except `AGENTS.md`,
|
|
107
|
+
`adr-record.md` and `SPEC_TEMPLATE.md` — the latter two are skill-home authoring references, never
|
|
108
|
+
deployed). This deploys the HOT ADR window `docs/ai/decisions.md` **and** the seed navigator
|
|
109
|
+
`docs/ai/adr/log.md` (its `adr/` subdir copies in wholesale), the spec store root
|
|
110
|
+
`docs/ai/specs/index.md` (its `specs/` subdir copies in wholesale; feature contracts are authored
|
|
111
|
+
under it from `SPEC_TEMPLATE.md`), plus the three seeded, **user-editable** strict-JSON configs:
|
|
110
112
|
`docs/ai/orchestration.json` — the per-project recipe defaults the composition root's `procedures`
|
|
111
113
|
advisor reads — `docs/ai/gates.json` — the project's gate declaration, an empty list to fill with
|
|
112
114
|
its own verification commands, consumed by the composition root's gate runner — and
|
|
@@ -223,6 +225,20 @@ Fill strategy:
|
|
|
223
225
|
hook gains its `archive-decisions.mjs --check` line only when the hook is next refreshed via
|
|
224
226
|
`node scripts/install-git-hooks.mjs`; an old hook without the line stays consistent-safe (the
|
|
225
227
|
decisions gate is simply not enforced yet — never a broken hook). **Same gate, also
|
|
228
|
+
stamp-independent — ensure the SPEC LAYER (Node projects; skip on a No-Node project like the
|
|
229
|
+
pair above):** copy `spec-schema.mjs` + `spec-schema.test.mjs` from
|
|
230
|
+
`${CLAUDE_SKILL_DIR}/references/scripts/` into the project's `scripts/` **if missing** (preserve
|
|
231
|
+
an existing file byte-for-byte). Then seed `docs/ai/specs/index.md` **if missing** from
|
|
232
|
+
`${CLAUDE_SKILL_DIR}/references/templates/specs/` (date filled; an existing root is preserved
|
|
233
|
+
byte-for-byte) **ONLY if BOTH pairs** — that reader pair AND `check-docs-size.mjs` +
|
|
234
|
+
`check-docs-size.test.mjs` — are regular files (probe with `ls -l`, never a follow-the-link
|
|
235
|
+
test: a symlink, dangling or not, or a directory at any of the five paths means NOTHING is
|
|
236
|
+
written) **byte-equal** to this skill's bundled copies (compare with `cmp`): the deployed checker
|
|
237
|
+
is what the pre-commit hook runs, and an older or edited one renders the store row by row and
|
|
238
|
+
reds its own index check. Otherwise seed NO store root and report which file differs: a
|
|
239
|
+
checker still on a body a release shipped is refreshed by the composition root's own upgrade
|
|
240
|
+
(it carries the catalog of shipped bodies), an edited one is the user's to refresh by hand — this
|
|
241
|
+
substrate carries no such catalog and never overwrites a deployed script. **Same gate, also
|
|
226
242
|
stamp-independent — ensure the NAVIGATOR:** `docs/ai/index.md` is a GENERATED artifact the entry
|
|
227
243
|
point declares always-loaded, so a deployment that never had one must gain it even at head — run
|
|
228
244
|
`node ${CLAUDE_SKILL_DIR}/references/scripts/check-docs-size.mjs --ensure-index --root=<target>`
|
|
@@ -333,9 +349,12 @@ The three setup choices each have a full contract in
|
|
|
333
349
|
- [`references/templates/`](references/templates/) — stack-agnostic `AGENTS.md` (with the three empty
|
|
334
350
|
pointer slots — methodology + orchestration + autonomy), `agent_rules.md`, the seeded user-editable
|
|
335
351
|
`orchestration.json` + `autonomy.json` configs, the `adr-record.md` ADR authoring reference + the
|
|
336
|
-
seed `adr/log.md` navigator,
|
|
352
|
+
seed `adr/log.md` navigator, the `SPEC_TEMPLATE.md` spec authoring reference + the seed
|
|
353
|
+
`specs/index.md` store root, and all `docs/ai/` files to deploy.
|
|
337
354
|
- [`references/scripts/`](references/scripts/) — the Node enforcement scripts (caps + staleness +
|
|
338
|
-
index-freshness gate
|
|
355
|
+
index-freshness gate with the counted `specs/` collapse, the import-free spec reader
|
|
356
|
+
`spec-schema.mjs` that defines a well-formed spec, one-file-per-ADR archive, hook installer) and
|
|
357
|
+
their unit tests.
|
|
339
358
|
- [`scripts/stamp-takeover.mjs`](scripts/stamp-takeover.mjs) — the upgrade-time lineage state
|
|
340
359
|
machine (`LINEAGE_HEAD`, atomic stamp writes) + tests.
|
|
341
360
|
- [`migrations/`](migrations/) — per-version upgrade steps, incl. `legacy-stamp-takeover.md`;
|
package/capability.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sabaiway/agent-workflow-memory",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.6.1",
|
|
4
4
|
"description": "Portable, cross-agent memory substrate for AI coding agents — an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement, deployable standalone or as part of the agent-workflow family. The memory layer of the agent-workflow family.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|
|
@@ -29,6 +29,7 @@ import { existsSync, lstatSync, realpathSync } from 'node:fs';
|
|
|
29
29
|
import { dirname, resolve, relative, join, basename, sep } from 'node:path';
|
|
30
30
|
import { fileURLToPath } from 'node:url';
|
|
31
31
|
import { randomBytes } from 'node:crypto';
|
|
32
|
+
import { readSpecDocument, SPECS_COLLAPSE } from './spec-schema.mjs';
|
|
32
33
|
|
|
33
34
|
const __filename = fileURLToPath(import.meta.url);
|
|
34
35
|
const __dirname = dirname(__filename);
|
|
@@ -36,10 +37,8 @@ const ROOT = resolve(__dirname, '..');
|
|
|
36
37
|
const DOCS_DIR = resolve(ROOT, 'docs/ai');
|
|
37
38
|
const INDEX_PATH = resolve(DOCS_DIR, 'index.md');
|
|
38
39
|
|
|
39
|
-
//
|
|
40
|
-
// (
|
|
41
|
-
// override them so the ADR-rotation hook (archive-decisions.mjs) and hermetic tests can regenerate
|
|
42
|
-
// an arbitrary root's index without ever touching the real repo tree.
|
|
40
|
+
// The module ROOT constants are the CLI DEFAULT (this deployment's own root); `--root=<dir>` and the
|
|
41
|
+
// exported `regenerateIndex(root, today)` target an arbitrary root (the ADR-rotation hook, hermetic tests).
|
|
43
42
|
const pathsFor = (root) => {
|
|
44
43
|
const base = resolve(root);
|
|
45
44
|
return { root: base, docsDir: resolve(base, 'docs/ai'), indexPath: resolve(base, 'docs/ai/index.md') };
|
|
@@ -58,12 +57,9 @@ const ENSURE_INDEX_PREFIX = 'ensure-index:';
|
|
|
58
57
|
const DEFAULT_PROJECT_NAME = 'this project';
|
|
59
58
|
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'dist-ssr', 'coverage', 'build', '.next']);
|
|
60
59
|
|
|
61
|
-
// `strict` is the finalizer's lens on the SAME walk:
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
// a genuine ENOENT stays an absence; every other fs error propagates.
|
|
65
|
-
// Only a genuine ENOENT is an absence. A code-LESS throw (an injected reader, a wrapped client) is
|
|
66
|
-
// not evidence of absence either, so it propagates too — "unknown" must never read as "empty".
|
|
60
|
+
// `strict` is the finalizer's lens on the SAME walk: a run that WRITES the navigator may not treat
|
|
61
|
+
// "could not read" as "nothing there". Only a genuine ENOENT is an absence — a code-LESS throw (an
|
|
62
|
+
// injected reader, a wrapped client) propagates too: "unknown" must never read as "empty".
|
|
67
63
|
const rethrowUnlessAbsent = (err, strict) => {
|
|
68
64
|
if (strict && err?.code !== 'ENOENT') throw err;
|
|
69
65
|
};
|
|
@@ -204,24 +200,25 @@ export const computeToday = (todayStr) =>
|
|
|
204
200
|
? new Date(`${todayStr}T00:00:00Z`)
|
|
205
201
|
: new Date(new Date().toISOString().slice(0, 10) + 'T00:00:00Z');
|
|
206
202
|
|
|
203
|
+
// A file under the spec store is ALSO read through the shared reader (already-read text, no second
|
|
204
|
+
// read): its verdict rides the row as `spec` and decides collapse eligibility; a refusal is ADVISORY
|
|
205
|
+
// here — surfaced as warnings, never a cap error — the structural checker owns the refusal.
|
|
206
|
+
const readSpecVerdict = (rel, text) =>
|
|
207
|
+
rel.startsWith(SPECS_COLLAPSE.prefix) ? readSpecDocument(text, rel.slice(SPECS_COLLAPSE.prefix.length)) : null;
|
|
208
|
+
|
|
207
209
|
export const inspectFile = async (filePath, today, root = ROOT) => {
|
|
208
210
|
const text = await readFile(filePath, 'utf8');
|
|
209
211
|
const lineCount = text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
|
|
210
212
|
const fm = parseFrontmatter(text);
|
|
211
213
|
const rel = relative(root, filePath);
|
|
214
|
+
const spec = readSpecVerdict(rel, text);
|
|
212
215
|
|
|
213
216
|
if (!fm) {
|
|
214
|
-
return {
|
|
215
|
-
path: rel,
|
|
216
|
-
lineCount,
|
|
217
|
-
frontmatter: null,
|
|
218
|
-
errors: [`missing YAML frontmatter`],
|
|
219
|
-
warnings: [],
|
|
220
|
-
};
|
|
217
|
+
return { path: rel, lineCount, frontmatter: null, spec, errors: [`missing YAML frontmatter`], warnings: [] };
|
|
221
218
|
}
|
|
222
219
|
|
|
223
220
|
const errors = [];
|
|
224
|
-
const warnings = [];
|
|
221
|
+
const warnings = (spec === null ? [] : [...spec.errors, ...spec.warnings]).map((finding) => `spec ${finding.rule}: ${finding.message}`);
|
|
225
222
|
|
|
226
223
|
const maxLines = fm.maxLines ? Number(fm.maxLines) : null;
|
|
227
224
|
if (maxLines === null || Number.isNaN(maxLines)) {
|
|
@@ -241,7 +238,7 @@ export const inspectFile = async (filePath, today, root = ROOT) => {
|
|
|
241
238
|
}
|
|
242
239
|
}
|
|
243
240
|
|
|
244
|
-
return { path: rel, lineCount, frontmatter: fm, errors, warnings };
|
|
241
|
+
return { path: rel, lineCount, frontmatter: fm, spec, errors, warnings };
|
|
245
242
|
};
|
|
246
243
|
|
|
247
244
|
const formatRow = (row) => {
|
|
@@ -304,19 +301,16 @@ const formatIndexRow = (row) => {
|
|
|
304
301
|
return `| ${link} | ${fm.type ?? '—'} | ${row.lineCount}/${fm.maxLines ?? '—'} | ${fm.lastUpdated ?? '—'} | ${fm.staleAfter ?? '—'} |`;
|
|
305
302
|
};
|
|
306
303
|
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
//
|
|
304
|
+
// Two stores grow O(n) forever and would blow the index's own 80-line cap: the one-file-per-ADR store
|
|
305
|
+
// (docs/ai/adr/) and the spec store (docs/ai/specs/). Each is a GROUP that COLLAPSES to a single
|
|
306
|
+
// counted row — while walkMarkdownFiles still finds + cap-checks every individual body (only the
|
|
307
|
+
// index RENDERING is collapsed). Membership is by PROOF: a genuine ADR record / the adr navigator,
|
|
308
|
+
// or a spec-store file the shared reader accepts. Anything else under either dir (a stray README.md,
|
|
309
|
+
// a malformed spec) renders as its OWN visible row — never silently hidden by the collapse.
|
|
311
310
|
const ADR_DIR_PREFIX = 'docs/ai/adr/';
|
|
312
311
|
const ADR_RECORD_RE = /\/AD-(\d{3,})-[^/]*\.md$/;
|
|
313
312
|
const ADR_NAV_PATH = 'docs/ai/adr/log.md';
|
|
314
313
|
|
|
315
|
-
// Only genuine records + the navigator collapse into the aggregate row; an UNEXPECTED file under
|
|
316
|
-
// adr/ (a stray README.md, AD-foo.md) renders as its OWN visible index row — never silently hidden
|
|
317
|
-
// by the collapse (it also fails archive-decisions' own store-integrity check).
|
|
318
|
-
const isCollapsibleAdr = (path) => path.startsWith(ADR_DIR_PREFIX) && (ADR_RECORD_RE.test(path) || path === ADR_NAV_PATH);
|
|
319
|
-
|
|
320
314
|
const formatAdrCollapseRow = (adrRows) => {
|
|
321
315
|
const recs = adrRows
|
|
322
316
|
.map((r) => {
|
|
@@ -329,23 +323,37 @@ const formatAdrCollapseRow = (adrRows) => {
|
|
|
329
323
|
return `| [\`adr/\`](./adr/log.md) | adr | ${recs.length} records | ${range} | — |`;
|
|
330
324
|
};
|
|
331
325
|
|
|
332
|
-
//
|
|
333
|
-
|
|
334
|
-
|
|
326
|
+
// The live counts make the row change when a spec is added or removed, so --check-index sees it.
|
|
327
|
+
const formatSpecsCollapseRow = (specRows) => {
|
|
328
|
+
const count = (kind) => specRows.filter((r) => r.spec.kind === kind).length;
|
|
329
|
+
const link = `[\`${SPECS_COLLAPSE.label}\`](./${SPECS_COLLAPSE.navPath.replace(/^docs\/ai\//, '')})`;
|
|
330
|
+
return `| ${link} | ${SPECS_COLLAPSE.type} | ${count('spec')} specs | ${count('part')} parts · ${count('index')} indexes | — |`;
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
const COLLAPSE_GROUPS = [
|
|
334
|
+
{ sortPath: ADR_DIR_PREFIX, isMember: (r) => r.path.startsWith(ADR_DIR_PREFIX) && (ADR_RECORD_RE.test(r.path) || r.path === ADR_NAV_PATH), format: formatAdrCollapseRow },
|
|
335
|
+
{ sortPath: SPECS_COLLAPSE.prefix, isMember: (r) => Boolean(r.spec) && r.spec.errors.length === 0, format: formatSpecsCollapseRow },
|
|
336
|
+
];
|
|
337
|
+
|
|
338
|
+
// Pure index renderer — inspected rows + the header date → the exact bytes `docs/ai/index.md` should
|
|
339
|
+
// contain. Shared by `--write-index` (writes it) and `--check-index` (diffs against on-disk).
|
|
335
340
|
export const buildIndex = (rows, todayStr, meta = {}) => {
|
|
336
341
|
const projectName = meta.projectName ?? DEFAULT_PROJECT_NAME;
|
|
337
342
|
const onDemandLinks = meta.onDemandLinks ?? [];
|
|
338
343
|
const hierarchicalLinks = meta.hierarchicalLinks ?? [];
|
|
339
344
|
const header = INDEX_HEADER.replace('__TODAY__', todayStr).replace('__PROJECT__', projectName);
|
|
340
345
|
const tableHeader = `| File | Type | Lines/Max | Updated | Stale after |\n|------|------|-----------|---------|-------------|`;
|
|
341
|
-
const
|
|
342
|
-
const
|
|
346
|
+
const singles = [];
|
|
347
|
+
const grouped = COLLAPSE_GROUPS.map(() => []);
|
|
343
348
|
for (const r of rows) {
|
|
344
349
|
if (r.path === 'docs/ai/index.md') continue;
|
|
345
|
-
(
|
|
350
|
+
const group = COLLAPSE_GROUPS.findIndex((g) => g.isMember(r));
|
|
351
|
+
(group === -1 ? singles : grouped[group]).push(r);
|
|
346
352
|
}
|
|
347
|
-
const tableEntries =
|
|
348
|
-
|
|
353
|
+
const tableEntries = singles.map((r) => ({ sortPath: r.path, md: formatIndexRow(r) }));
|
|
354
|
+
COLLAPSE_GROUPS.forEach((g, i) => {
|
|
355
|
+
if (grouped[i].length > 0) tableEntries.push({ sortPath: g.sortPath, md: g.format(grouped[i]) });
|
|
356
|
+
});
|
|
349
357
|
tableEntries.sort((a, b) => a.sortPath.localeCompare(b.sortPath));
|
|
350
358
|
const tableRows = tableEntries.map((e) => e.md).join('\n');
|
|
351
359
|
const onDemandSection =
|
|
@@ -359,11 +367,8 @@ export const buildIndex = (rows, todayStr, meta = {}) => {
|
|
|
359
367
|
return `${header}${tableHeader}\n${tableRows}${onDemandSection}${hierarchicalSection}\n`;
|
|
360
368
|
};
|
|
361
369
|
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
// for the header, so a mere day-rollover (no content change) is NOT flagged —
|
|
365
|
-
// only genuine drift in the file table (added/removed files, changed
|
|
366
|
-
// type/cap/lastUpdated/staleAfter, or a changed line count) makes it stale.
|
|
370
|
+
// Freshness regenerates the index with the on-disk header's OWN `lastUpdated`, so a mere day-rollover
|
|
371
|
+
// is NOT flagged — only genuine drift in the file table makes it stale.
|
|
367
372
|
export const checkIndexFreshness = (rows, onDiskText, meta = {}) => {
|
|
368
373
|
if (onDiskText === null || onDiskText === undefined || onDiskText === '') {
|
|
369
374
|
return { fresh: false, expected: buildIndex(rows, 'unknown', meta) };
|
|
@@ -374,13 +379,11 @@ export const checkIndexFreshness = (rows, onDiskText, meta = {}) => {
|
|
|
374
379
|
return { fresh: expected === onDiskText, expected };
|
|
375
380
|
};
|
|
376
381
|
|
|
377
|
-
// The navigator
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
// the
|
|
381
|
-
//
|
|
382
|
-
// same discipline in atomic-write.mjs; this deployment script ships dependency-free, so the
|
|
383
|
-
// semantics are REIMPLEMENTED here rather than imported.
|
|
382
|
+
// The navigator write lands on the deployment's own file and nowhere else: every component of
|
|
383
|
+
// <root>/docs/ai/index.md is lstat'ed no-follow (a symlink anywhere REFUSES — publishing through one
|
|
384
|
+
// would clobber its target), the body goes out through an exclusive-create temp renamed into place
|
|
385
|
+
// with the chain re-checked before the rename, and the temp never survives a failure. The kit's
|
|
386
|
+
// atomic-write.mjs runs the same discipline; this script ships dependency-free, so it is REIMPLEMENTED.
|
|
384
387
|
export const INDEX_WRITE_REFUSED = 'INDEX_WRITE_REFUSED';
|
|
385
388
|
const refuse = (message) => Object.assign(new Error(message), { code: INDEX_WRITE_REFUSED });
|
|
386
389
|
|
|
@@ -444,11 +447,8 @@ const writeIndex = async (rows, today, meta, { root = ROOT, indexPath = INDEX_PA
|
|
|
444
447
|
}
|
|
445
448
|
};
|
|
446
449
|
|
|
447
|
-
// regenerateIndex(root, todayStr) — the ONE reused generator
|
|
448
|
-
//
|
|
449
|
-
// (default this deployment). The ADR-rotation hook reaches it via the CLI (`--write-index --root=…`);
|
|
450
|
-
// hermetic tests call it directly. `todayStr` is 'YYYY-MM-DD' (null → today). Returns the written
|
|
451
|
-
// index path + row count. No second index implementation exists.
|
|
450
|
+
// regenerateIndex(root, todayStr) — the ONE reused generator: the SAME walk → inspect → discoverMeta →
|
|
451
|
+
// writeIndex pipeline as `--write-index`, against `root`. `todayStr` is 'YYYY-MM-DD' (null → today).
|
|
452
452
|
export const regenerateIndex = async (root, todayStr = null, deps = {}) => {
|
|
453
453
|
const paths = pathsFor(root);
|
|
454
454
|
const today = computeToday(todayStr);
|
|
@@ -460,12 +460,10 @@ export const regenerateIndex = async (root, todayStr = null, deps = {}) => {
|
|
|
460
460
|
return { indexPath: paths.indexPath, files: rows.length };
|
|
461
461
|
};
|
|
462
462
|
|
|
463
|
-
// The finalizer promises
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
// whose target happens to hold current bytes would otherwise report `already-current` over a file
|
|
468
|
-
// this mode refuses to write through — an exit 0 proving nothing about the deployment's own file.
|
|
463
|
+
// The finalizer promises EXACTLY ONE outcome line, so every step it owns runs inside one classified
|
|
464
|
+
// error path: an unreadable docs/ai is a NAMED refusal, never a stack trace. The containment guard
|
|
465
|
+
// runs BEFORE the freshness read: a symlinked navigator whose target holds current bytes would
|
|
466
|
+
// otherwise report `already-current` over a file this mode refuses to write through.
|
|
469
467
|
const runEnsureIndex = async ({ root, docsDir, indexPath, today, deps }) => {
|
|
470
468
|
const lstat = deps.lstat ?? lstatSync;
|
|
471
469
|
const read = deps.readFile ?? readFile;
|
|
@@ -571,9 +569,8 @@ export const runCli = async (argv, deps = {}) => {
|
|
|
571
569
|
return result(errorCount > 0 && !flags.report ? 1 : 0);
|
|
572
570
|
};
|
|
573
571
|
|
|
574
|
-
// Run main() only when executed directly, never on import
|
|
575
|
-
// reached through a symlink
|
|
576
|
-
// different and the CLI never runs. realpathSync collapses the link so both sides match.
|
|
572
|
+
// Run main() only when executed directly, never on import — compared by REAL path, so an entry point
|
|
573
|
+
// reached through a symlink still runs.
|
|
577
574
|
const isDirectRun = (() => {
|
|
578
575
|
const invoked = process.argv[1];
|
|
579
576
|
if (!invoked) return false;
|
|
@@ -178,11 +178,7 @@ describe('buildIndex — docs/ai/adr/ directory collapse (Decision 11)', () => {
|
|
|
178
178
|
});
|
|
179
179
|
|
|
180
180
|
it('a stray adr/ markdown file renders as its OWN visible row (never collapsed/hidden)', () => {
|
|
181
|
-
const rows = [
|
|
182
|
-
makeRow('docs/ai/adr/AD-001-a.md', { frontmatter: { type: 'adr', maxLines: '400' } }),
|
|
183
|
-
makeRow('docs/ai/adr/log.md', { frontmatter: { type: 'reference', maxLines: '200' } }),
|
|
184
|
-
makeRow('docs/ai/adr/notes.md', { frontmatter: { type: 'reference', maxLines: '100' } }),
|
|
185
|
-
];
|
|
181
|
+
const rows = [makeRow('docs/ai/adr/AD-001-a.md', { frontmatter: { type: 'adr', maxLines: '400' } }), makeRow('docs/ai/adr/log.md', { frontmatter: { type: 'reference', maxLines: '200' } }), makeRow('docs/ai/adr/notes.md', { frontmatter: { type: 'reference', maxLines: '100' } })];
|
|
186
182
|
const out = buildIndex(rows, '2026-07-09');
|
|
187
183
|
expect(out).toMatch(/adr\/notes\.md/); // the stray is a visible row, not swallowed by the collapse
|
|
188
184
|
const aggregateRows = out.split('\n').filter((l) => l.includes('](./adr/log.md)'));
|
|
@@ -190,11 +186,7 @@ describe('buildIndex — docs/ai/adr/ directory collapse (Decision 11)', () => {
|
|
|
190
186
|
});
|
|
191
187
|
|
|
192
188
|
it('the aggregate row shows the record count and a NUMERIC id range (AD-200 … AD-1000)', () => {
|
|
193
|
-
const rows = [
|
|
194
|
-
makeRow('docs/ai/adr/AD-200-a.md', { frontmatter: { type: 'adr', maxLines: '400' } }),
|
|
195
|
-
makeRow('docs/ai/adr/AD-1000-b.md', { frontmatter: { type: 'adr', maxLines: '400' } }),
|
|
196
|
-
makeRow('docs/ai/adr/log.md', { frontmatter: { type: 'reference', maxLines: '200' } }),
|
|
197
|
-
];
|
|
189
|
+
const rows = [makeRow('docs/ai/adr/AD-200-a.md', { frontmatter: { type: 'adr', maxLines: '400' } }), makeRow('docs/ai/adr/AD-1000-b.md', { frontmatter: { type: 'adr', maxLines: '400' } }), makeRow('docs/ai/adr/log.md', { frontmatter: { type: 'reference', maxLines: '200' } })];
|
|
198
190
|
const out = buildIndex(rows, '2026-07-09');
|
|
199
191
|
expect(out).toMatch(/\[`adr\/`\]\(\.\/adr\/log\.md\) \| adr \| 2 records \| AD-200 … AD-1000/);
|
|
200
192
|
});
|
|
@@ -219,13 +211,70 @@ describe('buildIndex — docs/ai/adr/ directory collapse (Decision 11)', () => {
|
|
|
219
211
|
});
|
|
220
212
|
});
|
|
221
213
|
|
|
214
|
+
// The spec store (docs/ai/specs/**) joins the collapse as a second GROUP: every file whose reader
|
|
215
|
+
// verdict is clean folds into ONE counted `specs/` row; a file the reader refuses keeps its own row.
|
|
216
|
+
const SPEC_FRONT = (kind, maxLines, extra = '') =>
|
|
217
|
+
`---\ntype: spec\nlastUpdated: 2026-08-23\nscope: permanent\nstaleAfter: 90d\nowner: none\nmaxLines: ${maxLines}\nkind: ${kind}\n${extra}---\n`;
|
|
218
|
+
const specBody = (slug) =>
|
|
219
|
+
`\n# Spec: ${slug}\n\n## Contract\n\nc\n\n## Scenarios\n\n- S1 a :: test/${slug}.test.mjs :: spec:${slug}/S1\n\n## Out of scope\n\n- b\n\n## Module\n\n- src/${slug}/\n`;
|
|
220
|
+
const specRow = (path, kind, errors = []) => makeRow(path, { frontmatter: { type: 'spec', maxLines: '150' }, spec: { kind, status: 'draft', revision: 1, errors, warnings: [] } });
|
|
221
|
+
const SPECS_ROW_RE = /\| \[`specs\/`\]\(\.\/specs\/index\.md\) \| spec \| (\d+) specs \| (\d+) parts · (\d+) indexes \| — \|/;
|
|
222
|
+
|
|
223
|
+
describe('buildIndex — docs/ai/specs/ store collapse (spec layer 1a)', () => {
|
|
224
|
+
it('1000 valid specs + their indexes render ONE counted specs/ row; the index stays <= 80 lines', () => {
|
|
225
|
+
const specs = Array.from({ length: 1000 }, (_, i) => specRow(`docs/ai/specs/d${Math.floor(i / 30)}/s${i}.md`, 'spec'));
|
|
226
|
+
const indexes = Array.from({ length: 34 }, (_, i) => specRow(`docs/ai/specs/d${i}/index.md`, 'index'));
|
|
227
|
+
const rows = [makeRow('docs/ai/handover.md'), specRow('docs/ai/specs/index.md', 'index'), ...indexes, ...specs];
|
|
228
|
+
const out = buildIndex(rows, '2026-08-23');
|
|
229
|
+
const lines = out.split('\n');
|
|
230
|
+
expect(lines.filter((l) => l.includes('](./specs/index.md)')).length).toBe(1);
|
|
231
|
+
expect(out).not.toMatch(/s999\.md/);
|
|
232
|
+
expect(lines.length <= 80).toBe(true);
|
|
233
|
+
expect(out.match(SPECS_ROW_RE).slice(1)).toEqual(['1000', '0', '35']);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('the counted row carries live counts per kind; a malformed file under specs/ keeps its OWN visible row beside it', () => {
|
|
237
|
+
const rows = [specRow('docs/ai/specs/index.md', 'index'), specRow('docs/ai/specs/a/index.md', 'spec'), specRow('docs/ai/specs/a/p.md', 'part'), specRow('docs/ai/specs/b.md', 'spec'), specRow('docs/ai/specs/broken.md', null, [{ rule: 'kind', message: 'x' }])];
|
|
238
|
+
const out = buildIndex(rows, '2026-08-23');
|
|
239
|
+
expect(out).toMatch(/specs\/broken\.md/);
|
|
240
|
+
expect(out.match(SPECS_ROW_RE).slice(1)).toEqual(['2', '1', '1']);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('adding or removing one valid spec drifts the counted row -> the on-disk index is stale', () => {
|
|
244
|
+
const base = [specRow('docs/ai/specs/index.md', 'index'), specRow('docs/ai/specs/a.md', 'spec')];
|
|
245
|
+
const onDisk = buildIndex(base, '2026-08-23');
|
|
246
|
+
expect(checkIndexFreshness([...base, specRow('docs/ai/specs/b.md', 'spec')], onDisk).fresh).toBe(false);
|
|
247
|
+
expect(checkIndexFreshness(base.slice(0, 1), onDisk).fresh).toBe(false);
|
|
248
|
+
expect(checkIndexFreshness(base, onDisk).fresh).toBe(true);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('inspectFile reads a file under docs/ai/specs/ through the reader: clean -> spec verdict, malformed -> advisory warnings only', async () => {
|
|
252
|
+
const root = await mkdtemp(join(tmpdir(), 'specs-inspect-'));
|
|
253
|
+
try {
|
|
254
|
+
await mkdir(join(root, 'docs', 'ai', 'specs'), { recursive: true });
|
|
255
|
+
const clean = join(root, 'docs', 'ai', 'specs', 'login.md');
|
|
256
|
+
await writeFile(clean, `${SPEC_FRONT('spec', 150, 'status: draft\nrevision: 1\n')}${specBody('login')}`);
|
|
257
|
+
const ok = await inspectFile(clean, computeToday('2026-08-23'), root);
|
|
258
|
+
expect(ok.errors).toEqual([]);
|
|
259
|
+
expect(ok.spec.kind).toBe('spec');
|
|
260
|
+
expect(ok.spec.errors).toEqual([]);
|
|
261
|
+
const malformed = join(root, 'docs', 'ai', 'specs', 'broken.md');
|
|
262
|
+
await writeFile(malformed, `${SPEC_FRONT('spec', 150, 'status: draft\nrevision: 1\n')}${specBody('login')}`);
|
|
263
|
+
const bad = await inspectFile(malformed, computeToday('2026-08-23'), root);
|
|
264
|
+
expect(bad.errors).toEqual([]);
|
|
265
|
+
expect(bad.spec.errors.map((e) => e.rule)).toEqual(['scenario-marker']);
|
|
266
|
+
expect(bad.warnings.some((w) => /spec scenario-marker/.test(w))).toBe(true);
|
|
267
|
+
await writeFile(join(root, 'docs', 'ai', 'handover.md'), `${SPEC_FRONT('spec', 150)}\n# outside the store\n`);
|
|
268
|
+
expect((await inspectFile(join(root, 'docs', 'ai', 'handover.md'), computeToday('2026-08-23'), root)).spec).toBe(null);
|
|
269
|
+
} finally {
|
|
270
|
+
await rm(root, { recursive: true, force: true });
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
222
275
|
describe('buildIndex', () => {
|
|
223
276
|
it('is deterministic, sorts rows by path, and excludes index.md itself', () => {
|
|
224
|
-
const rows = [
|
|
225
|
-
makeRow('docs/ai/index.md'),
|
|
226
|
-
makeRow('docs/ai/b.md'),
|
|
227
|
-
makeRow('docs/ai/a.md'),
|
|
228
|
-
];
|
|
277
|
+
const rows = [makeRow('docs/ai/index.md'), makeRow('docs/ai/b.md'), makeRow('docs/ai/a.md')];
|
|
229
278
|
const out = buildIndex(rows, '2026-05-29');
|
|
230
279
|
expect(out).toBe(buildIndex(rows, '2026-05-29')); // deterministic
|
|
231
280
|
expect(out).not.toMatch(/\[`index\.md`\]/); // index.md row excluded
|
|
@@ -328,4 +377,19 @@ describe('root parameterization (item (h))', () => {
|
|
|
328
377
|
expect(stale.status).toBe(1);
|
|
329
378
|
expect(stale.stderr).toMatch(/stale/);
|
|
330
379
|
});
|
|
380
|
+
|
|
381
|
+
it('the hook path over a real specs/ store: ONE counted row in the written index, a new spec makes --check-index exit 1', async () => {
|
|
382
|
+
const store = join(root, 'docs', 'ai', 'specs');
|
|
383
|
+
await mkdir(store, { recursive: true });
|
|
384
|
+
await writeFile(join(store, 'index.md'), `${SPEC_FRONT('index', 80)}\n# Specs\n\n> Up: [technical_specification.md](../technical_specification.md)\n\n## Children\n\n- [login](./login.md)\n`);
|
|
385
|
+
await writeFile(join(store, 'login.md'), `${SPEC_FRONT('spec', 150, 'status: draft\nrevision: 1\n')}${specBody('login')}`);
|
|
386
|
+
expect(runCli(['--write-index', `--root=${root}`]).status).toBe(0);
|
|
387
|
+
const index = await readFile(join(root, 'docs', 'ai', 'index.md'), 'utf8');
|
|
388
|
+
expect(index.match(SPECS_ROW_RE).slice(1)).toEqual(['1', '0', '1']);
|
|
389
|
+
expect(index).not.toMatch(/specs\/login\.md/);
|
|
390
|
+
expect(runCli(['--check-index', `--root=${root}`]).status).toBe(0);
|
|
391
|
+
await writeFile(join(store, 'signup.md'), `${SPEC_FRONT('spec', 150, 'status: draft\nrevision: 1\n')}${specBody('signup')}`);
|
|
392
|
+
expect(runCli(['--check-index', `--root=${root}`]).status).toBe(1);
|
|
393
|
+
expect(runCli([`--root=${root}`]).status).toBe(0);
|
|
394
|
+
});
|
|
331
395
|
});
|
|
@@ -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)
|
|
@@ -23,12 +23,12 @@ Read in order, then confirm before starting:
|
|
|
23
23
|
4. Confirm with the user: *"I'm taking task X. Confirm?"*
|
|
24
24
|
|
|
25
25
|
### 1.2. During Work
|
|
26
|
-
**Before any feature:**
|
|
26
|
+
**Before any feature:** name its governing spec(s) — the feature spec under `docs/ai/specs/` for every touched spec-covered slice (zero is legal during adoption; each cited spec's Out of scope bounds that slice's work). Where only a page spec (`docs/ai/pages/<page>.md`) exists it governs as an ADOPTION SHIM: state Out of scope + Revision inline in the plan. If the contract changes, the spec revision is authored WITH the plan (visible at review) and lands with the code, so docs and code never diverge.
|
|
27
27
|
|
|
28
28
|
**For every code change:**
|
|
29
29
|
1. Grep for similar implementations — reuse existing patterns.
|
|
30
30
|
2. Check the design-system layer for an existing component; if missing, add it there FIRST, then use it.
|
|
31
|
-
3. Verify changes align with `docs/ai/pages/<page>.md`;
|
|
31
|
+
3. Verify changes align with the governing spec(s) and `docs/ai/pages/<page>.md`; a new feature gets a `draft` spec (the skill's `SPEC_TEMPLATE.md`), a new page a full page spec.
|
|
32
32
|
4. Follow §2 (Self-Review): functional style, named exports, full variable names, no magic literals.
|
|
33
33
|
5. Write/update tests FIRST (TDD): unit for pure functions, E2E for user flows.
|
|
34
34
|
6. Run quality checks: lint, type-check, tests.
|
|
@@ -38,7 +38,7 @@ Read in order, then confirm before starting:
|
|
|
38
38
|
|
|
39
39
|
Before claiming "done":
|
|
40
40
|
1. Run all quality gates (lint + type-check + tests) — all green.
|
|
41
|
-
2. Update docs: `current_state.md` (feature ready), `changelog.md` (entry), `handover.md` (**REPLACE** the last-session block — session delta, never append; older deltas live in `changelog.md` → `history/`), `pages/<page>.md` (matches implementation). Only bump "Last Updated" when content actually changed.
|
|
41
|
+
2. Update docs: `current_state.md` (feature ready), `changelog.md` (entry), `handover.md` (**REPLACE** the last-session block — session delta, never append; older deltas live in `changelog.md` → `history/`), `pages/<page>.md` (matches implementation), the governing spec (its revision lands with the code: `draft` -> `live` on the landing row). Only bump "Last Updated" when content actually changed.
|
|
42
42
|
3. Run the docs cap-validator + index-freshness gate (pre-commit also enforces). On failure: trim the offending file, or run the changelog rotation if the offender is `changelog.md`.
|
|
43
43
|
4. If the work executed a plan file — complete it per the project's **planning skill / methodology** (the `workflow:methodology` slot in `AGENTS.md` points to it; see §5). Without completing that step the plan is not done. (The methodology, including any mandatory final cleanup, is defined there — not in this substrate.)
|
|
44
44
|
5. **Ask before committing** (§4): report lint / type-check / test counts + docs status, then wait for explicit approval. DO NOT auto-commit.
|
|
@@ -80,6 +80,7 @@ Apply these when authoring a plan, reviewing, folding a finding, or editing code
|
|
|
80
80
|
- **Fold by code, not prose.** Before folding a code-touching finding into a plan or change, read the cited `file:line` and cite it — a prose fold drifts from the code and seeds the next bug.
|
|
81
81
|
- **Finding scope (plan-execution) — name the invariant BEFORE the edit.** During EXECUTION only — a plan under authoring has no shipped behaviour to call a live defect in, so plan-review carries none of this. Every finding names the invariant its fix would enforce, and where that invariant already lives decides the disposition: already an acceptance criterion of the phase → **fold here**; it would have to be ADDED → ship the **narrow fix** for the found site (red first, then green) and queue ONLY the generalization — a deferral row carries the invariant, the origin `file:line`, the narrow fix, its proof and a residual exposure declared NOT live; no correct narrow fix → **blocking**: the phase does not close, and it is **never queued**. Two bars declared before each round: a finding counts only if it changes a **WRITE/REMOVE decision** or is a false statement in shipped text; a repeat finding in one subarea **routes to SUBTRACTION**, not a fourth patch.
|
|
82
82
|
- **Right altitude.** Pin intent + invariants + acceptance criteria (named tests); leave fine code-mechanics to Execute, where prose cannot diverge from reality.
|
|
83
|
+
- **Spec-first.** A plan names its GOVERNING spec(s) — zero, one or many, one per touched spec-covered slice (the feature spec under `docs/ai/specs/`; page-only coverage governs as an ADOPTION SHIM, with Out of scope + Revision stated inline in the plan). Each cited spec's Out of scope bounds that slice's work and the plan's non-goals restate it per slice — no global union; a cross-spec conflict is resolved by a spec revision BEFORE approval, never by silent precedence. A NEW feature's draft spec exists AT plan review (a `create` row); a change to a governed contract rides the plan as its proposed revision (a `modify` row); approval confirms plan and contract atomically, and the revision lands with the code. Scenario bindings are per scenario: a new scenario is `unbound` until its test lands in the same plan, and a status never regresses for an extension.
|
|
83
84
|
- **No code-mechanics in the plan.** A ledger row carries its path and anchor, and Verification carries the exact commands (the plan-shape canon) — checked syntax: the plan's own Verification runs them against an explicit expected outcome or gate; the only other syntax a plan may carry is a literal fixture/schema fragment a named test copies or validates. Un-run, logic-bearing syntax — control-flow, a regex, a glob, a grammar, an algorithm body, a mini-DSL — never lives in plan prose, however plausible or shell-verified it looks: a fold or draft that wants one is the trigger to write the test instead.
|
|
84
85
|
- **Test-as-spec.** Fold a code-touching finding into a red→green TEST, not a prose paragraph — the gate is the only deterministic checker; a paragraph cannot self-check.
|
|
85
86
|
- **Characterize-first.** Before editing UNCOVERED code, pin its current behavior in a green test, then edit — any unintended change goes red. Never edit what has no checker; first give it one. Keep edits atomic/reversible; prefer SUBTRACTIVE folds.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: spec
|
|
3
|
+
lastUpdated: {{DATE}}
|
|
4
|
+
scope: permanent
|
|
5
|
+
staleAfter: 90d
|
|
6
|
+
owner: none
|
|
7
|
+
maxLines: 80
|
|
8
|
+
kind: index
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Specs
|
|
12
|
+
|
|
13
|
+
> Up: [technical_specification.md](../technical_specification.md)
|
|
14
|
+
|
|
15
|
+
The top spec is the system root; this store holds the per-feature contracts under it. Every folder's
|
|
16
|
+
`index.md` lists ONLY its immediate children (at most 30); a spec over 150 lines is promoted to
|
|
17
|
+
`<slug>/index.md` + parts. Author a new spec from the skill's `SPEC_TEMPLATE.md`; the canon is the
|
|
18
|
+
engine's `references/specs.md`.
|
|
19
|
+
|
|
20
|
+
## Children
|
|
21
|
+
|