gcf-aif-ext 0.2.5 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -5
- package/commands/gcf.js +408 -73
- package/extension.json +1 -1
- package/hooks/pre-commit.js +47 -29
- package/injections/commit-status.md +1 -0
- package/injections/plan-context.md +3 -2
- package/injections/review-pillars.md +1 -1
- package/lib/anchors.js +110 -10
- package/lib/audit-core.js +175 -50
- package/lib/bundle.js +275 -0
- package/lib/context.js +55 -36
- package/lib/design-check.js +580 -0
- package/lib/design.js +179 -0
- package/lib/frontmatter.js +2 -1
- package/lib/gcf-paths.js +14 -3
- package/lib/git.js +85 -4
- package/lib/id.js +8 -14
- package/lib/index-gen.js +68 -16
- package/lib/ledger.js +92 -0
- package/lib/linter.js +91 -42
- package/lib/migrate.js +345 -0
- package/lib/playtest-list.js +1 -1
- package/lib/safe-path.js +23 -4
- package/lib/systems-doc.js +70 -0
- package/lib/transitions.js +1 -3
- package/lib/unit-ops.js +270 -0
- package/lib/validate-card.js +59 -14
- package/lib/verdict-fs.js +173 -96
- package/package.json +1 -1
- package/profiles/game.md +68 -0
- package/profiles/glossary.md +37 -0
- package/profiles/pillars.md +2 -1
- package/profiles/unit-constraint.md +42 -0
- package/profiles/unit-entity.md +38 -0
- package/profiles/unit-mechanic.md +106 -0
- package/profiles/unit-resource.md +86 -0
- package/profiles/unit-rule.md +42 -0
- package/schemas/card.schema.json +23 -7
- package/skills/gcf-adopt/SKILL.md +3 -3
- package/skills/gcf-audit/SKILL.md +4 -3
- package/skills/gcf-card/SKILL.md +26 -16
- package/skills/gcf-debate/SKILL.md +17 -14
- package/skills/gcf-ingest/SKILL.md +43 -16
- package/skills/gcf-init/SKILL.md +9 -8
- package/skills/gcf-verdict/SKILL.md +7 -6
- package/skills/gcf-vision/SKILL.md +17 -11
- package/templates/PILLARS.md +2 -0
- package/templates/backlog.md +2 -4
- package/templates/card.md +27 -10
- package/templates/cnlp.config.json +0 -1
- package/templates/design.md +59 -0
- package/templates/game.md +24 -0
- package/templates/glossary.md +11 -0
- package/templates/system.md +1 -1
- package/profiles/card.md +0 -65
package/lib/unit-ops.js
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// The moves a design unit makes out of design/ (GCF-PRD §4): `take` into a card, `done` into
|
|
2
|
+
// tests and code, `drop` into the graveyard. Each one removes the section and its backlog line
|
|
3
|
+
// in the same step, so an id never lives in two places (M12).
|
|
4
|
+
import { readFile, unlink } from 'node:fs/promises';
|
|
5
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { serialize } from './frontmatter.js';
|
|
9
|
+
import { CARD_TYPES, listOf } from './design.js';
|
|
10
|
+
import { buildLedger } from './ledger.js';
|
|
11
|
+
import { designCheck } from './design-check.js';
|
|
12
|
+
import { atomicWrite, withFileRollback } from './safe-path.js';
|
|
13
|
+
import { writeIndex } from './index-gen.js';
|
|
14
|
+
import { operationInProgress, commitPaths } from './git.js';
|
|
15
|
+
import { upsertBlock, openSystemDoc } from './systems-doc.js';
|
|
16
|
+
import { buryLine, recordLine } from './verdict-fs.js';
|
|
17
|
+
|
|
18
|
+
/** A design file's text without the `## <id>` section. */
|
|
19
|
+
export function removeUnit(text, id) {
|
|
20
|
+
const lines = String(text).split('\n');
|
|
21
|
+
const at = lines.findIndex((l) => l.trimEnd() === `## ${id}`);
|
|
22
|
+
if (at < 0) return text;
|
|
23
|
+
let end = lines.findIndex((l, i) => i > at && /^## \S/.test(l));
|
|
24
|
+
if (end < 0) end = lines.length;
|
|
25
|
+
|
|
26
|
+
return `${[...lines.slice(0, at), ...lines.slice(end)]
|
|
27
|
+
.join('\n')
|
|
28
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
29
|
+
.trimEnd()}\n`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** backlog.md without the line of `id`. */
|
|
33
|
+
export const backlogWithout = (text, id) =>
|
|
34
|
+
String(text)
|
|
35
|
+
.split('\n')
|
|
36
|
+
.filter((l) => l.match(/^- (\S+)/)?.[1] !== id)
|
|
37
|
+
.join('\n');
|
|
38
|
+
|
|
39
|
+
/** The single design unit `id`, or an error naming where the id lives instead. */
|
|
40
|
+
async function designUnit(projectDir, id) {
|
|
41
|
+
const ledger = await buildLedger(projectDir);
|
|
42
|
+
const { live, terminal } = ledger.where(id);
|
|
43
|
+
if (live.length !== 1 || live[0].state !== 'design') {
|
|
44
|
+
const at = live.map((p) => p.file).join(', ') || terminal.join(', ') || 'nowhere';
|
|
45
|
+
throw new Error(`${id} is not a single unit of gcf/design/ (it is: ${at})`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const unit = ledger.design.flatMap((d) => d.units).find((u) => u.id === id);
|
|
49
|
+
|
|
50
|
+
return /** @type {import('./design.js').Unit} */ (unit);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Files a move rewrites: the design file, the backlog, then whatever the move adds. */
|
|
54
|
+
function basePaths(root, unit) {
|
|
55
|
+
return { design: path.join(root, unit.file), backlog: path.join(root, 'backlog.md') };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function cutUnit(paths, id) {
|
|
59
|
+
await atomicWrite(paths.design, removeUnit(await readFile(paths.design, 'utf8'), id));
|
|
60
|
+
if (existsSync(paths.backlog))
|
|
61
|
+
await atomicWrite(paths.backlog, backlogWithout(await readFile(paths.backlog, 'utf8'), id));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The frontmatter a card starts with: the unit's header plus the fields of the card stage. */
|
|
65
|
+
function cardData(unit, today) {
|
|
66
|
+
const h = unit.header;
|
|
67
|
+
const pillars = listOf(h.pillars);
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
schema: 2,
|
|
71
|
+
id: unit.id,
|
|
72
|
+
type: h.type,
|
|
73
|
+
...(h.title ? { title: h.title } : {}),
|
|
74
|
+
status: 'proposed',
|
|
75
|
+
system: listOf(h.system),
|
|
76
|
+
depends: listOf(h.depends),
|
|
77
|
+
pillars: pillars === 'TBD' ? [] : pillars,
|
|
78
|
+
source: h.source,
|
|
79
|
+
...(h.reopens ? { reopens: h.reopens } : {}),
|
|
80
|
+
tune_count: 0,
|
|
81
|
+
built_at: null,
|
|
82
|
+
created: today,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Take a ready soft unit into work: its section becomes cards/<id>.md with the same body.
|
|
88
|
+
* No commit — `/gcf-card` adds `contract` and `interactions`, and the pre-commit gate holds
|
|
89
|
+
* the card until it carries both.
|
|
90
|
+
*/
|
|
91
|
+
export async function takeUnit(
|
|
92
|
+
projectDir,
|
|
93
|
+
id,
|
|
94
|
+
{ today = new Date().toISOString().slice(0, 10) } = {},
|
|
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
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const summary = (await designCheck(projectDir)).units.find((u) => u.id === id);
|
|
105
|
+
if (!summary?.ready) {
|
|
106
|
+
const why = summary?.blocked_by.length
|
|
107
|
+
? `blocked by ${summary.blocked_by.join(', ')}`
|
|
108
|
+
: `${summary?.tbd ?? '?'} TBD or a blocking design-check finding`;
|
|
109
|
+
throw new Error(`${id} is not ready (${why}): answer it in design/ first`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const paths = { ...basePaths(root, unit), card: path.join(root, 'cards', `${id}.md`) };
|
|
113
|
+
if (existsSync(paths.card)) throw new Error(`gcf/cards/${id}.md already exists`);
|
|
114
|
+
await withFileRollback([...Object.values(paths), path.join(root, 'INDEX.md')], async () => {
|
|
115
|
+
await atomicWrite(paths.card, serialize(cardData(unit, today), `\n${unit.body.trim()}\n`));
|
|
116
|
+
await cutUnit(paths, id);
|
|
117
|
+
await writeIndex(root);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
return { card: `gcf/cards/${id}.md` };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A file's text at a git revision spec (`HEAD:./x`, `:./x` for the index), '' when absent. */
|
|
124
|
+
function gitText(projectDir, spec) {
|
|
125
|
+
const res = spawnSync('git', ['show', spec], { cwd: projectDir, encoding: 'utf8' });
|
|
126
|
+
|
|
127
|
+
return res.status === 0 ? res.stdout : '';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* True when `file` differs from HEAD in the index or on disk. Line endings do not count: a file
|
|
132
|
+
* stored with CRLF and checked out with LF is not an edit.
|
|
133
|
+
*/
|
|
134
|
+
function uncommitted(projectDir, file) {
|
|
135
|
+
const rel = `./${path.relative(projectDir, file).replaceAll('\\', '/')}`;
|
|
136
|
+
const norm = (t) => String(t).replace(/\r\n/g, '\n').trimEnd();
|
|
137
|
+
const head = norm(gitText(projectDir, `HEAD:${rel}`));
|
|
138
|
+
const disk = norm(existsSync(file) ? readFileSync(file, 'utf8') : '');
|
|
139
|
+
|
|
140
|
+
return head !== norm(gitText(projectDir, `:${rel}`)) || head !== disk;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Run a move that ends with its fixed-format commit, all of it or none of it. The commit takes
|
|
145
|
+
* whole files and deletes the unit's section, so any uncommitted edit in them refuses the move
|
|
146
|
+
* 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.
|
|
148
|
+
*/
|
|
149
|
+
async function commitMove(projectDir, message, { files, mutate, commit }) {
|
|
150
|
+
const busy = commit ? operationInProgress(projectDir) : null;
|
|
151
|
+
if (busy) throw new Error(`cannot record ${message} while ${busy} is in progress`);
|
|
152
|
+
const dirty = commit ? files.filter((f) => uncommitted(projectDir, f)) : [];
|
|
153
|
+
if (dirty.length) {
|
|
154
|
+
const names = dirty.map((f) => path.relative(projectDir, f)).join(', ');
|
|
155
|
+
throw new Error(
|
|
156
|
+
`commit or stash these first (an answered unit is committed before its move), ` +
|
|
157
|
+
`${message} would carry them: ${names}`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
162
|
+
const touched = [...files, path.join(root, 'INDEX.md')];
|
|
163
|
+
/** @type {string | null} */
|
|
164
|
+
let hash = null;
|
|
165
|
+
await withFileRollback(
|
|
166
|
+
touched,
|
|
167
|
+
async () => {
|
|
168
|
+
await mutate();
|
|
169
|
+
await writeIndex(root);
|
|
170
|
+
if (commit) hash = commitPaths(projectDir, message, touched);
|
|
171
|
+
},
|
|
172
|
+
{ gitCwd: commit ? path.resolve(projectDir) : null },
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
return hash;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* A hard unit is built and its tests are green: it leaves design/. A line every future reader
|
|
180
|
+
* of the subsystem must remember goes to the systems doc's `constraints:` (GCF-PRD §6.3).
|
|
181
|
+
*/
|
|
182
|
+
export async function doneUnit(projectDir, id, { constraints = [], commit = true } = {}) {
|
|
183
|
+
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
184
|
+
const unit = await designUnit(projectDir, id);
|
|
185
|
+
if (CARD_TYPES.includes(unit.header.type))
|
|
186
|
+
throw new Error(`${id} is soft: it leaves design/ through a card and a verdict`);
|
|
187
|
+
const summary = (await designCheck(projectDir)).units.find((u) => u.id === id);
|
|
188
|
+
if (summary?.tbd)
|
|
189
|
+
throw new Error(`${id} still carries ${summary.tbd} TBD: it was built on a hole — answer it`);
|
|
190
|
+
const paths = basePaths(root, unit);
|
|
191
|
+
const doc = constraints.length ? await openSystemDoc(root, unit.system) : null;
|
|
192
|
+
const files = [paths.design, paths.backlog, path.join(root, 'history.md')];
|
|
193
|
+
if (doc) files.push(doc.file);
|
|
194
|
+
const mutate = async () => {
|
|
195
|
+
await cutUnit(paths, id);
|
|
196
|
+
await recordLine(root, id, 'done');
|
|
197
|
+
if (!doc) return;
|
|
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));
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
id,
|
|
208
|
+
hash: await commitMove(projectDir, `gcf(done): ${id}`, { files, mutate, commit }),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* A card taken into work but never built is given up: no playtest, so no verdict — a drop. Its
|
|
214
|
+
* file goes, the graveyard keeps the reason. A built card waits for its verdict instead.
|
|
215
|
+
*/
|
|
216
|
+
async function dropCard(projectDir, card, { reason, tags, commit }) {
|
|
217
|
+
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
218
|
+
const id = String(card.data.id);
|
|
219
|
+
if (!['proposed', 'tuning'].includes(card.data.status))
|
|
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
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
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
|
+
*/
|
|
245
|
+
export async function dropUnit(projectDir, id, { reason, tags = [], commit = true }) {
|
|
246
|
+
if (!String(reason ?? '').trim())
|
|
247
|
+
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
|
+
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
257
|
+
const unit = await designUnit(projectDir, id);
|
|
258
|
+
const paths = basePaths(root, unit);
|
|
259
|
+
const system = listOf(unit.header.system);
|
|
260
|
+
const files = [paths.design, paths.backlog, path.join(root, 'graveyard.md')];
|
|
261
|
+
const mutate = async () => {
|
|
262
|
+
await buryLine(root, id, reason, tags.length ? tags : system === 'TBD' ? [] : system);
|
|
263
|
+
await cutUnit(paths, id);
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
id,
|
|
268
|
+
hash: await commitMove(projectDir, `gcf(drop): ${id}`, { files, mutate, commit }),
|
|
269
|
+
};
|
|
270
|
+
}
|
package/lib/validate-card.js
CHANGED
|
@@ -1,37 +1,77 @@
|
|
|
1
1
|
// Runtime mirror of schemas/card.schema.json for the pre-commit hook (no ajv in
|
|
2
2
|
// production deps). The parity test keeps the two in agreement.
|
|
3
|
-
const FIELDS = [
|
|
4
|
-
|
|
3
|
+
const FIELDS = [
|
|
4
|
+
'schema',
|
|
5
|
+
'id',
|
|
6
|
+
'type',
|
|
7
|
+
'title',
|
|
8
|
+
'status',
|
|
9
|
+
'system',
|
|
10
|
+
'depends',
|
|
11
|
+
'pillars',
|
|
12
|
+
'source',
|
|
13
|
+
'reopens',
|
|
14
|
+
'tune_count',
|
|
15
|
+
'built_at',
|
|
16
|
+
'created',
|
|
17
|
+
];
|
|
18
|
+
const OPTIONAL = ['title', 'reopens', 'built_at'];
|
|
19
|
+
const REQUIRED = FIELDS.filter((f) => !OPTIONAL.includes(f));
|
|
20
|
+
const SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
21
|
+
const slugList = (v) => Array.isArray(v) && v.every((s) => SLUG.test(s));
|
|
22
|
+
const text = (v) => (typeof v === 'string' && v.length > 0) || 'must be a non-empty string';
|
|
5
23
|
|
|
6
24
|
const RULES = {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
25
|
+
// Schema 1 cards (`card-NNN` ids, `slug`, `route`) predate the Design Model: say how to move.
|
|
26
|
+
schema: (v) =>
|
|
27
|
+
v === 2 ||
|
|
28
|
+
(v === 1
|
|
29
|
+
? 'schema 1 card: rename the file to cards/<slug>.md, set id: <slug>, drop slug and route, ' +
|
|
30
|
+
'add type: mechanic and pillars: [], set schema: 2'
|
|
31
|
+
: 'schema must be 2'),
|
|
32
|
+
id: (v) => SLUG.test(v) || 'id must be a lowercase hyphen slug',
|
|
33
|
+
type: (v) =>
|
|
34
|
+
['mechanic', 'resource'].includes(v) ||
|
|
35
|
+
'type must be mechanic or resource (a hard unit never becomes a card)',
|
|
36
|
+
title: (v) => text(v) === true || `title ${text(v)}`,
|
|
37
|
+
status: (v) =>
|
|
38
|
+
['proposed', 'built', 'tuning'].includes(v) ||
|
|
39
|
+
`status "${v}" is not storable (П1: terminal statuses never live in a file)`,
|
|
40
|
+
system: (v) => (slugList(v) && v.length >= 1) || 'system must list >=1 subsystem slugs',
|
|
41
|
+
depends: (v) => slugList(v) || 'depends must list unit ids',
|
|
42
|
+
pillars: (v) => slugList(v) || 'pillars must list pillar ids',
|
|
43
|
+
source: (v) => /^(GDD#[A-Za-z0-9_-]+|manual)$/.test(v) || 'source must be GDD#<id> or manual',
|
|
44
|
+
reopens: (v) => text(v) === true || `reopens ${text(v)}`,
|
|
15
45
|
tune_count: (v) => (Number.isInteger(v) && v >= 0) || 'tune_count must be an integer >= 0',
|
|
16
|
-
built_at: (v) =>
|
|
46
|
+
built_at: (v) =>
|
|
47
|
+
v === null ||
|
|
48
|
+
v === undefined ||
|
|
49
|
+
/^[0-9a-f]{7,40}$/.test(v) ||
|
|
50
|
+
'built_at must be null or a commit hash',
|
|
17
51
|
created: (v) => /^\d{4}-\d{2}-\d{2}$/.test(dateStr(v)) || 'created must be YYYY-MM-DD',
|
|
18
52
|
};
|
|
19
53
|
|
|
20
54
|
const dateStr = (v) => (v instanceof Date ? v.toISOString().slice(0, 10) : String(v));
|
|
21
55
|
|
|
22
|
-
const UNTIL_OK = /^\[until:\s*(verdict\(
|
|
56
|
+
const UNTIL_OK = /^\[until:\s*(verdict\([a-z0-9-]+\)|retune\([a-z0-9-]+\)|manual\([^)]*\))\]$/;
|
|
23
57
|
|
|
24
58
|
/** Validate card frontmatter. Returns a list of one-line errors (empty = valid). */
|
|
25
59
|
export function validateCard(data) {
|
|
26
60
|
const errors = [];
|
|
61
|
+
if (data.schema !== undefined && data.schema !== 2) return [RULES.schema(data.schema)];
|
|
27
62
|
for (const key of REQUIRED) if (!(key in data)) errors.push(`missing field: ${key}`);
|
|
28
63
|
for (const key of Object.keys(data)) {
|
|
29
|
-
if (!FIELDS.includes(key))
|
|
64
|
+
if (!FIELDS.includes(key))
|
|
65
|
+
errors.push(
|
|
66
|
+
`unknown field: ${key}` +
|
|
67
|
+
(key === 'until' ? ' (until: is a line annotation, not frontmatter)' : ''),
|
|
68
|
+
);
|
|
30
69
|
else if (key in RULES) {
|
|
31
70
|
const res = RULES[key](data[key]);
|
|
32
71
|
if (res !== true) errors.push(res);
|
|
33
72
|
}
|
|
34
73
|
}
|
|
74
|
+
|
|
35
75
|
return errors;
|
|
36
76
|
}
|
|
37
77
|
|
|
@@ -41,8 +81,13 @@ export function validateUntil(body) {
|
|
|
41
81
|
const lines = String(body).split('\n');
|
|
42
82
|
lines.forEach((line, i) => {
|
|
43
83
|
for (const m of line.matchAll(/\[until:[^\]]*\]/g)) {
|
|
44
|
-
if (!UNTIL_OK.test(m[0]))
|
|
84
|
+
if (!UNTIL_OK.test(m[0]))
|
|
85
|
+
errors.push(
|
|
86
|
+
`line ${i + 1}: bad until annotation ${m[0]} ` +
|
|
87
|
+
'(use verdict(<id>), retune(system) or manual(text))',
|
|
88
|
+
);
|
|
45
89
|
}
|
|
46
90
|
});
|
|
91
|
+
|
|
47
92
|
return errors;
|
|
48
93
|
}
|