@polymorphism-tech/morph-spec 8.42.0 → 8.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +15 -5
- package/README.md +2 -2
- package/bin/morph-spec.js +48 -2
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +5 -4
- package/framework/MORPH.md +10 -9
- package/framework/agents.json +1 -1
- package/framework/docs/CLI.md +22 -10
- package/framework/evals/proof-rubrics/mutation-proof.md +43 -13
- package/framework/hooks/claude-code/core/heavy-gate.js +72 -0
- package/framework/hooks/claude-code/core/heavy-release.js +34 -0
- package/framework/hooks/claude-code/core/worktree-context.js +21 -20
- package/framework/hooks/claude-code/core/worktree-create.js +4 -4
- package/framework/hooks/claude-code/core/worktree-env-inject.js +1 -4
- package/framework/hooks/shared/agent-reap.js +89 -0
- package/framework/hooks/shared/e2e-check.js +3 -1
- package/framework/hooks/shared/graph-refresh.js +1 -9
- package/framework/hooks/shared/heavy-command.js +128 -0
- package/framework/hooks/shared/heavy-config.js +65 -0
- package/framework/hooks/shared/heavy-holders.js +85 -0
- package/framework/hooks/shared/heavy-slots.js +207 -0
- package/framework/hooks/shared/process-alive.js +8 -0
- package/framework/hooks/shared/worktree-root.js +93 -0
- package/framework/rules/ai-agents-standards.md +1 -0
- package/framework/schemas/config.schema.json +80 -0
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +8 -3
- package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
- package/framework/skills/core/morph-implement/SKILL.md +9 -4
- package/framework/skills/core/morph-machine/SKILL.md +46 -0
- package/framework/skills/core/morph-media/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +1 -1
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +45 -16
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/skills/core/morph-uiux/SKILL.md +1 -1
- package/framework/standards/PROVENANCE.json +10 -10
- package/framework/standards/STANDARDS.json +45 -1
- package/framework/standards/ai-agents/tool-adapters.md +226 -0
- package/framework/standards/frontend/design-system/ai-video-generation.md +2 -0
- package/framework/standards/frontend/scroll-driven/frame-scrub.md +11 -1
- package/framework/standards/infrastructure/docker/local-compose-isolation.md +11 -1
- package/package.json +1 -1
- package/src/commands/e2e/e2e.js +27 -8
- package/src/commands/mcp/mcp-setup.js +10 -9
- package/src/commands/project/doctor-procs.js +162 -0
- package/src/commands/project/doctor.js +42 -4
- package/src/commands/project/heavy.js +61 -0
- package/src/commands/project/init.js +1 -1
- package/src/commands/project/update.js +4 -0
- package/src/commands/project/worktree-reap.js +34 -0
- package/src/commands/project/worktree.js +112 -43
- package/src/commands/state/approve.js +2 -1
- package/src/commands/state/finish.js +4 -0
- package/src/commands/state/gate-check.js +2 -1
- package/src/commands/validation/mutate.js +200 -0
- package/src/commands/validation/verify.js +39 -22
- package/src/dashboard/scanner/features-scanner.js +4 -4
- package/src/dashboard/scanner/project-api.js +4 -4
- package/src/lib/detectors/claude-config-detector.js +30 -1
- package/src/lib/e2e/compose-env.js +5 -2
- package/src/lib/git/evidence-staleness.js +39 -0
- package/src/lib/heavy/cleanup.js +70 -0
- package/src/lib/heavy/gate.js +96 -0
- package/src/lib/installers/mcp-installer.js +15 -2
- package/src/lib/mutation/adapters.js +90 -0
- package/src/lib/mutation/commands.js +60 -0
- package/src/lib/mutation/manifest.js +138 -0
- package/src/lib/mutation/plan.js +36 -0
- package/src/lib/mutation/report.js +66 -0
- package/src/lib/mutation/runner.js +351 -0
- package/src/lib/mutation/schemata.js +60 -0
- package/src/lib/mutation/snapshot.js +69 -0
- package/src/lib/mutation/subset.js +75 -0
- package/src/lib/mutation/trx.js +51 -0
- package/src/lib/procs/classify.js +91 -0
- package/src/lib/procs/list.js +124 -0
- package/src/lib/procs/mcp-config.js +103 -0
- package/src/lib/procs/prune.js +25 -0
- package/src/lib/procs/wslconfig.js +70 -0
- package/src/lib/projects/registry.js +6 -2
- package/src/lib/risk-detector.js +3 -3
- package/src/lib/tasks/eval-node.js +4 -34
- package/src/lib/tasks/linux-runner.js +30 -3
- package/src/lib/tasks/test-runner.js +1 -1
- package/src/lib/validators/contracts/contract-compliance-validator.js +4 -42
- package/src/lib/validators/verify-runner.js +26 -8
- package/src/lib/worktree/agent-reaper.js +247 -0
- package/src/lib/worktree/dirs.js +3 -32
- package/src/lib/worktree/env-allocator.js +35 -5
- package/src/lib/worktree/vscode-settings.js +113 -0
- package/src/lib/worktree/worktree-audit.js +49 -5
- package/src/lib/worktree/worktree-include.js +46 -0
- package/src/scripts/setup-infra.js +4 -0
- package/src/utils/hooks-installer.js +11 -4
|
@@ -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) {
|
package/src/lib/risk-detector.js
CHANGED
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
* - e2eStatus + e2eReason + gate — nó e2e do último verify. Um `skip`
|
|
16
16
|
* cuja razão NÃO seja dispensa legítima pausa SÓ no ship (gate 'review').
|
|
17
17
|
* Sem escopo (stamp sem o campo) cai do mesmo lado seguro que `unknown`
|
|
18
|
-
* - verificationStale + gate: bool/str — stamp
|
|
19
|
-
* ship (gate 'review')
|
|
18
|
+
* - verificationStale + gate: bool/str — stamp de um commit cujo código já mudou
|
|
19
|
+
* (fora de .morph/) pausa SÓ no ship (gate 'review')
|
|
20
20
|
* - evalScoreSource + gate: str — no ship, score que NÃO veio do avaliador
|
|
21
21
|
* independente pausa (auto-avaliação não decide o Gate 3)
|
|
22
22
|
* - mutationProof + gate: object — { section, min, productionCode } lido do
|
|
@@ -274,7 +274,7 @@ export function shouldPauseGate(config, signals) {
|
|
|
274
274
|
if (signals.gate === 'review' && signals.verificationStale === true) {
|
|
275
275
|
return {
|
|
276
276
|
pause: true,
|
|
277
|
-
reason: 'verification stamp desatualizado (
|
|
277
|
+
reason: 'verification stamp desatualizado (o código mudou depois do commit do stamp) — rode `morph-spec verify` de novo antes do ship',
|
|
278
278
|
};
|
|
279
279
|
}
|
|
280
280
|
|
|
@@ -36,7 +36,7 @@ import { join, resolve, sep } from 'path';
|
|
|
36
36
|
import { tmpdir } from 'os';
|
|
37
37
|
|
|
38
38
|
import { isInside } from './test-runner.js';
|
|
39
|
-
import {
|
|
39
|
+
import { resolveAiPin } from '../ai/pin-drift.js';
|
|
40
40
|
|
|
41
41
|
/** The runner kind. One spelling, imported by every reader. */
|
|
42
42
|
export const EVAL_RUNNER = 'evals';
|
|
@@ -72,34 +72,6 @@ export function cacheRelevantPackages(packages) {
|
|
|
72
72
|
return out;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
/**
|
|
76
|
-
* Read the pin from a project, wherever the framework lives.
|
|
77
|
-
*
|
|
78
|
-
* Two roots, ONE reader: this repository carries `framework/ai-pin.json` at the
|
|
79
|
-
* top; an installed project carries it under `.morph/framework/`. `readAiPin`
|
|
80
|
-
* appends `framework/ai-pin.json` to whatever root it is given, so both cases
|
|
81
|
-
* are the same function with a different argument — never a second parser.
|
|
82
|
-
*
|
|
83
|
-
* Returns null when neither exists: a project with no pin simply has no stamp
|
|
84
|
-
* ruler, and refusing to run there would make the eval node unusable outside
|
|
85
|
-
* this framework's own stack.
|
|
86
|
-
*
|
|
87
|
-
* @param {string} projectPath
|
|
88
|
-
* @returns {{pinnedAt: string, packages: object}|null}
|
|
89
|
-
*/
|
|
90
|
-
export function readProjectPin(projectPath) {
|
|
91
|
-
for (const root of [projectPath, join(projectPath, '.morph')]) {
|
|
92
|
-
try {
|
|
93
|
-
return readAiPin(root);
|
|
94
|
-
} catch {
|
|
95
|
-
// Next candidate. The throw is readAiPin's contract for "no pin here",
|
|
96
|
-
// and it is the reason this loop exists instead of an existsSync probe
|
|
97
|
-
// that would duplicate the path knowledge.
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
return null;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
75
|
/**
|
|
104
76
|
* Resolve every path an eval node names, against the project root.
|
|
105
77
|
*
|
|
@@ -256,11 +228,9 @@ export function evalPreflight(projectPath, node, options = {}) {
|
|
|
256
228
|
return { status: 'fail', reason: `eval-cache-empty: ${cacheRoot} não contém nenhuma entrada de cache` };
|
|
257
229
|
}
|
|
258
230
|
|
|
259
|
-
const pin =
|
|
231
|
+
const pin = resolveAiPin(projectPath);
|
|
260
232
|
if (!pin) {
|
|
261
|
-
// No pin
|
|
262
|
-
// failure: the stamp check simply does not apply. Saying so is the point;
|
|
263
|
-
// silently treating it as "stamp ok" would be the same thing said badly.
|
|
233
|
+
// No pin in the project or in the CLI package: nothing to stamp against.
|
|
264
234
|
return { status: null, reason: null };
|
|
265
235
|
}
|
|
266
236
|
|
|
@@ -315,7 +285,7 @@ export function evalNodeEnv(node, projectPath, options = {}) {
|
|
|
315
285
|
MORPH_EVAL_ENVFILE: envFile,
|
|
316
286
|
};
|
|
317
287
|
|
|
318
|
-
const pin =
|
|
288
|
+
const pin = resolveAiPin(projectPath);
|
|
319
289
|
if (pin) {
|
|
320
290
|
// Compact, and only the packages that decide the cache format. The fixture
|
|
321
291
|
// stamps from THIS, so it never has to read (or parse) the pin itself.
|
|
@@ -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+/);
|
|
@@ -1,56 +1,21 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Contract Compliance Validator
|
|
3
|
-
*
|
|
4
|
-
* Parses contracts.cs from feature outputs and verifies that the project code
|
|
5
|
-
* actually implements all defined interfaces, methods, DTOs, and enums.
|
|
6
|
-
*
|
|
7
|
-
* MORPH-SPEC 3.0 - Phase A: Active Validation Pipeline
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
1
|
import { readFileSync, existsSync } from 'fs';
|
|
11
2
|
import { join } from 'path';
|
|
12
3
|
import { glob } from 'glob';
|
|
13
4
|
import chalk from 'chalk';
|
|
14
5
|
import { getAbsoluteOutputPath, resolveContractsOutputType } from '../../../core/paths/output-schema.js';
|
|
15
6
|
|
|
16
|
-
/**
|
|
17
|
-
* Validate that contracts.cs interfaces are implemented in project code
|
|
18
|
-
*
|
|
19
|
-
* @param {string} projectPath - Project root path
|
|
20
|
-
* @param {string} featureName - Feature name
|
|
21
|
-
* @param {Object} options - Options
|
|
22
|
-
* @returns {Object} { status, errors, warnings, issues, coverage }
|
|
23
|
-
*/
|
|
24
7
|
export async function validateContracts(projectPath, featureName, options = {}) {
|
|
25
|
-
// `'contracts'` is not an output type — `getAbsoluteOutputPath` threw
|
|
26
|
-
// "Unknown output type" on every call. The runner already resolves this the
|
|
27
|
-
// right way before deciding to schedule this validator
|
|
28
|
-
// (`resolveContractsOutputType()`); using the same resolver here is what
|
|
29
|
-
// keeps the two from disagreeing again.
|
|
30
8
|
const contractsPath = getAbsoluteOutputPath(projectPath, featureName, resolveContractsOutputType());
|
|
31
9
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
errors: 0,
|
|
36
|
-
warnings: 0,
|
|
37
|
-
issues: [],
|
|
38
|
-
coverage: null,
|
|
39
|
-
message: 'No contracts.cs found — skipping'
|
|
40
|
-
};
|
|
41
|
-
}
|
|
10
|
+
const skipped = (message) => ({ status: 'ok', errors: 0, warnings: 0, issues: [], coverage: null, message });
|
|
11
|
+
|
|
12
|
+
if (!existsSync(contractsPath)) return skipped('No spec.md found — skipping');
|
|
42
13
|
|
|
43
14
|
const contractsContent = readFileSync(contractsPath, 'utf-8');
|
|
44
15
|
const contracts = parseContracts(contractsContent);
|
|
45
16
|
|
|
46
17
|
if (contracts.interfaces.length === 0 && contracts.records.length === 0 && contracts.enums.length === 0) {
|
|
47
|
-
return
|
|
48
|
-
status: 'ok',
|
|
49
|
-
errors: 0,
|
|
50
|
-
warnings: 1,
|
|
51
|
-
issues: [{ level: 'warning', message: 'contracts.cs exists but no interfaces/records/enums found' }],
|
|
52
|
-
coverage: null
|
|
53
|
-
};
|
|
18
|
+
return skipped('spec.md declares no interfaces/records/enums — skipping');
|
|
54
19
|
}
|
|
55
20
|
|
|
56
21
|
// Find all .cs files in the project (excluding .morph outputs, bin, obj)
|
|
@@ -168,9 +133,6 @@ export async function validateContracts(projectPath, featureName, options = {})
|
|
|
168
133
|
};
|
|
169
134
|
}
|
|
170
135
|
|
|
171
|
-
/**
|
|
172
|
-
* Parse contracts.cs content and extract interfaces, records, and enums
|
|
173
|
-
*/
|
|
174
136
|
function parseContracts(content) {
|
|
175
137
|
const interfaces = [];
|
|
176
138
|
const records = [];
|