gcf-aif-ext 0.2.5 → 0.2.7
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 +2 -2
- package/commands/gcf.js +229 -71
- package/extension.json +1 -1
- package/hooks/pre-commit.js +28 -14
- package/injections/review-pillars.md +1 -1
- package/lib/anchors.js +108 -10
- package/lib/audit-core.js +136 -41
- package/lib/context.js +54 -36
- package/lib/frontmatter.js +2 -1
- package/lib/gcf-paths.js +14 -3
- package/lib/git.js +22 -4
- package/lib/id.js +2 -0
- package/lib/index-gen.js +33 -15
- package/lib/linter.js +79 -41
- package/lib/safe-path.js +23 -4
- package/lib/transitions.js +1 -3
- package/lib/validate-card.js +42 -8
- package/lib/verdict-fs.js +124 -61
- package/package.json +1 -1
- package/skills/gcf-adopt/SKILL.md +2 -2
- package/skills/gcf-card/SKILL.md +11 -10
- package/skills/gcf-debate/SKILL.md +1 -1
- package/skills/gcf-ingest/SKILL.md +24 -13
- package/skills/gcf-init/SKILL.md +9 -8
- package/skills/gcf-verdict/SKILL.md +1 -1
- package/templates/backlog.md +4 -3
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,66 @@ 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
|
+
/**
|
|
159
|
+
* Provenance check over gcf/backlog.md (GCF-PRD §11.2.3): every line citing `src: GDD#…`
|
|
160
|
+
* must carry `q: "…"`, and the quote must be a verbatim substring of that section in some
|
|
161
|
+
* gcf/source/ file. Line wraps in the GDD are not edits, so runs of whitespace compare equal.
|
|
162
|
+
*/
|
|
163
|
+
export async function checkQuotes(projectDir) {
|
|
164
|
+
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
165
|
+
const backlog = path.join(root, 'backlog.md');
|
|
166
|
+
if (!existsSync(backlog)) return [];
|
|
167
|
+
const bodies = await sourceBodies(path.join(root, 'source'));
|
|
168
|
+
|
|
169
|
+
return (await readFile(backlog, 'utf8'))
|
|
170
|
+
.split('\n')
|
|
171
|
+
.map((line) => quoteProblem(line, bodies))
|
|
172
|
+
.filter((p) => p !== null);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const ws = (t) => String(t).replace(/\s+/g, ' ').trim();
|
|
176
|
+
|
|
177
|
+
/** anchor -> whitespace-collapsed bodies across every gcf/source/ file */
|
|
178
|
+
async function sourceBodies(sourceDir) {
|
|
179
|
+
/** @type {Map<string, string[]>} */
|
|
180
|
+
const bodies = new Map();
|
|
181
|
+
if (!existsSync(sourceDir)) return bodies;
|
|
182
|
+
for (const name of (await readdir(sourceDir)).filter((n) => n.endsWith('.md'))) {
|
|
183
|
+
for (const s of splitSections(await readFile(path.join(sourceDir, name), 'utf8'))) {
|
|
184
|
+
bodies.set(s.anchor, [...(bodies.get(s.anchor) ?? []), ws(s.body)]);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return bodies;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The provenance problem of one backlog line, or null when it has none or cites no GDD. */
|
|
192
|
+
function quoteProblem(line, bodies) {
|
|
193
|
+
const id = line.match(/^- (cand-\d+)/)?.[1];
|
|
194
|
+
const anchor = line.match(/\|\s*src:\s*(GDD#[\w-]+)/)?.[1];
|
|
195
|
+
if (!id || !anchor) return null;
|
|
196
|
+
// The closing quote is the one before the next field, so a quote may itself hold `"`.
|
|
197
|
+
const quote = line.match(/\|\s*q:\s*"(.*?)"\s*(?:\||$)/)?.[1];
|
|
198
|
+
const found = bodies.get(anchor);
|
|
199
|
+
/** @type {string | null} */
|
|
200
|
+
let problem = null;
|
|
201
|
+
if (!found) problem = 'anchor not found in gcf/source/';
|
|
202
|
+
else if (!quote) problem = 'no q: "<verbatim quote>"';
|
|
203
|
+
else if (!found.some((b) => b.includes(ws(quote))))
|
|
204
|
+
problem = 'quote is not verbatim in the section';
|
|
205
|
+
|
|
206
|
+
return problem && { id, anchor, problem };
|
|
207
|
+
}
|
package/lib/audit-core.js
CHANGED
|
@@ -8,10 +8,29 @@ import { drifted } from './index-gen.js';
|
|
|
8
8
|
|
|
9
9
|
function git(args, cwd) {
|
|
10
10
|
const res = spawnSync('git', args, { cwd, encoding: 'utf8' });
|
|
11
|
+
|
|
11
12
|
return res.status === 0 ? res.stdout.trim() : null;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
const UNTIL_RE = /\[until:\s*(verdict\(card-\d{3}\)|retune\([a-z0-9-]+\)|manual\([^)]*\))\]/g;
|
|
16
|
+
const LEGAL_STATUS = ['proposed', 'built', 'tuning'];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {object} AuditReport
|
|
20
|
+
* @property {string} first_line
|
|
21
|
+
* @property {Array<{id: string, commits: number | null, limit: number}>} stale_built
|
|
22
|
+
* @property {Array<{id: string, tune_count: number, limit: number}>} tune_limit
|
|
23
|
+
* @property {Array<{id: string, depends: string[]}>} open_depends
|
|
24
|
+
* @property {{count: number, limit: number, over: boolean}} wip
|
|
25
|
+
* @property {{tokens: number, budget: number, over: boolean} | null} pillars
|
|
26
|
+
* @property {Array<{file: string, trigger: string, status: string}>} until
|
|
27
|
+
* @property {Record<string, object[]>} linter
|
|
28
|
+
* @property {object[]} pragmas
|
|
29
|
+
* @property {boolean} index_drift
|
|
30
|
+
* @property {Array<{id: string, status: string}>} illegal_status
|
|
31
|
+
* @property {number} [schema]
|
|
32
|
+
* @property {Array<{id: string, status: string, system: string[], depends: string[]}>} [cards]
|
|
33
|
+
*/
|
|
15
34
|
|
|
16
35
|
/**
|
|
17
36
|
* Deterministic audit core (GCF-PRD §10.1): until triggers, built staleness, tune
|
|
@@ -22,86 +41,162 @@ export async function audit(projectDir) {
|
|
|
22
41
|
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
23
42
|
const config = await readConfig(root);
|
|
24
43
|
const cards = await listCards(root);
|
|
25
|
-
const
|
|
26
|
-
|
|
44
|
+
const systems = await listSystems(root);
|
|
45
|
+
/** @type {AuditReport} */
|
|
46
|
+
const report = {
|
|
47
|
+
first_line: '',
|
|
48
|
+
stale_built: [],
|
|
49
|
+
tune_limit: [],
|
|
50
|
+
open_depends: [],
|
|
51
|
+
wip: wipOf(cards, config),
|
|
52
|
+
pillars: await pillarsOf(root, config),
|
|
53
|
+
until: untilRecords(cards, systems, projectDir),
|
|
54
|
+
linter: {},
|
|
55
|
+
pragmas: [],
|
|
56
|
+
index_drift: await drifted(root),
|
|
57
|
+
illegal_status: [],
|
|
58
|
+
};
|
|
59
|
+
// The harness gate reads everything from this one report — it never parses gcf/ itself.
|
|
60
|
+
report.schema = config.schema;
|
|
61
|
+
report.cards = cards.map((c) => ({
|
|
62
|
+
id: c.data.id,
|
|
63
|
+
status: c.data.status,
|
|
64
|
+
system: c.data.system ?? [],
|
|
65
|
+
depends: c.data.depends ?? [],
|
|
66
|
+
}));
|
|
67
|
+
checkCards(report, cards, config, projectDir);
|
|
68
|
+
lintSystems(report, systems);
|
|
69
|
+
report.first_line = firstLine(report);
|
|
27
70
|
|
|
71
|
+
return report;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Per-card checks: illegal status, built staleness, tune limit, open depends. */
|
|
75
|
+
function checkCards(report, cards, config, projectDir) {
|
|
76
|
+
const liveIds = new Set(cards.map((c) => c.data.id));
|
|
28
77
|
// Staleness is measured in commits, so outside a repository there is no answer to give.
|
|
29
78
|
// Without this probe every built card would read as stale and stop the harness.
|
|
30
79
|
const inRepo = git(['rev-parse', '--git-dir'], projectDir) !== null;
|
|
31
80
|
|
|
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
81
|
for (const c of cards) {
|
|
40
|
-
if (!
|
|
82
|
+
if (!LEGAL_STATUS.includes(c.data.status)) {
|
|
41
83
|
report.illegal_status.push({ id: c.data.id, status: c.data.status });
|
|
42
84
|
}
|
|
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
|
-
}
|
|
85
|
+
|
|
86
|
+
const stale = inRepo ? staleEntry(c, config, projectDir) : null;
|
|
87
|
+
if (stale) report.stale_built.push(stale);
|
|
88
|
+
|
|
52
89
|
if ((c.data.tune_count ?? 0) >= config.tune_limit) {
|
|
53
|
-
report.tune_limit.push({
|
|
90
|
+
report.tune_limit.push({
|
|
91
|
+
id: c.data.id,
|
|
92
|
+
tune_count: c.data.tune_count,
|
|
93
|
+
limit: config.tune_limit,
|
|
94
|
+
});
|
|
54
95
|
}
|
|
96
|
+
|
|
55
97
|
// A depends id that still has a card file is unplayed (GCF-PRD §7.2). Data for the
|
|
56
98
|
// harness gate, not an alert: a proposed card waiting on its dependency is normal.
|
|
57
99
|
const open = (c.data.depends ?? []).filter((d) => liveIds.has(d));
|
|
58
100
|
if (open.length) report.open_depends.push({ id: c.data.id, depends: open });
|
|
59
101
|
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A stale_built entry for a built card past the commit limit, else null. */
|
|
105
|
+
function staleEntry(c, config, projectDir) {
|
|
106
|
+
if (c.data.status !== 'built' || !c.data.built_at) return null;
|
|
107
|
+
// git() is null when the ref no longer resolves (rebase, squash, amend, shallow clone).
|
|
108
|
+
// Number(null) is 0, which reads as "fresh" — the one answer that must never be guessed.
|
|
109
|
+
const raw = git(['rev-list', '--count', `${c.data.built_at}..HEAD`], projectDir);
|
|
110
|
+
const n = raw === null ? NaN : Number(raw);
|
|
111
|
+
if (!Number.isFinite(n) || n > config.built_stale_after_commits) {
|
|
112
|
+
return {
|
|
113
|
+
id: c.data.id,
|
|
114
|
+
commits: Number.isFinite(n) ? n : null,
|
|
115
|
+
limit: config.built_stale_after_commits,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
60
121
|
|
|
61
|
-
|
|
62
|
-
|
|
122
|
+
function wipOf(cards, config) {
|
|
123
|
+
const count = cards.filter((c) => c.data.status === 'built').length;
|
|
63
124
|
|
|
125
|
+
return { count, limit: config.wip_built_limit, over: count > config.wip_built_limit };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function pillarsOf(root, config) {
|
|
64
129
|
const pillarsFile = path.join(root, 'PILLARS.md');
|
|
65
|
-
if (existsSync(pillarsFile))
|
|
66
|
-
|
|
67
|
-
|
|
130
|
+
if (!existsSync(pillarsFile)) return null;
|
|
131
|
+
const tokens = tokensEst(await readFile(pillarsFile, 'utf8'));
|
|
132
|
+
|
|
133
|
+
return { tokens, budget: config.pillars_budget, over: tokens > config.pillars_budget };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Verdict history from commit messages of the fixed format (GCF-PRD §8). */
|
|
137
|
+
function verdictMatchers(projectDir) {
|
|
138
|
+
const verdictLog = git(['log', '--grep', '^gcf(verdict):', '--format=%s'], projectDir) ?? '';
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
verdict: (cardId) => new RegExp(`^gcf\\(verdict\\): \\w+ ${cardId}\\b`, 'm').test(verdictLog),
|
|
142
|
+
// ponytail: retune(sys) fires on any tune verdict naming the system; the PRD's
|
|
143
|
+
// feel-section-diff refinement can replace this when it proves too coarse.
|
|
144
|
+
retune: (system) => new RegExp(`^gcf\\(verdict\\): tune .*→ .*${system}`, 'm').test(verdictLog),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function untilStatus(trigger, fired) {
|
|
149
|
+
if (trigger.startsWith('verdict(')) {
|
|
150
|
+
return fired.verdict(trigger.slice(8, -1)) ? 'stale' : 'open';
|
|
68
151
|
}
|
|
69
152
|
|
|
70
|
-
|
|
153
|
+
if (trigger.startsWith('retune(')) {
|
|
154
|
+
return fired.retune(trigger.slice(7, -1)) ? 'stale' : 'open';
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return 'manual';
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Until annotations across cards and systems docs. */
|
|
161
|
+
function untilRecords(cards, systems, projectDir) {
|
|
162
|
+
const fired = verdictMatchers(projectDir);
|
|
71
163
|
const docs = [
|
|
72
164
|
...cards.map((c) => ({ rel: `cards/${c.name}`, body: c.body })),
|
|
73
|
-
...
|
|
165
|
+
...systems.map((s) => ({ rel: `systems/${s.name}`, body: s.body })),
|
|
74
166
|
];
|
|
167
|
+
const until = [];
|
|
75
168
|
for (const doc of docs) {
|
|
76
169
|
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 });
|
|
170
|
+
until.push({ file: `gcf/${doc.rel}`, trigger: m[1], status: untilStatus(m[1], fired) });
|
|
83
171
|
}
|
|
84
172
|
}
|
|
85
173
|
|
|
86
|
-
|
|
87
|
-
|
|
174
|
+
return until;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** П2 linter over systems docs. */
|
|
178
|
+
function lintSystems(report, systems) {
|
|
179
|
+
for (const s of systems) {
|
|
88
180
|
const { issues, pragmas } = lint(s.body);
|
|
89
181
|
if (issues.length) report.linter[`gcf/systems/${s.name}`] = issues;
|
|
90
182
|
report.pragmas.push(...pragmas.map((p) => ({ file: `gcf/systems/${s.name}`, ...p })));
|
|
91
183
|
}
|
|
184
|
+
}
|
|
92
185
|
|
|
93
|
-
|
|
94
|
-
|
|
186
|
+
/** @param {AuditReport} report */
|
|
187
|
+
function firstLine(report) {
|
|
95
188
|
const alerts = [
|
|
96
189
|
report.illegal_status.length && `${report.illegal_status.length} card(s) in illegal status`,
|
|
97
|
-
report.stale_built.length &&
|
|
98
|
-
|
|
190
|
+
report.stale_built.length &&
|
|
191
|
+
`${report.stale_built.length} built card(s) stale — playtest first`,
|
|
192
|
+
report.tune_limit.length &&
|
|
193
|
+
`${report.tune_limit.length} card(s) at tune limit — kill or redesign`,
|
|
99
194
|
report.wip.over && `WIP over limit (${report.wip.count}/${report.wip.limit})`,
|
|
100
195
|
report.until.filter((u) => u.status === 'stale').length && 'stale until-records present',
|
|
101
196
|
report.pillars?.over && 'PILLARS over token budget',
|
|
102
197
|
Object.keys(report.linter).length && 'П2 linter findings in systems docs',
|
|
103
198
|
report.index_drift && 'INDEX drift (regenerate)',
|
|
104
199
|
].filter(Boolean);
|
|
105
|
-
|
|
106
|
-
return
|
|
200
|
+
|
|
201
|
+
return alerts.length ? alerts.join('; ') : 'clean';
|
|
107
202
|
}
|
package/lib/context.js
CHANGED
|
@@ -15,14 +15,34 @@ export async function buildContext(projectDir, cardId) {
|
|
|
15
15
|
const card = cards.find((c) => c.data.id === cardId);
|
|
16
16
|
if (!card) throw new Error(`Card not found: ${cardId}`);
|
|
17
17
|
|
|
18
|
+
const systems = card.data.system ?? [];
|
|
19
|
+
const files = await readsetFiles(root, card, cards);
|
|
20
|
+
const cardText = `${card.data.slug} ${systems.join(' ')} ${card.body}`.toLowerCase();
|
|
21
|
+
const graveyard_hits = await graveyardHits(root, cardText);
|
|
22
|
+
const d2 = await d2Assumptions(root, systems);
|
|
23
|
+
|
|
24
|
+
const extraTokens = tokensEst(graveyard_hits.join('\n')) + tokensEst(d2.join('\n'));
|
|
25
|
+
const total_tokens_est = files.reduce((sum, f) => sum + f.tokens_est, 0) + extraTokens;
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
card: cardId,
|
|
29
|
+
files,
|
|
30
|
+
graveyard_hits,
|
|
31
|
+
d2,
|
|
32
|
+
total_tokens_est,
|
|
33
|
+
budget: config.context_budget,
|
|
34
|
+
over_budget: total_tokens_est > config.context_budget,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** PILLARS, touched systems, the card, its depends — in that order, skipping absent files. */
|
|
39
|
+
async function readsetFiles(root, card, cards) {
|
|
18
40
|
const files = [];
|
|
19
41
|
const push = async (rel) => {
|
|
20
42
|
const abs = path.join(root, rel);
|
|
21
|
-
if (!existsSync(abs)) return
|
|
43
|
+
if (!existsSync(abs)) return;
|
|
22
44
|
const text = await readFile(abs, 'utf8');
|
|
23
|
-
|
|
24
|
-
files.push(entry);
|
|
25
|
-
return text;
|
|
45
|
+
files.push({ path: `gcf/${rel.replaceAll('\\', '/')}`, tokens_est: tokensEst(text) });
|
|
26
46
|
};
|
|
27
47
|
|
|
28
48
|
await push('PILLARS.md');
|
|
@@ -30,48 +50,46 @@ export async function buildContext(projectDir, cardId) {
|
|
|
30
50
|
for (const s of (await listSystems(root)).filter((d) => systems.includes(d.data.system))) {
|
|
31
51
|
await push(`systems/${s.name}`);
|
|
32
52
|
}
|
|
53
|
+
|
|
33
54
|
await push(`cards/${card.name}`);
|
|
34
55
|
for (const depId of card.data.depends ?? []) {
|
|
35
56
|
const dep = cards.find((c) => c.data.id === depId);
|
|
36
57
|
if (dep) await push(`cards/${dep.name}`);
|
|
37
58
|
}
|
|
38
59
|
|
|
39
|
-
|
|
40
|
-
|
|
60
|
+
return files;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Graveyard: headline tags only; a hit is a tag appearing in the card's own text. */
|
|
64
|
+
async function graveyardHits(root, cardText) {
|
|
41
65
|
const gyFile = path.join(root, 'graveyard.md');
|
|
42
|
-
if (existsSync(gyFile))
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
66
|
+
if (!existsSync(gyFile)) return [];
|
|
67
|
+
const hits = [];
|
|
68
|
+
for (const line of (await readFile(gyFile, 'utf8')).split('\n')) {
|
|
69
|
+
const m = line.match(/^- (card-\d{3}) \| .* \| tags: (.+)$/);
|
|
70
|
+
if (!m) continue;
|
|
71
|
+
const tags = m[2]
|
|
72
|
+
.split(',')
|
|
73
|
+
.map((t) => t.trim().toLowerCase())
|
|
74
|
+
.filter(Boolean);
|
|
75
|
+
if (tags.some((t) => cardText.includes(t))) hits.push(line.trim());
|
|
50
76
|
}
|
|
51
77
|
|
|
52
|
-
|
|
53
|
-
|
|
78
|
+
return hits;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** D2 assumptions whose sys: overlaps the card's systems (GCF-PRD §9.1). */
|
|
82
|
+
async function d2Assumptions(root, systems) {
|
|
54
83
|
const oqFile = path.join(root, 'open-questions.md');
|
|
55
|
-
if (existsSync(oqFile))
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
84
|
+
if (!existsSync(oqFile)) return [];
|
|
85
|
+
const records = (await readFile(oqFile, 'utf8')).split(/\n(?=- id:)/);
|
|
86
|
+
|
|
87
|
+
return records
|
|
88
|
+
.filter((rec) => /^\s*-?\s*id:/m.test(rec) && /class:\s*D2/.test(rec))
|
|
89
|
+
.filter((rec) => {
|
|
59
90
|
const sys = rec.match(/sys:\s*(.+)/)?.[1] ?? '';
|
|
60
|
-
if (systems.some((s) => sys.includes(s))) {
|
|
61
|
-
d2.push(rec.trim());
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
91
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
card: cardId,
|
|
70
|
-
files,
|
|
71
|
-
graveyard_hits,
|
|
72
|
-
d2,
|
|
73
|
-
total_tokens_est,
|
|
74
|
-
budget: config.context_budget,
|
|
75
|
-
over_budget: total_tokens_est > config.context_budget,
|
|
76
|
-
};
|
|
92
|
+
return systems.some((s) => sys.includes(s));
|
|
93
|
+
})
|
|
94
|
+
.map((rec) => rec.trim());
|
|
77
95
|
}
|
package/lib/frontmatter.js
CHANGED
|
@@ -15,8 +15,9 @@ export function parse(content) {
|
|
|
15
15
|
try {
|
|
16
16
|
data = YAML.parse(m[1]) ?? {};
|
|
17
17
|
} catch (err) {
|
|
18
|
-
throw new Error(`Frontmatter YAML could not be parsed: ${err.message}
|
|
18
|
+
throw new Error(`Frontmatter YAML could not be parsed: ${err.message}`, { cause: err });
|
|
19
19
|
}
|
|
20
|
+
|
|
20
21
|
return { data, body: m[2], hasFrontmatter: true };
|
|
21
22
|
}
|
|
22
23
|
|
package/lib/gcf-paths.js
CHANGED
|
@@ -19,6 +19,7 @@ const DEFAULT_CONFIG = {
|
|
|
19
19
|
export function gcfRoot(projectDir = process.cwd()) {
|
|
20
20
|
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
21
21
|
if (!existsSync(root)) throw new Error(`No gcf/ directory in ${projectDir}. Run gcf init first.`);
|
|
22
|
+
|
|
22
23
|
return root;
|
|
23
24
|
}
|
|
24
25
|
|
|
@@ -26,16 +27,23 @@ export async function readConfig(root) {
|
|
|
26
27
|
const file = path.join(root, 'config.yml');
|
|
27
28
|
if (!existsSync(file)) return { ...DEFAULT_CONFIG };
|
|
28
29
|
const parsed = YAML.parse(await readFile(file, 'utf8')) ?? {};
|
|
29
|
-
if (typeof parsed !== 'object' || Array.isArray(parsed))
|
|
30
|
+
if (typeof parsed !== 'object' || Array.isArray(parsed))
|
|
31
|
+
throw new Error('gcf/config.yml must be a key: value mapping');
|
|
30
32
|
// A blank `key:` parses as null and means "not set", like an absent key.
|
|
31
|
-
const config = {
|
|
33
|
+
const config = {
|
|
34
|
+
...DEFAULT_CONFIG,
|
|
35
|
+
...Object.fromEntries(Object.entries(parsed).filter(([, v]) => v !== null)),
|
|
36
|
+
};
|
|
32
37
|
// A limit that is not a number compares false against everything, which silently
|
|
33
38
|
// switches its gate off — the one failure a gate must not have.
|
|
34
39
|
for (const key of Object.keys(DEFAULT_CONFIG)) {
|
|
35
40
|
if (!Number.isFinite(config[key]) || config[key] < 0) {
|
|
36
|
-
throw new Error(
|
|
41
|
+
throw new Error(
|
|
42
|
+
`gcf/config.yml: ${key} must be a non-negative number, got ${JSON.stringify(config[key])}`,
|
|
43
|
+
);
|
|
37
44
|
}
|
|
38
45
|
}
|
|
46
|
+
|
|
39
47
|
return config;
|
|
40
48
|
}
|
|
41
49
|
|
|
@@ -52,6 +60,7 @@ async function listDocs(dir) {
|
|
|
52
60
|
const { data, body } = parse(await readFile(file, 'utf8'));
|
|
53
61
|
out.push({ file, name, data, body });
|
|
54
62
|
}
|
|
63
|
+
|
|
55
64
|
return out;
|
|
56
65
|
}
|
|
57
66
|
|
|
@@ -61,11 +70,13 @@ export const listSystems = (root) => listDocs(path.join(root, 'systems'));
|
|
|
61
70
|
/** First bullet of a `key:` block in a CNL-P body, for one-line descriptions. */
|
|
62
71
|
export function firstBullet(body, key) {
|
|
63
72
|
const m = String(body).match(new RegExp(`^${key}:\\s*\\n- (.+)$`, 'm'));
|
|
73
|
+
|
|
64
74
|
return m ? m[1].trim() : '';
|
|
65
75
|
}
|
|
66
76
|
|
|
67
77
|
/** Scalar value of a `key:` line. */
|
|
68
78
|
export function scalarOf(body, key) {
|
|
69
79
|
const m = String(body).match(new RegExp(`^${key}:\\s*(.+)$`, 'm'));
|
|
80
|
+
|
|
70
81
|
return m ? m[1].trim() : '';
|
|
71
82
|
}
|
package/lib/git.js
CHANGED
|
@@ -13,7 +13,10 @@ export function snapshotIndex(cwd, files) {
|
|
|
13
13
|
// -z: no C-quoting of non-ASCII paths, so the output is the path itself.
|
|
14
14
|
const res = spawnSync('git', ['ls-files', '-s', '-z', '--', ...rels], { cwd, encoding: 'utf8' });
|
|
15
15
|
if (res.status !== 0) return null;
|
|
16
|
-
const prefix = spawnSync('git', ['rev-parse', '--show-prefix'], {
|
|
16
|
+
const prefix = spawnSync('git', ['rev-parse', '--show-prefix'], {
|
|
17
|
+
cwd,
|
|
18
|
+
encoding: 'utf8',
|
|
19
|
+
}).stdout.trim();
|
|
17
20
|
const entries = new Map();
|
|
18
21
|
const conflicted = new Set();
|
|
19
22
|
for (const rec of res.stdout.split('\0').filter(Boolean)) {
|
|
@@ -22,7 +25,14 @@ export function snapshotIndex(cwd, files) {
|
|
|
22
25
|
if (m[3] === '0') entries.set(m[4], { mode: m[1], blob: m[2] });
|
|
23
26
|
else conflicted.add(m[4]);
|
|
24
27
|
}
|
|
25
|
-
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
cwd,
|
|
31
|
+
prefix,
|
|
32
|
+
files: rels
|
|
33
|
+
.filter((r) => !conflicted.has(r))
|
|
34
|
+
.map((rel) => ({ rel, entry: entries.get(rel) ?? null })),
|
|
35
|
+
};
|
|
26
36
|
}
|
|
27
37
|
|
|
28
38
|
/** Put back the entries `snapshotIndex` took. Returns one Error per path it could not restore. */
|
|
@@ -33,10 +43,18 @@ export function restoreIndex(snapshot) {
|
|
|
33
43
|
// --cacheinfo takes a path from the repository root and ignores the cwd; --force-remove
|
|
34
44
|
// takes one relative to the cwd.
|
|
35
45
|
const args = entry
|
|
36
|
-
? [
|
|
46
|
+
? [
|
|
47
|
+
'update-index',
|
|
48
|
+
'--add',
|
|
49
|
+
'--cacheinfo',
|
|
50
|
+
`${entry.mode},${entry.blob},${snapshot.prefix}${rel}`,
|
|
51
|
+
]
|
|
37
52
|
: ['update-index', '--force-remove', '--', rel];
|
|
38
53
|
const res = spawnSync('git', args, { cwd: snapshot.cwd, encoding: 'utf8' });
|
|
39
|
-
|
|
54
|
+
const reason = (res.stderr || '').trim() || res.error?.message;
|
|
55
|
+
if (res.status !== 0)
|
|
56
|
+
errors.push(new Error(`could not restore the index entry of ${rel}: ${reason}`));
|
|
40
57
|
}
|
|
58
|
+
|
|
41
59
|
return errors;
|
|
42
60
|
}
|