@polymorphism-tech/morph-spec 8.31.0 → 8.32.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.md +5 -0
- package/README.md +2 -2
- package/bin/morph-spec.js +19 -1
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +2 -1
- package/framework/MORPH.md +2 -2
- package/framework/docs/CLI.md +68 -63
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +3 -2
- package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +2 -2
- package/framework/skills/core/morph-implement/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +1 -1
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +2 -2
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/skills/core/morph-uiux/SKILL.md +1 -1
- package/package.json +1 -1
- package/src/commands/project/branch.js +170 -0
- package/src/commands/project/doctor.js +137 -9
- package/src/commands/project/fleet.js +10 -3
- package/src/commands/project/worktree.js +73 -50
- package/src/commands/state/finish.js +41 -26
- package/src/commands/state/list.js +79 -6
- package/src/core/state/lifecycle-audit.js +127 -0
- package/src/lib/git/branch-classifier.js +177 -0
- package/src/lib/git/gh.js +30 -0
- package/src/lib/git/pending-prs.js +89 -0
- package/src/lib/git/pr-index.js +70 -0
- package/src/lib/worktree/fleet-scanner.js +51 -21
- package/src/lib/worktree/ignored-triage.js +197 -0
- package/src/lib/worktree/worktree-audit.js +200 -35
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
cwdIsInsideWorktree,
|
|
14
14
|
} from '../project/worktree.js';
|
|
15
15
|
import { detectDefaultBranch, hasOriginRemote } from '../../lib/git/default-branch.js';
|
|
16
|
+
import { resolveGh } from '../../lib/git/gh.js';
|
|
16
17
|
import { releaseClaim } from '../../../framework/hooks/shared/claim-registry.js';
|
|
17
18
|
import {
|
|
18
19
|
composeProjectName,
|
|
@@ -326,7 +327,7 @@ export async function finishCommand(feature, options = {}) {
|
|
|
326
327
|
branch: expectedBranch,
|
|
327
328
|
base: target,
|
|
328
329
|
archived: archivePath.replace(/\\/g, '/'),
|
|
329
|
-
...(typeof openPr === 'string' ? { pr: openPr } : {}),
|
|
330
|
+
...(typeof openPr === 'string' ? { pr: { number: prNumberFromUrl(openPr), url: openPr } } : {}),
|
|
330
331
|
hint: `'${feature}' já foi arquivada e ${expectedBranch} já tem PR aberto — o finish anterior concluiu. Falta apenas mergear o PR.`,
|
|
331
332
|
});
|
|
332
333
|
process.exit(1);
|
|
@@ -487,7 +488,7 @@ async function integrateMerge({ emit, root, feature, expectedBranch, defaultBran
|
|
|
487
488
|
// VALID (it still names a live directory), so nothing to clean up here —
|
|
488
489
|
// only report where it lives and how to close it later.
|
|
489
490
|
emit({
|
|
490
|
-
finished: true, mode: 'merge', feature, merged: expectedBranch, kept: true, worktreeRemoved: false,
|
|
491
|
+
finished: true, integrated: true, mode: 'merge', feature, merged: expectedBranch, kept: true, worktreeRemoved: false,
|
|
491
492
|
worktree: { path: worktreePath, branch: expectedBranch },
|
|
492
493
|
hint: `worktree preservado em ${worktreePath} — feche depois com \`morph-spec worktree remove ${feature}\`.`,
|
|
493
494
|
...backupNote, ...ucNote,
|
|
@@ -526,10 +527,26 @@ async function integrateMerge({ emit, root, feature, expectedBranch, defaultBran
|
|
|
526
527
|
}
|
|
527
528
|
tryGit(['branch', '-d', expectedBranch], root);
|
|
528
529
|
// The root index (gitignored) was already updated by the archive (same cwd).
|
|
529
|
-
emit({ finished: true, mode: 'merge', feature, merged: expectedBranch, into: defaultBranch, worktreeRemoved: false, nextStep: MERGE_NEXT_STEP, ...ucNote });
|
|
530
|
+
emit({ finished: true, integrated: true, mode: 'merge', feature, merged: expectedBranch, into: defaultBranch, worktreeRemoved: false, nextStep: MERGE_NEXT_STEP, ...ucNote });
|
|
530
531
|
process.exit(0);
|
|
531
532
|
}
|
|
532
533
|
|
|
534
|
+
/**
|
|
535
|
+
* O número do PR a partir da url que o `gh pr create` imprime.
|
|
536
|
+
*
|
|
537
|
+
* Existe porque o número é o que um humano usa para falar do PR ("mergeia o
|
|
538
|
+
* #51"), e a url é a única coisa que o `gh` devolve. Um formato inesperado
|
|
539
|
+
* devolve `null` em vez de um palpite: o payload prefere admitir que não sabe
|
|
540
|
+
* o número a afirmar um errado.
|
|
541
|
+
*
|
|
542
|
+
* @param {string|null} url
|
|
543
|
+
* @returns {number|null}
|
|
544
|
+
*/
|
|
545
|
+
export function prNumberFromUrl(url) {
|
|
546
|
+
const m = typeof url === 'string' ? url.match(/\/pull\/(\d+)(?:[/?#]|$)/) : null;
|
|
547
|
+
return m ? Number(m[1]) : null;
|
|
548
|
+
}
|
|
549
|
+
|
|
533
550
|
// ── PR integration (push + gh, no local merge) ───────────────────────────────
|
|
534
551
|
|
|
535
552
|
async function integratePr({ emit, root, feature, expectedBranch, defaultBranch, isWorktreeFlow, worktreePath, options, prTitle, prBody, arch }) {
|
|
@@ -571,13 +588,23 @@ async function integratePr({ emit, root, feature, expectedBranch, defaultBranch,
|
|
|
571
588
|
dropFromRootIndex(root, feature);
|
|
572
589
|
}
|
|
573
590
|
|
|
591
|
+
const prNumber = prNumberFromUrl(prUrl);
|
|
592
|
+
const prLabel = prNumber ? `#${prNumber}` : 'o PR';
|
|
593
|
+
|
|
574
594
|
const out = {
|
|
575
595
|
finished: true,
|
|
596
|
+
// O comando terminou; a INTEGRAÇÃO não aconteceu. Eram a mesma coisa no
|
|
597
|
+
// payload antigo, e é dessa colisão que sai o "shipped" para uma feature
|
|
598
|
+
// cujo PR seguia aberto: quem lê `finished: true` sem mais nada conclui
|
|
599
|
+
// que acabou. A default só recebe archive/{f} quando o PR mergear.
|
|
600
|
+
integrated: false,
|
|
601
|
+
awaiting: 'merge',
|
|
576
602
|
mode: 'pr',
|
|
577
603
|
feature,
|
|
578
604
|
pushed: expectedBranch,
|
|
579
605
|
base: defaultBranch,
|
|
580
|
-
pr: prUrl,
|
|
606
|
+
pr: { number: prNumber, url: prUrl },
|
|
607
|
+
nextStep: `mergeie ${prLabel} — a feature só estará encerrada quando ${defaultBranch} receber o archive pelo merge`,
|
|
581
608
|
...promotedNote(arch),
|
|
582
609
|
...(shedBackup ? { shedBackup } : {}),
|
|
583
610
|
...(teardown
|
|
@@ -626,6 +653,7 @@ const MERGE_NEXT_STEP = 'integração LOCAL — nada foi publicado. Rode `git pu
|
|
|
626
653
|
function mergeOutput({ feature, branch, worktreePath, teardown, shedBackup }) {
|
|
627
654
|
return {
|
|
628
655
|
finished: true,
|
|
656
|
+
integrated: true,
|
|
629
657
|
mode: 'merge',
|
|
630
658
|
feature,
|
|
631
659
|
merged: branch,
|
|
@@ -641,6 +669,15 @@ function teardownExtras(teardown, worktreePath, branch, deleteBranch) {
|
|
|
641
669
|
const out = {};
|
|
642
670
|
if (teardown.ignoredBackup) out.worktreeIgnoredBackup = teardown.ignoredBackup;
|
|
643
671
|
if (teardown.worktreeRemoved && teardown.discarded.length) out.discardedIgnored = teardown.discarded;
|
|
672
|
+
// WHAT was copied out, and what needed no copy because the primary root holds
|
|
673
|
+
// the identical bytes. Reported on both paths: after a removal these two lists
|
|
674
|
+
// are the whole answer to "did I just lose something?", and the user should
|
|
675
|
+
// not have to open the backup directory to find out.
|
|
676
|
+
if (teardown.shieldable?.length) out.worktreeIgnoredBackedUp = teardown.shieldable;
|
|
677
|
+
if (teardown.replicable?.length) out.replicatedInRoot = teardown.replicable;
|
|
678
|
+
// The infra reparse points dropped BEFORE git touched the directory — the
|
|
679
|
+
// proof the primary root's real `.claude/` was unlinked, never followed.
|
|
680
|
+
if (teardown.unlinked?.length) out.unlinked = teardown.unlinked;
|
|
644
681
|
if (!teardown.worktreeRemoved) {
|
|
645
682
|
out.path = worktreePath;
|
|
646
683
|
out.branch = branch;
|
|
@@ -688,28 +725,6 @@ function gitStatusPorcelain(cwd) {
|
|
|
688
725
|
}
|
|
689
726
|
}
|
|
690
727
|
|
|
691
|
-
/**
|
|
692
|
-
* Resolve the `gh` invocation. Defaults to the `gh` binary on PATH. Overridable
|
|
693
|
-
* via MORPH_GH_BIN: a plain string names a different binary (a corporate wrapper,
|
|
694
|
-
* `hub`, …); a JSON array is a full argv prefix — e.g.
|
|
695
|
-
* `["/path/to/node","/path/to/gh-shim.js"]` — which is how the tests inject a
|
|
696
|
-
* cross-platform stub that execFileSync can actually spawn (Node ≥20 refuses to
|
|
697
|
-
* run `.cmd`/`.bat` shims without `shell:true`, and shell:true would reopen the
|
|
698
|
-
* arg-injection hole the whole file avoids).
|
|
699
|
-
*
|
|
700
|
-
* @returns {{ bin: string, prefix: string[] }}
|
|
701
|
-
*/
|
|
702
|
-
function resolveGh() {
|
|
703
|
-
const raw = process.env.MORPH_GH_BIN;
|
|
704
|
-
if (raw && raw.trim().startsWith('[')) {
|
|
705
|
-
try {
|
|
706
|
-
const arr = JSON.parse(raw);
|
|
707
|
-
if (Array.isArray(arr) && arr.length) return { bin: String(arr[0]), prefix: arr.slice(1).map(String) };
|
|
708
|
-
} catch { /* not JSON — treat as a plain binary name below */ }
|
|
709
|
-
}
|
|
710
|
-
return { bin: raw && raw.trim() ? raw.trim() : 'gh', prefix: [] };
|
|
711
|
-
}
|
|
712
|
-
|
|
713
728
|
function ghAuthOk() {
|
|
714
729
|
const { bin, prefix } = resolveGh();
|
|
715
730
|
try {
|
|
@@ -7,6 +7,8 @@ import { missingIdentity } from '../../core/state/feature-identity.js';
|
|
|
7
7
|
import { derivePhaseFrom } from '../../../framework/hooks/shared/state-reader.js';
|
|
8
8
|
import { isGitRepo } from '../project/worktree.js';
|
|
9
9
|
import { morphBranches, currentBranch, branchView } from '../../lib/worktree/feature-truth.js';
|
|
10
|
+
import { buildPrIndex } from '../../lib/git/pr-index.js';
|
|
11
|
+
import { pendingPrFeatures, prForFeature } from '../../lib/git/pending-prs.js';
|
|
10
12
|
|
|
11
13
|
/**
|
|
12
14
|
* `morph-spec list [--all] [--json] [--no-branch]` — the feature audit board.
|
|
@@ -176,16 +178,51 @@ function flagArchivedElsewhere(cwd, rows) {
|
|
|
176
178
|
}
|
|
177
179
|
}
|
|
178
180
|
|
|
181
|
+
/**
|
|
182
|
+
* A linha de uma feature que existe SÓ como PR aberto — sem pasta ativa nem
|
|
183
|
+
* arquivada nesta árvore. É o estado que o `finish --pr` deixa: o scaffold do
|
|
184
|
+
* root foi apagado pelo shed e o archive viaja no diff do PR, chegando na
|
|
185
|
+
* default apenas no merge. Sem esta linha o trabalho fica sem dono no quadro,
|
|
186
|
+
* e uma branch sem dono é candidata a poda.
|
|
187
|
+
*/
|
|
188
|
+
function pendingRow(p) {
|
|
189
|
+
return {
|
|
190
|
+
feature: p.feature,
|
|
191
|
+
name: null,
|
|
192
|
+
description: null,
|
|
193
|
+
workType: null,
|
|
194
|
+
status: 'in_progress',
|
|
195
|
+
phase: 'aguardando-merge',
|
|
196
|
+
gates: { proposal: false, uiux: false, plan: false, review: false },
|
|
197
|
+
selfAssessed: false,
|
|
198
|
+
flags: ['pr-pending'],
|
|
199
|
+
location: 'pr-pending',
|
|
200
|
+
createdAt: p.createdAt,
|
|
201
|
+
updatedAt: null,
|
|
202
|
+
source: 'pr',
|
|
203
|
+
branch: p.branch,
|
|
204
|
+
worktree: null,
|
|
205
|
+
pr: { number: p.number, state: p.state, url: p.url, ageDays: p.ageDays },
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
179
209
|
const mark = (on) => (on ? chalk.green('✓') : chalk.gray('·'));
|
|
180
210
|
const truncate = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
|
|
181
211
|
|
|
182
212
|
function whereLabel(row) {
|
|
213
|
+
if (row.location === 'pr-pending') return `branch ${row.branch}`;
|
|
183
214
|
if (row.location === 'archived') return '-';
|
|
184
215
|
if (row.worktree) return row.worktree.exists ? `worktree ${basename(row.worktree.path)}` : '⚠ worktree ausente';
|
|
185
216
|
if (row.source === 'branch') return `branch ${row.branch}`;
|
|
186
217
|
return 'raiz';
|
|
187
218
|
}
|
|
188
219
|
|
|
220
|
+
const prCell = (pr) => {
|
|
221
|
+
if (!pr) return '-';
|
|
222
|
+
const state = String(pr.state || '').toLowerCase();
|
|
223
|
+
return `#${pr.number} ${state}`;
|
|
224
|
+
};
|
|
225
|
+
|
|
189
226
|
export function listCommand(options = {}) {
|
|
190
227
|
const cwd = options.cwd || process.cwd();
|
|
191
228
|
const rows = readFeaturesIn(cwd, 'features', 'active');
|
|
@@ -195,6 +232,32 @@ export function listCommand(options = {}) {
|
|
|
195
232
|
// Commander's `--no-branch` sets options.branch = false; anything else (undefined/true) reads the branch.
|
|
196
233
|
if (options.branch !== false) resolveBranchTruth(cwd, rows);
|
|
197
234
|
|
|
235
|
+
// Estado do PR — uma consulta ao `gh` por invocação, compartilhada pela
|
|
236
|
+
// coluna e pela síntese. `--no-branch` (o modo offline rápido) pula as duas.
|
|
237
|
+
//
|
|
238
|
+
// `prIndex` continua `null` quando a pergunta não pôde ser feita (sem `gh`,
|
|
239
|
+
// sem auth, remoto não-GitHub, ou `--no-branch`). É o mesmo sinal que
|
|
240
|
+
// `--json` já expõe como `pr: null` por linha — a tabela texto usa o mesmo
|
|
241
|
+
// fato para decidir se a coluna PR sequer aparece (achado #2): índice `null`
|
|
242
|
+
// degrada para o comportamento de antes, sem coluna e sem `-` que afirme
|
|
243
|
+
// ausência.
|
|
244
|
+
let prIndex = null;
|
|
245
|
+
if (options.branch !== false) {
|
|
246
|
+
prIndex = buildPrIndex(cwd);
|
|
247
|
+
for (const row of rows) {
|
|
248
|
+
const pr = prForFeature(prIndex, row.feature);
|
|
249
|
+
row.pr = pr ? { number: pr.number, state: pr.state, url: pr.url, ageDays: null } : null;
|
|
250
|
+
}
|
|
251
|
+
const known = new Set(rows.map((r) => r.feature));
|
|
252
|
+
// `null` propaga desconhecido: nenhuma linha sintetizada, e nenhuma
|
|
253
|
+
// afirmação de que não há pendência.
|
|
254
|
+
for (const p of pendingPrFeatures(cwd, { index: prIndex }) || []) {
|
|
255
|
+
if (!known.has(p.feature)) rows.push(pendingRow(p));
|
|
256
|
+
}
|
|
257
|
+
} else {
|
|
258
|
+
for (const row of rows) row.pr = null;
|
|
259
|
+
}
|
|
260
|
+
|
|
198
261
|
rows.sort((a, b) => a.feature.localeCompare(b.feature));
|
|
199
262
|
|
|
200
263
|
if (options.json) {
|
|
@@ -208,22 +271,32 @@ export function listCommand(options = {}) {
|
|
|
208
271
|
}
|
|
209
272
|
|
|
210
273
|
const col = (s, w) => String(s ?? '-').padEnd(w);
|
|
274
|
+
// A coluna PR só aparece quando o índice foi construído com sucesso (achado
|
|
275
|
+
// #2) — um índice `null` não vira uma coluna cheia de `-`, que afirmaria
|
|
276
|
+
// "sem PR" para algo que na verdade é "não sabemos".
|
|
277
|
+
const showPr = Boolean(prIndex);
|
|
278
|
+
// `pr-pending` é estado normal de trabalho em voo, não inconsistência —
|
|
279
|
+
// não deve marcar `⚠` nem contar no rodapé (achado #6). Continua em
|
|
280
|
+
// `flags` no `--json`: é metadado real, só não é anomalia.
|
|
281
|
+
const warnFlags = (r) => r.flags.filter((f) => f !== 'pr-pending');
|
|
211
282
|
console.log(chalk.white.bold(
|
|
212
|
-
col('FEATURE', 22) + col('TYPE', 9) + col('STATUS', 13) + col('PHASE',
|
|
283
|
+
col('FEATURE', 22) + col('TYPE', 9) + col('STATUS', 13) + col('PHASE', 17) + col('WHERE', 22) + (showPr ? col('PR', 12) : '')
|
|
213
284
|
+ col('G1', 4) + col('G2', 4) + col('G3', 4) + col('', 3) + 'DESCRIPTION',
|
|
214
285
|
));
|
|
215
286
|
for (const r of rows) {
|
|
216
|
-
const flag = r.
|
|
217
|
-
const desc =
|
|
218
|
-
|
|
287
|
+
const flag = warnFlags(r).length ? chalk.yellow('⚠') : ' ';
|
|
288
|
+
const desc = r.location === 'pr-pending'
|
|
289
|
+
? chalk.yellow(`aguardando merge${r.pr?.ageDays != null ? ` há ${r.pr.ageDays}d` : ''} — sem cópia local`)
|
|
290
|
+
: (r.selfAssessed ? chalk.yellow('[G3 self-assessed] ') : '')
|
|
291
|
+
+ (r.description ? truncate(r.description, 34) : chalk.yellow('(sem descrição)'));
|
|
219
292
|
// padEnd counts the invisible color codes; pad the plain marks manually.
|
|
220
293
|
console.log(
|
|
221
|
-
col(r.feature, 22) + col(r.workType, 9) + col(r.status, 13) + col(r.phase,
|
|
294
|
+
col(r.feature, 22) + col(r.workType, 9) + col(r.status, 13) + col(r.phase, 17) + col(whereLabel(r), 22) + (showPr ? col(prCell(r.pr), 12) : '')
|
|
222
295
|
+ mark(r.gates.proposal) + ' ' + mark(r.gates.plan) + ' ' + mark(r.gates.review) + ' '
|
|
223
296
|
+ flag + ' ' + desc,
|
|
224
297
|
);
|
|
225
298
|
}
|
|
226
|
-
const flagged = rows.filter((r) => r.
|
|
299
|
+
const flagged = rows.filter((r) => warnFlags(r).length);
|
|
227
300
|
if (flagged.length) {
|
|
228
301
|
console.log(chalk.gray(`\n⚠ ${flagged.length} com inconsistência — rode 'morph-spec doctor' para detalhes.`));
|
|
229
302
|
}
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
* the next loadState, but surfaced so it's not a mystery)
|
|
13
13
|
* • branch-drift — the root's copy of a feature.json disagrees with the copy
|
|
14
14
|
* committed on `morph/{f}` (B5 — the root's view lags)
|
|
15
|
+
* • abandoned-scaffold — an `in_progress` feature that never produced
|
|
16
|
+
* anything: no artifacts, no approved gate, and a branch
|
|
17
|
+
* with no commit of its own (see abandonedScaffold)
|
|
15
18
|
* • corrupt — a feature.json that won't parse
|
|
16
19
|
*/
|
|
17
20
|
|
|
@@ -20,6 +23,8 @@ import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
|
|
|
20
23
|
import { join } from 'path';
|
|
21
24
|
import { gateOrderViolations } from './gate-order.js';
|
|
22
25
|
import { missingIdentity } from './feature-identity.js';
|
|
26
|
+
import { detectDefaultBranch } from '../../lib/git/default-branch.js';
|
|
27
|
+
import { commitsAhead, resolveBaseRef } from '../../lib/git/branch-classifier.js';
|
|
23
28
|
|
|
24
29
|
/** The operational truth of a feature.json: its status and which gates are approved. */
|
|
25
30
|
function operationalShape(fj) {
|
|
@@ -72,6 +77,125 @@ function branchDrift(baseDir, feature, rootFj) {
|
|
|
72
77
|
}
|
|
73
78
|
}
|
|
74
79
|
|
|
80
|
+
/**
|
|
81
|
+
* How long a scaffold has to sit untouched before it counts as abandoned.
|
|
82
|
+
*
|
|
83
|
+
* A dead scaffold and a feature born five minutes ago have the SAME shape:
|
|
84
|
+
* `create` forks `morph/{f}` off the default branch without committing, so at
|
|
85
|
+
* birth the branch has no commit of its own and the folder holds nothing but
|
|
86
|
+
* `feature.json`. Only elapsed time separates the two — and a check that fires
|
|
87
|
+
* on every fresh `create` is one people learn to scroll past.
|
|
88
|
+
*/
|
|
89
|
+
const ABANDONED_SCAFFOLD_MIN_AGE_MS = 24 * 60 * 60 * 1000;
|
|
90
|
+
|
|
91
|
+
/** Newest timestamp the feature carries, in ms; falls back to the folder's mtime. */
|
|
92
|
+
function lastTouched(fdir, fj) {
|
|
93
|
+
let newest = 0;
|
|
94
|
+
for (const ts of [fj?.updatedAt, fj?.createdAt]) {
|
|
95
|
+
const t = ts ? Date.parse(ts) : NaN;
|
|
96
|
+
if (!Number.isNaN(t)) newest = Math.max(newest, t);
|
|
97
|
+
}
|
|
98
|
+
if (newest) return newest;
|
|
99
|
+
try { return statSync(fdir).mtimeMs; } catch { return 0; }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Does the feature dir hold any FILE other than its own `feature.json`?
|
|
104
|
+
*
|
|
105
|
+
* Files, not entries: `create` scaffolds the phase folders empty, so an empty
|
|
106
|
+
* `0-proposal/` is the absence of work, not evidence of it.
|
|
107
|
+
*/
|
|
108
|
+
function hasArtifacts(fdir) {
|
|
109
|
+
let entries;
|
|
110
|
+
try { entries = readdirSync(fdir, { withFileTypes: true }); } catch { return false; }
|
|
111
|
+
for (const e of entries) {
|
|
112
|
+
if (e.isDirectory()) {
|
|
113
|
+
if (hasArtifacts(join(fdir, e.name))) return true;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (e.name === 'feature.json') continue;
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Commits on the feature's branch that the base does not already contain, or
|
|
124
|
+
* `null` when git cannot answer (no repo, no resolvable base, no branch, no
|
|
125
|
+
* rev-list). "Cannot prove" must never read as "proved zero" — the predicate
|
|
126
|
+
* below hinges on this number, so an unanswerable repo has to stay silent.
|
|
127
|
+
*
|
|
128
|
+
* The counting itself is `branch-classifier.js`'s `commitsAhead` against its
|
|
129
|
+
* `resolveBaseRef`, NOT a second copy: `origin/{default}` is the authority on
|
|
130
|
+
* what actually landed, and two drifting answers to "does this branch carry
|
|
131
|
+
* anything of its own?" is exactly what the `doctor`/`branch prune` split was
|
|
132
|
+
* unified to avoid.
|
|
133
|
+
*
|
|
134
|
+
* Note the field evidence this exists for: the branch pointed at the merge
|
|
135
|
+
* commit of somebody else's PR. That is the base's own tip — reachable from
|
|
136
|
+
* the base, and therefore zero commits of its own.
|
|
137
|
+
*/
|
|
138
|
+
function ownCommitCount(baseDir, branch) {
|
|
139
|
+
try {
|
|
140
|
+
execFileSync('git', ['rev-parse', '--git-dir'], { cwd: baseDir, stdio: 'pipe' });
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
const baseRef = resolveBaseRef(detectDefaultBranch(baseDir), baseDir);
|
|
145
|
+
if (!baseRef) return null;
|
|
146
|
+
try {
|
|
147
|
+
execFileSync('git', ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], { cwd: baseDir, stdio: 'pipe' });
|
|
148
|
+
} catch {
|
|
149
|
+
return 0; // the branch never existed — nothing was ever committed for it
|
|
150
|
+
}
|
|
151
|
+
return commitsAhead(baseRef, branch, baseDir);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The orphan skeleton `create --worktree` can leave behind: a feature.json on
|
|
156
|
+
* its own, `status: in_progress`, no tasks, no gates, and a branch parked on
|
|
157
|
+
* somebody else's commit. Every one of those alone is normal at some point in
|
|
158
|
+
* a feature's life; together, and only after the feature has gone stale, they
|
|
159
|
+
* mean nobody ever came back to it.
|
|
160
|
+
*
|
|
161
|
+
* The artifact test is deliberately broader than the "0 tasks" the report
|
|
162
|
+
* named: a proposal drafted and waiting on Gate 1 also has zero tasks and is
|
|
163
|
+
* very much alive. What a dead scaffold has is NOTHING — checked in the root
|
|
164
|
+
* and, if the feature registered one, in its worktree, where uncommitted work
|
|
165
|
+
* would otherwise be invisible to both the disk scan and `rev-list`.
|
|
166
|
+
*
|
|
167
|
+
* @returns {string|null} the issue detail, or null when the feature is alive
|
|
168
|
+
*/
|
|
169
|
+
function abandonedScaffold(baseDir, feature, fdir, fj) {
|
|
170
|
+
if (fj?.status !== 'in_progress') return null;
|
|
171
|
+
|
|
172
|
+
// "0 gates": a gate stamped `policy:{workType}` at birth (chore/hotfix) was
|
|
173
|
+
// never a decision anyone took, so it must not read as progress.
|
|
174
|
+
const decided = Object.values(fj.approvalGates || {}).some(
|
|
175
|
+
(g) => g?.approved === true && !String(g?.approvedBy ?? '').startsWith('policy:'),
|
|
176
|
+
);
|
|
177
|
+
if (decided) return null;
|
|
178
|
+
|
|
179
|
+
if (hasArtifacts(fdir)) return null;
|
|
180
|
+
const wtFeatureDir = fj.worktree?.path
|
|
181
|
+
? join(fj.worktree.path, '.morph', 'features', feature)
|
|
182
|
+
: null;
|
|
183
|
+
if (wtFeatureDir && existsSync(wtFeatureDir) && hasArtifacts(wtFeatureDir)) return null;
|
|
184
|
+
|
|
185
|
+
const age = Date.now() - lastTouched(fdir, fj);
|
|
186
|
+
if (age < ABANDONED_SCAFFOLD_MIN_AGE_MS) return null;
|
|
187
|
+
|
|
188
|
+
// The registered branch when the feature has one, `morph/{f}` otherwise —
|
|
189
|
+
// same name `branchDrift` above assumes.
|
|
190
|
+
const branch = fj.worktree?.branch || `morph/${feature}`;
|
|
191
|
+
if (ownCommitCount(baseDir, branch) !== 0) return null;
|
|
192
|
+
|
|
193
|
+
const days = Math.floor(age / (24 * 60 * 60 * 1000));
|
|
194
|
+
return `in_progress há ${days}d sem nada produzido: 0 artefatos, 0 gates aprovados `
|
|
195
|
+
+ `e \`${branch}\` sem commit próprio — rode \`morph-spec delete ${feature} --archive\` `
|
|
196
|
+
+ 'ou retome a feature';
|
|
197
|
+
}
|
|
198
|
+
|
|
75
199
|
/**
|
|
76
200
|
* @param {string} [baseDir] - project root (defaults to cwd)
|
|
77
201
|
* @returns {Array<{ feature: string, type: string, detail: string }>} issues
|
|
@@ -106,6 +230,9 @@ export function auditFeatureLifecycle(baseDir = process.cwd()) {
|
|
|
106
230
|
const drift = branchDrift(baseDir, name, fj);
|
|
107
231
|
if (drift) issues.push({ feature: name, type: 'branch-drift', detail: drift });
|
|
108
232
|
|
|
233
|
+
const dead = abandonedScaffold(baseDir, name, fdir, fj);
|
|
234
|
+
if (dead) issues.push({ feature: name, type: 'abandoned-scaffold', detail: dead });
|
|
235
|
+
|
|
109
236
|
const miss = missingIdentity(fj);
|
|
110
237
|
if (miss.length) {
|
|
111
238
|
issues.push({ feature: name, type: 'identity', detail: `missing ${miss.join(', ')}` });
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* branch-classifier.js — answer "is this branch's work already in the default
|
|
3
|
+
* branch?" for a repository that squash-merges.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS: a squash-merge puts the WORK in `origin/master` while
|
|
6
|
+
* leaving none of the branch's own commits reachable from it. So
|
|
7
|
+
* `git branch --merged origin/master` does not list the branch, and
|
|
8
|
+
* `git branch -d` refuses it as unmerged. The only remaining command is
|
|
9
|
+
* `git branch -D`, which is byte-identical to the command that discards real,
|
|
10
|
+
* never-integrated work — the tool gives the user no way to tell the two apart,
|
|
11
|
+
* and after enough of these the habit becomes "just -D it", which is how work
|
|
12
|
+
* gets lost.
|
|
13
|
+
*
|
|
14
|
+
* Every branch lands in exactly one class, and only the two MERGED ones are
|
|
15
|
+
* ever deletable:
|
|
16
|
+
*
|
|
17
|
+
* merged-ancestral — git itself proves it: the branch tip is an ancestor of
|
|
18
|
+
* the default. A plain merge, or a fast-forward.
|
|
19
|
+
* merged-squash — git cannot see it, but the evidence is positive: either
|
|
20
|
+
* GitHub reports the PR as MERGED, or the branch's tree is
|
|
21
|
+
* IDENTICAL to the default's (every change it carries is
|
|
22
|
+
* already there under a different commit).
|
|
23
|
+
* open — a PR is OPEN for it. Never deleted: the PR is the whole
|
|
24
|
+
* reason the branch must survive.
|
|
25
|
+
* in-worktree — checked out by a live worktree. Never deleted.
|
|
26
|
+
* unmerged — none of the above could be PROVEN. This is the fail-safe
|
|
27
|
+
* default and covers "no `gh`", "no origin", and "genuinely
|
|
28
|
+
* unintegrated work" alike, because from here they are the
|
|
29
|
+
* same thing: not proven integrated.
|
|
30
|
+
*
|
|
31
|
+
* Nothing here mutates. `pruneBranches` in commands/project/branch.js does the
|
|
32
|
+
* deleting, and it deletes ONLY what this file classified as merged.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { execFileSync } from 'child_process';
|
|
36
|
+
|
|
37
|
+
function git(args, cwd) {
|
|
38
|
+
return execFileSync('git', args, { cwd, stdio: 'pipe' }).toString();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Local branches with their tips. Excludes nothing — the caller decides.
|
|
43
|
+
*
|
|
44
|
+
* @returns {Array<{ name: string, sha: string, current: boolean, worktreePath: string|null }>}
|
|
45
|
+
*/
|
|
46
|
+
export function listLocalBranches(cwd) {
|
|
47
|
+
try {
|
|
48
|
+
const out = git(['for-each-ref', '--format=%(refname:short)%09%(objectname)%09%(HEAD)%09%(worktreepath)', 'refs/heads/'], cwd);
|
|
49
|
+
return out.split('\n').filter(Boolean).map((line) => {
|
|
50
|
+
const [name, sha, head, worktreePath] = line.split('\t');
|
|
51
|
+
return { name, sha, current: head === '*', worktreePath: worktreePath || null };
|
|
52
|
+
});
|
|
53
|
+
} catch {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Is `ancestor` reachable from `descendant`? False on any git error. */
|
|
59
|
+
function isAncestor(ancestor, descendant, cwd) {
|
|
60
|
+
try {
|
|
61
|
+
git(['merge-base', '--is-ancestor', ancestor, descendant], cwd);
|
|
62
|
+
return true;
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Does the branch introduce NOTHING the base does not already have?
|
|
70
|
+
*
|
|
71
|
+
* `git diff --quiet <base> <branch>` compares the two TREES: an empty diff
|
|
72
|
+
* means every file the branch touched already reads exactly as the branch left
|
|
73
|
+
* it in the base. That is precisely the state a squash-merge produces, and it
|
|
74
|
+
* is a positive proof rather than an inference from history shape.
|
|
75
|
+
*
|
|
76
|
+
* Deliberately NOT `base...branch` (the three-dot form diffs against the merge
|
|
77
|
+
* base, which for a squash-merged branch still shows the branch's own changes).
|
|
78
|
+
*
|
|
79
|
+
* Fail-CLOSED: any git error reads as "introduces something", never as merged.
|
|
80
|
+
*/
|
|
81
|
+
function treeIsIdentical(base, branch, cwd) {
|
|
82
|
+
try {
|
|
83
|
+
execFileSync('git', ['diff', '--quiet', base, branch], { cwd, stdio: 'pipe' });
|
|
84
|
+
return true;
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* How many commits the branch carries that the base does not. Used only for
|
|
92
|
+
* REPORTING — the classification never turns on it, because a squash-merged
|
|
93
|
+
* branch legitimately has commits ahead while carrying no unintegrated work.
|
|
94
|
+
*
|
|
95
|
+
* @returns {number|null} null when it cannot be computed
|
|
96
|
+
*/
|
|
97
|
+
export function commitsAhead(base, branch, cwd) {
|
|
98
|
+
try {
|
|
99
|
+
const out = git(['rev-list', '--count', `${base}..${branch}`], cwd).trim();
|
|
100
|
+
const n = Number(out);
|
|
101
|
+
return Number.isFinite(n) ? n : null;
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Resolve the ref to compare against: `origin/<default>` when the remote-tracking
|
|
109
|
+
* ref exists (the authority on what has actually landed), else the local branch.
|
|
110
|
+
*
|
|
111
|
+
* @returns {string|null} null when neither resolves — the caller must then
|
|
112
|
+
* classify everything as `unmerged`, because there is no baseline to prove
|
|
113
|
+
* integration against.
|
|
114
|
+
*/
|
|
115
|
+
export function resolveBaseRef(defaultBranch, cwd) {
|
|
116
|
+
if (!defaultBranch) return null;
|
|
117
|
+
for (const ref of [`refs/remotes/origin/${defaultBranch}`, `refs/heads/${defaultBranch}`]) {
|
|
118
|
+
try {
|
|
119
|
+
git(['show-ref', '--verify', '--quiet', ref], cwd);
|
|
120
|
+
return ref.replace(/^refs\/(remotes|heads)\//, '');
|
|
121
|
+
} catch { /* try the next */ }
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Classify one branch. Pure decision logic over already-gathered facts, so the
|
|
128
|
+
* table above is testable without a GitHub account.
|
|
129
|
+
*
|
|
130
|
+
* ORDER IS THE POLICY, and the two protective classes come FIRST: a branch that
|
|
131
|
+
* is checked out, or that carries an OPEN PR, is never deleted even if its tree
|
|
132
|
+
* happens to match the base — a PR being open means a human still has business
|
|
133
|
+
* with it.
|
|
134
|
+
*
|
|
135
|
+
* @param {object} b - from listLocalBranches
|
|
136
|
+
* @param {{ baseRef: string|null, cwd: string, pr: {number:number,state:string}|null|undefined }} ctx
|
|
137
|
+
* @returns {{ name: string, kind: string, reason: string, pr: number|null, ahead: number|null, deletable: boolean }}
|
|
138
|
+
*/
|
|
139
|
+
export function classifyBranch(b, { baseRef, cwd, pr }) {
|
|
140
|
+
const prNum = pr?.number ?? null;
|
|
141
|
+
const mk = (kind, reason, deletable) => ({
|
|
142
|
+
name: b.name, kind, reason, pr: prNum, ahead: baseRef ? commitsAhead(baseRef, b.name, cwd) : null, deletable,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
if (b.current || b.worktreePath) {
|
|
146
|
+
return mk('in-worktree', b.current ? 'é a branch atual (HEAD)' : `está em uso pelo worktree ${b.worktreePath}`, false);
|
|
147
|
+
}
|
|
148
|
+
if (pr?.state === 'OPEN') {
|
|
149
|
+
return mk('open', `PR #${prNum} está OPEN — a branch é o que o PR aponta`, false);
|
|
150
|
+
}
|
|
151
|
+
if (!baseRef) {
|
|
152
|
+
return mk('unmerged', 'não há branch default resolvível (origin/HEAD, main ou master) para comparar', false);
|
|
153
|
+
}
|
|
154
|
+
if (isAncestor(b.name, baseRef, cwd)) {
|
|
155
|
+
return mk('merged-ancestral', `já é ancestral de ${baseRef}`, true);
|
|
156
|
+
}
|
|
157
|
+
if (pr?.state === 'MERGED') {
|
|
158
|
+
return mk('merged-squash', `PR #${prNum} está MERGED (squash-merge: os commits não entram em ${baseRef}, o trabalho sim)`, true);
|
|
159
|
+
}
|
|
160
|
+
if (treeIsIdentical(baseRef, b.name, cwd)) {
|
|
161
|
+
return mk('merged-squash', `a árvore é idêntica à de ${baseRef} — tudo que a branch muda já está lá`, true);
|
|
162
|
+
}
|
|
163
|
+
return mk('unmerged', `carrega mudanças que não estão em ${baseRef} e nenhum PR MERGED comprova o contrário`, false);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Classify every local branch except the default itself.
|
|
168
|
+
*
|
|
169
|
+
* @param {{ cwd: string, defaultBranch: string|null, prIndex: Map|null }} ctx
|
|
170
|
+
* @returns {Array} one entry per branch, in `for-each-ref` order
|
|
171
|
+
*/
|
|
172
|
+
export function classifyBranches({ cwd, defaultBranch, prIndex }) {
|
|
173
|
+
const baseRef = resolveBaseRef(defaultBranch, cwd);
|
|
174
|
+
return listLocalBranches(cwd)
|
|
175
|
+
.filter((b) => b.name !== defaultBranch)
|
|
176
|
+
.map((b) => classifyBranch(b, { baseRef, cwd, pr: prIndex?.get(b.name) ?? null }));
|
|
177
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gh.js — como o `gh` é invocado, num lugar só.
|
|
3
|
+
*
|
|
4
|
+
* Extraído de `finish.js`, onde nasceu, porque o `pr-index.js` chamava `'gh'`
|
|
5
|
+
* cru: o caminho de SUCESSO do índice era intestável, já que não havia por
|
|
6
|
+
* onde injetar um stub. Duas formas de chamar o mesmo binário também são duas
|
|
7
|
+
* formas de discordar sobre qual binário é.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Resolve a invocação do `gh`. Por padrão, o `gh` do PATH. Sobrescrevível via
|
|
12
|
+
* MORPH_GH_BIN: uma string simples nomeia outro binário (um wrapper corporativo,
|
|
13
|
+
* `hub`, …); um array JSON é um prefixo de argv COMPLETO — por exemplo
|
|
14
|
+
* `["/path/to/node","/path/to/gh-shim.js"]` — que é como os testes injetam um
|
|
15
|
+
* stub cross-platform que o execFileSync consegue spawnar (Node ≥20 recusa
|
|
16
|
+
* rodar shims `.cmd`/`.bat` sem `shell:true`, e `shell:true` reabriria o buraco
|
|
17
|
+
* de injeção de argumento que estes caminhos evitam).
|
|
18
|
+
*
|
|
19
|
+
* @returns {{ bin: string, prefix: string[] }}
|
|
20
|
+
*/
|
|
21
|
+
export function resolveGh() {
|
|
22
|
+
const raw = process.env.MORPH_GH_BIN;
|
|
23
|
+
if (raw && raw.trim().startsWith('[')) {
|
|
24
|
+
try {
|
|
25
|
+
const arr = JSON.parse(raw);
|
|
26
|
+
if (Array.isArray(arr) && arr.length) return { bin: String(arr[0]), prefix: arr.slice(1).map(String) };
|
|
27
|
+
} catch { /* não é JSON — trata como nome de binário abaixo */ }
|
|
28
|
+
}
|
|
29
|
+
return { bin: raw && raw.trim() ? raw.trim() : 'gh', prefix: [] };
|
|
30
|
+
}
|