@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.
Files changed (102) hide show
  1. package/CLAUDE.md +14 -4
  2. package/README.md +3 -3
  3. package/bin/morph-spec.js +48 -2
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +1 -1
  6. package/docs/QUICKSTART.md +1 -1
  7. package/framework/CLAUDE.md +6 -5
  8. package/framework/MORPH.md +10 -9
  9. package/framework/agents.json +7 -7
  10. package/framework/docs/CLI.md +10 -3
  11. package/framework/evals/proof-rubrics/mutation-proof.md +43 -13
  12. package/framework/hooks/README.md +1 -0
  13. package/framework/hooks/claude-code/core/chain.js +2 -0
  14. package/framework/hooks/claude-code/core/comment-guard.js +107 -0
  15. package/framework/hooks/claude-code/core/heavy-gate.js +72 -0
  16. package/framework/hooks/claude-code/core/heavy-release.js +34 -0
  17. package/framework/hooks/claude-code/core/worktree-context.js +21 -20
  18. package/framework/hooks/claude-code/core/worktree-create.js +4 -4
  19. package/framework/hooks/shared/agent-reap.js +89 -0
  20. package/framework/hooks/shared/comment-rules.js +171 -0
  21. package/{src/lib/validators/code-budget → framework/hooks/shared}/comment-scanner.js +2 -2
  22. package/{src/lib/ai → framework/hooks/shared}/comment-stripper.js +2 -1
  23. package/framework/hooks/shared/e2e-check.js +3 -1
  24. package/framework/hooks/shared/graph-refresh.js +1 -9
  25. package/framework/hooks/shared/heavy-command.js +128 -0
  26. package/framework/hooks/shared/heavy-config.js +65 -0
  27. package/framework/hooks/shared/heavy-holders.js +85 -0
  28. package/framework/hooks/shared/heavy-slots.js +207 -0
  29. package/framework/hooks/shared/process-alive.js +8 -0
  30. package/framework/hooks/shared/source-files.js +34 -0
  31. package/framework/hooks/shared/worktree-root.js +93 -0
  32. package/framework/schemas/config.schema.json +80 -0
  33. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  34. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  35. package/framework/skills/core/morph-apply/SKILL.md +11 -4
  36. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  37. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  38. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  39. package/framework/skills/core/morph-implement/SKILL.md +12 -5
  40. package/framework/skills/core/morph-machine/SKILL.md +46 -0
  41. package/framework/skills/core/morph-media/SKILL.md +1 -1
  42. package/framework/skills/core/morph-plan/SKILL.md +1 -1
  43. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  44. package/framework/skills/core/morph-review/SKILL.md +45 -16
  45. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  46. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  47. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  48. package/framework/standards/PROVENANCE.json +10 -10
  49. package/framework/standards/STANDARDS.json +1 -1
  50. package/framework/standards/architecture/code-comments.md +9 -2
  51. package/framework/standards/frontend/design-system/ai-video-generation.md +2 -0
  52. package/framework/standards/frontend/scroll-driven/frame-scrub.md +11 -1
  53. package/package.json +1 -1
  54. package/src/commands/e2e/e2e.js +27 -8
  55. package/src/commands/mcp/mcp-setup.js +10 -9
  56. package/src/commands/project/doctor-procs.js +162 -0
  57. package/src/commands/project/doctor.js +42 -4
  58. package/src/commands/project/heavy.js +61 -0
  59. package/src/commands/project/init.js +1 -1
  60. package/src/commands/project/update.js +4 -0
  61. package/src/commands/project/worktree-reap.js +34 -0
  62. package/src/commands/project/worktree.js +78 -27
  63. package/src/commands/validation/mutate.js +200 -0
  64. package/src/commands/validation/verify.js +26 -1
  65. package/src/dashboard/scanner/features-scanner.js +4 -4
  66. package/src/dashboard/scanner/project-api.js +4 -4
  67. package/src/lib/ai/code-audit.js +1 -1
  68. package/src/lib/ai/registry-audit.js +1 -1
  69. package/src/lib/detectors/claude-config-detector.js +30 -1
  70. package/src/lib/heavy/cleanup.js +70 -0
  71. package/src/lib/heavy/gate.js +96 -0
  72. package/src/lib/installers/mcp-installer.js +15 -2
  73. package/src/lib/mutation/adapters.js +90 -0
  74. package/src/lib/mutation/commands.js +60 -0
  75. package/src/lib/mutation/manifest.js +138 -0
  76. package/src/lib/mutation/plan.js +36 -0
  77. package/src/lib/mutation/report.js +66 -0
  78. package/src/lib/mutation/runner.js +351 -0
  79. package/src/lib/mutation/schemata.js +60 -0
  80. package/src/lib/mutation/snapshot.js +69 -0
  81. package/src/lib/mutation/subset.js +75 -0
  82. package/src/lib/mutation/trx.js +51 -0
  83. package/src/lib/procs/classify.js +91 -0
  84. package/src/lib/procs/list.js +124 -0
  85. package/src/lib/procs/mcp-config.js +103 -0
  86. package/src/lib/procs/prune.js +25 -0
  87. package/src/lib/procs/wslconfig.js +70 -0
  88. package/src/lib/projects/registry.js +6 -2
  89. package/src/lib/tasks/dotnet-test-introspection.js +1 -1
  90. package/src/lib/tasks/linux-runner.js +30 -3
  91. package/src/lib/tasks/test-runner.js +1 -1
  92. package/src/lib/validators/code-budget/code-budget-validator.js +4 -6
  93. package/src/lib/validators/code-budget/index.js +1 -1
  94. package/src/lib/validators/reuse/symbol-scanner.js +1 -28
  95. package/src/lib/validators/verify-runner.js +26 -8
  96. package/src/lib/worktree/agent-reaper.js +247 -0
  97. package/src/lib/worktree/dirs.js +3 -32
  98. package/src/lib/worktree/vscode-settings.js +113 -0
  99. package/src/lib/worktree/worktree-audit.js +49 -5
  100. package/src/lib/worktree/worktree-include.js +46 -0
  101. package/src/scripts/setup-infra.js +4 -0
  102. package/src/utils/hooks-installer.js +14 -6
@@ -51,34 +51,35 @@ import { acquireClaim, foreignClaim, claimAge } from '../../shared/claim-registr
51
51
  import { rotateActivityLogIfLarge } from '../../shared/activity-logger.js';
52
52
  import { rotateEventsLogIfLarge } from '../../shared/telemetry-logger.js';
53
53
  import { kickGraphRefresh, seedFromPrimary } from '../../shared/graph-refresh.js';
54
+ import { kickAgentReap } from '../../shared/agent-reap.js';
55
+ import { isInContainer } from '../../shared/worktree-root.js';
56
+ import { isSecondaryWorktree, resolveGitCommonDir } from '../../shared/git-paths.js';
54
57
 
55
58
  function projectDir() {
56
59
  return process.env.CLAUDE_PROJECT_DIR || process.cwd();
57
60
  }
58
61
 
59
- function gitRevParse(arg, dir) {
60
- return execSync(`git rev-parse ${arg}`, { cwd: dir, stdio: ['pipe', 'pipe', 'pipe'] })
61
- .toString()
62
- .trim();
63
- }
62
+ const samePath = (a, b) => (process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b);
64
63
 
65
- /**
66
- * A secondary git worktree has a `--git-dir` (worktree-private, under
67
- * `.../.git/worktrees/{name}`) distinct from its `--git-common-dir` (the
68
- * primary repo's shared `.git`). In the primary root both resolve to the
69
- * same path. Any git error (not a repo at all) is treated as "not a
70
- * worktree" — fail-open.
71
- */
72
- function isSecondaryWorktree(dir) {
64
+ // Inside a worktree Claude Code keeps CLAUDE_PROJECT_DIR at the project root and moves only the payload `cwd`.
65
+ function sessionDir(payload) {
66
+ const base = projectDir();
67
+ const cwd = typeof payload?.cwd === 'string' ? payload.cwd : '';
68
+ if (!cwd || samePath(resolve(cwd), resolve(base))) return base;
73
69
  try {
74
- const gitDir = gitRevParse('--git-dir', dir);
75
- const commonDir = gitRevParse('--git-common-dir', dir);
76
- return gitDir !== commonDir;
70
+ const common = resolveGitCommonDir(cwd);
71
+ return common && samePath(resolve(common), resolve(resolveGitCommonDir(base) ?? '')) && isSecondaryWorktree(cwd) ? cwd : base;
77
72
  } catch {
78
- return false;
73
+ return base;
79
74
  }
80
75
  }
81
76
 
77
+ function gitRevParse(arg, dir) {
78
+ return execSync(`git rev-parse ${arg}`, { cwd: dir, stdio: ['pipe', 'pipe', 'pipe'] })
79
+ .toString()
80
+ .trim();
81
+ }
82
+
82
83
  /** Feature name from the current branch, if it matches `morph/{feature}`. */
83
84
  function featureFromBranch(dir) {
84
85
  try {
@@ -118,8 +119,7 @@ function isOutsideContainer(dir) {
118
119
  try {
119
120
  const common = resolve(dir, gitRevParse('--git-common-dir', dir)).replace(/\\/g, '/');
120
121
  const root = common.replace(/\/\.git$/, '');
121
- const norm = resolve(dir).replace(/\\/g, '/');
122
- return !['worktrees', '.worktrees'].some((d) => norm.startsWith(`${root}/${d}/`));
122
+ return !isInContainer(root, dir);
123
123
  } catch {
124
124
  return false;
125
125
  }
@@ -280,7 +280,7 @@ function claimBlock(dir, feature, sessionId) {
280
280
  */
281
281
  export async function run(payload) {
282
282
  try {
283
- const dir = projectDir();
283
+ const dir = sessionDir(payload);
284
284
  // The SessionStart payload carries session_id — the identity the claim
285
285
  // registry uses to tell "me" from "another session".
286
286
  const sessionId = payload?.session_id || `pid-${process.pid}`;
@@ -303,6 +303,7 @@ export async function run(payload) {
303
303
  // Returns immediately — nothing here waits on graphify.
304
304
  try { seedFromPrimary(dir); } catch { /* non-blocking — a no-op on the root */ }
305
305
  try { kickGraphRefresh(dir); } catch { /* non-blocking */ }
306
+ try { kickAgentReap(dir); } catch { /* non-blocking */ }
306
307
 
307
308
  if (isSecondaryWorktree(dir)) {
308
309
  const task = taskFromBranch(dir);
@@ -43,8 +43,7 @@ import { homedir } from 'os';
43
43
  import { stateExists } from '../../shared/state-reader.js';
44
44
  import { resolveGitCommonDir } from '../../shared/git-paths.js';
45
45
  import { runMorphSpec } from '../../shared/morph-cli.js';
46
-
47
- const WORKTREES_DIR = 'worktrees';
46
+ import { newWorktreesDir } from '../../shared/worktree-root.js';
48
47
 
49
48
  const fwd = (p) => String(p).replace(/\\/g, '/');
50
49
 
@@ -152,14 +151,15 @@ function defaultBranch(root) {
152
151
  */
153
152
  function gitFallback(root, name, base) {
154
153
  const branch = `morph-task/${name}`;
155
- const path = `${root}/${WORKTREES_DIR}/${name}`;
154
+ const container = newWorktreesDir(root);
155
+ const path = `${container}/${name}`;
156
156
  if (existsSync(path)) return { path };
157
157
  try { git(['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], root); }
158
158
  catch {
159
159
  const from = base || defaultBranch(root);
160
160
  git(from ? ['branch', branch, from] : ['branch', branch], root);
161
161
  }
162
- mkdirSync(join(root, WORKTREES_DIR), { recursive: true });
162
+ mkdirSync(container, { recursive: true });
163
163
  git(['worktree', 'add', path, branch], root);
164
164
  return { path };
165
165
  }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * agent-reap.js — dispara `morph-spec worktree reap` em background no SessionStart.
3
+ *
4
+ * O hook de SessionStart tem 5 s de timeout e o reaper mede git por árvore: por isso o
5
+ * disparo só olha o disco (nenhum subprocesso) e solta este arquivo como worker DESTACADO
6
+ * (`--run <raiz>`), que chama o CLI. Só a raiz: o worker nasce com `cwd` no git common dir
7
+ * e nunca segura o diretório de uma worktree.
8
+ *
9
+ * Fail-open em tudo. `MORPH_WORKTREE_REAP=0` desliga.
10
+ *
11
+ * @module agent-reap
12
+ */
13
+
14
+ import { spawn } from 'child_process';
15
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs';
16
+ import { join, resolve } from 'path';
17
+ import { fileURLToPath } from 'url';
18
+ import { isSecondaryWorktree, resolveGitCommonDir } from './git-paths.js';
19
+ import { primaryTree } from './graph-freshness.js';
20
+ import { runMorphSpec } from './morph-cli.js';
21
+
22
+ const WORKER = fileURLToPath(import.meta.url);
23
+ const THROTTLE_MS = 10 * 60_000;
24
+ const WORKER_TIMEOUT_MS = 15 * 60_000;
25
+ const ADMIN_DIR = /^agent-[0-9a-f]+\d*$/;
26
+ const AGENT_BRANCH_FILE = /^agent-[0-9a-f]+$/;
27
+
28
+ function anyEntry(dir, pattern) {
29
+ try { return readdirSync(dir).some((name) => pattern.test(name)); } catch { return false; }
30
+ }
31
+
32
+ /** Uma worktree de agent registrada, ou uma branch `morph-task/agent-*` (solta ou empacotada). */
33
+ export function hasAgentLeftovers(common) {
34
+ if (anyEntry(join(common, 'worktrees'), ADMIN_DIR)) return true;
35
+ if (anyEntry(join(common, 'refs', 'heads', 'morph-task'), AGENT_BRANCH_FILE)) return true;
36
+ try {
37
+ return /refs\/heads\/morph-task\/agent-[0-9a-f]+/.test(readFileSync(join(common, 'packed-refs'), 'utf8'));
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ function recentlyKicked(marker, now) {
44
+ try {
45
+ const age = now - Number(readFileSync(marker, 'utf8'));
46
+ return age >= 0 && age < THROTTLE_MS;
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * @param {string} dir - diretório do projeto da sessão
54
+ * @returns {{ kicked: boolean, reason?: string, pid?: number }}
55
+ */
56
+ export function kickAgentReap(dir, { spawnFn = spawn, env = process.env, now = Date.now } = {}) {
57
+ if (env.MORPH_WORKTREE_REAP === '0') return { kicked: false, reason: 'disabled' };
58
+ const common = resolveGitCommonDir(dir);
59
+ const root = common && primaryTree(dir);
60
+ if (!root) return { kicked: false, reason: 'not-a-repo' };
61
+ if (isSecondaryWorktree(dir)) return { kicked: false, reason: 'secondary-worktree' };
62
+ if (!hasAgentLeftovers(common)) return { kicked: false, reason: 'nothing-to-reap' };
63
+
64
+ const marker = join(common, 'morph-spec', 'agent-reap.kick');
65
+ if (recentlyKicked(marker, now())) return { kicked: false, reason: 'throttled' };
66
+ try {
67
+ mkdirSync(join(common, 'morph-spec'), { recursive: true });
68
+ writeFileSync(marker, String(now()));
69
+ const child = spawnFn(process.execPath, [WORKER, '--run', root], {
70
+ cwd: common, env, detached: true, stdio: 'ignore', windowsHide: true,
71
+ });
72
+ child.on?.('error', () => {});
73
+ child.unref?.();
74
+ return { kicked: true, pid: child.pid };
75
+ } catch {
76
+ return { kicked: false, reason: 'spawn-failed' };
77
+ }
78
+ }
79
+
80
+ const isMain = (() => {
81
+ const argv1 = process.argv[1];
82
+ if (!argv1) return false;
83
+ const norm = (p) => (process.platform === 'win32' ? resolve(p).toLowerCase() : resolve(p));
84
+ return norm(argv1) === norm(WORKER);
85
+ })();
86
+
87
+ if (isMain && process.argv[2] === '--run' && process.argv[3] && existsSync(process.argv[3])) {
88
+ try { runMorphSpec(['worktree', 'reap', '--json'], { cwd: process.argv[3], timeoutMs: WORKER_TIMEOUT_MS }); } catch { /* fail-open */ }
89
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * The comment shapes the `architecture-code-comments` standard forbids in
3
+ * every case, judged only on the comments an edit ADDED. Whether a short
4
+ * comment restates the code is not decidable here; that stays with the
5
+ * Gate 3 evaluator and the `code-budget` density.
6
+ *
7
+ * @module comment-rules
8
+ */
9
+
10
+ import { existsSync, readdirSync, readFileSync } from 'fs';
11
+ import { dirname, join } from 'path';
12
+
13
+ import { citesProcess, commentLines } from './comment-scanner.js';
14
+
15
+ export const MAX_BLOCK_LINES = 3;
16
+
17
+ const ALLOWED = [
18
+ /READS STANDARDS?:/,
19
+ /\bVERIFY:/,
20
+ /eslint-(?:disable|enable)/,
21
+ /@ts-(?:ignore|expect-error|nocheck|check)/,
22
+ /<auto-generated/i,
23
+ /prettier-ignore/,
24
+ /ReSharper\s+(?:disable|restore)/,
25
+ /@(?:vitest|jest)-environment/,
26
+ /^\/\/\/\s*<reference\b/,
27
+ /^#!/,
28
+ /istanbul ignore|c8 ignore|v8 ignore/,
29
+ ];
30
+
31
+ const DECORATION = /[-=*#~_+]{3,}/;
32
+ const REGION = /^\s*#\s*(?:end)?region\b/;
33
+
34
+ function bodyOf(text) {
35
+ return text.replace(/^(?:\/\/+|\/\*+|\*+)/, '').replace(/\*+\/$/, '').trim();
36
+ }
37
+
38
+ function isBanner(text) {
39
+ const body = bodyOf(text);
40
+ if (REGION.test(body)) return true;
41
+ if (!DECORATION.test(body)) return false;
42
+ const words = body.replace(/[-=*#~_+|]/g, '').trim();
43
+ return words === '' || /^[-=*#~_+]{3,}.*[-=*#~_+]{3,}$/.test(body);
44
+ }
45
+
46
+ function isDocComment(text, isCs) {
47
+ return isCs ? /^\/\/\/(?!\/)/.test(text) : /^\/\*\*(?!\*)/.test(text);
48
+ }
49
+
50
+ function nearest(startDir, test) {
51
+ let dir = startDir;
52
+ for (;;) {
53
+ const hit = test(dir);
54
+ if (hit) return hit;
55
+ const parent = dirname(dir);
56
+ if (parent === dir) return null;
57
+ dir = parent;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Doc-comments are allowed only on a package consumed outside the repo. When
63
+ * that cannot be read from the nearest project file, the rule stays quiet.
64
+ *
65
+ * @param {string} file - absolute path of the edited file
66
+ * @returns {boolean}
67
+ */
68
+ export function docCommentsForbidden(file) {
69
+ const dir = dirname(file);
70
+ if (/\.cs$/i.test(file)) {
71
+ const proj = nearest(dir, (d) => {
72
+ try {
73
+ const name = readdirSync(d).find((n) => /\.csproj$/i.test(n));
74
+ return name ? join(d, name) : null;
75
+ } catch {
76
+ return null;
77
+ }
78
+ });
79
+ if (!proj) return false;
80
+ const xml = readFileSync(proj, 'utf8');
81
+ return !/<(?:IsPackable|GeneratePackageOnBuild)>\s*true\s*</i.test(xml) && !/<PackageId>/i.test(xml);
82
+ }
83
+ const pkg = nearest(dir, (d) => (existsSync(join(d, 'package.json')) ? join(d, 'package.json') : null));
84
+ if (!pkg) return false;
85
+ return JSON.parse(readFileSync(pkg, 'utf8')).private === true;
86
+ }
87
+
88
+ /** Lines of `after` whose comment is not one `before` already had (moved comments do not count). */
89
+ function addedCommentLines(before, after, file) {
90
+ const remaining = new Map();
91
+ for (const text of commentLines(before, file).values()) {
92
+ remaining.set(text, (remaining.get(text) ?? 0) + 1);
93
+ }
94
+ const added = new Map();
95
+ for (const [line, text] of commentLines(after, file)) {
96
+ const left = remaining.get(text) ?? 0;
97
+ if (left > 0) remaining.set(text, left - 1);
98
+ else added.set(line, text);
99
+ }
100
+ return added;
101
+ }
102
+
103
+ function addedRegionLines(before, after) {
104
+ const old = new Map();
105
+ for (const row of before.split('\n')) {
106
+ if (REGION.test(row)) old.set(row.trim(), (old.get(row.trim()) ?? 0) + 1);
107
+ }
108
+ const out = [];
109
+ after.split('\n').forEach((row, i) => {
110
+ if (!REGION.test(row)) return;
111
+ const left = old.get(row.trim()) ?? 0;
112
+ if (left > 0) old.set(row.trim(), left - 1);
113
+ else out.push({ line: i + 1, text: row.trim() });
114
+ });
115
+ return out;
116
+ }
117
+
118
+ /**
119
+ * @param {{ file: string, before: string, after: string, docCommentsForbidden?: boolean }} input
120
+ * @returns {Array<{ line: number, rule: 'process'|'banner'|'doc-comment'|'long-block', text: string }>}
121
+ */
122
+ export function commentFindings({ file, before, after, docCommentsForbidden: docForbidden = false }) {
123
+ const isCs = /\.cs$/i.test(file);
124
+ const all = commentLines(after, file);
125
+ const added = addedCommentLines(before, after, file);
126
+ const findings = [];
127
+ const flagged = new Set();
128
+ const flag = (line, rule, text) => {
129
+ if (flagged.has(line)) return;
130
+ flagged.add(line);
131
+ findings.push({ line, rule, text });
132
+ };
133
+
134
+ for (const [line, text] of added) {
135
+ if (ALLOWED.some((re) => re.test(text))) continue;
136
+ if (citesProcess(text)) flag(line, 'process', text);
137
+ else if (isBanner(text)) flag(line, 'banner', text);
138
+ }
139
+ for (const { line, text } of addedRegionLines(before, after)) flag(line, 'banner', text);
140
+
141
+ if (docForbidden) {
142
+ for (const [line, text] of added) {
143
+ const opensBlock = !isCs || !isDocComment(all.get(line - 1) ?? '', isCs);
144
+ if (isDocComment(text, isCs) && opensBlock) flag(line, 'doc-comment', text);
145
+ }
146
+ }
147
+
148
+ const rows = after.split('\n');
149
+ const lines = [...all.keys()]
150
+ .filter((line) => (rows[line - 1] ?? '').trim() === all.get(line))
151
+ .sort((a, b) => a - b);
152
+ let run = [];
153
+ const closeRun = () => {
154
+ if (run.length && isDocComment(all.get(run[0]), isCs)) {
155
+ run = [];
156
+ return;
157
+ }
158
+ const addedInRun = run.filter((l) => added.has(l) && !ALLOWED.some((re) => re.test(all.get(l))));
159
+ if (addedInRun.length > MAX_BLOCK_LINES) {
160
+ flag(run[0], 'long-block', `${run.length} linhas: ${all.get(run[0])}`);
161
+ }
162
+ run = [];
163
+ };
164
+ for (const line of lines) {
165
+ if (run.length && line !== run[run.length - 1] + 1) closeRun();
166
+ run.push(line);
167
+ }
168
+ closeRun();
169
+
170
+ return findings.sort((a, b) => a.line - b.line);
171
+ }
@@ -5,10 +5,10 @@
5
5
  * comentário de string para o `doctor --ai`, com a sintaxe de TS/JS ligada
6
6
  * fora do C#. Um regex por linha confundiria `https://` com comentário.
7
7
  *
8
- * @module code-budget-comment-scanner
8
+ * @module comment-scanner
9
9
  */
10
10
 
11
- import { stripComments } from '../../ai/comment-stripper.js';
11
+ import { stripComments } from './comment-stripper.js';
12
12
 
13
13
  /** Vocabulário de processo do morph-spec, fora o id de task (abaixo). */
14
14
  const PROCESS_CITATION = [
@@ -11,7 +11,8 @@
11
11
  * canônico `model-registry.v2.json` usa e que o leitor C# do kit aceita de
12
12
  * propósito (`JsonCommentHandling.Skip`, `AllowTrailingCommas`);
13
13
  * - `dotnet-test-introspection.js` — lê o `global.json` por `jsoncToJson`;
14
- * - `validators/code-budget/comment-scanner.js` — acha as linhas de
14
+ * - `comment-scanner.js` (ao lado; lido pelo `code-budget` e pelo hook
15
+ * `comment-guard`) — acha as linhas de
15
16
  * comentário de `.cs`/`.ts`/`.js`, e é o único que liga a opção `js`.
16
17
  *
17
18
  * **Por que não um regex.** O exemplar tem `"http://localhost:11434/v1"` e uma
@@ -675,7 +675,7 @@ function sleep(ms) {
675
675
 
676
676
  /** Tear the stack down. Best-effort: teardown failure never fails a check. */
677
677
  export function tearDownStack(cwd, { composeFile, projectName, env, volumes = false } = {}, exec = defaultExec) {
678
- const args = [...composeArgs(composeFile, projectName), 'down'];
678
+ const args = [...composeArgs(composeFile, projectName), 'down', '--remove-orphans'];
679
679
  if (volumes) args.push('-v');
680
680
  const result = exec('docker', args, { cwd, env });
681
681
  return { status: result.code === 0 ? 'pass' : 'fail', output: result.output };
@@ -863,6 +863,7 @@ export function runE2eCheck(cwd, options = {}) {
863
863
  insideWorktree = isSecondaryWorktree(cwd),
864
864
  healthTimeout = DEFAULT_HEALTH_TIMEOUT,
865
865
  specTimeout = DEFAULT_SPEC_TIMEOUT,
866
+ beforeHeavy = null,
866
867
  _exec: exec = defaultExec,
867
868
  } = options;
868
869
 
@@ -910,6 +911,7 @@ export function runE2eCheck(cwd, options = {}) {
910
911
  : {};
911
912
  const composeEnv = { COMPOSE_PROJECT_NAME: projectName, ...authEnv, ...(env || {}) };
912
913
 
914
+ if (typeof beforeHeavy === 'function') beforeHeavy();
913
915
  const up = bringUpStack(
914
916
  cwd,
915
917
  { composeFile, projectName, gitCommonDir, env: composeEnv, forceFresh, healthTimeout },
@@ -50,6 +50,7 @@ import { fileURLToPath } from 'url';
50
50
  import { readPrimaryHead, resolveGitCommonDir, workTreeTop } from './git-paths.js';
51
51
  import { findFirstOnPath, quoteForCmd } from './morph-cli.js';
52
52
  import { ensureCommonExclude } from './git-exclude.js';
53
+ import { pidAlive } from './process-alive.js';
53
54
  import {
54
55
  GRAPH_DIR, graphFile, graphFreshness, ignoreRulesHash, primaryTree, readRefreshRecord,
55
56
  recordRefreshRun, refreshPending, treeKey, writeRefreshRecord,
@@ -110,15 +111,6 @@ function mtimeOf(path) {
110
111
  }
111
112
  }
112
113
 
113
- function pidAlive(pid) {
114
- try {
115
- process.kill(pid, 0);
116
- return true;
117
- } catch (err) {
118
- return err?.code === 'EPERM';
119
- }
120
- }
121
-
122
114
  /**
123
115
  * Estado do lock do repositório — o que `graph status` mostra como "refresh em
124
116
  * andamento". A idade conta da última renovação: um lock de processo morto,
@@ -0,0 +1,128 @@
1
+ import { tokenize } from './command-tokens.js';
2
+
3
+ const DOTNET_HEAVY = new Set(['build', 'test', 'publish', 'pack', 'msbuild', 'vstest']);
4
+ const PACKAGE_MANAGERS = new Set(['npm', 'pnpm', 'yarn', 'bun']);
5
+ const HEAVY_SCRIPT = /^(test|build)(:.+)?$/;
6
+ const WRAPPERS = new Set(['time', 'nohup', 'nice', 'command', 'exec', 'env']);
7
+ const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
8
+ const INFO_FLAGS = new Set(['--help', '-h', '--version', '-?']);
9
+
10
+ function splitSegments(command) {
11
+ const segments = [];
12
+ let current = '';
13
+ let quote = null;
14
+ const s = String(command || '');
15
+ for (let i = 0; i < s.length; i++) {
16
+ const ch = s[i];
17
+ if (quote) {
18
+ current += ch;
19
+ if (ch === quote) quote = null;
20
+ else if (ch === '\\' && quote === '"') current += s[++i] ?? '';
21
+ continue;
22
+ }
23
+ if (ch === '"' || ch === "'") { quote = ch; current += ch; continue; }
24
+ if (ch === '\\') { current += ch + (s[++i] ?? ''); continue; }
25
+ if (';|&\n'.includes(ch)) { segments.push(current); current = ''; continue; }
26
+ current += ch;
27
+ }
28
+ segments.push(current);
29
+ return segments;
30
+ }
31
+
32
+ function executable(token) {
33
+ return token.replace(/^.*[\\/]/, '').replace(/\.(exe|cmd|bat|ps1)$/i, '').toLowerCase();
34
+ }
35
+
36
+ function skipWrappers(tokens) {
37
+ let i = 0;
38
+ for (;;) {
39
+ const t = tokens[i];
40
+ if (t === undefined) return [];
41
+ if (ASSIGNMENT.test(t)) { i++; continue; }
42
+ if (t === 'timeout') { i += 2; continue; }
43
+ if (WRAPPERS.has(executable(t))) { i++; continue; }
44
+ return tokens.slice(i);
45
+ }
46
+ }
47
+
48
+ const DOCKER_VALUE_FLAGS = new Set(['-f', '--file', '-p', '--project-name', '--profile', '--env-file', '--project-directory', '-H', '--host', '--context']);
49
+
50
+ function firstArg(args, valueFlags = new Set()) {
51
+ for (let i = 0; i < args.length; i++) {
52
+ if (!args[i].startsWith('-')) return args[i];
53
+ if (valueFlags.has(args[i])) i++;
54
+ }
55
+ return undefined;
56
+ }
57
+
58
+ function kindOfTokens(tokens) {
59
+ const [head, ...args] = tokens;
60
+ if (!head || args.some((a) => INFO_FLAGS.has(a))) return null;
61
+ const exe = executable(head);
62
+ const sub = firstArg(args, exe === 'docker' || exe === 'docker-compose' ? DOCKER_VALUE_FLAGS : undefined);
63
+
64
+ if (exe === 'dotnet') return DOTNET_HEAVY.has(sub) ? `dotnet ${sub}` : null;
65
+ if (exe === 'msbuild') return 'msbuild';
66
+ if (exe === 'vitest' || exe === 'jest') return exe;
67
+ if (exe === 'playwright') return sub === 'test' ? 'playwright test' : null;
68
+ if (exe === 'next' || exe === 'vite') return sub === 'build' ? `${exe} build` : null;
69
+
70
+ if (PACKAGE_MANAGERS.has(exe)) {
71
+ if (sub === 'test' || sub === 'build') return `${exe} ${sub}`;
72
+ if (sub === 'run' || sub === 'run-script') {
73
+ const script = args.slice(args.indexOf(sub) + 1).find((t) => !t.startsWith('-'));
74
+ return script && HEAVY_SCRIPT.test(script) ? `${exe} run ${script}` : null;
75
+ }
76
+ return null;
77
+ }
78
+
79
+ if (exe === 'npx' || exe === 'bunx') {
80
+ if (sub === 'vitest' || sub === 'jest') return `npx ${sub}`;
81
+ const rest = args.slice(args.indexOf(sub) + 1).find((t) => !t.startsWith('-'));
82
+ if (sub === 'playwright') return rest === 'test' ? 'npx playwright test' : null;
83
+ if (sub === 'next') return rest === 'build' ? 'npx next build' : null;
84
+ return null;
85
+ }
86
+
87
+ if (exe === 'docker-compose') return sub === 'up' || sub === 'build' ? `docker-compose ${sub}` : null;
88
+ if (exe === 'docker') {
89
+ if (sub === 'build' || sub === 'buildx') return `docker ${sub}`;
90
+ if (sub === 'compose') {
91
+ const action = firstArg(args.slice(args.indexOf(sub) + 1), DOCKER_VALUE_FLAGS);
92
+ return action === 'up' || action === 'build' ? `docker compose ${action}` : null;
93
+ }
94
+ }
95
+ return null;
96
+ }
97
+
98
+ /**
99
+ * The first heavy command of a Bash command line, as a label, or null. Looks at the executable of
100
+ * each `;`/`&&`/`||`/`|`/newline segment, so `morph-spec heavy run -- dotnet test` and `echo "dotnet build"`
101
+ * are not heavy: the first runs under its own slot, the second runs nothing.
102
+ * @param {string} command
103
+ * @returns {string|null}
104
+ */
105
+ export function heavyKind(command) {
106
+ for (const segment of splitSegments(command)) {
107
+ const tokens = skipWrappers(tokenize(segment.replace(/^[\s({]+/, '').replace(/[\s)}]+$/, '')));
108
+ const kind = kindOfTokens(tokens);
109
+ if (kind) return kind;
110
+ }
111
+ return null;
112
+ }
113
+
114
+ /**
115
+ * How long a foreground Bash call can run: its own `timeout` (capped at the platform maximum) plus slack.
116
+ * The lease has no live process to watch, so this is the only thing that frees it after a denied or
117
+ * interrupted call.
118
+ */
119
+ const BASH_DEFAULT_TIMEOUT_MS = 2 * 60_000;
120
+ const BASH_MAX_TIMEOUT_MS = 10 * 60_000;
121
+ const LEASE_SLACK_MS = 30_000;
122
+
123
+ export function leaseTtlMs(toolInput, env = process.env) {
124
+ const fallback = Number(env.BASH_DEFAULT_TIMEOUT_MS) || BASH_DEFAULT_TIMEOUT_MS;
125
+ const max = Math.max(Number(env.BASH_MAX_TIMEOUT_MS) || BASH_MAX_TIMEOUT_MS, fallback);
126
+ const asked = Number(toolInput?.timeout);
127
+ return Math.min(Number.isFinite(asked) && asked > 0 ? asked : fallback, max) + LEASE_SLACK_MS;
128
+ }
@@ -0,0 +1,65 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ const CONFIG_FILE = join('.morph', 'config', 'config.json');
5
+
6
+ export const HEAVY_DEFAULTS = Object.freeze({
7
+ enabled: true,
8
+ slots: 2,
9
+ minFreeMemGb: 4,
10
+ waitTimeoutSec: 600,
11
+ maxHoldMinutes: 90,
12
+ shutdownBuildServer: true,
13
+ disableNodeReuse: false,
14
+ gateBash: false,
15
+ });
16
+
17
+ const isBool = (v) => typeof v === 'boolean';
18
+ const VALID = {
19
+ enabled: isBool,
20
+ slots: (v) => Number.isInteger(v) && v >= 1,
21
+ minFreeMemGb: (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0,
22
+ waitTimeoutSec: (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0,
23
+ maxHoldMinutes: (v) => typeof v === 'number' && Number.isFinite(v) && v > 0,
24
+ shutdownBuildServer: isBool,
25
+ disableNodeReuse: isBool,
26
+ gateBash: isBool,
27
+ };
28
+
29
+ export function readProjectConfig(projectPath) {
30
+ const file = join(projectPath, CONFIG_FILE);
31
+ if (!existsSync(file)) return null;
32
+ try {
33
+ return JSON.parse(readFileSync(file, 'utf8').replace(/^/, ''));
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ export function resolveHeavyConfig(config, env = process.env) {
40
+ const out = { ...HEAVY_DEFAULTS };
41
+ const warnings = [];
42
+ const raw = config?.heavy;
43
+
44
+ if (raw !== undefined) {
45
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
46
+ warnings.push('`heavy` no config.json não é um objeto; usando os padrões');
47
+ } else {
48
+ for (const [key, value] of Object.entries(raw)) {
49
+ if (!(key in VALID)) warnings.push(`heavy.${key} não existe; ignorada`);
50
+ else if (VALID[key](value)) out[key] = value;
51
+ else warnings.push(`heavy.${key} inválida (${JSON.stringify(value)}); usando ${JSON.stringify(HEAVY_DEFAULTS[key])}`);
52
+ }
53
+ }
54
+ }
55
+
56
+ if (/^(off|0|false)$/i.test(env.MORPH_HEAVY ?? '')) out.enabled = false;
57
+ const envSlots = Number(env.MORPH_HEAVY_SLOTS);
58
+ if (env.MORPH_HEAVY_SLOTS !== undefined && VALID.slots(envSlots)) out.slots = envSlots;
59
+
60
+ return { ...out, warnings };
61
+ }
62
+
63
+ export function readHeavyConfig(projectPath, env = process.env) {
64
+ return resolveHeavyConfig(readProjectConfig(projectPath), env);
65
+ }