gcf-aif-ext 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -11
- package/commands/gcf.js +287 -42
- package/extension.json +22 -5
- package/hooks/pre-commit.js +55 -31
- package/injections/commit-status.md +7 -5
- package/injections/plan-context.md +8 -5
- package/injections/review-pillars.md +4 -3
- package/lib/anchors.js +27 -4
- package/lib/audit-core.js +135 -48
- package/lib/bundle.js +7 -4
- package/lib/context.js +24 -23
- package/lib/design-check.js +78 -27
- package/lib/design.js +2 -2
- package/lib/gcf-paths.js +13 -2
- package/lib/id.js +2 -2
- package/lib/inbox.js +54 -0
- package/lib/index-gen.js +23 -8
- package/lib/ledger.js +19 -17
- package/lib/linter.js +2 -1
- package/lib/live.js +179 -0
- package/lib/plan-check.js +31 -0
- package/lib/playtest-list.js +8 -4
- package/lib/systems-doc.js +16 -1
- package/lib/transitions.js +36 -21
- package/lib/unit-ops.js +124 -97
- package/lib/validate-item.js +244 -0
- package/lib/verdict-fs.js +61 -128
- package/lib/work-ops.js +727 -0
- package/package.json +1 -1
- package/profiles/pillars.md +1 -1
- package/profiles/system.md +1 -1
- package/profiles/unit-constraint.md +10 -0
- package/profiles/unit-entity.md +10 -0
- package/profiles/unit-mechanic.md +6 -1
- package/profiles/unit-resource.md +6 -1
- package/profiles/unit-rule.md +10 -0
- package/schemas/item.schema.json +105 -0
- package/skills/gcf-adopt/SKILL.md +2 -2
- package/skills/gcf-audit/SKILL.md +3 -3
- package/skills/gcf-card/SKILL.md +25 -20
- package/skills/gcf-ingest/SKILL.md +3 -3
- package/skills/gcf-init/SKILL.md +2 -2
- package/skills/gcf-triage/SKILL.md +35 -0
- package/skills/gcf-verdict/SKILL.md +19 -16
- package/templates/config.yml +7 -0
- package/templates/{card.md → item.md} +10 -3
- package/lib/validate-card.js +0 -93
- package/schemas/card.schema.json +0 -46
package/lib/unit-ops.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
// The moves a design unit makes out of design/ (
|
|
2
|
-
// tests and code, `drop` into the graveyard. Each one removes the section and its
|
|
3
|
-
// in the same step, so an id never lives in two places (M12).
|
|
4
|
-
import { readFile
|
|
1
|
+
// The moves a design unit makes out of design/ (Methodology §2): `take` into a work item, `done`
|
|
2
|
+
// straight into tests and code, `drop` into the graveyard. Each one removes the section and its
|
|
3
|
+
// backlog line in the same step, so an id never lives in two places (M12).
|
|
4
|
+
import { readFile } from 'node:fs/promises';
|
|
5
5
|
import { existsSync, readFileSync } from 'node:fs';
|
|
6
6
|
import { spawnSync } from 'node:child_process';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { serialize } from './frontmatter.js';
|
|
9
|
-
import { CARD_TYPES, listOf } from './design.js';
|
|
9
|
+
import { CARD_TYPES, SLUG_RE, listOf } from './design.js';
|
|
10
10
|
import { buildLedger } from './ledger.js';
|
|
11
11
|
import { designCheck } from './design-check.js';
|
|
12
12
|
import { atomicWrite, withFileRollback } from './safe-path.js';
|
|
@@ -14,10 +14,13 @@ import { writeIndex } from './index-gen.js';
|
|
|
14
14
|
import { operationInProgress, commitPaths } from './git.js';
|
|
15
15
|
import { upsertBlock, openSystemDoc } from './systems-doc.js';
|
|
16
16
|
import { buryLine, recordLine } from './verdict-fs.js';
|
|
17
|
+
import { DEFAULT_GATE } from './validate-item.js';
|
|
17
18
|
|
|
18
19
|
/** A design file's text without the `## <id>` section. */
|
|
19
20
|
export function removeUnit(text, id) {
|
|
20
|
-
|
|
21
|
+
// A file kept with CRLF stays CRLF: work in LF, write back in the file's own line ending.
|
|
22
|
+
const eol = String(text).match(/\r?\n/)?.[0] ?? '\n';
|
|
23
|
+
const lines = String(text).replace(/\r\n/g, '\n').split('\n');
|
|
21
24
|
const at = lines.findIndex((l) => l.trimEnd() === `## ${id}`);
|
|
22
25
|
if (at < 0) return text;
|
|
23
26
|
let end = lines.findIndex((l, i) => i > at && /^## \S/.test(l));
|
|
@@ -26,7 +29,7 @@ export function removeUnit(text, id) {
|
|
|
26
29
|
return `${[...lines.slice(0, at), ...lines.slice(end)]
|
|
27
30
|
.join('\n')
|
|
28
31
|
.replace(/\n{3,}/g, '\n\n')
|
|
29
|
-
.trimEnd()}\n
|
|
32
|
+
.trimEnd()}\n`.replace(/\n/g, eol);
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
/** backlog.md without the line of `id`. */
|
|
@@ -37,19 +40,20 @@ export const backlogWithout = (text, id) =>
|
|
|
37
40
|
.join('\n');
|
|
38
41
|
|
|
39
42
|
/** The single design unit `id`, or an error naming where the id lives instead. */
|
|
40
|
-
|
|
41
|
-
const ledger = await buildLedger(projectDir);
|
|
43
|
+
function designUnitOf(ledger, id) {
|
|
42
44
|
const { live, terminal } = ledger.where(id);
|
|
43
45
|
if (live.length !== 1 || live[0].state !== 'design') {
|
|
44
46
|
const at = live.map((p) => p.file).join(', ') || terminal.join(', ') || 'nowhere';
|
|
45
47
|
throw new Error(`${id} is not a single unit of gcf/design/ (it is: ${at})`);
|
|
46
48
|
}
|
|
47
49
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
return /** @type {import('./design.js').Unit} */ (
|
|
51
|
+
ledger.design.flatMap((d) => d.units).find((u) => u.id === id)
|
|
52
|
+
);
|
|
51
53
|
}
|
|
52
54
|
|
|
55
|
+
const designUnit = async (projectDir, id) => designUnitOf(await buildLedger(projectDir), id);
|
|
56
|
+
|
|
53
57
|
/** Files a move rewrites: the design file, the backlog, then whatever the move adds. */
|
|
54
58
|
function basePaths(root, unit) {
|
|
55
59
|
return { design: path.join(root, unit.file), backlog: path.join(root, 'backlog.md') };
|
|
@@ -61,67 +65,118 @@ async function cutUnit(paths, id) {
|
|
|
61
65
|
await atomicWrite(paths.backlog, backlogWithout(await readFile(paths.backlog, 'utf8'), id));
|
|
62
66
|
}
|
|
63
67
|
|
|
64
|
-
/** The frontmatter a
|
|
65
|
-
function
|
|
68
|
+
/** The frontmatter a work item starts with: the unit's header plus the fields of work. */
|
|
69
|
+
function unitItemData(unit, today, parent) {
|
|
66
70
|
const h = unit.header;
|
|
67
71
|
const pillars = listOf(h.pillars);
|
|
72
|
+
const gate = DEFAULT_GATE[h.type];
|
|
68
73
|
|
|
69
74
|
return {
|
|
70
|
-
schema:
|
|
75
|
+
schema: 3,
|
|
71
76
|
id: unit.id,
|
|
72
77
|
type: h.type,
|
|
78
|
+
gate,
|
|
73
79
|
...(h.title ? { title: h.title } : {}),
|
|
74
|
-
status: '
|
|
80
|
+
status: 'ready',
|
|
81
|
+
...(parent ? { parent } : {}),
|
|
75
82
|
system: listOf(h.system),
|
|
76
83
|
depends: listOf(h.depends),
|
|
77
84
|
pillars: pillars === 'TBD' ? [] : pillars,
|
|
78
85
|
source: h.source,
|
|
79
86
|
...(h.reopens ? { reopens: h.reopens } : {}),
|
|
80
|
-
|
|
81
|
-
|
|
87
|
+
...(SLUG_RE.test(h.milestone ?? '') && h.milestone !== 'later'
|
|
88
|
+
? { milestone: h.milestone }
|
|
89
|
+
: {}),
|
|
90
|
+
...(gate === 'verdict' ? { tune_count: 0 } : {}),
|
|
91
|
+
// A child's capability is its feature's: the feature writes the one LIVE.md record.
|
|
92
|
+
...(parent ? { live: { none: 'parent' } } : {}),
|
|
82
93
|
created: today,
|
|
83
94
|
};
|
|
84
95
|
}
|
|
85
96
|
|
|
86
|
-
/**
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
)
|
|
96
|
-
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
97
|
-
const unit = await designUnit(projectDir, id);
|
|
98
|
-
if (!CARD_TYPES.includes(unit.header.type)) {
|
|
99
|
-
throw new Error(
|
|
100
|
-
`${id} is hard: close its TBD in place, run the AIF cycle, then gcf done ${id}`,
|
|
101
|
-
);
|
|
97
|
+
/** The feature `id` to group the taken units under: the live one, or a new one. */
|
|
98
|
+
function featureFor(ledger, root, { id, unitIds, systems, today }) {
|
|
99
|
+
const file = path.join(root, 'work', `${id}.md`);
|
|
100
|
+
const live = ledger.item(id);
|
|
101
|
+
if (live) {
|
|
102
|
+
if (live.data.type !== 'feature')
|
|
103
|
+
throw new Error(`${id} is a ${live.data.type}, not a feature`);
|
|
104
|
+
const children = [...new Set([...(live.data.children ?? []), ...unitIds])];
|
|
105
|
+
|
|
106
|
+
return { file, text: serialize({ ...live.data, children }, live.body) };
|
|
102
107
|
}
|
|
103
108
|
|
|
104
|
-
|
|
105
|
-
|
|
109
|
+
if (ledger.known(id)) throw new Error(`${id} is taken already: ids are never reused`);
|
|
110
|
+
const data = {
|
|
111
|
+
schema: 3,
|
|
112
|
+
id,
|
|
113
|
+
type: 'feature',
|
|
114
|
+
gate: 'auto',
|
|
115
|
+
status: 'ready',
|
|
116
|
+
children: unitIds,
|
|
117
|
+
system: [...new Set(systems)],
|
|
118
|
+
depends: [],
|
|
119
|
+
created: today,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
return { file, text: serialize(data, `\nintent:\n- TBD: what ${id} gives the player\n`) };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A unit goes into work only ready: no TBD, no blocking D1, no blocking finding. */
|
|
126
|
+
function refuseUnready(units, summaries) {
|
|
127
|
+
for (const unit of units) {
|
|
128
|
+
const summary = summaries.find((u) => u.id === unit.id);
|
|
129
|
+
if (summary?.ready) continue;
|
|
106
130
|
const why = summary?.blocked_by.length
|
|
107
131
|
? `blocked by ${summary.blocked_by.join(', ')}`
|
|
108
132
|
: `${summary?.tbd ?? '?'} TBD or a blocking design-check finding`;
|
|
109
|
-
throw new Error(`${id} is not ready (${why}): answer it in design/ first`);
|
|
133
|
+
throw new Error(`${unit.id} is not ready (${why}): answer it in design/ first`);
|
|
110
134
|
}
|
|
135
|
+
}
|
|
111
136
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
137
|
+
/**
|
|
138
|
+
* Take ready design units into work: each section becomes gcf/work/<id>.md with the same body.
|
|
139
|
+
* One unit stands alone; `feature` groups them under a feature item, new or live. No commit —
|
|
140
|
+
* `/gcf-card` adds `contract` and `interactions`, and the pre-commit gate holds the item until
|
|
141
|
+
* it carries both.
|
|
142
|
+
*/
|
|
143
|
+
export async function takeUnits(
|
|
144
|
+
projectDir,
|
|
145
|
+
ids,
|
|
146
|
+
{ feature = null, today = new Date().toISOString().slice(0, 10) } = {},
|
|
147
|
+
) {
|
|
148
|
+
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
149
|
+
const ledger = await buildLedger(projectDir);
|
|
150
|
+
const units = ids.map((id) => designUnitOf(ledger, id));
|
|
151
|
+
refuseUnready(units, (await designCheck(projectDir)).units);
|
|
152
|
+
const writes = units.map((unit) => ({
|
|
153
|
+
file: path.join(root, 'work', `${unit.id}.md`),
|
|
154
|
+
text: serialize(unitItemData(unit, today, feature), `\n${unit.body.trim()}\n`),
|
|
155
|
+
}));
|
|
156
|
+
for (const w of writes)
|
|
157
|
+
if (existsSync(w.file)) throw new Error(`gcf/work/${path.basename(w.file)} already exists`);
|
|
158
|
+
if (feature) {
|
|
159
|
+
const systems = units.flatMap((u) => listOf(u.header.system)).filter((s) => s !== 'TBD');
|
|
160
|
+
writes.push(featureFor(ledger, root, { id: feature, unitIds: ids, systems, today }));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const paths = units.map((u) => basePaths(root, u));
|
|
164
|
+
const touched = [
|
|
165
|
+
...writes.map((w) => w.file),
|
|
166
|
+
...paths.flatMap((p) => [p.design, p.backlog]),
|
|
167
|
+
path.join(root, 'INDEX.md'),
|
|
168
|
+
];
|
|
169
|
+
await withFileRollback(touched, async () => {
|
|
170
|
+
for (const w of writes) await atomicWrite(w.file, w.text);
|
|
171
|
+
for (const [i, unit] of units.entries()) await cutUnit(paths[i], unit.id);
|
|
117
172
|
await writeIndex(root);
|
|
118
173
|
});
|
|
119
174
|
|
|
120
|
-
return {
|
|
175
|
+
return { items: writes.map((w) => `gcf/work/${path.basename(w.file)}`) };
|
|
121
176
|
}
|
|
122
177
|
|
|
123
178
|
/** A file's text at a git revision spec (`HEAD:./x`, `:./x` for the index), '' when absent. */
|
|
124
|
-
function gitText(projectDir, spec) {
|
|
179
|
+
export function gitText(projectDir, spec) {
|
|
125
180
|
const res = spawnSync('git', ['show', spec], { cwd: projectDir, encoding: 'utf8' });
|
|
126
181
|
|
|
127
182
|
return res.status === 0 ? res.stdout : '';
|
|
@@ -144,12 +199,18 @@ function uncommitted(projectDir, file) {
|
|
|
144
199
|
* Run a move that ends with its fixed-format commit, all of it or none of it. The commit takes
|
|
145
200
|
* whole files and deletes the unit's section, so any uncommitted edit in them refuses the move
|
|
146
201
|
* up front — an answer in the unit itself included: it would leave in a commit that never held
|
|
147
|
-
* it, and no history would keep it.
|
|
202
|
+
* it, and no history would keep it. `inputs` are the item files the move reads and rewrites:
|
|
203
|
+
* their own uncommitted edits (an answer, a live: delta) ride along on purpose.
|
|
204
|
+
* @param {string} projectDir
|
|
205
|
+
* @param {string} message
|
|
206
|
+
* @param {{files: string[], mutate: () => Promise<void>, commit: boolean, inputs?: string[]}} move
|
|
148
207
|
*/
|
|
149
|
-
async function commitMove(projectDir, message, { files, mutate, commit }) {
|
|
208
|
+
export async function commitMove(projectDir, message, { files, mutate, commit, inputs = [] }) {
|
|
150
209
|
const busy = commit ? operationInProgress(projectDir) : null;
|
|
151
210
|
if (busy) throw new Error(`cannot record ${message} while ${busy} is in progress`);
|
|
152
|
-
const dirty = commit
|
|
211
|
+
const dirty = commit
|
|
212
|
+
? files.filter((f) => !inputs.includes(f) && uncommitted(projectDir, f))
|
|
213
|
+
: [];
|
|
153
214
|
if (dirty.length) {
|
|
154
215
|
const names = dirty.map((f) => path.relative(projectDir, f)).join(', ');
|
|
155
216
|
throw new Error(
|
|
@@ -176,14 +237,14 @@ async function commitMove(projectDir, message, { files, mutate, commit }) {
|
|
|
176
237
|
}
|
|
177
238
|
|
|
178
239
|
/**
|
|
179
|
-
* A hard unit is built and its tests are green: it leaves design
|
|
180
|
-
* of the subsystem must remember goes to the systems doc's `constraints
|
|
240
|
+
* A hard unit is built and its tests are green: it leaves design/ without a work item. A line
|
|
241
|
+
* every future reader of the subsystem must remember goes to the systems doc's `constraints:`.
|
|
181
242
|
*/
|
|
182
243
|
export async function doneUnit(projectDir, id, { constraints = [], commit = true } = {}) {
|
|
183
244
|
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
184
245
|
const unit = await designUnit(projectDir, id);
|
|
185
246
|
if (CARD_TYPES.includes(unit.header.type))
|
|
186
|
-
throw new Error(`${id} is soft: it leaves design/ through a
|
|
247
|
+
throw new Error(`${id} is soft: it leaves design/ through a work item and a verdict`);
|
|
187
248
|
const summary = (await designCheck(projectDir)).units.find((u) => u.id === id);
|
|
188
249
|
if (summary?.tbd)
|
|
189
250
|
throw new Error(`${id} still carries ${summary.tbd} TBD: it was built on a hole — answer it`);
|
|
@@ -194,13 +255,7 @@ export async function doneUnit(projectDir, id, { constraints = [], commit = true
|
|
|
194
255
|
const mutate = async () => {
|
|
195
256
|
await cutUnit(paths, id);
|
|
196
257
|
await recordLine(root, id, 'done');
|
|
197
|
-
if (
|
|
198
|
-
const body = upsertBlock(
|
|
199
|
-
doc.body,
|
|
200
|
-
'constraints',
|
|
201
|
-
constraints.map((c) => `- ${c}`),
|
|
202
|
-
);
|
|
203
|
-
await atomicWrite(doc.file, serialize({ ...doc.data, updated_by: id }, body));
|
|
258
|
+
if (doc) await addConstraints(doc, id, constraints);
|
|
204
259
|
};
|
|
205
260
|
|
|
206
261
|
return {
|
|
@@ -209,57 +264,29 @@ export async function doneUnit(projectDir, id, { constraints = [], commit = true
|
|
|
209
264
|
};
|
|
210
265
|
}
|
|
211
266
|
|
|
212
|
-
/**
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
throw new Error(`${id} is ${card.data.status}: play it and give the verdict, kill included`);
|
|
221
|
-
const rel = `./${path.relative(projectDir, card.file).replaceAll('\\', '/')}`;
|
|
222
|
-
if (commit && !gitText(projectDir, `HEAD:${rel}`)) {
|
|
223
|
-
throw new Error(
|
|
224
|
-
`${id} was taken but its card is not committed: commit it first, or undo the take ` +
|
|
225
|
-
`(delete the card, restore its section in gcf/design/ and its backlog line)`,
|
|
226
|
-
);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
const files = [card.file, path.join(root, 'graveyard.md')];
|
|
230
|
-
const mutate = async () => {
|
|
231
|
-
await buryLine(root, id, reason, tags.length ? tags : (card.data.system ?? []));
|
|
232
|
-
await unlink(card.file);
|
|
233
|
-
};
|
|
234
|
-
|
|
235
|
-
return {
|
|
236
|
-
id,
|
|
237
|
-
hash: await commitMove(projectDir, `gcf(drop): ${id}`, { files, mutate, commit }),
|
|
238
|
-
};
|
|
267
|
+
/** Lines every reader of a subsystem must remember, into its systems doc's `constraints:`. */
|
|
268
|
+
export async function addConstraints(doc, id, constraints) {
|
|
269
|
+
const body = upsertBlock(
|
|
270
|
+
doc.body,
|
|
271
|
+
'constraints',
|
|
272
|
+
constraints.map((c) => `- ${c}`),
|
|
273
|
+
);
|
|
274
|
+
await atomicWrite(doc.file, serialize({ ...doc.data, updated_by: id }, body));
|
|
239
275
|
}
|
|
240
276
|
|
|
241
|
-
/**
|
|
242
|
-
* A unit — or a card not yet built — is dropped before any playtest: a graveyard line keeps the
|
|
243
|
-
* reason (GCF-PRD §5.6).
|
|
244
|
-
*/
|
|
277
|
+
/** A unit is dropped before any work: a graveyard line keeps the reason (Methodology §9). */
|
|
245
278
|
export async function dropUnit(projectDir, id, { reason, tags = [], commit = true }) {
|
|
246
279
|
if (!String(reason ?? '').trim())
|
|
247
280
|
throw new Error('a drop needs a reason: the graveyard keeps it');
|
|
248
|
-
const ledger = await buildLedger(projectDir);
|
|
249
|
-
const live = ledger.where(id).live;
|
|
250
|
-
if (live.length === 1 && live[0].state === 'card') {
|
|
251
|
-
const card = ledger.cards.find((c) => c.data.id === id);
|
|
252
|
-
|
|
253
|
-
return dropCard(projectDir, card, { reason, tags, commit });
|
|
254
|
-
}
|
|
255
|
-
|
|
256
281
|
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
257
282
|
const unit = await designUnit(projectDir, id);
|
|
258
283
|
const paths = basePaths(root, unit);
|
|
259
284
|
const system = listOf(unit.header.system);
|
|
260
285
|
const files = [paths.design, paths.backlog, path.join(root, 'graveyard.md')];
|
|
286
|
+
const type = unit.header.type ?? 'mechanic';
|
|
261
287
|
const mutate = async () => {
|
|
262
|
-
|
|
288
|
+
const lineTags = tags.length ? tags : system === 'TBD' ? [] : system;
|
|
289
|
+
await buryLine(root, id, { type, reason, tags: lineTags });
|
|
263
290
|
await cutUnit(paths, id);
|
|
264
291
|
};
|
|
265
292
|
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// Runtime mirror of schemas/item.schema.json for the pre-commit hook (no ajv in production
|
|
2
|
+
// deps). `validateFields` is the per-field part the parity test holds against the schema; the
|
|
3
|
+
// cross-field rules (which status goes with which gate or type) live only here.
|
|
4
|
+
import { STORED } from './transitions.js';
|
|
5
|
+
|
|
6
|
+
export const ITEM_TYPES = [
|
|
7
|
+
'feature',
|
|
8
|
+
'mechanic',
|
|
9
|
+
'resource',
|
|
10
|
+
'rule',
|
|
11
|
+
'constraint',
|
|
12
|
+
'entity',
|
|
13
|
+
'tech',
|
|
14
|
+
'bug',
|
|
15
|
+
'spike',
|
|
16
|
+
'asset',
|
|
17
|
+
'content',
|
|
18
|
+
'perf',
|
|
19
|
+
'release',
|
|
20
|
+
];
|
|
21
|
+
// A work item that carries a design unit's body: checked against the unit's profile.
|
|
22
|
+
export const UNIT_ITEM_TYPES = ['mechanic', 'resource', 'rule', 'constraint', 'entity'];
|
|
23
|
+
// Methodology §3.1: who closes an item by default; the operator flips it per item.
|
|
24
|
+
export const DEFAULT_GATE = {
|
|
25
|
+
feature: 'auto',
|
|
26
|
+
mechanic: 'verdict',
|
|
27
|
+
resource: 'verdict',
|
|
28
|
+
rule: 'auto',
|
|
29
|
+
constraint: 'auto',
|
|
30
|
+
entity: 'auto',
|
|
31
|
+
tech: 'auto',
|
|
32
|
+
bug: 'auto',
|
|
33
|
+
spike: 'decision',
|
|
34
|
+
asset: 'verdict',
|
|
35
|
+
content: 'auto',
|
|
36
|
+
perf: 'auto',
|
|
37
|
+
release: 'decision',
|
|
38
|
+
};
|
|
39
|
+
export const GATES = ['verdict', 'auto', 'decision'];
|
|
40
|
+
export const SEVERITIES = ['critical', 'high', 'medium', 'low'];
|
|
41
|
+
const CHANGES = ['added', 'modified', 'removed'];
|
|
42
|
+
|
|
43
|
+
const FIELDS = [
|
|
44
|
+
'schema',
|
|
45
|
+
'id',
|
|
46
|
+
'type',
|
|
47
|
+
'gate',
|
|
48
|
+
'title',
|
|
49
|
+
'status',
|
|
50
|
+
'parent',
|
|
51
|
+
'children',
|
|
52
|
+
'system',
|
|
53
|
+
'depends',
|
|
54
|
+
'pillars',
|
|
55
|
+
'source',
|
|
56
|
+
'reopens',
|
|
57
|
+
'milestone',
|
|
58
|
+
'parallel',
|
|
59
|
+
'plan',
|
|
60
|
+
'tune_count',
|
|
61
|
+
'severity',
|
|
62
|
+
'blocked',
|
|
63
|
+
'since',
|
|
64
|
+
'frozen',
|
|
65
|
+
'live',
|
|
66
|
+
'model',
|
|
67
|
+
'effort',
|
|
68
|
+
'created',
|
|
69
|
+
];
|
|
70
|
+
const REQUIRED = ['schema', 'id', 'type', 'gate', 'status', 'created'];
|
|
71
|
+
const SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
72
|
+
// YAML reads `id: 2024` as a number; RegExp.test would pass it and every string lookup miss it.
|
|
73
|
+
const isSlug = (v) => typeof v === 'string' && SLUG.test(v);
|
|
74
|
+
const slugList = (v) => Array.isArray(v) && v.every(isSlug);
|
|
75
|
+
const isText = (v) => typeof v === 'string' && v.trim().length > 0;
|
|
76
|
+
const nullable = (check) => (v) => v === null || v === undefined || check(v);
|
|
77
|
+
const LIVE_SHAPE = 'live must be {none: <why>} or {changes: [...]} with at least one change';
|
|
78
|
+
|
|
79
|
+
/** At most two sentences and 250 characters: a table of contents, not a description. */
|
|
80
|
+
export function summaryProblem(s) {
|
|
81
|
+
if (!isText(s)) return 'summary must be text';
|
|
82
|
+
if (s.length > 250) return `summary is ${s.length} characters, at most 250`;
|
|
83
|
+
if ((s.match(/[.!?](\s|$)/g) ?? []).length > 2) return 'summary is more than two sentences';
|
|
84
|
+
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** One `changes:` entry of a live: delta, or null when it is well formed. */
|
|
89
|
+
function changeProblem(c) {
|
|
90
|
+
if (typeof c !== 'object' || c === null) return 'each live change is {id, change, summary}';
|
|
91
|
+
const extra = Object.keys(c).find((k) => !['id', 'change', 'summary', 'refs'].includes(k));
|
|
92
|
+
if (extra) return `unknown live change field: ${extra}`;
|
|
93
|
+
if (!isSlug(c.id)) return 'a live change id must be a lowercase hyphen slug';
|
|
94
|
+
if (!CHANGES.includes(c.change)) return `live change must be ${CHANGES.join(' | ')}`;
|
|
95
|
+
if (c.change !== 'removed' && !isText(c.summary)) return `live change ${c.id} needs a summary`;
|
|
96
|
+
const bad = c.summary === undefined ? null : summaryProblem(c.summary);
|
|
97
|
+
if (bad) return `live change ${c.id}: ${bad}`;
|
|
98
|
+
|
|
99
|
+
return c.refs === undefined || isText(c.refs) ? null : `live change ${c.id}: refs must be text`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A live: delta (Methodology §8): `{none: <why>}` or `{changes: [{id, change, summary}]}`. */
|
|
103
|
+
export function liveProblem(v) {
|
|
104
|
+
if (v === null || v === undefined) return null;
|
|
105
|
+
if (typeof v !== 'object' || Array.isArray(v)) return LIVE_SHAPE;
|
|
106
|
+
const [key, ...more] = Object.keys(v);
|
|
107
|
+
if (more.length) return LIVE_SHAPE;
|
|
108
|
+
if (key === 'none')
|
|
109
|
+
return isText(v.none) ? null : 'live.none must say why the item changes no capability';
|
|
110
|
+
const changes = key === 'changes' ? v.changes : null;
|
|
111
|
+
if (!Array.isArray(changes) || !changes.length) return LIVE_SHAPE;
|
|
112
|
+
|
|
113
|
+
return changes.map(changeProblem).find(Boolean) ?? null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const hash = (min, max) => (x) =>
|
|
117
|
+
typeof x === 'string' && new RegExp(`^[0-9a-f]{${min},${max}}$`).test(x);
|
|
118
|
+
|
|
119
|
+
const RULES = {
|
|
120
|
+
schema: (v) =>
|
|
121
|
+
v === 3 ||
|
|
122
|
+
(v === 2 || v === 1
|
|
123
|
+
? `schema ${v} card: GCF 0.4 keeps work items in gcf/work/<id>.md with schema: 3, ` +
|
|
124
|
+
'type and gate — there is no migration: put a card back into gcf/design/ as a unit ' +
|
|
125
|
+
'and run ai-factory gcf take'
|
|
126
|
+
: 'schema must be 3'),
|
|
127
|
+
id: (v) =>
|
|
128
|
+
isSlug(v) ||
|
|
129
|
+
(typeof v === 'number'
|
|
130
|
+
? 'id must be a string: YAML read it as a number, put the slug in quotes'
|
|
131
|
+
: 'id must be a lowercase hyphen slug'),
|
|
132
|
+
type: (v) => ITEM_TYPES.includes(v) || `type must be ${ITEM_TYPES.join(' | ')}`,
|
|
133
|
+
gate: (v) => GATES.includes(v) || `gate must be ${GATES.join(' | ')}`,
|
|
134
|
+
title: (v) => isText(v) || 'title must be a non-empty string',
|
|
135
|
+
status: (v) =>
|
|
136
|
+
STORED.includes(v) ||
|
|
137
|
+
`status "${v}" is not storable (${STORED.join(', ')}; a terminal move deletes the file)`,
|
|
138
|
+
parent: (v) => nullable(isSlug)(v) || 'parent must be an item id or null',
|
|
139
|
+
children: (v) => slugList(v) || 'children must list item ids',
|
|
140
|
+
system: (v) => slugList(v) || 'system must list subsystem slugs',
|
|
141
|
+
depends: (v) => slugList(v) || 'depends must list ids',
|
|
142
|
+
pillars: (v) => slugList(v) || 'pillars must list pillar ids',
|
|
143
|
+
source: (v) => /^(GDD#[A-Za-z0-9_-]+|manual)$/.test(v) || 'source must be GDD#<id> or manual',
|
|
144
|
+
reopens: (v) => nullable(isSlug)(v) || 'reopens must name the id it reworks, or null',
|
|
145
|
+
milestone: (v) => nullable(isSlug)(v) || 'milestone must be a milestone name or null',
|
|
146
|
+
parallel: (v) => ['ok', 'serial'].includes(v) || 'parallel must be ok or serial',
|
|
147
|
+
plan: (v) => nullable(isText)(v) || 'plan must be a path or null',
|
|
148
|
+
tune_count: (v) => (Number.isInteger(v) && v >= 0) || 'tune_count must be an integer >= 0',
|
|
149
|
+
severity: (v) =>
|
|
150
|
+
nullable((x) => SEVERITIES.includes(x))(v) || `severity must be ${SEVERITIES.join(' | ')}`,
|
|
151
|
+
blocked: (v) => nullable(isText)(v) || 'blocked must be text or null',
|
|
152
|
+
since: (v) => nullable(hash(7, 64))(v) || 'since must be null or a commit hash',
|
|
153
|
+
frozen: (v) => hash(40, 40)(v) || 'frozen must be the sha1 of the body',
|
|
154
|
+
live: (v) => liveProblem(v) ?? true,
|
|
155
|
+
model: (v) => nullable(isText)(v) || 'model must be text or null',
|
|
156
|
+
effort: (v) => nullable(isText)(v) || 'effort must be text or null',
|
|
157
|
+
created: (v) => /^\d{4}-\d{2}-\d{2}$/.test(dateStr(v)) || 'created must be YYYY-MM-DD',
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const dateStr = (v) => (v instanceof Date ? v.toISOString().slice(0, 10) : String(v));
|
|
161
|
+
|
|
162
|
+
/** Per-field errors only — what item.schema.json also decides. */
|
|
163
|
+
export function validateFields(data) {
|
|
164
|
+
if (data.schema !== undefined && data.schema !== 3) return [RULES.schema(data.schema)];
|
|
165
|
+
const errors = [];
|
|
166
|
+
for (const key of REQUIRED) if (!(key in data)) errors.push(`missing field: ${key}`);
|
|
167
|
+
for (const key of Object.keys(data)) {
|
|
168
|
+
if (!FIELDS.includes(key))
|
|
169
|
+
errors.push(
|
|
170
|
+
`unknown field: ${key}` +
|
|
171
|
+
(key === 'until' ? ' (until: is a line annotation, not frontmatter)' : '') +
|
|
172
|
+
(key === 'built_at' ? ' (schema 3 records since: when the item waits for you)' : ''),
|
|
173
|
+
);
|
|
174
|
+
else {
|
|
175
|
+
const res = RULES[key](data[key]);
|
|
176
|
+
if (res !== true) errors.push(res);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return errors;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const isUnit = (d) => UNIT_ITEM_TYPES.includes(d.type);
|
|
184
|
+
|
|
185
|
+
// Which status, gate and type go together (Methodology §4): [broken when, message].
|
|
186
|
+
/** @type {Array<[(d: any) => boolean, string]>} */
|
|
187
|
+
const CROSS = [
|
|
188
|
+
[
|
|
189
|
+
(d) => d.status === 'awaiting-verdict' && d.gate !== 'verdict',
|
|
190
|
+
'awaiting-verdict needs gate: verdict',
|
|
191
|
+
],
|
|
192
|
+
[
|
|
193
|
+
(d) => d.status === 'awaiting-decision' && d.gate !== 'decision' && d.type !== 'feature',
|
|
194
|
+
'awaiting-decision needs gate: decision (or a feature whose child died)',
|
|
195
|
+
],
|
|
196
|
+
[(d) => d.type === 'feature' && d.gate !== 'auto', 'a feature always has gate: auto'],
|
|
197
|
+
[(d) => d.status === 'parked' && d.type !== 'bug', 'only a bug is parked'],
|
|
198
|
+
[
|
|
199
|
+
(d) => d.status === 'unplayed' && !['mechanic', 'resource'].includes(d.type),
|
|
200
|
+
'only a mechanic or a resource is unplayed',
|
|
201
|
+
],
|
|
202
|
+
[
|
|
203
|
+
(d) => (d.status === 'unplayed') !== (d.frozen !== undefined),
|
|
204
|
+
'frozen: belongs to an unplayed item',
|
|
205
|
+
],
|
|
206
|
+
[(d) => d.status === 'blocked' && !isText(d.blocked), 'a blocked item says why in blocked:'],
|
|
207
|
+
[(d) => d.status !== 'blocked' && isText(d.blocked), 'blocked: is set but status is not blocked'],
|
|
208
|
+
[(d) => d.type === 'bug' && !SEVERITIES.includes(d.severity), 'a bug needs severity'],
|
|
209
|
+
[(d) => d.type !== 'bug' && Boolean(d.severity), 'severity belongs to a bug'],
|
|
210
|
+
[(d) => d.children !== undefined && d.type !== 'feature', 'only a feature has children'],
|
|
211
|
+
[
|
|
212
|
+
(d) => isUnit(d) && !(Array.isArray(d.system) && d.system.length),
|
|
213
|
+
'a design-unit item lists >=1 subsystem in system:',
|
|
214
|
+
],
|
|
215
|
+
[(d) => isUnit(d) && d.source === undefined, 'a design-unit item keeps its source:'],
|
|
216
|
+
[(d) => Boolean(d.parent) && d.parent === d.id, 'an item cannot be its own parent'],
|
|
217
|
+
];
|
|
218
|
+
|
|
219
|
+
/** Validate work item frontmatter. Returns a list of one-line errors (empty = valid). */
|
|
220
|
+
export function validateItem(data) {
|
|
221
|
+
const fields = validateFields(data);
|
|
222
|
+
if (fields.length) return fields;
|
|
223
|
+
|
|
224
|
+
return CROSS.filter(([broken]) => broken(data)).map(([, message]) => message);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const UNTIL_OK = /^\[until:\s*(verdict\([a-z0-9-]+\)|retune\([a-z0-9-]+\)|manual\([^)]*\))\]$/;
|
|
228
|
+
|
|
229
|
+
/** Validate every `[until: ...]` annotation in a body against the trigger grammar (§7.3). */
|
|
230
|
+
export function validateUntil(body) {
|
|
231
|
+
const errors = [];
|
|
232
|
+
const lines = String(body).split('\n');
|
|
233
|
+
lines.forEach((line, i) => {
|
|
234
|
+
for (const m of line.matchAll(/\[until:[^\]]*\]/g)) {
|
|
235
|
+
if (!UNTIL_OK.test(m[0]))
|
|
236
|
+
errors.push(
|
|
237
|
+
`line ${i + 1}: bad until annotation ${m[0]} ` +
|
|
238
|
+
'(use verdict(<id>), retune(system) or manual(text))',
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
return errors;
|
|
244
|
+
}
|