@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
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
export const MIN_ORPHAN_AGE_MS = 15 * 60 * 1000;
|
|
2
|
+
|
|
3
|
+
const PLAIN_CANDIDATES = new Set(['node', 'dotnet', 'testhost', 'esbuild']);
|
|
4
|
+
const CHROME_NAMES = new Set(['chrome', 'chromium', 'msedge', 'chrome-headless-shell']);
|
|
5
|
+
const BUILD_SERVER_NAMES = new Set(['vbcscompiler', 'msbuild']);
|
|
6
|
+
const BUILD_SERVER_CMD = /vbcscompiler|\/nodemode:\d|msbuild(?:taskhost)?\.dll|razorserver/i;
|
|
7
|
+
const AUTOMATION_CHROME = /--headless|--remote-debugging-pipe|playwright|puppeteer/i;
|
|
8
|
+
const CLAUDE_CLI = /@anthropic-ai[\\/]claude-code/i;
|
|
9
|
+
|
|
10
|
+
export function normalizeCmd(cmd) {
|
|
11
|
+
return String(cmd ?? '').toLowerCase().replace(/\\/g, '/');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function categorize(proc) {
|
|
15
|
+
const cmd = proc.cmd ?? '';
|
|
16
|
+
if (BUILD_SERVER_NAMES.has(proc.name)) return 'build-server';
|
|
17
|
+
if (proc.name === 'dotnet' && BUILD_SERVER_CMD.test(cmd)) return 'build-server';
|
|
18
|
+
if (CHROME_NAMES.has(proc.name)) {
|
|
19
|
+
return AUTOMATION_CHROME.test(cmd) && !/--type=/.test(cmd) ? 'chrome-headless' : null;
|
|
20
|
+
}
|
|
21
|
+
return PLAIN_CANDIDATES.has(proc.name) ? proc.name : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function isClaudeSession(proc) {
|
|
25
|
+
return proc.name === 'claude' || CLAUDE_CLI.test(proc.cmd ?? '');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function protectionReason(proc, { mcpTokens, holderPids }) {
|
|
29
|
+
if (holderPids.has(proc.pid)) return 'segura slot do semáforo pesado';
|
|
30
|
+
const cmd = normalizeCmd(proc.cmd);
|
|
31
|
+
if (/graph-refresh\.js/.test(cmd) && /--run\b/.test(cmd)) return 'worker de refresh do grafo';
|
|
32
|
+
const token = mcpTokens.find((t) => cmd.includes(t));
|
|
33
|
+
if (token) return `linha de comando de MCP configurado (${token})`;
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function classifyProcs(procs, { now = Date.now(), mcpTokens = [], holderPids = [], minAgeMs = MIN_ORPHAN_AGE_MS } = {}) {
|
|
38
|
+
const byPid = new Map(procs.map((p) => [p.pid, p]));
|
|
39
|
+
const tokens = mcpTokens.map((t) => String(t).toLowerCase());
|
|
40
|
+
const holders = new Set(holderPids);
|
|
41
|
+
|
|
42
|
+
const liveParent = (p) => {
|
|
43
|
+
const parent = byPid.get(p.ppid);
|
|
44
|
+
if (!parent) return null;
|
|
45
|
+
if (parent.startedMs !== null && p.startedMs !== null && parent.startedMs > p.startedMs) return null;
|
|
46
|
+
return parent;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const rows = [];
|
|
50
|
+
for (const proc of procs) {
|
|
51
|
+
const category = categorize(proc);
|
|
52
|
+
if (!category) continue;
|
|
53
|
+
const orphan = proc.ppid > 4 && liveParent(proc) === null;
|
|
54
|
+
const ageMs = proc.startedMs === null ? null : Math.max(0, now - proc.startedMs);
|
|
55
|
+
const row = {
|
|
56
|
+
pid: proc.pid,
|
|
57
|
+
ppid: proc.ppid,
|
|
58
|
+
name: proc.name,
|
|
59
|
+
category,
|
|
60
|
+
cmd: proc.cmd,
|
|
61
|
+
rssMb: Math.round(proc.rss / (1024 * 1024)),
|
|
62
|
+
startedMs: proc.startedMs,
|
|
63
|
+
ageMs,
|
|
64
|
+
orphan,
|
|
65
|
+
verdict: 'alive-parent',
|
|
66
|
+
reason: null,
|
|
67
|
+
};
|
|
68
|
+
if (orphan) {
|
|
69
|
+
const protectedBy = category === 'build-server' ? null : protectionReason(proc, { mcpTokens: tokens, holderPids: holders });
|
|
70
|
+
if (category === 'build-server') {
|
|
71
|
+
row.verdict = 'build-server';
|
|
72
|
+
row.reason = 'nó de build reutilizável pode servir outra sessão; encerre com `dotnet build-server shutdown` quando ocioso';
|
|
73
|
+
} else if (protectedBy) {
|
|
74
|
+
row.verdict = 'protected';
|
|
75
|
+
row.reason = protectedBy;
|
|
76
|
+
} else if (ageMs === null) {
|
|
77
|
+
row.verdict = 'protected';
|
|
78
|
+
row.reason = 'idade desconhecida';
|
|
79
|
+
} else if (ageMs < minAgeMs) {
|
|
80
|
+
row.verdict = 'protected';
|
|
81
|
+
row.reason = `mais novo que ${Math.round(minAgeMs / 60000)} min`;
|
|
82
|
+
} else {
|
|
83
|
+
row.verdict = 'orphan';
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
rows.push(row);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const sessions = procs.filter(isClaudeSession).length;
|
|
90
|
+
return { rows, sessions };
|
|
91
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
|
|
4
|
+
const PS_PROJECTION = "[pscustomobject]@{ pid = $_.ProcessId; ppid = $_.ParentProcessId; name = $_.Name; cmd = $_.CommandLine; rss = [int64]$_.WorkingSetSize; started = if ($_.CreationDate) { $_.CreationDate.ToUniversalTime().ToString('o') } else { $null } }";
|
|
5
|
+
const PS_PREFIX = '[Console]::OutputEncoding = [Text.Encoding]::UTF8; ';
|
|
6
|
+
|
|
7
|
+
export function defaultProcExec(file, args, { timeout = 30000 } = {}) {
|
|
8
|
+
try {
|
|
9
|
+
const output = execFileSync(file, args, {
|
|
10
|
+
encoding: 'utf8',
|
|
11
|
+
timeout,
|
|
12
|
+
windowsHide: true,
|
|
13
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
14
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
15
|
+
});
|
|
16
|
+
return { code: 0, output };
|
|
17
|
+
} catch (err) {
|
|
18
|
+
return {
|
|
19
|
+
code: err.status ?? 1,
|
|
20
|
+
output: `${err.stdout ?? ''}${err.stderr ?? ''}`,
|
|
21
|
+
missing: err.code === 'ENOENT',
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function normalizeProcName(name) {
|
|
27
|
+
return String(name ?? '').toLowerCase().replace(/\.exe$/, '');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function toProc(p) {
|
|
31
|
+
const startedMs = typeof p.startedMs === 'number' ? p.startedMs : p.started ? Date.parse(p.started) : null;
|
|
32
|
+
return {
|
|
33
|
+
pid: Number(p.pid),
|
|
34
|
+
ppid: Number(p.ppid ?? 0),
|
|
35
|
+
name: normalizeProcName(p.name),
|
|
36
|
+
cmd: typeof p.cmd === 'string' && p.cmd.length ? p.cmd : null,
|
|
37
|
+
rss: Number(p.rss ?? 0),
|
|
38
|
+
startedMs: Number.isFinite(startedMs) ? startedMs : null,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function parseWindowsProcs(text) {
|
|
43
|
+
const trimmed = String(text ?? '').trim();
|
|
44
|
+
if (!trimmed) return [];
|
|
45
|
+
const raw = JSON.parse(trimmed);
|
|
46
|
+
return (Array.isArray(raw) ? raw : [raw]).filter((p) => p && Number.isInteger(p.pid)).map(toProc);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function parseEtime(etime) {
|
|
50
|
+
const m = /^(?:(?:(\d+)-)?(\d+):)?(\d+):(\d+)$/.exec(String(etime).trim());
|
|
51
|
+
if (!m) return null;
|
|
52
|
+
const [, d = 0, h = 0, min, s] = m;
|
|
53
|
+
return ((Number(d) * 24 + Number(h)) * 60 + Number(min)) * 60 + Number(s);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function parsePosixProcs(text, now = Date.now()) {
|
|
57
|
+
const procs = [];
|
|
58
|
+
for (const line of String(text ?? '').split(/\r?\n/)) {
|
|
59
|
+
const m = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(\d+)\s+(\S+)\s*(.*)$/.exec(line);
|
|
60
|
+
if (!m) continue;
|
|
61
|
+
const seconds = parseEtime(m[3]);
|
|
62
|
+
procs.push(toProc({
|
|
63
|
+
pid: Number(m[1]),
|
|
64
|
+
ppid: Number(m[2]),
|
|
65
|
+
name: m[5].split('/').pop(),
|
|
66
|
+
cmd: m[6] || m[5],
|
|
67
|
+
rss: Number(m[4]) * 1024,
|
|
68
|
+
startedMs: seconds === null ? null : now - seconds * 1000,
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
return procs;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function fromFixture(path) {
|
|
75
|
+
const raw = JSON.parse(readFileSync(path, 'utf8').replace(/^/, ''));
|
|
76
|
+
return (Array.isArray(raw) ? raw : []).map(toProc);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function windowsScript(filter) {
|
|
80
|
+
const query = filter ? `Get-CimInstance Win32_Process -Filter "${filter}"` : 'Get-CimInstance Win32_Process';
|
|
81
|
+
return `${PS_PREFIX}${query} | ForEach-Object { ${PS_PROJECTION} } | ConvertTo-Json -Compress`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const POSIX_COLUMNS = 'pid=,ppid=,etime=,rss=,comm=,args=';
|
|
85
|
+
|
|
86
|
+
function runList({ pid = null, exec, platform, now }) {
|
|
87
|
+
if (platform === 'win32') {
|
|
88
|
+
const filter = pid === null ? '' : `ProcessId=${Number(pid)}`;
|
|
89
|
+
const res = exec('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', windowsScript(filter)]);
|
|
90
|
+
if (res.code !== 0) return { procs: null, reason: `PowerShell falhou (exit ${res.code})` };
|
|
91
|
+
try {
|
|
92
|
+
return { procs: parseWindowsProcs(res.output), reason: null };
|
|
93
|
+
} catch {
|
|
94
|
+
return { procs: null, reason: 'saída do PowerShell ilegível' };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (platform === 'linux' || platform === 'darwin') {
|
|
98
|
+
const args = pid === null ? ['-eo', POSIX_COLUMNS] : ['-p', String(Number(pid)), '-o', POSIX_COLUMNS];
|
|
99
|
+
const res = exec('ps', args);
|
|
100
|
+
if (res.code !== 0 && pid === null) return { procs: null, reason: `ps falhou (exit ${res.code})` };
|
|
101
|
+
return { procs: parsePosixProcs(res.output, now), reason: null };
|
|
102
|
+
}
|
|
103
|
+
return { procs: null, reason: `plataforma ${platform} sem listador de processos` };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function listProcesses({ exec = defaultProcExec, platform = process.platform, now = Date.now(), env = process.env } = {}) {
|
|
107
|
+
if (env.MORPH_PROCS_FIXTURE) {
|
|
108
|
+
try {
|
|
109
|
+
return { procs: fromFixture(env.MORPH_PROCS_FIXTURE), reason: null };
|
|
110
|
+
} catch {
|
|
111
|
+
return { procs: null, reason: 'MORPH_PROCS_FIXTURE ilegível' };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return runList({ exec, platform, now });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function readProcess(pid, { exec = defaultProcExec, platform = process.platform, now = Date.now(), env = process.env } = {}) {
|
|
118
|
+
if (env.MORPH_PROCS_FIXTURE) {
|
|
119
|
+
const { procs } = listProcesses({ env });
|
|
120
|
+
return procs?.find((p) => p.pid === pid) ?? null;
|
|
121
|
+
}
|
|
122
|
+
const { procs } = runList({ pid, exec, platform, now });
|
|
123
|
+
return procs?.find((p) => p.pid === pid) ?? null;
|
|
124
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { jsoncToJson } from '../../../framework/hooks/shared/comment-stripper.js';
|
|
5
|
+
import { readEnabledPlugins } from '../detectors/claude-config-detector.js';
|
|
6
|
+
|
|
7
|
+
const GENERIC_TOKENS = new Set([
|
|
8
|
+
'cmd', 'cmd.exe', 'npx', 'npx.cmd', 'npm', 'node', 'node.exe', 'python', 'python3', 'uv', 'uvx',
|
|
9
|
+
'dotnet', 'docker', 'run', 'exec', 'bash', 'sh', 'powershell', 'pwsh',
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
const toolNamePart = (s) => String(s).replace(/[^A-Za-z0-9_-]/g, '_');
|
|
13
|
+
|
|
14
|
+
function readJsonc(file, unreadable) {
|
|
15
|
+
if (!existsSync(file)) return null;
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(jsoncToJson(readFileSync(file, 'utf8').replace(/^/, '')));
|
|
18
|
+
} catch {
|
|
19
|
+
unreadable.push(file);
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function sameDir(a, b) {
|
|
25
|
+
const norm = (p) => resolve(p).replace(/\\/g, '/').replace(/\/$/, '');
|
|
26
|
+
const [x, y] = [norm(a), norm(b)];
|
|
27
|
+
return process.platform === 'win32' ? x.toLowerCase() === y.toLowerCase() : x === y;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function toServers(map, scope, extra = {}) {
|
|
31
|
+
return Object.entries(map && typeof map === 'object' ? map : {}).map(([name, cfg]) => ({
|
|
32
|
+
name,
|
|
33
|
+
scope,
|
|
34
|
+
command: cfg?.command ?? null,
|
|
35
|
+
args: Array.isArray(cfg?.args) ? cfg.args.map(String) : [],
|
|
36
|
+
url: cfg?.url ?? null,
|
|
37
|
+
...extra,
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function pluginMcpServers(installPath, plugin, unreadable) {
|
|
42
|
+
const doc = readJsonc(join(installPath, '.mcp.json'), unreadable);
|
|
43
|
+
if (!doc || typeof doc !== 'object') return [];
|
|
44
|
+
const map = doc.mcpServers && typeof doc.mcpServers === 'object' ? doc.mcpServers : doc;
|
|
45
|
+
return toServers(map, 'plugin', { plugin });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function pickInstall(entries, projectDir) {
|
|
49
|
+
const list = Array.isArray(entries) ? entries.filter((e) => e?.installPath) : [];
|
|
50
|
+
return list.find((e) => e.projectPath && sameDir(e.projectPath, projectDir)) ?? list.find((e) => e.scope === 'user') ?? list[0] ?? null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function readMcpConfig({ projectDir, home = homedir() }) {
|
|
54
|
+
const unreadable = [];
|
|
55
|
+
const servers = [];
|
|
56
|
+
|
|
57
|
+
const project = readJsonc(join(projectDir, '.mcp.json'), unreadable);
|
|
58
|
+
servers.push(...toServers(project?.mcpServers, 'project'));
|
|
59
|
+
|
|
60
|
+
const userDoc = readJsonc(join(home, '.claude.json'), unreadable);
|
|
61
|
+
servers.push(...toServers(userDoc?.mcpServers, 'user'));
|
|
62
|
+
const projectKey = Object.keys(userDoc?.projects ?? {}).find((k) => sameDir(k, projectDir));
|
|
63
|
+
if (projectKey) servers.push(...toServers(userDoc.projects[projectKey]?.mcpServers, 'local'));
|
|
64
|
+
|
|
65
|
+
const enabledPlugins = readEnabledPlugins(projectDir, home);
|
|
66
|
+
const installed = readJsonc(join(home, '.claude', 'plugins', 'installed_plugins.json'), unreadable)?.plugins ?? {};
|
|
67
|
+
const pluginServers = [];
|
|
68
|
+
for (const id of enabledPlugins) {
|
|
69
|
+
const install = pickInstall(installed[id], projectDir);
|
|
70
|
+
if (install) pluginServers.push(...pluginMcpServers(install.installPath, id.split('@')[0], unreadable));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { servers, pluginServers, enabledPlugins, unreadable };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function findDuplicateMcps({ servers, pluginServers }) {
|
|
77
|
+
const duplicates = [];
|
|
78
|
+
for (const ps of pluginServers) {
|
|
79
|
+
const declared = servers.filter((s) => s.name === ps.name);
|
|
80
|
+
if (!declared.length) continue;
|
|
81
|
+
duplicates.push({
|
|
82
|
+
server: ps.name,
|
|
83
|
+
plugin: ps.plugin,
|
|
84
|
+
declaredIn: [...new Set(declared.map((s) => s.scope))],
|
|
85
|
+
toolNames: [`mcp__${ps.name}`, `mcp__plugin_${toolNamePart(ps.plugin)}_${toolNamePart(ps.name)}`],
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
return duplicates;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function mcpCommandTokens({ servers, pluginServers }) {
|
|
92
|
+
const tokens = new Set();
|
|
93
|
+
for (const s of [...servers, ...pluginServers]) {
|
|
94
|
+
if (s.url && !s.command) continue;
|
|
95
|
+
for (const raw of [s.command, ...s.args]) {
|
|
96
|
+
if (typeof raw !== 'string') continue;
|
|
97
|
+
const token = raw.toLowerCase().replace(/\\/g, '/');
|
|
98
|
+
if (token.length < 4 || /^[-/]/.test(token) || token.includes('${') || GENERIC_TOKENS.has(token)) continue;
|
|
99
|
+
tokens.add(token);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return [...tokens];
|
|
103
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { readProcess } from './list.js';
|
|
2
|
+
|
|
3
|
+
export function pruneOrphans(rows, { read = readProcess, kill = (pid) => process.kill(pid) } = {}) {
|
|
4
|
+
const results = [];
|
|
5
|
+
for (const row of rows) {
|
|
6
|
+
if (row.verdict !== 'orphan') continue;
|
|
7
|
+
const fresh = read(row.pid);
|
|
8
|
+
const base = { pid: row.pid, name: row.name };
|
|
9
|
+
if (!fresh) {
|
|
10
|
+
results.push({ ...base, outcome: 'skipped', reason: 'já encerrado' });
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
if (fresh.startedMs !== row.startedMs || fresh.cmd !== row.cmd || fresh.name !== row.name) {
|
|
14
|
+
results.push({ ...base, outcome: 'skipped', reason: 'o PID foi reaproveitado por outro processo' });
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
kill(row.pid);
|
|
19
|
+
results.push({ ...base, outcome: 'killed' });
|
|
20
|
+
} catch (err) {
|
|
21
|
+
results.push({ ...base, outcome: 'failed', reason: err?.code ?? err?.message ?? 'erro desconhecido' });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return results;
|
|
25
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir, totalmem } from 'node:os';
|
|
4
|
+
|
|
5
|
+
const GB = 1024 ** 3;
|
|
6
|
+
|
|
7
|
+
export function parseWslConfig(text) {
|
|
8
|
+
const sections = {};
|
|
9
|
+
let current = null;
|
|
10
|
+
for (const raw of String(text).replace(/^/, '').split(/\r?\n/)) {
|
|
11
|
+
const line = raw.trim();
|
|
12
|
+
if (!line || line.startsWith('#') || line.startsWith(';')) continue;
|
|
13
|
+
const header = /^\[([^\]]+)\]$/.exec(line);
|
|
14
|
+
if (header) {
|
|
15
|
+
current = header[1].trim().toLowerCase();
|
|
16
|
+
sections[current] ??= {};
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
const eq = line.indexOf('=');
|
|
20
|
+
if (eq < 0 || current === null) continue;
|
|
21
|
+
sections[current][line.slice(0, eq).trim().toLowerCase()] = line.slice(eq + 1).trim();
|
|
22
|
+
}
|
|
23
|
+
return sections;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function suggestedMemoryGb(totalMemBytes) {
|
|
27
|
+
return Math.max(4, Math.floor((totalMemBytes / GB) * 0.4));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function suggestion({ memory, reclaim, totalMemBytes }) {
|
|
31
|
+
const lines = [];
|
|
32
|
+
if (!memory) lines.push('[wsl2]', `memory=${suggestedMemoryGb(totalMemBytes)}GB`);
|
|
33
|
+
if (!reclaim) lines.push('[experimental]', 'autoMemoryReclaim=gradual');
|
|
34
|
+
return lines.length ? lines.join('\n') : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function inspectWslConfig({
|
|
38
|
+
home = homedir(),
|
|
39
|
+
platform = process.platform,
|
|
40
|
+
totalMemBytes = totalmem(),
|
|
41
|
+
exists = existsSync,
|
|
42
|
+
read = (p) => readFileSync(p, 'utf8'),
|
|
43
|
+
} = {}) {
|
|
44
|
+
if (platform !== 'win32') return { status: 'skip', reason: 'WSL só existe no Windows' };
|
|
45
|
+
const path = join(home, '.wslconfig');
|
|
46
|
+
if (!exists(path)) {
|
|
47
|
+
return {
|
|
48
|
+
status: 'missing',
|
|
49
|
+
path,
|
|
50
|
+
memory: null,
|
|
51
|
+
autoMemoryReclaim: null,
|
|
52
|
+
suggestion: suggestion({ memory: null, reclaim: null, totalMemBytes }),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
let sections;
|
|
56
|
+
try {
|
|
57
|
+
sections = parseWslConfig(read(path));
|
|
58
|
+
} catch {
|
|
59
|
+
return { status: 'skip', reason: `${path} ilegível` };
|
|
60
|
+
}
|
|
61
|
+
const memory = sections.wsl2?.memory ?? null;
|
|
62
|
+
const autoMemoryReclaim = sections.experimental?.automemoryreclaim ?? null;
|
|
63
|
+
return {
|
|
64
|
+
status: 'present',
|
|
65
|
+
path,
|
|
66
|
+
memory,
|
|
67
|
+
autoMemoryReclaim,
|
|
68
|
+
suggestion: suggestion({ memory, reclaim: autoMemoryReclaim, totalMemBytes }),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -42,8 +42,12 @@ export function isMorphProject(dir) {
|
|
|
42
42
|
|
|
43
43
|
/** A worktree checkout is part of its repo's project, never a project of its own. */
|
|
44
44
|
function insideWorktreeContainer(dir) {
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
if (WORKTREE_DIR_NAMES.includes(basename(dirname(dir)))) return true;
|
|
46
|
+
try {
|
|
47
|
+
return /[\\/]\.git[\\/]worktrees[\\/][^\\/]+\s*$/.test(readFileSync(join(dir, '.git'), 'utf8'));
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
47
51
|
}
|
|
48
52
|
|
|
49
53
|
function projectName(dir) {
|
|
@@ -23,7 +23,7 @@ import { existsSync, readFileSync, readdirSync } from 'fs';
|
|
|
23
23
|
import { dirname, join, resolve } from 'path';
|
|
24
24
|
|
|
25
25
|
import { isInside } from './test-runner.js';
|
|
26
|
-
import { jsoncToJson } from '
|
|
26
|
+
import { jsoncToJson } from '../../../framework/hooks/shared/comment-stripper.js';
|
|
27
27
|
|
|
28
28
|
const MTP_RUNNER = 'Microsoft.Testing.Platform';
|
|
29
29
|
const FLOOR_PROPERTY = /<TestingPlatformCommandLineArguments\b[^>]*>[^<]*--minimum-expected-tests/i;
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
* @module linux-runner
|
|
39
39
|
*/
|
|
40
40
|
|
|
41
|
+
import { createHash } from 'crypto';
|
|
41
42
|
import { resolve } from 'path';
|
|
42
43
|
|
|
43
44
|
import { defaultExec, isDockerAvailable } from '../../../framework/hooks/shared/e2e-check.js';
|
|
@@ -174,9 +175,10 @@ export function buildContainerScript({ nodes }) {
|
|
|
174
175
|
* @param {{ projectPath: string, image: string, script: string }} params
|
|
175
176
|
* @returns {string[]}
|
|
176
177
|
*/
|
|
177
|
-
export function buildLinuxArgv({ projectPath, image, script }) {
|
|
178
|
+
export function buildLinuxArgv({ projectPath, image, script, runId = null }) {
|
|
178
179
|
return [
|
|
179
180
|
'run', '--rm',
|
|
181
|
+
...(runId ? ['--label', `${LABEL_RUN}=${runId}`, '--label', `${LABEL_ROOT}=${rootHash(projectPath)}`] : []),
|
|
180
182
|
// Read-only, and mounted at /src: the SOURCE of the copy, never the tree
|
|
181
183
|
// the tests run from. See the module docs for why that distinction is the
|
|
182
184
|
// entire point of the flag.
|
|
@@ -269,8 +271,14 @@ export function runLinuxSuite({ projectPath, nodes, image, timeout, exec = defau
|
|
|
269
271
|
}
|
|
270
272
|
|
|
271
273
|
const script = buildContainerScript({ nodes: runnable });
|
|
272
|
-
const
|
|
273
|
-
const
|
|
274
|
+
const runId = `${process.pid}-${Date.now().toString(36)}`;
|
|
275
|
+
const argv = buildLinuxArgv({ projectPath, image: resolvedImage, script, runId });
|
|
276
|
+
let run;
|
|
277
|
+
try {
|
|
278
|
+
run = exec('docker', argv, { cwd: projectPath, timeout });
|
|
279
|
+
} finally {
|
|
280
|
+
reapLabeledContainers({ runId, exec, cwd: projectPath });
|
|
281
|
+
}
|
|
274
282
|
|
|
275
283
|
const results = splitByMarkers(run.output, runnable).map((r) =>
|
|
276
284
|
r.exitCode === null && !r.reason
|
|
@@ -291,6 +299,25 @@ export function runLinuxSuite({ projectPath, nodes, image, timeout, exec = defau
|
|
|
291
299
|
return { results: [...enriched, ...refused], environment, argv, script, containerCode: run.code };
|
|
292
300
|
}
|
|
293
301
|
|
|
302
|
+
export const LABEL_RUN = 'morph-spec.verify';
|
|
303
|
+
export const LABEL_ROOT = 'morph-spec.root';
|
|
304
|
+
|
|
305
|
+
export function rootHash(projectPath) {
|
|
306
|
+
const root = resolve(projectPath).replace(/\\/g, '/');
|
|
307
|
+
const key = process.platform === 'win32' ? root.toLowerCase() : root;
|
|
308
|
+
return createHash('sha1').update(key).digest('hex').slice(0, 12);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// A timeout kills the docker client, not the container: `--rm` never fires for it.
|
|
312
|
+
export function reapLabeledContainers({ runId, exec, cwd }) {
|
|
313
|
+
try {
|
|
314
|
+
const listed = exec('docker', ['ps', '-aq', '--filter', `label=${LABEL_RUN}=${runId}`], { cwd, timeout: 15_000 });
|
|
315
|
+
if (listed.code !== 0) return;
|
|
316
|
+
const ids = String(listed.output ?? '').split(/\s+/).filter((id) => /^[0-9a-f]{6,64}$/i.test(id));
|
|
317
|
+
if (ids.length) exec('docker', ['rm', '-f', ...ids], { cwd, timeout: 30_000 });
|
|
318
|
+
} catch { /* limpeza best-effort: nunca muda o veredito da suíte */ }
|
|
319
|
+
}
|
|
320
|
+
|
|
294
321
|
/** Last few lines of the container output — enough to name a pull failure. */
|
|
295
322
|
function tail(output, lines = 5) {
|
|
296
323
|
return String(output || '').split('\n').slice(-lines).join('\n').trim();
|
|
@@ -349,7 +349,7 @@ export function resolveTestBuildTargets(node) {
|
|
|
349
349
|
* application's solution and may therefore never have been restored at all.
|
|
350
350
|
* F29's project was exactly that project.
|
|
351
351
|
*/
|
|
352
|
-
function dotnetBuildCommandFor(target, projectPath) {
|
|
352
|
+
export function dotnetBuildCommandFor(target, projectPath) {
|
|
353
353
|
const base = getBuildSteps('dotnet', { cwd: projectPath || '.' })[0]?.command;
|
|
354
354
|
if (!base) return null;
|
|
355
355
|
const [verb, subcommand, ...flags] = base.split(/\s+/);
|
|
@@ -21,8 +21,9 @@ import { join } from 'path';
|
|
|
21
21
|
|
|
22
22
|
import { addedLinesAgainst, detectDefaultBranch } from '../../git/default-branch.js';
|
|
23
23
|
import { resolveBaseRef } from '../../git/branch-classifier.js';
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
24
|
+
import { isTestFile } from '../reuse/symbol-scanner.js';
|
|
25
|
+
import { isGenerated, isProjectSource } from '../../../../framework/hooks/shared/source-files.js';
|
|
26
|
+
import { citesProcess, commentLines } from '../../../../framework/hooks/shared/comment-scanner.js';
|
|
26
27
|
|
|
27
28
|
/** Razão teste ÷ produção acima da qual se avisa. */
|
|
28
29
|
export const MAX_TEST_RATIO = 1.5;
|
|
@@ -39,9 +40,6 @@ export const MIN_CODE_LINES = 40;
|
|
|
39
40
|
/** Quantos locais citar no aviso de comentário antes de resumir com "(+N)". */
|
|
40
41
|
const MAX_CITED = 4;
|
|
41
42
|
|
|
42
|
-
/** Código gerado não é escrito por ninguém, então não entra em nenhum dos lados. */
|
|
43
|
-
const GENERATED = /(?:\.Designer|\.g|\.g\.i)\.cs$|ModelSnapshot\.cs$/i;
|
|
44
|
-
|
|
45
43
|
const decimal = (n) => n.toFixed(1).replace('.', ',');
|
|
46
44
|
|
|
47
45
|
/**
|
|
@@ -56,7 +54,7 @@ export function measureCodeBudget(files) {
|
|
|
56
54
|
const measured = { commentLines: 0, processLines: [], testLines: 0, productionLines: 0, commentsByFile: {} };
|
|
57
55
|
|
|
58
56
|
for (const { path, content, added } of files) {
|
|
59
|
-
if (!isProjectSource(path) ||
|
|
57
|
+
if (!isProjectSource(path) || isGenerated(path)) continue;
|
|
60
58
|
const addedSet = added ? new Set(added) : null;
|
|
61
59
|
const isAdded = (n) => addedSet === null || addedSet.has(n);
|
|
62
60
|
|
|
@@ -18,24 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { isTestPath, languageOf } from '../../graph/sanitize.js';
|
|
21
|
-
|
|
22
|
-
/** Extensões que este scanner sabe ler. Fora daqui, o arquivo é ignorado. */
|
|
23
|
-
const SCANNABLE = /\.(cs|ts|tsx|js|jsx|mjs|cjs)$/i;
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Diretórios que contêm arquivos com extensão de código mas que não são o
|
|
27
|
-
* código-fonte do projeto. `.morph/` é o caso que apareceu de verdade: o
|
|
28
|
-
* `archive/{feature}/1-design/contracts.cs` de uma feature arquivada entra no
|
|
29
|
-
* grafo do ProspectPRO como um tipo `RoulettePriority` a mais, e acusaria a
|
|
30
|
-
* entidade real de duplicar o próprio artefato de planejamento.
|
|
31
|
-
*/
|
|
32
|
-
const NOT_SOURCE = /(^|\/)(?:\.morph|node_modules|dist|build|out|\.next|bin|obj|coverage)\//;
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Declaration file do TypeScript. `auth-debug.d.ts` + `auth-debug.ts`
|
|
36
|
-
* declarando o mesmo tipo é o mecanismo da linguagem, não duplicação.
|
|
37
|
-
*/
|
|
38
|
-
const DECLARATION_FILE = /\.d\.ts$/i;
|
|
21
|
+
import { isProjectSource } from '../../../../framework/hooks/shared/source-files.js';
|
|
39
22
|
|
|
40
23
|
/**
|
|
41
24
|
* Nomes que a convenção usa como tipo LOCAL de arquivo, não como conceito do
|
|
@@ -63,16 +46,6 @@ const WEB_DECL =
|
|
|
63
46
|
/** Linha que é claramente comentário — não vale a pena parsear. */
|
|
64
47
|
const COMMENT_LINE = /^\s*(?:\/\/|\/\*|\*|<!--)/;
|
|
65
48
|
|
|
66
|
-
/**
|
|
67
|
-
* @param {string} sourceFile
|
|
68
|
-
* @returns {boolean} código do projeto numa extensão lida, fora de `.d.ts` e de
|
|
69
|
-
* pasta que não é fonte — teste incluído
|
|
70
|
-
*/
|
|
71
|
-
export function isProjectSource(sourceFile) {
|
|
72
|
-
const f = String(sourceFile || '').replace(/\\/g, '/');
|
|
73
|
-
return SCANNABLE.test(f) && !DECLARATION_FILE.test(f) && !NOT_SOURCE.test(f);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
49
|
/**
|
|
77
50
|
* @param {string} sourceFile
|
|
78
51
|
* @returns {boolean} arquivo de teste, pelo mesmo predicado do grafo
|