@warren-dean/code-kit 0.12.1 → 0.13.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +18 -0
- package/README.md +108 -40
- package/SECURITY.md +11 -2
- package/bin/code-kit.mjs +61 -17
- package/hooks/guard-bash.mjs +35 -0
- package/hooks/lib/checks.mjs +19 -6
- package/hooks/lib/config.mjs +8 -0
- package/hooks/lib/next.mjs +34 -8
- package/hooks/lib/plan.mjs +4 -2
- package/hooks/lib/queue.mjs +25 -6
- package/hooks/lib/regression.mjs +321 -0
- package/hooks/lib/rules.mjs +34 -0
- package/hooks/lib/verify.mjs +53 -7
- package/hooks/mod/decisions.mjs +102 -6
- package/hooks/mod/loop.mjs +1 -1
- package/hooks/mod/panes.mjs +92 -11
- package/hooks/mod/register.mjs +79 -22
- package/hooks/mod/register.test.ts +215 -68
- package/hooks/mod/view.mjs +96 -25
- package/hooks/stop-check.mjs +1 -1
- package/package.json +1 -1
- package/skills/dispatch/SKILL.md +1 -0
- package/skills/review/SKILL.md +3 -1
- package/skills/spec-design/SKILL.md +1 -1
- package/templates/agent.md +2 -0
- package/templates/claude-md.md +1 -1
- package/templates/plan.md +1 -1
package/bin/code-kit.mjs
CHANGED
|
@@ -106,6 +106,8 @@ import {
|
|
|
106
106
|
import { DEPENDENCY_APPROVAL, dependencyApproval } from '../hooks/lib/dependencies.mjs';
|
|
107
107
|
import { dependencyRuleProblems, npmFacts, packageOf } from '../hooks/lib/registry.mjs';
|
|
108
108
|
import { heldTo, laneOfBranch, verifyBranch } from '../hooks/lib/verify.mjs';
|
|
109
|
+
import { skippedLine } from '../hooks/lib/checks.mjs';
|
|
110
|
+
import { TESTS_APPROVAL } from '../hooks/lib/regression.mjs';
|
|
109
111
|
|
|
110
112
|
const out = (s) => process.stdout.write(`${s}\n`);
|
|
111
113
|
// Piped into `head` and the like: stop quietly when the reader closes.
|
|
@@ -398,6 +400,8 @@ const VERIFY_GROUPS = {
|
|
|
398
400
|
layers: 'Layer rules',
|
|
399
401
|
baseline: 'Baseline',
|
|
400
402
|
design: 'Designs before screens',
|
|
403
|
+
tests: 'Weakened tests',
|
|
404
|
+
regression: 'Fixes and their tests',
|
|
401
405
|
checks: 'Checks',
|
|
402
406
|
};
|
|
403
407
|
|
|
@@ -522,9 +526,11 @@ function merge(branch) {
|
|
|
522
526
|
|
|
523
527
|
/**
|
|
524
528
|
* Verifies `branch`, checks included, and merges it into the base (`--into`, or the first protected
|
|
525
|
-
* branch).
|
|
529
|
+
* branch). With `commit`, the commit the lead's review passed, it verifies and merges exactly that
|
|
530
|
+
* commit, and refuses if the branch has moved on from it. Returns { into, message }; throws a
|
|
531
|
+
* MergeStop when nothing was merged.
|
|
526
532
|
*/
|
|
527
|
-
function mergeOne(branch, config, byPerson) {
|
|
533
|
+
function mergeOne(branch, config, byPerson, commit = null) {
|
|
528
534
|
const refuse = (message) => {
|
|
529
535
|
throw new MergeStop('refused', message);
|
|
530
536
|
};
|
|
@@ -583,13 +589,20 @@ function mergeOne(branch, config, byPerson) {
|
|
|
583
589
|
// temporary worktree, where checks that need an install fail rather than being skipped.
|
|
584
590
|
const verifyIn = checkedOut(branch) ?? worktree(branch, true);
|
|
585
591
|
clean(verifyIn, branch);
|
|
592
|
+
const tip = g(verifyIn, 'rev-parse', 'HEAD').trim();
|
|
593
|
+
if (commit && tip !== commit)
|
|
594
|
+
fail(
|
|
595
|
+
`Nothing was merged: ${branch} has moved on since its review passed (${commit.slice(0, 7)} → ${tip.slice(0, 7)}). Review it again, then queue it.`,
|
|
596
|
+
'moved',
|
|
597
|
+
);
|
|
586
598
|
const lane = laneOfBranch(branch, config);
|
|
587
|
-
const { changed, found } = verifyBranch({
|
|
599
|
+
const { changed, found, skipped } = verifyBranch({
|
|
588
600
|
root: verifyIn,
|
|
589
601
|
base: into,
|
|
590
602
|
config,
|
|
591
603
|
lane,
|
|
592
604
|
checks: true,
|
|
605
|
+
branch,
|
|
593
606
|
});
|
|
594
607
|
const problems = Object.entries(VERIFY_GROUPS).flatMap(([key, title]) =>
|
|
595
608
|
found[key].map((p) => `${title}: ${p.split('\n').join('\n ')}`),
|
|
@@ -609,8 +622,8 @@ function mergeOne(branch, config, byPerson) {
|
|
|
609
622
|
'-m',
|
|
610
623
|
`Merge ${branch} into ${into}`,
|
|
611
624
|
'-m',
|
|
612
|
-
`Verified with code-kit verify (${changed.length} file(s)${lane ? `, held to ${heldTo(lane)}` : ''}) and merged ${byPerson ? 'by the person, from the code-kit pane' : 'by the lead within the delegated rules'}
|
|
613
|
-
|
|
625
|
+
`Verified with code-kit verify (${changed.length} file(s)${lane ? `, held to ${heldTo(lane)}` : ''}) at ${tip.slice(0, 7)}${commit ? ', the commit the review passed,' : ''} and merged ${byPerson ? 'by the person, from the code-kit pane' : 'by the lead within the delegated rules'}.${skipped.length ? ` ${notRun(skipped)}` : ''}`,
|
|
626
|
+
tip,
|
|
614
627
|
);
|
|
615
628
|
} catch (e) {
|
|
616
629
|
try {
|
|
@@ -644,7 +657,7 @@ function mergeOne(branch, config, byPerson) {
|
|
|
644
657
|
}
|
|
645
658
|
return {
|
|
646
659
|
into,
|
|
647
|
-
message: `Merged ${branch} into ${into}: verify passed on ${changed.length} file(s)${lane ? `, held to ${heldTo(lane)}` : ''}.${removed}`,
|
|
660
|
+
message: `Merged ${branch} into ${into}: verify passed on ${changed.length} file(s)${lane ? `, held to ${heldTo(lane)}` : ''}, at ${tip.slice(0, 7)}.${skipped.length ? ` ${notRun(skipped)}` : ''}${removed}`,
|
|
648
661
|
};
|
|
649
662
|
} finally {
|
|
650
663
|
cleanup();
|
|
@@ -669,16 +682,24 @@ function queue() {
|
|
|
669
682
|
? `Took ${branch} out of the merge queue.`
|
|
670
683
|
: `${branch} isn't waiting in the merge queue.`,
|
|
671
684
|
);
|
|
685
|
+
let commit;
|
|
672
686
|
try {
|
|
673
|
-
execFileSync('git', ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], {
|
|
674
|
-
|
|
675
|
-
|
|
687
|
+
commit = execFileSync('git', ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], {
|
|
688
|
+
encoding: 'utf8',
|
|
689
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
690
|
+
}).trim();
|
|
676
691
|
} catch {
|
|
677
692
|
die(`There is no branch ${branch}.`);
|
|
678
693
|
}
|
|
679
|
-
|
|
694
|
+
// The commit the review passed: the queue merges exactly it, and a branch that moves is reviewed again.
|
|
695
|
+
const added = enqueue(root, branch, commit);
|
|
696
|
+
if (!added) return out(`${branch} is already in the merge queue, at ${commit.slice(0, 7)}.`);
|
|
697
|
+
if (added === 'updated')
|
|
698
|
+
return out(`${branch} keeps its place in the merge queue, now at ${commit.slice(0, 7)}.`);
|
|
680
699
|
const place = waiting(readQueue(root)).length;
|
|
681
|
-
return out(
|
|
700
|
+
return out(
|
|
701
|
+
`Queued ${branch} at ${commit.slice(0, 7)} to merge${place > 1 ? `, ${place - 1} ahead of it` : ', next'}.`,
|
|
702
|
+
);
|
|
682
703
|
}
|
|
683
704
|
if (sub === 'rules') return rulesOntoBase(root, config);
|
|
684
705
|
if (sub === 'merge') {
|
|
@@ -687,7 +708,7 @@ function queue() {
|
|
|
687
708
|
const head = waiting(entries)[0];
|
|
688
709
|
if (!head) return out('The merge queue is empty.');
|
|
689
710
|
try {
|
|
690
|
-
const { message } = mergeOne(head.branch, config, byPerson);
|
|
711
|
+
const { message } = mergeOne(head.branch, config, byPerson, head.commit ?? null);
|
|
691
712
|
settle(root, head.branch, 'merged');
|
|
692
713
|
const left = waiting(readQueue(root));
|
|
693
714
|
return out(
|
|
@@ -697,6 +718,12 @@ function queue() {
|
|
|
697
718
|
if (!(e instanceof MergeStop)) throw e;
|
|
698
719
|
// MQ-3: a conflict or a failed verify goes back to the lane; a refusal leaves the queue as it is.
|
|
699
720
|
if (e.kind === 'refused') die(e.message);
|
|
721
|
+
// A branch that moved after its review leaves the queue for the lead to review it again; it
|
|
722
|
+
// isn't sent back, since nothing is wrong with it yet.
|
|
723
|
+
if (e.kind === 'moved') {
|
|
724
|
+
settle(root, head.branch, 'moved', 'changed after its review');
|
|
725
|
+
die(`${e.message}\n${head.branch} leaves the queue until then.`);
|
|
726
|
+
}
|
|
700
727
|
const into = intoName ?? config.branches.protected[0];
|
|
701
728
|
const after = lastMerged(entries);
|
|
702
729
|
const why =
|
|
@@ -722,7 +749,11 @@ function queue() {
|
|
|
722
749
|
const left = waiting(entries);
|
|
723
750
|
if (!left.length) out('The merge queue is empty.');
|
|
724
751
|
else out(`${left.length} waiting to merge, in order:`);
|
|
725
|
-
left.forEach((e, i) =>
|
|
752
|
+
left.forEach((e, i) =>
|
|
753
|
+
out(
|
|
754
|
+
` ${i + 1}. ${e.branch} (passed review ${e.passed}${e.commit ? ` at ${e.commit.slice(0, 7)}` : ''})`,
|
|
755
|
+
),
|
|
756
|
+
);
|
|
726
757
|
}
|
|
727
758
|
|
|
728
759
|
/**
|
|
@@ -906,6 +937,7 @@ function story(id) {
|
|
|
906
937
|
config,
|
|
907
938
|
lane,
|
|
908
939
|
checks: false,
|
|
940
|
+
branch: s.branch,
|
|
909
941
|
});
|
|
910
942
|
report.verify = {
|
|
911
943
|
checks: false,
|
|
@@ -1099,18 +1131,21 @@ function verify() {
|
|
|
1099
1131
|
}
|
|
1100
1132
|
}
|
|
1101
1133
|
const lane = laneOfBranch(branch, config);
|
|
1102
|
-
const { changed, found } = verifyBranch({
|
|
1134
|
+
const { changed, found, skipped } = verifyBranch({
|
|
1103
1135
|
root: resolve('.'),
|
|
1104
1136
|
base: from,
|
|
1105
1137
|
config,
|
|
1106
1138
|
lane,
|
|
1107
1139
|
checks: !flag('--no-checks'),
|
|
1140
|
+
// REG-1 is code-kit's own, so CI's --no-checks still proves a fix.
|
|
1141
|
+
fixes: true,
|
|
1142
|
+
branch,
|
|
1108
1143
|
});
|
|
1109
1144
|
if (flag('--json')) {
|
|
1110
1145
|
const count = Object.values(found).reduce((n, list) => n + list.length, 0);
|
|
1111
1146
|
out(
|
|
1112
1147
|
JSON.stringify(
|
|
1113
|
-
{ base: from, branch, lane, changed: changed.length, found, problems: count },
|
|
1148
|
+
{ base: from, branch, lane, changed: changed.length, found, problems: count, skipped },
|
|
1114
1149
|
null,
|
|
1115
1150
|
2,
|
|
1116
1151
|
),
|
|
@@ -1128,10 +1163,15 @@ function verify() {
|
|
|
1128
1163
|
out(`\n${title}:`);
|
|
1129
1164
|
for (const p of found[key]) out(` ${p.split('\n').join('\n ')}`);
|
|
1130
1165
|
}
|
|
1166
|
+
if (skipped.length) out(`\n${notRun(skipped)}`);
|
|
1131
1167
|
if (count) die(`\n${count} problem(s). Fix them on this branch.`);
|
|
1132
|
-
out('No problems.');
|
|
1168
|
+
out(skipped.length ? 'No problems in what ran.' : 'No problems.');
|
|
1133
1169
|
}
|
|
1134
1170
|
|
|
1171
|
+
/** The checks that didn't run, for verify's and a merge's report. */
|
|
1172
|
+
const notRun = (skipped) =>
|
|
1173
|
+
`Not run, because a tool isn't installed here: ${skipped.map(skippedLine).join('; ')}. Install it where verify runs, or set "ifMissing": "fail" on the check so a missing tool fails it.`;
|
|
1174
|
+
|
|
1135
1175
|
/**
|
|
1136
1176
|
* ONB-7: whether a branch's changes need the security reviewer, and why: sign-in, permissions, outside
|
|
1137
1177
|
* input, personal data, encryption, payments, secrets, commands built from strings, new dependencies,
|
|
@@ -1299,7 +1339,7 @@ async function approve(given) {
|
|
|
1299
1339
|
);
|
|
1300
1340
|
if (laneName !== undefined && !config.lanes[laneName])
|
|
1301
1341
|
die(`There is no lane "${laneName}". Lanes: ${Object.keys(config.lanes).join(', ')}.`);
|
|
1302
|
-
const known = new Set(['kit', ...config.protected.map((p) => p.approval)]);
|
|
1342
|
+
const known = new Set(['kit', TESTS_APPROVAL, ...config.protected.map((p) => p.approval)]);
|
|
1303
1343
|
const isPackage = (n) => /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(n);
|
|
1304
1344
|
const names = given.map((n) =>
|
|
1305
1345
|
n.startsWith(DEPENDENCY_APPROVAL) || known.has(n) || !isPackage(n) ? n : dependencyApproval(n),
|
|
@@ -1327,6 +1367,10 @@ async function approve(given) {
|
|
|
1327
1367
|
);
|
|
1328
1368
|
const outside = [];
|
|
1329
1369
|
for (const name of names) {
|
|
1370
|
+
if (name === TESTS_APPROVAL) {
|
|
1371
|
+
outside.push(`${name}: weakening a test is the person's to allow, and is never delegated`);
|
|
1372
|
+
continue;
|
|
1373
|
+
}
|
|
1330
1374
|
if (name.startsWith(DEPENDENCY_APPROVAL)) {
|
|
1331
1375
|
if (!rules.dependencies) {
|
|
1332
1376
|
outside.push(`${name}: dependencies aren't delegated`);
|
package/hooks/guard-bash.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import { CONFIG_FILE } from './lib/config.mjs';
|
|
|
15
15
|
import { laneOfBranch, storyOfBranch, teamOf, teamProvider, whoami } from './lib/team.mjs';
|
|
16
16
|
import { matchesAny } from './lib/glob.mjs';
|
|
17
17
|
import { noteBefore } from './lane-audit.mjs';
|
|
18
|
+
import { TESTS_APPROVAL, commitWeakenings } from './lib/regression.mjs';
|
|
18
19
|
import {
|
|
19
20
|
APPROVAL_LOG,
|
|
20
21
|
actorFor,
|
|
@@ -452,6 +453,40 @@ function recordApprovals() {
|
|
|
452
453
|
grantedAt: null,
|
|
453
454
|
});
|
|
454
455
|
}
|
|
456
|
+
// REG-2: a commit that weakens a test waits for the person's `tests` approval, which nobody else can
|
|
457
|
+
// give; it's recorded in the log, so verify accepts the branch, and used up by this commit.
|
|
458
|
+
const weakened = commitWeakenings(
|
|
459
|
+
root,
|
|
460
|
+
files.filter((f) => !arrivesWithMerge(root, f, { staged: true })),
|
|
461
|
+
config,
|
|
462
|
+
);
|
|
463
|
+
if (weakened.length) {
|
|
464
|
+
const granted = approval(root, TESTS_APPROVAL, actor);
|
|
465
|
+
const said = (w) => `it ${w.reasons.join(', and ')}`;
|
|
466
|
+
if (!granted) {
|
|
467
|
+
const [w, ...more] = weakened;
|
|
468
|
+
recordRequest(root, actor, TESTS_APPROVAL, w.file, `A weakened test: ${said(w)}.`);
|
|
469
|
+
const personOnly = { ...config, approvals: { ...config.approvals, delegate: null } };
|
|
470
|
+
block(
|
|
471
|
+
`Blocked: ${w.file} is a weakened test (${said(w)}); committing it needs a person's approval in force.
|
|
472
|
+
` +
|
|
473
|
+
(more.length
|
|
474
|
+
? `Also weakened: ${more.map((x) => `${x.file} (${said(x)})`).join('; ')}.
|
|
475
|
+
`
|
|
476
|
+
: '') +
|
|
477
|
+
`A weakened test can hide a regression. Fix the code so the test passes as it was. If the test itself is wrong, ask the person: neither the lead nor a lane can approve it. They run
|
|
478
|
+
${approvalHowTo(TESTS_APPROVAL, actor, root, 'weakening tests in one commit', personOnly)}`,
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
for (const w of weakened)
|
|
482
|
+
record(w.file, {
|
|
483
|
+
approval: TESTS_APPROVAL,
|
|
484
|
+
reason: granted.reason,
|
|
485
|
+
grantedAt: granted.grantedAt,
|
|
486
|
+
weakened: w.reasons,
|
|
487
|
+
});
|
|
488
|
+
useApproval(root, TESTS_APPROVAL, actor);
|
|
489
|
+
}
|
|
455
490
|
// A dependency approval is used up by the commit that adds its package to a manifest or lockfile.
|
|
456
491
|
const manifests = files.filter((f) => isDependencyFile(f));
|
|
457
492
|
if (manifests.length && actor.kind !== 'readonly') {
|
package/hooks/lib/checks.mjs
CHANGED
|
@@ -15,8 +15,12 @@ function directories(check, files, root) {
|
|
|
15
15
|
return [...new Set(dirs)].map((d) => join(root, d)).filter((d) => existsSync(d));
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
/**
|
|
19
|
-
|
|
18
|
+
/**
|
|
19
|
+
* The first failure as { title, output }, or null. `actor` decides `owners`; the lead always may. A
|
|
20
|
+
* command that isn't installed skips the rest of the check, unless `ifMissing` is "fail", and is
|
|
21
|
+
* added to `skipped` as { check, command, where }, so a pass never hides a check that didn't run.
|
|
22
|
+
*/
|
|
23
|
+
function runCheck(check, files, root, actor, skipped) {
|
|
20
24
|
if (check.owners && !(actor.kind === 'lead' || check.owners.includes(actor.lane))) {
|
|
21
25
|
return {
|
|
22
26
|
title: `The ${actor.label} changed ${check.name} files it does not own:`,
|
|
@@ -37,6 +41,7 @@ function runCheck(check, files, root, actor) {
|
|
|
37
41
|
return {
|
|
38
42
|
title: `${check.name}: \`${command}\` is not installed${where}. Install it so the check can run.`,
|
|
39
43
|
};
|
|
44
|
+
skipped.push({ check: check.name, command, where: where.replace(/^ in /, '') || null });
|
|
40
45
|
return null; // not installed yet (fresh clone): the check can't run
|
|
41
46
|
}
|
|
42
47
|
if (res.error || res.status === null)
|
|
@@ -53,15 +58,23 @@ function runCheck(check, files, root, actor) {
|
|
|
53
58
|
return null;
|
|
54
59
|
}
|
|
55
60
|
|
|
56
|
-
/**
|
|
61
|
+
/**
|
|
62
|
+
* Runs every check whose files changed, in order: { failure, skipped }, where `failure` is the first
|
|
63
|
+
* failure or null, and `skipped` lists the checks that couldn't run because a tool isn't installed.
|
|
64
|
+
*/
|
|
57
65
|
export function runChecks(config, changed, root, actor) {
|
|
66
|
+
const skipped = [];
|
|
58
67
|
for (const check of config.checks) {
|
|
59
68
|
const files = changed.filter(
|
|
60
69
|
(f) => matchesAny(f, check.files) && !matchesAny(f, check.exclude),
|
|
61
70
|
);
|
|
62
71
|
if (!files.length) continue;
|
|
63
|
-
const failure = runCheck(check, files, root, actor);
|
|
64
|
-
if (failure) return failure;
|
|
72
|
+
const failure = runCheck(check, files, root, actor, skipped);
|
|
73
|
+
if (failure) return { failure, skipped };
|
|
65
74
|
}
|
|
66
|
-
return null;
|
|
75
|
+
return { failure: null, skipped };
|
|
67
76
|
}
|
|
77
|
+
|
|
78
|
+
/** A skipped check in a sentence: "typecheck (`tsc` isn't installed in apps/web)". */
|
|
79
|
+
export const skippedLine = (s) =>
|
|
80
|
+
`${s.check} (\`${s.command}\` isn't installed${s.where ? ` in ${s.where}` : ''})`;
|
package/hooks/lib/config.mjs
CHANGED
|
@@ -93,6 +93,8 @@ export function withDefaults(c) {
|
|
|
93
93
|
shell: { block: c.shell?.block ?? [], restricted: c.shell?.restricted ?? [] },
|
|
94
94
|
postEdit: c.postEdit ?? [],
|
|
95
95
|
checks: c.checks ?? [],
|
|
96
|
+
tests: c.tests ?? [],
|
|
97
|
+
regression: { prepare: c.regression?.prepare ?? null },
|
|
96
98
|
branches: { protected: c.branches?.protected ?? ['main', 'master'] },
|
|
97
99
|
approvals: { lead: c.approvals?.lead === true, delegate: delegateRules(c.approvals?.delegate) },
|
|
98
100
|
harness: harnessSettings(c.harness),
|
|
@@ -211,6 +213,12 @@ export function validate(c) {
|
|
|
211
213
|
need(e.exclude === undefined || isList(e.exclude), `${at}.exclude must be a list of globs`);
|
|
212
214
|
});
|
|
213
215
|
each('checks', (e, at) => validateCheck(e, at, lanes, need));
|
|
216
|
+
need(c.tests === undefined || isList(c.tests), '"tests" must be a list of globs: the test files');
|
|
217
|
+
need(
|
|
218
|
+
c.regression === undefined ||
|
|
219
|
+
(isObj(c.regression) && (c.regression.prepare === undefined || isStr(c.regression.prepare))),
|
|
220
|
+
'"regression" must be { prepare?: a command run in a fresh checkout before a fix\'s tests }',
|
|
221
|
+
);
|
|
214
222
|
const known = ADAPTERS.map((a) => a.name);
|
|
215
223
|
need(
|
|
216
224
|
c.adapters === undefined ||
|
package/hooks/lib/next.mjs
CHANGED
|
@@ -26,7 +26,10 @@ const git = (root, ...args) => {
|
|
|
26
26
|
}
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* The spec-design brief: { path, status, openQuestions: [question, ...], open: [{ question, owner,
|
|
31
|
+
* blocks }] }, or null. `open` reads the table's Owner and Blocks columns where it has them.
|
|
32
|
+
*/
|
|
30
33
|
export function readBrief(root, config) {
|
|
31
34
|
const specs = config?.docs?.specs;
|
|
32
35
|
const candidates = [specs && `${posix.dirname(specs)}/brief.md`, 'docs/brief.md'].filter(Boolean);
|
|
@@ -39,12 +42,27 @@ export function readBrief(root, config) {
|
|
|
39
42
|
const start = lines.findIndex((l) => /^##\s+Open questions/i.test(l));
|
|
40
43
|
const section = start < 0 ? [] : lines.slice(start + 1);
|
|
41
44
|
const end = section.findIndex((l) => /^##\s/.test(l));
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
const rows = section.slice(0, end < 0 ? undefined : end).filter((l) => /^\|/.test(l));
|
|
46
|
+
const cells = (l) =>
|
|
47
|
+
l
|
|
48
|
+
.split('|')
|
|
49
|
+
.slice(1, -1)
|
|
50
|
+
.map((c) => c.trim());
|
|
51
|
+
const header = rows.find((l) => /^\|\s*Question\s*\|/i.test(l));
|
|
52
|
+
const columns = header ? cells(header).map((c) => c.toLowerCase()) : ['question'];
|
|
53
|
+
const at = (name) => columns.findIndex((c) => c.startsWith(name));
|
|
54
|
+
const open = rows
|
|
55
|
+
.filter((l) => l !== header && !/^\|\s*-/.test(l))
|
|
56
|
+
.map((l) => {
|
|
57
|
+
const c = cells(l);
|
|
58
|
+
return {
|
|
59
|
+
question: c[0] ?? '',
|
|
60
|
+
owner: at('owner') > 0 ? c[at('owner')] || null : null,
|
|
61
|
+
blocks: at('block') > 0 ? c[at('block')] || null : null,
|
|
62
|
+
};
|
|
63
|
+
})
|
|
64
|
+
.filter((q) => q.question && !q.question.startsWith('{{'));
|
|
65
|
+
return { path, status: status ?? null, openQuestions: open.map((q) => q.question), open };
|
|
48
66
|
}
|
|
49
67
|
|
|
50
68
|
const hasMarkdown = (dir) => {
|
|
@@ -87,7 +105,15 @@ export function nextStep(root, base) {
|
|
|
87
105
|
const brief = readBrief(root, config);
|
|
88
106
|
if (brief?.openQuestions.length) {
|
|
89
107
|
attention.push(`${brief.openQuestions.length} open question(s) in ${brief.path}.`);
|
|
90
|
-
|
|
108
|
+
// A question the brief gives to the lead alone (Owner: Lead) is the lead's to settle, not the person's.
|
|
109
|
+
briefQuestions = brief.open
|
|
110
|
+
.filter(
|
|
111
|
+
(q) =>
|
|
112
|
+
!q.owner ||
|
|
113
|
+
!/\blead\b/i.test(q.owner) ||
|
|
114
|
+
/owner|person|you|product|client/i.test(q.owner),
|
|
115
|
+
)
|
|
116
|
+
.map((q) => ({ ...q, path: brief.path }));
|
|
91
117
|
}
|
|
92
118
|
|
|
93
119
|
if (!config) {
|
package/hooks/lib/plan.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Requirements and stories, read from the project's docs. Specs (docs.specs) hold requirements as
|
|
2
2
|
// headings `### BOOK-4 Short name` (`(removed)` at the end drops one); the plan (docs.plan) holds
|
|
3
3
|
// stories as `### ST-12 Title` followed by `**Lane:**`, `**Requirements:**`, `**Depends on:**` and
|
|
4
|
-
// `**Status:**` lines
|
|
4
|
+
// `**Status:**` lines, and for a fix, `**Fixes:**`. A lane builds a story on the branch `<lane>/st-12`. The lane `lead` marks the
|
|
5
5
|
// lead's own stories (contracts, foundations, spikes), which the lead builds rather than dispatches;
|
|
6
6
|
// `**Requirements:** none` marks a story that delivers no requirement, such as a spike.
|
|
7
7
|
import { execFileSync } from 'node:child_process';
|
|
@@ -52,7 +52,7 @@ const field = (lines, name) =>
|
|
|
52
52
|
.trim() ?? '';
|
|
53
53
|
|
|
54
54
|
/**
|
|
55
|
-
* Every story in the plan: [{ id, title, lane, requirements, dependsOn, status, milestone }]. A story's
|
|
55
|
+
* Every story in the plan: [{ id, title, lane, requirements, dependsOn, status, fixes, milestone }]. A story's
|
|
56
56
|
* milestone is the `## ` heading above it ("Milestone 5: Your own key"), or null.
|
|
57
57
|
*/
|
|
58
58
|
export function readStories(root, planPath) {
|
|
@@ -75,6 +75,8 @@ export function readStories(root, planPath) {
|
|
|
75
75
|
lane: field(body, 'Lane'),
|
|
76
76
|
requirements: [...field(body, 'Requirements').matchAll(REQUIREMENT)].map((r) => r[1]),
|
|
77
77
|
noRequirements: /^none\b/i.test(field(body, 'Requirements')),
|
|
78
|
+
// REG-1: what a fix story fixes, or null.
|
|
79
|
+
fixes: field(body, 'Fixes') || null,
|
|
78
80
|
dependsOn: [...field(body, 'Depends on').matchAll(/\bST-\d+\b/g)].map((r) => r[0]),
|
|
79
81
|
status: STATES.includes(status) ? status : 'todo',
|
|
80
82
|
milestone,
|
package/hooks/lib/queue.mjs
CHANGED
|
@@ -11,7 +11,10 @@ const DONE_KEPT = 20;
|
|
|
11
11
|
|
|
12
12
|
const queueFile = (root) => join(projectWorktrees(root)[0] ?? root, QUEUE_FILE);
|
|
13
13
|
|
|
14
|
-
/**
|
|
14
|
+
/**
|
|
15
|
+
* The queue's entries: [{ branch, commit, passed, state, at?, reason? }], waiting ones in merge order.
|
|
16
|
+
* `commit` is the one the lead's review passed; entries queued before 0.13.0 have none.
|
|
17
|
+
*/
|
|
15
18
|
export function readQueue(root) {
|
|
16
19
|
try {
|
|
17
20
|
const entries = JSON.parse(readFileSync(queueFile(root), 'utf8'));
|
|
@@ -35,13 +38,26 @@ function writeQueue(root, entries) {
|
|
|
35
38
|
/** The branches still to merge, in order (MQ-1). */
|
|
36
39
|
export const waiting = (entries) => entries.filter((e) => ['waiting', 'merging'].includes(e.state));
|
|
37
40
|
|
|
38
|
-
/**
|
|
39
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Adds a branch the lead's review passed at `commit`, at the back; a branch already waiting keeps its
|
|
43
|
+
* place, with the commit its latest review passed.
|
|
44
|
+
*/
|
|
45
|
+
export function enqueue(root, branch, commit, now = Date.now()) {
|
|
40
46
|
const entries = readQueue(root);
|
|
41
|
-
|
|
47
|
+
const queued = waiting(entries).find((e) => e.branch === branch);
|
|
48
|
+
if (queued) {
|
|
49
|
+
if (queued.commit === commit) return false;
|
|
50
|
+
writeQueue(
|
|
51
|
+
root,
|
|
52
|
+
entries.map((e) =>
|
|
53
|
+
e === queued ? { ...e, commit, passed: new Date(now).toISOString() } : e,
|
|
54
|
+
),
|
|
55
|
+
);
|
|
56
|
+
return 'updated';
|
|
57
|
+
}
|
|
42
58
|
writeQueue(root, [
|
|
43
59
|
...entries.filter((e) => e.branch !== branch),
|
|
44
|
-
{ branch, passed: new Date(now).toISOString(), state: 'waiting' },
|
|
60
|
+
{ branch, commit, passed: new Date(now).toISOString(), state: 'waiting' },
|
|
45
61
|
]);
|
|
46
62
|
return true;
|
|
47
63
|
}
|
|
@@ -57,7 +73,10 @@ export function dequeue(root, branch) {
|
|
|
57
73
|
return true;
|
|
58
74
|
}
|
|
59
75
|
|
|
60
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* Sets a waiting branch's state: merging, merged, sent back (with why), moved (it changed after its
|
|
78
|
+
* review, so it's reviewed again), or waiting again.
|
|
79
|
+
*/
|
|
61
80
|
export function settle(root, branch, state, reason = null, now = Date.now()) {
|
|
62
81
|
const entries = readQueue(root).map((e) =>
|
|
63
82
|
e.branch === branch && ['waiting', 'merging'].includes(e.state)
|