happy-stacks 0.6.12 → 0.6.13
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/docs/commit-audits/happy/_tools/generate-plans.mjs +453 -0
- package/docs/commit-audits/happy/_tools/generate-pr-assignment.mjs +430 -0
- package/docs/commit-audits/happy/_tools/init-pr-assignment-working.mjs +107 -0
- package/docs/commit-audits/happy/leeroy-wip.commit-analysis.md +1849 -0
- package/docs/commit-audits/happy/leeroy-wip.commit-export.fuller-stat.md +747 -1
- package/docs/commit-audits/happy/leeroy-wip.commit-index.json +11740 -0
- package/docs/commit-audits/happy/leeroy-wip.commit-index.tsv +252 -0
- package/docs/commit-audits/happy/leeroy-wip.commit-inventory.md +18 -11
- package/docs/commit-audits/happy/leeroy-wip.commit-manual-review.md +1236 -92
- package/docs/commit-audits/happy/leeroy-wip.maintainers-overview.draft.md +448 -0
- package/docs/commit-audits/happy/leeroy-wip.pr-assignment.draft.tsv +252 -0
- package/docs/commit-audits/happy/leeroy-wip.pr-assignment.working.tsv +288 -0
- package/docs/commit-audits/happy/leeroy-wip.pr-catalog.draft.md +245 -0
- package/docs/commit-audits/happy/leeroy-wip.pr-stack-plan.draft.md +350 -0
- package/docs/commit-audits/happy/leeroy-wip.rewrite-deferred-fragments.tsv +65 -0
- package/docs/commit-audits/happy/leeroy-wip.rewrite-ledger.tsv +56 -0
- package/docs/commit-audits/happy/leeroy-wip.rewrite-process.md +240 -0
- package/docs/commit-audits/happy/leeroy-wip.rewrite-status.tsv +39 -0
- package/docs/commit-audits/happy/leeroy-wip.split-plan.draft.md +93 -0
- package/docs/commit-audits/happy/leeroy-wip.topic-buckets.md +76 -0
- package/docs/commit-audits/happy/pr-desc.extraction-ledger.tsv +279 -0
- package/docs/commit-audits/happy/pr-desc.original.md +0 -0
- package/docs/commit-audits/happy/pr-desc.post-audit-extraction-ledger.tsv +54 -0
- package/docs/commit-audits/happy/pr-desc.working-document.md +536 -0
- package/docs/happy-development.md +18 -1
- package/docs/isolated-linux-vm.md +23 -1
- package/docs/stacks.md +21 -1
- package/package.json +1 -1
- package/scripts/auth.mjs +46 -8
- package/scripts/daemon.mjs +44 -21
- package/scripts/doctor.mjs +2 -2
- package/scripts/doctor_cmd.test.mjs +67 -0
- package/scripts/happy.mjs +18 -5
- package/scripts/provision/linux-ubuntu-review-pr.sh +5 -1
- package/scripts/provision/macos-lima-happy-vm.sh +34 -2
- package/scripts/review.mjs +347 -124
- package/scripts/review_pr.mjs +78 -2
- package/scripts/run.mjs +2 -1
- package/scripts/stack.mjs +265 -19
- package/scripts/stack_daemon_cmd.test.mjs +196 -0
- package/scripts/stack_happy_cmd.test.mjs +103 -0
- package/scripts/utils/cli/prereqs.mjs +12 -1
- package/scripts/utils/dev/daemon.mjs +3 -1
- package/scripts/utils/proc/pm.mjs +1 -1
- package/scripts/utils/review/detached_worktree.mjs +61 -0
- package/scripts/utils/review/detached_worktree.test.mjs +62 -0
- package/scripts/utils/review/findings.mjs +133 -20
- package/scripts/utils/review/findings.test.mjs +88 -1
- package/scripts/utils/review/runners/augment.mjs +71 -0
- package/scripts/utils/review/runners/augment.test.mjs +42 -0
- package/scripts/utils/review/runners/coderabbit.mjs +54 -10
- package/scripts/utils/review/runners/coderabbit.test.mjs +15 -48
- package/scripts/utils/review/sliced_runner.mjs +39 -0
- package/scripts/utils/review/sliced_runner.test.mjs +47 -0
- package/scripts/utils/review/tool_home_seed.mjs +99 -0
- package/scripts/utils/review/tool_home_seed.test.mjs +113 -0
- package/scripts/utils/stack/cli_identities.mjs +29 -0
- package/scripts/utils/stack/startup.mjs +45 -7
- package/scripts/worktrees.mjs +8 -5
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const ROOT = process.cwd();
|
|
5
|
+
const AUDIT_DIR = path.join(ROOT, 'docs', 'commit-audits', 'happy');
|
|
6
|
+
|
|
7
|
+
const ANALYSIS_PATH = path.join(AUDIT_DIR, 'leeroy-wip.commit-analysis.md');
|
|
8
|
+
const MANUAL_PATH = path.join(AUDIT_DIR, 'leeroy-wip.commit-manual-review.md');
|
|
9
|
+
|
|
10
|
+
const OUT_INDEX_JSON = path.join(AUDIT_DIR, 'leeroy-wip.commit-index.json');
|
|
11
|
+
const OUT_INDEX_TSV = path.join(AUDIT_DIR, 'leeroy-wip.commit-index.tsv');
|
|
12
|
+
const OUT_BUCKETS_MD = path.join(AUDIT_DIR, 'leeroy-wip.topic-buckets.md');
|
|
13
|
+
const OUT_SPLIT_MD = path.join(AUDIT_DIR, 'leeroy-wip.split-plan.draft.md');
|
|
14
|
+
const OUT_PR_MD = path.join(AUDIT_DIR, 'leeroy-wip.pr-stack-plan.draft.md');
|
|
15
|
+
|
|
16
|
+
function splitCommitSections(markdown) {
|
|
17
|
+
// Sections start at: "## 001 ..."
|
|
18
|
+
const re = /^##\s+(\d{3})\b/mg;
|
|
19
|
+
const indices = [];
|
|
20
|
+
let m;
|
|
21
|
+
while ((m = re.exec(markdown))) {
|
|
22
|
+
indices.push({ n: Number(m[1]), idx: m.index });
|
|
23
|
+
}
|
|
24
|
+
indices.sort((a, b) => a.n - b.n);
|
|
25
|
+
|
|
26
|
+
const sections = new Map();
|
|
27
|
+
for (let i = 0; i < indices.length; i++) {
|
|
28
|
+
const start = indices[i].idx;
|
|
29
|
+
const end = i + 1 < indices.length ? indices[i + 1].idx : markdown.length;
|
|
30
|
+
sections.set(indices[i].n, markdown.slice(start, end));
|
|
31
|
+
}
|
|
32
|
+
return sections;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parseCodeBlock(section, heading) {
|
|
36
|
+
// Find a heading then the next ```text ... ```
|
|
37
|
+
const idx = section.indexOf(heading);
|
|
38
|
+
if (idx < 0) return null;
|
|
39
|
+
const after = section.slice(idx + heading.length);
|
|
40
|
+
const fenceStart = after.indexOf('```text');
|
|
41
|
+
if (fenceStart < 0) return null;
|
|
42
|
+
const rest = after.slice(fenceStart + '```text'.length);
|
|
43
|
+
const fenceEnd = rest.indexOf('```');
|
|
44
|
+
if (fenceEnd < 0) return null;
|
|
45
|
+
return rest.slice(0, fenceEnd).trimEnd().replace(/^\n/, '');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseAnalysisSection(section) {
|
|
49
|
+
const header = section.match(/^##\s+(\d{3})\s+(\d{4}-\d{2}-\d{2})\s+([0-9a-f]{12,40})/m);
|
|
50
|
+
const n = header ? Number(header[1]) : null;
|
|
51
|
+
const date = header?.[2] ?? null;
|
|
52
|
+
const shaShort = header?.[3]?.slice(0, 12) ?? null;
|
|
53
|
+
|
|
54
|
+
const sha = section.match(/^- SHA:\s+([0-9a-f]{40})/m)?.[1] ?? null;
|
|
55
|
+
const subject = section.match(/^- Subject:\s+(.*)$/m)?.[1]?.trim() ?? null;
|
|
56
|
+
const topicBucket = section.match(/^- Topic bucket:\s+`([^`]+)`/m)?.[1] ?? null;
|
|
57
|
+
const touchedAreas = section.match(/^- Touched areas:\s+(.*)$/m)?.[1]?.trim() ?? null;
|
|
58
|
+
const filesChanged = Number(section.match(/^- Files changed:\s+(\d+)/m)?.[1] ?? '0') || null;
|
|
59
|
+
const lines = section.match(/^- Lines:\s+\+(\d+)\s+\/\s+-(\d+)/m);
|
|
60
|
+
const linesAdded = lines ? Number(lines[1]) : null;
|
|
61
|
+
const linesDeleted = lines ? Number(lines[2]) : null;
|
|
62
|
+
|
|
63
|
+
const filesBlock = parseCodeBlock(section, '### Files (name-status)');
|
|
64
|
+
const files = filesBlock
|
|
65
|
+
? filesBlock
|
|
66
|
+
.split('\n')
|
|
67
|
+
.map((l) => l.trim())
|
|
68
|
+
.filter(Boolean)
|
|
69
|
+
.map((l) => {
|
|
70
|
+
const [status, ...rest] = l.split(/\s+/);
|
|
71
|
+
return { status, path: rest.join(' ') };
|
|
72
|
+
})
|
|
73
|
+
: [];
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
n,
|
|
77
|
+
date,
|
|
78
|
+
shaShort,
|
|
79
|
+
sha,
|
|
80
|
+
subject,
|
|
81
|
+
topicBucket,
|
|
82
|
+
touchedAreas,
|
|
83
|
+
filesChanged,
|
|
84
|
+
linesAdded,
|
|
85
|
+
linesDeleted,
|
|
86
|
+
files,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function parseManualSection(section) {
|
|
91
|
+
const header = section.match(/^##\s+(\d{3})\s+(\d{4}-\d{2}-\d{2})\s+([0-9a-f]{12,40})(?:\s+`([^`]+)`)?/m);
|
|
92
|
+
const n = header ? Number(header[1]) : null;
|
|
93
|
+
const date = header?.[2] ?? null;
|
|
94
|
+
const shaShort = header?.[3]?.slice(0, 12) ?? null;
|
|
95
|
+
const scopeTag = header?.[4] ?? null;
|
|
96
|
+
|
|
97
|
+
const subject = section.match(/^- Subject:\s+(.*)$/m)?.[1]?.trim() ?? null;
|
|
98
|
+
const verdict = section.match(/^- Keep\/squash\/split\/drop\/reorder:\s+(.*)$/m)?.[1]?.trim() ?? null;
|
|
99
|
+
|
|
100
|
+
const pass2 = section.match(/^Pass2FullDiff:\s+CAPTURED.*$/m)?.[0] ?? null;
|
|
101
|
+
const notesReviewed = pass2?.match(/\bnotesReviewed=(yes|no)\b/)?.[1] ?? null;
|
|
102
|
+
const reviewedAt = pass2?.match(/\breviewedAt=([0-9T:+-]+)\b/)?.[1] ?? null;
|
|
103
|
+
const notesUpdated = pass2?.match(/\bnotesUpdated=(yes|no)\b/)?.[1] ?? null;
|
|
104
|
+
|
|
105
|
+
const dependsLines = [];
|
|
106
|
+
for (const line of section.split('\n')) {
|
|
107
|
+
const m = line.match(/^- Depends on:\s*(.*)$/);
|
|
108
|
+
if (m) dependsLines.push(m[1].trim());
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const rewriteBlockIdx = section.indexOf('### Rewrite Commit Message');
|
|
112
|
+
const rewriteHints =
|
|
113
|
+
rewriteBlockIdx >= 0
|
|
114
|
+
? section
|
|
115
|
+
.slice(rewriteBlockIdx)
|
|
116
|
+
.split('\n')
|
|
117
|
+
.slice(0, 25)
|
|
118
|
+
.map((l) => l.trim())
|
|
119
|
+
.filter((l) => l.startsWith('- '))
|
|
120
|
+
.map((l) => l.replace(/^- /, ''))
|
|
121
|
+
: [];
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
n,
|
|
125
|
+
date,
|
|
126
|
+
shaShort,
|
|
127
|
+
scopeTag,
|
|
128
|
+
subject,
|
|
129
|
+
verdict,
|
|
130
|
+
notesReviewed,
|
|
131
|
+
reviewedAt,
|
|
132
|
+
notesUpdated,
|
|
133
|
+
depends: dependsLines,
|
|
134
|
+
rewriteHints,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function tsvEscape(value) {
|
|
139
|
+
if (value == null) return '';
|
|
140
|
+
return String(value).replace(/\t/g, ' ').replace(/\r?\n/g, ' ');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function buildBucketSummary(commits) {
|
|
144
|
+
const counts = new Map();
|
|
145
|
+
for (const c of commits) {
|
|
146
|
+
const b = c.topicBucket ?? 'unknown';
|
|
147
|
+
counts.set(b, (counts.get(b) ?? 0) + 1);
|
|
148
|
+
}
|
|
149
|
+
return [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function getVerdictKind(verdict) {
|
|
153
|
+
if (!verdict) return 'unknown';
|
|
154
|
+
const lower = verdict.toLowerCase();
|
|
155
|
+
if (lower.startsWith('split')) return 'split';
|
|
156
|
+
if (lower.startsWith('drop')) return 'drop';
|
|
157
|
+
if (lower.startsWith('squash')) return 'squash';
|
|
158
|
+
if (lower.startsWith('keep')) return 'keep';
|
|
159
|
+
return lower.split(/[,\s]/)[0] || 'unknown';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function draftPrDefinitions() {
|
|
163
|
+
// Draft only: you will refine this with humans before rewriting history.
|
|
164
|
+
return [
|
|
165
|
+
{
|
|
166
|
+
id: 'PR01',
|
|
167
|
+
title: 'Foundations: tooling, deps, test infra',
|
|
168
|
+
buckets: ['crypto', 'deps', 'config', 'format', 'typecheck', 'dev', 'runtime', 'logging', 'test'],
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
id: 'PR02',
|
|
172
|
+
title: 'Expo web modals + expo-router pin/patch',
|
|
173
|
+
buckets: ['expo-app', 'expo'],
|
|
174
|
+
note: 'Draft: later we will split expo-app commits across PRs based on touched files (router/postinstall vs feature UI).',
|
|
175
|
+
},
|
|
176
|
+
{ id: 'PR03', title: 'i18n structure + localization passes', buckets: ['i18n'] },
|
|
177
|
+
{
|
|
178
|
+
id: 'PR04',
|
|
179
|
+
title: 'UI primitives + navigation/UX surfaces',
|
|
180
|
+
buckets: ['ui', 'modal', 'popover', 'agent-input', 'a11y', 'web', 'command-palette', 'autocomplete', 'experiments', 'zen'],
|
|
181
|
+
},
|
|
182
|
+
{ id: 'PR05', title: 'Core storage & persistence primitives', buckets: ['storage', 'persistence', 'settings', 'env', 'auth'] },
|
|
183
|
+
{ id: 'PR06', title: 'Secrets / vault / SecretString', buckets: ['secrets'] },
|
|
184
|
+
{ id: 'PR07', title: 'Profiles + API keys + environment variables UX', buckets: ['profiles', 'api-keys', 'env'] },
|
|
185
|
+
{ id: 'PR08', title: 'New session wizard + session creation UX', buckets: ['new-session', 'new', 'machine'] },
|
|
186
|
+
{ id: 'PR09', title: 'Permissions + tool views', buckets: ['permission', 'tools'] },
|
|
187
|
+
{
|
|
188
|
+
id: 'PR10',
|
|
189
|
+
title: 'CLI/daemon/RPC plumbing + capabilities',
|
|
190
|
+
buckets: [
|
|
191
|
+
'capabilities',
|
|
192
|
+
'cli-detection',
|
|
193
|
+
'detect-cli',
|
|
194
|
+
'rpc',
|
|
195
|
+
'daemon',
|
|
196
|
+
'socket',
|
|
197
|
+
'hooks',
|
|
198
|
+
'happy',
|
|
199
|
+
'cli',
|
|
200
|
+
'happy-cli',
|
|
201
|
+
'app',
|
|
202
|
+
'common',
|
|
203
|
+
'utils',
|
|
204
|
+
'pr107',
|
|
205
|
+
'reducer',
|
|
206
|
+
],
|
|
207
|
+
},
|
|
208
|
+
{ id: 'PR11', title: 'Terminal/tmux/headless + Ink UX', buckets: ['terminal', 'tmux', 'ink'] },
|
|
209
|
+
{ id: 'PR12', title: 'Message queue V1 + pending/discard flows', buckets: ['queue', 'sync'] },
|
|
210
|
+
{ id: 'PR13', title: 'Resume + session lifecycle', buckets: ['resume', 'session', 'sessions', 'scanner', 'offline'] },
|
|
211
|
+
{ id: 'PR14', title: 'Codex & Claude integration', buckets: ['codex', 'claude', 'gemini', 'fork'] },
|
|
212
|
+
{ id: 'PR15', title: 'Server & server-light: sqlite flavor, schema sync, storage backends', buckets: ['server-light', 'server', 'storage', 'security'] },
|
|
213
|
+
];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function assignCommitsToPrs(commits, prDefs) {
|
|
217
|
+
const bucketToPr = new Map();
|
|
218
|
+
for (const pr of prDefs) {
|
|
219
|
+
for (const b of pr.buckets) {
|
|
220
|
+
// first-wins: later we will refine for overlapping buckets like storage/env/expo-app.
|
|
221
|
+
if (!bucketToPr.has(b)) bucketToPr.set(b, pr.id);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const byPr = new Map(prDefs.map((p) => [p.id, []]));
|
|
226
|
+
const unassigned = [];
|
|
227
|
+
|
|
228
|
+
for (const c of commits) {
|
|
229
|
+
const prId = bucketToPr.get(c.topicBucket ?? 'unknown') ?? null;
|
|
230
|
+
if (!prId) {
|
|
231
|
+
unassigned.push(c);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
byPr.get(prId).push(c);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
for (const pr of prDefs) {
|
|
238
|
+
byPr.set(
|
|
239
|
+
pr.id,
|
|
240
|
+
(byPr.get(pr.id) ?? []).sort((a, b) => a.n - b.n),
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const assignedCount = [...byPr.values()].reduce((sum, list) => sum + list.length, 0);
|
|
245
|
+
return { byPr, unassigned, assignedCount };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function main() {
|
|
249
|
+
const [analysisMd, manualMd] = await Promise.all([
|
|
250
|
+
fs.readFile(ANALYSIS_PATH, 'utf8'),
|
|
251
|
+
fs.readFile(MANUAL_PATH, 'utf8'),
|
|
252
|
+
]);
|
|
253
|
+
|
|
254
|
+
const analysisSections = splitCommitSections(analysisMd);
|
|
255
|
+
const manualSections = splitCommitSections(manualMd);
|
|
256
|
+
|
|
257
|
+
const commits = [];
|
|
258
|
+
for (let n = 1; n <= 249; n++) {
|
|
259
|
+
const a = analysisSections.get(n);
|
|
260
|
+
const m = manualSections.get(n);
|
|
261
|
+
if (!a || !m) {
|
|
262
|
+
throw new Error(`Missing section for commit ${String(n).padStart(3, '0')}: analysis=${Boolean(a)} manual=${Boolean(m)}`);
|
|
263
|
+
}
|
|
264
|
+
const analysis = parseAnalysisSection(a);
|
|
265
|
+
const manual = parseManualSection(m);
|
|
266
|
+
commits.push({
|
|
267
|
+
n,
|
|
268
|
+
date: analysis.date ?? manual.date,
|
|
269
|
+
sha: analysis.sha ?? null,
|
|
270
|
+
shaShort: analysis.shaShort ?? manual.shaShort,
|
|
271
|
+
scopeTag: manual.scopeTag,
|
|
272
|
+
subject: analysis.subject ?? manual.subject,
|
|
273
|
+
topicBucket: analysis.topicBucket,
|
|
274
|
+
touchedAreas: analysis.touchedAreas,
|
|
275
|
+
filesChanged: analysis.filesChanged,
|
|
276
|
+
linesAdded: analysis.linesAdded,
|
|
277
|
+
linesDeleted: analysis.linesDeleted,
|
|
278
|
+
files: analysis.files,
|
|
279
|
+
verdict: manual.verdict,
|
|
280
|
+
verdictKind: getVerdictKind(manual.verdict),
|
|
281
|
+
notesReviewed: manual.notesReviewed,
|
|
282
|
+
reviewedAt: manual.reviewedAt,
|
|
283
|
+
notesUpdated: manual.notesUpdated,
|
|
284
|
+
depends: manual.depends,
|
|
285
|
+
rewriteHints: manual.rewriteHints,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Hard guard: pass2 must be complete before we proceed.
|
|
290
|
+
const notReviewed = commits.filter((c) => c.notesReviewed !== 'yes');
|
|
291
|
+
if (notReviewed.length) {
|
|
292
|
+
throw new Error(`Pass2 notesReviewed not complete. Remaining: ${notReviewed.map((c) => c.n).join(', ')}`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
await fs.writeFile(OUT_INDEX_JSON, JSON.stringify({ generatedAt: new Date().toISOString(), commits }, null, 2) + '\n', 'utf8');
|
|
296
|
+
|
|
297
|
+
const tsvHeader = [
|
|
298
|
+
'n',
|
|
299
|
+
'date',
|
|
300
|
+
'shaShort',
|
|
301
|
+
'scopeTag',
|
|
302
|
+
'topicBucket',
|
|
303
|
+
'subject',
|
|
304
|
+
'verdictKind',
|
|
305
|
+
'verdict',
|
|
306
|
+
'filesChanged',
|
|
307
|
+
'linesAdded',
|
|
308
|
+
'linesDeleted',
|
|
309
|
+
'notesReviewed',
|
|
310
|
+
'reviewedAt',
|
|
311
|
+
'notesUpdated',
|
|
312
|
+
].join('\t');
|
|
313
|
+
const tsvRows = commits.map((c) =>
|
|
314
|
+
[
|
|
315
|
+
String(c.n).padStart(3, '0'),
|
|
316
|
+
c.date,
|
|
317
|
+
c.shaShort,
|
|
318
|
+
c.scopeTag,
|
|
319
|
+
c.topicBucket,
|
|
320
|
+
c.subject,
|
|
321
|
+
c.verdictKind,
|
|
322
|
+
c.verdict,
|
|
323
|
+
c.filesChanged,
|
|
324
|
+
c.linesAdded,
|
|
325
|
+
c.linesDeleted,
|
|
326
|
+
c.notesReviewed,
|
|
327
|
+
c.reviewedAt,
|
|
328
|
+
c.notesUpdated,
|
|
329
|
+
]
|
|
330
|
+
.map(tsvEscape)
|
|
331
|
+
.join('\t'),
|
|
332
|
+
);
|
|
333
|
+
await fs.writeFile(
|
|
334
|
+
OUT_INDEX_TSV,
|
|
335
|
+
`# GENERATED FILE - do not edit by hand.\n# Regenerate: node docs/commit-audits/happy/_tools/generate-plans.mjs\n${tsvHeader}\n${tsvRows.join('\n')}\n`,
|
|
336
|
+
'utf8',
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
const bucketSummary = buildBucketSummary(commits);
|
|
340
|
+
const bucketMd = [
|
|
341
|
+
'# Topic buckets (auto-triage)',
|
|
342
|
+
'',
|
|
343
|
+
`Generated: ${new Date().toISOString()}`,
|
|
344
|
+
'',
|
|
345
|
+
'Counts:',
|
|
346
|
+
'',
|
|
347
|
+
...bucketSummary.map(([b, count]) => `- \`${b}\`: ${count}`),
|
|
348
|
+
'',
|
|
349
|
+
'Notes:',
|
|
350
|
+
'- These buckets come from `leeroy-wip.commit-analysis.md` and are a starting point, not the final PR grouping.',
|
|
351
|
+
'- Some buckets (notably `expo-app`, `storage`, `env`, `sync`) will be split across PRs by feature surface during planning.',
|
|
352
|
+
'',
|
|
353
|
+
].join('\n');
|
|
354
|
+
await fs.writeFile(OUT_BUCKETS_MD, bucketMd, 'utf8');
|
|
355
|
+
|
|
356
|
+
const splitCommits = commits.filter((c) => c.verdictKind === 'split');
|
|
357
|
+
const splitMd = [
|
|
358
|
+
'# Split plan (draft)',
|
|
359
|
+
'',
|
|
360
|
+
`Generated: ${new Date().toISOString()}`,
|
|
361
|
+
'',
|
|
362
|
+
'This lists commits whose manual-review verdict suggests **splitting** (mixed concerns).',
|
|
363
|
+
'The “how to split” is captured here as a checklist so we can execute it mechanically during the rewrite (via `cherry-pick -n` + selective staging).',
|
|
364
|
+
'',
|
|
365
|
+
...splitCommits.flatMap((c) => {
|
|
366
|
+
const head = `## ${String(c.n).padStart(3, '0')} ${c.shaShort} ${c.subject ?? ''}`.trim();
|
|
367
|
+
const files = c.files?.length ? c.files.map((f) => `- ${f.status} \`${f.path}\``) : ['- (files unavailable)'];
|
|
368
|
+
const hints = c.rewriteHints?.length ? c.rewriteHints.map((h) => `- ${h}`) : ['- (no rewrite hints captured)'];
|
|
369
|
+
return [
|
|
370
|
+
head,
|
|
371
|
+
'',
|
|
372
|
+
'- Verdict: split',
|
|
373
|
+
...(c.depends?.length ? ['- Depends on:', ...c.depends.map((d) => ` - ${d}`)] : []),
|
|
374
|
+
'',
|
|
375
|
+
'Files touched:',
|
|
376
|
+
...files,
|
|
377
|
+
'',
|
|
378
|
+
'Rewrite hints (from manual review):',
|
|
379
|
+
...hints,
|
|
380
|
+
'',
|
|
381
|
+
];
|
|
382
|
+
}),
|
|
383
|
+
].join('\n');
|
|
384
|
+
await fs.writeFile(OUT_SPLIT_MD, splitMd, 'utf8');
|
|
385
|
+
|
|
386
|
+
const prDefs = draftPrDefinitions();
|
|
387
|
+
const { byPr, unassigned, assignedCount } = assignCommitsToPrs(commits, prDefs);
|
|
388
|
+
if (unassigned.length) {
|
|
389
|
+
throw new Error(`PR assignment left ${unassigned.length} commits unassigned.`);
|
|
390
|
+
}
|
|
391
|
+
if (assignedCount !== commits.length) {
|
|
392
|
+
throw new Error(`PR assignment mismatch: assigned=${assignedCount} total=${commits.length}`);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const prMdLines = [
|
|
396
|
+
'# PR stack plan (draft)',
|
|
397
|
+
'',
|
|
398
|
+
`Generated: ${new Date().toISOString()}`,
|
|
399
|
+
'',
|
|
400
|
+
'This is a **draft** proposal to split the `leeroy-wip` changes into ~15 PRs.',
|
|
401
|
+
'It is derived from the audit metadata (topic buckets + manual verdicts), and must be reviewed/edited by humans before any history rewrite.',
|
|
402
|
+
'',
|
|
403
|
+
'Principles:',
|
|
404
|
+
'- Prefer “final feature state” (squash fixups into the feature commits).',
|
|
405
|
+
'- Keep commit granularity *within* a PR, but avoid “evolution noise” (back-and-forth) in the rewritten history.',
|
|
406
|
+
'- Avoid mega-commits: when a commit is marked `split`, execute it as multiple commits by file/feature boundary.',
|
|
407
|
+
'',
|
|
408
|
+
'Coverage:',
|
|
409
|
+
`- Total commits: ${commits.length}`,
|
|
410
|
+
`- notesReviewed=yes: ${commits.filter((c) => c.notesReviewed === 'yes').length}`,
|
|
411
|
+
`- Split candidates: ${splitCommits.length}`,
|
|
412
|
+
`- Assigned to PRs: ${assignedCount}`,
|
|
413
|
+
'',
|
|
414
|
+
];
|
|
415
|
+
|
|
416
|
+
for (const pr of prDefs) {
|
|
417
|
+
const items = byPr.get(pr.id) ?? [];
|
|
418
|
+
prMdLines.push(`## ${pr.id}: ${pr.title}`);
|
|
419
|
+
if (pr.note) prMdLines.push(`- Note: ${pr.note}`);
|
|
420
|
+
prMdLines.push(`- Buckets: ${pr.buckets.map((b) => `\`${b}\``).join(', ')}`);
|
|
421
|
+
prMdLines.push(`- Commits: ${items.length}`);
|
|
422
|
+
prMdLines.push('- Commit list:');
|
|
423
|
+
for (const c of items) {
|
|
424
|
+
const flags = [];
|
|
425
|
+
if (c.verdictKind === 'split') flags.push('SPLIT');
|
|
426
|
+
if (c.verdictKind === 'drop') flags.push('DROP?');
|
|
427
|
+
if (c.notesUpdated === 'yes') flags.push('notesUpdated');
|
|
428
|
+
const flagText = flags.length ? ` [${flags.join(',')}]` : '';
|
|
429
|
+
prMdLines.push(` - ${String(c.n).padStart(3, '0')} ${c.shaShort} ${c.subject ?? ''}${flagText}`.trimEnd());
|
|
430
|
+
}
|
|
431
|
+
prMdLines.push('');
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
await fs.writeFile(OUT_PR_MD, prMdLines.join('\n') + '\n', 'utf8');
|
|
435
|
+
|
|
436
|
+
// Small sanity print (for CI/local use)
|
|
437
|
+
// eslint-disable-next-line no-console
|
|
438
|
+
console.log(`[audit] wrote: ${path.relative(ROOT, OUT_INDEX_JSON)}`);
|
|
439
|
+
// eslint-disable-next-line no-console
|
|
440
|
+
console.log(`[audit] wrote: ${path.relative(ROOT, OUT_INDEX_TSV)}`);
|
|
441
|
+
// eslint-disable-next-line no-console
|
|
442
|
+
console.log(`[audit] wrote: ${path.relative(ROOT, OUT_BUCKETS_MD)}`);
|
|
443
|
+
// eslint-disable-next-line no-console
|
|
444
|
+
console.log(`[audit] wrote: ${path.relative(ROOT, OUT_SPLIT_MD)}`);
|
|
445
|
+
// eslint-disable-next-line no-console
|
|
446
|
+
console.log(`[audit] wrote: ${path.relative(ROOT, OUT_PR_MD)}`);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
main().catch((err) => {
|
|
450
|
+
// eslint-disable-next-line no-console
|
|
451
|
+
console.error(err);
|
|
452
|
+
process.exitCode = 1;
|
|
453
|
+
});
|