@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.
- package/CLAUDE.md +6 -0
- package/README.md +2 -2
- package/bin/morph-spec.js +20 -2
- 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 +10 -5
- 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/tasks/test-runner.js +200 -38
- package/src/lib/validators/verify-runner.js +30 -4
- 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
|
@@ -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
|
-
*
|
|
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
|
|
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
|
|
45
|
+
export function resolveTestPlan(projectPath = '.') {
|
|
25
46
|
// 1. Read from .morph/config/config.json
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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]
|
|
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 =
|
|
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
|
|
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] -
|
|
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
|
-
|
|
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
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
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
|
-
|
|
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
|
+
}
|