@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
|
@@ -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
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
75
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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(
|
|
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
|
|
8
|
+
* @module comment-scanner
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { stripComments } from '
|
|
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
|
-
* - `
|
|
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
|
+
}
|