gcf-aif-ext 0.4.1 → 0.5.1
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 +22 -1
- package/commands/gcf.js +172 -4
- package/extension.json +1 -1
- package/hooks/pre-commit.js +5 -66
- package/lib/board.js +113 -0
- package/lib/design-check.js +24 -6
- package/lib/gate.js +106 -0
- package/lib/integrate.js +322 -0
- package/lib/live.js +82 -23
- package/lib/main-lock.js +112 -0
- package/lib/phase.js +228 -0
- package/lib/playtest-list.js +60 -1
- package/lib/unit-ops.js +26 -1
- package/lib/validate-item.js +10 -0
- package/lib/verdict-fs.js +2 -1
- package/lib/verify.js +72 -0
- package/lib/work-ops.js +61 -7
- package/package.json +1 -1
- package/profiles/glossary.md +1 -1
- package/profiles/system.md +2 -2
- package/profiles/unit-constraint.md +3 -3
- package/profiles/unit-entity.md +3 -3
- package/profiles/unit-mechanic.md +6 -18
- package/profiles/unit-resource.md +6 -18
- package/profiles/unit-rule.md +3 -3
- package/schemas/item.schema.json +4 -0
- package/skills/gcf-card/SKILL.md +5 -4
- package/skills/gcf-verdict/SKILL.md +1 -1
- package/templates/agents-block.md +11 -4
- package/templates/config.yml +2 -0
package/lib/integrate.js
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
// The integrator (Methodology §7): an item runs in its worktree on branch `work/<id>`, and the
|
|
2
|
+
// branch is ready once its tip carries the trailer `GCF-Verified: <id>` — readiness lives in git,
|
|
3
|
+
// not in memory. `integrate` merges such a branch into main and records the item's move there,
|
|
4
|
+
// under the main lock; without an id it picks up every verified branch a dead run left behind.
|
|
5
|
+
import { appendFile, mkdir, readFile } from 'node:fs/promises';
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { buildLedger } from './ledger.js';
|
|
10
|
+
import { parse } from './frontmatter.js';
|
|
11
|
+
import { withMainLock } from './main-lock.js';
|
|
12
|
+
import { verifyItem, deltaProblems } from './verify.js';
|
|
13
|
+
import { submitItem, closeItem, blockItem } from './work-ops.js';
|
|
14
|
+
import { isValidId } from './id.js';
|
|
15
|
+
|
|
16
|
+
const TRAILER = 'GCF-Verified';
|
|
17
|
+
export const branchOf = (id) => `work/${id}`;
|
|
18
|
+
export const worktreeOf = (projectDir, id) =>
|
|
19
|
+
path.join(path.resolve(projectDir), '.gcf-session', 'worktrees', id);
|
|
20
|
+
|
|
21
|
+
function git(cwd, args, { check = true } = {}) {
|
|
22
|
+
const res = spawnSync('git', args, { cwd, encoding: 'utf8' });
|
|
23
|
+
const err = (res.stderr || res.stdout || '').trim();
|
|
24
|
+
if (check && res.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${err}`);
|
|
25
|
+
|
|
26
|
+
return { ok: res.status === 0, out: (res.stdout ?? '').trim(), err };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** `.gcf-session/` stays out of `git status` without a tracked .gitignore change. */
|
|
30
|
+
async function excludeSession(projectDir) {
|
|
31
|
+
const common = path.resolve(projectDir, git(projectDir, ['rev-parse', '--git-common-dir']).out);
|
|
32
|
+
const file = path.join(common, 'info', 'exclude');
|
|
33
|
+
const text = existsSync(file) ? await readFile(file, 'utf8') : '';
|
|
34
|
+
if (text.split(/\r?\n/).includes('/.gcf-session/')) return;
|
|
35
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
36
|
+
await appendFile(file, `${text && !text.endsWith('\n') ? '\n' : ''}/.gcf-session/\n`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const METRICS = ':(exclude)gcf/.metrics';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Bring the main checkout's branch into a resumed worktree: the operator's answer from
|
|
43
|
+
* `unblock`, a newer LIVE.md and the other items' states live on main only.
|
|
44
|
+
*/
|
|
45
|
+
function syncWithMain(projectDir, dir) {
|
|
46
|
+
const main = git(projectDir, ['rev-parse', '--abbrev-ref', 'HEAD']).out;
|
|
47
|
+
if (main === 'HEAD' || main.startsWith('work/')) return {};
|
|
48
|
+
const res = git(dir, ['merge', '--no-edit', '-m', `gcf(sync): ${main}`, main], { check: false });
|
|
49
|
+
if (res.ok) return {};
|
|
50
|
+
if (git(dir, ['rev-parse', '-q', '--verify', 'MERGE_HEAD'], { check: false }).ok)
|
|
51
|
+
git(dir, ['merge', '--abort'], { check: false });
|
|
52
|
+
|
|
53
|
+
return { sync: `${main} does not merge into the worktree: ${res.err}` };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A worktree for a ready item: `.gcf-session/worktrees/<id>` on branch `work/<id>` from HEAD.
|
|
58
|
+
* An existing one is resumed with main merged in; `sync` names a merge that did not go.
|
|
59
|
+
*/
|
|
60
|
+
export async function addWorktree(projectDir, id) {
|
|
61
|
+
if (!isValidId(id)) throw new Error(`${id} is not an item id`);
|
|
62
|
+
const item = (await buildLedger(projectDir)).item(id);
|
|
63
|
+
if (!item) throw new Error(`${id} is not a work item of gcf/work/`);
|
|
64
|
+
const dir = worktreeOf(projectDir, id);
|
|
65
|
+
if (existsSync(dir))
|
|
66
|
+
return {
|
|
67
|
+
id,
|
|
68
|
+
path: dir,
|
|
69
|
+
branch: branchOf(id),
|
|
70
|
+
created: false,
|
|
71
|
+
...syncWithMain(projectDir, dir),
|
|
72
|
+
};
|
|
73
|
+
if (item.data.status !== 'ready')
|
|
74
|
+
throw new Error(`${id} is ${item.data.status}: a run starts on a ready item`);
|
|
75
|
+
await excludeSession(projectDir);
|
|
76
|
+
// A worktree folder deleted by hand leaves its registration behind: add would refuse.
|
|
77
|
+
git(projectDir, ['worktree', 'prune']);
|
|
78
|
+
const ref = `refs/heads/${branchOf(id)}`;
|
|
79
|
+
const exists = git(projectDir, ['rev-parse', '--verify', '--quiet', ref], { check: false }).ok;
|
|
80
|
+
const from = exists ? [dir, branchOf(id)] : ['-b', branchOf(id), dir, 'HEAD'];
|
|
81
|
+
git(projectDir, ['worktree', 'add', ...from]);
|
|
82
|
+
|
|
83
|
+
return { id, path: dir, branch: branchOf(id), created: true };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* `verify --mark`: the DoD is green on branch `work/<id>` with everything committed — an empty
|
|
88
|
+
* commit carrying the trailer says so in git. Refuses on red checks or uncommitted work.
|
|
89
|
+
*/
|
|
90
|
+
export async function markVerified(worktreeDir, id, opts = {}) {
|
|
91
|
+
const branch = git(worktreeDir, ['rev-parse', '--abbrev-ref', 'HEAD']).out;
|
|
92
|
+
if (branch !== branchOf(id))
|
|
93
|
+
throw new Error(`${id} is verified on its branch ${branchOf(id)}, this is ${branch}`);
|
|
94
|
+
if (git(worktreeDir, ['status', '--porcelain', '--', '.', METRICS]).out)
|
|
95
|
+
throw new Error('commit the work first: the trailer marks a commit, not a working tree');
|
|
96
|
+
const r = await verifyItem(worktreeDir, id, opts);
|
|
97
|
+
if (!r.ok) return { ...r, marked: false };
|
|
98
|
+
const msg = ['-m', `gcf(verified): ${id}`, '-m', `${TRAILER}: ${id}`];
|
|
99
|
+
git(worktreeDir, ['commit', '--allow-empty', ...msg]);
|
|
100
|
+
|
|
101
|
+
return { ...r, marked: true };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isVerified(projectDir, id) {
|
|
105
|
+
const format = `--format=%(trailers:key=${TRAILER},valueonly)`;
|
|
106
|
+
const trailers = git(projectDir, ['log', '-1', format, branchOf(id)], { check: false }).out;
|
|
107
|
+
|
|
108
|
+
return trailers.split('\n').some((v) => v.trim() === id);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The ids of branches `work/<id>` whose tip carries `GCF-Verified: <id>`. */
|
|
112
|
+
export function verifiedBranches(projectDir) {
|
|
113
|
+
const refs = git(projectDir, ['for-each-ref', '--format=%(refname:short)', 'refs/heads/work/']);
|
|
114
|
+
|
|
115
|
+
return refs.out
|
|
116
|
+
.split('\n')
|
|
117
|
+
.filter(Boolean)
|
|
118
|
+
.map((b) => b.slice('work/'.length))
|
|
119
|
+
.filter((id) => isVerified(projectDir, id));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Take the worktree and the branch away once the branch is in main (or tagged, for a spike).
|
|
124
|
+
* Without --force: work left uncommitted in the worktree stops the removal and is named.
|
|
125
|
+
*/
|
|
126
|
+
function cleanUp(projectDir, id, { force = false } = {}) {
|
|
127
|
+
const notes = [];
|
|
128
|
+
const dir = worktreeOf(projectDir, id);
|
|
129
|
+
if (existsSync(dir)) {
|
|
130
|
+
// The run's own audit and context lines are no work to keep.
|
|
131
|
+
git(dir, ['checkout', '--', 'gcf/.metrics'], { check: false });
|
|
132
|
+
git(dir, ['clean', '-fdq', '--', 'gcf/.metrics'], { check: false });
|
|
133
|
+
const res = git(projectDir, ['worktree', 'remove', dir], { check: false });
|
|
134
|
+
if (!res.ok) notes.push(`worktree kept: ${res.err}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
git(projectDir, ['worktree', 'prune'], { check: false });
|
|
138
|
+
const res = git(projectDir, ['branch', force ? '-D' : '-d', branchOf(id)], { check: false });
|
|
139
|
+
if (!res.ok) notes.push(`branch kept: ${res.err}`);
|
|
140
|
+
|
|
141
|
+
return notes.length ? { cleanup: notes } : {};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The item file at `rev` without its `live:`: what a branch must leave as main has it. */
|
|
145
|
+
function itemBeyondLive(projectDir, rev, id) {
|
|
146
|
+
const res = git(projectDir, ['show', `${rev}:./gcf/work/${id}.md`], { check: false });
|
|
147
|
+
if (!res.ok) return null;
|
|
148
|
+
const { data, body } = parse(res.out);
|
|
149
|
+
const { live, ...rest } = data;
|
|
150
|
+
|
|
151
|
+
return JSON.stringify([rest, String(body).trim()]);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The branch may change in gcf/ only its own item's `live:` (Methodology §5.2): status, other
|
|
156
|
+
* items and the config move on main through their commands.
|
|
157
|
+
*/
|
|
158
|
+
function foreignGcfEdits(projectDir, id) {
|
|
159
|
+
const own = `gcf/work/${id}.md`;
|
|
160
|
+
const range = `HEAD...${branchOf(id)}`;
|
|
161
|
+
const changed = git(projectDir, ['diff', '--name-only', range, '--', 'gcf/'])
|
|
162
|
+
.out.split('\n')
|
|
163
|
+
.filter((f) => f && f !== own && f !== 'gcf/INDEX.md' && !f.startsWith('gcf/.metrics/'));
|
|
164
|
+
const base = git(projectDir, ['merge-base', 'HEAD', branchOf(id)]).out;
|
|
165
|
+
if (itemBeyondLive(projectDir, base, id) !== itemBeyondLive(projectDir, branchOf(id), id))
|
|
166
|
+
changed.push(`${own} beyond its live:`);
|
|
167
|
+
|
|
168
|
+
return changed;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Whether the item's move was recorded after its branch merged: a crash may fall in between. */
|
|
172
|
+
function movedSinceMerge(projectDir, id) {
|
|
173
|
+
const grep = ['--fixed-strings', `--grep=gcf(integrate): ${id}`];
|
|
174
|
+
const merge = git(projectDir, ['log', '-1', '--format=%H', ...grep]).out;
|
|
175
|
+
if (!merge) return false;
|
|
176
|
+
const after = git(projectDir, ['log', '--format=%s', `${merge}..HEAD`]).out.split('\n');
|
|
177
|
+
const move = new RegExp(`^gcf\\((submit|close|block)\\): ${id}( |$)`);
|
|
178
|
+
|
|
179
|
+
return after.some((s) => move.test(s));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The move a merged item ends its run with. Its delta is checked against main first — the
|
|
184
|
+
* worktree's LIVE.md may be behind. A move the item refuses blocks it with the reason; when even
|
|
185
|
+
* the block fails, the error goes up and the next integrate records the move again.
|
|
186
|
+
*/
|
|
187
|
+
async function recordMove(projectDir, item) {
|
|
188
|
+
const { id, gate } = item.data;
|
|
189
|
+
const stale = await deltaProblems(projectDir, item);
|
|
190
|
+
try {
|
|
191
|
+
if (stale.length) throw new Error(stale.join('; '));
|
|
192
|
+
if (gate === 'auto') return { move: 'close', ...(await closeItem(projectDir, id)) };
|
|
193
|
+
|
|
194
|
+
return { move: 'submit', ...(await submitItem(projectDir, id)) };
|
|
195
|
+
} catch (e) {
|
|
196
|
+
const reason = `integrate: ${e.message}`;
|
|
197
|
+
|
|
198
|
+
return { move: 'block', reason, ...(await blockItem(projectDir, id, { reason })) };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** A spike's code never reaches main (§5.5): only its plan does, and a tag keeps the branch. */
|
|
203
|
+
async function integrateSpike(projectDir, item) {
|
|
204
|
+
const id = String(item.data.id);
|
|
205
|
+
const plan = item.data.plan ?? `.ai-factory/plans/${id}.md`;
|
|
206
|
+
if (git(projectDir, ['cat-file', '-e', `${branchOf(id)}:./${plan}`], { check: false }).ok) {
|
|
207
|
+
git(projectDir, ['checkout', branchOf(id), '--', plan]);
|
|
208
|
+
// A plan main already holds as it is (a rerun after a crash) has nothing to commit.
|
|
209
|
+
if (!git(projectDir, ['diff', '--cached', '--quiet', '--', plan], { check: false }).ok)
|
|
210
|
+
git(projectDir, ['commit', '-m', `gcf(integrate): ${id} plan`, '--only', '--', plan]);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
git(projectDir, ['tag', '-f', `spike/${id}`, branchOf(id)]);
|
|
214
|
+
const moved = await recordMove(projectDir, item);
|
|
215
|
+
|
|
216
|
+
return { ...moved, ...cleanUp(projectDir, id, { force: true }) };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Merge the branch; null when it went in, else the conflict or the refusal. */
|
|
220
|
+
function mergeBranch(projectDir, id) {
|
|
221
|
+
const args = ['merge', '--no-ff', '--no-edit', '-m', `gcf(integrate): ${id}`, branchOf(id)];
|
|
222
|
+
const merge = git(projectDir, args, { check: false });
|
|
223
|
+
if (merge.ok) return null;
|
|
224
|
+
const head = ['rev-parse', '-q', '--verify', 'MERGE_HEAD'];
|
|
225
|
+
if (!git(projectDir, head, { check: false }).ok)
|
|
226
|
+
return { error: `merge of ${branchOf(id)} refused: ${merge.err}` };
|
|
227
|
+
git(projectDir, ['merge', '--abort'], { check: false });
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
conflict:
|
|
231
|
+
`${branchOf(id)} conflicts with main: in its worktree merge main, resolve, run ` +
|
|
232
|
+
`gcf verify ${id} --mark again, then gcf integrate ${id}`,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** A branch main already holds: its move may still be owed, after a crash in between. */
|
|
237
|
+
async function integrateMerged(projectDir, id, item) {
|
|
238
|
+
if (!item || item.data.status !== 'ready' || movedSinceMerge(projectDir, id))
|
|
239
|
+
return { id, skipped: `${branchOf(id)} is in main already`, ...cleanUp(projectDir, id) };
|
|
240
|
+
|
|
241
|
+
return { id, ...(await recordMove(projectDir, item)), ...cleanUp(projectDir, id) };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function integrateOne(projectDir, id) {
|
|
245
|
+
const item = (await buildLedger(projectDir)).item(id);
|
|
246
|
+
const ancestor = ['merge-base', '--is-ancestor', branchOf(id), 'HEAD'];
|
|
247
|
+
if (git(projectDir, ancestor, { check: false }).ok) return integrateMerged(projectDir, id, item);
|
|
248
|
+
if (!item || item.data.status !== 'ready')
|
|
249
|
+
return { id, skipped: `${id} is ${item ? item.data.status : 'not a work item'} on main` };
|
|
250
|
+
if (item.data.type === 'spike') return { id, ...(await integrateSpike(projectDir, item)) };
|
|
251
|
+
const foreign = foreignGcfEdits(projectDir, id);
|
|
252
|
+
if (foreign.length)
|
|
253
|
+
return {
|
|
254
|
+
id,
|
|
255
|
+
error:
|
|
256
|
+
`${branchOf(id)} changes gcf/ beyond the item's live: (${foreign.join(', ')}) — take ` +
|
|
257
|
+
'those edits out of the branch: they reach main through their commands',
|
|
258
|
+
};
|
|
259
|
+
const failed = mergeBranch(projectDir, id);
|
|
260
|
+
if (failed) return { id, ...failed };
|
|
261
|
+
const moved = await recordMove(projectDir, (await buildLedger(projectDir)).item(id));
|
|
262
|
+
|
|
263
|
+
return { id, ...moved, ...cleanUp(projectDir, id) };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Audit and context append to tracked logs in gcf/.metrics on every call: commit them, or a
|
|
268
|
+
* branch bringing its own lines could not merge over them. Union merge joins the lines.
|
|
269
|
+
*/
|
|
270
|
+
function commitMetrics(projectDir) {
|
|
271
|
+
const dirty = git(projectDir, [
|
|
272
|
+
'status',
|
|
273
|
+
'--porcelain',
|
|
274
|
+
'--untracked-files=no',
|
|
275
|
+
'--',
|
|
276
|
+
'gcf/.metrics',
|
|
277
|
+
]);
|
|
278
|
+
if (dirty.out)
|
|
279
|
+
git(projectDir, [
|
|
280
|
+
'commit',
|
|
281
|
+
'-q',
|
|
282
|
+
'--no-verify',
|
|
283
|
+
'-m',
|
|
284
|
+
'gcf(metrics): run logs',
|
|
285
|
+
'--only',
|
|
286
|
+
'--',
|
|
287
|
+
'gcf/.metrics',
|
|
288
|
+
]);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Merge verified branches into main and record each item's move: gate auto closes, the others
|
|
293
|
+
* submit; a move the item refuses blocks it with the reason. `ids` empty: every verified branch.
|
|
294
|
+
* One item's failure is reported and the others still go.
|
|
295
|
+
*/
|
|
296
|
+
export async function integrate(projectDir, ids = []) {
|
|
297
|
+
const branch = git(projectDir, ['rev-parse', '--abbrev-ref', 'HEAD']).out;
|
|
298
|
+
if (branch === 'HEAD' || branch.startsWith('work/'))
|
|
299
|
+
throw new Error(`integrate runs in the main checkout on its branch, not on ${branch}`);
|
|
300
|
+
|
|
301
|
+
return withMainLock(projectDir, async () => {
|
|
302
|
+
if (git(projectDir, ['status', '--porcelain', '--untracked-files=no', '--', '.', METRICS]).out)
|
|
303
|
+
throw new Error('main has uncommitted changes: commit or stash them before integrating');
|
|
304
|
+
commitMetrics(projectDir);
|
|
305
|
+
for (const id of ids)
|
|
306
|
+
if (!isVerified(projectDir, id))
|
|
307
|
+
throw new Error(
|
|
308
|
+
`${branchOf(id)} carries no ${TRAILER}: ${id} trailer — run gcf verify ${id} --mark ` +
|
|
309
|
+
'in its worktree once the DoD is green',
|
|
310
|
+
);
|
|
311
|
+
const results = [];
|
|
312
|
+
for (const id of ids.length ? ids : verifiedBranches(projectDir)) {
|
|
313
|
+
try {
|
|
314
|
+
results.push(await integrateOne(projectDir, id));
|
|
315
|
+
} catch (e) {
|
|
316
|
+
results.push({ id, error: e.message });
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return results;
|
|
321
|
+
});
|
|
322
|
+
}
|
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];
|
|
42
|
+
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
19
45
|
|
|
20
|
-
/**
|
|
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
|
}
|
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
|
+
}
|