create-cadmo 0.2.0 → 0.4.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/README.md +1 -1
- package/index.js +3 -1
- package/package.json +2 -2
- package/templates/AGENTS.md +4 -3
- package/templates/cadmo-grammar.mjs +88 -0
- package/templates/claude-commands/cadmo-done.md +4 -1
- package/templates/claude-commands/cadmo-gate.md +3 -0
- package/templates/claude-commands/cadmo-spec.md +3 -0
- package/templates/plan.md +26 -21
- package/templates/spec-drift-workflow.yml +27 -4
- package/templates/spec-drift.mjs +172 -55
- package/templates/spec.md +38 -24
package/README.md
CHANGED
|
@@ -14,6 +14,6 @@ Drops into the current directory, never overwriting anything:
|
|
|
14
14
|
- `cadmo/plan.md` — the internal route: tasks, constraints, how to validate
|
|
15
15
|
- `cadmo/decision.md` — architecture decisions with a "when to reconsider" trigger
|
|
16
16
|
- `cadmo/spec-drift.mjs` + a ready CI workflow — **specs must change with the code they watch** (the drift guard)
|
|
17
|
-
- `.claude/commands/cadmo-*.md` — `/cadmo-gate`, `/cadmo-spec`, `/cadmo-done` slash commands (when Claude Code is detected, or `--claude`)
|
|
17
|
+
- `.claude/commands/cadmo-*.md` — `/cadmo-gate`, `/cadmo-spec`, `/cadmo-done` slash commands (when Claude Code is detected, or `--claude`). **Pick one source for the commands:** if you install the [Cadmo plugin](https://github.com/tiagotorres91/cadmo) (`/plugin install cadmo@cadmo`, which provides `/cadmo:gate` etc.), run the scaffolder with `--no-claude` — otherwise you'll have both spellings.
|
|
18
18
|
|
|
19
19
|
The method, docs and protocols: **https://github.com/tiagotorres91/cadmo**
|
package/index.js
CHANGED
|
@@ -29,6 +29,7 @@ if (args.includes('--help') || args.includes('-h')) {
|
|
|
29
29
|
cadmo/plan.md the internal route: tasks, constraints, validation
|
|
30
30
|
cadmo/decision.md ADR with a "when to reconsider" trigger
|
|
31
31
|
cadmo/spec-drift.mjs the drift guard: specs must change with the code they watch
|
|
32
|
+
cadmo/cadmo-grammar.mjs the shared front-matter/glob grammar the guard imports
|
|
32
33
|
cadmo/spec-drift-workflow.yml ready CI workflow (move into .github/workflows/)
|
|
33
34
|
.claude/commands/cadmo-*.md (when Claude Code is detected, or --claude)
|
|
34
35
|
|
|
@@ -46,6 +47,7 @@ const FILES = [
|
|
|
46
47
|
['spec.md', path.join('cadmo', 'spec.md')],
|
|
47
48
|
['plan.md', path.join('cadmo', 'plan.md')],
|
|
48
49
|
['decision.md', path.join('cadmo', 'decision.md')],
|
|
50
|
+
['cadmo-grammar.mjs', path.join('cadmo', 'cadmo-grammar.mjs')],
|
|
49
51
|
['spec-drift.mjs', path.join('cadmo', 'spec-drift.mjs')],
|
|
50
52
|
['spec-drift-workflow.yml', path.join('cadmo', 'spec-drift-workflow.yml')],
|
|
51
53
|
];
|
|
@@ -83,7 +85,7 @@ for (const [src, dest] of plan) {
|
|
|
83
85
|
|
|
84
86
|
console.log(`\n ${placed} file(s) ${DRY ? 'would be created' : 'created'}, ${skipped} kept.\n`);
|
|
85
87
|
if (!hasClaude && !NO_CLAUDE) {
|
|
86
|
-
console.log(' (No CLAUDE.md/.claude detected — run
|
|
88
|
+
console.log(' (No CLAUDE.md/.claude folder detected — run: npm create cadmo -- --claude (the -- matters: npm swallows flags without it) to also install the /cadmo-* slash commands.)');
|
|
87
89
|
}
|
|
88
90
|
console.log(' Next steps:');
|
|
89
91
|
console.log(' 1. Fill in AGENTS.md — the local map your AI pair reads first');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-cadmo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Scaffold the Cadmo method into your project \u2014 a right-sized method for building software with AI as your pair.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cadmo",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"create-cadmo": "index.js"
|
|
30
30
|
},
|
|
31
31
|
"engines": {
|
|
32
|
-
"node": ">=
|
|
32
|
+
"node": ">=18"
|
|
33
33
|
},
|
|
34
34
|
"scripts": {
|
|
35
35
|
"test": "node --test"
|
package/templates/AGENTS.md
CHANGED
|
@@ -15,17 +15,18 @@
|
|
|
15
15
|
## Where things live
|
|
16
16
|
- Specs / decisions: <path>
|
|
17
17
|
- Plans: <path>
|
|
18
|
-
- Demands / task board: <where>
|
|
18
|
+
- Demands / task board: <where — GitHub issues works; "none yet" is honest>
|
|
19
19
|
|
|
20
20
|
## How to verify (the harness — run before saying "done")
|
|
21
21
|
- Build/type-check: `<command>`
|
|
22
22
|
- Tests: `<command>`
|
|
23
23
|
- End-to-end / smoke: `<command>`
|
|
24
|
-
- Staging URL: <url> · Production: <url>
|
|
24
|
+
- Staging URL: <url — or "none: ships as a package / binary / store build"> · Production: <url or delivery channel>
|
|
25
25
|
|
|
26
26
|
## Gates
|
|
27
27
|
- Plan approved before building; production ships only with explicit human approval.
|
|
28
28
|
- <any project-specific rule or gotcha>
|
|
29
29
|
|
|
30
30
|
## The method
|
|
31
|
-
Opening choreography, definition of done, the layers — see the Cadmo method
|
|
31
|
+
Opening choreography, definition of done, the layers — see the Cadmo method:
|
|
32
|
+
https://github.com/tiagotorres91/cadmo/blob/main/docs/method.md
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// cadmo-grammar — THE single grammar for Cadmo front matter and watch-globs.
|
|
2
|
+
// Every consumer (spec-drift, cadmo-score, the plugin's Stop hook, the scaffolded
|
|
3
|
+
// copy) uses THIS logic; the copies are byte-identical and guarded by
|
|
4
|
+
// scripts/check-template-sync.sh. Round-4 review found three divergent
|
|
5
|
+
// reimplementations (one carrying an already-fixed globstar bug) — hence this file.
|
|
6
|
+
//
|
|
7
|
+
// Accepted `watches:` forms (all equivalent):
|
|
8
|
+
// watches: | watches: | watches: [a/**, b.py] | watches: a/**
|
|
9
|
+
// - a/** | - a/** |
|
|
10
|
+
// - b.py | - b.py |
|
|
11
|
+
// (indented block, unindented block, inline array, single inline value)
|
|
12
|
+
|
|
13
|
+
export function frontMatter(text) {
|
|
14
|
+
if (!text.startsWith('---')) return null;
|
|
15
|
+
const end = text.indexOf('\n---', 3);
|
|
16
|
+
if (end < 0) return null;
|
|
17
|
+
return { fm: text.slice(3, end), end };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function unquote(s) {
|
|
21
|
+
return s.trim().replace(/^["']|["']$/g, '');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function parseWatches(text) {
|
|
25
|
+
const parsed = frontMatter(text);
|
|
26
|
+
if (!parsed) return null;
|
|
27
|
+
const lines = parsed.fm.split(/\r?\n/);
|
|
28
|
+
const watches = [];
|
|
29
|
+
let inWatches = false;
|
|
30
|
+
for (const line of lines) {
|
|
31
|
+
const head = line.match(/^watches:\s*(.*)$/);
|
|
32
|
+
if (head) {
|
|
33
|
+
inWatches = true;
|
|
34
|
+
const inline = head[1].trim();
|
|
35
|
+
if (inline.startsWith('[')) {
|
|
36
|
+
for (const part of inline.replace(/^\[|\]\s*$/g, '').split(',')) {
|
|
37
|
+
const v = unquote(part);
|
|
38
|
+
if (v) watches.push(v);
|
|
39
|
+
}
|
|
40
|
+
inWatches = false; // inline array is complete on this line
|
|
41
|
+
} else if (inline) {
|
|
42
|
+
watches.push(unquote(inline)); // single inline value
|
|
43
|
+
inWatches = false;
|
|
44
|
+
}
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (inWatches) {
|
|
48
|
+
const item = line.match(/^\s*-\s+(.+?)\s*$/); // indented OR unindented list item
|
|
49
|
+
if (item) watches.push(unquote(item[1]));
|
|
50
|
+
else if (line.trim() !== '') inWatches = false; // next key ends the block
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return watches.length ? watches : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function parseReviewed(text) {
|
|
57
|
+
const parsed = frontMatter(text);
|
|
58
|
+
if (!parsed) return null;
|
|
59
|
+
for (const line of parsed.fm.split(/\r?\n/)) {
|
|
60
|
+
const m = line.match(/^reviewed:\s*(\S+)\s*$/);
|
|
61
|
+
if (m) return m[1];
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Glob -> RegExp. `*` matches within one path segment; `**` matches zero or more
|
|
67
|
+
// segments (gitignore semantics: src/**/auth.js matches src/auth.js).
|
|
68
|
+
// Zero-backslash implementation (escaping via char classes / fromCharCode).
|
|
69
|
+
const BS = String.fromCharCode(92);
|
|
70
|
+
export function globToRegex(glob) {
|
|
71
|
+
let esc = '';
|
|
72
|
+
for (const ch of glob) {
|
|
73
|
+
if (ch === '*' || ch === '/') esc += ch;
|
|
74
|
+
else if ('.+^$(){}|[]?'.includes(ch) || ch === BS) esc += BS + ch;
|
|
75
|
+
else esc += ch;
|
|
76
|
+
}
|
|
77
|
+
esc = esc.split('**').join('__DSTAR__');
|
|
78
|
+
esc = esc.split('*').join('[^/]*');
|
|
79
|
+
esc = esc.split('/__DSTAR__/').join('/(?:.*/)?'); // a/**/b -> a/b and a/x/y/b
|
|
80
|
+
esc = esc.split('__DSTAR__/').join('(?:.*/)?'); // **/b -> b and x/b
|
|
81
|
+
esc = esc.split('/__DSTAR__').join('(?:/.*)?'); // a/** -> a and a/x
|
|
82
|
+
esc = esc.split('__DSTAR__').join('.*'); // bare **
|
|
83
|
+
return new RegExp('^' + esc + '$');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function anyMatch(watches, file) {
|
|
87
|
+
return watches.some((w) => globToRegex(w).test(file));
|
|
88
|
+
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Run the definition of done honestly — checked against evidence (run the commands), not memory
|
|
3
|
+
---
|
|
1
4
|
# /cadmo-done — the definition of done, checked honestly
|
|
2
5
|
|
|
3
6
|
Run the definition of done for: **$ARGUMENTS** (or the work we just finished).
|
|
@@ -8,7 +11,7 @@ Check each item **against evidence, not memory** — run the commands, don't ass
|
|
|
8
11
|
- [ ] **Tested functionally** — the real flow (browser, curl, CLI run), not just linters
|
|
9
12
|
- [ ] Validated in staging before production (when there is one)
|
|
10
13
|
- [ ] Acceptance criteria from the spec/plan — checked one by one, quoted
|
|
11
|
-
- [ ] Spec updated if a rule changed (same commit) — run `node cadmo/spec-drift.mjs --base
|
|
14
|
+
- [ ] Spec updated if a rule changed (same commit) — run `node cadmo/spec-drift.mjs` if present (no --base needed: it auto-resolves, remote or not)
|
|
12
15
|
- [ ] Anything untested? Say it explicitly: "I didn't test X"
|
|
13
16
|
- [ ] Touched auth/input/data/secrets? → flag that an adversarial security pass is due before shipping
|
|
14
17
|
|
package/templates/plan.md
CHANGED
|
@@ -1,21 +1,26 @@
|
|
|
1
|
-
# <Demand name> — plan (internal)
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
## Objective
|
|
6
|
-
What changes in the world when this is done (1–3 lines).
|
|
7
|
-
|
|
8
|
-
##
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
1
|
+
# <Demand name> — plan (internal)
|
|
2
|
+
|
|
3
|
+
For new-and-relevant work this pairs with `spec.md`; for a standard 3+ step fix it stands alone.
|
|
4
|
+
|
|
5
|
+
## Objective
|
|
6
|
+
What changes in the world when this is done (1–3 lines).
|
|
7
|
+
|
|
8
|
+
## Acceptance criteria
|
|
9
|
+
- [ ] Observable and specific ("login returns a 24h JWT", not "user logs in")
|
|
10
|
+
- [ ] If there IS a separate `spec.md`, the criteria live THERE — don't duplicate; delete this section.
|
|
11
|
+
If this plan stands alone (no spec), this is their home — criteria before code, always.
|
|
12
|
+
|
|
13
|
+
## Investigation (optional)
|
|
14
|
+
What was tried/considered before and why it was discarded (3–6 lines) — avoids re-investigating dead ends.
|
|
15
|
+
|
|
16
|
+
## Context & constraints
|
|
17
|
+
Current state, decisions already made, what NOT to change.
|
|
18
|
+
|
|
19
|
+
## Technical plan
|
|
20
|
+
Files/tables/endpoints touched; approach; alternatives discarded.
|
|
21
|
+
|
|
22
|
+
## Tasks
|
|
23
|
+
- [ ] Small, verifiable (status kept here — any session resumes without getting lost)
|
|
24
|
+
|
|
25
|
+
## How to validate
|
|
26
|
+
Commands/flows that prove the acceptance criteria (build, tests, e2e, real manual flow).
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
# Example workflow — copy to .github/workflows/spec-drift.yml in your project.
|
|
2
|
-
# Fails
|
|
3
|
-
#
|
|
2
|
+
# Fails when a file watched by a spec changes and the spec doesn't change with it.
|
|
3
|
+
# Works on pull_request AND on push (so it fires even if you ship straight to main).
|
|
4
|
+
# Audited escape hatch (never silent): "spec-drift: skip -- <reason>" in the PR body
|
|
5
|
+
# (pull_request runs) or in the latest commit message (push runs).
|
|
4
6
|
name: spec-drift
|
|
5
7
|
on:
|
|
6
8
|
pull_request:
|
|
9
|
+
push:
|
|
10
|
+
branches: [main]
|
|
7
11
|
jobs:
|
|
8
12
|
drift:
|
|
9
|
-
if: "${{ !contains(github.event.pull_request.body, 'spec-drift: skip') }}"
|
|
10
13
|
runs-on: ubuntu-latest
|
|
11
14
|
steps:
|
|
12
15
|
- uses: actions/checkout@v4
|
|
@@ -16,4 +19,24 @@ jobs:
|
|
|
16
19
|
with:
|
|
17
20
|
node-version: "20"
|
|
18
21
|
- name: specs must change with the code they govern
|
|
19
|
-
|
|
22
|
+
env:
|
|
23
|
+
# env var, never inline interpolation — a PR body is attacker-controlled text
|
|
24
|
+
PR_BODY: ${{ github.event.pull_request.body }}
|
|
25
|
+
run: |
|
|
26
|
+
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
|
27
|
+
if printf '%s' "$PR_BODY" | grep -qi 'spec-drift: skip'; then
|
|
28
|
+
echo "spec-drift: skip found in the PR body — skipping."
|
|
29
|
+
exit 0
|
|
30
|
+
fi
|
|
31
|
+
BASE="origin/${{ github.base_ref }}"
|
|
32
|
+
else
|
|
33
|
+
if git log -1 --format=%B | grep -qi 'spec-drift: skip'; then
|
|
34
|
+
echo "spec-drift: skip found in commit message — skipping."
|
|
35
|
+
exit 0
|
|
36
|
+
fi
|
|
37
|
+
BASE="${{ github.event.before }}"
|
|
38
|
+
# first push of a branch/repo: event.before is all-zeros -> last commit -> empty tree
|
|
39
|
+
git cat-file -e "${BASE}^{commit}" 2>/dev/null \
|
|
40
|
+
|| BASE="$(git rev-parse HEAD~1 2>/dev/null || echo 4b825dc642cb6eb9a060e54bf8d69288fbee4904)"
|
|
41
|
+
fi
|
|
42
|
+
node cadmo/spec-drift.mjs --base "$BASE"
|
package/templates/spec-drift.mjs
CHANGED
|
@@ -1,39 +1,86 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/*
|
|
3
|
-
* spec-drift — the rule that keeps documentation honest
|
|
4
|
-
* a spec declares which files implement it; if those files change and the spec
|
|
5
|
-
* doesn't change in the same diff, the check fails.
|
|
3
|
+
* spec-drift — the rule that keeps documentation honest.
|
|
6
4
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
5
|
+
* Two layers, both zero-dependency:
|
|
6
|
+
*
|
|
7
|
+
* DRIFT (v1): a spec declares which files implement it (`watches:`); if those
|
|
8
|
+
* files change in the diff and the spec doesn't change in the same diff → DRIFT.
|
|
9
|
+
* Cheap, but shallow: co-change in a diff proves the spec was TOUCHED, not that
|
|
10
|
+
* its content is still TRUE.
|
|
11
|
+
*
|
|
12
|
+
* SUSPECT (v2, opt-in): a "reviewed-state" layer. `--stamp <spec>` records a
|
|
13
|
+
* sha256 over the current content of the spec's watched files as `reviewed: <hash>`
|
|
14
|
+
* — a human affirming "I re-read this code and this spec is still true." The check
|
|
15
|
+
* recomputes that hash; a mismatch reports SUSPECT even if the spec was edited.
|
|
16
|
+
* Honesty note: the hash proves bytes unchanged, not semantic truth — a human
|
|
17
|
+
* still has to actually re-read before re-stamping. The full-length sha256 is
|
|
18
|
+
* an honesty marker, not an adversarial control: for adversarial assurance use
|
|
19
|
+
* signed tags / Sigstore (see cadmo-validate's N2/N3 levels).
|
|
20
|
+
*
|
|
21
|
+
* SUSPECT scoping ("checks activate by surface touched"): by default, SUSPECT only
|
|
22
|
+
* fires for specs whose WATCHED SET intersects the current diff — an innocent
|
|
23
|
+
* README-only PR must not fail CI for a stamp a previous merge skipped. Use
|
|
24
|
+
* `--suspect-all` for the full repo-state check (push-to-main / scheduled runs).
|
|
25
|
+
*
|
|
26
|
+
* A spec WITHOUT a `reviewed:` key gets the DRIFT check only — SUSPECT is opt-in
|
|
27
|
+
* (right-sizing: pay the re-read discipline only where it earns its keep).
|
|
28
|
+
*
|
|
29
|
+
* Grammar (front matter forms, glob semantics) lives in ./cadmo-grammar.mjs —
|
|
30
|
+
* the SINGLE grammar shared by spec-drift, cadmo-score and the plugin hook.
|
|
11
31
|
*
|
|
12
32
|
* Usage:
|
|
13
|
-
* node spec-drift.mjs [--base
|
|
33
|
+
* node spec-drift.mjs [--base <ref>] [--dir .] [--suspect-all]
|
|
34
|
+
* node spec-drift.mjs --stamp <spec.md> [--dir .]
|
|
14
35
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* - src/billing/**
|
|
19
|
-
* - api/invoices.py
|
|
20
|
-
* ---
|
|
36
|
+
* With no --base, the first existing ref wins: origin/main → main (when you're
|
|
37
|
+
* on a different branch) → HEAD~1 → the empty tree (single-commit repo: the
|
|
38
|
+
* whole initial commit is the diff). A solo repo with no remote just works.
|
|
21
39
|
*
|
|
22
40
|
* Escape hatch (audited, not silent): if a change genuinely doesn't alter any
|
|
23
|
-
* documented rule, say so where reviewers can see it —
|
|
24
|
-
*
|
|
41
|
+
* documented rule, say so where reviewers can see it — the shipped workflow
|
|
42
|
+
* honors "spec-drift: skip — <reason>" in the PR body (pull_request runs) or
|
|
43
|
+
* in the latest commit message (push runs).
|
|
25
44
|
*/
|
|
26
45
|
import { execFileSync } from 'node:child_process';
|
|
46
|
+
import crypto from 'node:crypto';
|
|
27
47
|
import fs from 'node:fs';
|
|
28
48
|
import path from 'node:path';
|
|
49
|
+
import { frontMatter, parseWatches as watchesOf, parseReviewed as reviewedOf, globToRegex } from './cadmo-grammar.mjs';
|
|
29
50
|
|
|
51
|
+
const BS = String.fromCharCode(92);
|
|
30
52
|
const args = process.argv.slice(2);
|
|
31
53
|
function opt(name, fallback) {
|
|
32
54
|
const i = args.indexOf(name);
|
|
33
55
|
return i >= 0 && args[i + 1] ? args[i + 1] : fallback;
|
|
34
56
|
}
|
|
35
|
-
const BASE = opt('--base', 'origin/main');
|
|
36
57
|
const ROOT = path.resolve(opt('--dir', '.'));
|
|
58
|
+
// sha of git's empty tree — diffing against it means "everything is new"
|
|
59
|
+
const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
|
|
60
|
+
function refExists(ref) {
|
|
61
|
+
try {
|
|
62
|
+
execFileSync('git', ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], { cwd: ROOT, stdio: ['ignore', 'ignore', 'ignore'] });
|
|
63
|
+
return true;
|
|
64
|
+
} catch { return false; }
|
|
65
|
+
}
|
|
66
|
+
function resolveBase() {
|
|
67
|
+
if (refExists('origin/main')) return 'origin/main';
|
|
68
|
+
let branch = '';
|
|
69
|
+
try {
|
|
70
|
+
branch = execFileSync('git', ['symbolic-ref', '--short', 'HEAD'], { cwd: ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
71
|
+
} catch { /* detached HEAD */ }
|
|
72
|
+
if (branch !== 'main' && refExists('main')) return 'main';
|
|
73
|
+
if (refExists('HEAD~1')) return 'HEAD~1';
|
|
74
|
+
return EMPTY_TREE;
|
|
75
|
+
}
|
|
76
|
+
const BASE_GIVEN = opt('--base', null);
|
|
77
|
+
const BASE = BASE_GIVEN || resolveBase();
|
|
78
|
+
const STAMP = opt('--stamp', null);
|
|
79
|
+
const SUSPECT_ALL = args.includes('--suspect-all');
|
|
80
|
+
if (args.includes('--stamp') && !STAMP) {
|
|
81
|
+
console.error('spec-drift --stamp: missing <spec.md> argument.');
|
|
82
|
+
process.exit(2);
|
|
83
|
+
}
|
|
37
84
|
|
|
38
85
|
// --- collect specs that declare `watches:` in front matter ---
|
|
39
86
|
function* mdFiles(dir) {
|
|
@@ -45,56 +92,113 @@ function* mdFiles(dir) {
|
|
|
45
92
|
}
|
|
46
93
|
}
|
|
47
94
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
const
|
|
55
|
-
const
|
|
56
|
-
let
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
if (
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
95
|
+
const readSpec = (file) => fs.readFileSync(file, 'utf8');
|
|
96
|
+
|
|
97
|
+
// Add or update a top-level `reviewed:` key, preserving everything else
|
|
98
|
+
// (including the file's EOL style — a CRLF file must not gain a lone LF line).
|
|
99
|
+
function setReviewed(text, hash) {
|
|
100
|
+
const eol = text.includes('\r\n') ? '\r\n' : '\n';
|
|
101
|
+
const parsed = frontMatter(text);
|
|
102
|
+
const rest = text.slice(parsed.end); // '\n---' … onwards
|
|
103
|
+
let lines = parsed.fm.split(/\r?\n/);
|
|
104
|
+
let found = false;
|
|
105
|
+
lines = lines.map((l) => {
|
|
106
|
+
if (/^reviewed:\s*/.test(l)) { found = true; return `reviewed: ${hash}`; }
|
|
107
|
+
return l;
|
|
108
|
+
});
|
|
109
|
+
if (!found) {
|
|
110
|
+
if (lines.length && lines[lines.length - 1] === '') lines[lines.length - 1] = `reviewed: ${hash}`;
|
|
111
|
+
else lines.push(`reviewed: ${hash}`);
|
|
64
112
|
}
|
|
65
|
-
return
|
|
113
|
+
return '---' + lines.join(eol) + rest;
|
|
66
114
|
}
|
|
67
115
|
|
|
68
|
-
// ---
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
116
|
+
// --- reviewed-state hashing ---
|
|
117
|
+
// Line endings are normalized so a CRLF working tree (git autocrlf) and the
|
|
118
|
+
// LF-stored HEAD blob hash identically. Watched files are text source.
|
|
119
|
+
function norm(buf) {
|
|
120
|
+
return buf.toString('utf8').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function matched(watches, files) {
|
|
124
|
+
const regexes = watches.map(globToRegex);
|
|
125
|
+
return files.filter((f) => regexes.some((r) => r.test(f))).sort();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function hashWatchedDisk(watches) {
|
|
129
|
+
const tracked = execFileSync('git', ['ls-files'], { cwd: ROOT, encoding: 'utf8' }).split('\n').filter(Boolean);
|
|
130
|
+
const h = crypto.createHash('sha256');
|
|
131
|
+
for (const f of matched(watches, tracked)) {
|
|
132
|
+
h.update(f); h.update('\n');
|
|
133
|
+
h.update(norm(fs.readFileSync(path.join(ROOT, f))));
|
|
134
|
+
}
|
|
135
|
+
return h.digest('hex');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function hashWatchedHead(watches) {
|
|
139
|
+
const tracked = execFileSync('git', ['ls-tree', '-r', 'HEAD', '--name-only'], { cwd: ROOT, encoding: 'utf8' }).split('\n').filter(Boolean);
|
|
140
|
+
const h = crypto.createHash('sha256');
|
|
141
|
+
for (const f of matched(watches, tracked)) {
|
|
142
|
+
h.update(f); h.update('\n');
|
|
143
|
+
h.update(norm(execFileSync('git', ['show', `HEAD:${f}`], { cwd: ROOT })));
|
|
144
|
+
}
|
|
145
|
+
return h.digest('hex');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// --- --stamp mode: re-affirm a spec after re-reading its watched files ---
|
|
149
|
+
if (STAMP) {
|
|
150
|
+
const specPath = path.resolve(ROOT, STAMP);
|
|
151
|
+
if (!fs.existsSync(specPath)) {
|
|
152
|
+
console.error(`spec-drift --stamp: no such spec: ${STAMP}`);
|
|
153
|
+
process.exit(2);
|
|
77
154
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
155
|
+
const watches = watchesOf(readSpec(specPath));
|
|
156
|
+
if (!watches) {
|
|
157
|
+
console.error(`spec-drift --stamp: ${STAMP} has no \`watches:\` front matter — nothing to stamp.`);
|
|
158
|
+
process.exit(2);
|
|
159
|
+
}
|
|
160
|
+
const hash = hashWatchedDisk(watches);
|
|
161
|
+
// Footgun guard: stamping uncommitted content records a hash HEAD can never match.
|
|
162
|
+
try {
|
|
163
|
+
const head = hashWatchedHead(watches);
|
|
164
|
+
if (head !== hash) {
|
|
165
|
+
console.error('spec-drift --stamp: WARNING — watched files differ between working tree and HEAD; you are stamping UNCOMMITTED content. Commit the watched changes (or stamp after committing) or the check will report SUSPECT.');
|
|
166
|
+
}
|
|
167
|
+
} catch { /* no HEAD yet (fresh repo) — nothing to compare */ }
|
|
168
|
+
fs.writeFileSync(specPath, setReviewed(readSpec(specPath), hash));
|
|
169
|
+
console.log(`spec-drift: stamped ${STAMP} — reviewed: ${hash}`);
|
|
170
|
+
process.exit(0);
|
|
82
171
|
}
|
|
83
172
|
|
|
84
173
|
// --- changed files vs base ---
|
|
174
|
+
// The empty tree has no merge base, so it gets a two-dot diff; refs get three-dot.
|
|
175
|
+
const RANGE = BASE === EMPTY_TREE ? [BASE, 'HEAD'] : [`${BASE}...HEAD`];
|
|
85
176
|
let changed;
|
|
86
177
|
try {
|
|
87
|
-
changed = execFileSync('git', ['diff', '--name-only',
|
|
178
|
+
changed = execFileSync('git', ['diff', '--name-only', ...RANGE], { cwd: ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
|
|
88
179
|
.split('\n').filter(Boolean);
|
|
180
|
+
if (!BASE_GIVEN) {
|
|
181
|
+
console.log(`spec-drift: no --base given — diffing against ${BASE === EMPTY_TREE ? 'the empty tree (single-commit repo: the whole initial commit)' : BASE}.`);
|
|
182
|
+
}
|
|
89
183
|
} catch (e) {
|
|
90
|
-
|
|
184
|
+
const detail = (e.stderr ? e.stderr.toString() : e.message).split(/\r?\n/).find(Boolean) || '';
|
|
185
|
+
console.error(`spec-drift: cannot diff against ${BASE} — ${detail}`);
|
|
186
|
+
console.error('spec-drift: is this a git repository with at least one commit? Pass --base <ref> to pick the comparison point explicitly.');
|
|
91
187
|
process.exit(2);
|
|
92
188
|
}
|
|
93
189
|
|
|
94
190
|
const specs = [];
|
|
95
191
|
for (const f of mdFiles(ROOT)) {
|
|
96
|
-
const
|
|
97
|
-
|
|
192
|
+
const text = readSpec(f);
|
|
193
|
+
const watches = watchesOf(text);
|
|
194
|
+
const rel = path.relative(ROOT, f).split(BS).join('/');
|
|
195
|
+
if (watches) {
|
|
196
|
+
specs.push({ spec: rel, watches, reviewed: reviewedOf(text) });
|
|
197
|
+
} else if (text.startsWith('<!--') && /^watches:/m.test(text)) {
|
|
198
|
+
// the template ships with a leading HTML comment; front matter placed after
|
|
199
|
+
// it is invisible to the grammar — a spec that LOOKS guarded but isn't.
|
|
200
|
+
console.error(`WARNING: ${rel} has a watches: line but does not start with --- (front matter must be the very first bytes — delete the leading comment). This spec is NOT being checked.`);
|
|
201
|
+
}
|
|
98
202
|
}
|
|
99
203
|
|
|
100
204
|
if (!specs.length) {
|
|
@@ -104,17 +208,30 @@ if (!specs.length) {
|
|
|
104
208
|
|
|
105
209
|
const changedSet = new Set(changed);
|
|
106
210
|
let drift = 0;
|
|
107
|
-
|
|
211
|
+
let suspect = 0;
|
|
212
|
+
for (const { spec, watches, reviewed } of specs) {
|
|
108
213
|
const regexes = watches.map(globToRegex);
|
|
109
|
-
const hits = changed.filter(c => regexes.some(r => r.test(c)));
|
|
214
|
+
const hits = changed.filter((c) => regexes.some((r) => r.test(c)));
|
|
110
215
|
if (hits.length && !changedSet.has(spec)) {
|
|
111
216
|
drift++;
|
|
112
217
|
console.error(`DRIFT: ${hits.join(', ')} changed, but ${spec} (which watches ${watches.join(', ')}) did not change in this diff.`);
|
|
113
218
|
}
|
|
219
|
+
// SUSPECT: opt-in via `reviewed:`, and diff-scoped unless --suspect-all —
|
|
220
|
+
// only a diff that touches this spec's watched surface puts it on trial here.
|
|
221
|
+
if (reviewed && (SUSPECT_ALL || hits.length)) {
|
|
222
|
+
const now = hashWatchedHead(watches);
|
|
223
|
+
if (now !== reviewed) {
|
|
224
|
+
suspect++;
|
|
225
|
+
console.error(`SUSPECT: ${spec} was reviewed at ${reviewed.slice(0, 12)}… but its watched files hash ${now.slice(0, 12)}… at HEAD — the code changed since the last review. Re-read the watched files, then re-run: node spec-drift.mjs --stamp ${spec}`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
114
228
|
}
|
|
115
229
|
|
|
116
|
-
if (drift) {
|
|
117
|
-
|
|
230
|
+
if (drift || suspect) {
|
|
231
|
+
const parts = [];
|
|
232
|
+
if (drift) parts.push(`${drift} drifting`);
|
|
233
|
+
if (suspect) parts.push(`${suspect} suspect`);
|
|
234
|
+
console.error(`\nspec-drift: ${parts.join(', ')}. Update the spec in the same change (or state why no documented rule changed, visibly); re-stamp any SUSPECT spec after actually re-reading its watched files — the hash matches bytes, only you can vouch for the meaning.`);
|
|
118
235
|
process.exit(1);
|
|
119
236
|
}
|
|
120
237
|
console.log(`spec-drift: ${specs.length} spec(s) checked against ${changed.length} changed file(s) — in sync ✓`);
|
package/templates/spec.md
CHANGED
|
@@ -1,24 +1,38 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
##
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
1
|
+
<!-- To enforce this spec against code, add real YAML front matter as the VERY FIRST lines:
|
|
2
|
+
---
|
|
3
|
+
watches:
|
|
4
|
+
- src/the-area-this-governs/**
|
|
5
|
+
- api/the-endpoint.py
|
|
6
|
+
---
|
|
7
|
+
Then spec-drift fails any change to those files that doesn't update this spec. -->
|
|
8
|
+
|
|
9
|
+
# Spec — <name>
|
|
10
|
+
|
|
11
|
+
**Status:** draft — awaiting validation (<who>) · **Demand:** <link/id>
|
|
12
|
+
|
|
13
|
+
## Goal & context
|
|
14
|
+
The business problem and what changes in the world (2–5 lines, in the client's language).
|
|
15
|
+
(Relevant work already passed the value gate — its success metric becomes an acceptance criterion here.)
|
|
16
|
+
|
|
17
|
+
## Acceptance criteria
|
|
18
|
+
- [ ] Simple ones in free form
|
|
19
|
+
- [ ] Critical ones (money / data / integration / security) in EARS:
|
|
20
|
+
WHEN <trigger>, THE SYSTEM SHALL <behavior>
|
|
21
|
+
|
|
22
|
+
## Data (AI features only)
|
|
23
|
+
Source, owner, access, quality/bias, corpus refresh plan.
|
|
24
|
+
|
|
25
|
+
## Risks (critical or AI demands only)
|
|
26
|
+
Risk → response. For AI: corpus drift, provider API change, cost, regulation.
|
|
27
|
+
|
|
28
|
+
## Out of scope
|
|
29
|
+
What this delivery does NOT cover (controlled expectations).
|
|
30
|
+
|
|
31
|
+
## Impact on stable specs
|
|
32
|
+
Which sections of the system/domain specs will be updated on delivery (the absorption).
|
|
33
|
+
|
|
34
|
+
## Impact on existing data (only if this touches persisted state)
|
|
35
|
+
Does this change data that already exists — a DB schema/migration, saved user records, files a
|
|
36
|
+
prior version produced? Name what existing data is affected and the migration/backfill plan (or
|
|
37
|
+
"none — new data only"). A rule that changes *how* something is computed does not retroactively
|
|
38
|
+
fix rows already written; say how the old ones are handled.
|