gcf-aif-ext 0.2.5 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -5
- package/commands/gcf.js +408 -73
- package/extension.json +1 -1
- package/hooks/pre-commit.js +47 -29
- package/injections/commit-status.md +1 -0
- package/injections/plan-context.md +3 -2
- package/injections/review-pillars.md +1 -1
- package/lib/anchors.js +110 -10
- package/lib/audit-core.js +175 -50
- package/lib/bundle.js +275 -0
- package/lib/context.js +55 -36
- package/lib/design-check.js +580 -0
- package/lib/design.js +179 -0
- package/lib/frontmatter.js +2 -1
- package/lib/gcf-paths.js +14 -3
- package/lib/git.js +85 -4
- package/lib/id.js +8 -14
- package/lib/index-gen.js +68 -16
- package/lib/ledger.js +92 -0
- package/lib/linter.js +91 -42
- package/lib/migrate.js +345 -0
- package/lib/playtest-list.js +1 -1
- package/lib/safe-path.js +23 -4
- package/lib/systems-doc.js +70 -0
- package/lib/transitions.js +1 -3
- package/lib/unit-ops.js +270 -0
- package/lib/validate-card.js +59 -14
- package/lib/verdict-fs.js +173 -96
- package/package.json +1 -1
- package/profiles/game.md +68 -0
- package/profiles/glossary.md +37 -0
- package/profiles/pillars.md +2 -1
- package/profiles/unit-constraint.md +42 -0
- package/profiles/unit-entity.md +38 -0
- package/profiles/unit-mechanic.md +106 -0
- package/profiles/unit-resource.md +86 -0
- package/profiles/unit-rule.md +42 -0
- package/schemas/card.schema.json +23 -7
- package/skills/gcf-adopt/SKILL.md +3 -3
- package/skills/gcf-audit/SKILL.md +4 -3
- package/skills/gcf-card/SKILL.md +26 -16
- package/skills/gcf-debate/SKILL.md +17 -14
- package/skills/gcf-ingest/SKILL.md +43 -16
- package/skills/gcf-init/SKILL.md +9 -8
- package/skills/gcf-verdict/SKILL.md +7 -6
- package/skills/gcf-vision/SKILL.md +17 -11
- package/templates/PILLARS.md +2 -0
- package/templates/backlog.md +2 -4
- package/templates/card.md +27 -10
- package/templates/cnlp.config.json +0 -1
- package/templates/design.md +59 -0
- package/templates/game.md +24 -0
- package/templates/glossary.md +11 -0
- package/templates/system.md +1 -1
- package/profiles/card.md +0 -65
package/hooks/pre-commit.js
CHANGED
|
@@ -4,14 +4,15 @@
|
|
|
4
4
|
// The gate reads the WORKING TREE, so it only runs when the commit touches gcf/ and
|
|
5
5
|
// refuses to judge a gcf/ whose working tree and index disagree.
|
|
6
6
|
import { existsSync } from 'node:fs';
|
|
7
|
-
import { readFile } from 'node:fs/promises';
|
|
8
7
|
import { spawnSync } from 'node:child_process';
|
|
9
8
|
import path from 'node:path';
|
|
10
|
-
import {
|
|
9
|
+
import { listSystems } from '../lib/gcf-paths.js';
|
|
11
10
|
import { validateCard, validateUntil } from '../lib/validate-card.js';
|
|
12
11
|
import { stemMatches } from '../lib/id.js';
|
|
13
12
|
import { writeIndex } from '../lib/index-gen.js';
|
|
14
13
|
import { lint } from '../lib/linter.js';
|
|
14
|
+
import { buildLedger } from '../lib/ledger.js';
|
|
15
|
+
import { designCheck } from '../lib/design-check.js';
|
|
15
16
|
|
|
16
17
|
const projectDir = process.cwd();
|
|
17
18
|
const root = path.join(projectDir, 'gcf');
|
|
@@ -26,68 +27,85 @@ const fail = (msg) => {
|
|
|
26
27
|
// rather than letting a broken probe wave a commit past every check.
|
|
27
28
|
function git(args) {
|
|
28
29
|
const res = spawnSync('git', args, { cwd: projectDir, encoding: 'utf8' });
|
|
30
|
+
|
|
29
31
|
return res.status === 0 ? res.stdout.trim() : null;
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
const staged = git(['diff', '--cached', '--name-only', '--', root]);
|
|
33
35
|
if (staged === '') process.exit(0); // this commit does not touch gcf/
|
|
34
36
|
if (staged !== null) {
|
|
35
|
-
// Untracked counts:
|
|
36
|
-
// validated and written into the staged INDEX while not being in the repository
|
|
37
|
+
// Untracked counts: cards and units are found by reading directories, so an uncommitted file
|
|
38
|
+
// would be validated and written into the staged INDEX while not being in the repository.
|
|
37
39
|
const unstaged = git(['diff', '--name-only', '--', root]);
|
|
38
40
|
const untracked = git(['ls-files', '--others', '--exclude-standard', '--', root]);
|
|
39
41
|
if (unstaged || untracked) {
|
|
40
|
-
fail(
|
|
42
|
+
fail(
|
|
43
|
+
'gcf/ has changes outside the index — stage them, or commit gcf/ separately ' +
|
|
44
|
+
'(the gate reads the working tree)',
|
|
45
|
+
);
|
|
41
46
|
}
|
|
42
47
|
}
|
|
43
48
|
|
|
44
|
-
const
|
|
45
|
-
const cardIds = new Set(cards.map((c) => c.data.id));
|
|
46
|
-
const systemDocs = await listSystems(root);
|
|
49
|
+
const ledger = await buildLedger(projectDir);
|
|
47
50
|
|
|
48
51
|
// 1-2: frontmatter validity, storable statuses, until grammar, filename invariant.
|
|
49
|
-
for (const c of cards) {
|
|
52
|
+
for (const c of ledger.cards) {
|
|
50
53
|
const errors = [
|
|
51
54
|
...validateCard(c.data),
|
|
52
55
|
...validateUntil(c.body),
|
|
53
|
-
...(stemMatches(c.file, c.data.id
|
|
56
|
+
...(stemMatches(c.file, c.data.id) ? [] : [`filename must be ${c.data.id}.md`]),
|
|
54
57
|
];
|
|
55
58
|
if (errors.length) fail(`${c.name}: ${errors[0]}`);
|
|
56
59
|
}
|
|
57
|
-
|
|
60
|
+
|
|
61
|
+
for (const s of await listSystems(root)) {
|
|
58
62
|
const errors = validateUntil(s.body);
|
|
59
63
|
if (errors.length) fail(`systems/${s.name}: ${errors[0]}`);
|
|
60
64
|
}
|
|
61
65
|
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
// A unit or card leaving without its move (take, done, drop, a verdict) loses its reason, not
|
|
67
|
+
// its integrity: a card or unit still citing it fails M2 below, in this commit. A warning, not a
|
|
68
|
+
// refusal — a revert of the commit that added it removes an id legitimately.
|
|
69
|
+
for (const id of ledger.vanished()) {
|
|
70
|
+
console.error(
|
|
71
|
+
`gcf pre-commit: warning: ${id} left design/ or cards/ without done, drop or a verdict — ` +
|
|
72
|
+
'no history or graveyard line keeps why',
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Design model and card bodies: every finding of severity `block` refuses the commit.
|
|
77
|
+
const first = (await designCheck(projectDir)).issues.find((i) => i.severity === 'block');
|
|
78
|
+
if (first) fail(`${first.file}:${first.line}: ${first.check}: ${first.message}`);
|
|
69
79
|
|
|
70
|
-
|
|
80
|
+
// 4: a built card must not sit on an unplayed dependency — a live card, or a unit still in
|
|
81
|
+
// design/. A collapsed, done or killed id is played; an unknown one is a typo (design check).
|
|
82
|
+
for (const c of ledger.cards.filter((x) => x.data.status === 'built')) {
|
|
71
83
|
for (const dep of c.data.depends ?? []) {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
} else if (!collapsed(dep)) {
|
|
77
|
-
fail(`${c.data.id} depends on unknown ${dep} (no card, no verdict, no graveyard line)`);
|
|
78
|
-
}
|
|
84
|
+
const live = ledger.where(dep).live[0];
|
|
85
|
+
if (live)
|
|
86
|
+
fail(`${c.data.id} is built on top of unplayed ${dep} (${live.state} — GCF-PRD §7.2)`);
|
|
79
87
|
}
|
|
80
88
|
}
|
|
81
89
|
|
|
90
|
+
for (const c of ledger.cards) {
|
|
91
|
+
const unknown = (c.data.depends ?? []).find((d) => !ledger.known(d));
|
|
92
|
+
if (unknown)
|
|
93
|
+
fail(`${c.data.id} depends on unknown ${unknown} (no unit, card, history or graveyard line)`);
|
|
94
|
+
}
|
|
95
|
+
|
|
82
96
|
// 5: П2 linter over systems docs.
|
|
83
|
-
for (const s of
|
|
97
|
+
for (const s of await listSystems(root)) {
|
|
84
98
|
const { issues } = lint(s.body);
|
|
85
|
-
if (issues.length)
|
|
99
|
+
if (issues.length)
|
|
100
|
+
fail(`systems/${s.name}:${issues[0].line}: ${issues[0].rule}: ${issues[0].text}`);
|
|
86
101
|
}
|
|
87
102
|
|
|
88
103
|
// 3: regenerate INDEX and stage it (deterministic artifact, not a human error).
|
|
89
104
|
await writeIndex(root);
|
|
90
105
|
// An unstaged INDEX (gitignored, locked index) would leave the commit without the row it validated.
|
|
91
|
-
const added = spawnSync('git', ['add', '--', path.join(root, 'INDEX.md')], {
|
|
106
|
+
const added = spawnSync('git', ['add', '--', path.join(root, 'INDEX.md')], {
|
|
107
|
+
cwd: projectDir,
|
|
108
|
+
stdio: 'ignore',
|
|
109
|
+
});
|
|
92
110
|
if (added.status !== 0) fail('could not stage gcf/INDEX.md — is it gitignored?');
|
|
93
111
|
process.exit(0);
|
|
@@ -4,3 +4,4 @@ gcf_commit:
|
|
|
4
4
|
- when `gcf/` exists and the commit implements a card, verify the card frontmatter is current
|
|
5
5
|
- an implementation commit that completes a build flips `status:` to `built` and records `built_at:` with the commit hash in the same commit
|
|
6
6
|
- do not commit a card file whose `status:` is `validated` or `killed`: those are terminal and never stored
|
|
7
|
+
- when the commit makes a hard unit's tests green, run `ai-factory gcf done <id>` after it: the unit leaves `gcf/design/` in its own fixed-format commit
|
|
@@ -4,5 +4,6 @@ gcf_gate:
|
|
|
4
4
|
- when `gcf/` exists and the task names a card, run `ai-factory gcf context <card-id>` first
|
|
5
5
|
- the returned file list is the readset: read those files and nothing else under `gcf/`
|
|
6
6
|
- `over_budget: true` in the result: stop and record a D1 question instead of planning
|
|
7
|
-
-
|
|
8
|
-
- shape the plan by the card slots: behavior,
|
|
7
|
+
- an id in `depends:` that still lives as a card or as a unit of `gcf/design/` is unplayed: do not plan on top of it, stop
|
|
8
|
+
- shape the plan by the card slots: intent, behavior, refusal, boundaries, persistence, feel envelopes, contract, interactions, constraints, miss criterion
|
|
9
|
+
- when the task names a hard unit (rule, constraint, entity), there is no card: read its section only, from the file `ai-factory gcf where <id>` names, and plan its tests from `statement`, `threshold` or `fields`
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
## GCF: pillar and constraint review (from gcf-aif-ext)
|
|
2
2
|
|
|
3
3
|
gcf_review:
|
|
4
|
-
- when `gcf/` exists and the change implements a card, read `gcf/PILLARS.md`
|
|
4
|
+
- when `gcf/` exists and the change implements a card, read the card and `gcf/PILLARS.md` when it exists (it stays absent until the pillars interview)
|
|
5
5
|
- check the diff against every negative constraint of the card: name each violation with file and line
|
|
6
6
|
- check the diff against each pillar's review question: a contradiction is a finding, not a note
|
|
7
7
|
- numeric feel values in code outside the card's envelope: report the value and the envelope bounds
|
package/lib/anchors.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
2
|
+
import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises';
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
|
|
@@ -7,45 +7,67 @@ import path from 'node:path';
|
|
|
7
7
|
// stable under reorder, not under rename. Re-import matches orphaned anchors by body
|
|
8
8
|
// hash and reports a rename instead of delete+add.
|
|
9
9
|
|
|
10
|
-
const norm = (s) =>
|
|
10
|
+
const norm = (s) =>
|
|
11
|
+
String(s)
|
|
12
|
+
.toLowerCase()
|
|
13
|
+
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
|
14
|
+
.trim();
|
|
11
15
|
const h8 = (s) => createHash('sha1').update(s, 'utf8').digest('hex').slice(0, 8);
|
|
12
16
|
// A body keeps its punctuation and structure, unlike a heading: `norm` would hash
|
|
13
17
|
// `- a` + `- b` the same as `a, b`, hiding a real edit from classifyDelta.
|
|
14
|
-
const bodyNorm = (s) =>
|
|
18
|
+
const bodyNorm = (s) =>
|
|
19
|
+
String(s)
|
|
20
|
+
.replace(/\r\n/g, '\n')
|
|
21
|
+
.replace(/[ \t]+$/gm, '')
|
|
22
|
+
.trim();
|
|
15
23
|
const EMPTY_HASH = h8('');
|
|
16
24
|
// Bumped whenever body hashing changes: a stored map from an older scheme cannot be
|
|
17
25
|
// compared against fresh hashes, and reporting every section as changed would flood the
|
|
18
26
|
// human with D1 questions about a GDD nobody edited.
|
|
19
27
|
export const HASH_VERSION = 2;
|
|
20
28
|
|
|
21
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* Split markdown into heading-addressed sections.
|
|
31
|
+
* Text before the first heading is `GDD#preamble`.
|
|
32
|
+
*/
|
|
22
33
|
export function splitSections(markdown) {
|
|
23
34
|
const lines = String(markdown).split('\n');
|
|
35
|
+
/**
|
|
36
|
+
* @typedef {{ heading: string | null, level: number, start: number, lines: string[] }} RawSection
|
|
37
|
+
*/
|
|
38
|
+
/** @type {RawSection[]} */
|
|
24
39
|
const sections = [];
|
|
25
|
-
|
|
40
|
+
/** @type {RawSection} */
|
|
41
|
+
let current = { heading: null, level: 0, start: 1, lines: [] };
|
|
26
42
|
const flush = () => {
|
|
27
43
|
if (current.heading === null && !current.lines.join('').trim()) return;
|
|
28
44
|
sections.push(current);
|
|
29
45
|
};
|
|
30
|
-
|
|
46
|
+
|
|
47
|
+
for (const [i, line] of lines.entries()) {
|
|
31
48
|
const m = line.match(/^(#{1,6})\s+(.+?)\s*$/);
|
|
32
49
|
if (m) {
|
|
33
50
|
flush();
|
|
34
|
-
current = { heading: m[2], level: m[1].length, lines: [] };
|
|
51
|
+
current = { heading: m[2], level: m[1].length, start: i + 1, lines: [] };
|
|
35
52
|
} else current.lines.push(line);
|
|
36
53
|
}
|
|
54
|
+
|
|
37
55
|
flush();
|
|
38
56
|
|
|
39
57
|
const seen = new Map();
|
|
58
|
+
|
|
40
59
|
return sections.map((s) => {
|
|
41
60
|
const base = s.heading === null ? 'preamble' : h8(norm(s.heading));
|
|
42
61
|
const n = seen.get(base) ?? 0;
|
|
43
62
|
seen.set(base, n + 1);
|
|
44
63
|
const body = s.lines.join('\n');
|
|
64
|
+
|
|
45
65
|
return {
|
|
46
66
|
anchor: `GDD#${n ? `${base}-${n}` : base}`,
|
|
47
67
|
heading: s.heading,
|
|
48
68
|
level: s.level,
|
|
69
|
+
// 1-based and inclusive, heading line first: a reader opens the section, not the file.
|
|
70
|
+
lines: [s.start, s.start + s.lines.length - (s.heading === null ? 1 : 0)],
|
|
49
71
|
body,
|
|
50
72
|
body_hash: h8(bodyNorm(body)),
|
|
51
73
|
};
|
|
@@ -60,9 +82,16 @@ export async function importSource(projectDir, file) {
|
|
|
60
82
|
const base = path.basename(file);
|
|
61
83
|
await mkdir(path.join(root, 'source'), { recursive: true });
|
|
62
84
|
await writeFile(path.join(root, 'source', base), content, 'utf8');
|
|
63
|
-
const anchors = Object.fromEntries(
|
|
85
|
+
const anchors = Object.fromEntries(
|
|
86
|
+
sections.map((s) => [s.anchor, { heading: s.heading, body_hash: s.body_hash }]),
|
|
87
|
+
);
|
|
64
88
|
const map = { hash_version: HASH_VERSION, anchors };
|
|
65
|
-
await writeFile(
|
|
89
|
+
await writeFile(
|
|
90
|
+
path.join(root, 'source', `${base}.anchors.json`),
|
|
91
|
+
JSON.stringify(map, null, 2),
|
|
92
|
+
'utf8',
|
|
93
|
+
);
|
|
94
|
+
|
|
66
95
|
return { file: `gcf/source/${base}`, sections };
|
|
67
96
|
}
|
|
68
97
|
|
|
@@ -71,6 +100,10 @@ export async function importSource(projectDir, file) {
|
|
|
71
100
|
* A removed anchor whose body hash reappears under a new anchor is a rename.
|
|
72
101
|
*/
|
|
73
102
|
export function classifyDelta(oldMap, sections) {
|
|
103
|
+
/**
|
|
104
|
+
* @type {{ added: string[], changed: string[], removed: string[],
|
|
105
|
+
* renamed: { from: string, to: string }[] }}
|
|
106
|
+
*/
|
|
74
107
|
const delta = { added: [], changed: [], removed: [], renamed: [] };
|
|
75
108
|
const newByAnchor = new Map(sections.map((s) => [s.anchor, s]));
|
|
76
109
|
|
|
@@ -79,6 +112,7 @@ export function classifyDelta(oldMap, sections) {
|
|
|
79
112
|
if (!old) continue;
|
|
80
113
|
if (old.body_hash !== s.body_hash) delta.changed.push(s.anchor);
|
|
81
114
|
}
|
|
115
|
+
|
|
82
116
|
const missing = Object.entries(oldMap).filter(([a]) => !newByAnchor.has(a));
|
|
83
117
|
const fresh = sections.filter((s) => !(s.anchor in oldMap));
|
|
84
118
|
// Each fresh section can be claimed once: without consuming the match, one section
|
|
@@ -86,12 +120,15 @@ export function classifyDelta(oldMap, sections) {
|
|
|
86
120
|
const pool = [...fresh];
|
|
87
121
|
for (const [anchor, old] of missing) {
|
|
88
122
|
// An empty body hashes the same everywhere, so it is evidence of nothing.
|
|
89
|
-
const i =
|
|
123
|
+
const i =
|
|
124
|
+
old.body_hash === EMPTY_HASH ? -1 : pool.findIndex((s) => s.body_hash === old.body_hash);
|
|
90
125
|
if (i >= 0) delta.renamed.push({ from: anchor, to: pool.splice(i, 1)[0].anchor });
|
|
91
126
|
else delta.removed.push(anchor);
|
|
92
127
|
}
|
|
128
|
+
|
|
93
129
|
const renamedTo = new Set(delta.renamed.map((r) => r.to));
|
|
94
130
|
delta.added = fresh.map((s) => s.anchor).filter((a) => !renamedTo.has(a));
|
|
131
|
+
|
|
95
132
|
return delta;
|
|
96
133
|
}
|
|
97
134
|
|
|
@@ -105,5 +142,68 @@ export async function loadAnchorMap(projectDir, baseName) {
|
|
|
105
142
|
if (!existsSync(file)) return null;
|
|
106
143
|
const raw = JSON.parse(await readFile(file, 'utf8'));
|
|
107
144
|
if (raw.hash_version !== HASH_VERSION) return { anchors: null, rebaselined: true };
|
|
145
|
+
|
|
108
146
|
return { anchors: raw.anchors ?? {}, rebaselined: false };
|
|
109
147
|
}
|
|
148
|
+
|
|
149
|
+
/** The section an anchor names in one gcf/source/ file, or null. */
|
|
150
|
+
export async function readSection(projectDir, baseName, anchor) {
|
|
151
|
+
const file = path.join(path.resolve(projectDir), 'gcf', 'source', path.basename(baseName));
|
|
152
|
+
if (!existsSync(file))
|
|
153
|
+
throw new Error(`no gcf/source/${path.basename(baseName)} - anchor it first`);
|
|
154
|
+
|
|
155
|
+
return splitSections(await readFile(file, 'utf8')).find((s) => s.anchor === anchor) ?? null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const ws = (t) => String(t).replace(/\s+/g, ' ').trim();
|
|
159
|
+
|
|
160
|
+
/** anchor -> whitespace-collapsed bodies across every gcf/source/ file */
|
|
161
|
+
export async function sourceBodies(sourceDir) {
|
|
162
|
+
/** @type {Map<string, string[]>} */
|
|
163
|
+
const bodies = new Map();
|
|
164
|
+
if (!existsSync(sourceDir)) return bodies;
|
|
165
|
+
for (const name of (await readdir(sourceDir)).filter((n) => n.endsWith('.md'))) {
|
|
166
|
+
for (const s of splitSections(await readFile(path.join(sourceDir, name), 'utf8'))) {
|
|
167
|
+
bodies.set(s.anchor, [...(bodies.get(s.anchor) ?? []), ws(s.body)]);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return bodies;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** gcf-relative files that cite anchors: units, cards, and the `sections:` of questions. */
|
|
175
|
+
async function anchorCiters(root) {
|
|
176
|
+
const out = [];
|
|
177
|
+
for (const dir of ['design', 'cards']) {
|
|
178
|
+
if (!existsSync(path.join(root, dir))) continue;
|
|
179
|
+
const names = (await readdir(path.join(root, dir))).filter((n) => n.endsWith('.md'));
|
|
180
|
+
out.push(...names.map((n) => `${dir}/${n}`));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (existsSync(path.join(root, 'open-questions.md'))) out.push('open-questions.md');
|
|
184
|
+
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* A renamed heading changes its anchor (GCF-PRD §11.6): rewrite `GDD#<from>` to `GDD#<to>` in
|
|
190
|
+
* design/, cards/ and open-questions.md, so provenance, `source:` and `sections:` follow the
|
|
191
|
+
* section instead of pointing at an anchor that no longer exists. Returns the files it changed.
|
|
192
|
+
*/
|
|
193
|
+
export async function rewriteAnchors(projectDir, renamed) {
|
|
194
|
+
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
195
|
+
const changed = [];
|
|
196
|
+
for (const rel of renamed.length ? await anchorCiters(root) : []) {
|
|
197
|
+
const file = path.join(root, rel);
|
|
198
|
+
const text = await readFile(file, 'utf8');
|
|
199
|
+
const next = renamed.reduce(
|
|
200
|
+
(t, r) => t.replace(new RegExp(`${r.from}(?![\\w-])`, 'g'), () => r.to),
|
|
201
|
+
text,
|
|
202
|
+
);
|
|
203
|
+
if (next === text) continue;
|
|
204
|
+
await writeFile(file, next, 'utf8');
|
|
205
|
+
changed.push(rel);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return changed;
|
|
209
|
+
}
|
package/lib/audit-core.js
CHANGED
|
@@ -5,13 +5,36 @@ import { spawnSync } from 'node:child_process';
|
|
|
5
5
|
import { listCards, listSystems, readConfig, tokensEst } from './gcf-paths.js';
|
|
6
6
|
import { lint } from './linter.js';
|
|
7
7
|
import { drifted } from './index-gen.js';
|
|
8
|
+
import { designCheck } from './design-check.js';
|
|
9
|
+
import { buildLedger } from './ledger.js';
|
|
8
10
|
|
|
9
11
|
function git(args, cwd) {
|
|
10
12
|
const res = spawnSync('git', args, { cwd, encoding: 'utf8' });
|
|
13
|
+
|
|
11
14
|
return res.status === 0 ? res.stdout.trim() : null;
|
|
12
15
|
}
|
|
13
16
|
|
|
14
|
-
const UNTIL_RE = /\[until:\s*(verdict\(
|
|
17
|
+
const UNTIL_RE = /\[until:\s*(verdict\([a-z0-9-]+\)|retune\([a-z0-9-]+\)|manual\([^)]*\))\]/g;
|
|
18
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
19
|
+
const LEGAL_STATUS = ['proposed', 'built', 'tuning'];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {object} AuditReport
|
|
23
|
+
* @property {string} first_line
|
|
24
|
+
* @property {Array<{id: string, commits: number | null, limit: number}>} stale_built
|
|
25
|
+
* @property {Array<{id: string, tune_count: number, limit: number}>} tune_limit
|
|
26
|
+
* @property {Array<{id: string, depends: string[]}>} open_depends
|
|
27
|
+
* @property {{count: number, limit: number, over: boolean}} wip
|
|
28
|
+
* @property {{tokens: number, budget: number, over: boolean} | null} pillars
|
|
29
|
+
* @property {Array<{file: string, trigger: string, status: string}>} until
|
|
30
|
+
* @property {Record<string, object[]>} linter
|
|
31
|
+
* @property {object[]} pragmas
|
|
32
|
+
* @property {boolean} index_drift
|
|
33
|
+
* @property {Array<{id: string, status: string}>} illegal_status
|
|
34
|
+
* @property {number} [schema]
|
|
35
|
+
* @property {Array<{id: string, status: string, system: string[], depends: string[]}>} [cards]
|
|
36
|
+
* @property {{blocking: number, units: number, ready: number}} [design]
|
|
37
|
+
*/
|
|
15
38
|
|
|
16
39
|
/**
|
|
17
40
|
* Deterministic audit core (GCF-PRD §10.1): until triggers, built staleness, tune
|
|
@@ -22,86 +45,188 @@ export async function audit(projectDir) {
|
|
|
22
45
|
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
23
46
|
const config = await readConfig(root);
|
|
24
47
|
const cards = await listCards(root);
|
|
25
|
-
const
|
|
26
|
-
|
|
48
|
+
const systems = await listSystems(root);
|
|
49
|
+
/** @type {AuditReport} */
|
|
50
|
+
const report = {
|
|
51
|
+
first_line: '',
|
|
52
|
+
stale_built: [],
|
|
53
|
+
tune_limit: [],
|
|
54
|
+
open_depends: [],
|
|
55
|
+
wip: wipOf(cards, config),
|
|
56
|
+
pillars: await pillarsOf(root, config),
|
|
57
|
+
until: untilRecords(cards, systems, projectDir, await buildLedger(projectDir)),
|
|
58
|
+
linter: {},
|
|
59
|
+
pragmas: [],
|
|
60
|
+
index_drift: await drifted(root),
|
|
61
|
+
illegal_status: [],
|
|
62
|
+
};
|
|
63
|
+
// The harness gate reads everything from this one report — it never parses gcf/ itself.
|
|
64
|
+
report.schema = config.schema;
|
|
65
|
+
report.cards = cards.map((c) => ({
|
|
66
|
+
id: c.data.id,
|
|
67
|
+
status: c.data.status,
|
|
68
|
+
system: c.data.system ?? [],
|
|
69
|
+
depends: c.data.depends ?? [],
|
|
70
|
+
}));
|
|
71
|
+
const design = await designCheck(projectDir);
|
|
72
|
+
checkCards(report, cards, config, { projectDir, designIds: design.units.map((u) => u.id) });
|
|
73
|
+
report.design = {
|
|
74
|
+
blocking: design.blocking,
|
|
75
|
+
units: design.units.length,
|
|
76
|
+
ready: design.units.filter((u) => u.ready).length,
|
|
77
|
+
};
|
|
78
|
+
lintSystems(report, systems);
|
|
79
|
+
report.first_line = firstLine(report);
|
|
27
80
|
|
|
81
|
+
return report;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Per-card checks: illegal status, built staleness, tune limit, open depends. `designIds` are
|
|
86
|
+
* units still in gcf/design/: a card depending on one builds on what nobody has played yet.
|
|
87
|
+
*/
|
|
88
|
+
function checkCards(report, cards, config, { projectDir, designIds }) {
|
|
89
|
+
const liveIds = new Set([...cards.map((c) => c.data.id), ...designIds]);
|
|
28
90
|
// Staleness is measured in commits, so outside a repository there is no answer to give.
|
|
29
91
|
// Without this probe every built card would read as stale and stop the harness.
|
|
30
92
|
const inRepo = git(['rev-parse', '--git-dir'], projectDir) !== null;
|
|
31
93
|
|
|
32
|
-
// Verdict history from commit messages of the fixed format (GCF-PRD §8).
|
|
33
|
-
const verdictLog = git(['log', '--grep', '^gcf(verdict):', '--format=%s'], projectDir) ?? '';
|
|
34
|
-
const verdictFired = (cardId) => new RegExp(`^gcf\\(verdict\\): \\w+ ${cardId}\\b`, 'm').test(verdictLog);
|
|
35
|
-
// ponytail: retune(sys) fires on any tune verdict naming the system; the PRD's
|
|
36
|
-
// feel-section-diff refinement can replace this when it proves too coarse.
|
|
37
|
-
const retuneFired = (system) => new RegExp(`^gcf\\(verdict\\): tune .*→ .*${system}`, 'm').test(verdictLog);
|
|
38
|
-
|
|
39
94
|
for (const c of cards) {
|
|
40
|
-
if (!
|
|
95
|
+
if (!LEGAL_STATUS.includes(c.data.status)) {
|
|
41
96
|
report.illegal_status.push({ id: c.data.id, status: c.data.status });
|
|
42
97
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const n = raw === null ? NaN : Number(raw);
|
|
48
|
-
if (!Number.isFinite(n) || n > config.built_stale_after_commits) {
|
|
49
|
-
report.stale_built.push({ id: c.data.id, commits: Number.isFinite(n) ? n : null, limit: config.built_stale_after_commits });
|
|
50
|
-
}
|
|
51
|
-
}
|
|
98
|
+
|
|
99
|
+
const stale = inRepo ? staleEntry(c, config, projectDir) : null;
|
|
100
|
+
if (stale) report.stale_built.push(stale);
|
|
101
|
+
|
|
52
102
|
if ((c.data.tune_count ?? 0) >= config.tune_limit) {
|
|
53
|
-
report.tune_limit.push({
|
|
103
|
+
report.tune_limit.push({
|
|
104
|
+
id: c.data.id,
|
|
105
|
+
tune_count: c.data.tune_count,
|
|
106
|
+
limit: config.tune_limit,
|
|
107
|
+
});
|
|
54
108
|
}
|
|
109
|
+
|
|
55
110
|
// A depends id that still has a card file is unplayed (GCF-PRD §7.2). Data for the
|
|
56
111
|
// harness gate, not an alert: a proposed card waiting on its dependency is normal.
|
|
57
112
|
const open = (c.data.depends ?? []).filter((d) => liveIds.has(d));
|
|
58
113
|
if (open.length) report.open_depends.push({ id: c.data.id, depends: open });
|
|
59
114
|
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A stale_built entry for a built card past the commit limit, else null. */
|
|
118
|
+
function staleEntry(c, config, projectDir) {
|
|
119
|
+
if (c.data.status !== 'built' || !c.data.built_at) return null;
|
|
120
|
+
// git() is null when the ref no longer resolves (rebase, squash, amend, shallow clone).
|
|
121
|
+
// Number(null) is 0, which reads as "fresh" — the one answer that must never be guessed.
|
|
122
|
+
const raw = git(['rev-list', '--count', `${c.data.built_at}..HEAD`], projectDir);
|
|
123
|
+
const n = raw === null ? NaN : Number(raw);
|
|
124
|
+
if (!Number.isFinite(n) || n > config.built_stale_after_commits) {
|
|
125
|
+
return {
|
|
126
|
+
id: c.data.id,
|
|
127
|
+
commits: Number.isFinite(n) ? n : null,
|
|
128
|
+
limit: config.built_stale_after_commits,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function wipOf(cards, config) {
|
|
136
|
+
const count = cards.filter((c) => c.data.status === 'built').length;
|
|
60
137
|
|
|
61
|
-
|
|
62
|
-
|
|
138
|
+
return { count, limit: config.wip_built_limit, over: count > config.wip_built_limit };
|
|
139
|
+
}
|
|
63
140
|
|
|
141
|
+
async function pillarsOf(root, config) {
|
|
64
142
|
const pillarsFile = path.join(root, 'PILLARS.md');
|
|
65
|
-
if (existsSync(pillarsFile))
|
|
66
|
-
|
|
67
|
-
|
|
143
|
+
if (!existsSync(pillarsFile)) return null;
|
|
144
|
+
const tokens = tokensEst(await readFile(pillarsFile, 'utf8'));
|
|
145
|
+
|
|
146
|
+
return { tokens, budget: config.pillars_budget, over: tokens > config.pillars_budget };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Whether a verdict fired. `verdict(id)` reads the files, as the ledger does (GCF-PRD §5.8): a
|
|
151
|
+
* keep or kill left a history or graveyard line, a tune left the card's tune_count — so a
|
|
152
|
+
* squash or a shallow clone does not hide it, and a revert takes it back.
|
|
153
|
+
*/
|
|
154
|
+
function verdictMatchers(projectDir, ledger, cards) {
|
|
155
|
+
const tuned = cards.filter((c) => (c.data.tune_count ?? 0) > 0);
|
|
156
|
+
const verdictLog = git(['log', '--grep', '^gcf(verdict):', '--format=%s'], projectDir) ?? '';
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
verdict: (id) =>
|
|
160
|
+
ledger.where(id).terminal.some((t) => t !== 'done') || tuned.some((c) => c.data.id === id),
|
|
161
|
+
// shortcut: a kept card's tunes leave no file behind, so retune(sys) also greps the verdict
|
|
162
|
+
// subjects; upgrade when tunes get their own history line.
|
|
163
|
+
retune: (system) =>
|
|
164
|
+
tuned.some((c) => (c.data.system ?? []).includes(system)) ||
|
|
165
|
+
new RegExp(`^gcf\\(verdict\\): tune .*→ (?:.*,)?${escapeRe(system)}(?:,|$)`, 'm').test(
|
|
166
|
+
verdictLog,
|
|
167
|
+
),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function untilStatus(trigger, fired) {
|
|
172
|
+
if (trigger.startsWith('verdict(')) {
|
|
173
|
+
return fired.verdict(trigger.slice(8, -1)) ? 'stale' : 'open';
|
|
68
174
|
}
|
|
69
175
|
|
|
70
|
-
|
|
176
|
+
if (trigger.startsWith('retune(')) {
|
|
177
|
+
return fired.retune(trigger.slice(7, -1)) ? 'stale' : 'open';
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return 'manual';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Until annotations across cards and systems docs. */
|
|
184
|
+
function untilRecords(cards, systems, projectDir, ledger) {
|
|
185
|
+
const fired = verdictMatchers(projectDir, ledger, cards);
|
|
71
186
|
const docs = [
|
|
72
187
|
...cards.map((c) => ({ rel: `cards/${c.name}`, body: c.body })),
|
|
73
|
-
...
|
|
188
|
+
...systems.map((s) => ({ rel: `systems/${s.name}`, body: s.body })),
|
|
74
189
|
];
|
|
190
|
+
const until = [];
|
|
75
191
|
for (const doc of docs) {
|
|
76
192
|
for (const m of doc.body.matchAll(UNTIL_RE)) {
|
|
77
|
-
|
|
78
|
-
let status = 'open';
|
|
79
|
-
if (trigger.startsWith('verdict(')) status = verdictFired(trigger.slice(8, -1)) ? 'stale' : 'open';
|
|
80
|
-
else if (trigger.startsWith('retune(')) status = retuneFired(trigger.slice(7, -1)) ? 'stale' : 'open';
|
|
81
|
-
else status = 'manual';
|
|
82
|
-
report.until.push({ file: `gcf/${doc.rel}`, trigger, status });
|
|
193
|
+
until.push({ file: `gcf/${doc.rel}`, trigger: m[1], status: untilStatus(m[1], fired) });
|
|
83
194
|
}
|
|
84
195
|
}
|
|
85
196
|
|
|
86
|
-
|
|
87
|
-
|
|
197
|
+
return until;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** П2 linter over systems docs. */
|
|
201
|
+
function lintSystems(report, systems) {
|
|
202
|
+
for (const s of systems) {
|
|
88
203
|
const { issues, pragmas } = lint(s.body);
|
|
89
204
|
if (issues.length) report.linter[`gcf/systems/${s.name}`] = issues;
|
|
90
205
|
report.pragmas.push(...pragmas.map((p) => ({ file: `gcf/systems/${s.name}`, ...p })));
|
|
91
206
|
}
|
|
207
|
+
}
|
|
92
208
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
report.
|
|
106
|
-
|
|
209
|
+
/** Alerts about cards in flight, which lead the line. @param {AuditReport} report */
|
|
210
|
+
const cardAlerts = (report) => [
|
|
211
|
+
report.illegal_status.length && `${report.illegal_status.length} card(s) in illegal status`,
|
|
212
|
+
report.stale_built.length && `${report.stale_built.length} built card(s) stale — playtest first`,
|
|
213
|
+
report.tune_limit.length &&
|
|
214
|
+
`${report.tune_limit.length} card(s) at tune limit — kill or redesign`,
|
|
215
|
+
report.wip.over && `WIP over limit (${report.wip.count}/${report.wip.limit})`,
|
|
216
|
+
];
|
|
217
|
+
|
|
218
|
+
/** Alerts about the documents. @param {AuditReport} report */
|
|
219
|
+
const docAlerts = (report) => [
|
|
220
|
+
report.until.filter((u) => u.status === 'stale').length && 'stale until-records present',
|
|
221
|
+
report.pillars?.over && 'PILLARS over token budget',
|
|
222
|
+
Object.keys(report.linter).length && 'П2 linter findings in systems docs',
|
|
223
|
+
report.index_drift && 'INDEX drift (regenerate)',
|
|
224
|
+
report.design?.blocking && `${report.design.blocking} blocking design finding(s)`,
|
|
225
|
+
];
|
|
226
|
+
|
|
227
|
+
/** @param {AuditReport} report */
|
|
228
|
+
function firstLine(report) {
|
|
229
|
+
const alerts = [...cardAlerts(report), ...docAlerts(report)].filter(Boolean);
|
|
230
|
+
|
|
231
|
+
return alerts.length ? alerts.join('; ') : 'clean';
|
|
107
232
|
}
|