@polymorphism-tech/morph-spec 10.1.1 → 10.5.2
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 +3 -7
- package/bin/morph-spec.js +10 -41
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -4
- package/docs/QUICKSTART.md +1 -1
- package/framework/MORPH.md +1 -1
- package/framework/agents.json +1 -2
- package/framework/commands/morph-archive.md +1 -1
- package/framework/commands/morph-preflight.md +3 -4
- package/framework/docs/CLI.md +45 -22
- package/framework/evals/review-checklist.md +5 -5
- package/framework/hooks/README.md +2 -2
- package/framework/hooks/claude-code/core/heavy-gate.js +21 -2
- package/framework/hooks/claude-code/core/validate-completion.js +0 -35
- package/framework/hooks/shared/e2e-check.js +44 -7
- package/framework/hooks/shared/heavy-command.js +12 -1
- package/framework/hooks/shared/heavy-config.js +12 -0
- package/framework/hooks/shared/host-services.js +9 -6
- package/framework/hooks/shared/next-step.js +12 -8
- package/framework/phases.json +4 -4
- package/framework/rules/plan-grammar.md +4 -1
- package/framework/schemas/config.schema.json +59 -1
- package/framework/schemas/feature.schema.json +15 -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 -5
- package/framework/skills/core/morph-apply/references/hotfix.md +1 -1
- package/framework/skills/core/morph-apply/references/machine.md +3 -2
- package/framework/skills/core/morph-apply/references/review.md +9 -7
- package/framework/skills/core/morph-plan/SKILL.md +7 -6
- package/framework/skills/core/morph-plan/references/critique.md +1 -1
- package/framework/skills/core/morph-uiux/SKILL.md +2 -2
- 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/examples/design-system-examples.md +0 -3
- 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/doctor.js +0 -1
- package/src/commands/project/heavy.js +13 -0
- package/src/commands/state/approve.js +9 -109
- package/src/commands/state/report-check.js +44 -2
- package/src/commands/validation/verify.js +57 -7
- package/src/core/state/state-manager.js +0 -24
- package/src/dashboard/app/main.jsx +13 -13
- package/src/dashboard/vite.config.js +24 -24
- package/src/lib/heavy/cleanup.js +8 -2
- package/src/lib/heavy/gate.js +24 -1
- 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/plan/critique.js +23 -3
- package/src/lib/procs/prune.js +23 -1
- package/src/lib/review-confirmation.js +67 -0
- package/src/lib/review-reports.js +1 -0
- package/src/lib/risk-detector.js +2 -2
- package/src/lib/tasks/test-nodes.js +10 -0
- package/src/lib/tasks/test-runner.js +37 -43
- package/src/lib/telemetry/telemetry-reader.js +1 -2
- package/src/lib/telemetry/verify-event.js +7 -0
- package/src/lib/validators/verify-cache.js +63 -0
- package/src/lib/validators/verify-runner.js +131 -26
- package/src/lib/verify/affected.js +141 -0
- package/src/lib/verify/detached.js +154 -0
- package/src/utils/agents-installer.js +2 -2
- package/src/utils/hooks-installer.js +0 -20
- package/bin/validate.js +0 -212
- package/framework/hooks/claude-code/__pycache__/statusline.cpython-313.pyc +0 -0
- package/framework/templates/.idea/morph-templates.xml +0 -92
- package/framework/templates/.vscode/morph-templates.code-snippets +0 -186
- package/src/commands/observability/telemetry.js +0 -60
- package/src/commands/validation/validate-feature.js +0 -337
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
+
import { execFileSync } from 'child_process';
|
|
2
3
|
import { existsSync, readFileSync } from 'fs';
|
|
3
4
|
import { join } from 'path';
|
|
4
5
|
import { implementProgress, parseReport, reportPath } from '../../../framework/hooks/shared/report-progress.js';
|
|
@@ -14,6 +15,8 @@ import { isTestPath } from '../../lib/graph/sanitize.js';
|
|
|
14
15
|
import { getAbsoluteOutputPath } from '../../core/paths/output-schema.js';
|
|
15
16
|
import { collectReviewRequirement } from './gate-check.js';
|
|
16
17
|
import { featureGeneratesMedia } from '../../lib/plan/media.js';
|
|
18
|
+
import { resolveBaseRef } from '../../lib/git/branch-classifier.js';
|
|
19
|
+
import { reviewConfirmation } from '../../lib/review-confirmation.js';
|
|
17
20
|
|
|
18
21
|
/** Each review report: complete when it carries a readable verdict and is not partial. */
|
|
19
22
|
export function inspectReviewReports(cwd, feature) {
|
|
@@ -75,10 +78,44 @@ function treeFor(cwd, feature, beforeEval) {
|
|
|
75
78
|
const tree = treeState(cwd, { snapshot });
|
|
76
79
|
if (!beforeEval) return { tree };
|
|
77
80
|
const reviewModels = reviewDispatch(cwd, feature);
|
|
81
|
+
const gate3 = gate3Briefing(cwd, feature);
|
|
78
82
|
const next = snapshotTree(cwd);
|
|
79
|
-
if (!next) return { tree, beforeEval: null, reviewModels };
|
|
83
|
+
if (!next) return { tree, beforeEval: null, reviewModels, gate3 };
|
|
80
84
|
atomicWriteJson(path, next);
|
|
81
|
-
return { tree, beforeEval: { at: next.at, files: Object.keys(next.files).length }, reviewModels };
|
|
85
|
+
return { tree, beforeEval: { at: next.at, files: Object.keys(next.files).length }, reviewModels, gate3 };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function gitLine(cwd, args) {
|
|
89
|
+
try {
|
|
90
|
+
return execFileSync('git', args, { cwd, stdio: 'pipe' }).toString().trim() || null;
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function readVerification(cwd, feature) {
|
|
97
|
+
try {
|
|
98
|
+
return JSON.parse(readFileSync(join(cwd, '.morph', 'features', feature, 'feature.json'), 'utf8')).verification ?? null;
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// What every dispatch needs, in one read. `redispatch: null` = no report yet, every required role goes.
|
|
105
|
+
function gate3Briefing(cwd, feature) {
|
|
106
|
+
const sha = gitLine(cwd, ['rev-parse', 'HEAD']);
|
|
107
|
+
const defaultRef = resolveBaseRef(detectDefaultBranch(cwd), cwd);
|
|
108
|
+
const reports = reviewReports(cwd, feature);
|
|
109
|
+
const anyReport = Object.values(reports).some((report) => report.status !== 'missing');
|
|
110
|
+
const moved = anyReport && sha ? reviewConfirmation(cwd, reports, sha).moved : [];
|
|
111
|
+
const { checks } = readPlanChecks(cwd, feature);
|
|
112
|
+
return {
|
|
113
|
+
base: defaultRef ? gitLine(cwd, ['merge-base', defaultRef, 'HEAD']) : null,
|
|
114
|
+
sha,
|
|
115
|
+
flaky: readVerification(cwd, feature)?.flaky ?? [],
|
|
116
|
+
e2eChecks: (checks?.checks ?? []).filter((check) => check.proof === 'e2e').map((check) => check.id),
|
|
117
|
+
redispatch: anyReport ? moved.filter((role) => !role.skippable).map((role) => role.role) : null,
|
|
118
|
+
};
|
|
82
119
|
}
|
|
83
120
|
|
|
84
121
|
export function reportCheckCommand(feature, options = {}) {
|
|
@@ -128,6 +165,11 @@ function printHuman(obj) {
|
|
|
128
165
|
const m = obj.reviewModels;
|
|
129
166
|
console.log(chalk.gray(` dispatch: verifier ${m.verifier.model}, reviewer ${m.reviewer.model}${m.security.required ? `, security ${m.security.model}` : ''}`));
|
|
130
167
|
}
|
|
168
|
+
if (obj.gate3) {
|
|
169
|
+
const g = obj.gate3;
|
|
170
|
+
console.log(chalk.gray(` gate 3: base ${g.base?.slice(0, 8) ?? '-'}, sha ${g.sha?.slice(0, 8) ?? '-'}`
|
|
171
|
+
+ `${g.redispatch ? `, voltam: ${g.redispatch.join(', ') || 'nenhum'}` : ''}${g.flaky.length ? `, instáveis: ${g.flaky.join(', ')}` : ''}`));
|
|
172
|
+
}
|
|
131
173
|
printTree(obj.tree);
|
|
132
174
|
console.log('');
|
|
133
175
|
}
|
|
@@ -15,6 +15,25 @@ import { memoryBusyHint } from '../../lib/heavy/live-stacks.js';
|
|
|
15
15
|
import { readHeavyConfig } from '../../../framework/hooks/shared/heavy-config.js';
|
|
16
16
|
import { runCiVerify, CiUsageError, reusableLocalStamp } from '../../lib/ci/ci-verify.js';
|
|
17
17
|
import { syncStackRules } from '../../lib/installers/stack-sync.js';
|
|
18
|
+
import {
|
|
19
|
+
DETACH_RUN_ENV, DETACH_STAGE_ENV, DETACH_WORKER_ENV, launchWorker, markWorkerExit, runFilesOf, superviseVerify,
|
|
20
|
+
} from '../../lib/verify/detached.js';
|
|
21
|
+
import { watchRun } from '../../lib/heavy/watchdog.js';
|
|
22
|
+
|
|
23
|
+
const detachedArgs = () => process.argv.slice(2).filter((arg) => arg !== '--detach');
|
|
24
|
+
|
|
25
|
+
async function runDetached(featureName, options) {
|
|
26
|
+
const result = await superviseVerify({ feature: featureName, args: detachedArgs() });
|
|
27
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
28
|
+
if (result.died) {
|
|
29
|
+
const reason = 'a rodada destacada parou sem resultado';
|
|
30
|
+
if (options.json) console.log(JSON.stringify({ status: 'busy', persisted: false, reason }));
|
|
31
|
+
else console.error(chalk.yellow(`⏳ ${reason}: sem stamp, não é vermelho.`));
|
|
32
|
+
} else if (result.stdout) {
|
|
33
|
+
process.stdout.write(result.stdout);
|
|
34
|
+
}
|
|
35
|
+
process.exit(result.exitCode);
|
|
36
|
+
}
|
|
18
37
|
|
|
19
38
|
function statusIcon(status) {
|
|
20
39
|
if (status === 'pass') return chalk.green('✓');
|
|
@@ -109,12 +128,15 @@ const PARTIAL_RUN_FLAGS = [['filter', 'filtered'], ['skipTests', 'skip-tests'],
|
|
|
109
128
|
const CI_EXCLUSIVE_FLAGS = [
|
|
110
129
|
['filter', '--filter'], ['skipTests', '--skip-tests'], ['skipBuild', '--skip-build'], ['skipE2e', '--skip-e2e'],
|
|
111
130
|
['freshE2e', '--fresh-e2e'], ['linux', '--linux'], ['recordBaseline', '--record-baseline'], ['refreshCache', '--refresh-cache'],
|
|
112
|
-
['noPersist', '--no-persist'],
|
|
131
|
+
['noPersist', '--no-persist'], ['fresh', '--fresh'], ['affected', '--affected'],
|
|
113
132
|
];
|
|
114
133
|
|
|
115
|
-
async function runLocalVerify(featureName, options) {
|
|
134
|
+
async function runLocalVerify(featureName, options, fingerprint) {
|
|
116
135
|
try {
|
|
117
136
|
return await runVerify(process.cwd(), featureName, {
|
|
137
|
+
fingerprint,
|
|
138
|
+
fresh: options.fresh,
|
|
139
|
+
affected: options.affected,
|
|
118
140
|
timeout: options.timeout,
|
|
119
141
|
skipTests: options.skipTests,
|
|
120
142
|
skipBuild: options.skipBuild,
|
|
@@ -164,6 +186,28 @@ function exitWithReusedStamp(featureName, reused, why, options) {
|
|
|
164
186
|
// Exit 2 is "the command cannot mean what you typed" (unknown feature, `--filter` matching
|
|
165
187
|
// nothing, `--record-baseline` without config.json, `--linux` without Docker): never a verdict.
|
|
166
188
|
export async function verifyCommand(featureName, options = {}) {
|
|
189
|
+
const stage = process.env[DETACH_STAGE_ENV];
|
|
190
|
+
if (stage === 'launch') {
|
|
191
|
+
launchWorker({ argv: process.argv.slice(2) });
|
|
192
|
+
process.exit(0);
|
|
193
|
+
}
|
|
194
|
+
if (stage === 'watchdog') {
|
|
195
|
+
const { exit, err } = runFilesOf(process.env[DETACH_RUN_ENV]);
|
|
196
|
+
await watchRun({
|
|
197
|
+
workerPid: Number(process.env[DETACH_WORKER_ENV]), exitFile: exit, errFile: err,
|
|
198
|
+
projectPath: process.cwd(), feature: featureName, config: readHeavyConfig(process.cwd()),
|
|
199
|
+
});
|
|
200
|
+
process.exit(0);
|
|
201
|
+
}
|
|
202
|
+
if (stage === 'worker') {
|
|
203
|
+
markWorkerExit(process.env[DETACH_RUN_ENV]);
|
|
204
|
+
// A verify nested in a test suite must not report as this run.
|
|
205
|
+
delete process.env[DETACH_STAGE_ENV];
|
|
206
|
+
delete process.env[DETACH_RUN_ENV];
|
|
207
|
+
// Out of a shell's reach there is no call timeout to beat, so the queue never needs to refuse.
|
|
208
|
+
process.env.MORPH_HEAVY_WAIT_SEC = String(readHeavyConfig(process.cwd()).detachedWaitMinutes * 60);
|
|
209
|
+
}
|
|
210
|
+
|
|
167
211
|
let feature;
|
|
168
212
|
try {
|
|
169
213
|
feature = getFeature(featureName);
|
|
@@ -174,6 +218,7 @@ export async function verifyCommand(featureName, options = {}) {
|
|
|
174
218
|
console.error(chalk.red(`\n❌ Feature not found: ${featureName}\n`));
|
|
175
219
|
process.exit(2);
|
|
176
220
|
}
|
|
221
|
+
if (options.detach) await runDetached(featureName, options);
|
|
177
222
|
|
|
178
223
|
syncStackRules(process.cwd(), { persistTags: false, log: (line) => console.error(chalk.gray(line)) });
|
|
179
224
|
|
|
@@ -214,14 +259,17 @@ export async function verifyCommand(featureName, options = {}) {
|
|
|
214
259
|
// Taken before the run: it is the tree the run is about to read.
|
|
215
260
|
const fingerprint = treeFingerprint(process.cwd());
|
|
216
261
|
|
|
217
|
-
const result = ci ? ci.result : await runLocalVerify(featureName, options);
|
|
262
|
+
const result = ci ? ci.result : await runLocalVerify(featureName, options, fingerprint);
|
|
218
263
|
|
|
219
264
|
const cleanup = ci ? { status: 'skipped', reason: 'ci' } : afterHeavyRun({
|
|
220
265
|
projectPath: process.cwd(),
|
|
221
266
|
config: readHeavyConfig(process.cwd()),
|
|
267
|
+
onlyUnderPressure: true,
|
|
222
268
|
});
|
|
223
269
|
|
|
224
|
-
|
|
270
|
+
// Only a complete run may stand in for the proof the review gate asks for: the affected one keeps its own record.
|
|
271
|
+
const scopedRun = result.scope === 'affected';
|
|
272
|
+
const historyKey = scopedRun ? 'verifications.affected' : 'verifications.feature';
|
|
225
273
|
// A run that measured only part of the suite must not stand in for the proof a gate asks for:
|
|
226
274
|
// --filter and --skip-*, or a --record-baseline that recorded nothing; --no-persist keeps its own reason.
|
|
227
275
|
const partialRun = options.noPersist ? null : PARTIAL_RUN_FLAGS.find(([flag]) => options[flag])?.[1] ?? null;
|
|
@@ -234,7 +282,7 @@ export async function verifyCommand(featureName, options = {}) {
|
|
|
234
282
|
|
|
235
283
|
let compact = null;
|
|
236
284
|
if (persist) {
|
|
237
|
-
const prevEntry = feature.verifications?.feature;
|
|
285
|
+
const prevEntry = feature.verifications?.[scopedRun ? 'affected' : 'feature'];
|
|
238
286
|
const now = new Date().toISOString();
|
|
239
287
|
compact = {
|
|
240
288
|
status: result.status,
|
|
@@ -242,6 +290,7 @@ export async function verifyCommand(featureName, options = {}) {
|
|
|
242
290
|
attempts: (prevEntry?.attempts ?? 0) + 1,
|
|
243
291
|
failStreak: result.status === 'fail' ? (prevEntry?.failStreak ?? 0) + 1 : 0,
|
|
244
292
|
updatedAt: now,
|
|
293
|
+
...(scopedRun ? { sha: getHeadSha(process.cwd()) } : {}),
|
|
245
294
|
};
|
|
246
295
|
await updateFeature(featureName, historyKey, compact);
|
|
247
296
|
|
|
@@ -254,9 +303,10 @@ export async function verifyCommand(featureName, options = {}) {
|
|
|
254
303
|
e2eReason: result.nodes.e2e.status === 'skip' ? (result.nodes.e2e.reason ?? null) : null,
|
|
255
304
|
sha: ci ? ci.run.headSha : getHeadSha(process.cwd()),
|
|
256
305
|
ts: now,
|
|
306
|
+
...(result.flaky?.length ? { flaky: result.flaky } : {}),
|
|
257
307
|
...(ci ? { source: 'ci', ciRun: ci.run.url } : {}),
|
|
258
308
|
};
|
|
259
|
-
await updateFeature(featureName, 'verification', stamp);
|
|
309
|
+
if (!scopedRun) await updateFeature(featureName, 'verification', stamp);
|
|
260
310
|
}
|
|
261
311
|
|
|
262
312
|
// A run over the exact tree of the previous one proves nothing
|
|
@@ -266,7 +316,7 @@ export async function verifyCommand(featureName, options = {}) {
|
|
|
266
316
|
const cwd = process.cwd();
|
|
267
317
|
if (persist && !ci) repeatOf = findRepeatedVerify(cwd, { feature: featureName, fingerprint });
|
|
268
318
|
const tree = resolve(cwd).replace(/\\/g, '/') === resolvePrimaryRoot(cwd) ? 'root' : 'worktree';
|
|
269
|
-
recordVerifyEvent(cwd, buildVerifyEvent(result, { sha: ci ? ci.run.headSha : getHeadSha(cwd), tree, persisted: persist, reason, fingerprint, source: ci ? 'ci' : 'local' }));
|
|
319
|
+
recordVerifyEvent(cwd, buildVerifyEvent(result, { sha: ci ? ci.run.headSha : getHeadSha(cwd), tree, persisted: persist && !scopedRun, reason: scopedRun ? 'affected' : reason, fingerprint, source: ci ? 'ci' : 'local' }));
|
|
270
320
|
} catch { /* telemetry never changes the verdict */ }
|
|
271
321
|
|
|
272
322
|
// The refusal always goes to stderr, so it survives --json (whose stdout has
|
|
@@ -521,27 +521,3 @@ export function getApprovalGate(featureName, gate) {
|
|
|
521
521
|
return feature.approvalGates[gate] || null;
|
|
522
522
|
}
|
|
523
523
|
|
|
524
|
-
export function getApprovalHistory(featureName) {
|
|
525
|
-
const state = loadState();
|
|
526
|
-
const feature = state.features[featureName];
|
|
527
|
-
|
|
528
|
-
if (!feature || !feature.approvalGates) {
|
|
529
|
-
return [];
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
const history = [];
|
|
533
|
-
|
|
534
|
-
Object.entries(feature.approvalGates).forEach(([gate, data]) => {
|
|
535
|
-
if (data.timestamp) {
|
|
536
|
-
history.push({
|
|
537
|
-
gate,
|
|
538
|
-
approved: data.approved,
|
|
539
|
-
timestamp: data.timestamp,
|
|
540
|
-
approvedBy: data.approvedBy || data.rejectedBy,
|
|
541
|
-
reason: data.reason
|
|
542
|
-
});
|
|
543
|
-
}
|
|
544
|
-
});
|
|
545
|
-
|
|
546
|
-
return history.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
|
|
547
|
-
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import { StrictMode } from 'react';
|
|
2
|
-
import { createRoot } from 'react-dom/client';
|
|
3
|
-
import { BrowserRouter } from 'react-router-dom';
|
|
4
|
-
import App from './App.jsx';
|
|
5
|
-
import './styles/index.css';
|
|
6
|
-
|
|
7
|
-
createRoot(document.getElementById('root')).render(
|
|
8
|
-
<StrictMode>
|
|
9
|
-
<BrowserRouter>
|
|
10
|
-
<App />
|
|
11
|
-
</BrowserRouter>
|
|
12
|
-
</StrictMode>
|
|
13
|
-
);
|
|
1
|
+
import { StrictMode } from 'react';
|
|
2
|
+
import { createRoot } from 'react-dom/client';
|
|
3
|
+
import { BrowserRouter } from 'react-router-dom';
|
|
4
|
+
import App from './App.jsx';
|
|
5
|
+
import './styles/index.css';
|
|
6
|
+
|
|
7
|
+
createRoot(document.getElementById('root')).render(
|
|
8
|
+
<StrictMode>
|
|
9
|
+
<BrowserRouter>
|
|
10
|
+
<App />
|
|
11
|
+
</BrowserRouter>
|
|
12
|
+
</StrictMode>
|
|
13
|
+
);
|
|
@@ -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/cleanup.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readdirSync } from 'node:fs';
|
|
2
|
+
import { freemem } from 'node:os';
|
|
2
3
|
import { getBuildSteps } from '../../../framework/hooks/shared/build-check.js';
|
|
3
4
|
import { listProcesses, defaultProcExec } from '../procs/list.js';
|
|
4
5
|
import { readProjectConfig } from '../../../framework/hooks/shared/heavy-config.js';
|
|
@@ -8,6 +9,7 @@ const DOTNET_ACTIVE = /\bdotnet(?:\.exe)?["']?\s+(?:build|test|publish|pack|msbu
|
|
|
8
9
|
const IDLE_WORKER = /\/nodemode:\d|vbcscompiler|razorserver/i;
|
|
9
10
|
const SHUTDOWN_TIMEOUT_MS = 30_000;
|
|
10
11
|
const TAIL_CHARS = 400;
|
|
12
|
+
const GB = 1024 ** 3;
|
|
11
13
|
|
|
12
14
|
function hasDotnet(projectPath) {
|
|
13
15
|
const config = readProjectConfig(projectPath);
|
|
@@ -32,14 +34,18 @@ export function dotnetBusy(procs) {
|
|
|
32
34
|
});
|
|
33
35
|
}
|
|
34
36
|
|
|
35
|
-
export function afterHeavyRun({ projectPath, config, env = process.env, deps = {} }) {
|
|
37
|
+
export function afterHeavyRun({ projectPath, config, env = process.env, onlyUnderPressure = false, deps = {} }) {
|
|
36
38
|
const {
|
|
37
|
-
exec = defaultProcExec, list = listProcesses, dotnet = hasDotnet, exclusive = runExclusive,
|
|
39
|
+
exec = defaultProcExec, list = listProcesses, dotnet = hasDotnet, exclusive = runExclusive, freeMem = freemem,
|
|
38
40
|
} = deps;
|
|
39
41
|
|
|
40
42
|
if (!config.enabled) return { status: 'skipped', reason: 'semáforo desligado: sem ele nada prova que outra sessão não está compilando' };
|
|
41
43
|
if (!config.shutdownBuildServer) return { status: 'skipped', reason: 'desligado em heavy.shutdownBuildServer' };
|
|
42
44
|
if (!dotnet(projectPath)) return { status: 'skipped', reason: 'projeto sem .NET' };
|
|
45
|
+
// A warm compiler is what makes the next round short; twice the floor keeps the next queue open.
|
|
46
|
+
if (onlyUnderPressure && freeMem() / GB >= config.minFreeMemGb * 2) {
|
|
47
|
+
return { status: 'skipped', reason: 'RAM livre de sobra: o compilador fica quente para a próxima rodada' };
|
|
48
|
+
}
|
|
43
49
|
|
|
44
50
|
const guarded = exclusive({
|
|
45
51
|
dir: heavyDir(env), label: 'build-server-shutdown', maxHoldMs: config.maxHoldMinutes * 60_000, env,
|
package/src/lib/heavy/gate.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { availableParallelism, freemem } from 'node:os';
|
|
1
2
|
import { minFreeMemFor, readHeavyConfig } from '../../../framework/hooks/shared/heavy-config.js';
|
|
2
3
|
import { heavyClass } from '../../../framework/hooks/shared/heavy-command.js';
|
|
4
|
+
import { GB } from '../../../framework/hooks/shared/heavy-holders.js';
|
|
3
5
|
import {
|
|
4
6
|
acquireHeavy, awaitFreeMemory, describeBusy, enterVerifySuite, heavyDir, isVerifySuite, releaseHeavy, updateLease,
|
|
5
7
|
} from '../../../framework/hooks/shared/heavy-slots.js';
|
|
8
|
+
import { reclaimUnlessDisabled } from './reclaim.js';
|
|
6
9
|
|
|
7
10
|
export class HeavyBusyError extends Error {
|
|
8
11
|
constructor(result, config) {
|
|
@@ -24,6 +27,9 @@ export class HeavyBusyError extends Error {
|
|
|
24
27
|
|
|
25
28
|
const NODE_REUSE_ENV = 'MSBUILDDISABLENODEREUSE';
|
|
26
29
|
const LEASE_ENV = 'MORPH_HEAVY_LEASE';
|
|
30
|
+
const CPU_ENV = 'DOTNET_PROCESSOR_COUNT';
|
|
31
|
+
|
|
32
|
+
export const cpuShareOf = (slots, cores = availableParallelism()) => Math.max(2, Math.floor(cores / slots));
|
|
27
33
|
|
|
28
34
|
export function createHeavyGate({
|
|
29
35
|
projectPath, label = 'verify', feature = null, config = readHeavyConfig(projectPath),
|
|
@@ -36,6 +42,19 @@ export function createHeavyGate({
|
|
|
36
42
|
let suiteEntered = false;
|
|
37
43
|
let exitHandler = null;
|
|
38
44
|
let saved = null;
|
|
45
|
+
let reclaimed = false;
|
|
46
|
+
const politeRun = config.enabled && config.yieldToUser;
|
|
47
|
+
|
|
48
|
+
const reclaimBelow = (floor) => {
|
|
49
|
+
const { freeMem = freemem, reclaim = reclaimUnlessDisabled } = deps;
|
|
50
|
+
if (reclaimed || !config.reclaimIdleMinutes || freeMem() / GB >= floor) return;
|
|
51
|
+
reclaimed = true;
|
|
52
|
+
try {
|
|
53
|
+
const { freed, containers = [] } = reclaim({ projectPath, feature, config, env });
|
|
54
|
+
const gone = [...freed, ...containers];
|
|
55
|
+
if (gone.length) log(`heavy: RAM abaixo do piso, derrubei stacks paradas: ${gone.join(', ')}`);
|
|
56
|
+
} catch {}
|
|
57
|
+
};
|
|
39
58
|
|
|
40
59
|
const restoreEnv = () => {
|
|
41
60
|
if (!saved) return;
|
|
@@ -59,6 +78,7 @@ export function createHeavyGate({
|
|
|
59
78
|
const raiseFloor = (kind) => {
|
|
60
79
|
const floor = minFreeMemFor(config, kind);
|
|
61
80
|
if (!lease || floor <= admittedGb) return;
|
|
81
|
+
reclaimBelow(floor);
|
|
62
82
|
const result = awaitFreeMemory({
|
|
63
83
|
minFreeMemGb: floor, waitTimeoutMs: config.waitTimeoutSec * 1000, kind, log, ...deps,
|
|
64
84
|
});
|
|
@@ -85,6 +105,7 @@ export function createHeavyGate({
|
|
|
85
105
|
return {
|
|
86
106
|
config,
|
|
87
107
|
get waitedMs() { return waitedMs; },
|
|
108
|
+
get cpuShare() { return politeRun ? cpuShareOf(config.slots) : null; },
|
|
88
109
|
|
|
89
110
|
enter(node, kind = heavyClass(node)) {
|
|
90
111
|
const suite = isVerifySuite({ label, node });
|
|
@@ -98,6 +119,7 @@ export function createHeavyGate({
|
|
|
98
119
|
|
|
99
120
|
if (config.enabled) {
|
|
100
121
|
const minFreeMemGb = minFreeMemFor(config, kind);
|
|
122
|
+
reclaimBelow(minFreeMemGb);
|
|
101
123
|
const result = acquireHeavy({
|
|
102
124
|
dir: heavyDir(env),
|
|
103
125
|
slots: config.slots,
|
|
@@ -121,8 +143,9 @@ export function createHeavyGate({
|
|
|
121
143
|
waitedMs = result.waitedMs;
|
|
122
144
|
}
|
|
123
145
|
|
|
124
|
-
saved = { [NODE_REUSE_ENV]: env[NODE_REUSE_ENV], [LEASE_ENV]: env[LEASE_ENV] };
|
|
146
|
+
saved = { [NODE_REUSE_ENV]: env[NODE_REUSE_ENV], [LEASE_ENV]: env[LEASE_ENV], [CPU_ENV]: env[CPU_ENV] };
|
|
125
147
|
if (config.disableNodeReuse) env[NODE_REUSE_ENV] = '1';
|
|
148
|
+
if (politeRun) env[CPU_ENV] ??= String(cpuShareOf(config.slots));
|
|
126
149
|
if (lease) env[LEASE_ENV] = String(process.pid);
|
|
127
150
|
entered = true;
|
|
128
151
|
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/plan/critique.js
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'fs';
|
|
2
2
|
import { getAbsoluteOutputPath } from '../../core/paths/output-schema.js';
|
|
3
|
+
import { featureGeneratesMedia } from './media.js';
|
|
3
4
|
|
|
4
5
|
export const CRITIQUE_EXEMPT_WORK_TYPES = ['chore', 'hotfix'];
|
|
5
6
|
|
|
7
|
+
const SMALL_PLAN_MAX_CHECKS = 3;
|
|
8
|
+
const SMALL_PLAN_PROOFS = ['teste', 'leitura'];
|
|
9
|
+
|
|
10
|
+
/** A plan this size has little for a fresh reader to catch; a rung or a proof outside the code does. */
|
|
11
|
+
export function isSmallPlan(checks, planText = '') {
|
|
12
|
+
const scenarios = checks?.checks ?? [];
|
|
13
|
+
return scenarios.length > 0
|
|
14
|
+
&& scenarios.length <= SMALL_PLAN_MAX_CHECKS
|
|
15
|
+
&& scenarios.every((scenario) => SMALL_PLAN_PROOFS.includes(scenario.proof) && !scenario.rung)
|
|
16
|
+
&& !featureGeneratesMedia(planText);
|
|
17
|
+
}
|
|
18
|
+
|
|
6
19
|
const PLAN_HASH_RE = /^\*\*Plan-Hash:\*\*\s*`?([0-9a-f]{64})`?\s*$/m;
|
|
7
20
|
const APPLIED_RE = /^\*\*Aplicado-em:\*\*\s*`?([0-9a-f]{64})`?\s*$/gm;
|
|
8
21
|
|
|
@@ -21,8 +34,15 @@ export function readPlanCritique(cwd, featureName, { workType, planChecks }) {
|
|
|
21
34
|
if (CRITIQUE_EXEMPT_WORK_TYPES.includes(workType)) return 'exempt';
|
|
22
35
|
const currentHash = planChecks?.status === 'fresh' ? planChecks.checks?.planHash : null;
|
|
23
36
|
if (!currentHash) return null;
|
|
37
|
+
if (isSmallPlan(planChecks.checks, readText(getAbsoluteOutputPath(cwd, featureName, 'plan')) ?? '')) return 'exempt';
|
|
24
38
|
const path = getAbsoluteOutputPath(cwd, featureName, 'planCritique');
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
39
|
+
return critiqueStatus(readText(path), currentHash);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readText(path) {
|
|
43
|
+
try {
|
|
44
|
+
return existsSync(path) ? readFileSync(path, 'utf8') : null;
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
28
48
|
}
|
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 = [];
|