@polymorphism-tech/morph-spec 8.42.0 → 8.44.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 +15 -5
- package/README.md +2 -2
- package/bin/morph-spec.js +48 -2
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +5 -4
- package/framework/MORPH.md +10 -9
- package/framework/agents.json +1 -1
- package/framework/docs/CLI.md +22 -10
- package/framework/evals/proof-rubrics/mutation-proof.md +43 -13
- package/framework/hooks/claude-code/core/heavy-gate.js +72 -0
- package/framework/hooks/claude-code/core/heavy-release.js +34 -0
- package/framework/hooks/claude-code/core/worktree-context.js +21 -20
- package/framework/hooks/claude-code/core/worktree-create.js +4 -4
- package/framework/hooks/claude-code/core/worktree-env-inject.js +1 -4
- package/framework/hooks/shared/agent-reap.js +89 -0
- package/framework/hooks/shared/e2e-check.js +3 -1
- package/framework/hooks/shared/graph-refresh.js +1 -9
- package/framework/hooks/shared/heavy-command.js +128 -0
- package/framework/hooks/shared/heavy-config.js +65 -0
- package/framework/hooks/shared/heavy-holders.js +85 -0
- package/framework/hooks/shared/heavy-slots.js +207 -0
- package/framework/hooks/shared/process-alive.js +8 -0
- package/framework/hooks/shared/worktree-root.js +93 -0
- package/framework/rules/ai-agents-standards.md +1 -0
- package/framework/schemas/config.schema.json +80 -0
- 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 +8 -3
- 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 +1 -1
- package/framework/skills/core/morph-implement/SKILL.md +9 -4
- package/framework/skills/core/morph-machine/SKILL.md +46 -0
- package/framework/skills/core/morph-media/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 +45 -16
- 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/framework/standards/PROVENANCE.json +10 -10
- package/framework/standards/STANDARDS.json +45 -1
- package/framework/standards/ai-agents/tool-adapters.md +226 -0
- package/framework/standards/frontend/design-system/ai-video-generation.md +2 -0
- package/framework/standards/frontend/scroll-driven/frame-scrub.md +11 -1
- package/framework/standards/infrastructure/docker/local-compose-isolation.md +11 -1
- package/package.json +1 -1
- package/src/commands/e2e/e2e.js +27 -8
- package/src/commands/mcp/mcp-setup.js +10 -9
- package/src/commands/project/doctor-procs.js +162 -0
- package/src/commands/project/doctor.js +42 -4
- package/src/commands/project/heavy.js +61 -0
- package/src/commands/project/init.js +1 -1
- package/src/commands/project/update.js +4 -0
- package/src/commands/project/worktree-reap.js +34 -0
- package/src/commands/project/worktree.js +112 -43
- package/src/commands/state/approve.js +2 -1
- package/src/commands/state/finish.js +4 -0
- package/src/commands/state/gate-check.js +2 -1
- package/src/commands/validation/mutate.js +200 -0
- package/src/commands/validation/verify.js +39 -22
- package/src/dashboard/scanner/features-scanner.js +4 -4
- package/src/dashboard/scanner/project-api.js +4 -4
- package/src/lib/detectors/claude-config-detector.js +30 -1
- package/src/lib/e2e/compose-env.js +5 -2
- package/src/lib/git/evidence-staleness.js +39 -0
- package/src/lib/heavy/cleanup.js +70 -0
- package/src/lib/heavy/gate.js +96 -0
- package/src/lib/installers/mcp-installer.js +15 -2
- package/src/lib/mutation/adapters.js +90 -0
- package/src/lib/mutation/commands.js +60 -0
- package/src/lib/mutation/manifest.js +138 -0
- package/src/lib/mutation/plan.js +36 -0
- package/src/lib/mutation/report.js +66 -0
- package/src/lib/mutation/runner.js +351 -0
- package/src/lib/mutation/schemata.js +60 -0
- package/src/lib/mutation/snapshot.js +69 -0
- package/src/lib/mutation/subset.js +75 -0
- package/src/lib/mutation/trx.js +51 -0
- package/src/lib/procs/classify.js +91 -0
- package/src/lib/procs/list.js +124 -0
- package/src/lib/procs/mcp-config.js +103 -0
- package/src/lib/procs/prune.js +25 -0
- package/src/lib/procs/wslconfig.js +70 -0
- package/src/lib/projects/registry.js +6 -2
- package/src/lib/risk-detector.js +3 -3
- package/src/lib/tasks/eval-node.js +4 -34
- package/src/lib/tasks/linux-runner.js +30 -3
- package/src/lib/tasks/test-runner.js +1 -1
- package/src/lib/validators/contracts/contract-compliance-validator.js +4 -42
- package/src/lib/validators/verify-runner.js +26 -8
- package/src/lib/worktree/agent-reaper.js +247 -0
- package/src/lib/worktree/dirs.js +3 -32
- package/src/lib/worktree/env-allocator.js +35 -5
- package/src/lib/worktree/vscode-settings.js +113 -0
- package/src/lib/worktree/worktree-audit.js +49 -5
- package/src/lib/worktree/worktree-include.js +46 -0
- package/src/scripts/setup-infra.js +4 -0
- package/src/utils/hooks-installer.js +11 -4
|
@@ -47,6 +47,7 @@ import { runValidation } from './validation-runner.js';
|
|
|
47
47
|
import { loadRubric, aggregateScores } from './score-aggregator.js';
|
|
48
48
|
import { checkOutputsTraceability } from './traceability-check.js';
|
|
49
49
|
import { getAbsoluteOutputPath } from '../../core/paths/output-schema.js';
|
|
50
|
+
import { createHeavyGate } from '../heavy/gate.js';
|
|
50
51
|
|
|
51
52
|
/** Default build timeout — dotnet cold build is slow; 30s (the Stop-hook default) is too short. */
|
|
52
53
|
const DEFAULT_BUILD_TIMEOUT = 120_000;
|
|
@@ -105,9 +106,20 @@ export class VerifyUsageError extends Error {
|
|
|
105
106
|
* @param {Function} [options._runValidation] - Injectable override for runValidation (testing)
|
|
106
107
|
* @param {Function} [options._runE2eCheck] - Injectable override for runE2eCheck (testing)
|
|
107
108
|
* @param {Function} [options._checkOutputsTraceability] - Injectable override for checkOutputsTraceability (testing)
|
|
109
|
+
* @param {Object} [options._heavyGate] - Injectable heavy-slot gate (testing); default is one per run
|
|
108
110
|
* @returns {Promise<Object>} Aggregated verify result — see module docs for shape
|
|
111
|
+
* @throws {HeavyBusyError} when no heavy slot (or RAM) freed within `heavy.waitTimeoutSec`
|
|
109
112
|
*/
|
|
110
113
|
export async function runVerify(projectPath, featureName, taskId, options = {}) {
|
|
114
|
+
const gate = options._heavyGate ?? createHeavyGate({ projectPath, feature: featureName });
|
|
115
|
+
try {
|
|
116
|
+
return await runVerifyGated(projectPath, featureName, taskId, options, gate);
|
|
117
|
+
} finally {
|
|
118
|
+
gate.release();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function runVerifyGated(projectPath, featureName, taskId, options, gate) {
|
|
111
123
|
const {
|
|
112
124
|
rubric: rubricOverride,
|
|
113
125
|
timeout = DEFAULT_BUILD_TIMEOUT,
|
|
@@ -209,7 +221,7 @@ export async function runVerify(projectPath, featureName, taskId, options = {})
|
|
|
209
221
|
const feedback = [];
|
|
210
222
|
|
|
211
223
|
// 1. build
|
|
212
|
-
nodes.build = await runBuildNode({ projectPath, stack, paths, timeout, skipBuild, _runBuildCheck });
|
|
224
|
+
nodes.build = await runBuildNode({ projectPath, stack, paths, timeout, skipBuild, gate, _runBuildCheck });
|
|
213
225
|
appendBuildFeedback(feedback, nodes.build);
|
|
214
226
|
|
|
215
227
|
// 2. tests — skipped entirely (no invocation) when build already failed.
|
|
@@ -229,6 +241,7 @@ export async function runVerify(projectPath, featureName, taskId, options = {})
|
|
|
229
241
|
refreshCache,
|
|
230
242
|
linux,
|
|
231
243
|
linuxImage,
|
|
244
|
+
gate,
|
|
232
245
|
_runTestSuite,
|
|
233
246
|
_runLinuxSuite,
|
|
234
247
|
_exec,
|
|
@@ -255,6 +268,7 @@ export async function runVerify(projectPath, featureName, taskId, options = {})
|
|
|
255
268
|
skipE2e,
|
|
256
269
|
freshE2e,
|
|
257
270
|
buildNode: nodes.build,
|
|
271
|
+
gate,
|
|
258
272
|
_runE2eCheck,
|
|
259
273
|
});
|
|
260
274
|
appendE2eFeedback(feedback, nodes.e2e);
|
|
@@ -297,7 +311,7 @@ export async function runVerify(projectPath, featureName, taskId, options = {})
|
|
|
297
311
|
// Build node
|
|
298
312
|
// ============================================================================
|
|
299
313
|
|
|
300
|
-
async function runBuildNode({ projectPath, stack, paths = {}, timeout, skipBuild, _runBuildCheck }) {
|
|
314
|
+
async function runBuildNode({ projectPath, stack, paths = {}, timeout, skipBuild, gate, _runBuildCheck }) {
|
|
301
315
|
if (skipBuild) {
|
|
302
316
|
return { status: 'skip', command: null, durationMs: 0, reason: 'requested', output: '' };
|
|
303
317
|
}
|
|
@@ -309,6 +323,7 @@ async function runBuildNode({ projectPath, stack, paths = {}, timeout, skipBuild
|
|
|
309
323
|
}
|
|
310
324
|
const command = steps.map((s) => s.command).join(' && ');
|
|
311
325
|
|
|
326
|
+
gate.enter('build');
|
|
312
327
|
const start = Date.now();
|
|
313
328
|
const result = await _runBuildCheck(projectPath, stack, timeout, paths);
|
|
314
329
|
const durationMs = Date.now() - start;
|
|
@@ -352,7 +367,7 @@ function appendBuildFeedback(feedback, buildNode) {
|
|
|
352
367
|
*/
|
|
353
368
|
async function runTestsNode({
|
|
354
369
|
projectPath, testPlan, filter, skipTests, buildNode, timeout, taskId, refreshCache = false,
|
|
355
|
-
linux = false, linuxImage = null, _runTestSuite, _runLinuxSuite = runLinuxSuite, _exec,
|
|
370
|
+
linux = false, linuxImage = null, gate, _runTestSuite, _runLinuxSuite = runLinuxSuite, _exec,
|
|
356
371
|
}) {
|
|
357
372
|
const { nodes, source } = testPlan;
|
|
358
373
|
// Kept readable for whoever only prints the string — the same join the
|
|
@@ -372,7 +387,7 @@ async function runTestsNode({
|
|
|
372
387
|
|
|
373
388
|
if (linux) {
|
|
374
389
|
const { runs, environment } = await runNodesInLinux({
|
|
375
|
-
projectPath, nodes, filter, taskId, timeout, linuxImage, _runLinuxSuite, _exec,
|
|
390
|
+
projectPath, nodes, filter, taskId, timeout, linuxImage, gate, _runLinuxSuite, _exec,
|
|
376
391
|
});
|
|
377
392
|
return rollUpTestRuns(runs, { command, source, environment });
|
|
378
393
|
}
|
|
@@ -380,7 +395,7 @@ async function runTestsNode({
|
|
|
380
395
|
const runs = [];
|
|
381
396
|
for (const node of nodes) {
|
|
382
397
|
runs.push(await runOneTestNode({
|
|
383
|
-
projectPath, node, filter, taskId, timeout, refreshCache, _runTestSuite,
|
|
398
|
+
projectPath, node, filter, taskId, timeout, refreshCache, gate, _runTestSuite,
|
|
384
399
|
}));
|
|
385
400
|
}
|
|
386
401
|
|
|
@@ -403,7 +418,7 @@ async function runTestsNode({
|
|
|
403
418
|
* bind mount: see linux-runner.js. That is the whole reason this path exists.
|
|
404
419
|
*/
|
|
405
420
|
async function runNodesInLinux({
|
|
406
|
-
projectPath, nodes, filter, taskId, timeout, linuxImage, _runLinuxSuite, _exec,
|
|
421
|
+
projectPath, nodes, filter, taskId, timeout, linuxImage, gate, _runLinuxSuite, _exec,
|
|
407
422
|
}) {
|
|
408
423
|
const decided = new Map();
|
|
409
424
|
const toRun = [];
|
|
@@ -419,6 +434,7 @@ async function runNodesInLinux({
|
|
|
419
434
|
(sum, node) => sum + resolveTestTimeout(projectPath, node, timeout), 0
|
|
420
435
|
) || undefined;
|
|
421
436
|
|
|
437
|
+
if (toRun.length > 0) gate.enter('tests');
|
|
422
438
|
const suite = await _runLinuxSuite({
|
|
423
439
|
projectPath,
|
|
424
440
|
nodes: toRun,
|
|
@@ -458,7 +474,7 @@ async function runNodesInLinux({
|
|
|
458
474
|
* from the report is the defect this whole feature exists to close.
|
|
459
475
|
*/
|
|
460
476
|
async function runOneTestNode({
|
|
461
|
-
projectPath, node, filter, taskId, timeout, refreshCache = false, _runTestSuite,
|
|
477
|
+
projectPath, node, filter, taskId, timeout, refreshCache = false, gate, _runTestSuite,
|
|
462
478
|
}) {
|
|
463
479
|
const decided = preDecideRun({ node, filter, taskId, projectPath, refreshCache });
|
|
464
480
|
if (decided) return decided;
|
|
@@ -476,6 +492,7 @@ async function runOneTestNode({
|
|
|
476
492
|
// wrote nothing the old scenario count — and hiding an empty run behind it.
|
|
477
493
|
if (evalChannel) discardFile(evalChannel.summaryPath);
|
|
478
494
|
|
|
495
|
+
gate.enter('tests');
|
|
479
496
|
const start = Date.now();
|
|
480
497
|
const result = await _runTestSuite(projectPath, {
|
|
481
498
|
timeout, node, ...(evalChannel ? { env: evalChannel.env } : {}),
|
|
@@ -1027,7 +1044,7 @@ function appendValidatorsFeedback(feedback, validatorsNode) {
|
|
|
1027
1044
|
// E2E node
|
|
1028
1045
|
// ============================================================================
|
|
1029
1046
|
|
|
1030
|
-
async function runE2eNode({ projectPath, featureName, taskId, task = null, skipE2e, freshE2e, buildNode, _runE2eCheck }) {
|
|
1047
|
+
async function runE2eNode({ projectPath, featureName, taskId, task = null, skipE2e, freshE2e, buildNode, gate, _runE2eCheck }) {
|
|
1031
1048
|
if (buildNode.status === 'fail') {
|
|
1032
1049
|
return {
|
|
1033
1050
|
status: 'skip',
|
|
@@ -1056,6 +1073,7 @@ async function runE2eNode({ projectPath, featureName, taskId, task = null, skipE
|
|
|
1056
1073
|
env: buildComposeEnv({ cwd: projectPath, config, featureName, gitCommonDir }),
|
|
1057
1074
|
skipRequested: skipE2e,
|
|
1058
1075
|
forceFresh: freshE2e,
|
|
1076
|
+
beforeHeavy: () => gate.enter('e2e'),
|
|
1059
1077
|
// The stack stays up between verifies on purpose: cold-starting a .NET
|
|
1060
1078
|
// container per task is the difference between a usable loop and one
|
|
1061
1079
|
// nobody runs. `morph-spec e2e down` (and `finish`) tear it down.
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import {
|
|
3
|
+
closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, statSync, unlinkSync, writeFileSync,
|
|
4
|
+
} from 'node:fs';
|
|
5
|
+
import { basename, join, resolve } from 'node:path';
|
|
6
|
+
import {
|
|
7
|
+
cwdIsInsideWorktree, isGitRepo, parseWorktreeList, resolvePrimaryRoot, runWorktreeRemove,
|
|
8
|
+
sessionAnchoredInWorktree,
|
|
9
|
+
} from '../../commands/project/worktree.js';
|
|
10
|
+
import { containerDirs, isUnder } from '../../../framework/hooks/shared/worktree-root.js';
|
|
11
|
+
import { withoutLeftoverIgnore } from '../../../framework/hooks/shared/graph-freshness.js';
|
|
12
|
+
import { resolveGitCommonDir } from '../../../framework/hooks/shared/git-paths.js';
|
|
13
|
+
import { pidAlive } from '../../../framework/hooks/shared/process-alive.js';
|
|
14
|
+
import { readProjectConfig } from '../../../framework/hooks/shared/heavy-config.js';
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_GRACE_MINUTES = 30;
|
|
17
|
+
|
|
18
|
+
const AGENT_DIR = /^agent-([0-9a-f]+)$/;
|
|
19
|
+
const AGENT_BRANCH = /^morph-task\/agent-([0-9a-f]+)$/;
|
|
20
|
+
const AGENT_REF = /^refs\/heads\/morph-task\/agent-[0-9a-f]+$/;
|
|
21
|
+
const TAIL_BYTES = 64 * 1024;
|
|
22
|
+
const LOCK_MAX_AGE_MS = 15 * 60_000;
|
|
23
|
+
const SAMPLE = 5;
|
|
24
|
+
|
|
25
|
+
const norm = (p) => resolve(p).replace(/\\/g, '/');
|
|
26
|
+
const gitOut = (cwd, args, input) => execFileSync('git', args, {
|
|
27
|
+
cwd, stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf8', input,
|
|
28
|
+
});
|
|
29
|
+
const sample = (list) => `${list.slice(0, SAMPLE).join(', ')}${list.length > SAMPLE ? `, +${list.length - SAMPLE}` : ''}`;
|
|
30
|
+
|
|
31
|
+
export function resolveGraceMinutes(root, override) {
|
|
32
|
+
const valid = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0;
|
|
33
|
+
if (valid(override)) return override;
|
|
34
|
+
const configured = readProjectConfig(root)?.worktree?.reap?.graceMinutes;
|
|
35
|
+
return valid(configured) ? configured : DEFAULT_GRACE_MINUTES;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Commits reachable from `tip` and from no other branch, remote or tag; agent branches are left out
|
|
40
|
+
* of the comparison, and `--all` is not used (the agent's own HEAD would protect it). `null` = unmeasured.
|
|
41
|
+
*/
|
|
42
|
+
export function exclusiveCommitCount(root, tip) {
|
|
43
|
+
if (!tip) return null;
|
|
44
|
+
try {
|
|
45
|
+
const refs = gitOut(root, ['for-each-ref', '--format=%(refname)', 'refs/heads', 'refs/remotes', 'refs/tags'])
|
|
46
|
+
.split(/\r?\n/).filter((r) => r && !AGENT_REF.test(r));
|
|
47
|
+
const input = `${[tip, ...refs.map((r) => `^${r}`)].join('\n')}\n`;
|
|
48
|
+
const count = Number.parseInt(gitOut(root, ['rev-list', '--count', '--stdin'], input).trim(), 10);
|
|
49
|
+
return Number.isInteger(count) ? count : null;
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function tailOfEvents(file) {
|
|
56
|
+
const fd = openSync(file, 'r');
|
|
57
|
+
try {
|
|
58
|
+
const { size, mtimeMs } = fstatSync(fd);
|
|
59
|
+
const len = Math.min(size, TAIL_BYTES);
|
|
60
|
+
const buf = Buffer.alloc(len);
|
|
61
|
+
readSync(fd, buf, 0, len, size - len);
|
|
62
|
+
const lines = buf.toString('utf8').split(/\r?\n/).filter((l) => l.trim());
|
|
63
|
+
if (size > len) lines.shift();
|
|
64
|
+
return { last: lines.at(-1) ?? null, mtimeMs };
|
|
65
|
+
} finally {
|
|
66
|
+
closeSync(fd);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** `stopped` only when the tree's last event is this agent's own `AgentUsage`. */
|
|
71
|
+
export function readAgentStop(worktreePath, hex) {
|
|
72
|
+
const file = join(worktreePath, '.morph', 'logs', 'events.jsonl');
|
|
73
|
+
if (!existsSync(file)) return { status: 'unknown', detail: 'sem .morph/logs/events.jsonl na árvore' };
|
|
74
|
+
let tail;
|
|
75
|
+
try { tail = tailOfEvents(file); } catch { return { status: 'unknown', detail: 'events.jsonl ilegível' }; }
|
|
76
|
+
let event = null;
|
|
77
|
+
try { event = tail.last ? JSON.parse(tail.last) : null; } catch { event = null; }
|
|
78
|
+
if (!event || typeof event !== 'object') return { status: 'unknown', detail: 'último evento ilegível' };
|
|
79
|
+
if (event.event !== 'AgentUsage') return { status: 'active', detail: `último evento: ${event.event ?? '?'}` };
|
|
80
|
+
if (String(event.agentId ?? '').toLowerCase() !== hex) return { status: 'unknown', detail: 'AgentUsage de outro agente' };
|
|
81
|
+
return { status: 'stopped', mtimeMs: tail.mtimeMs };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function dirtOf(worktreePath) {
|
|
85
|
+
try {
|
|
86
|
+
const lines = gitOut(worktreePath, ['status', '--porcelain', '--untracked-files=all']).split(/\r?\n/).filter((l) => l.trim());
|
|
87
|
+
return withoutLeftoverIgnore(worktreePath, lines).map((l) => (l.startsWith('??') ? `untracked ${l.slice(3)}` : l.slice(3)));
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function agentBranches(root) {
|
|
94
|
+
try {
|
|
95
|
+
return gitOut(root, ['for-each-ref', '--format=%(refname:short) %(objectname)', 'refs/heads/morph-task/'])
|
|
96
|
+
.split(/\r?\n/).filter(Boolean)
|
|
97
|
+
.map((line) => { const [name, sha] = line.split(' '); return { name, sha, hex: AGENT_BRANCH.exec(name)?.[1] }; })
|
|
98
|
+
.filter((b) => b.hex);
|
|
99
|
+
} catch {
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function branchTouchedMs(root, branch) {
|
|
105
|
+
const common = resolveGitCommonDir(root);
|
|
106
|
+
if (!common) return null;
|
|
107
|
+
try { return statSync(join(common, 'logs', 'refs', 'heads', ...branch.split('/'))).mtimeMs; } catch { return null; }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function agentWorktrees(root) {
|
|
111
|
+
const rootNorm = norm(root);
|
|
112
|
+
const containers = containerDirs(root);
|
|
113
|
+
return parseWorktreeList(root).flatMap((e) => {
|
|
114
|
+
const path = norm(e.path);
|
|
115
|
+
const hex = AGENT_DIR.exec(basename(path))?.[1];
|
|
116
|
+
if (!hex || path === rootNorm) return [];
|
|
117
|
+
if (!isUnder(containers, path)) return [];
|
|
118
|
+
const branch = e.branch ? e.branch.replace(/^refs\/heads\//, '') : null;
|
|
119
|
+
if (branch && branch !== `morph-task/agent-${hex}`) return [];
|
|
120
|
+
return [{ path, hex, branch, tip: e.head, locked: e.locked }];
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function judgeWorktree(item, ctx) {
|
|
125
|
+
const keep = (state, reason, detail) => ({ ...item, state, reason, detail });
|
|
126
|
+
if (!existsSync(item.path)) return keep('preserved', 'directory-missing', 'o diretório sumiu; `morph-spec doctor --worktrees --prune` limpa o registro');
|
|
127
|
+
if (cwdIsInsideWorktree(ctx.cwd, item.path) || sessionAnchoredInWorktree(item.path, ctx.env)) return keep('active', 'anchored');
|
|
128
|
+
if (item.locked) return keep('preserved', 'locked', '`git worktree unlock` libera, se o lock já não vale');
|
|
129
|
+
|
|
130
|
+
const stop = readAgentStop(item.path, item.hex);
|
|
131
|
+
if (stop.status === 'active') return keep('active', 'agent-active', stop.detail);
|
|
132
|
+
if (stop.status !== 'stopped') return keep('preserved', 'no-usage-event', stop.detail);
|
|
133
|
+
const idleMin = (ctx.now - stop.mtimeMs) / 60_000;
|
|
134
|
+
if (idleMin < ctx.graceMinutes) return keep('pending', 'within-grace', `parou há ${Math.floor(idleMin)} min (graça de ${ctx.graceMinutes} min)`);
|
|
135
|
+
|
|
136
|
+
const dirt = dirtOf(item.path);
|
|
137
|
+
if (dirt === null) return keep('preserved', 'unknown', 'git status falhou');
|
|
138
|
+
if (dirt.length) return keep('preserved', 'dirty', sample(dirt));
|
|
139
|
+
|
|
140
|
+
const exclusive = exclusiveCommitCount(ctx.root, item.tip);
|
|
141
|
+
if (exclusive === null) return keep('preserved', 'unknown', 'não foi possível contar os commits exclusivos');
|
|
142
|
+
if (exclusive > 0) return keep('preserved', 'exclusive-commits', `${exclusive} commit(s) só nesta branch`);
|
|
143
|
+
return keep('reapable', 'stopped-clean');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function judgeOrphanBranch(branch, ctx) {
|
|
147
|
+
const item = { path: null, hex: branch.hex, branch: branch.name, tip: branch.sha };
|
|
148
|
+
const keep = (state, reason, detail) => ({ ...item, state, reason, detail });
|
|
149
|
+
const exclusive = exclusiveCommitCount(ctx.root, branch.sha);
|
|
150
|
+
if (exclusive === null) return keep('preserved', 'unknown', 'não foi possível contar os commits exclusivos');
|
|
151
|
+
if (exclusive > 0) return keep('preserved', 'exclusive-commits', `${exclusive} commit(s) só nesta branch`);
|
|
152
|
+
const touched = branchTouchedMs(ctx.root, branch.name);
|
|
153
|
+
if (touched === null) return keep('preserved', 'unknown', 'sem reflog para medir a idade da branch');
|
|
154
|
+
const idleMin = (ctx.now - touched) / 60_000;
|
|
155
|
+
if (idleMin < ctx.graceMinutes) return keep('pending', 'within-grace', `branch tocada há ${Math.floor(idleMin)} min (graça de ${ctx.graceMinutes} min)`);
|
|
156
|
+
return keep('reapable', 'orphan-branch');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Read-only: every agent tree and orphan agent branch with its verdict. */
|
|
160
|
+
export function planAgentReap(root, { cwd = root, now = Date.now(), graceMinutes, env = process.env } = {}) {
|
|
161
|
+
const ctx = { root, cwd, now, env, graceMinutes: resolveGraceMinutes(root, graceMinutes) };
|
|
162
|
+
const trees = agentWorktrees(root);
|
|
163
|
+
const withTree = new Set(trees.map((t) => t.branch).filter(Boolean));
|
|
164
|
+
const orphans = agentBranches(root).filter((b) => !withTree.has(b.name));
|
|
165
|
+
const items = [...trees.map((t) => judgeWorktree(t, ctx)), ...orphans.map((b) => judgeOrphanBranch(b, ctx))];
|
|
166
|
+
return { graceMinutes: ctx.graceMinutes, items };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function acquireReapLock(root, now) {
|
|
170
|
+
const common = resolveGitCommonDir(root);
|
|
171
|
+
if (!common) return null;
|
|
172
|
+
const dir = join(common, 'morph-spec');
|
|
173
|
+
const file = join(dir, 'agent-reap.lock');
|
|
174
|
+
try { mkdirSync(dir, { recursive: true }); } catch { return null; }
|
|
175
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
176
|
+
try {
|
|
177
|
+
writeFileSync(file, JSON.stringify({ pid: process.pid, since: now }), { flag: 'wx' });
|
|
178
|
+
return () => { try { unlinkSync(file); } catch { /* already gone */ } };
|
|
179
|
+
} catch (err) {
|
|
180
|
+
if (err.code !== 'EEXIST') return null;
|
|
181
|
+
}
|
|
182
|
+
let holder = null;
|
|
183
|
+
try { holder = JSON.parse(readFileSync(file, 'utf8')); } catch { holder = null; }
|
|
184
|
+
const stale = !holder || !pidAlive(holder.pid) || now - Number(holder.since) > LOCK_MAX_AGE_MS;
|
|
185
|
+
if (!stale) return null;
|
|
186
|
+
try { unlinkSync(file); } catch { return null; }
|
|
187
|
+
}
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function deleteAgentBranch(root, branch, sha) {
|
|
192
|
+
if (exclusiveCommitCount(root, sha) !== 0) return false;
|
|
193
|
+
try { gitOut(root, ['branch', '-D', branch]); return true; } catch { return false; }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const brief = ({ path, branch, hex }) => ({ path, branch, hex });
|
|
197
|
+
const why = ({ path, branch, hex, reason, detail }) => ({ path, branch, hex, reason, ...(detail ? { detail } : {}) });
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Removes the worktrees and branches of stopped, clean agents that hold no commit only they have;
|
|
201
|
+
* everything else is listed with the reason and left alone.
|
|
202
|
+
*/
|
|
203
|
+
export async function reapAgentWorktrees(cwd = process.cwd(), {
|
|
204
|
+
dryRun = false, graceMinutes, now = Date.now, env = process.env, remove = runWorktreeRemove,
|
|
205
|
+
} = {}) {
|
|
206
|
+
if (!isGitRepo(cwd)) return { ok: false, error: 'not-a-git-repo', dryRun };
|
|
207
|
+
const root = resolvePrimaryRoot(cwd);
|
|
208
|
+
const summarize = (plan, extra = {}) => {
|
|
209
|
+
const of = (state) => plan.items.filter((i) => i.state === state);
|
|
210
|
+
return {
|
|
211
|
+
ok: true, dryRun, graceMinutes: plan.graceMinutes,
|
|
212
|
+
reaped: [], branchesDeleted: [],
|
|
213
|
+
...(dryRun ? { wouldReap: of('reapable').map(brief) } : {}),
|
|
214
|
+
preserved: [...of('preserved'), ...of('pending')].map(why),
|
|
215
|
+
active: of('active').length,
|
|
216
|
+
...extra,
|
|
217
|
+
};
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
if (dryRun) return summarize(planAgentReap(root, { cwd, now: now(), graceMinutes, env }));
|
|
221
|
+
|
|
222
|
+
const release = acquireReapLock(root, now());
|
|
223
|
+
if (!release) return { ...summarize({ graceMinutes: resolveGraceMinutes(root, graceMinutes), items: [] }), busy: true };
|
|
224
|
+
try {
|
|
225
|
+
const plan = planAgentReap(root, { cwd, now: now(), graceMinutes, env });
|
|
226
|
+
const out = summarize(plan);
|
|
227
|
+
for (const item of plan.items.filter((i) => i.state === 'reapable')) {
|
|
228
|
+
if (item.path) {
|
|
229
|
+
const { out: removal } = await remove(null, { target: item.path, ifClean: true, relinkOnFailure: true, cwd: root });
|
|
230
|
+
if (!removal.removed) {
|
|
231
|
+
out.preserved.push(why({ ...item, reason: 'remove-failed', detail: removal.hint || removal.error }));
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
out.reaped.push({ ...brief(item), ...(removal.ignoredBackup ? { backup: removal.ignoredBackup } : {}) });
|
|
235
|
+
if (item.branch && deleteAgentBranch(root, item.branch, item.tip)) out.branchesDeleted.push(item.branch);
|
|
236
|
+
} else if (deleteAgentBranch(root, item.branch, item.tip)) {
|
|
237
|
+
out.branchesDeleted.push(item.branch);
|
|
238
|
+
out.reaped.push(brief(item));
|
|
239
|
+
} else {
|
|
240
|
+
out.preserved.push(why({ ...item, reason: 'unknown', detail: 'a branch mudou antes de ser apagada' }));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return out;
|
|
244
|
+
} finally {
|
|
245
|
+
release();
|
|
246
|
+
}
|
|
247
|
+
}
|
package/src/lib/worktree/dirs.js
CHANGED
|
@@ -1,32 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
* Every reader that resolves worktrees BY CONVENTION (test discovery, graph
|
|
5
|
-
* ignore list, the installed .gitignore, the architecture scanner, the
|
|
6
|
-
* dashboard scanner) imports from here. Duplicating the string is how the
|
|
7
|
-
* C1 bug comes back: a reader keeps the old name and silently goes blind.
|
|
8
|
-
*
|
|
9
|
-
* Readers that go through `git worktree list` (fleet, finish, worktree list)
|
|
10
|
-
* never depended on this — git records absolute paths.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* The directory new worktrees are created under, relative to the primary root.
|
|
15
|
-
*
|
|
16
|
-
* NO leading dot, deliberately (C1). Jest escapes the `.` when it interpolates
|
|
17
|
-
* an absolute `rootDir` into the `testMatch` glob, so `<root>/.worktrees/...`
|
|
18
|
-
* stops matching and an entire frontend suite reports **0 tests without an
|
|
19
|
-
* error** — the suite "passes" having tested nothing. The dot bought nothing:
|
|
20
|
-
* the directory is gitignored either way.
|
|
21
|
-
*/
|
|
22
|
-
export const WORKTREES_DIR = 'worktrees';
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* The pre-C1 directory name. New worktrees never land here, but every reader
|
|
26
|
-
* that resolves worktrees BY CONVENTION must still accept it, so a machine
|
|
27
|
-
* that already had `.worktrees/{f}` keeps working with no manual migration.
|
|
28
|
-
*/
|
|
29
|
-
export const LEGACY_WORKTREES_DIR = '.worktrees';
|
|
30
|
-
|
|
31
|
-
/** Both names, new first — the read order for convention-based lookups. */
|
|
32
|
-
export const WORKTREE_DIR_NAMES = [WORKTREES_DIR, LEGACY_WORKTREES_DIR];
|
|
1
|
+
// The names live in framework/hooks/shared/worktree-root.js (a hook cannot import `src/`, and the
|
|
2
|
+
// `worktree.root` resolver needs them); every convention-based reader still imports them from here.
|
|
3
|
+
export { WORKTREES_DIR, LEGACY_WORKTREES_DIR, WORKTREE_DIR_NAMES } from '../../../framework/hooks/shared/worktree-root.js';
|
|
@@ -168,10 +168,10 @@ export const PORT_OFFSETS = Object.freeze({ web: 0, api: 1, postgres: 2, redis:
|
|
|
168
168
|
* not in the root's. Every value is a string (env-safe); key order is stable.
|
|
169
169
|
*
|
|
170
170
|
* @param {{ base: number, range: [number, number] }} block
|
|
171
|
-
* @param {{ stack?: string, feature?: string }} [opts]
|
|
171
|
+
* @param {{ stack?: string, feature?: string, aliases?: Record<string, string> }} [opts] - aliases: resolvePortAliases()
|
|
172
172
|
* @returns {Record<string,string>}
|
|
173
173
|
*/
|
|
174
|
-
export function portLayout(block, { stack = '', feature = '' } = {}) {
|
|
174
|
+
export function portLayout(block, { stack = '', feature = '', aliases = {} } = {}) {
|
|
175
175
|
const env = {};
|
|
176
176
|
if (feature) env.COMPOSE_PROJECT_NAME = composeProjectName(feature);
|
|
177
177
|
env.MORPH_PORT_BASE = String(block.base);
|
|
@@ -183,9 +183,39 @@ export function portLayout(block, { stack = '', feature = '' } = {}) {
|
|
|
183
183
|
if (/\bdotnet\b/i.test(String(stack))) {
|
|
184
184
|
env.ASPNETCORE_URLS = `http://localhost:${block.base + PORT_OFFSETS.api}`;
|
|
185
185
|
}
|
|
186
|
+
for (const [alias, port] of Object.entries(aliases)) env[alias] = String(block.base + PORT_OFFSETS[port]);
|
|
186
187
|
return env;
|
|
187
188
|
}
|
|
188
189
|
|
|
190
|
+
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
191
|
+
const RESERVED_ENV_NAMES = new Set([
|
|
192
|
+
...Object.keys(portLayout({ base: 0, range: [0, 0] }, { stack: 'dotnet', feature: 'x' })),
|
|
193
|
+
'PLAYWRIGHT_BASE_URL',
|
|
194
|
+
'MORPH_FEATURE',
|
|
195
|
+
]);
|
|
196
|
+
|
|
197
|
+
// e2e.portEnvNames: extra env names for the same four ports, so a compose file reading
|
|
198
|
+
// ${PG_PORT} is fed by the block, not by its own default. An entry that cannot be
|
|
199
|
+
// honoured is dropped and named in `problems`.
|
|
200
|
+
export function resolvePortAliases(config) {
|
|
201
|
+
const declared = config?.e2e?.portEnvNames;
|
|
202
|
+
const aliases = {};
|
|
203
|
+
const problems = [];
|
|
204
|
+
if (declared === undefined || declared === null) return { aliases, problems };
|
|
205
|
+
if (typeof declared !== 'object' || Array.isArray(declared)) {
|
|
206
|
+
return { aliases, problems: [`e2e.portEnvNames deve ser um objeto { ${Object.keys(PORT_OFFSETS).join('|')}: "NOME" }`] };
|
|
207
|
+
}
|
|
208
|
+
for (const [port, name] of Object.entries(declared)) {
|
|
209
|
+
const at = `e2e.portEnvNames.${port}`;
|
|
210
|
+
if (!Object.hasOwn(PORT_OFFSETS, port)) problems.push(`${at}: porta desconhecida (use ${Object.keys(PORT_OFFSETS).join('|')})`);
|
|
211
|
+
else if (typeof name !== 'string' || !ENV_NAME.test(name)) problems.push(`${at}: ${JSON.stringify(name)} não é um nome de variável de ambiente válido`);
|
|
212
|
+
else if (RESERVED_ENV_NAMES.has(name)) problems.push(`${at}: "${name}" já é uma variável do layout`);
|
|
213
|
+
else if (Object.hasOwn(aliases, name)) problems.push(`${at}: "${name}" já é o alias de ${aliases[name]}`);
|
|
214
|
+
else aliases[name] = port;
|
|
215
|
+
}
|
|
216
|
+
return { aliases, problems };
|
|
217
|
+
}
|
|
218
|
+
|
|
189
219
|
/**
|
|
190
220
|
* Render the `.morph/worktree.env` contents for a block: a header, the
|
|
191
221
|
* feature, and exactly the `portLayout()` pairs — nothing else, so the file
|
|
@@ -199,15 +229,15 @@ export function portLayout(block, { stack = '', feature = '' } = {}) {
|
|
|
199
229
|
* @param {string} feature - the env-registry key: `<feature>` or `task/<name>`
|
|
200
230
|
* @param {{ base: number, range: [number, number] }} block
|
|
201
231
|
* @param {string} [stack] - config.json `project.stack` (e.g. "nextjs+dotnet")
|
|
202
|
-
* @param {{ morphFeature?: string|null }} [opts]
|
|
232
|
+
* @param {{ morphFeature?: string|null, aliases?: Record<string, string> }} [opts]
|
|
203
233
|
* @returns {string}
|
|
204
234
|
*/
|
|
205
|
-
export function buildEnvFileContent(feature, block, stack = '', { morphFeature = null } = {}) {
|
|
235
|
+
export function buildEnvFileContent(feature, block, stack = '', { morphFeature = null, aliases = {} } = {}) {
|
|
206
236
|
const lines = [
|
|
207
237
|
`# Managed by morph-spec — ambiente dedicado desta worktree (feature: ${feature}).`,
|
|
208
238
|
'# Derivado, git-invisível, regenerado por `worktree setup|link`; nunca editar nem commitar.',
|
|
209
239
|
`MORPH_FEATURE=${morphFeature || feature}`,
|
|
210
|
-
...Object.entries(portLayout(block, { stack, feature })).map(([k, v]) => `${k}=${v}`),
|
|
240
|
+
...Object.entries(portLayout(block, { stack, feature, aliases })).map(([k, v]) => `${k}=${v}`),
|
|
211
241
|
];
|
|
212
242
|
return `${lines.join('\n')}\n`;
|
|
213
243
|
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { jsoncToJson, stripComments } from '../../../framework/hooks/shared/comment-stripper.js';
|
|
5
|
+
import { ensureCommonExclude } from '../../../framework/hooks/shared/git-exclude.js';
|
|
6
|
+
import { readProjectConfig } from '../../../framework/hooks/shared/heavy-config.js';
|
|
7
|
+
import { getProjectTags } from '../stack-filter.js';
|
|
8
|
+
import { realEffects } from '../installers/effects.js';
|
|
9
|
+
import { WORKTREE_DIR_NAMES } from './dirs.js';
|
|
10
|
+
|
|
11
|
+
const SETTINGS_REL = join('.vscode', 'settings.json');
|
|
12
|
+
const EXCLUDE_MARKER = '# morph-spec: editor settings';
|
|
13
|
+
const DEFAULT_INDENT = ' ';
|
|
14
|
+
const DOTNET_ROOT_FILE = /\.(sln|slnx|csproj)$/i;
|
|
15
|
+
|
|
16
|
+
const isDotnet = (root, config) => getProjectTags(config).includes('dotnet')
|
|
17
|
+
|| (() => { try { return readdirSync(root).some((n) => DOTNET_ROOT_FILE.test(n)); } catch { return false; } })();
|
|
18
|
+
|
|
19
|
+
/** Globs per settings key. `bin/obj` leave `files.exclude` outside .NET: a JS `bin/` holds source. */
|
|
20
|
+
export function desiredEditorSettings({ dotnet }) {
|
|
21
|
+
const dirs = WORKTREE_DIR_NAMES.map((n) => `**/${n}/**`);
|
|
22
|
+
const watched = Object.fromEntries([...dirs, '**/node_modules/**', '**/bin/**', '**/obj/**'].map((g) => [g, true]));
|
|
23
|
+
const listed = Object.fromEntries([...dirs, '**/node_modules/**', ...(dotnet ? ['**/bin/**', '**/obj/**'] : [])].map((g) => [g, true]));
|
|
24
|
+
return { 'files.watcherExclude': watched, 'search.exclude': { ...watched }, 'files.exclude': listed };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
28
|
+
|
|
29
|
+
function isTracked(root, rel) {
|
|
30
|
+
try {
|
|
31
|
+
execFileSync('git', ['ls-files', '--error-unmatch', '--', rel], { cwd: root, stdio: 'pipe' });
|
|
32
|
+
return true;
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function mergeMissing(current, wanted) {
|
|
39
|
+
const added = [];
|
|
40
|
+
const skipped = [];
|
|
41
|
+
for (const [key, globs] of Object.entries(wanted)) {
|
|
42
|
+
if (current[key] === undefined) {
|
|
43
|
+
current[key] = { ...globs };
|
|
44
|
+
added.push(...Object.keys(globs).map((g) => `${key}:${g}`));
|
|
45
|
+
} else if (isPlainObject(current[key])) {
|
|
46
|
+
for (const glob of Object.keys(globs)) {
|
|
47
|
+
if (!(glob in current[key])) { current[key][glob] = true; added.push(`${key}:${glob}`); }
|
|
48
|
+
}
|
|
49
|
+
} else {
|
|
50
|
+
skipped.push(key);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return { added, skipped };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function snippetFor(wanted, present) {
|
|
57
|
+
const missing = {};
|
|
58
|
+
for (const [key, globs] of Object.entries(wanted)) {
|
|
59
|
+
const gaps = Object.fromEntries(Object.entries(globs).filter(([g]) => !(isPlainObject(present?.[key]) && g in present[key])));
|
|
60
|
+
if (Object.keys(gaps).length) missing[key] = gaps;
|
|
61
|
+
}
|
|
62
|
+
return JSON.stringify(missing, null, 2);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Read-only: what `.vscode/settings.json` would need. `write` carries the full new text;
|
|
67
|
+
* `suggest` carries the snippet to paste, because rewriting would drop comments, trailing
|
|
68
|
+
* commas or a tracked file's cleanliness.
|
|
69
|
+
*/
|
|
70
|
+
export function planEditorSettings(root, { config = readProjectConfig(root), env = process.env } = {}) {
|
|
71
|
+
const file = join(root, SETTINGS_REL);
|
|
72
|
+
if (config?.worktree?.editorSettings === false || env.MORPH_EDITOR_SETTINGS === '0') return { status: 'off', file };
|
|
73
|
+
|
|
74
|
+
const wanted = desiredEditorSettings({ dotnet: isDotnet(root, config) });
|
|
75
|
+
if (!existsSync(file)) {
|
|
76
|
+
return { status: 'write', file, created: true, added: Object.entries(wanted).flatMap(([k, g]) => Object.keys(g).map((x) => `${k}:${x}`)), content: `${JSON.stringify(wanted, null, DEFAULT_INDENT)}\n` };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const suggest = (reason, present) => ({ status: 'suggest', file, reason, snippet: snippetFor(wanted, present) });
|
|
80
|
+
let text;
|
|
81
|
+
try { text = readFileSync(file, 'utf8'); } catch { return suggest('unreadable'); }
|
|
82
|
+
const body = text.replace(/^\uFEFF/, '');
|
|
83
|
+
let parsed;
|
|
84
|
+
try { parsed = JSON.parse(jsoncToJson(body)); } catch { return suggest('unreadable'); }
|
|
85
|
+
if (!isPlainObject(parsed)) return suggest('not-object');
|
|
86
|
+
if (stripComments(body) !== body || jsoncToJson(body) !== stripComments(body)) return suggest('comments', parsed);
|
|
87
|
+
|
|
88
|
+
const { added, skipped } = mergeMissing(parsed, wanted);
|
|
89
|
+
if (!added.length) return { status: 'unchanged', file, ...(skipped.length ? { skipped } : {}) };
|
|
90
|
+
if (isTracked(root, SETTINGS_REL)) return suggest('tracked', JSON.parse(jsoncToJson(body)));
|
|
91
|
+
|
|
92
|
+
const indent = /^([ \t]+)"/m.exec(body)?.[1] ?? DEFAULT_INDENT;
|
|
93
|
+
const eol = body.includes('\r\n') ? '\r\n' : '\n';
|
|
94
|
+
const out = JSON.stringify(parsed, null, indent).replace(/\n/g, eol) + (/\n$/.test(body) ? eol : '');
|
|
95
|
+
return { status: 'write', file, created: false, added, ...(skipped.length ? { skipped } : {}), content: `${text.startsWith('\uFEFF') ? '\uFEFF' : ''}${out}` };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Applies the plan through `fx` (so `update --dry-run` predicts it). A created file joins the
|
|
100
|
+
* common `info/exclude`: untracked, it would count as root dirt and `finish --merge` refuses that.
|
|
101
|
+
*/
|
|
102
|
+
export function applyEditorSettings(root, { fx = realEffects(), config, env } = {}) {
|
|
103
|
+
const plan = planEditorSettings(root, { config, env });
|
|
104
|
+
const { content, ...report } = plan;
|
|
105
|
+
if (plan.status !== 'write') return report;
|
|
106
|
+
try {
|
|
107
|
+
fx.writeFile(plan.file, content);
|
|
108
|
+
if (plan.created && !fx.dryRun) ensureCommonExclude(root, ['/.vscode/settings.json'], EXCLUDE_MARKER);
|
|
109
|
+
return { ...report, written: !fx.dryRun };
|
|
110
|
+
} catch (err) {
|
|
111
|
+
return { status: 'failed', file: plan.file, reason: String(err?.message ?? err).slice(0, 200) };
|
|
112
|
+
}
|
|
113
|
+
}
|