gcf-aif-ext 0.4.0 → 0.5.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 +26 -1
- package/commands/gcf.js +206 -5
- package/extension.json +1 -1
- package/hooks/pre-commit.js +5 -61
- package/lib/agents-block.js +66 -0
- package/lib/board.js +113 -0
- package/lib/context.js +11 -4
- package/lib/design-check.js +74 -16
- package/lib/gate.js +106 -0
- package/lib/integrate.js +322 -0
- package/lib/live.js +98 -27
- package/lib/main-lock.js +112 -0
- package/lib/phase.js +228 -0
- package/lib/pillars.js +113 -0
- package/lib/playtest-list.js +60 -1
- package/lib/unit-ops.js +28 -2
- package/lib/validate-item.js +10 -0
- package/lib/verdict-fs.js +10 -3
- package/lib/verify.js +72 -0
- package/lib/work-ops.js +98 -12
- package/package.json +4 -3
- package/schemas/item.schema.json +4 -0
- package/skills/gcf-card/SKILL.md +10 -8
- package/skills/gcf-init/SKILL.md +6 -3
- package/skills/gcf-verdict/SKILL.md +1 -1
- package/templates/agents-block.md +36 -0
- package/templates/config.yml +2 -0
package/lib/live.js
CHANGED
|
@@ -9,53 +9,94 @@ import { atomicWrite } from './safe-path.js';
|
|
|
9
9
|
import { listItems } from './gcf-paths.js';
|
|
10
10
|
import { summaryProblem } from './validate-item.js';
|
|
11
11
|
|
|
12
|
-
const SECTIONS = {
|
|
12
|
+
const SECTIONS = {
|
|
13
|
+
release: 'release',
|
|
14
|
+
capabilities: 'capabilities',
|
|
15
|
+
'known-issues': 'knownIssues',
|
|
16
|
+
};
|
|
17
|
+
const SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
13
18
|
const NONE = {
|
|
14
19
|
capabilities: '- none: no capability recorded yet',
|
|
15
20
|
'known-issues': '- none: no known issue',
|
|
16
21
|
};
|
|
17
22
|
|
|
18
23
|
/** @typedef {{id: string, summary: string, refs?: string}} LiveRecord */
|
|
24
|
+
/** @typedef {{version: string, channel?: string, date?: string, phase?: string}} LiveRelease */
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A line of the one-record `release:` block (Methodology §13): the last release, which the end
|
|
28
|
+
* of the next release item replaces. False when the line is none of the block's lines.
|
|
29
|
+
*/
|
|
30
|
+
function releaseLine(out, line) {
|
|
31
|
+
const start = line.match(/^- version:\s*(\S+)\s*$/);
|
|
32
|
+
if (start) {
|
|
33
|
+
if (out.release) out.problems.push('release: holds one record, the last release');
|
|
34
|
+
out.release = { version: start[1] };
|
|
35
|
+
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const field = line.match(/^ {2}(channel|date|phase):\s*(\S+)\s*$/);
|
|
40
|
+
if (!field || !out.release) return false;
|
|
41
|
+
out.release[field[1]] = field[2];
|
|
19
42
|
|
|
20
|
-
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Parse LIVE.md text: `{phase, release, capabilities, knownIssues, problems}`. `phase`
|
|
48
|
+
* (Methodology §10) sits in the frontmatter, null until the first `gcf phase next`; `release`
|
|
49
|
+
* is null until a release item ends.
|
|
50
|
+
*/
|
|
21
51
|
export function parseLive(text) {
|
|
22
|
-
/** @type {{
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
52
|
+
/** @type {{phase: string | null, release: LiveRelease | null, capabilities: LiveRecord[],
|
|
53
|
+
* knownIssues: LiveRecord[], problems: string[]}} */
|
|
54
|
+
const out = { phase: null, release: null, capabilities: [], knownIssues: [], problems: [] };
|
|
55
|
+
const norm = String(text).replace(/\r\n/g, '\n');
|
|
56
|
+
const front = norm.match(/^---\n([\s\S]*?)\n---\n?/);
|
|
57
|
+
const phase = front?.[1].match(/^phase:(.*)$/m)?.[1].trim();
|
|
58
|
+
if (phase !== undefined && !SLUG.test(phase))
|
|
59
|
+
out.problems.push(`phase: ${phase} is not a phase name`);
|
|
60
|
+
else out.phase = phase ?? null;
|
|
61
|
+
const body = front ? norm.slice(front[0].length) : norm;
|
|
62
|
+
/** @type {'release' | 'capabilities' | 'knownIssues' | null} */
|
|
28
63
|
let section = null;
|
|
29
64
|
/** @type {LiveRecord | null} */
|
|
30
65
|
let rec = null;
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if (head) {
|
|
34
|
-
section = SECTIONS[head[1]] ?? null;
|
|
35
|
-
if (!section) out.problems.push(`line ${i + 1}: unknown section ${head[1]}:`);
|
|
36
|
-
rec = null;
|
|
37
|
-
|
|
38
|
-
return;
|
|
39
|
-
}
|
|
40
|
-
|
|
66
|
+
/** A record line of capabilities or known-issues; false when the line is not one. */
|
|
67
|
+
const recordLine = (line) => {
|
|
41
68
|
const start = line.match(/^- id:\s*(\S+)\s*$/);
|
|
42
69
|
if (start && section) {
|
|
43
70
|
rec = { id: start[1], summary: '' };
|
|
44
71
|
out[section].push(rec);
|
|
45
72
|
|
|
46
|
-
return;
|
|
73
|
+
return true;
|
|
47
74
|
}
|
|
48
75
|
|
|
49
76
|
const field = line.match(/^ {2}(summary|refs):\s*(.+?)\s*$/);
|
|
50
|
-
if (field
|
|
51
|
-
|
|
77
|
+
if (!field || !rec) return false;
|
|
78
|
+
rec[field[1]] = field[2];
|
|
79
|
+
|
|
80
|
+
return true;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
body.split('\n').forEach((line, i) => {
|
|
84
|
+
const head = line.match(/^([a-z-]+):\s*$/);
|
|
85
|
+
if (head) {
|
|
86
|
+
section = SECTIONS[head[1]] ?? null;
|
|
87
|
+
if (!section) out.problems.push(`line ${i + 1}: unknown section ${head[1]}:`);
|
|
88
|
+
rec = null;
|
|
52
89
|
|
|
53
90
|
return;
|
|
54
91
|
}
|
|
55
92
|
|
|
93
|
+
if (section === 'release' ? releaseLine(out, line) : recordLine(line)) return;
|
|
56
94
|
if (line.trim() && !/^- none\b/.test(line) && !line.startsWith('#'))
|
|
57
95
|
out.problems.push(`line ${i + 1}: not a record line: ${line.trim()}`);
|
|
58
96
|
});
|
|
97
|
+
const r = out.release;
|
|
98
|
+
if (r && !(SLUG.test(r.channel ?? '') && /^\d{4}-\d{2}-\d{2}$/.test(r.date ?? '')))
|
|
99
|
+
out.problems.push('release: needs version, a slug channel and a YYYY-MM-DD date');
|
|
59
100
|
|
|
60
101
|
return out;
|
|
61
102
|
}
|
|
@@ -66,12 +107,18 @@ const recordText = (r) =>
|
|
|
66
107
|
const byId = (a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
67
108
|
|
|
68
109
|
/** LIVE.md text: records sorted by id, so two branches touching different ids merge cleanly. */
|
|
69
|
-
export function serializeLive({ capabilities, knownIssues }) {
|
|
110
|
+
export function serializeLive({ phase = null, release = null, capabilities, knownIssues }) {
|
|
70
111
|
const block = (key, list) =>
|
|
71
112
|
`${key}:\n${list.length ? [...list].sort(byId).map(recordText).join('\n') : NONE[key]}\n`;
|
|
72
113
|
|
|
73
114
|
return [
|
|
74
|
-
|
|
115
|
+
`---\ntype: live\n${phase ? `phase: ${phase}\n` : ''}---\n`,
|
|
116
|
+
...(release
|
|
117
|
+
? [
|
|
118
|
+
`release:\n- version: ${release.version}\n channel: ${release.channel}\n` +
|
|
119
|
+
` date: ${release.date}\n${release.phase ? ` phase: ${release.phase}\n` : ''}`,
|
|
120
|
+
]
|
|
121
|
+
: []),
|
|
75
122
|
block('capabilities', capabilities),
|
|
76
123
|
block('known-issues', knownIssues),
|
|
77
124
|
].join('\n');
|
|
@@ -85,6 +132,18 @@ export async function readLive(root) {
|
|
|
85
132
|
return parseLive(existsSync(file) ? await readFile(file, 'utf8') : '');
|
|
86
133
|
}
|
|
87
134
|
|
|
135
|
+
/**
|
|
136
|
+
* LIVE.md for a command that writes it or acts on its phase: a malformed file refuses, since
|
|
137
|
+
* rewriting it would drop what the parser could not read.
|
|
138
|
+
*/
|
|
139
|
+
export async function readLiveStrict(root) {
|
|
140
|
+
const live = await readLive(root);
|
|
141
|
+
if (live.problems.length)
|
|
142
|
+
throw new Error(`gcf/LIVE.md: ${live.problems[0]} — fix it by hand in its own commit first`);
|
|
143
|
+
|
|
144
|
+
return live;
|
|
145
|
+
}
|
|
146
|
+
|
|
88
147
|
export async function writeLive(root, live) {
|
|
89
148
|
await atomicWrite(liveFile(root), serializeLive(live));
|
|
90
149
|
}
|
|
@@ -117,13 +176,25 @@ export function applyDelta(capabilities, delta, itemId) {
|
|
|
117
176
|
/**
|
|
118
177
|
* The capabilities without what `delta` added: an unplayed mechanic killed, dropped or sent back
|
|
119
178
|
* to tuning leaves the game, so its record leaves LIVE.md with it. `modified` and `removed`
|
|
120
|
-
* cannot be undone from the delta alone and stay as they are.
|
|
179
|
+
* cannot be undone from the delta alone and stay as they are. A record another item modified
|
|
180
|
+
* since is refused: taking it out would erase that item's accepted change.
|
|
121
181
|
*/
|
|
122
|
-
export function revertDelta(capabilities, delta) {
|
|
182
|
+
export function revertDelta(capabilities, delta, itemId) {
|
|
123
183
|
if (!delta || 'none' in delta) return capabilities;
|
|
124
|
-
const added = delta.changes.filter((c) => c.change === 'added')
|
|
184
|
+
const added = delta.changes.filter((c) => c.change === 'added');
|
|
185
|
+
for (const c of added) {
|
|
186
|
+
const now = capabilities.find((r) => r.id === c.id);
|
|
187
|
+
// LIVE.md keeps fields trimmed: compare in that form, or a trailing space reads as an edit.
|
|
188
|
+
const same = (a, b) => String(a ?? '').trim() === String(b ?? '').trim();
|
|
189
|
+
if (now && !(same(now.summary, c.summary) && same(now.refs, c.refs)))
|
|
190
|
+
throw new Error(
|
|
191
|
+
`${itemId}: capability ${c.id} was modified after ${itemId} added it — taking it out ` +
|
|
192
|
+
'would erase that change: remove the record from LIVE.md by hand in its own commit ' +
|
|
193
|
+
'and repeat, or keep the item',
|
|
194
|
+
);
|
|
195
|
+
}
|
|
125
196
|
|
|
126
|
-
return capabilities.filter((r) => !added.
|
|
197
|
+
return capabilities.filter((r) => !added.some((c) => c.id === r.id));
|
|
127
198
|
}
|
|
128
199
|
|
|
129
200
|
/** The known-issue record of a parked bug: its title or first intent bullet, and its file. */
|
package/lib/main-lock.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Writes to main serialize through `.gcf-session/main.lock` (Methodology §7): every committed gcf
|
|
2
|
+
// move and the integrator hold it while they write and commit, so a run and the operator wait for
|
|
3
|
+
// each other instead of committing over each other. Re-entrant along one call chain: `integrate`
|
|
4
|
+
// holds it across the merge and the move it records.
|
|
5
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
6
|
+
import { link, mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
|
7
|
+
import { randomUUID } from 'node:crypto';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
const chain = new AsyncLocalStorage();
|
|
11
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
12
|
+
|
|
13
|
+
export const lockFile = (projectDir) =>
|
|
14
|
+
path.join(path.resolve(projectDir), '.gcf-session', 'main.lock');
|
|
15
|
+
|
|
16
|
+
const alive = (pid) => {
|
|
17
|
+
try {
|
|
18
|
+
process.kill(pid, 0);
|
|
19
|
+
|
|
20
|
+
return true;
|
|
21
|
+
} catch (e) {
|
|
22
|
+
// EPERM: alive under another user. ESRCH: gone.
|
|
23
|
+
return e.code === 'EPERM';
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** The lock's text when its holder is gone, else null. The file appears whole: see `take`. */
|
|
28
|
+
async function staleText(file) {
|
|
29
|
+
try {
|
|
30
|
+
const text = await readFile(file, 'utf8');
|
|
31
|
+
const pid = Number(text.split('\n')[0]);
|
|
32
|
+
if (Number.isInteger(pid) && pid > 0) return alive(pid) ? null : text;
|
|
33
|
+
|
|
34
|
+
// Not ours to read: a lock in some other shape counts as dead only once it is old.
|
|
35
|
+
return Date.now() - (await stat(file)).mtimeMs > 10000 ? text : null;
|
|
36
|
+
} catch {
|
|
37
|
+
return null; // released meanwhile
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Take the lock atomically: the pid goes into a file of its own first, and a hard link puts it
|
|
43
|
+
* in place whole or fails with EEXIST — no reader ever sees a lock without its pid.
|
|
44
|
+
*/
|
|
45
|
+
async function take(file, token) {
|
|
46
|
+
const tmp = `${file}.${token}`;
|
|
47
|
+
await writeFile(tmp, `${process.pid}\n${token}\n`);
|
|
48
|
+
try {
|
|
49
|
+
await link(tmp, file);
|
|
50
|
+
|
|
51
|
+
return true;
|
|
52
|
+
} catch (e) {
|
|
53
|
+
if (e.code !== 'EEXIST') throw e;
|
|
54
|
+
|
|
55
|
+
return false;
|
|
56
|
+
} finally {
|
|
57
|
+
await unlink(tmp).catch(() => {});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Move a dead holder's lock aside. shortcut: two takers that judged the same dead lock may race
|
|
63
|
+
* here — the rename lets one of them win, a fresh lock renamed by the loser is put back.
|
|
64
|
+
*/
|
|
65
|
+
async function clearStale(file, text) {
|
|
66
|
+
const aside = `${file}.stale-${randomUUID()}`;
|
|
67
|
+
try {
|
|
68
|
+
await rename(file, aside);
|
|
69
|
+
} catch {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if ((await readFile(aside, 'utf8').catch(() => text)) !== text)
|
|
74
|
+
await link(aside, file).catch(() => {});
|
|
75
|
+
await unlink(aside).catch(() => {});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Run `fn` holding the main lock. Waits up to `waitMs` for another holder; a lock whose process
|
|
80
|
+
* died is taken over.
|
|
81
|
+
* @template T
|
|
82
|
+
* @param {string} projectDir
|
|
83
|
+
* @param {() => Promise<T>} fn
|
|
84
|
+
* @returns {Promise<T>}
|
|
85
|
+
*/
|
|
86
|
+
export async function withMainLock(projectDir, fn, { waitMs = 60000 } = {}) {
|
|
87
|
+
const file = lockFile(projectDir);
|
|
88
|
+
const held = chain.getStore() ?? new Set();
|
|
89
|
+
if (held.has(file)) return fn();
|
|
90
|
+
|
|
91
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
92
|
+
const token = randomUUID();
|
|
93
|
+
const until = Date.now() + waitMs;
|
|
94
|
+
while (!(await take(file, token))) {
|
|
95
|
+
const dead = await staleText(file);
|
|
96
|
+
if (dead !== null) await clearStale(file, dead);
|
|
97
|
+
else if (Date.now() > until)
|
|
98
|
+
throw new Error(
|
|
99
|
+
`gcf/ is busy: another gcf move holds ${path.relative(projectDir, file)} — retry when ` +
|
|
100
|
+
'it ends, or delete the file if no gcf command is running',
|
|
101
|
+
);
|
|
102
|
+
else await sleep(100);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
return await chain.run(new Set([...held, file]), fn);
|
|
107
|
+
} finally {
|
|
108
|
+
// Only our own lock leaves: one taken over as dead meanwhile belongs to its new holder.
|
|
109
|
+
const text = await readFile(file, 'utf8').catch(() => '');
|
|
110
|
+
if (text.includes(token)) await unlink(file).catch(() => {});
|
|
111
|
+
}
|
|
112
|
+
}
|
package/lib/phase.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// Phases (Methodology §10): the project's stage decides which item types it takes, and each
|
|
2
|
+
// stage has exit criteria. The current phase is `phase:` in the LIVE.md frontmatter, the first
|
|
3
|
+
// phase while it is absent; the set comes from code, and `phases:` in gcf/config.yml replaces it.
|
|
4
|
+
// Only the operator moves the phase on: `phase next`, against unmet criteria with a reason.
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { readConfig } from './gcf-paths.js';
|
|
7
|
+
import { readLiveStrict, writeLive, liveFile } from './live.js';
|
|
8
|
+
import { buildLedger } from './ledger.js';
|
|
9
|
+
import { ITEM_TYPES } from './validate-item.js';
|
|
10
|
+
import { commitMove } from './unit-ops.js';
|
|
11
|
+
|
|
12
|
+
const ALL = ITEM_TYPES;
|
|
13
|
+
const SOFT = ['mechanic', 'resource'];
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {{name: string, accepts: string[], with_reopens: string[], exit: string[],
|
|
17
|
+
* milestone?: string}} Phase
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** @type {Phase[]} */
|
|
21
|
+
export const DEFAULT_PHASES = [
|
|
22
|
+
// Final assets are not requested in a prototype (§5.6): placeholders need no item.
|
|
23
|
+
{
|
|
24
|
+
name: 'prototype',
|
|
25
|
+
accepts: ALL.filter((t) => t !== 'asset'),
|
|
26
|
+
with_reopens: [],
|
|
27
|
+
exit: ['mechanic-kept'],
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'vertical-slice',
|
|
31
|
+
accepts: ALL,
|
|
32
|
+
with_reopens: [],
|
|
33
|
+
exit: ['milestone-done', 'no-placeholders'],
|
|
34
|
+
},
|
|
35
|
+
{ name: 'alpha', accepts: ALL, with_reopens: [], exit: ['features-done'] },
|
|
36
|
+
{
|
|
37
|
+
name: 'beta',
|
|
38
|
+
accepts: ['bug', 'perf', 'content', 'asset', 'release'],
|
|
39
|
+
with_reopens: SOFT,
|
|
40
|
+
exit: ['no-open-bugs'],
|
|
41
|
+
},
|
|
42
|
+
{ name: 'release', accepts: ['bug', 'release'], with_reopens: [], exit: ['release-done'] },
|
|
43
|
+
{ name: 'live', accepts: ['bug', 'content', 'perf', 'release'], with_reopens: SOFT, exit: [] },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
const finished = ['unplayed', 'parked'];
|
|
47
|
+
const milestoneOf = (phase) => phase.milestone ?? phase.name;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Exit criteria: `true` met, `false` not, `null` for one only the operator can see. Each gets
|
|
51
|
+
* the ledger, LIVE.md and the phase, and returns `[met, detail]`.
|
|
52
|
+
*/
|
|
53
|
+
const CRITERIA = {
|
|
54
|
+
'mechanic-kept': (ledger) => {
|
|
55
|
+
const kept = ledger.ids().filter((id) => ledger.where(id).terminal.includes('collapsed'));
|
|
56
|
+
|
|
57
|
+
return [
|
|
58
|
+
kept.length > 0,
|
|
59
|
+
kept.length ? `kept: ${kept.join(', ')}` : 'no mechanic or resource kept yet',
|
|
60
|
+
];
|
|
61
|
+
},
|
|
62
|
+
// An ended item leaves no file, so "nothing open" cannot tell a done milestone from one never
|
|
63
|
+
// tagged: the operator reads that case, the code only names what is still open.
|
|
64
|
+
'milestone-done': (ledger, live, phase) => {
|
|
65
|
+
const m = milestoneOf(phase);
|
|
66
|
+
const open = [
|
|
67
|
+
...ledger.design.flatMap((d) => d.units).filter((u) => u.header.milestone === m),
|
|
68
|
+
...ledger.items
|
|
69
|
+
.filter((c) => c.data.milestone === m && !finished.includes(c.data.status))
|
|
70
|
+
.map((c) => ({ id: c.data.id })),
|
|
71
|
+
].map((x) => x.id);
|
|
72
|
+
|
|
73
|
+
return open.length
|
|
74
|
+
? [false, `milestone ${m} still open: ${open.join(', ')}`]
|
|
75
|
+
: [null, `nothing open carries milestone ${m}: check by hand it is done, not untagged`];
|
|
76
|
+
},
|
|
77
|
+
// Feature complete: a live feature of this milestone, or one with no milestone at all, is open.
|
|
78
|
+
'features-done': (ledger, live, phase) => {
|
|
79
|
+
const m = milestoneOf(phase);
|
|
80
|
+
const open = ledger.items
|
|
81
|
+
.filter((c) => c.data.type === 'feature' && [m, null, undefined].includes(c.data.milestone))
|
|
82
|
+
.map((c) => c.data.id);
|
|
83
|
+
|
|
84
|
+
return [
|
|
85
|
+
!open.length,
|
|
86
|
+
open.length ? `features open: ${open.join(', ')}` : `no feature of ${m} or untagged open`,
|
|
87
|
+
];
|
|
88
|
+
},
|
|
89
|
+
'no-open-bugs': (ledger) => {
|
|
90
|
+
const open = ledger.items
|
|
91
|
+
.filter(
|
|
92
|
+
(c) =>
|
|
93
|
+
c.data.type === 'bug' &&
|
|
94
|
+
['critical', 'high'].includes(c.data.severity) &&
|
|
95
|
+
c.data.status !== 'parked',
|
|
96
|
+
)
|
|
97
|
+
.map((c) => c.data.id);
|
|
98
|
+
|
|
99
|
+
return [
|
|
100
|
+
!open.length,
|
|
101
|
+
open.length ? `critical/high bugs open: ${open.join(', ')}` : 'no critical or high bug open',
|
|
102
|
+
];
|
|
103
|
+
},
|
|
104
|
+
// Only a release shipped in this phase: a demo shipped in alpha does not end the release phase.
|
|
105
|
+
'release-done': (ledger, live) => {
|
|
106
|
+
const r = live.release;
|
|
107
|
+
const ours = Boolean(r) && (r.phase ?? null) === live.phase;
|
|
108
|
+
|
|
109
|
+
return [
|
|
110
|
+
ours,
|
|
111
|
+
ours
|
|
112
|
+
? `released ${r.version} to ${r.channel}`
|
|
113
|
+
: 'no release of this phase in LIVE.md: its release item has not ended yet',
|
|
114
|
+
];
|
|
115
|
+
},
|
|
116
|
+
'no-placeholders': () => [null, 'check by hand: the slice holds no placeholder'],
|
|
117
|
+
};
|
|
118
|
+
export const CRITERIA_NAMES = Object.keys(CRITERIA);
|
|
119
|
+
|
|
120
|
+
const isList = (v, of) => Array.isArray(v) && v.every((x) => of.includes(x));
|
|
121
|
+
const isSlug = (v) => typeof v === 'string' && /^[a-z0-9]+(-[a-z0-9]+)*$/.test(v);
|
|
122
|
+
|
|
123
|
+
/** The phases of a project: `phases:` of gcf/config.yml, checked, or the default set. */
|
|
124
|
+
export function phasesOf(config) {
|
|
125
|
+
const list = config.phases;
|
|
126
|
+
if (list === undefined) return DEFAULT_PHASES;
|
|
127
|
+
const bad = (msg) => new Error(`gcf/config.yml phases: ${msg}`);
|
|
128
|
+
if (!Array.isArray(list) || !list.length) throw bad('a non-empty list of phases');
|
|
129
|
+
const names = list.map((p) => p?.name);
|
|
130
|
+
const twice = names.find((n, i) => names.indexOf(n) !== i);
|
|
131
|
+
if (twice !== undefined) throw bad(`${twice} is listed twice`);
|
|
132
|
+
|
|
133
|
+
return list.map((p, i) => {
|
|
134
|
+
const name = p?.name;
|
|
135
|
+
if (!isSlug(name)) throw bad(`entry ${i + 1} needs a slug name`);
|
|
136
|
+
if (p.milestone !== undefined && !isSlug(p.milestone))
|
|
137
|
+
throw bad(`${name}: milestone is a milestone name`);
|
|
138
|
+
const accepts = p.accepts === 'all' ? ALL : p.accepts;
|
|
139
|
+
if (!isList(accepts, ALL)) throw bad(`${name}: accepts is all or a list of ${ALL.join(', ')}`);
|
|
140
|
+
const withReopens = p.with_reopens ?? [];
|
|
141
|
+
if (!isList(withReopens, ALL)) throw bad(`${name}: with_reopens is a list of item types`);
|
|
142
|
+
const exit = p.exit ?? [];
|
|
143
|
+
if (!isList(exit, CRITERIA_NAMES))
|
|
144
|
+
throw bad(`${name}: exit is a list of ${CRITERIA_NAMES.join(', ')}`);
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
name,
|
|
148
|
+
accepts,
|
|
149
|
+
with_reopens: withReopens,
|
|
150
|
+
exit,
|
|
151
|
+
...(p.milestone ? { milestone: p.milestone } : {}),
|
|
152
|
+
};
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The phases, the current one and LIVE.md. A `phase:` the set does not name is an error. */
|
|
157
|
+
async function load(projectDir) {
|
|
158
|
+
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
159
|
+
const phases = phasesOf(await readConfig(root));
|
|
160
|
+
const live = await readLiveStrict(root);
|
|
161
|
+
const at = live.phase === null ? 0 : phases.findIndex((p) => p.name === live.phase);
|
|
162
|
+
if (at < 0) {
|
|
163
|
+
const names = phases.map((p) => p.name).join(', ');
|
|
164
|
+
throw new Error(`LIVE.md phase ${live.phase} is not a phase of the project (${names})`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return { root, phases, at, live };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Refuse a new item of `type` the current phase does not take. */
|
|
171
|
+
export async function refuseByPhase(projectDir, type, reopens = null) {
|
|
172
|
+
const { phases, at } = await load(projectDir);
|
|
173
|
+
const phase = phases[at];
|
|
174
|
+
if (phase.accepts.includes(type) || (reopens && phase.with_reopens.includes(type))) return;
|
|
175
|
+
const takes = phase.with_reopens.length
|
|
176
|
+
? `${phase.accepts.join(', ')}; ${phase.with_reopens.join(', ')} only with reopens`
|
|
177
|
+
: phase.accepts.join(', ');
|
|
178
|
+
throw new Error(
|
|
179
|
+
`phase ${phase.name} takes no ${type} item (it takes ${takes}) — the operator moves the ` +
|
|
180
|
+
'phase on with gcf phase next',
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** `gcf phase`: the current phase, what it takes, its exit criteria and the next phase. */
|
|
185
|
+
export async function phaseStatus(projectDir) {
|
|
186
|
+
const { phases, at, live } = await load(projectDir);
|
|
187
|
+
const phase = phases[at];
|
|
188
|
+
const ledger = await buildLedger(projectDir);
|
|
189
|
+
const criteria = phase.exit.map((name) => {
|
|
190
|
+
const [met, detail] = CRITERIA[name](ledger, live, phase);
|
|
191
|
+
|
|
192
|
+
return { name, met, detail };
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
phase: phase.name,
|
|
197
|
+
accepts: phase.accepts,
|
|
198
|
+
with_reopens: phase.with_reopens,
|
|
199
|
+
criteria,
|
|
200
|
+
next: phases[at + 1]?.name ?? null,
|
|
201
|
+
ready: criteria.every((c) => c.met !== false),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The operator moves the phase on, commit `gcf(phase): <from> → <to>`. An unmet criterion needs
|
|
207
|
+
* `reason`, which the commit carries; the move itself is the operator's word on one that only
|
|
208
|
+
* the operator can check.
|
|
209
|
+
*/
|
|
210
|
+
export async function nextPhase(projectDir, { reason = '', commit = true } = {}) {
|
|
211
|
+
const status = await phaseStatus(projectDir);
|
|
212
|
+
if (!status.next) throw new Error(`${status.phase} is the last phase`);
|
|
213
|
+
const unmet = status.criteria.filter((c) => c.met === false);
|
|
214
|
+
if (unmet.length && !reason.trim())
|
|
215
|
+
throw new Error(
|
|
216
|
+
`${status.phase} exit criteria not met (${unmet.map((c) => c.detail).join('; ')}) — ` +
|
|
217
|
+
'move on anyway with --reason "<why>"',
|
|
218
|
+
);
|
|
219
|
+
const { root, live } = await load(projectDir);
|
|
220
|
+
const tail = `${status.phase} → ${status.next}${reason.trim() ? `: ${reason.trim()}` : ''}`;
|
|
221
|
+
const hash = await commitMove(projectDir, `gcf(phase): ${tail}`, {
|
|
222
|
+
files: [liveFile(root)],
|
|
223
|
+
mutate: () => writeLive(root, { ...live, phase: status.next }),
|
|
224
|
+
commit,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
return { from: status.phase, to: status.next, forced: unmet.map((c) => c.name), hash };
|
|
228
|
+
}
|
package/lib/pillars.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Revising PILLARS.md after units and items cite it: a rename follows the id everywhere it is
|
|
2
|
+
// cited, a retire takes it out and sends every unit left without a pillar back to the human.
|
|
3
|
+
// Pillars are human authorship (gcf-init): these moves only carry out the human's decision.
|
|
4
|
+
import { readFile } from 'node:fs/promises';
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { serialize } from './frontmatter.js';
|
|
8
|
+
import { SLUG_RE, listOf, parseDesign } from './design.js';
|
|
9
|
+
import { recordsIn } from './design-check.js';
|
|
10
|
+
import { buildLedger } from './ledger.js';
|
|
11
|
+
import { atomicWrite } from './safe-path.js';
|
|
12
|
+
import { commitMove } from './unit-ops.js';
|
|
13
|
+
|
|
14
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
15
|
+
|
|
16
|
+
/** The `id: <id>` line of a PILLARS.md record; `\r` and the line break stay outside it. */
|
|
17
|
+
const idLine = (id) =>
|
|
18
|
+
new RegExp(`^([ \\t]*(?:- )?id:[ \\t]*)${escapeRe(id)}[ \\t]*(?=\\r?$)`, 'm');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The design file with `edit` applied to each unit's header `pillars:` line — the header only:
|
|
22
|
+
* a `pillars:` line in a body or the preamble is not a citation. TBD stays TBD; a list left
|
|
23
|
+
* empty reads TBD, so the unit is not ready until the human gives it a pillar again.
|
|
24
|
+
*/
|
|
25
|
+
function rewriteHeaders(text, edit) {
|
|
26
|
+
const eol = text.includes('\r\n') ? '\r\n' : '\n';
|
|
27
|
+
const lines = text.split(/\r?\n/);
|
|
28
|
+
for (const u of parseDesign(text, '').units) {
|
|
29
|
+
const at = u.headerLines.pillars;
|
|
30
|
+
const list = listOf(u.header.pillars ?? '');
|
|
31
|
+
if (at === undefined || list === 'TBD') continue;
|
|
32
|
+
const next = edit(list);
|
|
33
|
+
if (next.join() !== list.join())
|
|
34
|
+
lines[at - 1] = `pillars: ${next.length ? `[${next.join(', ')}]` : 'TBD'}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return lines.join(eol);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Apply `edit` to every citing list — unit headers in design/, item frontmatter in work/ — and
|
|
42
|
+
* `editPillars` to PILLARS.md, in one commit `gcf(pillar): <tail>`. Returns the items left
|
|
43
|
+
* citing no pillar: an item in work has no TBD to fall back to, so the human hears about it.
|
|
44
|
+
*/
|
|
45
|
+
async function revise(projectDir, id, { edit, editPillars, tail, commit }) {
|
|
46
|
+
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
47
|
+
const pillarsFile = path.join(root, 'PILLARS.md');
|
|
48
|
+
const text = existsSync(pillarsFile) ? await readFile(pillarsFile, 'utf8') : '';
|
|
49
|
+
const ids = recordsIn(text, 'pillars').map((p) => p.id);
|
|
50
|
+
if (!ids.includes(id)) throw new Error(`${id} is not a pillar of gcf/PILLARS.md`);
|
|
51
|
+
const ledger = await buildLedger(projectDir);
|
|
52
|
+
const designFiles = ledger.design.map((d) => path.join(root, d.file));
|
|
53
|
+
const items = ledger.items.filter((c) => (c.data.pillars ?? []).includes(id));
|
|
54
|
+
const files = [pillarsFile, ...designFiles, ...items.map((c) => c.file)];
|
|
55
|
+
const mutate = async () => {
|
|
56
|
+
await atomicWrite(pillarsFile, editPillars(text));
|
|
57
|
+
for (const f of designFiles) {
|
|
58
|
+
const old = await readFile(f, 'utf8');
|
|
59
|
+
const next = rewriteHeaders(old, edit);
|
|
60
|
+
if (next !== old) await atomicWrite(f, next);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const c of items) {
|
|
64
|
+
await atomicWrite(c.file, serialize({ ...c.data, pillars: edit(c.data.pillars) }, c.body));
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
id,
|
|
70
|
+
hash: await commitMove(projectDir, `gcf(pillar): ${tail}`, { files, mutate, commit }),
|
|
71
|
+
bare: items.filter((c) => !edit(c.data.pillars).length).map((c) => String(c.data.id)),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** `pillar rename <from> <to>`: the id changes in PILLARS.md and every unit and item citing it. */
|
|
76
|
+
export async function renamePillar(projectDir, from, to, { commit = true } = {}) {
|
|
77
|
+
if (!SLUG_RE.test(to)) throw new Error(`${to}: a pillar id is a lowercase hyphen slug`);
|
|
78
|
+
const text = await readFile(path.join(projectDir, 'gcf', 'PILLARS.md'), 'utf8').catch(() => '');
|
|
79
|
+
if (recordsIn(text, 'pillars').some((p) => p.id === to))
|
|
80
|
+
throw new Error(`${to} is a pillar already`);
|
|
81
|
+
|
|
82
|
+
return revise(projectDir, from, {
|
|
83
|
+
edit: (list) => list.map((p) => (p === from ? to : p)),
|
|
84
|
+
editPillars: (t) => t.replace(idLine(from), (_, head) => `${head}${to}`),
|
|
85
|
+
tail: `rename ${from} → ${to}`,
|
|
86
|
+
commit,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* `pillar retire <id> --reason`: the record leaves PILLARS.md and the id leaves every list. A
|
|
92
|
+
* unit left citing no pillar reads `pillars: TBD` again — it is not ready until the human gives
|
|
93
|
+
* it one, or decides it is out of scope. The reason is kept in the commit.
|
|
94
|
+
*/
|
|
95
|
+
export async function retirePillar(projectDir, id, { reason, commit = true }) {
|
|
96
|
+
const why = String(reason ?? '')
|
|
97
|
+
.replace(/\s+/g, ' ')
|
|
98
|
+
.trim();
|
|
99
|
+
if (!why) throw new Error('a retire needs a reason: the commit keeps it');
|
|
100
|
+
const own = idLine(id);
|
|
101
|
+
|
|
102
|
+
return revise(projectDir, id, {
|
|
103
|
+
edit: (list) => list.filter((p) => p !== id),
|
|
104
|
+
// A record starts at a `- ` line; the one holding `id: <id>` goes, blank lines and all.
|
|
105
|
+
editPillars: (t) =>
|
|
106
|
+
t
|
|
107
|
+
.split(/\n(?=- )/)
|
|
108
|
+
.filter((rec) => !own.test(rec))
|
|
109
|
+
.join('\n'),
|
|
110
|
+
tail: `retire ${id}: ${why}`,
|
|
111
|
+
commit,
|
|
112
|
+
});
|
|
113
|
+
}
|