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/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) => String(s).toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
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) => String(s).replace(/\r\n/g, '\n').replace(/[ \t]+$/gm, '').trim();
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
- /** Split markdown into heading-addressed sections. Text before the first heading is `GDD#preamble`. */
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
- let current = { heading: null, level: 0, lines: [] };
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
- for (const line of lines) {
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(sections.map((s) => [s.anchor, { heading: s.heading, body_hash: s.body_hash }]));
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(path.join(root, 'source', `${base}.anchors.json`), JSON.stringify(map, null, 2), 'utf8');
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 = old.body_hash === EMPTY_HASH ? -1 : pool.findIndex((s) => s.body_hash === old.body_hash);
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 report = { first_line: '', stale_built: [], tune_limit: [], open_depends: [], wip: null, pillars: null, until: [], linter: {}, pragmas: [], index_drift: false, illegal_status: [] };
26
- const liveIds = new Set(cards.map((c) => c.data.id));
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 (!['proposed', 'built', 'tuning'].includes(c.data.status)) {
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
- if (inRepo && c.data.status === 'built' && c.data.built_at) {
44
- // git() is null when the ref no longer resolves (rebase, squash, amend, shallow clone).
45
- // Number(null) is 0, which reads as "fresh" — the one answer that must never be guessed.
46
- const raw = git(['rev-list', '--count', `${c.data.built_at}..HEAD`], projectDir);
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({ id: c.data.id, tune_count: c.data.tune_count, limit: config.tune_limit });
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
- const wipCount = cards.filter((c) => c.data.status === 'built').length;
62
- report.wip = { count: wipCount, limit: config.wip_built_limit, over: wipCount > config.wip_built_limit };
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
- const tokens = tokensEst(await readFile(pillarsFile, 'utf8'));
67
- report.pillars = { tokens, budget: config.pillars_budget, over: tokens > config.pillars_budget };
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
- // Until annotations across cards and systems docs.
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
- ...(await listSystems(root)).map((s) => ({ rel: `systems/${s.name}`, body: s.body })),
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
- const trigger = m[1];
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
- // П2 linter over systems docs.
87
- for (const s of await listSystems(root)) {
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
- report.index_drift = await drifted(root);
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 && `${report.stale_built.length} built card(s) stale — playtest first`,
98
- report.tune_limit.length && `${report.tune_limit.length} card(s) at tune limit — kill or redesign`,
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
- report.first_line = alerts.length ? alerts.join('; ') : 'clean';
106
- return report;
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 null;
43
+ if (!existsSync(abs)) return;
22
44
  const text = await readFile(abs, 'utf8');
23
- const entry = { path: `gcf/${rel.replaceAll('\\', '/')}`, tokens_est: tokensEst(text) };
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
- // Graveyard: headline tags only; a hit is a tag appearing in the card's own text.
40
- const graveyard_hits = [];
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
- const cardText = `${card.data.slug} ${systems.join(' ')} ${card.body}`.toLowerCase();
44
- for (const line of (await readFile(gyFile, 'utf8')).split('\n')) {
45
- const m = line.match(/^- (card-\d{3}) \| .* \| tags: (.+)$/);
46
- if (!m) continue;
47
- const tags = m[2].split(',').map((t) => t.trim().toLowerCase()).filter(Boolean);
48
- if (tags.some((t) => cardText.includes(t))) graveyard_hits.push(line.trim());
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
- // D2 assumptions whose sys: overlaps the card's systems (GCF-PRD §9.1).
53
- const d2 = [];
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
- const records = (await readFile(oqFile, 'utf8')).split(/\n(?=- id:)/);
57
- for (const rec of records) {
58
- if (!/^\s*-?\s*id:/m.test(rec) || !/class:\s*D2/.test(rec)) continue;
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
- const extraTokens = tokensEst(graveyard_hits.join('\n')) + tokensEst(d2.join('\n'));
67
- const total_tokens_est = files.reduce((sum, f) => sum + f.tokens_est, 0) + extraTokens;
68
- return {
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
  }
@@ -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)) throw new Error('gcf/config.yml must be a key: value mapping');
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 = { ...DEFAULT_CONFIG, ...Object.fromEntries(Object.entries(parsed).filter(([, v]) => v !== null)) };
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(`gcf/config.yml: ${key} must be a non-negative number, got ${JSON.stringify(config[key])}`);
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'], { cwd, encoding: 'utf8' }).stdout.trim();
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
- return { cwd, prefix, files: rels.filter((r) => !conflicted.has(r)).map((rel) => ({ rel, entry: entries.get(rel) ?? null })) };
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
- ? ['update-index', '--add', '--cacheinfo', `${entry.mode},${entry.blob},${snapshot.prefix}${rel}`]
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
- if (res.status !== 0) errors.push(new Error(`could not restore the index entry of ${rel}: ${(res.stderr || '').trim() || res.error?.message}`));
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
  }