@polymorphism-tech/morph-spec 8.41.0 → 8.43.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 +14 -4
- package/README.md +3 -3
- 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 +6 -5
- package/framework/MORPH.md +10 -9
- package/framework/agents.json +7 -7
- package/framework/docs/CLI.md +10 -3
- package/framework/evals/proof-rubrics/mutation-proof.md +43 -13
- package/framework/hooks/README.md +1 -0
- package/framework/hooks/claude-code/core/chain.js +2 -0
- package/framework/hooks/claude-code/core/comment-guard.js +107 -0
- 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/shared/agent-reap.js +89 -0
- package/framework/hooks/shared/comment-rules.js +171 -0
- package/{src/lib/validators/code-budget → framework/hooks/shared}/comment-scanner.js +2 -2
- package/{src/lib/ai → framework/hooks/shared}/comment-stripper.js +2 -1
- 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/source-files.js +34 -0
- package/framework/hooks/shared/worktree-root.js +93 -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 +11 -4
- 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 +12 -5
- 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 +1 -1
- package/framework/standards/architecture/code-comments.md +9 -2
- package/framework/standards/frontend/design-system/ai-video-generation.md +2 -0
- package/framework/standards/frontend/scroll-driven/frame-scrub.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 +78 -27
- package/src/commands/validation/mutate.js +200 -0
- package/src/commands/validation/verify.js +26 -1
- package/src/dashboard/scanner/features-scanner.js +4 -4
- package/src/dashboard/scanner/project-api.js +4 -4
- package/src/lib/ai/code-audit.js +1 -1
- package/src/lib/ai/registry-audit.js +1 -1
- package/src/lib/detectors/claude-config-detector.js +30 -1
- 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/tasks/dotnet-test-introspection.js +1 -1
- package/src/lib/tasks/linux-runner.js +30 -3
- package/src/lib/tasks/test-runner.js +1 -1
- package/src/lib/validators/code-budget/code-budget-validator.js +4 -6
- package/src/lib/validators/code-budget/index.js +1 -1
- package/src/lib/validators/reuse/symbol-scanner.js +1 -28
- 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/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 +14 -6
|
@@ -58,6 +58,21 @@ import { dropLeftoverIgnore, withoutLeftoverIgnore } from '../../../framework/ho
|
|
|
58
58
|
// rationale documented there); re-exported here so existing importers and the
|
|
59
59
|
// dependency-matrix row keep pointing at this file.
|
|
60
60
|
import { WORKTREES_DIR, LEGACY_WORKTREES_DIR, WORKTREE_DIR_NAMES } from '../../lib/worktree/dirs.js';
|
|
61
|
+
import { applyEditorSettings } from '../../lib/worktree/vscode-settings.js';
|
|
62
|
+
import { copyWorktreeIncludes } from '../../lib/worktree/worktree-include.js';
|
|
63
|
+
import { containerDirs, externalContainer, newWorktreesDir } from '../../../framework/hooks/shared/worktree-root.js';
|
|
64
|
+
|
|
65
|
+
/** Fail-open: the editor file is a convenience, never a reason a worktree is not created. */
|
|
66
|
+
function rootAndEditorReport(root) {
|
|
67
|
+
const { warning } = externalContainer(root);
|
|
68
|
+
const worktreeRoot = warning ? { worktreeRootWarning: warning } : {};
|
|
69
|
+
try {
|
|
70
|
+
const report = applyEditorSettings(root);
|
|
71
|
+
return report.status === 'off' ? worktreeRoot : { ...worktreeRoot, editorSettings: report };
|
|
72
|
+
} catch {
|
|
73
|
+
return worktreeRoot;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
61
76
|
export { WORKTREES_DIR, LEGACY_WORKTREES_DIR, WORKTREE_DIR_NAMES };
|
|
62
77
|
|
|
63
78
|
// ── Pure helpers (exported for tests) ──────────────────────────────────────
|
|
@@ -146,7 +161,8 @@ export function worktreeExists(worktreePath, cwd = process.cwd()) {
|
|
|
146
161
|
export function buildWorktreePath(feature, cwd = process.cwd(), options = {}) {
|
|
147
162
|
const suffix = options.fresh ? `-${getDateSuffix()}` : '';
|
|
148
163
|
const normalizedCwd = cwd.replace(/\\/g, '/');
|
|
149
|
-
|
|
164
|
+
const container = externalContainer(normalizedCwd).dir ?? `${normalizedCwd}/${WORKTREES_DIR}`;
|
|
165
|
+
return `${container}/${feature}${suffix}`;
|
|
150
166
|
}
|
|
151
167
|
|
|
152
168
|
/**
|
|
@@ -514,7 +530,7 @@ export async function runWorktreeSetup(feature, options = {}) {
|
|
|
514
530
|
}
|
|
515
531
|
|
|
516
532
|
// Create worktree directory parent if needed
|
|
517
|
-
const parentDir =
|
|
533
|
+
const parentDir = newWorktreesDir(cwd);
|
|
518
534
|
if (!existsSync(parentDir)) {
|
|
519
535
|
mkdirSync(parentDir, { recursive: true });
|
|
520
536
|
}
|
|
@@ -524,6 +540,7 @@ export async function runWorktreeSetup(feature, options = {}) {
|
|
|
524
540
|
|
|
525
541
|
// Set up infrastructure junctions (node_modules, .claude, .morph/framework).
|
|
526
542
|
setupInfraLinks(worktreePath, cwd);
|
|
543
|
+
const included = copyWorktreeIncludes(worktreePath, cwd);
|
|
527
544
|
|
|
528
545
|
// Fleet env: allocate this feature's deterministic port block and write the
|
|
529
546
|
// worktree-local manifest (.morph/worktree.env). Fail-open — a worktree
|
|
@@ -573,9 +590,11 @@ export async function runWorktreeSetup(feature, options = {}) {
|
|
|
573
590
|
branch,
|
|
574
591
|
branchedFrom,
|
|
575
592
|
feature,
|
|
593
|
+
...(included.length ? { worktreeInclude: included } : {}),
|
|
576
594
|
materialized: materialized.materialized,
|
|
577
595
|
...(envBlock ? { env: { base: envBlock.base, range: envBlock.range } } : {}),
|
|
578
596
|
...(graph.seeded ? { graph: 'seeded' } : {}),
|
|
597
|
+
...rootAndEditorReport(resolvePrimaryRoot(cwd)),
|
|
579
598
|
nextStep: `Abra o Claude Code em ${worktreePath} para trabalhar nesta feature`,
|
|
580
599
|
};
|
|
581
600
|
} catch (err) {
|
|
@@ -699,13 +718,14 @@ export async function runWorktreeProvision(name, options = {}) {
|
|
|
699
718
|
}
|
|
700
719
|
}
|
|
701
720
|
|
|
702
|
-
const parentDir =
|
|
721
|
+
const parentDir = newWorktreesDir(root);
|
|
703
722
|
if (!existsSync(parentDir)) mkdirSync(parentDir, { recursive: true });
|
|
704
723
|
|
|
705
724
|
execFileSync('git', ['worktree', 'add', worktreePath, branch], { cwd: root, stdio: 'pipe' });
|
|
706
725
|
|
|
707
726
|
setupInfraLinks(worktreePath, root);
|
|
708
727
|
const envFiles = copyRootEnvFiles(worktreePath, root);
|
|
728
|
+
const included = copyWorktreeIncludes(worktreePath, root);
|
|
709
729
|
// A tree born from `morph/<f>` works FOR that feature: MORPH_FEATURE says
|
|
710
730
|
// so, while the port block stays the task's own (`task/<name>`).
|
|
711
731
|
const forFeature = String(options.base || '').match(/^morph\/(.+)$/)?.[1] ?? null;
|
|
@@ -724,9 +744,11 @@ export async function runWorktreeProvision(name, options = {}) {
|
|
|
724
744
|
name,
|
|
725
745
|
...(forFeature ? { feature: forFeature } : {}),
|
|
726
746
|
...(envFiles.length ? { envFiles } : {}),
|
|
747
|
+
...(included.length ? { worktreeInclude: included } : {}),
|
|
727
748
|
materialized: materialized.materialized,
|
|
728
749
|
...(envBlock ? { env: { base: envBlock.base, range: envBlock.range } } : {}),
|
|
729
750
|
...(graph.seeded ? { graph: 'seeded' } : {}),
|
|
751
|
+
...rootAndEditorReport(root),
|
|
730
752
|
};
|
|
731
753
|
} catch (err) {
|
|
732
754
|
return { ok: true, exitCode: 0, created: false, error: (err.message || 'unknown').slice(0, 200), name };
|
|
@@ -886,7 +908,7 @@ export async function allocateWorktreeEnv(worktreePath, root, envKey, { morphFea
|
|
|
886
908
|
* Parse `git worktree list --porcelain` into structured entries.
|
|
887
909
|
* Fail-open: returns `[]` on any git error (e.g. not a git repo).
|
|
888
910
|
* @param {string} cwd
|
|
889
|
-
* @returns {Array<{ path: string, branch: string|null, head: string|null }>}
|
|
911
|
+
* @returns {Array<{ path: string, branch: string|null, head: string|null, locked: boolean }>}
|
|
890
912
|
*/
|
|
891
913
|
export function parseWorktreeList(cwd) {
|
|
892
914
|
let output;
|
|
@@ -901,7 +923,9 @@ export function parseWorktreeList(cwd) {
|
|
|
901
923
|
for (const line of output.split('\n')) {
|
|
902
924
|
if (line.startsWith('worktree ')) {
|
|
903
925
|
if (current) entries.push(current);
|
|
904
|
-
current = { path: line.slice('worktree '.length).trim().replace(/\\/g, '/'), branch: null, head: null };
|
|
926
|
+
current = { path: line.slice('worktree '.length).trim().replace(/\\/g, '/'), branch: null, head: null, locked: false };
|
|
927
|
+
} else if (/^locked(\s|$)/.test(line) && current) {
|
|
928
|
+
current.locked = true;
|
|
905
929
|
} else if (line.startsWith('branch ') && current) {
|
|
906
930
|
current.branch = line.slice('branch '.length).trim();
|
|
907
931
|
} else if (line.startsWith('HEAD ') && current) {
|
|
@@ -937,8 +961,8 @@ export function findWorktreeForFeature(feature, root) {
|
|
|
937
961
|
|
|
938
962
|
let match = entries.find((e) => e.branch === refName);
|
|
939
963
|
if (!match) {
|
|
940
|
-
const
|
|
941
|
-
match = entries.find((e) => e.path
|
|
964
|
+
const guesses = containerDirs(root).map((dir) => `${dir}/${feature}`);
|
|
965
|
+
match = entries.find((e) => guesses.includes(e.path));
|
|
942
966
|
}
|
|
943
967
|
if (!match) return null;
|
|
944
968
|
|
|
@@ -1388,6 +1412,12 @@ export function pickRemovalFailure(stderrs) {
|
|
|
1388
1412
|
* `stamp` names the backup; `finish` passes the one it gave the shed, so both
|
|
1389
1413
|
* folders of that finish count as one backup under the retention.
|
|
1390
1414
|
*
|
|
1415
|
+
* `relinkOnFailure` (the agent reaper) also restores the junctions when git
|
|
1416
|
+
* FAILED, not only when we refused: the reaper removes trees on a schedule, and
|
|
1417
|
+
* a lock that leaves a possibly-live agent without `.claude` kills its hooks.
|
|
1418
|
+
* Off by default — a person retrying a failed removal by hand is what the
|
|
1419
|
+
* unlinked state protects. A tree git already de-registered has nothing to link.
|
|
1420
|
+
*
|
|
1391
1421
|
* @param {string} worktreePath
|
|
1392
1422
|
* @param {{ root: string, feature: string, branch: string, options?: object, deleteBranch?: boolean, stamp?: string }} ctx
|
|
1393
1423
|
* @returns {Promise<{ worktreeRemoved: boolean, ignoredBackup: string|null, discarded: string[],
|
|
@@ -1395,7 +1425,7 @@ export function pickRemovalFailure(stderrs) {
|
|
|
1395
1425
|
* relinked: boolean, refused: boolean, infraLeftUnlinked: string[],
|
|
1396
1426
|
* removalFailure: {kind: string, detail: string}|null }>}
|
|
1397
1427
|
*/
|
|
1398
|
-
export async function teardownWorktree(worktreePath, { root, feature, branch, options = {}, deleteBranch = true, addressByTarget = false, stamp }) {
|
|
1428
|
+
export async function teardownWorktree(worktreePath, { root, feature, branch, options = {}, deleteBranch = true, addressByTarget = false, relinkOnFailure = false, stamp }) {
|
|
1399
1429
|
// Drop the infra junctions FIRST, without following them — this is what makes
|
|
1400
1430
|
// a removal survivable (see unlinkWorktreeInfraLinks). Runs unconditionally.
|
|
1401
1431
|
//
|
|
@@ -1463,7 +1493,8 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
|
|
|
1463
1493
|
// and the caller falls back to telling the user to run `worktree link --all`.
|
|
1464
1494
|
const refused = !canRemove;
|
|
1465
1495
|
let relinked = false;
|
|
1466
|
-
|
|
1496
|
+
const relinkAfterFailure = relinkOnFailure && !refused && !worktreeRemoved && !removalFailure?.alreadyDeregistered;
|
|
1497
|
+
if ((refused || relinkAfterFailure) && unlinked.length && existsSync(worktreePath)) {
|
|
1467
1498
|
try {
|
|
1468
1499
|
setupInfraLinks(worktreePath, root);
|
|
1469
1500
|
relinked = true;
|
|
@@ -1471,7 +1502,7 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
|
|
|
1471
1502
|
}
|
|
1472
1503
|
// What the caller must tell the user about on the FAILURE path: the worktree
|
|
1473
1504
|
// survived without its shared infra, on purpose.
|
|
1474
|
-
const infraLeftUnlinked = (!worktreeRemoved && !refused) ? unlinked : [];
|
|
1505
|
+
const infraLeftUnlinked = (!worktreeRemoved && !refused && !relinked) ? unlinked : [];
|
|
1475
1506
|
|
|
1476
1507
|
// Every branch names `morph-spec worktree remove` and NEVER `git worktree
|
|
1477
1508
|
// remove`. The raw command follows the junctions this function manages, and a
|
|
@@ -1508,8 +1539,14 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
|
|
|
1508
1539
|
+ (removalFailure.kind === 'in-use' ? ' — EM USO por outro processo (instância do Claude Code, VS Code, dev server ou terminal com cwd nele)' : '')
|
|
1509
1540
|
+ `${removalFailure.detail ? ` (${removalFailure.detail})` : ''}. \`${removeCmd}\` não o alcança mais — o git não o conhece. Feche o que o usa, confira que está vazio e sem junction, e apague só o diretório (sem recursão)`
|
|
1510
1541
|
: removalFailure?.kind === 'in-use'
|
|
1511
|
-
? `worktree NÃO removido: o diretório está EM USO por outro processo (instância do Claude Code, VS Code, dev server ou terminal com cwd em "${worktreePath}").
|
|
1512
|
-
|
|
1542
|
+
? `worktree NÃO removido: o diretório está EM USO por outro processo (instância do Claude Code, VS Code, dev server ou terminal com cwd em "${worktreePath}"). `
|
|
1543
|
+
+ (relinked
|
|
1544
|
+
? `A infra compartilhada foi RESTAURADA — o worktree segue utilizável. Feche o que está usando o diretório e rode \`${removeCmd}\``
|
|
1545
|
+
: `A infra compartilhada ficou DESMONTADA de propósito — feche o que está usando o diretório e rode \`${removeCmd}\`. Se preferir continuar trabalhando nele, restaure a infra com \`morph-spec worktree link --all\``)
|
|
1546
|
+
: `worktree NÃO removido: a remoção falhou${removalFailure?.detail ? ` (${removalFailure.detail})` : ''}. `
|
|
1547
|
+
+ (relinked
|
|
1548
|
+
? `A infra compartilhada foi RESTAURADA — o worktree segue utilizável. Rode \`${removeCmd}\` para tentar de novo`
|
|
1549
|
+
: `A infra compartilhada ficou DESMONTADA de propósito — rode \`${removeCmd}\` para tentar de novo, ou \`morph-spec worktree link --all\` para voltar a trabalhar nele`);
|
|
1513
1550
|
|
|
1514
1551
|
return {
|
|
1515
1552
|
worktreeRemoved,
|
|
@@ -1580,8 +1617,9 @@ export function sessionAnchoredInWorktree(worktreePath, env = process.env) {
|
|
|
1580
1617
|
// ── remove ───────────────────────────────────────────────────────────────
|
|
1581
1618
|
|
|
1582
1619
|
/**
|
|
1583
|
-
*
|
|
1584
|
-
* `morph-spec worktree remove --target <path
|
|
1620
|
+
* Non-exiting core of `morph-spec worktree remove <feature>` /
|
|
1621
|
+
* `morph-spec worktree remove --target <path>` — returns `{ out, code }` instead
|
|
1622
|
+
* of printing and exiting, so the agent reaper can call it in a loop.
|
|
1585
1623
|
*
|
|
1586
1624
|
* WHY THIS EXISTS: `teardownWorktree` — the only removal that drops the infra
|
|
1587
1625
|
* junctions before letting git near the directory — used to be reachable ONLY
|
|
@@ -1600,13 +1638,14 @@ export function sessionAnchoredInWorktree(worktreePath, env = process.env) {
|
|
|
1600
1638
|
* closing a feature is still `morph-spec finish`.
|
|
1601
1639
|
*
|
|
1602
1640
|
* @param {string|undefined} featureArg
|
|
1603
|
-
* @param {{ target?: string, discardIgnored?: boolean, deleteBranch?: boolean, ifClean?: boolean, cwd?: string }} [options]
|
|
1641
|
+
* @param {{ target?: string, discardIgnored?: boolean, deleteBranch?: boolean, ifClean?: boolean, relinkOnFailure?: boolean, cwd?: string }} [options]
|
|
1642
|
+
* @returns {Promise<{ out: object, code: 0|1 }>}
|
|
1604
1643
|
*/
|
|
1605
|
-
export async function
|
|
1644
|
+
export async function runWorktreeRemove(featureArg, options = {}) {
|
|
1606
1645
|
const cwdArg = options.cwd || process.cwd();
|
|
1607
|
-
const
|
|
1646
|
+
const outcome = (out, code) => ({ out, code });
|
|
1608
1647
|
|
|
1609
|
-
if (!isGitRepo(cwdArg))
|
|
1648
|
+
if (!isGitRepo(cwdArg)) return outcome({ removed: false, error: 'not-a-git-repo' }, 1);
|
|
1610
1649
|
const root = resolvePrimaryRoot(cwdArg);
|
|
1611
1650
|
|
|
1612
1651
|
let worktreePath = null;
|
|
@@ -1623,11 +1662,11 @@ export async function worktreeRemoveCommand(featureArg, options = {}) {
|
|
|
1623
1662
|
// is the same path-traversal class already fixed in delete/archive.
|
|
1624
1663
|
const entry = parseWorktreeList(root).find((e) => e.path === worktreePath);
|
|
1625
1664
|
if (!entry) {
|
|
1626
|
-
|
|
1665
|
+
return outcome({ removed: false, error: 'not-a-registered-worktree', target: worktreePath, root,
|
|
1627
1666
|
hint: 'o path precisa ser um worktree registrado neste repo — confira `morph-spec worktree list`' }, 1);
|
|
1628
1667
|
}
|
|
1629
1668
|
if (worktreePath === root) {
|
|
1630
|
-
|
|
1669
|
+
return outcome({ removed: false, error: 'refuses-primary-root', path: worktreePath,
|
|
1631
1670
|
hint: 'a raiz primária nunca é removível por este comando' }, 1);
|
|
1632
1671
|
}
|
|
1633
1672
|
branch = entry.branch ? entry.branch.replace(/^refs\/heads\//, '') : null;
|
|
@@ -1640,12 +1679,12 @@ export async function worktreeRemoveCommand(featureArg, options = {}) {
|
|
|
1640
1679
|
}
|
|
1641
1680
|
} else {
|
|
1642
1681
|
if (!isValidFeatureName(feature)) {
|
|
1643
|
-
|
|
1682
|
+
return outcome({ removed: false, error: 'invalid-feature-name', feature: feature ?? null,
|
|
1644
1683
|
hint: 'informe <feature> (kebab-case) ou use --target <path>' }, 1);
|
|
1645
1684
|
}
|
|
1646
1685
|
const found = findWorktreeForFeature(feature, root);
|
|
1647
1686
|
if (!found || found.path.replace(/\\/g, '/') === root) {
|
|
1648
|
-
|
|
1687
|
+
return outcome({ removed: false, error: 'no-worktree-for-feature', feature, root,
|
|
1649
1688
|
hint: `nenhum worktree separado para "${feature}" — confira \`morph-spec worktree list\`` }, 1);
|
|
1650
1689
|
}
|
|
1651
1690
|
worktreePath = found.path;
|
|
@@ -1656,7 +1695,7 @@ export async function worktreeRemoveCommand(featureArg, options = {}) {
|
|
|
1656
1695
|
// is MORE likely to hit it: the natural moment to run it is right after
|
|
1657
1696
|
// working inside the worktree.
|
|
1658
1697
|
if (cwdIsInsideWorktree(cwdArg, worktreePath)) {
|
|
1659
|
-
|
|
1698
|
+
return outcome({ removed: false, error: 'cwd-inside-worktree', path: worktreePath, root,
|
|
1660
1699
|
hint: `saia do worktree antes de removê-lo — o processo trava o próprio diretório de trabalho. Rode de novo a partir de "${root}"` }, 1);
|
|
1661
1700
|
}
|
|
1662
1701
|
|
|
@@ -1666,7 +1705,7 @@ export async function worktreeRemoveCommand(featureArg, options = {}) {
|
|
|
1666
1705
|
// refusal IS the deferral.
|
|
1667
1706
|
if (sessionAnchoredInWorktree(worktreePath)) {
|
|
1668
1707
|
const again = featureAddressable && feature ? `morph-spec worktree remove ${feature}` : `morph-spec worktree remove --target "${worktreePath}"`;
|
|
1669
|
-
|
|
1708
|
+
return outcome({ removed: false, error: 'session-anchored-in-worktree', path: worktreePath, root,
|
|
1670
1709
|
hint: `a sessão do Claude Code que rodou este comando está ancorada neste worktree — os hooks dela resolvem \`.morph/framework\` por ele, e desmontá-lo quebraria todo tool call seguinte (e o diretório seguiria travado pela sessão). Feche esta sessão e rode \`${again}\` de uma sessão aberta em "${root}"` }, 1);
|
|
1671
1710
|
}
|
|
1672
1711
|
|
|
@@ -1681,12 +1720,12 @@ export async function worktreeRemoveCommand(featureArg, options = {}) {
|
|
|
1681
1720
|
try {
|
|
1682
1721
|
status = execFileSync('git', ['status', '--porcelain', '--untracked-files=all'], { cwd: worktreePath, stdio: 'pipe' }).toString();
|
|
1683
1722
|
} catch {
|
|
1684
|
-
|
|
1723
|
+
return outcome({ removed: false, error: 'status-failed', path: worktreePath, root,
|
|
1685
1724
|
hint: 'não foi possível provar que o worktree está limpo — nada foi removido' }, 1);
|
|
1686
1725
|
}
|
|
1687
1726
|
const dirt = withoutLeftoverIgnore(worktreePath, status.split(/\r?\n/).filter((l) => l.trim()));
|
|
1688
1727
|
if (dirt.length) {
|
|
1689
|
-
|
|
1728
|
+
return outcome({ removed: false, error: 'worktree-dirty', path: worktreePath, root, dirt: dirt.slice(0, 20),
|
|
1690
1729
|
hint: `o worktree tem trabalho não commitado — commit ou descarte antes, ou rode \`morph-spec worktree remove --target "${worktreePath}"\` sem --if-clean para remover com backup` }, 1);
|
|
1691
1730
|
}
|
|
1692
1731
|
}
|
|
@@ -1725,6 +1764,7 @@ export async function worktreeRemoveCommand(featureArg, options = {}) {
|
|
|
1725
1764
|
// Removing a worktree is not closing a feature: the branch is the work, and
|
|
1726
1765
|
// deleting it here would discard unintegrated commits. Opt-in only.
|
|
1727
1766
|
deleteBranch: options.deleteBranch === true,
|
|
1767
|
+
relinkOnFailure: options.relinkOnFailure === true,
|
|
1728
1768
|
// Re-run advice must address the worktree the way it was addressable here:
|
|
1729
1769
|
// a `--target` worktree has no feature name that would resolve.
|
|
1730
1770
|
addressByTarget: !featureAddressable,
|
|
@@ -1763,7 +1803,18 @@ export async function worktreeRemoveCommand(featureArg, options = {}) {
|
|
|
1763
1803
|
const retention = enforceBackupRetention(root, { protectStamps: [stamp] });
|
|
1764
1804
|
if (retention) out.backupRetention = retention;
|
|
1765
1805
|
}
|
|
1766
|
-
|
|
1806
|
+
return outcome(out, teardown.worktreeRemoved ? 0 : 1);
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
/**
|
|
1810
|
+
* `morph-spec worktree remove` — prints the JSON verdict and exits with its code.
|
|
1811
|
+
* @param {string|undefined} featureArg
|
|
1812
|
+
* @param {object} [options] - see `runWorktreeRemove`
|
|
1813
|
+
*/
|
|
1814
|
+
export async function worktreeRemoveCommand(featureArg, options = {}) {
|
|
1815
|
+
const { out, code } = await runWorktreeRemove(featureArg, options);
|
|
1816
|
+
console.log(JSON.stringify(out));
|
|
1817
|
+
process.exit(code);
|
|
1767
1818
|
}
|
|
1768
1819
|
|
|
1769
1820
|
// ── list ─────────────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import { resolveTestBuildTargets } from '../../lib/tasks/test-runner.js';
|
|
6
|
+
import { dotnetTestMode } from '../../lib/tasks/dotnet-test-introspection.js';
|
|
7
|
+
import { dotnetBuildCommands } from '../../lib/mutation/commands.js';
|
|
8
|
+
import { resolveTestNodes } from '../../lib/tasks/test-nodes.js';
|
|
9
|
+
import { changedPathsAgainst, detectDefaultBranch, getHeadSha } from '../../lib/git/default-branch.js';
|
|
10
|
+
import { createHeavyGate, HeavyBusyError } from '../../lib/heavy/gate.js';
|
|
11
|
+
import { readHeavyConfig } from '../../../framework/hooks/shared/heavy-config.js';
|
|
12
|
+
import { resolveGitCommonDir } from '../../../framework/hooks/shared/git-paths.js';
|
|
13
|
+
import { BACKSLASH_TOKEN, ManifestError } from '../../lib/mutation/manifest.js';
|
|
14
|
+
import { groupByNode, loadPlan } from '../../lib/mutation/plan.js';
|
|
15
|
+
import { dotnetSubset, jsSubset, testProjectDirsOf } from '../../lib/mutation/subset.js';
|
|
16
|
+
import { dotnetAdapter, genericAdapter, spawnExec } from '../../lib/mutation/adapters.js';
|
|
17
|
+
import { runMutation } from '../../lib/mutation/runner.js';
|
|
18
|
+
import { renderSection, summarize } from '../../lib/mutation/report.js';
|
|
19
|
+
import { readJournal, restoreSnapshot } from '../../lib/mutation/snapshot.js';
|
|
20
|
+
|
|
21
|
+
const EXIT = { ok: 0, failed: 1, usage: 2, busy: 3, unsafe: 4 };
|
|
22
|
+
const KEEP_RUNS = 2;
|
|
23
|
+
const DEFAULT_JOBS = 2;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* One heavy slot per worker: the first waits like any heavy node, the others
|
|
27
|
+
* are taken only if free right now. The run uses as many workers as it got,
|
|
28
|
+
* so a busy machine degrades to sequential instead of queueing.
|
|
29
|
+
*/
|
|
30
|
+
export function workerGate({ root, feature, jobs }) {
|
|
31
|
+
const config = readHeavyConfig(root);
|
|
32
|
+
const gates = [createHeavyGate({ projectPath: root, label: 'mutate', feature, config })];
|
|
33
|
+
const quiet = { ...config, waitTimeoutSec: 0, warnings: [] };
|
|
34
|
+
for (let i = 1; i < jobs; i++) {
|
|
35
|
+
const { MORPH_HEAVY_LEASE: _inherited, ...env } = process.env;
|
|
36
|
+
gates.push(createHeavyGate({ projectPath: root, label: 'mutate', feature, config: quiet, env }));
|
|
37
|
+
}
|
|
38
|
+
let acquired = 0;
|
|
39
|
+
return {
|
|
40
|
+
get jobs() { return acquired; },
|
|
41
|
+
enter(node) {
|
|
42
|
+
gates[0].enter(node);
|
|
43
|
+
acquired = 1;
|
|
44
|
+
for (const gate of gates.slice(1)) {
|
|
45
|
+
try { gate.enter(node); acquired += 1; } catch (err) {
|
|
46
|
+
if (!(err instanceof HeavyBusyError)) throw err;
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
release() {
|
|
52
|
+
for (const gate of [...gates].reverse()) gate.release();
|
|
53
|
+
acquired = 0;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const gitOut = (cwd, args) => execFileSync('git', args, { cwd, stdio: 'pipe', encoding: 'utf8', windowsHide: true });
|
|
59
|
+
|
|
60
|
+
function workRoot(root) {
|
|
61
|
+
const common = resolveGitCommonDir(root);
|
|
62
|
+
return common ? join(common, 'morph-spec', 'mutation') : join(root, '.morph', 'tmp', 'mutation');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function pruneOldRuns(base, keep) {
|
|
66
|
+
if (!existsSync(base)) return;
|
|
67
|
+
const runs = readdirSync(base).sort();
|
|
68
|
+
for (const name of runs.slice(0, Math.max(0, runs.length - keep))) {
|
|
69
|
+
const done = ['g0', 'g1', 'g2', 'g3'].every((g) => {
|
|
70
|
+
const journal = readJournal(join(base, name, g, 'snapshot'));
|
|
71
|
+
return journal === null || journal.state === 'restored';
|
|
72
|
+
});
|
|
73
|
+
if (done) rmSync(join(base, name), { recursive: true, force: true });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function fail(message, code) {
|
|
78
|
+
console.error(chalk.red(message));
|
|
79
|
+
process.exitCode = code;
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function restoreLatest(root, out = console) {
|
|
84
|
+
const base = workRoot(root);
|
|
85
|
+
if (!existsSync(base)) { out.log('nada a restaurar: nenhuma rodada de mutação registrada'); return []; }
|
|
86
|
+
const restored = [];
|
|
87
|
+
for (const run of readdirSync(base).sort()) {
|
|
88
|
+
for (const group of readdirSync(join(base, run))) {
|
|
89
|
+
const journal = readJournal(join(base, run, group, 'snapshot'));
|
|
90
|
+
if (!journal || journal.state === 'restored') continue;
|
|
91
|
+
const report = restoreSnapshot(journal);
|
|
92
|
+
restored.push({ run, group, files: report });
|
|
93
|
+
out.log(`${run}/${group}: ${report.every((r) => r.ok) ? 'restaurado' : 'FALHOU'} — ${report.map((r) => `${r.file} ${r.ok ? 'ok' : 'md5 diverge'}`).join(', ')}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (restored.length === 0) out.log('nada a restaurar: toda rodada registrada já foi restaurada');
|
|
97
|
+
return restored;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function subsetFor({ node, group, root, options }) {
|
|
101
|
+
if (options.subset === false) return { usable: false, reason: '--no-subset' };
|
|
102
|
+
const mutatedFiles = group.regions.map((r) => r.file);
|
|
103
|
+
if (node.runner === 'dotnet') {
|
|
104
|
+
const base = detectDefaultBranch(root);
|
|
105
|
+
const changed = base ? changedPathsAgainst(base, root) : [];
|
|
106
|
+
return dotnetSubset({ root, testProjectDirs: testProjectDirsOf(root, resolveTestBuildTargets(node)), mutatedFiles, changed });
|
|
107
|
+
}
|
|
108
|
+
return jsSubset({ runner: node.runner, command: node.command, root, mutatedFiles });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function mutateCommand(feature, options = {}) {
|
|
112
|
+
const root = resolve(options.cwd ?? process.cwd());
|
|
113
|
+
if (options.restore) { restoreLatest(root); return null; }
|
|
114
|
+
|
|
115
|
+
const planPath = resolve(root, options.plan ?? join('.morph', 'features', feature ?? '', '4-review', 'mutants.json'));
|
|
116
|
+
if (!existsSync(planPath)) return fail(`manifesto não encontrado: ${planPath}\nEscreva-o com a ferramenta Write, se você a tem; só com Bash, heredoc de delimitador entre aspas simples, com a barra invertida do código como ${BACKSLASH_TOKEN} — formato em .morph/framework/CLI.md, seção mutate.`, EXIT.usage);
|
|
117
|
+
|
|
118
|
+
let regions;
|
|
119
|
+
try {
|
|
120
|
+
regions = loadPlan({ root, planPath });
|
|
121
|
+
} catch (err) {
|
|
122
|
+
if (err instanceof ManifestError) return fail(err.message, EXIT.usage);
|
|
123
|
+
if (err instanceof SyntaxError) return fail(`${err.message}\nSe o manifesto tem barra invertida, o transporte do Bash pode tê-la alterado: escreva cada uma como ${BACKSLASH_TOKEN}, que a ferramenta expande.`, EXIT.usage);
|
|
124
|
+
throw err;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const { nodes } = resolveTestNodes(root);
|
|
128
|
+
const { groups, orphans } = groupByNode(regions, nodes, root);
|
|
129
|
+
if (orphans.length > 0) {
|
|
130
|
+
return fail(`nenhuma suíte declarada roda: ${orphans.map((o) => `${o.id} (${o.file})`).join(', ')}\nDeclare o nó em project.tests[] (.morph/config/config.json).`, EXIT.usage);
|
|
131
|
+
}
|
|
132
|
+
const mtp = groups.find((g) => g.node.runner === 'dotnet' && dotnetTestMode(resolve(root, g.node.cwd || '.')) === 'mtp');
|
|
133
|
+
if (mtp) return fail(`a suíte "${mtp.node.id}" roda no Microsoft.Testing.Platform (global.json): o TRX do VSTest, de onde o veredito e o motivo saem, não existe nela. Suporte a MTP ainda não há — rode a mutação com a suíte inteira à mão.`, EXIT.usage);
|
|
134
|
+
const uncompilable = groups.find((g) => g.node.runner === 'dotnet' && dotnetBuildCommands(g.node, root).length === 0);
|
|
135
|
+
if (uncompilable) return fail(`a suíte "${uncompilable.node.id}" não aponta nenhum .csproj (solution, ou nada): sem saber o que compilar, todo mutante rodaria sobre o binário antigo e sairia "sobrevivente". Declare o campo build no nó (config.json, project.tests[]).`, EXIT.usage);
|
|
136
|
+
const preDecided = groups.find((g) => g.node.status);
|
|
137
|
+
if (preDecided) return fail(`a suíte "${preDecided.node.id}" já vem decidida como ${preDecided.node.status}: ${preDecided.node.reason ?? ''}`, EXIT.usage);
|
|
138
|
+
|
|
139
|
+
if (!options.allowDirty) {
|
|
140
|
+
const dirty = gitOut(root, ['status', '--porcelain', '--', ...new Set(regions.map((r) => r.file))]).trim();
|
|
141
|
+
if (dirty) return fail(`há alteração não commitada nos arquivos a mutar:\n${dirty}\nCommite o fix ANTES de mutar (ou use --allow-dirty sabendo que o snapshot é o único caminho de volta).`, EXIT.usage);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const runId = `mut-${Date.now().toString(36)}`;
|
|
145
|
+
const base = workRoot(root);
|
|
146
|
+
pruneOldRuns(base, KEEP_RUNS);
|
|
147
|
+
const git = { porcelain: () => gitOut(root, ['status', '--porcelain', '--', '.', ':(exclude).morph']), head: () => getHeadSha(root) };
|
|
148
|
+
const jobs = Math.max(1, Number.isInteger(options.jobs) ? options.jobs : DEFAULT_JOBS);
|
|
149
|
+
const gate = workerGate({ root, feature: feature ?? null, jobs });
|
|
150
|
+
const exec = options.exec ?? spawnExec;
|
|
151
|
+
|
|
152
|
+
const merged = { wallMs: 0, mutants: [], timings: [], restore: { files: [], ok: true, treeClean: true }, binary: { proof: 'unavailable', projects: [] }, baseline: {}, method: { schemata: 0, rebuild: 0, subset: {} } };
|
|
153
|
+
const subsets = [];
|
|
154
|
+
try {
|
|
155
|
+
for (const [index, group] of groups.entries()) {
|
|
156
|
+
const workDir = join(base, runId, `g${index}`);
|
|
157
|
+
mkdirSync(workDir, { recursive: true });
|
|
158
|
+
const subset = subsetFor({ node: group.node, group, root, options });
|
|
159
|
+
subsets.push(subset);
|
|
160
|
+
const timeoutMs = options.timeoutMs ?? group.node.timeoutMs ?? 900_000;
|
|
161
|
+
const make = group.node.runner === 'dotnet' ? dotnetAdapter : genericAdapter;
|
|
162
|
+
const adapter = make({ node: group.node, root, workDir, exec, subset, timeoutMs });
|
|
163
|
+
const result = await runMutation({ root, node: group.node, regions: group.regions, workDir, runId, exec, adapter, gate, git, options: { timeoutMs, jobs }, log: (m) => options.json || console.error(chalk.gray(m)) });
|
|
164
|
+
merged.mutants.push(...result.mutants);
|
|
165
|
+
merged.timings.push(...result.timings);
|
|
166
|
+
merged.wallMs += result.wallMs;
|
|
167
|
+
merged.restore.files.push(...result.restore.files);
|
|
168
|
+
merged.restore.ok &&= result.restore.ok;
|
|
169
|
+
merged.restore.treeClean &&= result.restore.treeClean;
|
|
170
|
+
merged.restore.porcelainAfter = result.restore.porcelainAfter;
|
|
171
|
+
if (result.binary.proof !== 'unavailable') merged.binary = ['mismatch', 'unlocated'].includes(merged.binary.proof) ? merged.binary : result.binary;
|
|
172
|
+
merged.baseline = { ...merged.baseline, ...Object.fromEntries(Object.entries(result.baseline).filter(([, v]) => v)) };
|
|
173
|
+
merged.method.schemata += result.method.schemata;
|
|
174
|
+
merged.method.rebuild += result.method.rebuild;
|
|
175
|
+
merged.method.subset = result.method.subset;
|
|
176
|
+
merged.method.jobs = Math.max(merged.method.jobs ?? 1, result.method.jobs);
|
|
177
|
+
}
|
|
178
|
+
} catch (err) {
|
|
179
|
+
if (err instanceof HeavyBusyError) return fail(`suíte não rodou: ${err.message}. Tente de novo quando liberar (morph-spec heavy status).`, EXIT.busy);
|
|
180
|
+
if (err.code === 'baseline-build-failed') return fail(`${err.message}\n${String(err.output ?? '').split('\n').slice(-12).join('\n')}`, EXIT.failed);
|
|
181
|
+
throw err;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const order = new Map(regions.map((r, i) => [r.id, i]));
|
|
185
|
+
merged.mutants.sort((a, b) => order.get(a.id) - order.get(b.id));
|
|
186
|
+
const section = renderSection(merged, { head: git.head(), date: new Date().toISOString().slice(0, 10), subset: subsets[0] ?? null });
|
|
187
|
+
const outPath = resolve(root, options.out ?? join('.morph', 'features', feature ?? '_', '4-review', 'mutation-section.md'));
|
|
188
|
+
mkdirSync(join(outPath, '..'), { recursive: true });
|
|
189
|
+
writeFileSync(outPath, section);
|
|
190
|
+
|
|
191
|
+
const tally = summarize(merged);
|
|
192
|
+
if (options.json) console.log(JSON.stringify({ ...merged, tally, section: outPath }, null, 2));
|
|
193
|
+
else {
|
|
194
|
+
console.log(section);
|
|
195
|
+
console.log(chalk.gray(`seção gravada em ${outPath}`));
|
|
196
|
+
console.log(`${chalk.green(`${tally.killed} mortos`)} · ${tally.survived ? chalk.yellow(`${tally.survived} sobreviveram`) : '0 sobreviveram'} · ${tally['build-error']} não compilaram · ${tally.inconclusive} inconclusivos`);
|
|
197
|
+
}
|
|
198
|
+
if (!merged.restore.ok || ['mismatch', 'unlocated'].includes(merged.binary.proof) || !merged.restore.treeClean) process.exitCode = EXIT.unsafe;
|
|
199
|
+
return { ...merged, tally, section: outPath };
|
|
200
|
+
}
|
|
@@ -9,6 +9,9 @@ import { getHeadSha } from '../../lib/git/default-branch.js';
|
|
|
9
9
|
import { EVAL_RUNNER } from '../../lib/tasks/eval-node.js';
|
|
10
10
|
import { buildVerifyEvent, recordVerifyEvent, findRepeatedVerify } from '../../lib/telemetry/verify-event.js';
|
|
11
11
|
import { treeFingerprint } from '../../lib/git/tree-fingerprint.js';
|
|
12
|
+
import { HeavyBusyError } from '../../lib/heavy/gate.js';
|
|
13
|
+
import { afterHeavyRun } from '../../lib/heavy/cleanup.js';
|
|
14
|
+
import { readHeavyConfig } from '../../../framework/hooks/shared/heavy-config.js';
|
|
12
15
|
|
|
13
16
|
/** ✓ pass · ✗ fail · – skip */
|
|
14
17
|
function statusIcon(status) {
|
|
@@ -252,9 +255,27 @@ export async function verifyCommand(featureName, taskId, options = {}) {
|
|
|
252
255
|
console.error(chalk.red(`\n❌ ${err.message}\n`));
|
|
253
256
|
process.exit(2);
|
|
254
257
|
}
|
|
258
|
+
// Ocupado não é veredito: nada foi verificado, então não há stamp nem
|
|
259
|
+
// evento, e o exit 3 diz "tente de novo", nunca "vermelho".
|
|
260
|
+
if (err instanceof HeavyBusyError) {
|
|
261
|
+
if (options.json) {
|
|
262
|
+
const holders = err.holders.map(({ pid, label, feature, node, since }) => ({ pid, label, feature, node, since }));
|
|
263
|
+
console.log(JSON.stringify({
|
|
264
|
+
status: 'busy', persisted: false, busy: { reason: err.reason, holders, freeGb: err.freeGb, waitedMs: err.waitedMs },
|
|
265
|
+
}, null, 2));
|
|
266
|
+
}
|
|
267
|
+
console.error(chalk.yellow(`\n⏳ verify não rodou: ${err.message}. Não é vermelho — tente de novo quando liberar (\`morph-spec heavy status\`).\n`));
|
|
268
|
+
process.exit(3);
|
|
269
|
+
}
|
|
255
270
|
throw err;
|
|
256
271
|
}
|
|
257
272
|
|
|
273
|
+
const cleanup = afterHeavyRun({
|
|
274
|
+
projectPath: process.cwd(),
|
|
275
|
+
config: readHeavyConfig(process.cwd()),
|
|
276
|
+
scope: taskId ? 'task' : 'feature',
|
|
277
|
+
});
|
|
278
|
+
|
|
258
279
|
const scopeKey = taskId ? `taskVerifications.${taskId}` : 'verifications.feature';
|
|
259
280
|
// `--record-baseline` is config maintenance, not gate evidence: it measures
|
|
260
281
|
// the suite in order to write `expectedCount`. Persisting a `verification`
|
|
@@ -343,10 +364,14 @@ export async function verifyCommand(featureName, taskId, options = {}) {
|
|
|
343
364
|
}
|
|
344
365
|
|
|
345
366
|
if (options.json) {
|
|
346
|
-
const output = { ...result, persisted: persist, ...(persist ? {} : { reason }), repeatOf };
|
|
367
|
+
const output = { ...result, persisted: persist, ...(persist ? {} : { reason }), repeatOf, cleanup };
|
|
347
368
|
console.log(JSON.stringify(output, null, 2));
|
|
348
369
|
} else {
|
|
349
370
|
console.log(formatVerifyResult(result));
|
|
371
|
+
if (cleanup.status !== 'skipped') {
|
|
372
|
+
const mark = cleanup.status === 'ran' ? chalk.green('✓') : chalk.yellow('⚠');
|
|
373
|
+
console.log(chalk.gray(`${mark} dotnet build-server shutdown: ${cleanup.status}${cleanup.reason ? ` (${cleanup.reason})` : ''}\n`));
|
|
374
|
+
}
|
|
350
375
|
if (repeatOf) {
|
|
351
376
|
console.log(chalk.yellow(`⚠️ Mesma árvore do verify de feature de ${repeatOf.ts} (${repeatOf.status}): nada mudou fora de .morph/ desde então. `
|
|
352
377
|
+ 'Esta rodada não prova nada novo — reuse o resultado anterior, a menos que o ambiente (Docker, banco, serviço externo) tenha mudado.\n'));
|
|
@@ -26,7 +26,7 @@ import { GATE_PREREQ } from '../../core/state/gate-order.js';
|
|
|
26
26
|
import { validateTasksJson } from '../../lib/tasks/tasks-json-parser.js';
|
|
27
27
|
import { evaluatorVerdictFromText } from '../../lib/evaluator-report.js';
|
|
28
28
|
import { resolveFeatureTree } from '../../lib/worktree/feature-truth.js';
|
|
29
|
-
import {
|
|
29
|
+
import { containerDirs } from '../../../framework/hooks/shared/worktree-root.js';
|
|
30
30
|
import { canonicalPath, isInside, readMorphConfig } from '../../lib/tasks/test-runner.js';
|
|
31
31
|
import { isFinished } from './feature-status.js';
|
|
32
32
|
|
|
@@ -79,9 +79,9 @@ function collectLocations(projectDir) {
|
|
|
79
79
|
for (const name of subdirs(rootFeatures)) {
|
|
80
80
|
if (existsSync(join(rootFeatures, name, 'feature.json'))) slot(name).root = join(rootFeatures, name);
|
|
81
81
|
}
|
|
82
|
-
for (const
|
|
83
|
-
for (const wt of subdirs(
|
|
84
|
-
const wtFeatures = join(
|
|
82
|
+
for (const container of containerDirs(projectDir)) {
|
|
83
|
+
for (const wt of subdirs(container)) {
|
|
84
|
+
const wtFeatures = join(container, wt, '.morph', 'features');
|
|
85
85
|
for (const name of subdirs(wtFeatures)) {
|
|
86
86
|
if (existsSync(join(wtFeatures, name, 'feature.json'))) slot(name).worktrees.push(join(wtFeatures, name));
|
|
87
87
|
}
|
|
@@ -12,7 +12,7 @@ import { join } from 'path';
|
|
|
12
12
|
import { existsSync, readdirSync } from 'fs';
|
|
13
13
|
import { scanFeatures, readFeatureDetail, readFeatureFile } from './features-scanner.js';
|
|
14
14
|
import { createEventStore, buildFeed, lastActivityByFeature, ALL_KINDS, DEFAULT_KINDS } from './activity-scanner.js';
|
|
15
|
-
import {
|
|
15
|
+
import { containerDirs } from '../../../framework/hooks/shared/worktree-root.js';
|
|
16
16
|
import { isFinished } from './feature-status.js';
|
|
17
17
|
import { resolveFeatureTree } from '../../lib/worktree/feature-truth.js';
|
|
18
18
|
|
|
@@ -51,11 +51,11 @@ export function isMorphDataReload(path) {
|
|
|
51
51
|
*/
|
|
52
52
|
export function watchTargets(projectDir) {
|
|
53
53
|
const morphDirs = [join(projectDir, '.morph')];
|
|
54
|
-
for (const
|
|
54
|
+
for (const container of containerDirs(projectDir)) {
|
|
55
55
|
let entries = [];
|
|
56
|
-
try { entries = readdirSync(
|
|
56
|
+
try { entries = readdirSync(container, { withFileTypes: true }); } catch { continue; }
|
|
57
57
|
for (const e of entries) {
|
|
58
|
-
if (e.isDirectory()) morphDirs.push(join(
|
|
58
|
+
if (e.isDirectory()) morphDirs.push(join(container, e.name, '.morph'));
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
61
|
const targets = [];
|
package/src/lib/ai/code-audit.js
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
import { readFileSync } from 'node:fs';
|
|
26
26
|
import { relative } from 'node:path';
|
|
27
27
|
import { collectProjectFiles } from './ai-project-scan.js';
|
|
28
|
-
import { stripComments } from '
|
|
28
|
+
import { stripComments } from '../../../framework/hooks/shared/comment-stripper.js';
|
|
29
29
|
|
|
30
30
|
/** Símbolos renomeados antes do 1.0 GA, e o nome vivo de cada um. */
|
|
31
31
|
export const RENAMED_SYMBOLS = {
|
|
@@ -22,7 +22,7 @@ import { readFileSync } from 'node:fs';
|
|
|
22
22
|
import { existsSync } from 'node:fs';
|
|
23
23
|
import { isAbsolute, join, basename } from 'node:path';
|
|
24
24
|
import { collectProjectFiles } from './ai-project-scan.js';
|
|
25
|
-
import { jsoncToJson } from '
|
|
25
|
+
import { jsoncToJson } from '../../../framework/hooks/shared/comment-stripper.js';
|
|
26
26
|
|
|
27
27
|
/** Limite de tamanho para um JSON candidato (bytes de texto) — evita ler dumps. */
|
|
28
28
|
const MAX_JSON_CHARS = 512 * 1024;
|