@polymorphism-tech/morph-spec 10.2.0 → 10.5.3
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/README.md +2 -2
- package/bin/morph-spec.js +9 -0
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/MORPH.md +1 -1
- package/framework/agents.json +1 -1
- package/framework/docs/CLI.md +22 -2
- package/framework/evals/review-checklist.md +5 -5
- package/framework/hooks/claude-code/core/heavy-gate.js +21 -2
- package/framework/hooks/shared/background-spawn.js +20 -0
- package/framework/hooks/shared/graph-refresh.js +2 -1
- package/framework/hooks/shared/heavy-config.js +21 -0
- package/framework/hooks/shared/host-services.js +9 -6
- package/framework/hooks/shared/next-step.js +11 -7
- package/framework/rules/plan-grammar.md +4 -1
- package/framework/schemas/config.schema.json +39 -0
- package/framework/schemas/feature.schema.json +14 -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 +7 -4
- package/framework/skills/core/morph-apply/references/machine.md +3 -2
- package/framework/skills/core/morph-apply/references/review.md +5 -3
- package/framework/skills/core/morph-plan/SKILL.md +4 -3
- package/framework/skills/core/morph-plan/references/critique.md +1 -1
- package/framework/skills/core/morph-uiux/SKILL.md +1 -1
- package/framework/templates/IDE-SNIPPETS.md +262 -262
- package/framework/templates/code/dotnet/abstractions/api-endpoint.cs +7 -7
- package/framework/templates/code/dotnet/feature/request.cs.hbs +4 -4
- package/framework/templates/code/dotnet/feature/response.cs.hbs +4 -4
- package/framework/templates/code/sql/ai-agents/pgvector-embeddings-migration.sql.template +91 -91
- package/framework/templates/docs/plan.md +2 -1
- package/framework/templates/frontend/nextjs/client-component.tsx.hbs +26 -26
- package/framework/templates/frontend/nextjs/tsconfig.json.hbs +26 -26
- package/framework/templates/infrastructure/docker/Dockerfile.template +58 -58
- package/framework/templates/infrastructure/docker/dockerfile-api.dockerfile +38 -38
- package/framework/templates/infrastructure/docker/dockerfile-web.dockerfile +48 -48
- package/framework/templates/infrastructure/github/actions/docker-build-push/action.yml.hbs +45 -45
- package/framework/templates/infrastructure/github/actions/health-check/action.yml.hbs +27 -27
- package/framework/templates/infrastructure/github/workflows/docker-build-push.yml.hbs +59 -59
- package/framework/templates/infrastructure/github/workflows/dotnet-build.yml.hbs +39 -39
- package/framework/templates/state.template.json +17 -17
- package/framework/templates/ui/design-system.css +226 -226
- package/package.json +1 -1
- package/src/commands/project/heavy.js +13 -0
- package/src/commands/validation/verify.js +51 -5
- package/src/dashboard/app/main.jsx +13 -13
- package/src/dashboard/vite.config.js +24 -24
- package/src/lib/heavy/gate.js +34 -2
- package/src/lib/heavy/reclaim.js +107 -0
- package/src/lib/heavy/watchdog.js +46 -0
- package/src/lib/next-step.js +1 -0
- package/src/lib/plan/checks.js +1 -1
- package/src/lib/procs/prune.js +23 -1
- package/src/lib/tasks/test-nodes.js +5 -0
- package/src/lib/validators/verify-runner.js +34 -4
- package/src/lib/verify/affected.js +141 -0
- package/src/lib/verify/detached.js +156 -0
- package/src/utils/hooks-installer.js +8 -0
- package/framework/templates/.idea/morph-templates.xml +0 -92
- package/framework/templates/.vscode/morph-templates.code-snippets +0 -186
|
@@ -1,24 +1,24 @@
|
|
|
1
|
-
import { defineConfig } from 'vite';
|
|
2
|
-
import react from '@vitejs/plugin-react';
|
|
3
|
-
import tailwindcss from '@tailwindcss/vite';
|
|
4
|
-
import morphDataPlugin from './vite-plugin-morph-data.js';
|
|
5
|
-
import { fileURLToPath } from 'url';
|
|
6
|
-
import { dirname, resolve } from 'path';
|
|
7
|
-
|
|
8
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
9
|
-
const __dirname = dirname(__filename);
|
|
10
|
-
|
|
11
|
-
const frameworkDir = resolve(__dirname, '..', '..', 'framework');
|
|
12
|
-
const projectDir = process.env.MORPH_PROJECT_DIR || process.cwd();
|
|
13
|
-
|
|
14
|
-
export default defineConfig({
|
|
15
|
-
root: __dirname,
|
|
16
|
-
plugins: [
|
|
17
|
-
react(),
|
|
18
|
-
tailwindcss(),
|
|
19
|
-
morphDataPlugin(frameworkDir, projectDir),
|
|
20
|
-
],
|
|
21
|
-
server: {
|
|
22
|
-
port: 3200,
|
|
23
|
-
},
|
|
24
|
-
});
|
|
1
|
+
import { defineConfig } from 'vite';
|
|
2
|
+
import react from '@vitejs/plugin-react';
|
|
3
|
+
import tailwindcss from '@tailwindcss/vite';
|
|
4
|
+
import morphDataPlugin from './vite-plugin-morph-data.js';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
import { dirname, resolve } from 'path';
|
|
7
|
+
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
9
|
+
const __dirname = dirname(__filename);
|
|
10
|
+
|
|
11
|
+
const frameworkDir = resolve(__dirname, '..', '..', 'framework');
|
|
12
|
+
const projectDir = process.env.MORPH_PROJECT_DIR || process.cwd();
|
|
13
|
+
|
|
14
|
+
export default defineConfig({
|
|
15
|
+
root: __dirname,
|
|
16
|
+
plugins: [
|
|
17
|
+
react(),
|
|
18
|
+
tailwindcss(),
|
|
19
|
+
morphDataPlugin(frameworkDir, projectDir),
|
|
20
|
+
],
|
|
21
|
+
server: {
|
|
22
|
+
port: 3200,
|
|
23
|
+
},
|
|
24
|
+
});
|
package/src/lib/heavy/gate.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { freemem } from 'node:os';
|
|
2
|
+
import { CPU_SHARE_ENVS, cpuShareOf, minFreeMemFor, readHeavyConfig } from '../../../framework/hooks/shared/heavy-config.js';
|
|
2
3
|
import { heavyClass } from '../../../framework/hooks/shared/heavy-command.js';
|
|
4
|
+
import { GB, readHolders } from '../../../framework/hooks/shared/heavy-holders.js';
|
|
5
|
+
import { pidAlive } from '../../../framework/hooks/shared/process-alive.js';
|
|
3
6
|
import {
|
|
4
7
|
acquireHeavy, awaitFreeMemory, describeBusy, enterVerifySuite, heavyDir, isVerifySuite, releaseHeavy, updateLease,
|
|
5
8
|
} from '../../../framework/hooks/shared/heavy-slots.js';
|
|
9
|
+
import { reclaimUnlessDisabled } from './reclaim.js';
|
|
6
10
|
|
|
7
11
|
export class HeavyBusyError extends Error {
|
|
8
12
|
constructor(result, config) {
|
|
@@ -36,6 +40,29 @@ export function createHeavyGate({
|
|
|
36
40
|
let suiteEntered = false;
|
|
37
41
|
let exitHandler = null;
|
|
38
42
|
let saved = null;
|
|
43
|
+
let reclaimed = false;
|
|
44
|
+
let cpuShare = null;
|
|
45
|
+
const politeRun = config.enabled && config.yieldToUser;
|
|
46
|
+
|
|
47
|
+
// Judged at each node: a run alone takes nearly the machine, and steps back once another one is in.
|
|
48
|
+
const applyCpuShare = () => {
|
|
49
|
+
if (!politeRun) return;
|
|
50
|
+
const { holders = readHolders } = deps;
|
|
51
|
+
const live = holders(heavyDir(env), { now: Date.now, isAlive: pidAlive, maxHoldMs: config.maxHoldMinutes * 60_000 });
|
|
52
|
+
cpuShare = cpuShareOf(config.slots, { alone: !live.some((h) => h.pid !== process.pid) });
|
|
53
|
+
for (const name of CPU_SHARE_ENVS) env[name] = String(cpuShare);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const reclaimBelow = (floor) => {
|
|
57
|
+
const { freeMem = freemem, reclaim = reclaimUnlessDisabled } = deps;
|
|
58
|
+
if (reclaimed || !config.reclaimIdleMinutes || freeMem() / GB >= floor) return;
|
|
59
|
+
reclaimed = true;
|
|
60
|
+
try {
|
|
61
|
+
const { freed, containers = [] } = reclaim({ projectPath, feature, config, env });
|
|
62
|
+
const gone = [...freed, ...containers];
|
|
63
|
+
if (gone.length) log(`heavy: RAM abaixo do piso, derrubei stacks paradas: ${gone.join(', ')}`);
|
|
64
|
+
} catch {}
|
|
65
|
+
};
|
|
39
66
|
|
|
40
67
|
const restoreEnv = () => {
|
|
41
68
|
if (!saved) return;
|
|
@@ -59,6 +86,7 @@ export function createHeavyGate({
|
|
|
59
86
|
const raiseFloor = (kind) => {
|
|
60
87
|
const floor = minFreeMemFor(config, kind);
|
|
61
88
|
if (!lease || floor <= admittedGb) return;
|
|
89
|
+
reclaimBelow(floor);
|
|
62
90
|
const result = awaitFreeMemory({
|
|
63
91
|
minFreeMemGb: floor, waitTimeoutMs: config.waitTimeoutSec * 1000, kind, log, ...deps,
|
|
64
92
|
});
|
|
@@ -85,6 +113,7 @@ export function createHeavyGate({
|
|
|
85
113
|
return {
|
|
86
114
|
config,
|
|
87
115
|
get waitedMs() { return waitedMs; },
|
|
116
|
+
get cpuShare() { return cpuShare; },
|
|
88
117
|
|
|
89
118
|
enter(node, kind = heavyClass(node)) {
|
|
90
119
|
const suite = isVerifySuite({ label, node });
|
|
@@ -92,12 +121,14 @@ export function createHeavyGate({
|
|
|
92
121
|
if (suite && lease && !suiteEntered) awaitSuite();
|
|
93
122
|
raiseFloor(kind);
|
|
94
123
|
updateLease(lease, { node });
|
|
124
|
+
applyCpuShare();
|
|
95
125
|
return;
|
|
96
126
|
}
|
|
97
127
|
for (const warning of config.warnings ?? []) log(`heavy: ${warning}`);
|
|
98
128
|
|
|
99
129
|
if (config.enabled) {
|
|
100
130
|
const minFreeMemGb = minFreeMemFor(config, kind);
|
|
131
|
+
reclaimBelow(minFreeMemGb);
|
|
101
132
|
const result = acquireHeavy({
|
|
102
133
|
dir: heavyDir(env),
|
|
103
134
|
slots: config.slots,
|
|
@@ -121,8 +152,9 @@ export function createHeavyGate({
|
|
|
121
152
|
waitedMs = result.waitedMs;
|
|
122
153
|
}
|
|
123
154
|
|
|
124
|
-
saved =
|
|
155
|
+
saved = Object.fromEntries([NODE_REUSE_ENV, LEASE_ENV, ...CPU_SHARE_ENVS].map((name) => [name, env[name]]));
|
|
125
156
|
if (config.disableNodeReuse) env[NODE_REUSE_ENV] = '1';
|
|
157
|
+
applyCpuShare();
|
|
126
158
|
if (lease) env[LEASE_ENV] = String(process.pid);
|
|
127
159
|
entered = true;
|
|
128
160
|
if (lease) {
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { defaultExec } from '../../../framework/hooks/shared/e2e-check.js';
|
|
4
|
+
import { heavyDir, readHolders } from '../../../framework/hooks/shared/heavy-holders.js';
|
|
5
|
+
import { pidAlive } from '../../../framework/hooks/shared/process-alive.js';
|
|
6
|
+
import { EVENTS_FILE } from '../../../framework/hooks/shared/telemetry-logger.js';
|
|
7
|
+
import { tearDownFeatureStack } from '../e2e/stack-teardown.js';
|
|
8
|
+
import { projectKey, readRegistry } from '../projects/registry.js';
|
|
9
|
+
import { resolveFeatureTree } from '../worktree/feature-truth.js';
|
|
10
|
+
import { afterHeavyRun } from './cleanup.js';
|
|
11
|
+
import { liveE2eStacks, repoMainRoot } from './live-stacks.js';
|
|
12
|
+
|
|
13
|
+
const DOCKER_TIMEOUT_MS = 10_000;
|
|
14
|
+
|
|
15
|
+
function lastActivityMs(tree) {
|
|
16
|
+
try {
|
|
17
|
+
return statSync(join(tree, EVENTS_FILE)).mtimeMs;
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function liveHolders(config, env, now) {
|
|
24
|
+
return readHolders(heavyDir(env), { now, isAlive: pidAlive, maxHoldMs: config.maxHoldMinutes * 60_000 });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function registeredRoots() {
|
|
28
|
+
try {
|
|
29
|
+
return readRegistry().projects.map((p) => p.dir);
|
|
30
|
+
} catch {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const uniqueRoots = (roots) => [...new Map(roots.map((r) => [projectKey(r), r])).values()];
|
|
36
|
+
|
|
37
|
+
// One daemon answers for every project: asking it once per project would add a hung timeout each.
|
|
38
|
+
function onceByArgs(exec) {
|
|
39
|
+
const seen = new Map();
|
|
40
|
+
return (file, args, opts) => {
|
|
41
|
+
const key = args.join(' ');
|
|
42
|
+
if (!seen.has(key)) seen.set(key, exec(file, args, opts));
|
|
43
|
+
return seen.get(key);
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function reclaimStacks({ root, feature, held, idleMs, now, stacks, treeOf, activityOf, tearDown, exec }) {
|
|
48
|
+
const freed = [];
|
|
49
|
+
for (const stack of stacks(root, { exec })) {
|
|
50
|
+
if (!stack.feature || stack.state === 'unknown' || stack.feature === feature || held.has(stack.feature)) continue;
|
|
51
|
+
const archived = stack.state === 'archived';
|
|
52
|
+
// A feature without a worktree lives in the root that listed it.
|
|
53
|
+
const tree = archived ? null : treeOf(stack.feature, root) ?? root;
|
|
54
|
+
if (!archived) {
|
|
55
|
+
// Unknown activity counts as live: another session's verifier may be navigating this stack.
|
|
56
|
+
const last = activityOf(tree);
|
|
57
|
+
if (last === null || now() - last < idleMs) continue;
|
|
58
|
+
}
|
|
59
|
+
// An active feature keeps its data: the next `e2e up` only rebuilds.
|
|
60
|
+
const result = tearDown(stack.feature, { root, worktreePath: tree, volumes: archived });
|
|
61
|
+
if (result?.status !== 'fail') freed.push(stack.feature);
|
|
62
|
+
}
|
|
63
|
+
return freed;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function reclaimContainers(prefixes, exec) {
|
|
67
|
+
const removed = [];
|
|
68
|
+
for (const prefix of prefixes) {
|
|
69
|
+
const ps = exec('docker', ['ps', '-a', '--filter', `name=^${prefix}`, '--format', '{{.Names}}'], { timeout: DOCKER_TIMEOUT_MS });
|
|
70
|
+
if (ps.code !== 0) continue;
|
|
71
|
+
for (const name of String(ps.output ?? '').split(/\r?\n/).map((n) => n.trim()).filter(Boolean)) {
|
|
72
|
+
if (exec('docker', ['rm', '-f', name], { timeout: DOCKER_TIMEOUT_MS }).code === 0) removed.push(name);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return removed;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The entry every real caller uses: a test suite faking low RAM must never reach this machine's stacks. */
|
|
79
|
+
export function reclaimUnlessDisabled(options) {
|
|
80
|
+
if (process.env.MORPH_HEAVY_RECLAIM === 'off') return { freed: [], containers: [] };
|
|
81
|
+
return reclaimMemory(options);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function reclaimMemory({ projectPath, feature = null, config, env = process.env, deps = {} }) {
|
|
85
|
+
const {
|
|
86
|
+
stacks = liveE2eStacks, tearDown = tearDownFeatureStack, treeOf = (f, root) => resolveFeatureTree(f, root).tree,
|
|
87
|
+
activityOf = lastActivityMs, holders = liveHolders, buildServer = afterHeavyRun, now = Date.now,
|
|
88
|
+
roots = registeredRoots, exec = defaultExec,
|
|
89
|
+
} = deps;
|
|
90
|
+
if (!config.reclaimIdleMinutes) return { freed: [], containers: [] };
|
|
91
|
+
|
|
92
|
+
const cachedExec = onceByArgs(exec);
|
|
93
|
+
const live = holders(config, env, now);
|
|
94
|
+
const held = new Set(live.map((h) => h.feature).filter(Boolean));
|
|
95
|
+
const idleMs = config.reclaimIdleMinutes * 60_000;
|
|
96
|
+
|
|
97
|
+
const freed = uniqueRoots([repoMainRoot(projectPath), ...roots()]).flatMap((root) => reclaimStacks({
|
|
98
|
+
root, feature, held, idleMs, now, stacks, treeOf, activityOf, tearDown, exec: cachedExec,
|
|
99
|
+
}));
|
|
100
|
+
|
|
101
|
+
// A test container has no owner to ask: any holder that may be testing protects them all.
|
|
102
|
+
const testing = live.some((h) => h.node === 'tests' || !h.node);
|
|
103
|
+
const containers = testing ? [] : reclaimContainers(config.reclaimContainers, exec);
|
|
104
|
+
|
|
105
|
+
buildServer({ projectPath, config, env, onlyUnderPressure: true });
|
|
106
|
+
return { freed, containers };
|
|
107
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { freemem } from 'node:os';
|
|
3
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
4
|
+
import { GB } from '../../../framework/hooks/shared/heavy-holders.js';
|
|
5
|
+
import { pidAlive } from '../../../framework/hooks/shared/process-alive.js';
|
|
6
|
+
import { stopTree } from '../procs/prune.js';
|
|
7
|
+
import { reclaimUnlessDisabled } from './reclaim.js';
|
|
8
|
+
|
|
9
|
+
const SAMPLE_MS = 10_000;
|
|
10
|
+
const LOW_SAMPLES_TO_STOP = 6;
|
|
11
|
+
const EXIT_BUSY = '3';
|
|
12
|
+
|
|
13
|
+
/** The floor judges only the start of a node; this judges the middle of one. It only ever stops its own run. */
|
|
14
|
+
export async function watchRun({
|
|
15
|
+
workerPid, exitFile, errFile, projectPath, feature, config, env = process.env, deps = {},
|
|
16
|
+
}) {
|
|
17
|
+
const {
|
|
18
|
+
freeMem = freemem, isAlive = pidAlive, wait = sleep, sampleMs = SAMPLE_MS, reclaim = reclaimUnlessDisabled,
|
|
19
|
+
list, kill,
|
|
20
|
+
} = deps;
|
|
21
|
+
if (!config.criticalFreeMemGb) return 'off';
|
|
22
|
+
|
|
23
|
+
let low = 0;
|
|
24
|
+
while (isAlive(workerPid) && !existsSync(exitFile)) {
|
|
25
|
+
const freeGb = freeMem() / GB;
|
|
26
|
+
if (freeGb >= config.criticalFreeMemGb) {
|
|
27
|
+
low = 0;
|
|
28
|
+
} else {
|
|
29
|
+
low += 1;
|
|
30
|
+
if (low === 1) {
|
|
31
|
+
try {
|
|
32
|
+
reclaim({ projectPath, feature, config, env });
|
|
33
|
+
} catch {}
|
|
34
|
+
}
|
|
35
|
+
if (low >= LOW_SAMPLES_TO_STOP) {
|
|
36
|
+
if (existsSync(exitFile)) return 'finished';
|
|
37
|
+
stopTree(workerPid, { list, kill });
|
|
38
|
+
appendFileSync(errFile, `verify parado: ${freeGb.toFixed(1)} GB livres, abaixo de heavy.criticalFreeMemGb (${config.criticalFreeMemGb}) por ${LOW_SAMPLES_TO_STOP} leituras. Não é vermelho.\n`);
|
|
39
|
+
writeFileSync(exitFile, EXIT_BUSY);
|
|
40
|
+
return 'stopped';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
await wait(sampleMs);
|
|
44
|
+
}
|
|
45
|
+
return 'finished';
|
|
46
|
+
}
|
package/src/lib/next-step.js
CHANGED
|
@@ -6,5 +6,6 @@ export function nextStepFor(featureName, tree) {
|
|
|
6
6
|
const signals = collectNextSignals(featureName, tree);
|
|
7
7
|
if (!signals) return null;
|
|
8
8
|
signals.verificationStale = treeMovedSince(tree, signals.verification?.sha);
|
|
9
|
+
signals.affectedStale = treeMovedSince(tree, signals.affected?.sha);
|
|
9
10
|
return { ...decideNext(signals), phase: signals.phase, failStreak: signals.failStreak };
|
|
10
11
|
}
|
package/src/lib/plan/checks.js
CHANGED
|
@@ -103,7 +103,7 @@ export function validatePlan(plan, { workType = null, riskRules = true, text = n
|
|
|
103
103
|
} else if (riskRules && proof === 'teste' && risk === null) {
|
|
104
104
|
errors.push(`${c.id}: "prova: teste" sem "risco:" (${RISK_KINDS.join(' | ')}) — sem risco, a prova é "leitura" ou "e2e"${at(c.line)}`);
|
|
105
105
|
} else if (risk && proof === 'leitura') {
|
|
106
|
-
warnings.push(`${c.id}: risco "${risk}" provado só por leitura${at(c.line)}`);
|
|
106
|
+
(riskRules ? errors : warnings).push(`${c.id}: risco "${risk}" provado só por leitura — quem tem risco se prova executando ("teste" ou "e2e")${at(c.line)}`);
|
|
107
107
|
}
|
|
108
108
|
if (canonicalRung(c.rung) === false) {
|
|
109
109
|
errors.push(`${c.id}: degrau "${c.rung}" fora de ${REWORK_RUNGS.join(' | ')}${at(c.line)}`);
|
package/src/lib/procs/prune.js
CHANGED
|
@@ -1,4 +1,26 @@
|
|
|
1
|
-
import { readProcess } from './list.js';
|
|
1
|
+
import { listProcesses, readProcess } from './list.js';
|
|
2
|
+
|
|
3
|
+
export function descendantsOf(pid, procs) {
|
|
4
|
+
const found = [];
|
|
5
|
+
const queue = [pid];
|
|
6
|
+
while (queue.length) {
|
|
7
|
+
const parent = queue.shift();
|
|
8
|
+
for (const child of procs.filter((p) => p.ppid === parent && !found.includes(p.pid))) {
|
|
9
|
+
found.push(child.pid);
|
|
10
|
+
queue.push(child.pid);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return found;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function stopTree(pid, { list = listProcesses, kill = (target) => process.kill(target) } = {}) {
|
|
17
|
+
const { procs } = list();
|
|
18
|
+
for (const target of [...descendantsOf(pid, procs ?? []).reverse(), pid]) {
|
|
19
|
+
try {
|
|
20
|
+
kill(target);
|
|
21
|
+
} catch {}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
2
24
|
|
|
3
25
|
export function pruneOrphans(rows, { read = readProcess, kill = (pid) => process.kill(pid) } = {}) {
|
|
4
26
|
const results = [];
|
|
@@ -90,6 +90,7 @@ function makeNode(partial) {
|
|
|
90
90
|
projectCount: partial.projectCount ?? 1,
|
|
91
91
|
// null is "not an eval node", distinct from an empty block (already refused).
|
|
92
92
|
evals: partial.evals ?? null,
|
|
93
|
+
affected: partial.affected ?? null,
|
|
93
94
|
status: partial.status ?? null,
|
|
94
95
|
reason: partial.reason ?? null,
|
|
95
96
|
};
|
|
@@ -128,6 +129,9 @@ function firstProblem(raw, projectPath) {
|
|
|
128
129
|
if (raw.runner !== undefined && !TEST_RUNNERS.includes(raw.runner)) {
|
|
129
130
|
return `runner must be one of ${TEST_RUNNERS.join(' | ')}`;
|
|
130
131
|
}
|
|
132
|
+
if (raw.affected !== undefined && (!isPlainObject(raw.affected) || typeof raw.affected.flag !== 'string' || !raw.affected.flag.trim())) {
|
|
133
|
+
return 'affected must be an object with a non-empty flag';
|
|
134
|
+
}
|
|
131
135
|
if (raw.expectedCount !== undefined && !isNonNegativeInteger(raw.expectedCount)) {
|
|
132
136
|
return 'expectedCount must be an integer >= 0';
|
|
133
137
|
}
|
|
@@ -217,6 +221,7 @@ export function validateTestNodes(config, projectPath = null) {
|
|
|
217
221
|
e2e: entry.e2e ?? false,
|
|
218
222
|
linuxImage: entry.linuxImage ?? null,
|
|
219
223
|
evals: entry.evals ?? null,
|
|
224
|
+
affected: entry.affected ?? null,
|
|
220
225
|
projectCount: 1,
|
|
221
226
|
});
|
|
222
227
|
});
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
} from '../tasks/eval-node.js';
|
|
23
23
|
import { runValidation } from './validation-runner.js';
|
|
24
24
|
import { createHeavyGate } from '../heavy/gate.js';
|
|
25
|
+
import { selectAffected } from '../verify/affected.js';
|
|
25
26
|
import {
|
|
26
27
|
cacheEntryFrom, planReuse, readVerifyCache, reusedEntry, writeVerifyCache,
|
|
27
28
|
} from './verify-cache.js';
|
|
@@ -48,12 +49,34 @@ export class VerifyUsageError extends Error {
|
|
|
48
49
|
export async function runVerify(projectPath, featureName, options = {}) {
|
|
49
50
|
const gate = options._heavyGate ?? createHeavyGate({ projectPath, feature: featureName });
|
|
50
51
|
try {
|
|
51
|
-
return await runVerifyGated(projectPath, featureName, options, gate);
|
|
52
|
+
return await runVerifyGated(projectPath, featureName, applyAffectedScope(projectPath, options), gate);
|
|
52
53
|
} finally {
|
|
53
54
|
gate.release();
|
|
54
55
|
}
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
export function withCpuShare(node, share) {
|
|
59
|
+
if (!share || node.runner !== 'vitest' || /--max-?workers/i.test(node.command ?? '')) return node;
|
|
60
|
+
return { ...node, command: `${node.command} --maxWorkers=${share}` };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function applyAffectedScope(projectPath, options) {
|
|
64
|
+
if (!options.affected) return options;
|
|
65
|
+
const conflict = [['filter', '--filter'], ['recordBaseline', '--record-baseline'], ['refreshCache', '--refresh-cache'], ['linux', '--linux']]
|
|
66
|
+
.find(([key]) => options[key]);
|
|
67
|
+
if (conflict) throw new VerifyUsageError(`--affected escolhe os testes sozinho; não combina com ${conflict[1]}`);
|
|
68
|
+
|
|
69
|
+
const selection = selectAffected({ projectPath, nodes: resolveTestNodes(projectPath).nodes, deps: options._affectedDeps });
|
|
70
|
+
if (selection.scope === 'full') return { ...options, escalatedBecause: selection.reason };
|
|
71
|
+
return {
|
|
72
|
+
...options,
|
|
73
|
+
selection,
|
|
74
|
+
skipE2e: true,
|
|
75
|
+
skipBuild: options.skipBuild || selection.docsOnly,
|
|
76
|
+
skipTests: options.skipTests || selection.nodes.length === 0,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
57
80
|
async function runVerifyGated(projectPath, featureName, options, gate) {
|
|
58
81
|
const {
|
|
59
82
|
timeout = DEFAULT_BUILD_TIMEOUT,
|
|
@@ -81,7 +104,8 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
|
|
|
81
104
|
|
|
82
105
|
// Usage errors are judged before the build: paying for a build to discover a typo
|
|
83
106
|
// is the worst ordering, and a refusal after the cache was touched leaves a half-rewritten tree.
|
|
84
|
-
const
|
|
107
|
+
const resolvedPlan = resolveTestNodes(projectPath);
|
|
108
|
+
const testPlan = options.selection ? { ...resolvedPlan, nodes: options.selection.nodes } : resolvedPlan;
|
|
85
109
|
const filter = parseTestFilter(options.filter);
|
|
86
110
|
if (filter && !testPlan.nodes.some((node) => nodeMatchesFilter(node, filter))) {
|
|
87
111
|
const declared = testPlan.nodes.map((node) => node.id).join(', ') || '(none)';
|
|
@@ -121,6 +145,11 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
|
|
|
121
145
|
|
|
122
146
|
const nodes = {};
|
|
123
147
|
const feedback = [];
|
|
148
|
+
if (options.escalatedBecause) feedback.push(`[verify] rodada completa: ${options.escalatedBecause}`);
|
|
149
|
+
if (options.selection) {
|
|
150
|
+
const kept = options.selection.nodes.map((n) => n.id).join(', ') || 'nenhuma';
|
|
151
|
+
feedback.push(`[verify] rodada afetada, suítes: ${kept}; a completa roda uma vez antes da aprovação`);
|
|
152
|
+
}
|
|
124
153
|
|
|
125
154
|
// Only a complete run feeds or reads the cache: a partial one proves nothing about the nodes it left out.
|
|
126
155
|
const cacheDir = fingerprint && !filter && !skipTests && !skipBuild && !skipE2e && !recordBaseline
|
|
@@ -212,6 +241,7 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
|
|
|
212
241
|
feature: featureName,
|
|
213
242
|
stack,
|
|
214
243
|
status,
|
|
244
|
+
scope: options.selection ? 'affected' : 'full',
|
|
215
245
|
nodes,
|
|
216
246
|
feedback,
|
|
217
247
|
reused,
|
|
@@ -454,7 +484,7 @@ async function runOneTestNode({
|
|
|
454
484
|
const start = Date.now();
|
|
455
485
|
const childEnv = env || evalChannel ? { ...(env || {}), ...(evalChannel?.env || {}) } : null;
|
|
456
486
|
const result = await _runTestSuite(projectPath, {
|
|
457
|
-
timeout, node, ...buildFacts, background: node.parallel === true, ...(childEnv ? { env: childEnv } : {}),
|
|
487
|
+
timeout, node: withCpuShare(node, gate.cpuShare), ...buildFacts, background: node.parallel === true, ...(childEnv ? { env: childEnv } : {}),
|
|
458
488
|
});
|
|
459
489
|
const durationMs = Date.now() - start;
|
|
460
490
|
const logPath = writeFailedRunLog(projectPath, node, result);
|
|
@@ -596,7 +626,7 @@ function judgeRun({ node, result, durationMs, logPath = null }) {
|
|
|
596
626
|
if (verdict.verdict === 'drop') warnings.push(verdict.reason);
|
|
597
627
|
|
|
598
628
|
// Without a baseline, zero is the floor: a green run over no test is a suite that stopped running.
|
|
599
|
-
if (result.passed && count === 0 && node.runner !== EVAL_RUNNER && !Number.isInteger(node.expectedCount)) {
|
|
629
|
+
if (result.passed && count === 0 && !node.allowEmpty && node.runner !== EVAL_RUNNER && !Number.isInteger(node.expectedCount)) {
|
|
600
630
|
return { ...entry, status: 'fail', durationMs, count, warnings, reason: 'suíte verde sem nenhum teste executado', output: '' };
|
|
601
631
|
}
|
|
602
632
|
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { basename, extname, join } from 'node:path';
|
|
4
|
+
import { changedPathsAgainst, detectDefaultBranch } from '../git/default-branch.js';
|
|
5
|
+
import { resolveBaseRef } from '../git/branch-classifier.js';
|
|
6
|
+
import { contractsTouched, migrationsTouched } from '../risk-detector.js';
|
|
7
|
+
import { isTestFile } from '../validators/reuse/symbol-scanner.js';
|
|
8
|
+
|
|
9
|
+
const MAX_TEST_CLASSES = 40;
|
|
10
|
+
const INLINE_FILTER = /(^|\s)(--filter|-Filter)\s+(?:"([^"]*)"|(\S+))/i;
|
|
11
|
+
const SCRIPT_IN_COMMAND = /([\w./\\-]+\.(?:ps1|sh))\b/i;
|
|
12
|
+
const PS_FILTER_PARAM = /\[string\]\s*\$Filter\b(?:\s*=\s*['"]([^'"]*)['"])?/i;
|
|
13
|
+
const PS_FILTER_USED = /dotnet\s+test[^\n]*\$Filter\b/i;
|
|
14
|
+
const FORWARDS_ARGS = /dotnet\s+test[^\n]*("?\$@"?|\$\{@\}|@args|\$args)/i;
|
|
15
|
+
const EVAL_NODE =/(^|[-_])evals?($|[-_])/i;
|
|
16
|
+
const DOC_PATH =/\.(md|mdx|txt|png|jpe?g|gif|svg|webp)$/i;
|
|
17
|
+
const DOTNET_SOURCE = /\.(cs|razor|cshtml)$/i;
|
|
18
|
+
const SCRIPT_SOURCE = /\.(tsx?|jsx?|mjs|cjs|css|json)$/i;
|
|
19
|
+
// Everything compiles or resolves through these, so the diff says nothing about what they reach.
|
|
20
|
+
const WIDE_PATH = /(^|\/)(package(-lock)?\.json|pnpm-lock\.yaml|yarn\.lock|global\.json|Directory\.(Build|Packages)\.(props|targets)|tsconfig[^/]*\.json|vitest\.config\.[^/]+|[^/]+\.(csproj|sln|slnx))$/i;
|
|
21
|
+
|
|
22
|
+
const defaultGit = (args, cwd) => execFileSync('git', args, { cwd, stdio: 'pipe' }).toString();
|
|
23
|
+
|
|
24
|
+
function mergeBase(baseRef, cwd, git) {
|
|
25
|
+
try {
|
|
26
|
+
return git(['merge-base', 'HEAD', baseRef], cwd).trim() || null;
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function testFilesNaming(names, cwd, git) {
|
|
33
|
+
if (names.length === 0) return [];
|
|
34
|
+
try {
|
|
35
|
+
const output = git(['grep', '-lw', ...names.flatMap((n) => ['-e', n]), '--', '*.cs'], cwd);
|
|
36
|
+
return output.split(/\r?\n/).filter((path) => path && isTestFile(path));
|
|
37
|
+
} catch {
|
|
38
|
+
// git grep exits 1 when nothing matches.
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const classOf = (path) => basename(path, extname(path));
|
|
44
|
+
|
|
45
|
+
const readText = (path) => {
|
|
46
|
+
try {
|
|
47
|
+
return readFileSync(path, 'utf8');
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Reads the wrapper the command calls, so a project gets the narrowing without declaring anything. */
|
|
54
|
+
function scriptFilter(node, cwd, read) {
|
|
55
|
+
const script = SCRIPT_IN_COMMAND.exec(node.command ?? '')?.[1];
|
|
56
|
+
const text = script ? read(join(cwd, node.cwd ?? '.', script)) : null;
|
|
57
|
+
if (!text) return null;
|
|
58
|
+
if (PS_FILTER_USED.test(text)) {
|
|
59
|
+
const param = PS_FILTER_PARAM.exec(text);
|
|
60
|
+
if (param) return { flag: '-Filter', baseFilter: param[1] ?? null };
|
|
61
|
+
}
|
|
62
|
+
return FORWARDS_ARGS.test(text) ? { flag: '--filter' } : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function narrowDotnet(node, classes, cwd, read) {
|
|
66
|
+
const selected = classes.map((c) => `FullyQualifiedName~${c}`).join('|');
|
|
67
|
+
const inline = INLINE_FILTER.exec(node.command ?? '');
|
|
68
|
+
if (inline) {
|
|
69
|
+
const [whole, lead, flag, quoted, bare] = inline;
|
|
70
|
+
return node.command.replace(whole, `${lead}${flag} "(${quoted ?? bare})&(${selected})"`);
|
|
71
|
+
}
|
|
72
|
+
const plain = /\bdotnet\s+test\b/.test(node.command ?? '') ? { flag: '--filter' } : null;
|
|
73
|
+
const how = node.affected ?? plain ?? scriptFilter(node, cwd, read);
|
|
74
|
+
if (!how) return null;
|
|
75
|
+
const filter = how.baseFilter ? `(${how.baseFilter})&(${selected})` : selected;
|
|
76
|
+
return `${node.command} ${how.flag} "${filter}"`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function selectDotnet(node, relevant, cwd, git, read) {
|
|
80
|
+
const tests = relevant.filter(isTestFile);
|
|
81
|
+
const sources = relevant.filter((p) => !isTestFile(p)).map(classOf);
|
|
82
|
+
const classes = [...new Set([...tests, ...testFilesNaming(sources, cwd, git)].map(classOf))].sort();
|
|
83
|
+
if (classes.length === 0) return { skip: true };
|
|
84
|
+
if (classes.length > MAX_TEST_CLASSES) return { wide: classes.length };
|
|
85
|
+
const command = narrowDotnet(node, classes, cwd, read);
|
|
86
|
+
// A command nothing can narrow runs whole, as it would without this.
|
|
87
|
+
return command ? { node: { ...node, command, allowEmpty: true }, classes } : { node };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function selectVitest(node, base) {
|
|
91
|
+
return { node: { ...node, command: `${node.command} --changed ${base} --passWithNoTests`, allowEmpty: true } };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function selectNode(node, { changed, base, cwd, git, read }) {
|
|
95
|
+
// A project may run its evals as a plain dotnet node: they cost money, so the name counts too.
|
|
96
|
+
if (node.e2e || node.evals || EVAL_NODE.test(node.id ?? '')) return { skip: true };
|
|
97
|
+
const prefix = node.cwd && node.cwd !== '.' ? `${node.cwd.replace(/\\/g, '/').replace(/\/$/, '')}/` : '';
|
|
98
|
+
const inside = changed.filter((p) => p.startsWith(prefix));
|
|
99
|
+
if (node.runner === 'dotnet') {
|
|
100
|
+
const relevant = inside.filter((p) => DOTNET_SOURCE.test(p));
|
|
101
|
+
return relevant.length ? selectDotnet(node, relevant, cwd, git, read) : { skip: true };
|
|
102
|
+
}
|
|
103
|
+
const relevant = inside.filter((p) => SCRIPT_SOURCE.test(p));
|
|
104
|
+
if (relevant.length === 0) return { skip: true };
|
|
105
|
+
return node.runner === 'vitest' ? selectVitest(node, base) : { node };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** @returns {{ scope: 'full', reason: string } | { scope: 'affected', base: string, docsOnly: boolean, nodes: object[], skipped: string[] }} */
|
|
109
|
+
export function selectAffected({ projectPath, nodes, deps = {} }) {
|
|
110
|
+
const {
|
|
111
|
+
git = defaultGit, changedPaths = changedPathsAgainst, read = readText,
|
|
112
|
+
baseRefOf = (p) => resolveBaseRef(detectDefaultBranch(p), p),
|
|
113
|
+
} = deps;
|
|
114
|
+
const baseRef = baseRefOf(projectPath);
|
|
115
|
+
const base = baseRef ? mergeBase(baseRef, projectPath, git) : null;
|
|
116
|
+
if (!base) return { scope: 'full', reason: 'sem base de comparação com a branch padrão' };
|
|
117
|
+
|
|
118
|
+
const changed = changedPaths(base, projectPath)
|
|
119
|
+
.map((p) => p.replace(/\\/g, '/'))
|
|
120
|
+
.filter((p) => !p.startsWith('.morph/'));
|
|
121
|
+
|
|
122
|
+
const migration = migrationsTouched(changed);
|
|
123
|
+
if (migration.length) return { scope: 'full', reason: `migration tocada: ${migration[0]}` };
|
|
124
|
+
const contract = contractsTouched(changed);
|
|
125
|
+
if (contract.length) return { scope: 'full', reason: `contrato tocado: ${contract[0]}` };
|
|
126
|
+
// A test project's own file reaches only tests, which the complete run still covers.
|
|
127
|
+
const wide = changed.find((p) => WIDE_PATH.test(p) && !isTestFile(p));
|
|
128
|
+
if (wide) return { scope: 'full', reason: `arquivo que alcança o projeto inteiro: ${wide}` };
|
|
129
|
+
|
|
130
|
+
const code = changed.filter((p) => !DOC_PATH.test(p));
|
|
131
|
+
const context = { changed: code, base, cwd: projectPath, git, read };
|
|
132
|
+
const selected = [];
|
|
133
|
+
const skipped = [];
|
|
134
|
+
for (const node of nodes) {
|
|
135
|
+
const choice = selectNode(node, context);
|
|
136
|
+
if (choice.wide) return { scope: 'full', reason: `código compartilhado: ${choice.wide} classes de teste alcançadas` };
|
|
137
|
+
if (choice.skip) skipped.push(node.id);
|
|
138
|
+
else selected.push(choice.node);
|
|
139
|
+
}
|
|
140
|
+
return { scope: 'affected', base, docsOnly: code.length === 0, nodes: selected, skipped };
|
|
141
|
+
}
|