@polymorphism-tech/morph-spec 8.31.0 → 8.33.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.
Files changed (39) hide show
  1. package/CLAUDE.md +6 -0
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +20 -2
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +1 -1
  6. package/docs/QUICKSTART.md +1 -1
  7. package/framework/CLAUDE.md +2 -1
  8. package/framework/MORPH.md +2 -2
  9. package/framework/docs/CLI.md +10 -5
  10. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  11. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  12. package/framework/skills/core/morph-apply/SKILL.md +3 -2
  13. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  14. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  15. package/framework/skills/core/morph-hotfix/SKILL.md +2 -2
  16. package/framework/skills/core/morph-implement/SKILL.md +1 -1
  17. package/framework/skills/core/morph-plan/SKILL.md +1 -1
  18. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  19. package/framework/skills/core/morph-review/SKILL.md +2 -2
  20. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  21. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  22. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  23. package/package.json +1 -1
  24. package/src/commands/project/branch.js +170 -0
  25. package/src/commands/project/doctor.js +137 -9
  26. package/src/commands/project/fleet.js +10 -3
  27. package/src/commands/project/worktree.js +73 -50
  28. package/src/commands/state/finish.js +41 -26
  29. package/src/commands/state/list.js +79 -6
  30. package/src/core/state/lifecycle-audit.js +127 -0
  31. package/src/lib/git/branch-classifier.js +177 -0
  32. package/src/lib/git/gh.js +30 -0
  33. package/src/lib/git/pending-prs.js +89 -0
  34. package/src/lib/git/pr-index.js +70 -0
  35. package/src/lib/tasks/test-runner.js +200 -38
  36. package/src/lib/validators/verify-runner.js +30 -4
  37. package/src/lib/worktree/fleet-scanner.js +51 -21
  38. package/src/lib/worktree/ignored-triage.js +197 -0
  39. package/src/lib/worktree/worktree-audit.js +200 -35
@@ -8,32 +8,46 @@
8
8
  * .sln) → "dotnet test <proj> --nologo -v q" for each match
9
9
  * 4. package.json present AND has scripts.test → "npm test"
10
10
  * 5. null (no test suite detected)
11
+ *
12
+ * Step 3 deliberately does NOT see linked git worktrees. A worktree is another
13
+ * feature's checkout of the SAME repo: its `tests/X.Tests.csproj` is a copy of
14
+ * the one already matched at the root, so including it both doubles the wall
15
+ * clock and reports on code that is not the tree under verification. In the
16
+ * field this produced a red `tests` node — "fail" at exactly the timeout, with
17
+ * the first run's own summary reading "Com falha: 0" — while nothing had
18
+ * failed. Two independent filters carry that rule (see collectDotnetTestProjects):
19
+ * the CONVENTION path (worktrees/, .worktrees/) and, for worktrees created
20
+ * anywhere else, the registry git itself keeps.
11
21
  */
12
22
 
13
- import { execSync } from 'child_process';
23
+ import { execSync, execFileSync } from 'child_process';
14
24
  import { existsSync, readFileSync, readdirSync } from 'fs';
15
- import { join } from 'path';
25
+ import { join, resolve, relative, isAbsolute } from 'path';
16
26
  import { globSync } from 'glob';
17
27
 
28
+ import { WORKTREES_DIR, LEGACY_WORKTREES_DIR } from '../../commands/project/worktree.js';
29
+
18
30
  /**
19
- * Resolve the test command for a project.
31
+ * Timeout budget granted to ONE test project. The chained `a && b` command is
32
+ * given this much per link: a fixed cap made every multi-project chain born
33
+ * condemned — it timed out for being long, not for being broken.
34
+ */
35
+ export const DEFAULT_TEST_TIMEOUT_PER_PROJECT = 120_000;
36
+
37
+ /**
38
+ * Resolve the test command for a project, together with how many test projects
39
+ * it chains — the timeout budget is derived from that count.
20
40
  *
21
41
  * @param {string} [projectPath] - Project root directory (defaults to '.')
22
- * @returns {string | null} The test command to run, or null if none found
42
+ * @returns {{ command: string | null, projectCount: number, source: string }}
43
+ * `source` is one of 'config' | 'dotnet-root' | 'dotnet-scan' | 'npm' | 'none'.
23
44
  */
24
- export function resolveTestCommand(projectPath = '.') {
45
+ export function resolveTestPlan(projectPath = '.') {
25
46
  // 1. Read from .morph/config/config.json
26
- const configPath = join(projectPath, '.morph', 'config', 'config.json');
27
- if (existsSync(configPath)) {
28
- try {
29
- const config = JSON.parse(readFileSync(configPath, 'utf8'));
30
- const cmd = config?.project?.testCommand;
31
- if (cmd && typeof cmd === 'string') {
32
- return cmd;
33
- }
34
- } catch {
35
- // Malformed config — fall through to detection
36
- }
47
+ const config = readConfig(projectPath);
48
+ const configured = config?.project?.testCommand;
49
+ if (configured && typeof configured === 'string') {
50
+ return { command: configured, projectCount: 1, source: 'config' };
37
51
  }
38
52
 
39
53
  // 2. Detect .NET project (sln/csproj at root) → dotnet test
@@ -43,7 +57,7 @@ export function resolveTestCommand(projectPath = '.') {
43
57
  (entry) => entry.endsWith('.sln') || entry.endsWith('.csproj')
44
58
  );
45
59
  if (hasDotnetProject) {
46
- return 'dotnet test';
60
+ return { command: 'dotnet test', projectCount: 1, source: 'dotnet-root' };
47
61
  }
48
62
  } catch {
49
63
  // Unreadable project directory — fall through to next detection
@@ -53,19 +67,13 @@ export function resolveTestCommand(projectPath = '.') {
53
67
  // (e.g. tests/MyApp.Tests/MyApp.Tests.csproj), common in monorepos that
54
68
  // have no root .sln. Run each matched project explicitly rather than a
55
69
  // bare `dotnet test` (which finds nothing to build at the root).
56
- try {
57
- const testProjects = globSync('**/*Tests.csproj', {
58
- cwd: projectPath,
59
- ignore: ['**/bin/**', '**/obj/**', '**/node_modules/**'],
60
- });
61
- if (testProjects.length > 0) {
62
- return testProjects
63
- .sort()
64
- .map((p) => `dotnet test "${p}" --nologo -v q`)
65
- .join(' && ');
66
- }
67
- } catch {
68
- // Glob failure — fall through to next detection
70
+ const testProjects = collectDotnetTestProjects(projectPath);
71
+ if (testProjects.length > 0) {
72
+ return {
73
+ command: testProjects.map((p) => `dotnet test "${p}" --nologo -v q`).join(' && '),
74
+ projectCount: testProjects.length,
75
+ source: 'dotnet-scan',
76
+ };
69
77
  }
70
78
 
71
79
  // 4. package.json present AND has scripts.test → npm test
@@ -74,7 +82,7 @@ export function resolveTestCommand(projectPath = '.') {
74
82
  try {
75
83
  const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
76
84
  if (pkg?.scripts?.test) {
77
- return 'npm test';
85
+ return { command: 'npm test', projectCount: 1, source: 'npm' };
78
86
  }
79
87
  } catch {
80
88
  // Malformed package.json — fall through to null
@@ -82,43 +90,197 @@ export function resolveTestCommand(projectPath = '.') {
82
90
  }
83
91
 
84
92
  // 5. Nothing found
85
- return null;
93
+ return { command: null, projectCount: 0, source: 'none' };
94
+ }
95
+
96
+ /**
97
+ * Resolve the test command for a project.
98
+ *
99
+ * @param {string} [projectPath] - Project root directory (defaults to '.')
100
+ * @returns {string | null} The test command to run, or null if none found
101
+ */
102
+ export function resolveTestCommand(projectPath = '.') {
103
+ return resolveTestPlan(projectPath).command;
104
+ }
105
+
106
+ /**
107
+ * Find the *Tests.csproj files that belong to THIS tree — never to a linked
108
+ * worktree of the same repo.
109
+ *
110
+ * Two filters, because one is not enough:
111
+ * - the glob ignore covers worktrees created by convention (`worktrees/{f}`
112
+ * and the legacy `.worktrees/{f}`), which is where `morph-spec worktree
113
+ * setup` puts them;
114
+ * - `git worktree list --porcelain` covers every OTHER location, including a
115
+ * worktree someone created by hand outside the convention. Fail-open: no
116
+ * git, no answer, no filter — the convention filter still stands.
117
+ *
118
+ * @param {string} projectPath
119
+ * @returns {string[]} Project-relative csproj paths, sorted
120
+ */
121
+ function collectDotnetTestProjects(projectPath) {
122
+ let matches;
123
+ try {
124
+ matches = globSync('**/*Tests.csproj', {
125
+ cwd: projectPath,
126
+ ignore: [
127
+ '**/bin/**',
128
+ '**/obj/**',
129
+ '**/node_modules/**',
130
+ `${WORKTREES_DIR}/**`,
131
+ `${LEGACY_WORKTREES_DIR}/**`,
132
+ ],
133
+ });
134
+ } catch {
135
+ // Glob failure — caller falls through to the next detection step
136
+ return [];
137
+ }
138
+
139
+ const linked = listLinkedWorktreePaths(projectPath);
140
+ if (linked.length > 0) {
141
+ matches = matches.filter(
142
+ (p) => !linked.some((wt) => isInside(resolve(projectPath, p), wt))
143
+ );
144
+ }
145
+
146
+ return matches.sort();
147
+ }
148
+
149
+ /**
150
+ * Absolute paths of the git worktrees linked to this repo, excluding the tree
151
+ * being verified. Fail-open — a repo without git (or a git that errors) yields
152
+ * an empty list, and discovery proceeds on the convention filter alone.
153
+ *
154
+ * @param {string} projectPath
155
+ * @returns {string[]}
156
+ */
157
+ function listLinkedWorktreePaths(projectPath) {
158
+ let out;
159
+ try {
160
+ out = execFileSync('git', ['worktree', 'list', '--porcelain'], {
161
+ cwd: projectPath,
162
+ stdio: 'pipe',
163
+ encoding: 'utf8',
164
+ windowsHide: true,
165
+ });
166
+ } catch {
167
+ return [];
168
+ }
169
+
170
+ const self = resolve(projectPath);
171
+ return out
172
+ .split('\n')
173
+ .filter((line) => line.startsWith('worktree '))
174
+ .map((line) => resolve(line.slice('worktree '.length).trim()))
175
+ .filter((p) => p && p !== self);
176
+ }
177
+
178
+ /** True when `child` is `parent` or lives under it. */
179
+ function isInside(child, parent) {
180
+ const rel = relative(parent, child);
181
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
182
+ }
183
+
184
+ function readConfig(projectPath) {
185
+ const configPath = join(projectPath, '.morph', 'config', 'config.json');
186
+ if (!existsSync(configPath)) return null;
187
+ try {
188
+ return JSON.parse(readFileSync(configPath, 'utf8'));
189
+ } catch {
190
+ // Malformed config — treated as absent by every caller
191
+ return null;
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Resolve the timeout budget for the test node.
197
+ *
198
+ * Precedence: explicit override (`morph-spec verify --timeout`) >
199
+ * `config.project.testTimeoutMs` > default scaled by the number of chained
200
+ * test projects.
201
+ *
202
+ * @param {string} projectPath
203
+ * @param {number} projectCount - How many test projects the command chains
204
+ * @param {number} [override] - Explicit override in ms, if the caller has one
205
+ * @returns {number} Timeout in ms
206
+ */
207
+ export function resolveTestTimeout(projectPath, projectCount, override) {
208
+ if (Number.isFinite(override) && override > 0) return override;
209
+
210
+ const configured = readConfig(projectPath)?.project?.testTimeoutMs;
211
+ if (Number.isFinite(configured) && configured > 0) return configured;
212
+
213
+ return DEFAULT_TEST_TIMEOUT_PER_PROJECT * Math.max(1, projectCount);
214
+ }
215
+
216
+ /**
217
+ * Count the test runs that reported a summary before the command was cut off.
218
+ * Heuristic by construction (the summary line is localized), and used only to
219
+ * name WHICH link of the chain was still running when the clock ran out.
220
+ */
221
+ function countCompletedRuns(output) {
222
+ const matches = (output || '').match(
223
+ /(?:^|\n)\s*(?:Aprovado|Passed|Failed|Reprovado|Com falha|Erro)!/gi
224
+ );
225
+ return matches ? matches.length : 0;
86
226
  }
87
227
 
88
228
  /**
89
229
  * Run the project's test suite.
90
230
  *
91
231
  * @param {string} [projectPath] - Project root directory (defaults to '.')
92
- * @param {object} [opts] - Optional overrides for testing
232
+ * @param {object} [opts]
233
+ * @param {number} [opts.timeout] - Explicit timeout in ms (highest precedence)
93
234
  * @param {Function} [opts._execSync] - Override execSync for unit testing
94
- * @returns {{ passed?: boolean, output?: string, exitCode?: number, skipped?: boolean, reason?: string }}
235
+ * @returns {{ passed?: boolean, output?: string, exitCode?: number, skipped?: boolean, reason?: string, timedOut?: boolean, timeoutMs?: number, message?: string }}
95
236
  */
96
237
  export function runTestSuite(projectPath = '.', opts = {}) {
97
- const cmd = resolveTestCommand(projectPath);
238
+ const { command: cmd, projectCount } = resolveTestPlan(projectPath);
98
239
 
99
240
  if (!cmd) {
100
241
  return { skipped: true, reason: 'No testCommand configured and no known project file detected' };
101
242
  }
102
243
 
103
244
  const exec = opts._execSync ?? execSync;
245
+ const timeout = resolveTestTimeout(projectPath, projectCount, opts.timeout);
104
246
 
105
247
  try {
106
248
  const output = exec(cmd, {
107
249
  cwd: projectPath,
108
- timeout: 120_000,
250
+ timeout,
109
251
  encoding: 'utf8',
110
252
  stdio: 'pipe'
111
253
  });
112
- return { passed: true, output: output ?? '', exitCode: 0 };
254
+ return { passed: true, output: output ?? '', exitCode: 0, timeoutMs: timeout };
113
255
  } catch (err) {
114
256
  const output = (err.stdout || '') + (err.stderr || '');
257
+
258
+ // A timeout is NOT a red suite: the command was interrupted, so no verdict
259
+ // exists. Saying so by name is the whole point — the field diagnosis of
260
+ // this cost a reading of durationMs against a truncated log.
261
+ if (err.code === 'ETIMEDOUT' || err.killed === true) {
262
+ const total = Math.max(1, projectCount);
263
+ const at = Math.min(countCompletedRuns(output) + 1, total);
264
+ const where = total > 1 ? ` na execução ${at} de ${total}` : '';
265
+ return {
266
+ passed: false,
267
+ timedOut: true,
268
+ timeoutMs: timeout,
269
+ buildFailed: false,
270
+ message: `timeout após ${timeout} ms${where} — nenhum teste falhou, o comando foi interrompido`,
271
+ output,
272
+ exitCode: err.status ?? null,
273
+ };
274
+ }
275
+
115
276
  const BUILD_FAIL = /Build FAILED|error MSB\d+|error CS\d+|Could not find.*\.csproj/i;
116
277
  const buildFailed = BUILD_FAIL.test(output);
117
278
  return {
118
279
  passed: false,
119
280
  buildFailed,
120
281
  output,
121
- exitCode: err.status ?? 1
282
+ exitCode: err.status ?? 1,
283
+ timeoutMs: timeout,
122
284
  };
123
285
  }
124
286
  }
@@ -48,7 +48,8 @@ const OUTPUT_TAIL_LINES = 20;
48
48
  * @param {string|null} [taskId] - Task ID for task-scoped verify, or null/undefined for feature scope
49
49
  * @param {Object} [options]
50
50
  * @param {string} [options.rubric] - Force a rubric id (highest precedence)
51
- * @param {number} [options.timeout] - Build timeout in ms (default 120000)
51
+ * @param {number} [options.timeout] - Timeout in ms for the build AND tests nodes
52
+ * (default: 120000 for build; for tests, see resolveTestTimeout())
52
53
  * @param {boolean} [options.skipTests] - Skip the tests node
53
54
  * @param {boolean} [options.skipBuild] - Skip the build node
54
55
  * @param {boolean} [options.skipE2e] - Skip the e2e node
@@ -86,7 +87,18 @@ export async function runVerify(projectPath, featureName, taskId, options = {})
86
87
  appendBuildFeedback(feedback, nodes.build);
87
88
 
88
89
  // 2. tests — skipped entirely (no invocation) when build already failed.
89
- nodes.tests = await runTestsNode({ projectPath, skipTests, buildNode: nodes.build, _runTestSuite });
90
+ // The tests node gets the SAME --timeout the user typed, but only when they
91
+ // typed one: `timeout` above is defaulted for the build, and passing that
92
+ // default down would silently outrank config.project.testTimeoutMs and the
93
+ // per-project scaling (see resolveTestTimeout()).
94
+ const explicitTimeout = Number.isFinite(options.timeout) ? options.timeout : undefined;
95
+ nodes.tests = await runTestsNode({
96
+ projectPath,
97
+ skipTests,
98
+ buildNode: nodes.build,
99
+ timeout: explicitTimeout,
100
+ _runTestSuite,
101
+ });
90
102
  appendTestsFeedback(feedback, nodes.tests);
91
103
 
92
104
  // 3. validators — ALWAYS runs, even on a red build (free static feedback).
@@ -174,7 +186,7 @@ function appendBuildFeedback(feedback, buildNode) {
174
186
  // Tests node
175
187
  // ============================================================================
176
188
 
177
- async function runTestsNode({ projectPath, skipTests, buildNode, _runTestSuite }) {
189
+ async function runTestsNode({ projectPath, skipTests, buildNode, timeout, _runTestSuite }) {
178
190
  const command = resolveTestCommand(projectPath);
179
191
 
180
192
  if (skipTests) {
@@ -192,7 +204,7 @@ async function runTestsNode({ projectPath, skipTests, buildNode, _runTestSuite }
192
204
  }
193
205
 
194
206
  const start = Date.now();
195
- const result = await _runTestSuite(projectPath);
207
+ const result = await _runTestSuite(projectPath, { timeout });
196
208
  const durationMs = Date.now() - start;
197
209
 
198
210
  if (result.skipped) {
@@ -205,6 +217,20 @@ async function runTestsNode({ projectPath, skipTests, buildNode, _runTestSuite }
205
217
  };
206
218
  }
207
219
 
220
+ // A timeout has no verdict to report: the tail of a truncated log reads like
221
+ // a passing run cut mid-sentence. Lead with what actually happened.
222
+ if (result.timedOut) {
223
+ const tail = tailLines(result.output, OUTPUT_TAIL_LINES);
224
+ return {
225
+ status: 'fail',
226
+ command,
227
+ durationMs,
228
+ timedOut: true,
229
+ reason: result.message,
230
+ output: tail ? `${result.message}\n${tail}` : result.message,
231
+ };
232
+ }
233
+
208
234
  return {
209
235
  status: result.passed ? 'pass' : 'fail',
210
236
  command,
@@ -28,6 +28,8 @@ import {
28
28
  pendingGatesFrom,
29
29
  } from '../../../framework/hooks/shared/state-reader.js';
30
30
  import { listClaims } from '../../../framework/hooks/shared/claim-registry.js';
31
+ import { buildPrIndex } from '../git/pr-index.js';
32
+ import { pendingPrFeatures } from '../git/pending-prs.js';
31
33
 
32
34
  function countFrom(raw) {
33
35
  const data = JSON.parse(raw);
@@ -133,32 +135,18 @@ function remoteHeads(root) {
133
135
  /**
134
136
  * One `gh pr list` for the whole scan → Map<headRefName, {number, state}>.
135
137
  *
136
- * Gated on the origin URL actually pointing at github.com — `gh` against any
137
- * other host is a guaranteed error, and paying its start-up cost to learn that
138
- * on every `fleet` call is pure latency. Fail-open: no `gh`, not authenticated,
139
- * or a timeout all yield `null`, which just leaves the PR half of the column
140
- * empty while `pushed` still works.
138
+ * Delegates to the shared `buildPrIndex` so `fleet`, `branch prune` and
139
+ * `doctor --worktrees` cannot answer "does this branch have a PR" differently
140
+ * — the destructive two read it to decide what NOT to delete.
141
+ *
142
+ * Fail-open: `null` (no `gh`, not authenticated, not GitHub, timeout) just
143
+ * leaves the PR half of the column empty while `pushed` still works.
141
144
  *
142
145
  * @param {string} root
143
146
  * @returns {Map<string,{number:number,state:string}>|null}
144
147
  */
145
148
  function remotePrs(root) {
146
- try {
147
- const url = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: root, stdio: 'pipe' }).toString();
148
- if (!/github\.com/i.test(url)) return null;
149
- const out = execFileSync('gh', ['pr', 'list', '--state', 'all', '--limit', '100', '--json', 'number,state,headRefName'], {
150
- cwd: root, stdio: 'pipe', timeout: 8000,
151
- }).toString();
152
- const map = new Map();
153
- for (const pr of JSON.parse(out)) {
154
- // First wins: `gh` lists newest-first, so an OPEN PR shadows an older
155
- // closed one on the same branch.
156
- if (pr?.headRefName && !map.has(pr.headRefName)) map.set(pr.headRefName, { number: pr.number, state: pr.state });
157
- }
158
- return map;
159
- } catch {
160
- return null;
161
- }
149
+ return buildPrIndex(root, { limit: 100 });
162
150
  }
163
151
 
164
152
  /**
@@ -297,5 +285,47 @@ export function scanFleet(cwd = process.cwd()) {
297
285
  }
298
286
  } catch { /* fail-open */ }
299
287
 
288
+ // Features that exist ONLY as an open PR: no registered worktree, no
289
+ // `.morph/features/*` folder in this tree. This is exactly what
290
+ // `finish --pr` leaves behind — and without this pass `fleet` would say
291
+ // "nothing in flight" about work that is under review on GitHub.
292
+ //
293
+ // `prs` was attempted above whenever `heads` is truthy (`remotePrs` runs —
294
+ // see `const prs = heads ? remotePrs(root) : null;` — regardless of
295
+ // whether that `gh` call itself succeeded). Gate on the SAME condition
296
+ // `remotePrs` used, not on `prs !== null`: `remotePrs`/`buildPrIndex` can
297
+ // fail (timeout, not authenticated, malformed JSON) and legitimately
298
+ // return `null` even after really invoking `gh`. Passing that `null`
299
+ // through lets `pendingPrFeatures` short-circuit on its own documented
300
+ // contract (explicit `null` index = known unknown, never a fallback build)
301
+ // instead of retrying `gh` a second time in the same scan. Only when
302
+ // `remotePrs` was never attempted at all (`heads` falsy) does
303
+ // `pendingPrFeatures` get no index, so it makes the first (and only) `gh`
304
+ // call itself.
305
+ try {
306
+ const seen = new Set(entries.map((e) => e.feature));
307
+ const pendOpts = heads ? { index: prs } : {};
308
+ for (const p of pendingPrFeatures(root, pendOpts) || []) {
309
+ if (seen.has(p.feature)) continue;
310
+ entries.push({
311
+ feature: p.feature,
312
+ workType: null,
313
+ phase: 'aguardando-merge',
314
+ status: 'in_progress',
315
+ pendingGates: [],
316
+ location: 'pr-pending',
317
+ path: null,
318
+ branch: p.branch,
319
+ tasks: null,
320
+ env: null,
321
+ handoff: { exists: false, generatedAt: null },
322
+ lastCommit: null,
323
+ dirty: null,
324
+ remote: { branch: p.branch, pushed: true, sha: null, pr: { number: p.number, state: p.state } },
325
+ claim: null,
326
+ });
327
+ }
328
+ } catch { /* fail-open — a pendency not listed must never take down the scan */ }
329
+
300
330
  return { root, entries };
301
331
  }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * ignored-triage.js — decide, per gitignored leftover, whether removing a
3
+ * worktree would DESTROY something or merely delete a copy.
4
+ *
5
+ * WHY THIS EXISTS: `git worktree remove` deletes gitignored files outright and
6
+ * the clean-check that gates it (`git status --porcelain`) omits them by
7
+ * design, so a user's `.env` dies silently while the command reports success.
8
+ * The first fix for that refused the removal whenever ANY unrecognised
9
+ * gitignored path was present — correct, but blind: in the observed session a
10
+ * worktree carried `.env` and `.env.e2e` that were BYTE-IDENTICAL to the
11
+ * primary root's, alongside a `keys/key-*.xml` that existed nowhere else. The
12
+ * refusal treated them alike, and the only escape it offered
13
+ * (`--discard-ignored`) destroyed the unique one together with the replicas.
14
+ *
15
+ * The triage below replaces "recognised vs not" with the question that actually
16
+ * decides whether bytes are lost:
17
+ *
18
+ * REPLICABLE — the primary root has the same relative path with identical
19
+ * content. Deleting the worktree's copy loses nothing; the
20
+ * original stays where it always was.
21
+ * DISPOSABLE — a well-known build artifact (`bin/`, `obj/`, `node_modules/`,
22
+ * `__pycache__/`, `test-results/`, `.auth/`, `*.tsbuildinfo`).
23
+ * Reproducible from source, which is a STRONGER guarantee than
24
+ * backed up.
25
+ * EXCLUSIVE — everything else: no counterpart in the root, or a counterpart
26
+ * whose content differs. These are the only bytes at stake, and
27
+ * the caller copies them to `finish-backup` before proceeding.
28
+ *
29
+ * Fail-CLOSED on every uncertainty: an unreadable file, a hash that cannot be
30
+ * computed, a directory too large to compare, a counterpart that is a link
31
+ * rather than a real file — all classify as EXCLUSIVE. Being wrong in that
32
+ * direction costs a backup copy; being wrong the other way costs the file.
33
+ */
34
+
35
+ import { createHash } from 'crypto';
36
+ import { existsSync, lstatSync, readdirSync, readFileSync } from 'fs';
37
+ import { join } from 'path';
38
+
39
+ /**
40
+ * Well-known, regenerable build artifacts. Deliberately narrow — do NOT add
41
+ * speculative patterns (`dist/`, `.next/`) that a project might legitimately
42
+ * hand-edit; anything unlisted still reaches the EXCLUSIVE bucket, where it is
43
+ * backed up rather than assumed disposable.
44
+ *
45
+ * `.auth/` (Playwright storage state) belongs here for a reason worth stating:
46
+ * it holds a captured session, so it LOOKS like precious local state — but it
47
+ * is regenerated by the auth setup project on the next run, and it expires on
48
+ * its own. Keeping it out of the backup avoids copying credentials around.
49
+ */
50
+ const DISPOSABLE_DIR_NAMES = new Set([
51
+ 'bin', 'obj', 'node_modules', '__pycache__', 'test-results', '.auth',
52
+ ]);
53
+ const DISPOSABLE_FILE_RE = /\.tsbuildinfo$/;
54
+
55
+ /**
56
+ * Comparing a directory recursively is only worth doing while it stays small:
57
+ * the point is to spare `.env`-sized replicas from a needless refusal, not to
58
+ * checksum a data set. Past either cap the answer becomes EXCLUSIVE (the safe
59
+ * direction), and the caller backs it up.
60
+ */
61
+ const REPLICABLE_MAX_FILES = 500;
62
+ const REPLICABLE_MAX_BYTES = 8 * 1024 * 1024;
63
+
64
+ /**
65
+ * @param {string} p - forward-slash relative path
66
+ * @returns {boolean}
67
+ */
68
+ export function isDisposableBuildArtifact(p) {
69
+ if (DISPOSABLE_FILE_RE.test(p)) return true;
70
+ return p.split('/').filter(Boolean).some((seg) => DISPOSABLE_DIR_NAMES.has(seg));
71
+ }
72
+
73
+ function sha256OfFile(path) {
74
+ try {
75
+ return createHash('sha256').update(readFileSync(path)).digest('hex');
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Real file (not a link, not a directory)? A reparse point must never be read
83
+ * THROUGH — following one is the exact failure mode the whole teardown path
84
+ * exists to prevent — so a link on either side makes the pair incomparable.
85
+ */
86
+ function isRealFile(path) {
87
+ try {
88
+ return lstatSync(path).isFile();
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+
94
+ function isRealDir(path) {
95
+ try {
96
+ return lstatSync(path).isDirectory();
97
+ } catch {
98
+ return false;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Every real file under `dir`, relative to it. Returns null when the walk hits
104
+ * a link, an unreadable entry, or either cap — "cannot enumerate" and "too big
105
+ * to compare" both mean "do not claim this is replicable".
106
+ */
107
+ function walkFiles(dir, budget) {
108
+ const out = [];
109
+ const stack = [''];
110
+ while (stack.length) {
111
+ const rel = stack.pop();
112
+ let entries;
113
+ try {
114
+ entries = readdirSync(join(dir, rel), { withFileTypes: true });
115
+ } catch {
116
+ return null;
117
+ }
118
+ for (const e of entries) {
119
+ const childRel = rel ? `${rel}/${e.name}` : e.name;
120
+ const childAbs = join(dir, childRel);
121
+ if (e.isSymbolicLink()) return null; // never compare through a link
122
+ if (e.isDirectory()) { stack.push(childRel); continue; }
123
+ if (!e.isFile()) return null; // socket/fifo/unknown — incomparable
124
+ let size;
125
+ try { size = lstatSync(childAbs).size; } catch { return null; }
126
+ budget.files += 1;
127
+ budget.bytes += size;
128
+ if (budget.files > REPLICABLE_MAX_FILES || budget.bytes > REPLICABLE_MAX_BYTES) return null;
129
+ out.push(childRel);
130
+ }
131
+ }
132
+ return out;
133
+ }
134
+
135
+ /**
136
+ * Does `root/rel` hold exactly the same content as `worktree/rel`?
137
+ *
138
+ * Files compare by SHA-256; directories compare by having the SAME set of
139
+ * files, each with an identical hash — a root copy that is merely a SUPERSET
140
+ * is not a replica of the worktree's, because the worktree's own extra files
141
+ * would still be lost.
142
+ *
143
+ * @returns {boolean} false on ANY uncertainty
144
+ */
145
+ export function isReplicatedInRoot(worktreePath, rootPath, rel) {
146
+ const src = join(worktreePath, rel);
147
+ const dst = join(rootPath, rel);
148
+ if (!existsSync(src) || !existsSync(dst)) return false;
149
+
150
+ if (isRealFile(src)) {
151
+ if (!isRealFile(dst)) return false;
152
+ const a = sha256OfFile(src);
153
+ const b = sha256OfFile(dst);
154
+ return a !== null && a === b;
155
+ }
156
+
157
+ if (isRealDir(src)) {
158
+ if (!isRealDir(dst)) return false;
159
+ const srcFiles = walkFiles(src, { files: 0, bytes: 0 });
160
+ if (srcFiles === null) return false;
161
+ const dstFiles = walkFiles(dst, { files: 0, bytes: 0 });
162
+ if (dstFiles === null) return false;
163
+ if (srcFiles.length !== dstFiles.length) return false;
164
+ const dstSet = new Set(dstFiles);
165
+ for (const f of srcFiles) {
166
+ if (!dstSet.has(f)) return false;
167
+ const a = sha256OfFile(join(src, f));
168
+ const b = sha256OfFile(join(dst, f));
169
+ if (a === null || a !== b) return false;
170
+ }
171
+ return true;
172
+ }
173
+
174
+ return false; // a link, or something that is neither — incomparable
175
+ }
176
+
177
+ /**
178
+ * Split gitignored worktree paths into the three buckets above.
179
+ *
180
+ * ORDER IS DELIBERATE: disposable is checked FIRST, so a `bin/` that happens to
181
+ * be byte-identical to the root's is discarded rather than hashed, and a
182
+ * `node_modules` never enters the comparison budget at all.
183
+ *
184
+ * @param {{ worktreePath: string, rootPath: string, paths: string[] }} args
185
+ * @returns {{ replicable: string[], disposable: string[], exclusive: string[] }}
186
+ */
187
+ export function triageIgnoredPaths({ worktreePath, rootPath, paths }) {
188
+ const replicable = [];
189
+ const disposable = [];
190
+ const exclusive = [];
191
+ for (const rel of paths) {
192
+ if (isDisposableBuildArtifact(rel)) { disposable.push(rel); continue; }
193
+ if (isReplicatedInRoot(worktreePath, rootPath, rel)) { replicable.push(rel); continue; }
194
+ exclusive.push(rel);
195
+ }
196
+ return { replicable, disposable, exclusive };
197
+ }