gcf-aif-ext 0.2.7 → 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 +6 -4
- package/commands/gcf.js +190 -13
- package/extension.json +1 -1
- package/hooks/pre-commit.js +34 -30
- package/injections/commit-status.md +1 -0
- package/injections/plan-context.md +3 -2
- package/lib/anchors.js +36 -34
- package/lib/audit-core.js +56 -26
- package/lib/bundle.js +275 -0
- package/lib/context.js +3 -2
- package/lib/design-check.js +580 -0
- package/lib/design.js +179 -0
- package/lib/git.js +63 -0
- package/lib/id.js +7 -15
- package/lib/index-gen.js +42 -8
- package/lib/ledger.js +92 -0
- package/lib/linter.js +14 -3
- package/lib/migrate.js +345 -0
- package/lib/playtest-list.js +1 -1
- package/lib/systems-doc.js +70 -0
- package/lib/unit-ops.js +270 -0
- package/lib/validate-card.js +29 -18
- package/lib/verdict-fs.js +102 -88
- 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 +1 -1
- package/skills/gcf-audit/SKILL.md +4 -3
- package/skills/gcf-card/SKILL.md +25 -16
- package/skills/gcf-debate/SKILL.md +17 -14
- package/skills/gcf-ingest/SKILL.md +39 -23
- package/skills/gcf-init/SKILL.md +3 -3
- package/skills/gcf-verdict/SKILL.md +6 -5
- package/skills/gcf-vision/SKILL.md +17 -11
- package/templates/PILLARS.md +2 -0
- package/templates/backlog.md +2 -5
- 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
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
// Design check (GCF-PRD §11.9): the mechanical checks over gcf/design/, each with a severity.
|
|
2
|
+
// `block` refuses a commit (the pre-commit hook runs this); `report` is a reading of where
|
|
3
|
+
// the design stands — an unfinished unit is the normal state of a design, not a violation.
|
|
4
|
+
import { readFile } from 'node:fs/promises';
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { bodyIssues, parseBlocks, itemsOf, readRecords } from 'cnlp-kit';
|
|
8
|
+
import {
|
|
9
|
+
SLUG_RE,
|
|
10
|
+
UNIT_TYPES,
|
|
11
|
+
CARD_TYPES,
|
|
12
|
+
AGENT_SLOTS,
|
|
13
|
+
listOf,
|
|
14
|
+
loadDocProfile,
|
|
15
|
+
slotValues,
|
|
16
|
+
provenanceOf,
|
|
17
|
+
isTbd,
|
|
18
|
+
} from './design.js';
|
|
19
|
+
import { buildLedger } from './ledger.js';
|
|
20
|
+
import { sourceBodies } from './anchors.js';
|
|
21
|
+
|
|
22
|
+
// Units a number may carry. A project adds its own in the `units:` block of glossary.md.
|
|
23
|
+
// Bare `px` is absent on purpose: its size depends on the screen density.
|
|
24
|
+
export const UNITS = [
|
|
25
|
+
...['s', 'ms', 'min', 'h', '%', 'fps', 'KB', 'MB', 'GB', 'deg', 'm'],
|
|
26
|
+
...['cell', 'cells', 'tile', 'tiles', 'css-px'],
|
|
27
|
+
];
|
|
28
|
+
// A unit is one token, or `<noun> per <event>` for a rate (`2..4 coins per level`).
|
|
29
|
+
const ENVELOPE_RE = /^-?\d+(?:\.\d+)?\.\.-?\d+(?:\.\d+)? (\S+)(?: per \S+)?$/;
|
|
30
|
+
// `in` takes only a set or a hard range: "respawn in 5 s" is a duration in prose, not a limit.
|
|
31
|
+
const COMPARISON_RE = new RegExp(
|
|
32
|
+
'(?:^|\\s)(?:(?:=|<=|>=|<|>)\\s+-?\\d[\\d.]*' + // an operator and a value,
|
|
33
|
+
'|in\\s+(?:\\{[^}]*\\}|-?\\d[\\d.]*\\.\\.-?\\d[\\d.]*))' + // or `in` a set or a range
|
|
34
|
+
'(?:\\s+(\\S+)(?: per \\S+)?)?$', // and its unit, closing the line
|
|
35
|
+
);
|
|
36
|
+
const ENVELOPE_BLOCKS = ['feel', 'sources', 'sinks'];
|
|
37
|
+
const FLAGS = ['src-changed', 'orphan', 'impl?'];
|
|
38
|
+
const REQUIRED_HEADER = ['type', 'route', 'system', 'milestone', 'pillars', 'depends', 'source'];
|
|
39
|
+
const ws = (t) => String(t).replace(/\s+/g, ' ').trim();
|
|
40
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @typedef {object} Issue
|
|
44
|
+
* @property {string} check M1..M14
|
|
45
|
+
* @property {string} severity 'block' refuses a commit, 'report' is a reading
|
|
46
|
+
* @property {string} file gcf-relative
|
|
47
|
+
* @property {number} line
|
|
48
|
+
* @property {string | null} id
|
|
49
|
+
* @property {string} message
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Text a check may judge: quoted and backticked spans are data, paths and ids are names.
|
|
54
|
+
* `allSlugs` drops every hyphen slug (the glossary check: `tile-tray` is an id, not prose);
|
|
55
|
+
* otherwise only an id carrying a digit goes (`q-003`), so a unit like `css-px` stays.
|
|
56
|
+
*/
|
|
57
|
+
const judged = (v, allSlugs = false) =>
|
|
58
|
+
String(v)
|
|
59
|
+
.replace(/"[^"]*"|`[^`]*`/g, ' ')
|
|
60
|
+
.replace(/\S*\/\S*/g, ' ')
|
|
61
|
+
.replace(/\b[a-z][a-z0-9]*(?:-[a-z0-9]+)+\b/g, (s) => (allSlugs || /\d/.test(s) ? ' ' : s))
|
|
62
|
+
.replace(/\s*\[until:[^\]]*\]\s*$/, '');
|
|
63
|
+
|
|
64
|
+
/** M5: a digit is legal as an envelope (feel, a resource rate) or as a closing comparison. */
|
|
65
|
+
export function numberProblem(block, value, units = UNITS) {
|
|
66
|
+
const v = judged(value).trim();
|
|
67
|
+
if (!/\d/.test(v)) return null;
|
|
68
|
+
const env = v.match(ENVELOPE_RE);
|
|
69
|
+
if (env) {
|
|
70
|
+
if (!ENVELOPE_BLOCKS.includes(block)) return 'an envelope is legal only in feel and in a rate';
|
|
71
|
+
|
|
72
|
+
return units.includes(env[1]) ? null : `unit "${env[1]}" is not in the closed list`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// A comparison closing the line covers its own number only: "lasts 0.25 s, cooldown = 1 s"
|
|
76
|
+
// still carries a number in prose before the operator.
|
|
77
|
+
const cmp = v.match(COMPARISON_RE);
|
|
78
|
+
const rest = cmp ? v.slice(0, cmp.index) : v;
|
|
79
|
+
if (cmp && cmp[1] && !units.includes(cmp[1])) return `unit "${cmp[1]}" is not in the list`;
|
|
80
|
+
if (!/\d/.test(namesOut(rest))) return null;
|
|
81
|
+
|
|
82
|
+
return 'a number outside an envelope and a closing comparison: write it as one, or in words';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// A token where letters and digits are glued is a name when it starts with a letter (Vector3,
|
|
86
|
+
// Rigidbody2D) or is a dimension (3D) or a size label (1x2). A number glued to anything else
|
|
87
|
+
// stays a quantity (100px, 500HP, 60fps, 2x, 3rd), and so does a multiplier (x2).
|
|
88
|
+
const NAME_TOKEN = /\b(?=[A-Za-z0-9]*[A-Za-z])(?=[A-Za-z0-9]*\d)[A-Za-z0-9]+\b/g;
|
|
89
|
+
const isName = (t) => (/^[A-Za-z]/.test(t) && !/^x\d+$/i.test(t)) || /^\d+(?:D|x\d+)$/i.test(t);
|
|
90
|
+
const namesOut = (v) => v.replace(NAME_TOKEN, (t) => (isName(t) ? ' ' : t));
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* True when a line's numbers are all one exact comparison closing it — legal in any block. The
|
|
94
|
+
* linter's own unit list is wider, so a unit outside the M5 list is the linter's to judge.
|
|
95
|
+
*/
|
|
96
|
+
export const closingComparison = (line) => {
|
|
97
|
+
const v = judged(line).trim();
|
|
98
|
+
const cmp = v.match(COMPARISON_RE);
|
|
99
|
+
|
|
100
|
+
return Boolean(cmp) && !/\d/.test(namesOut(v.slice(0, cmp?.index ?? 0)));
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/** A whole-word, case-insensitive match that also works for Cyrillic (`\b` is ASCII-only). */
|
|
104
|
+
const wordRe = (w) => new RegExp(`(?<![\\p{L}\\p{N}_])${escapeRe(w)}(?![\\p{L}\\p{N}_])`, 'iu');
|
|
105
|
+
|
|
106
|
+
async function readIf(file) {
|
|
107
|
+
return existsSync(file) ? readFile(file, 'utf8') : null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Items of one top-level `key:` block of a document: raw item lines. */
|
|
111
|
+
function itemsIn(text, key) {
|
|
112
|
+
if (text === null) return [];
|
|
113
|
+
const { lines, sections } = parseBlocks(text);
|
|
114
|
+
const at = sections.find((s) => s.key === key);
|
|
115
|
+
|
|
116
|
+
return at ? itemsOf(lines, at.line) : [];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const recordsIn = (text, key) => readRecords(itemsIn(text, key)).records.map((r) => r.values);
|
|
120
|
+
|
|
121
|
+
/** `blocks:` of open-questions.md: every reference, and the D1 ones per blocked id. */
|
|
122
|
+
export function questionBlocks(text) {
|
|
123
|
+
/** @type {Map<string, string[]>} */
|
|
124
|
+
const d1 = new Map();
|
|
125
|
+
const refs = [];
|
|
126
|
+
for (const rec of String(text ?? '').split(/\n(?=- id:)/)) {
|
|
127
|
+
const qid = rec.match(/^- id:\s*(\S+)/)?.[1];
|
|
128
|
+
const ids = listOf(rec.match(/^\s+blocks:\s*(.+)$/m)?.[1] ?? '');
|
|
129
|
+
if (!qid || ids === 'TBD' || !ids.length) continue;
|
|
130
|
+
refs.push({ qid, ids });
|
|
131
|
+
if (!/^\s+class:\s*D1\b/m.test(rec)) continue;
|
|
132
|
+
for (const id of ids) d1.set(id, [...(d1.get(id) ?? []), qid]);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { d1, refs };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Everything outside a unit that its references resolve against. */
|
|
139
|
+
async function loadContext(root) {
|
|
140
|
+
const glossary = await readIf(path.join(root, 'glossary.md'));
|
|
141
|
+
const game = await readIf(path.join(root, 'game.md'));
|
|
142
|
+
const pillars = await readIf(path.join(root, 'PILLARS.md'));
|
|
143
|
+
const forbidden = recordsIn(glossary, 'terms').flatMap((t) => {
|
|
144
|
+
const not = listOf(t.not ?? '');
|
|
145
|
+
|
|
146
|
+
return not === 'TBD' ? [] : not;
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
milestones: game === null ? null : recordsIn(game, 'milestones').map((m) => m.name),
|
|
151
|
+
pillarIds: recordsIn(pillars, 'pillars')
|
|
152
|
+
.map((p) => p.id)
|
|
153
|
+
.filter(Boolean),
|
|
154
|
+
forbidden: forbidden.map((w) => ({ word: w, re: wordRe(w) })),
|
|
155
|
+
units: [
|
|
156
|
+
...UNITS,
|
|
157
|
+
...itemsIn(glossary, 'units').map((i) => i.text.replace(/^- /, '').split(':')[0].trim()),
|
|
158
|
+
],
|
|
159
|
+
questions: questionBlocks(await readIf(path.join(root, 'open-questions.md'))),
|
|
160
|
+
sources: await sourceBodies(path.join(root, 'source')),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Header rules: each returns `[check, message]` or null for one field of a unit's header. */
|
|
165
|
+
const HEADER_RULES = {
|
|
166
|
+
type: (v) => (UNIT_TYPES.includes(v) ? null : ['M1', `type must be ${UNIT_TYPES.join(' | ')}`]),
|
|
167
|
+
route: (v, u) => {
|
|
168
|
+
const m = v.match(/^(soft|hard) \(.+\)$/);
|
|
169
|
+
if (!m) return ['M1', 'route must read "soft|hard (<why, one line>)"'];
|
|
170
|
+
const soft = CARD_TYPES.includes(u.header.type);
|
|
171
|
+
if ((m[1] === 'soft') === soft || !UNIT_TYPES.includes(u.header.type)) return null;
|
|
172
|
+
|
|
173
|
+
return [
|
|
174
|
+
'M1',
|
|
175
|
+
`a ${u.header.type} is ${soft ? 'soft' : 'hard'}: only mechanic and resource get a card`,
|
|
176
|
+
];
|
|
177
|
+
},
|
|
178
|
+
system: (v, u) => {
|
|
179
|
+
const list = listOf(v);
|
|
180
|
+
if (list === 'TBD' || !list.length || !list.every((s) => SLUG_RE.test(s)))
|
|
181
|
+
return ['M1', 'system must list >=1 subsystem slugs'];
|
|
182
|
+
|
|
183
|
+
return list[0] === u.system
|
|
184
|
+
? null
|
|
185
|
+
: ['M1', `the first system must be ${u.system}, the file it lives in`];
|
|
186
|
+
},
|
|
187
|
+
milestone: (v, u, ctx) => {
|
|
188
|
+
if (v === 'TBD' || v === 'later') return null;
|
|
189
|
+
if (!SLUG_RE.test(v)) return ['M1', 'milestone must be a milestone name, later or TBD'];
|
|
190
|
+
|
|
191
|
+
return ctx.milestones === null || ctx.milestones.includes(v)
|
|
192
|
+
? null
|
|
193
|
+
: ['M2', `milestone ${v} is not a milestone of game.md`];
|
|
194
|
+
},
|
|
195
|
+
pillars: (v, u, ctx) => {
|
|
196
|
+
const list = listOf(v);
|
|
197
|
+
if (list === 'TBD') return null;
|
|
198
|
+
const missing = list.filter((p) => !ctx.pillarIds.includes(p));
|
|
199
|
+
|
|
200
|
+
return missing.length ? ['M2', `pillar ${missing.join(', ')} has no id in PILLARS.md`] : null;
|
|
201
|
+
},
|
|
202
|
+
depends: (v, u, ctx, ledger) => {
|
|
203
|
+
const list = listOf(v);
|
|
204
|
+
if (list === 'TBD') return ['M1', 'depends lists ids or reads none'];
|
|
205
|
+
const unknown = list.filter((d) => d === u.id || !ledger.known(d));
|
|
206
|
+
|
|
207
|
+
return unknown.length ? ['M2', `depends on unknown ${unknown.join(', ')}`] : null;
|
|
208
|
+
},
|
|
209
|
+
source: (v) =>
|
|
210
|
+
/^(GDD#[A-Za-z0-9_-]+|manual)$/.test(v)
|
|
211
|
+
? null
|
|
212
|
+
: ['M1', 'source must be GDD#<anchor> or manual'],
|
|
213
|
+
flags: (v) => {
|
|
214
|
+
const list = listOf(v);
|
|
215
|
+
|
|
216
|
+
return list !== 'TBD' && list.every((f) => FLAGS.includes(f))
|
|
217
|
+
? null
|
|
218
|
+
: ['M1', `flags are ${FLAGS.join(', ')}`];
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
/** M1/M2 over a unit's header. */
|
|
223
|
+
function headerIssues(u, ctx, ledger) {
|
|
224
|
+
const out = [];
|
|
225
|
+
if (!SLUG_RE.test(u.id)) out.push(['M1', u.line, 'the id must be a lowercase hyphen slug']);
|
|
226
|
+
for (const key of REQUIRED_HEADER) {
|
|
227
|
+
if (!(key in u.header)) out.push(['M1', u.line, `missing header field ${key}:`]);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
for (const [key, value] of Object.entries(u.header)) {
|
|
231
|
+
const res = HEADER_RULES[key]?.(value, u, ctx, ledger);
|
|
232
|
+
if (res) out.push([res[0], u.headerLines[key], `${key}: ${res[1]}`]);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return out;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Blocks the card stage writes: a unit in design/ has not reached them yet.
|
|
239
|
+
const CARD_STAGE = ['contract', 'interactions'];
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* M1 over a body: the profile of its type, with `key: TBD` accepted on any block. In design/
|
|
243
|
+
* (`stage` 'design') the card-stage blocks are not owed yet.
|
|
244
|
+
*/
|
|
245
|
+
async function bodyProblems(body, type, stage = 'card') {
|
|
246
|
+
const profile = await loadDocProfile(`unit-${type}`);
|
|
247
|
+
const tbdLines = new Set(
|
|
248
|
+
parseBlocks(body)
|
|
249
|
+
.sections.filter((s) => s.value === 'TBD')
|
|
250
|
+
.map((s) => s.line),
|
|
251
|
+
);
|
|
252
|
+
const owed = (m) =>
|
|
253
|
+
stage === 'design' && CARD_STAGE.some((k) => m === `missing required block "${k}:"`);
|
|
254
|
+
const issues = bodyIssues(body, profile).filter(
|
|
255
|
+
(i) =>
|
|
256
|
+
!owed(i.message) &&
|
|
257
|
+
!(tbdLines.has(i.line) && i.message.includes('carries a value on the key line')),
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
return { profile, issues };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** M4 and M5 over the values of a unit. */
|
|
264
|
+
function valueIssues(values, ctx) {
|
|
265
|
+
const out = [];
|
|
266
|
+
for (const v of values) {
|
|
267
|
+
const text = judged(v.value, true);
|
|
268
|
+
const hit = ctx.forbidden.find((f) => f.re.test(text));
|
|
269
|
+
if (hit) out.push(['M4', v.line, `"${hit.word}" is a forbidden synonym in glossary.md`]);
|
|
270
|
+
// `contract` names code — paths and types like Rigidbody2D — not quantities.
|
|
271
|
+
const num = v.block === 'contract' ? null : numberProblem(v.block, v.value, ctx.units);
|
|
272
|
+
if (num) out.push(['M5', v.line, `${num}: ${v.value}`]);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return out;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** One provenance record: its own form, and its quote verbatim in the section it names. */
|
|
279
|
+
function recordProblem(r, ctx) {
|
|
280
|
+
if (r.src || r.quote) {
|
|
281
|
+
if (!r.src || !r.quote) return 'src and quote go together';
|
|
282
|
+
const bodies = ctx.sources.get(r.src);
|
|
283
|
+
if (!bodies) return `${r.src} is not an anchor of gcf/source/`;
|
|
284
|
+
|
|
285
|
+
return bodies.some((b) => b.includes(ws(r.quote)))
|
|
286
|
+
? null
|
|
287
|
+
: `quote is not verbatim in ${r.src}: "${r.quote}"`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (r.answer) return /^(q-\d+|manual)$/.test(r.answer) ? null : 'answer is q-NNN or manual';
|
|
291
|
+
if (r.assumed) return /^q-\d+$/.test(r.assumed) ? null : 'assumed is q-NNN';
|
|
292
|
+
|
|
293
|
+
return 'a record carries src + quote, answer, or assumed';
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Slots with content a GDD-sourced unit must back by a provenance record. */
|
|
297
|
+
function uncovered(values, records) {
|
|
298
|
+
const covers = (block, name) =>
|
|
299
|
+
records.some(
|
|
300
|
+
(r) =>
|
|
301
|
+
r.slot === block ||
|
|
302
|
+
(name ? r.slot === `${block}.${name}` : r.slot?.startsWith(`${block}.`)),
|
|
303
|
+
);
|
|
304
|
+
const need = new Map();
|
|
305
|
+
for (const v of values.filter((x) => !isTbd(x.value) && !AGENT_SLOTS.includes(x.block))) {
|
|
306
|
+
const slot = v.name ? `${v.block}.${v.name}` : v.block;
|
|
307
|
+
if (!need.has(slot) && !covers(v.block, v.name)) need.set(slot, v.line);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return [...need];
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* A problem that comes from the GDD moving under a record — the quote changed, the anchor went —
|
|
315
|
+
* rather than from the record itself. A flagged unit and a card report it instead of blocking:
|
|
316
|
+
* a re-import must not lock every commit until each record is rewritten.
|
|
317
|
+
*/
|
|
318
|
+
export const isDrift = (problem) =>
|
|
319
|
+
/^(quote is not verbatim|GDD#\S+ is not an anchor)/.test(problem ?? '');
|
|
320
|
+
|
|
321
|
+
/** M9: provenance records are well formed, quotes verbatim, every GDD-sourced slot backed. */
|
|
322
|
+
function provenanceIssues(u, values, ctx) {
|
|
323
|
+
const records = provenanceOf(u.body);
|
|
324
|
+
const flags = listOf(u.header.flags ?? '');
|
|
325
|
+
const flagged = flags !== 'TBD' && flags.some((f) => ['src-changed', 'orphan'].includes(f));
|
|
326
|
+
const out = [];
|
|
327
|
+
for (const r of records) {
|
|
328
|
+
const problem = recordProblem(r, ctx);
|
|
329
|
+
const sev = flagged && isDrift(problem) ? 'report' : 'block';
|
|
330
|
+
if (problem) out.push(['M9', r.line, problem, sev]);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (!String(u.header.source ?? '').startsWith('GDD#')) return out;
|
|
334
|
+
for (const [slot, line] of uncovered(values, records)) {
|
|
335
|
+
out.push(['M9', line, `${slot} has content and no provenance record`]);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return out;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Every check over one unit; issue lines are lines of the design file. */
|
|
342
|
+
async function checkUnit(u, ctx, ledger) {
|
|
343
|
+
/** @type {Issue[]} */
|
|
344
|
+
const issues = [];
|
|
345
|
+
const add = (check, line, message, severity = 'block') =>
|
|
346
|
+
issues.push({ check, severity, file: u.file, line, id: u.id, message });
|
|
347
|
+
const atBody = (line) => (line ? u.bodyLine + line - 1 : u.line);
|
|
348
|
+
for (const [c, line, m] of headerIssues(u, ctx, ledger)) add(c, line, m);
|
|
349
|
+
if (!UNIT_TYPES.includes(u.header.type)) return { issues, values: [] };
|
|
350
|
+
|
|
351
|
+
const { profile, issues: shape } = await bodyProblems(u.body, u.header.type, 'design');
|
|
352
|
+
for (const i of shape) add('M1', atBody(i.line), i.message);
|
|
353
|
+
const values = slotValues(u.body, profile);
|
|
354
|
+
for (const [c, line, m] of valueIssues(values, ctx)) add(c, atBody(line), m);
|
|
355
|
+
for (const [c, line, m, sev] of provenanceIssues(u, values, ctx)) add(c, atBody(line), m, sev);
|
|
356
|
+
|
|
357
|
+
return { issues, values };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** M1 and M5 over a singleton document (game.md, glossary.md), `key: TBD` accepted. */
|
|
361
|
+
async function checkDoc(root, name, ctx) {
|
|
362
|
+
const text = await readIf(path.join(root, `${name}.md`));
|
|
363
|
+
if (text === null) return [];
|
|
364
|
+
const profile = await loadDocProfile(name);
|
|
365
|
+
const tbdLines = new Set(
|
|
366
|
+
parseBlocks(text)
|
|
367
|
+
.sections.filter((s) => s.value === 'TBD')
|
|
368
|
+
.map((s) => s.line),
|
|
369
|
+
);
|
|
370
|
+
const shape = bodyIssues(text, profile).filter(
|
|
371
|
+
(i) => !(tbdLines.has(i.line) && i.message.includes('carries a value on the key line')),
|
|
372
|
+
);
|
|
373
|
+
const nums = slotValues(text, profile).flatMap((v) => {
|
|
374
|
+
const p = numberProblem(v.block, v.value, ctx.units);
|
|
375
|
+
|
|
376
|
+
return p ? [{ line: v.line, message: `${p}: ${v.value}`, check: 'M5' }] : [];
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
return [...shape.map((i) => ({ ...i, check: 'M1' })), ...nums].map((i) => ({
|
|
380
|
+
check: i.check,
|
|
381
|
+
severity: 'block',
|
|
382
|
+
file: `${name}.md`,
|
|
383
|
+
line: i.line,
|
|
384
|
+
id: null,
|
|
385
|
+
message: i.message,
|
|
386
|
+
}));
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Each design file opens with the subsystem's `purpose:` (`TBD` until someone writes it). */
|
|
390
|
+
function preambleIssues(design) {
|
|
391
|
+
return design
|
|
392
|
+
.filter((d) => !parseBlocks(d.preamble).sections.some((s) => s.key === 'purpose'))
|
|
393
|
+
.map((d) => ({
|
|
394
|
+
check: 'M1',
|
|
395
|
+
severity: 'block',
|
|
396
|
+
file: d.file,
|
|
397
|
+
line: 1,
|
|
398
|
+
id: null,
|
|
399
|
+
message: `${d.file} opens with "purpose:" — why the subsystem exists, or TBD`,
|
|
400
|
+
}));
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* A card is a unit in work: the same profile, plus what the card stage owes — no TBD left,
|
|
405
|
+
* `contract` and `interactions` written. Its frontmatter is validated by the schema separately.
|
|
406
|
+
*/
|
|
407
|
+
async function checkCard(card, ctx) {
|
|
408
|
+
/** @type {Issue[]} */
|
|
409
|
+
const issues = [];
|
|
410
|
+
const file = `cards/${card.name}`;
|
|
411
|
+
const raw = await readFile(card.file, 'utf8');
|
|
412
|
+
const offset = raw.split('\n').length - card.body.split('\n').length;
|
|
413
|
+
const add = (check, line, message, severity = 'block') =>
|
|
414
|
+
issues.push({ check, severity, file, line: line + offset, id: card.data.id, message });
|
|
415
|
+
if (!CARD_TYPES.includes(card.data.type)) return issues;
|
|
416
|
+
|
|
417
|
+
const { profile, issues: shape } = await bodyProblems(card.body, card.data.type);
|
|
418
|
+
for (const i of shape) add('M1', i.line, i.message);
|
|
419
|
+
const values = slotValues(card.body, profile);
|
|
420
|
+
for (const v of values.filter((x) => isTbd(x.value)))
|
|
421
|
+
add('M1', v.line, `a card carries no TBD: ${v.block}`);
|
|
422
|
+
for (const [c, line, m] of valueIssues(values, ctx)) add(c, line, m);
|
|
423
|
+
for (const r of provenanceOf(card.body)) {
|
|
424
|
+
const problem = recordProblem(r, ctx);
|
|
425
|
+
// A card's quotes go stale when the GDD moves on: that is a D1 for the human, not a gate.
|
|
426
|
+
if (problem) add('M9', r.line, problem, isDrift(problem) ? 'report' : 'block');
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return issues;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** M12: one live place per id, and no silent return from a terminal state. */
|
|
433
|
+
function ledgerIssues(ledger) {
|
|
434
|
+
/** @type {Issue[]} */
|
|
435
|
+
const out = [];
|
|
436
|
+
for (const id of ledger.ids()) {
|
|
437
|
+
const { live, terminal } = ledger.where(id);
|
|
438
|
+
const file = live[0]?.file ?? 'history.md';
|
|
439
|
+
const add = (message) =>
|
|
440
|
+
out.push({ check: 'M12', severity: 'block', file, line: 1, id, message });
|
|
441
|
+
if (live.length > 1)
|
|
442
|
+
add(`${id} lives in ${live.length} places: ${live.map((p) => p.file).join(', ')}`);
|
|
443
|
+
if (live.length && terminal.length && !reopens(ledger, id)) {
|
|
444
|
+
add(
|
|
445
|
+
`${id} is ${terminal.join(' and ')} already: a live copy needs "reopens: <why>" ` +
|
|
446
|
+
'(an undone move: take its line out of history.md or graveyard.md)',
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return out;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function reopens(ledger, id) {
|
|
455
|
+
const unit = ledger.design.flatMap((d) => d.units).find((u) => u.id === id);
|
|
456
|
+
const card = ledger.cards.find((c) => c.data.id === id);
|
|
457
|
+
|
|
458
|
+
return Boolean(unit?.header.reopens || card?.data.reopens);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** M14: backlog.md is the order of the units in design/, each exactly once. */
|
|
462
|
+
function backlogIssues(text, unitIds) {
|
|
463
|
+
/** @type {Issue[]} */
|
|
464
|
+
const out = [];
|
|
465
|
+
if (text === null) return out;
|
|
466
|
+
const add = (line, id, message) =>
|
|
467
|
+
out.push({ check: 'M14', severity: 'block', file: 'backlog.md', line, id, message });
|
|
468
|
+
const seen = new Set();
|
|
469
|
+
text.split('\n').forEach((l, i) => {
|
|
470
|
+
const id = l.match(/^- (\S+)/)?.[1];
|
|
471
|
+
if (!id) return;
|
|
472
|
+
if (seen.has(id)) add(i + 1, id, `${id} is listed twice`);
|
|
473
|
+
else if (!unitIds.has(id)) add(i + 1, id, `${id} is not a unit of gcf/design/`);
|
|
474
|
+
seen.add(id);
|
|
475
|
+
});
|
|
476
|
+
for (const id of unitIds)
|
|
477
|
+
if (!seen.has(id)) add(1, id, `${id} is missing from the backlog order`);
|
|
478
|
+
|
|
479
|
+
return out;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** M2 over open-questions.md: every `blocks:` names a known id. */
|
|
483
|
+
function questionIssues(ctx, ledger) {
|
|
484
|
+
return ctx.questions.refs.flatMap(({ qid, ids }) =>
|
|
485
|
+
ids
|
|
486
|
+
.filter((id) => !ledger.known(id))
|
|
487
|
+
.map((id) => ({
|
|
488
|
+
check: 'M2',
|
|
489
|
+
severity: 'block',
|
|
490
|
+
file: 'open-questions.md',
|
|
491
|
+
line: 1,
|
|
492
|
+
id: qid,
|
|
493
|
+
message: `${qid} blocks unknown ${id}`,
|
|
494
|
+
})),
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** Tallies per key: units, ready units, slot values, TBD values. */
|
|
499
|
+
function tally(units, key) {
|
|
500
|
+
/** @type {Record<string, {units: number, ready: number, values: number, tbd: number}>} */
|
|
501
|
+
const out = {};
|
|
502
|
+
for (const u of units) {
|
|
503
|
+
const t = (out[u[key]] ??= { units: 0, ready: 0, values: 0, tbd: 0 });
|
|
504
|
+
t.units++;
|
|
505
|
+
t.ready += u.ready ? 1 : 0;
|
|
506
|
+
t.values += u.values;
|
|
507
|
+
t.tbd += u.tbd;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return out;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** M7: which pillars the units serve, which serve none, which units serve no pillar. */
|
|
514
|
+
function pillarReport(units, ctx) {
|
|
515
|
+
const refs = Object.fromEntries(ctx.pillarIds.map((p) => [p, 0]));
|
|
516
|
+
for (const u of units.filter((x) => x.pillars !== 'TBD')) {
|
|
517
|
+
for (const p of u.pillars) refs[p] = (refs[p] ?? 0) + 1;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return {
|
|
521
|
+
refs,
|
|
522
|
+
unreferenced: Object.keys(refs).filter((p) => refs[p] === 0),
|
|
523
|
+
// `pillars: TBD` is waiting for the pillars interview, not a unit without a reason.
|
|
524
|
+
without: units
|
|
525
|
+
.filter((u) => CARD_TYPES.includes(u.type) && u.pillars !== 'TBD' && !u.pillars.length)
|
|
526
|
+
.map((u) => u.id),
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Run every design check over a project. `blocking` counts the issues that refuse a commit;
|
|
532
|
+
* the rest of the report (completeness, readiness, pillars) is a reading, not a gate.
|
|
533
|
+
*/
|
|
534
|
+
export async function designCheck(projectDir) {
|
|
535
|
+
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
536
|
+
const ledger = await buildLedger(projectDir);
|
|
537
|
+
const ctx = await loadContext(root);
|
|
538
|
+
/** @type {Issue[]} */
|
|
539
|
+
const issues = [];
|
|
540
|
+
const units = [];
|
|
541
|
+
for (const u of ledger.design.flatMap((d) => d.units)) {
|
|
542
|
+
const { issues: own, values } = await checkUnit(u, ctx, ledger);
|
|
543
|
+
issues.push(...own);
|
|
544
|
+
const tbd = values.filter((v) => isTbd(v.value)).length;
|
|
545
|
+
const blockedBy = ctx.questions.d1.get(u.id) ?? [];
|
|
546
|
+
const pillars = listOf(u.header.pillars ?? '');
|
|
547
|
+
units.push({
|
|
548
|
+
id: u.id,
|
|
549
|
+
file: u.file,
|
|
550
|
+
system: u.system,
|
|
551
|
+
type: u.header.type ?? null,
|
|
552
|
+
milestone: u.header.milestone ?? 'TBD',
|
|
553
|
+
pillars,
|
|
554
|
+
values: values.length,
|
|
555
|
+
tbd,
|
|
556
|
+
blocked_by: blockedBy,
|
|
557
|
+
ready: tbd === 0 && !blockedBy.length && !own.some((i) => i.severity === 'block'),
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
for (const c of ledger.cards) issues.push(...(await checkCard(c, ctx)));
|
|
562
|
+
for (const doc of ['game', 'glossary']) issues.push(...(await checkDoc(root, doc, ctx)));
|
|
563
|
+
issues.push(...preambleIssues(ledger.design));
|
|
564
|
+
issues.push(...ledgerIssues(ledger), ...questionIssues(ctx, ledger));
|
|
565
|
+
if (ledger.design.length) {
|
|
566
|
+
const backlog = await readIf(path.join(root, 'backlog.md'));
|
|
567
|
+
issues.push(...backlogIssues(backlog, new Set(units.map((u) => u.id))));
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return {
|
|
571
|
+
blocking: issues.filter((i) => i.severity === 'block').length,
|
|
572
|
+
issues,
|
|
573
|
+
units,
|
|
574
|
+
systems: tally(units, 'system'),
|
|
575
|
+
milestones: tally(units, 'milestone'),
|
|
576
|
+
pillars: pillarReport(units, ctx),
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
export { judged, uncovered };
|