mustflow 2.21.1 → 2.22.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/dist/cli/commands/run/executor.js +34 -15
- package/dist/cli/commands/run/output.js +24 -0
- package/dist/cli/commands/run.js +3 -1
- package/dist/cli/commands/verify/args.js +34 -0
- package/dist/cli/commands/verify.js +94 -35
- package/dist/cli/i18n/en.js +2 -1
- package/dist/cli/i18n/es.js +2 -1
- package/dist/cli/i18n/fr.js +2 -1
- package/dist/cli/i18n/hi.js +2 -1
- package/dist/cli/i18n/ko.js +2 -1
- package/dist/cli/i18n/zh.js +2 -1
- package/dist/cli/lib/filesystem.js +23 -93
- package/dist/cli/lib/local-index/index.js +88 -20
- package/dist/core/command-effects.js +3 -4
- package/dist/core/run-write-drift.js +117 -27
- package/dist/core/safe-filesystem.js +3 -0
- package/dist/core/verification-scheduler.js +1 -1
- package/package.json +1 -1
- package/schemas/run-receipt.schema.json +15 -1
- package/schemas/verify-report.schema.json +28 -0
- package/templates/default/manifest.toml +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { BoundedOutputBuffer } from '../../../core/bounded-output.js';
|
|
3
3
|
import { createPendingTimeoutTermination, forceTerminateProcessTree, getKillMethod, terminateProcessTree, } from './process-tree.js';
|
|
4
|
-
import { createOutputLimitError, isOutputLimitExceededError, writeStreamChunk } from './output.js';
|
|
4
|
+
import { createOutputLimitError, isOutputLimitExceededError, writeOutputLimitTerminationMarker, writeStreamChunk, writeStreamChunkPrefix, } from './output.js';
|
|
5
5
|
const TERMINATION_CONFIRMATION_FALLBACK_MS = 1000;
|
|
6
6
|
function runSpawnedCommandStreaming(command, cwd, env, timeoutSeconds, killAfterSeconds, maxOutputBytes, stdoutTailBytes, stderrTailBytes, reporter, streamOutput, enforceOutputLimit) {
|
|
7
7
|
return new Promise((resolve) => {
|
|
@@ -17,6 +17,7 @@ function runSpawnedCommandStreaming(command, cwd, env, timeoutSeconds, killAfter
|
|
|
17
17
|
let forceKillTimeout;
|
|
18
18
|
let terminationFallbackTimeout;
|
|
19
19
|
let terminationStarted = false;
|
|
20
|
+
let outputLimitMarkerWritten = false;
|
|
20
21
|
let termination = null;
|
|
21
22
|
const child = spawn(command.executable, command.args ?? [], {
|
|
22
23
|
cwd,
|
|
@@ -92,25 +93,43 @@ function runSpawnedCommandStreaming(command, cwd, env, timeoutSeconds, killAfter
|
|
|
92
93
|
}
|
|
93
94
|
beginTermination();
|
|
94
95
|
};
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
if (streamOutput) {
|
|
99
|
-
writeStreamChunk(reporter, 'stdout', chunk);
|
|
96
|
+
const writeOutputLimitMarkerOnce = () => {
|
|
97
|
+
if (!streamOutput || outputLimitMarkerWritten) {
|
|
98
|
+
return;
|
|
100
99
|
}
|
|
101
|
-
|
|
102
|
-
|
|
100
|
+
outputLimitMarkerWritten = true;
|
|
101
|
+
writeOutputLimitTerminationMarker(reporter);
|
|
102
|
+
};
|
|
103
|
+
const handleOutputChunk = (stream, buffer, chunk) => {
|
|
104
|
+
const previousBytes = stream === 'stdout' ? stdoutBytes : stderrBytes;
|
|
105
|
+
const nextBytes = previousBytes + chunk.byteLength;
|
|
106
|
+
const exceedsLimit = enforceOutputLimit && nextBytes > maxOutputBytes;
|
|
107
|
+
const remainingStreamBytes = enforceOutputLimit ? Math.max(0, maxOutputBytes - previousBytes) : chunk.byteLength;
|
|
108
|
+
buffer.append(chunk);
|
|
109
|
+
if (stream === 'stdout') {
|
|
110
|
+
stdoutBytes = nextBytes;
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
stderrBytes = nextBytes;
|
|
103
114
|
}
|
|
104
|
-
});
|
|
105
|
-
child.stderr?.on('data', (chunk) => {
|
|
106
|
-
stderr.append(chunk);
|
|
107
|
-
stderrBytes += chunk.byteLength;
|
|
108
115
|
if (streamOutput) {
|
|
109
|
-
|
|
116
|
+
if (exceedsLimit) {
|
|
117
|
+
writeStreamChunkPrefix(reporter, stream, chunk, remainingStreamBytes);
|
|
118
|
+
writeOutputLimitMarkerOnce();
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
writeStreamChunk(reporter, stream, chunk);
|
|
122
|
+
}
|
|
110
123
|
}
|
|
111
|
-
if (
|
|
112
|
-
stopForOutputLimit(
|
|
124
|
+
if (exceedsLimit) {
|
|
125
|
+
stopForOutputLimit(stream);
|
|
113
126
|
}
|
|
127
|
+
};
|
|
128
|
+
child.stdout?.on('data', (chunk) => {
|
|
129
|
+
handleOutputChunk('stdout', stdout, chunk);
|
|
130
|
+
});
|
|
131
|
+
child.stderr?.on('data', (chunk) => {
|
|
132
|
+
handleOutputChunk('stderr', stderr, chunk);
|
|
114
133
|
});
|
|
115
134
|
child.once('error', (error) => {
|
|
116
135
|
childError = error;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const OUTPUT_LIMIT_ERROR_CODE = 'ENOBUFS';
|
|
2
2
|
const OUTPUT_LIMIT_ERROR_MESSAGE = /\bmaxBuffer\b.*\bexceeded\b/i;
|
|
3
|
+
const OUTPUT_LIMIT_TERMINATION_MARKER = '[mustflow] output limit exceeded; terminating command before streaming more child output.';
|
|
3
4
|
export function emitOutput(reporter, output, stream) {
|
|
4
5
|
if (!output) {
|
|
5
6
|
return;
|
|
@@ -45,6 +46,29 @@ export function writeStreamChunk(reporter, stream, chunk) {
|
|
|
45
46
|
}
|
|
46
47
|
reporter.stderr(chunk.toString());
|
|
47
48
|
}
|
|
49
|
+
function isUtf8ContinuationByte(value) {
|
|
50
|
+
return value !== undefined && (value & 0xc0) === 0x80;
|
|
51
|
+
}
|
|
52
|
+
function findUtf8PrefixEnd(buffer, maxBytes) {
|
|
53
|
+
let end = Math.min(buffer.byteLength, Math.max(0, Math.trunc(maxBytes)));
|
|
54
|
+
if (end >= buffer.byteLength) {
|
|
55
|
+
return buffer.byteLength;
|
|
56
|
+
}
|
|
57
|
+
while (end > 0 && isUtf8ContinuationByte(buffer[end])) {
|
|
58
|
+
end -= 1;
|
|
59
|
+
}
|
|
60
|
+
return end;
|
|
61
|
+
}
|
|
62
|
+
export function writeStreamChunkPrefix(reporter, stream, chunk, maxBytes) {
|
|
63
|
+
const prefixEnd = findUtf8PrefixEnd(chunk, maxBytes);
|
|
64
|
+
if (prefixEnd <= 0) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
writeStreamChunk(reporter, stream, chunk.subarray(0, prefixEnd));
|
|
68
|
+
}
|
|
69
|
+
export function writeOutputLimitTerminationMarker(reporter) {
|
|
70
|
+
reporter.stderr(OUTPUT_LIMIT_TERMINATION_MARKER);
|
|
71
|
+
}
|
|
48
72
|
export function createOutputLimitError(stream, maxOutputBytes) {
|
|
49
73
|
return Object.assign(new Error(`${stream} exceeded per-stream max_output_bytes (${maxOutputBytes})`), {
|
|
50
74
|
code: OUTPUT_LIMIT_ERROR_CODE,
|
package/dist/cli/commands/run.js
CHANGED
|
@@ -171,7 +171,9 @@ export async function runRun(args, reporter, lang = 'en', options = {}) {
|
|
|
171
171
|
}
|
|
172
172
|
const runReceiptPolicy = profiler.measure('retention_policy', () => resolveRunReceiptRetentionPolicy(readMustflowConfigIfExists(projectRoot)));
|
|
173
173
|
const env = profiler.measure('environment', () => createCommandEnv(projectRoot, { policy: plan.envPolicy, allowlist: plan.envAllowlist }));
|
|
174
|
-
const writeTracker = profiler.measure('write_drift_before', () => startRunWriteTracking(projectRoot, contract, intentName
|
|
174
|
+
const writeTracker = profiler.measure('write_drift_before', () => startRunWriteTracking(projectRoot, contract, intentName, {
|
|
175
|
+
additionalDeclaredPaths: options.additionalDeclaredWritePaths,
|
|
176
|
+
}));
|
|
175
177
|
const stdoutTailBytes = Math.min(runReceiptPolicy.stdoutTailBytes, plan.maxOutputBytes);
|
|
176
178
|
const stderrTailBytes = Math.min(runReceiptPolicy.stderrTailBytes, plan.maxOutputBytes);
|
|
177
179
|
let streamedOutput = false;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { availableParallelism } from 'node:os';
|
|
1
2
|
export const DEFAULT_VERIFY_PARALLELISM = 1;
|
|
3
|
+
export const MAX_VERIFY_PARALLELISM = 8;
|
|
2
4
|
export function parseVerifyArgs(args) {
|
|
3
5
|
let reason;
|
|
4
6
|
let fromClassification;
|
|
@@ -10,6 +12,7 @@ export function parseVerifyArgs(args) {
|
|
|
10
12
|
let planOnly = false;
|
|
11
13
|
let changed = false;
|
|
12
14
|
let parallelism = DEFAULT_VERIFY_PARALLELISM;
|
|
15
|
+
let parallelismSpecified = false;
|
|
13
16
|
for (let index = 0; index < args.length; index += 1) {
|
|
14
17
|
const arg = args[index];
|
|
15
18
|
if (arg === '--json') {
|
|
@@ -34,6 +37,7 @@ export function parseVerifyArgs(args) {
|
|
|
34
37
|
return { json, planOnly, changed, reason, parallelism, error: 'invalid_parallel_value' };
|
|
35
38
|
}
|
|
36
39
|
parallelism = parsedParallelism;
|
|
40
|
+
parallelismSpecified = true;
|
|
37
41
|
index += 1;
|
|
38
42
|
continue;
|
|
39
43
|
}
|
|
@@ -144,6 +148,7 @@ export function parseVerifyArgs(args) {
|
|
|
144
148
|
return { json, planOnly, changed, reason, parallelism, error: 'invalid_parallel_value' };
|
|
145
149
|
}
|
|
146
150
|
parallelism = parsedParallelism;
|
|
151
|
+
parallelismSpecified = true;
|
|
147
152
|
continue;
|
|
148
153
|
}
|
|
149
154
|
if (arg.startsWith('--from-plan=')) {
|
|
@@ -251,6 +256,7 @@ export function parseVerifyArgs(args) {
|
|
|
251
256
|
reproEvidence,
|
|
252
257
|
externalEvidence,
|
|
253
258
|
parallelism,
|
|
259
|
+
parallelismSpecified,
|
|
254
260
|
};
|
|
255
261
|
}
|
|
256
262
|
function parseVerifyParallelism(value) {
|
|
@@ -260,3 +266,31 @@ function parseVerifyParallelism(value) {
|
|
|
260
266
|
const parsed = Number(value);
|
|
261
267
|
return Number.isSafeInteger(parsed) ? parsed : null;
|
|
262
268
|
}
|
|
269
|
+
function readAvailableParallelism() {
|
|
270
|
+
try {
|
|
271
|
+
const value = availableParallelism();
|
|
272
|
+
return Number.isSafeInteger(value) && value > 0 ? value : null;
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
export function resolveVerifyParallelism(requested, cpuAvailable = readAvailableParallelism()) {
|
|
279
|
+
const cpuLimit = cpuAvailable === null ? MAX_VERIFY_PARALLELISM : Math.max(DEFAULT_VERIFY_PARALLELISM, cpuAvailable);
|
|
280
|
+
const effectiveLimit = Math.max(DEFAULT_VERIFY_PARALLELISM, Math.min(MAX_VERIFY_PARALLELISM, cpuLimit));
|
|
281
|
+
const effective = Math.max(DEFAULT_VERIFY_PARALLELISM, Math.min(requested, effectiveLimit));
|
|
282
|
+
const capped = effective !== requested;
|
|
283
|
+
const mode = effective > DEFAULT_VERIFY_PARALLELISM ? 'parallel_chunks' : 'serial';
|
|
284
|
+
const note = mode === 'parallel_chunks'
|
|
285
|
+
? 'Parallel verification is a bounded optimization for eligible non-conflicting entries; it is not stronger evidence than serial verification.'
|
|
286
|
+
: 'Verification runs serially unless an eligible non-conflicting batch receives an effective parallelism greater than 1.';
|
|
287
|
+
return {
|
|
288
|
+
requested,
|
|
289
|
+
effective,
|
|
290
|
+
repositoryMax: MAX_VERIFY_PARALLELISM,
|
|
291
|
+
cpuAvailable,
|
|
292
|
+
capped,
|
|
293
|
+
mode,
|
|
294
|
+
note,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { readFileSync } from 'node:fs';
|
|
3
2
|
import path from 'node:path';
|
|
4
3
|
import { createClassifyOutput } from './classify.js';
|
|
5
4
|
import { runRun } from './run.js';
|
|
6
5
|
import { createChangeVerificationReport, } from '../../core/change-verification.js';
|
|
7
|
-
import { writeJsonFileInsideWithoutSymlinks } from '../../core/safe-filesystem.js';
|
|
6
|
+
import { readUtf8FileInsideWithoutSymlinks, writeJsonFileInsideWithoutSymlinks } from '../../core/safe-filesystem.js';
|
|
8
7
|
import { createVerifyCompletionVerdict, } from '../../core/completion-verdict.js';
|
|
9
8
|
import { createStateRunId } from '../../core/atomic-state-write.js';
|
|
10
9
|
import { createExternalEvidenceRisks, } from '../../core/external-evidence.js';
|
|
@@ -13,8 +12,9 @@ import { countReproEvidenceVerdictEffects, createReproEvidenceRisks, } from '../
|
|
|
13
12
|
import { createVerifyEvidenceModel } from '../../core/verification-evidence.js';
|
|
14
13
|
import { createScopeDiffRisks } from '../../core/scope-risk.js';
|
|
15
14
|
import { countValidationRatchetVerdictEffects, createValidationRatchetRisks, } from '../../core/validation-ratchet.js';
|
|
15
|
+
import { finishRunWriteBatchTracking, startRunWriteBatchTracking, } from '../../core/run-write-drift.js';
|
|
16
16
|
import { readCommandContract } from '../../core/config-loading.js';
|
|
17
|
-
import { DEFAULT_VERIFY_PARALLELISM, parseVerifyArgs } from './verify/args.js';
|
|
17
|
+
import { DEFAULT_VERIFY_PARALLELISM, parseVerifyArgs, resolveVerifyParallelism, } from './verify/args.js';
|
|
18
18
|
import { printUsageError, renderHelp } from '../lib/cli-output.js';
|
|
19
19
|
import { t } from '../lib/i18n.js';
|
|
20
20
|
import { readLocalCommandEffectGraphs, readLocalPathSurfaces, readLocalSourceAnchorVerdictRisks, } from '../lib/local-index.js';
|
|
@@ -240,15 +240,27 @@ function resolvePlanPath(projectRoot, inputPath) {
|
|
|
240
240
|
}
|
|
241
241
|
return resolved;
|
|
242
242
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
243
|
+
function readJsonInputFile(projectRoot, inputPath, invalidCode) {
|
|
244
|
+
const inputFilePath = resolvePlanPath(projectRoot, inputPath);
|
|
245
|
+
let content;
|
|
246
246
|
try {
|
|
247
|
-
|
|
247
|
+
content = readUtf8FileInsideWithoutSymlinks(projectRoot, inputFilePath);
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
if (error instanceof Error && error.message.startsWith('Path must not contain symlinks:')) {
|
|
251
|
+
throw new Error('input_path_contains_symlink');
|
|
252
|
+
}
|
|
253
|
+
throw new Error(invalidCode);
|
|
254
|
+
}
|
|
255
|
+
try {
|
|
256
|
+
return JSON.parse(content);
|
|
248
257
|
}
|
|
249
258
|
catch {
|
|
250
|
-
throw new Error(
|
|
259
|
+
throw new Error(invalidCode);
|
|
251
260
|
}
|
|
261
|
+
}
|
|
262
|
+
export function readInputFromClassificationReport(projectRoot, inputPath) {
|
|
263
|
+
const parsed = readJsonInputFile(projectRoot, inputPath, 'invalid_plan_file');
|
|
252
264
|
const classificationReport = readStrictClassifyPlan(projectRoot, parsed);
|
|
253
265
|
return {
|
|
254
266
|
reasons: classificationReport.summary.validationReasons,
|
|
@@ -469,14 +481,7 @@ function readRegressionGuardEvidence(value) {
|
|
|
469
481
|
};
|
|
470
482
|
}
|
|
471
483
|
function readReproEvidenceFile(projectRoot, inputPath) {
|
|
472
|
-
|
|
473
|
-
const evidencePath = resolvePlanPath(projectRoot, inputPath);
|
|
474
|
-
try {
|
|
475
|
-
parsed = JSON.parse(readFileSync(evidencePath, 'utf8'));
|
|
476
|
-
}
|
|
477
|
-
catch {
|
|
478
|
-
throw new Error('invalid_repro_evidence_file');
|
|
479
|
-
}
|
|
484
|
+
const parsed = readJsonInputFile(projectRoot, inputPath, 'invalid_repro_evidence_file');
|
|
480
485
|
if (!isPlainRecord(parsed) || parsed.schema_version !== '1' || parsed.command !== 'repro-evidence') {
|
|
481
486
|
throw new Error('unsupported_repro_evidence_source');
|
|
482
487
|
}
|
|
@@ -500,14 +505,7 @@ function readReproEvidenceFile(projectRoot, inputPath) {
|
|
|
500
505
|
};
|
|
501
506
|
}
|
|
502
507
|
function readExternalEvidenceFile(projectRoot, inputPath) {
|
|
503
|
-
|
|
504
|
-
const evidencePath = resolvePlanPath(projectRoot, inputPath);
|
|
505
|
-
try {
|
|
506
|
-
parsed = JSON.parse(readFileSync(evidencePath, 'utf8'));
|
|
507
|
-
}
|
|
508
|
-
catch {
|
|
509
|
-
throw new Error('invalid_external_evidence_file');
|
|
510
|
-
}
|
|
508
|
+
const parsed = readJsonInputFile(projectRoot, inputPath, 'invalid_external_evidence_file');
|
|
511
509
|
if (!isPlainRecord(parsed) || parsed.schema_version !== '1' || parsed.command !== 'external-evidence') {
|
|
512
510
|
throw new Error('unsupported_external_evidence_source');
|
|
513
511
|
}
|
|
@@ -552,6 +550,8 @@ export function planErrorMessageKey(code) {
|
|
|
552
550
|
switch (code) {
|
|
553
551
|
case 'plan_path_outside_root':
|
|
554
552
|
return 'verify.error.plan_path_outside_root';
|
|
553
|
+
case 'input_path_contains_symlink':
|
|
554
|
+
return 'verify.error.input_path_contains_symlink';
|
|
555
555
|
case 'missing_plan_reasons':
|
|
556
556
|
return 'verify.error.missing_plan_reasons';
|
|
557
557
|
case 'unsupported_plan_source':
|
|
@@ -628,11 +628,12 @@ function testTargetsByScheduledIntent(report) {
|
|
|
628
628
|
candidate.appliedTestTargets.length > 0)
|
|
629
629
|
.map((candidate) => [candidate.intent, candidate.appliedTestTargets]));
|
|
630
630
|
}
|
|
631
|
-
async function runVerificationIntent(intent, lang, verificationPlanId, testTargets = []) {
|
|
631
|
+
async function runVerificationIntent(intent, lang, verificationPlanId, testTargets = [], additionalDeclaredWritePaths = []) {
|
|
632
632
|
const output = createBufferedOutput();
|
|
633
633
|
const exitCode = await runRun([intent, '--json'], output.reporter, lang, {
|
|
634
634
|
writeLatestReceipt: false,
|
|
635
635
|
testTargets,
|
|
636
|
+
additionalDeclaredWritePaths,
|
|
636
637
|
});
|
|
637
638
|
const rawStdout = output.stdout().trim();
|
|
638
639
|
let receipt = null;
|
|
@@ -676,14 +677,52 @@ async function runVerificationEntriesSequentially(entries, lang, verificationPla
|
|
|
676
677
|
}
|
|
677
678
|
return results;
|
|
678
679
|
}
|
|
679
|
-
async function runVerificationEntriesInParallelChunks(entries, parallelism, lang, verificationPlanId, scheduledTestTargets) {
|
|
680
|
+
async function runVerificationEntriesInParallelChunks(projectRoot, entries, parallelism, lang, verificationPlanId, scheduledTestTargets) {
|
|
680
681
|
const results = [];
|
|
681
682
|
for (let index = 0; index < entries.length; index += parallelism) {
|
|
682
683
|
const chunk = entries.slice(index, index + parallelism);
|
|
683
|
-
|
|
684
|
+
const batchTracker = startRunWriteBatchTracking(projectRoot);
|
|
685
|
+
const chunkResults = await Promise.all(chunk.map((entry) => runVerificationIntent(entry.intent, lang, verificationPlanId, scheduledTestTargets.get(entry.intent) ?? [])));
|
|
686
|
+
const writeDriftByIntent = finishRunWriteBatchTracking(batchTracker, chunk.map((entry) => ({
|
|
687
|
+
intentName: entry.intent,
|
|
688
|
+
declaredPaths: declaredWritePathsForScheduleEntry(entry),
|
|
689
|
+
observedPaths: observedWriteDriftPaths(chunkResults.find((result) => result.intent === entry.intent)),
|
|
690
|
+
})));
|
|
691
|
+
results.push(...chunkResults.map((result) => applyParallelChunkWriteDrift(result, writeDriftByIntent)));
|
|
684
692
|
}
|
|
685
693
|
return results;
|
|
686
694
|
}
|
|
695
|
+
function declaredWritePathsForScheduleEntry(entry) {
|
|
696
|
+
return [
|
|
697
|
+
...new Set(entry.effects
|
|
698
|
+
.filter((effect) => effect.access === 'write' && typeof effect.path === 'string')
|
|
699
|
+
.map((effect) => effect.path)),
|
|
700
|
+
].sort((left, right) => left.localeCompare(right));
|
|
701
|
+
}
|
|
702
|
+
function observedWriteDriftPaths(result) {
|
|
703
|
+
const writeDrift = objectField(result?.receipt?.write_drift);
|
|
704
|
+
const observedPaths = writeDrift?.observed_paths;
|
|
705
|
+
if (!Array.isArray(observedPaths)) {
|
|
706
|
+
return [];
|
|
707
|
+
}
|
|
708
|
+
return observedPaths.filter((value) => typeof value === 'string');
|
|
709
|
+
}
|
|
710
|
+
function applyParallelChunkWriteDrift(result, writeDriftByIntent) {
|
|
711
|
+
if (!result.intent || !result.receipt) {
|
|
712
|
+
return result;
|
|
713
|
+
}
|
|
714
|
+
const writeDrift = writeDriftByIntent.get(result.intent);
|
|
715
|
+
if (!writeDrift) {
|
|
716
|
+
return result;
|
|
717
|
+
}
|
|
718
|
+
return {
|
|
719
|
+
...result,
|
|
720
|
+
receipt: {
|
|
721
|
+
...result.receipt,
|
|
722
|
+
write_drift: writeDrift,
|
|
723
|
+
},
|
|
724
|
+
};
|
|
725
|
+
}
|
|
687
726
|
function verificationResultFailed(result) {
|
|
688
727
|
return (!result.skipped &&
|
|
689
728
|
(result.status === 'failed' ||
|
|
@@ -691,7 +730,7 @@ function verificationResultFailed(result) {
|
|
|
691
730
|
result.status === 'start_failed' ||
|
|
692
731
|
result.status === 'output_limit_exceeded'));
|
|
693
732
|
}
|
|
694
|
-
async function runScheduledVerificationIntents(report, lang, verificationPlanId, scheduledTestTargets, parallelism) {
|
|
733
|
+
async function runScheduledVerificationIntents(report, projectRoot, lang, verificationPlanId, scheduledTestTargets, parallelism) {
|
|
695
734
|
const results = [];
|
|
696
735
|
for (let batchIndex = 0; batchIndex < report.schedule.batches.length; batchIndex += 1) {
|
|
697
736
|
const batch = report.schedule.batches[batchIndex];
|
|
@@ -703,7 +742,7 @@ async function runScheduledVerificationIntents(report, lang, verificationPlanId,
|
|
|
703
742
|
if (entries.length > 1 && entries.every((entry) => entry.parallelEligible)) {
|
|
704
743
|
batchResults =
|
|
705
744
|
parallelism > DEFAULT_VERIFY_PARALLELISM
|
|
706
|
-
? await runVerificationEntriesInParallelChunks(entries, parallelism, lang, verificationPlanId, scheduledTestTargets)
|
|
745
|
+
? await runVerificationEntriesInParallelChunks(projectRoot, entries, parallelism, lang, verificationPlanId, scheduledTestTargets)
|
|
707
746
|
: await runVerificationEntriesSequentially(entries, lang, verificationPlanId, scheduledTestTargets);
|
|
708
747
|
}
|
|
709
748
|
else {
|
|
@@ -995,7 +1034,7 @@ function readVerificationFailureFingerprint(value) {
|
|
|
995
1034
|
}
|
|
996
1035
|
function readPreviousVerifyLatestSummary(projectRoot) {
|
|
997
1036
|
try {
|
|
998
|
-
const parsed = JSON.parse(
|
|
1037
|
+
const parsed = JSON.parse(readUtf8FileInsideWithoutSymlinks(projectRoot, path.join(projectRoot, LATEST_RUN_RECEIPT_PATH)));
|
|
999
1038
|
if (parsed.command !== 'verify' ||
|
|
1000
1039
|
parsed.kind !== 'verify_run_summary' ||
|
|
1001
1040
|
typeof parsed.verification_plan_id !== 'string' ||
|
|
@@ -1055,6 +1094,17 @@ function createVerificationPlanId(report, contract) {
|
|
|
1055
1094
|
};
|
|
1056
1095
|
return hashTextSha256(stableJson(fingerprintSource));
|
|
1057
1096
|
}
|
|
1097
|
+
function toParallelismReport(settings) {
|
|
1098
|
+
return {
|
|
1099
|
+
requested: settings.requested,
|
|
1100
|
+
effective: settings.effective,
|
|
1101
|
+
repository_max: settings.repositoryMax,
|
|
1102
|
+
cpu_available: settings.cpuAvailable,
|
|
1103
|
+
capped: settings.capped,
|
|
1104
|
+
mode: settings.mode,
|
|
1105
|
+
note: settings.note,
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1058
1108
|
function writeVerifyRunReceipts(projectRoot, output, report, sourceAnchorRisks, scopeDiffRisks, validationRatchetRisks, reproEvidence, externalChecks) {
|
|
1059
1109
|
const statePaths = createVerifyRunStatePaths(projectRoot);
|
|
1060
1110
|
const receipts = [];
|
|
@@ -1204,7 +1254,7 @@ function writeVerifyRunReceipts(projectRoot, output, report, sourceAnchorRisks,
|
|
|
1204
1254
|
writeJsonFileInsideWithoutSymlinks(projectRoot, path.join(projectRoot, LATEST_RUN_RECEIPT_PATH), latest);
|
|
1205
1255
|
return outputWithReceiptPaths;
|
|
1206
1256
|
}
|
|
1207
|
-
async function createVerifyOutput(input, planSource, projectRoot, lang, reproEvidence = null, externalChecks = [], parallelism = DEFAULT_VERIFY_PARALLELISM) {
|
|
1257
|
+
async function createVerifyOutput(input, planSource, projectRoot, lang, reproEvidence = null, externalChecks = [], parallelism = DEFAULT_VERIFY_PARALLELISM, parallelismReport = null) {
|
|
1208
1258
|
const contract = readCommandContract(projectRoot);
|
|
1209
1259
|
const report = createChangeVerificationReport(input.classificationReport, contract, projectRoot);
|
|
1210
1260
|
const verificationPlanId = createVerificationPlanId(report, contract);
|
|
@@ -1217,7 +1267,7 @@ async function createVerifyOutput(input, planSource, projectRoot, lang, reproEvi
|
|
|
1217
1267
|
const reproEvidenceRisks = createReproEvidenceRisks(reproEvidence, { verificationPlanId });
|
|
1218
1268
|
const reproEvidenceVerdictEffects = countReproEvidenceVerdictEffects(reproEvidenceRisks);
|
|
1219
1269
|
const externalEvidenceRisks = createExternalEvidenceRisks(externalChecks);
|
|
1220
|
-
const results = await runScheduledVerificationIntents(report, lang, verificationPlanId, scheduledTestTargets, parallelism);
|
|
1270
|
+
const results = await runScheduledVerificationIntents(report, projectRoot, lang, verificationPlanId, scheduledTestTargets, parallelism);
|
|
1221
1271
|
results.push(...createSkippedResults(report.candidates, scheduledIntents, report.gaps));
|
|
1222
1272
|
const summary = summarizeResults(results);
|
|
1223
1273
|
const status = getVerificationStatus(summary);
|
|
@@ -1285,6 +1335,7 @@ async function createVerifyOutput(input, planSource, projectRoot, lang, reproEvi
|
|
|
1285
1335
|
failure_fingerprint: failureFingerprint,
|
|
1286
1336
|
repeated_failure_summary: null,
|
|
1287
1337
|
summary,
|
|
1338
|
+
...(parallelismReport ? { parallelism: parallelismReport } : {}),
|
|
1288
1339
|
...(reproEvidence ? { repro_evidence: reproEvidence } : {}),
|
|
1289
1340
|
...(externalChecks.length > 0 ? { external_checks: externalChecks } : {}),
|
|
1290
1341
|
run_dir: '',
|
|
@@ -1340,9 +1391,15 @@ function renderVerifyOutput(output, lang) {
|
|
|
1340
1391
|
`passed: ${output.summary.passed}`,
|
|
1341
1392
|
`failed: ${output.summary.failed}`,
|
|
1342
1393
|
`skipped: ${output.summary.skipped}`,
|
|
1343
|
-
'',
|
|
1344
|
-
t(lang, 'verify.label.results'),
|
|
1345
1394
|
];
|
|
1395
|
+
if (output.parallelism) {
|
|
1396
|
+
const cpuAvailable = output.parallelism.cpu_available ?? t(lang, 'value.none');
|
|
1397
|
+
lines.push(`parallelism: requested ${output.parallelism.requested}, effective ${output.parallelism.effective}, repository max ${output.parallelism.repository_max}, cpu available ${cpuAvailable}, mode ${output.parallelism.mode}`);
|
|
1398
|
+
if (output.parallelism.capped) {
|
|
1399
|
+
lines.push(`parallelism note: ${output.parallelism.note}`);
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
lines.push('', t(lang, 'verify.label.results'));
|
|
1346
1403
|
for (const result of output.results) {
|
|
1347
1404
|
const intent = result.intent ?? t(lang, 'value.none');
|
|
1348
1405
|
const reason = result.reason ? ` (${result.reason})` : '';
|
|
@@ -1460,7 +1517,9 @@ export async function runVerify(args, reporter, lang = 'en') {
|
|
|
1460
1517
|
reporter.stdout(JSON.stringify(await createPlanOnlyOutput(input, projectRoot), null, 2));
|
|
1461
1518
|
return 0;
|
|
1462
1519
|
}
|
|
1463
|
-
const
|
|
1520
|
+
const parallelismSettings = resolveVerifyParallelism(parsed.parallelism ?? DEFAULT_VERIFY_PARALLELISM);
|
|
1521
|
+
const parallelismReport = parsed.parallelismSpecified ? toParallelismReport(parallelismSettings) : null;
|
|
1522
|
+
const output = await createVerifyOutput(input, parsed.fromClassification ?? parsed.fromPlan ?? (parsed.changed ? 'changed' : null), projectRoot, lang, reproEvidence, externalChecks, parallelismSettings.effective, parallelismReport);
|
|
1464
1523
|
if (parsed.json) {
|
|
1465
1524
|
reporter.stdout(JSON.stringify(output, null, 2));
|
|
1466
1525
|
}
|
package/dist/cli/i18n/en.js
CHANGED
|
@@ -757,7 +757,7 @@ Read these files before working:
|
|
|
757
757
|
"verify.help.option.writePlan": "Compatibility option that writes the changed-file classification report",
|
|
758
758
|
"verify.help.option.reproEvidence": "Read structured bug-fix reproduction evidence from a repository-local JSON summary",
|
|
759
759
|
"verify.help.option.externalEvidence": "Read lower-authority external CI evidence from a repository-local JSON summary",
|
|
760
|
-
"verify.help.option.parallel": "Run safe non-conflicting schedule batches with up to this many commands; default is 1",
|
|
760
|
+
"verify.help.option.parallel": "Run safe non-conflicting schedule batches with up to this many commands, capped by local limits; default is 1",
|
|
761
761
|
"verify.help.option.planOnly": "Print the verification plan without running commands; requires --json",
|
|
762
762
|
"verify.help.exit.ok": "All selected verification intents passed",
|
|
763
763
|
"verify.help.exit.fail": "Verification failed, was partial, was blocked, or input was invalid",
|
|
@@ -778,6 +778,7 @@ Read these files before working:
|
|
|
778
778
|
"verify.error.plan_root_mismatch": "Classification report must come from this mustflow root",
|
|
779
779
|
"verify.error.missing_plan_reasons": "Classification report must include summary.validationReasons",
|
|
780
780
|
"verify.error.plan_path_outside_root": "Classification report path must stay inside the mustflow root",
|
|
781
|
+
"verify.error.input_path_contains_symlink": "Input file path must not contain symlinks",
|
|
781
782
|
"verify.error.changed_files_unavailable": "Unable to inspect changed files with git status",
|
|
782
783
|
"verify.error.invalid_repro_evidence_file": "Repro evidence must be a readable JSON summary with structured evidence fields",
|
|
783
784
|
"verify.error.unsupported_repro_evidence_source": "Repro evidence input must use command repro-evidence",
|
package/dist/cli/i18n/es.js
CHANGED
|
@@ -757,7 +757,7 @@ Lee estos archivos antes de trabajar:
|
|
|
757
757
|
"verify.help.option.writePlan": "Opción de compatibilidad que escribe el informe de clasificación de cambios",
|
|
758
758
|
"verify.help.option.reproEvidence": "Lee evidencia estructurada de reproducción de errores desde un resumen JSON local del repositorio",
|
|
759
759
|
"verify.help.option.externalEvidence": "Lee evidencia de CI externa de menor autoridad desde un resumen JSON local del repositorio",
|
|
760
|
-
"verify.help.option.parallel": "Ejecuta lotes programados seguros y sin conflictos con hasta esta cantidad de comandos; el valor predeterminado es 1",
|
|
760
|
+
"verify.help.option.parallel": "Ejecuta lotes programados seguros y sin conflictos con hasta esta cantidad de comandos, limitada por topes locales; el valor predeterminado es 1",
|
|
761
761
|
"verify.help.option.planOnly": "Imprime el plan de verificación sin ejecutar comandos; requiere --json",
|
|
762
762
|
"verify.help.exit.ok": "Todas las intenciones de verificación seleccionadas pasaron",
|
|
763
763
|
"verify.help.exit.fail": "La verificación falló, fue parcial, quedó bloqueada o la entrada no fue válida",
|
|
@@ -778,6 +778,7 @@ Lee estos archivos antes de trabajar:
|
|
|
778
778
|
"verify.error.plan_root_mismatch": "El informe de clasificación debe provenir de esta raíz mustflow",
|
|
779
779
|
"verify.error.missing_plan_reasons": "El informe de clasificación debe incluir summary.validationReasons",
|
|
780
780
|
"verify.error.plan_path_outside_root": "La ruta del informe de clasificación debe permanecer dentro de la raíz mustflow",
|
|
781
|
+
"verify.error.input_path_contains_symlink": "La ruta del archivo de entrada no debe contener enlaces simbólicos",
|
|
781
782
|
"verify.error.changed_files_unavailable": "No se pudieron inspeccionar los archivos cambiados con git status",
|
|
782
783
|
"verify.error.invalid_repro_evidence_file": "La evidencia de reproducción debe ser un resumen JSON legible con campos de evidencia estructurados",
|
|
783
784
|
"verify.error.unsupported_repro_evidence_source": "La entrada de evidencia de reproducción debe usar command repro-evidence",
|
package/dist/cli/i18n/fr.js
CHANGED
|
@@ -757,7 +757,7 @@ Lisez ces fichiers avant de travailler :
|
|
|
757
757
|
"verify.help.option.writePlan": "Option de compatibilité qui écrit le rapport de classification des changements",
|
|
758
758
|
"verify.help.option.reproEvidence": "Lit une preuve structurée de reproduction de bogue depuis un résumé JSON local au dépôt",
|
|
759
759
|
"verify.help.option.externalEvidence": "Lit une preuve CI externe de moindre autorité depuis un résumé JSON local au dépôt",
|
|
760
|
-
"verify.help.option.parallel": "Exécute les lots planifiés sûrs et sans conflit avec au plus ce nombre de commandes ; valeur par défaut : 1",
|
|
760
|
+
"verify.help.option.parallel": "Exécute les lots planifiés sûrs et sans conflit avec au plus ce nombre de commandes, plafonné par les limites locales ; valeur par défaut : 1",
|
|
761
761
|
"verify.help.option.planOnly": "Affiche le plan de vérification sans exécuter de commandes; nécessite --json",
|
|
762
762
|
"verify.help.exit.ok": "Toutes les intentions de vérification sélectionnées ont réussi",
|
|
763
763
|
"verify.help.exit.fail": "La vérification a échoué, est partielle, est bloquée ou l'entrée est invalide",
|
|
@@ -778,6 +778,7 @@ Lisez ces fichiers avant de travailler :
|
|
|
778
778
|
"verify.error.plan_root_mismatch": "Le rapport de classification doit venir de cette racine mustflow",
|
|
779
779
|
"verify.error.missing_plan_reasons": "Le rapport de classification doit inclure summary.validationReasons",
|
|
780
780
|
"verify.error.plan_path_outside_root": "Le chemin du rapport de classification doit rester dans la racine mustflow",
|
|
781
|
+
"verify.error.input_path_contains_symlink": "Le chemin du fichier d'entrée ne doit pas contenir de liens symboliques",
|
|
781
782
|
"verify.error.changed_files_unavailable": "Impossible d'inspecter les fichiers modifies avec git status",
|
|
782
783
|
"verify.error.invalid_repro_evidence_file": "La preuve de reproduction doit être un résumé JSON lisible avec des champs de preuve structurés",
|
|
783
784
|
"verify.error.unsupported_repro_evidence_source": "L'entrée de preuve de reproduction doit utiliser command repro-evidence",
|
package/dist/cli/i18n/hi.js
CHANGED
|
@@ -757,7 +757,7 @@ export const hiMessages = {
|
|
|
757
757
|
"verify.help.option.writePlan": "Changed-file classification report लिखने वाला compatibility option",
|
|
758
758
|
"verify.help.option.reproEvidence": "Repository-local JSON summary से structured bug reproduction evidence पढ़ें",
|
|
759
759
|
"verify.help.option.externalEvidence": "Repository-local JSON summary से lower-authority external CI evidence पढ़ें",
|
|
760
|
-
"verify.help.option.parallel": "Safe और non-conflicting schedule batches को इतने commands तक साथ
|
|
760
|
+
"verify.help.option.parallel": "Safe और non-conflicting schedule batches को इतने commands तक साथ चलाएं, local limits से capped; default 1 है",
|
|
761
761
|
"verify.help.option.planOnly": "Commands चलाए बिना verification plan print करें; --json चाहिए",
|
|
762
762
|
"verify.help.exit.ok": "सभी selected verification intents pass हुए",
|
|
763
763
|
"verify.help.exit.fail": "Verification fail हुआ, partial रहा, blocked रहा, या input invalid था",
|
|
@@ -778,6 +778,7 @@ export const hiMessages = {
|
|
|
778
778
|
"verify.error.plan_root_mismatch": "Classification report इसी mustflow root से आना चाहिए",
|
|
779
779
|
"verify.error.missing_plan_reasons": "Classification report में summary.validationReasons होना चाहिए",
|
|
780
780
|
"verify.error.plan_path_outside_root": "Classification report path mustflow root के अंदर रहना चाहिए",
|
|
781
|
+
"verify.error.input_path_contains_symlink": "Input file path में symbolic link नहीं होना चाहिए",
|
|
781
782
|
"verify.error.changed_files_unavailable": "git status से बदली फ़ाइलें नहीं पढ़ी जा सकीं",
|
|
782
783
|
"verify.error.invalid_repro_evidence_file": "Repro evidence structured evidence fields वाला readable JSON summary होना चाहिए",
|
|
783
784
|
"verify.error.unsupported_repro_evidence_source": "Repro evidence input को command repro-evidence इस्तेमाल करना चाहिए",
|
package/dist/cli/i18n/ko.js
CHANGED
|
@@ -757,7 +757,7 @@ export const koMessages = {
|
|
|
757
757
|
"verify.help.option.writePlan": "변경 파일 분류 보고서를 쓰는 호환 옵션입니다",
|
|
758
758
|
"verify.help.option.reproEvidence": "저장소 안의 JSON 요약에서 구조화된 버그 재현 증거를 읽습니다",
|
|
759
759
|
"verify.help.option.externalEvidence": "저장소 안의 JSON 요약에서 낮은 권한의 외부 CI 증거를 읽습니다",
|
|
760
|
-
"verify.help.option.parallel": "안전하고 서로 충돌하지 않는 예정 실행 묶음을 이 개수까지 함께
|
|
760
|
+
"verify.help.option.parallel": "안전하고 서로 충돌하지 않는 예정 실행 묶음을 이 개수까지 함께 실행하되, 로컬 상한을 적용합니다. 기본값은 1입니다",
|
|
761
761
|
"verify.help.option.planOnly": "명령을 실행하지 않고 검증 계획만 출력합니다. --json이 필요합니다",
|
|
762
762
|
"verify.help.exit.ok": "선택된 모든 검증 의도가 통과했습니다",
|
|
763
763
|
"verify.help.exit.fail": "검증이 실패했거나, 일부만 실행됐거나, 막혔거나, 입력이 올바르지 않습니다",
|
|
@@ -778,6 +778,7 @@ export const koMessages = {
|
|
|
778
778
|
"verify.error.plan_root_mismatch": "분류 보고서는 현재 mustflow 루트에서 나온 것이어야 합니다",
|
|
779
779
|
"verify.error.missing_plan_reasons": "분류 보고서에는 summary.validationReasons가 있어야 합니다",
|
|
780
780
|
"verify.error.plan_path_outside_root": "분류 보고서 경로는 mustflow 루트 안에 있어야 합니다",
|
|
781
|
+
"verify.error.input_path_contains_symlink": "입력 파일 경로에는 심볼릭 링크가 포함되면 안 됩니다",
|
|
781
782
|
"verify.error.changed_files_unavailable": "git status로 변경 파일을 확인할 수 없습니다",
|
|
782
783
|
"verify.error.invalid_repro_evidence_file": "재현 증거는 구조화된 증거 필드를 포함한 읽을 수 있는 JSON 요약이어야 합니다",
|
|
783
784
|
"verify.error.unsupported_repro_evidence_source": "재현 증거 입력은 command repro-evidence를 사용해야 합니다",
|
package/dist/cli/i18n/zh.js
CHANGED
|
@@ -757,7 +757,7 @@ export const zhMessages = {
|
|
|
757
757
|
"verify.help.option.writePlan": "写入变更文件分类报告的兼容选项",
|
|
758
758
|
"verify.help.option.reproEvidence": "从仓库本地 JSON 摘要读取结构化的 bug 复现证据",
|
|
759
759
|
"verify.help.option.externalEvidence": "从仓库本地 JSON 摘要读取低权限外部 CI 证据",
|
|
760
|
-
"verify.help.option.parallel": "
|
|
760
|
+
"verify.help.option.parallel": "最多并行执行这个数量的安全、无冲突计划批次命令,并受本地上限限制;默认值为 1",
|
|
761
761
|
"verify.help.option.planOnly": "仅输出验证计划,不执行命令;需要 --json",
|
|
762
762
|
"verify.help.exit.ok": "选中的所有验证意图均已通过",
|
|
763
763
|
"verify.help.exit.fail": "验证失败、部分完成、被阻止,或输入无效",
|
|
@@ -778,6 +778,7 @@ export const zhMessages = {
|
|
|
778
778
|
"verify.error.plan_root_mismatch": "分类报告必须来自当前 mustflow 根目录",
|
|
779
779
|
"verify.error.missing_plan_reasons": "分类报告必须包含 summary.validationReasons",
|
|
780
780
|
"verify.error.plan_path_outside_root": "分类报告路径必须位于 mustflow 根目录内",
|
|
781
|
+
"verify.error.input_path_contains_symlink": "输入文件路径不得包含符号链接",
|
|
781
782
|
"verify.error.changed_files_unavailable": "无法通过 git status 检查变更文件",
|
|
782
783
|
"verify.error.invalid_repro_evidence_file": "复现证据必须是包含结构化证据字段的可读取 JSON 摘要",
|
|
783
784
|
"verify.error.unsupported_repro_evidence_source": "复现证据输入必须使用 command repro-evidence",
|
|
@@ -1,113 +1,43 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync, lstatSync, readdirSync, statSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
|
|
3
|
+
export { ensureFileTargetInsideWithoutSymlinks, ensureInside, ensureInsideWithoutSymlinks, readFileInsideWithoutSymlinks, readUtf8FileInsideWithoutSymlinks, writeFileInsideWithoutSymlinks, writeUtf8FileInsideWithoutSymlinks, } from '../../core/safe-filesystem.js';
|
|
4
|
+
import { readFileInsideWithoutSymlinks, writeFileInsideWithoutSymlinks, } from '../../core/safe-filesystem.js';
|
|
4
5
|
export function toPosixPath(value) {
|
|
5
6
|
return value.split(path.sep).join('/');
|
|
6
7
|
}
|
|
7
|
-
export function
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
const relative = path.relative(parent, child);
|
|
11
|
-
if (relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))) {
|
|
12
|
-
return;
|
|
13
|
-
}
|
|
14
|
-
throw new Error(`Path escapes allowed directory: ${childPath}`);
|
|
15
|
-
}
|
|
16
|
-
function isMissingPathError(error) {
|
|
17
|
-
return error instanceof Error && 'code' in error && error.code === 'ENOENT';
|
|
18
|
-
}
|
|
19
|
-
export function ensureInsideWithoutSymlinks(parentPath, childPath, options = {}) {
|
|
20
|
-
ensureInside(parentPath, childPath);
|
|
21
|
-
const parent = path.resolve(parentPath);
|
|
22
|
-
const child = path.resolve(childPath);
|
|
23
|
-
const relative = path.relative(parent, child);
|
|
24
|
-
const segments = relative === '' ? [] : relative.split(path.sep).filter((segment) => segment.length > 0);
|
|
25
|
-
let currentPath = parent;
|
|
26
|
-
const parentStats = lstatSync(parent);
|
|
27
|
-
if (parentStats.isSymbolicLink()) {
|
|
28
|
-
throw new Error(`Path must not contain symlinks: ${childPath}`);
|
|
29
|
-
}
|
|
30
|
-
for (const [index, segment] of segments.entries()) {
|
|
31
|
-
currentPath = path.join(currentPath, segment);
|
|
32
|
-
const isLeaf = index === segments.length - 1;
|
|
33
|
-
try {
|
|
34
|
-
const stats = lstatSync(currentPath);
|
|
35
|
-
if (stats.isSymbolicLink()) {
|
|
36
|
-
throw new Error(`Path must not contain symlinks: ${childPath}`);
|
|
37
|
-
}
|
|
38
|
-
if (!isLeaf && !stats.isDirectory()) {
|
|
39
|
-
throw new Error(`Path component is not a directory: ${currentPath}`);
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
catch (error) {
|
|
43
|
-
if (isMissingPathError(error) && options.allowMissingLeaf) {
|
|
44
|
-
return;
|
|
45
|
-
}
|
|
46
|
-
throw error;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
export function readUtf8FileInsideWithoutSymlinks(parentPath, childPath) {
|
|
51
|
-
return readFileInsideWithoutSymlinks(parentPath, childPath).toString('utf8');
|
|
52
|
-
}
|
|
53
|
-
export function readFileInsideWithoutSymlinks(parentPath, childPath) {
|
|
54
|
-
const absoluteChildPath = path.resolve(childPath);
|
|
55
|
-
ensureInsideWithoutSymlinks(parentPath, absoluteChildPath);
|
|
56
|
-
const fileDescriptor = openSync(absoluteChildPath, constants.O_RDONLY | NOFOLLOW_FLAG);
|
|
57
|
-
try {
|
|
58
|
-
return readFileSync(fileDescriptor);
|
|
59
|
-
}
|
|
60
|
-
finally {
|
|
61
|
-
closeSync(fileDescriptor);
|
|
62
|
-
}
|
|
8
|
+
export function copyFileInsideWithoutSymlinks(sourceParentPath, sourcePath, targetParentPath, targetPath) {
|
|
9
|
+
const content = readFileInsideWithoutSymlinks(sourceParentPath, sourcePath);
|
|
10
|
+
writeFileInsideWithoutSymlinks(targetParentPath, targetPath, content);
|
|
63
11
|
}
|
|
64
|
-
|
|
65
|
-
const absoluteChildPath = path.resolve(childPath);
|
|
66
|
-
ensureInside(parentPath, absoluteChildPath);
|
|
67
|
-
ensureInsideWithoutSymlinks(parentPath, path.dirname(absoluteChildPath), { allowMissingLeaf: true });
|
|
12
|
+
function pathExistsWithoutFollowingLeaf(filePath) {
|
|
68
13
|
try {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
throw new Error(`Path must not contain symlinks: ${childPath}`);
|
|
72
|
-
}
|
|
73
|
-
if (!stats.isFile()) {
|
|
74
|
-
throw new Error(`Path must be a regular file: ${childPath}`);
|
|
75
|
-
}
|
|
14
|
+
lstatSync(filePath);
|
|
15
|
+
return true;
|
|
76
16
|
}
|
|
77
17
|
catch (error) {
|
|
78
|
-
if (
|
|
79
|
-
return;
|
|
18
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
|
19
|
+
return false;
|
|
80
20
|
}
|
|
81
21
|
throw error;
|
|
82
22
|
}
|
|
83
23
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
ensureFileTargetInsideWithoutSymlinks(parentPath, absoluteChildPath, { allowMissingLeaf: true });
|
|
93
|
-
const fileDescriptor = openSync(absoluteChildPath, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | NOFOLLOW_FLAG);
|
|
94
|
-
try {
|
|
95
|
-
writeFileSync(fileDescriptor, content);
|
|
96
|
-
}
|
|
97
|
-
finally {
|
|
98
|
-
closeSync(fileDescriptor);
|
|
24
|
+
function nearestExistingAncestor(filePath) {
|
|
25
|
+
let currentPath = path.resolve(path.dirname(filePath));
|
|
26
|
+
while (!pathExistsWithoutFollowingLeaf(currentPath)) {
|
|
27
|
+
const parentPath = path.dirname(currentPath);
|
|
28
|
+
if (parentPath === currentPath) {
|
|
29
|
+
throw new Error(`No existing parent directory for path: ${filePath}`);
|
|
30
|
+
}
|
|
31
|
+
currentPath = parentPath;
|
|
99
32
|
}
|
|
100
|
-
|
|
101
|
-
export function copyFileInsideWithoutSymlinks(sourceParentPath, sourcePath, targetParentPath, targetPath) {
|
|
102
|
-
const content = readFileInsideWithoutSymlinks(sourceParentPath, sourcePath);
|
|
103
|
-
writeFileInsideWithoutSymlinks(targetParentPath, targetPath, content);
|
|
33
|
+
return currentPath;
|
|
104
34
|
}
|
|
105
35
|
export function copyFileIfMissing(sourcePath, targetPath, relativePath) {
|
|
106
|
-
if (
|
|
36
|
+
if (pathExistsWithoutFollowingLeaf(targetPath)) {
|
|
107
37
|
return { status: 'skipped', relativePath };
|
|
108
38
|
}
|
|
109
|
-
|
|
110
|
-
|
|
39
|
+
const content = readFileInsideWithoutSymlinks(path.dirname(sourcePath), sourcePath);
|
|
40
|
+
writeFileInsideWithoutSymlinks(nearestExistingAncestor(targetPath), targetPath, content);
|
|
111
41
|
return { status: 'created', relativePath };
|
|
112
42
|
}
|
|
113
43
|
export function listFilesRecursive(rootPath, options = {}) {
|
|
@@ -310,6 +310,15 @@ function readIndexedFileMetadataRecord(projectRoot, relativePath, sourceScope) {
|
|
|
310
310
|
mtimeMs: Math.round(stats.mtimeMs),
|
|
311
311
|
};
|
|
312
312
|
}
|
|
313
|
+
function hashIndexedFileMetadataRecord(projectRoot, metadata) {
|
|
314
|
+
return {
|
|
315
|
+
...metadata,
|
|
316
|
+
contentHash: sha256Bytes(readFileSync(path.join(projectRoot, ...metadata.path.split('/')))),
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
function hashIndexedFileMetadataRecords(projectRoot, metadataRecords) {
|
|
320
|
+
return metadataRecords.map((metadata) => hashIndexedFileMetadataRecord(projectRoot, metadata));
|
|
321
|
+
}
|
|
313
322
|
function collectIndexedFileRecords(projectRoot, documents, sourceAnchors, sourceAnchorCandidatePaths = []) {
|
|
314
323
|
const records = new Map();
|
|
315
324
|
for (const document of documents) {
|
|
@@ -332,16 +341,16 @@ function collectSourceAnchorCandidatePaths(projectRoot, sourceConfig) {
|
|
|
332
341
|
excludeGeneratedOrVendor: true,
|
|
333
342
|
});
|
|
334
343
|
}
|
|
335
|
-
function
|
|
344
|
+
function collectFastPreflightIndexedFileMetadataRecords(projectRoot, includeSource, sourceConfig) {
|
|
336
345
|
const records = new Map();
|
|
337
346
|
for (const relativePath of getExistingIndexablePaths(projectRoot)) {
|
|
338
|
-
records.set(relativePath,
|
|
347
|
+
records.set(relativePath, readIndexedFileMetadataRecord(projectRoot, relativePath, 'workflow'));
|
|
339
348
|
}
|
|
340
349
|
if (includeSource) {
|
|
341
350
|
try {
|
|
342
351
|
for (const sourcePath of collectSourceAnchorCandidatePaths(projectRoot, sourceConfig)) {
|
|
343
352
|
if (!records.has(sourcePath)) {
|
|
344
|
-
records.set(sourcePath,
|
|
353
|
+
records.set(sourcePath, readIndexedFileMetadataRecord(projectRoot, sourcePath, 'source_anchor'));
|
|
345
354
|
}
|
|
346
355
|
}
|
|
347
356
|
}
|
|
@@ -350,7 +359,7 @@ function collectFastPreflightIndexedFileRecords(projectRoot, includeSource, sour
|
|
|
350
359
|
}
|
|
351
360
|
}
|
|
352
361
|
if (existsSync(path.join(projectRoot, ...LATEST_RUN_STATE_RELATIVE_PATH.split('/')))) {
|
|
353
|
-
records.set(LATEST_RUN_STATE_RELATIVE_PATH,
|
|
362
|
+
records.set(LATEST_RUN_STATE_RELATIVE_PATH, readIndexedFileMetadataRecord(projectRoot, LATEST_RUN_STATE_RELATIVE_PATH, 'state'));
|
|
354
363
|
}
|
|
355
364
|
return [...records.values()].sort((left, right) => left.path.localeCompare(right.path));
|
|
356
365
|
}
|
|
@@ -2074,6 +2083,27 @@ function indexedFilesMatch(database, currentFiles) {
|
|
|
2074
2083
|
}
|
|
2075
2084
|
return true;
|
|
2076
2085
|
}
|
|
2086
|
+
function indexedFileMetadataMatch(database, currentFiles) {
|
|
2087
|
+
const rows = queryRows(database, 'SELECT path, source_scope, size_bytes, mtime_ms, parser_version FROM indexed_files ORDER BY path');
|
|
2088
|
+
if (rows.length !== currentFiles.length) {
|
|
2089
|
+
return false;
|
|
2090
|
+
}
|
|
2091
|
+
const currentByPath = new Map(currentFiles.map((file) => [file.path, file]));
|
|
2092
|
+
for (const row of rows) {
|
|
2093
|
+
const storedPath = toSearchString(row.path);
|
|
2094
|
+
const current = currentByPath.get(storedPath);
|
|
2095
|
+
if (!current) {
|
|
2096
|
+
return false;
|
|
2097
|
+
}
|
|
2098
|
+
if (normalizeIndexedFileSourceScope(row.source_scope) !== current.sourceScope ||
|
|
2099
|
+
toNullableNumber(row.size_bytes) !== current.sizeBytes ||
|
|
2100
|
+
toNullableNumber(row.mtime_ms) !== current.mtimeMs ||
|
|
2101
|
+
toSearchString(row.parser_version) !== LOCAL_INDEX_PARSER_VERSION) {
|
|
2102
|
+
return false;
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
return true;
|
|
2106
|
+
}
|
|
2077
2107
|
async function readIncrementalPreflightReuse(SQL, databasePath, projectRoot, currentFiles, sourceScopeHash, dryRun, indexMode) {
|
|
2078
2108
|
if (!currentFiles) {
|
|
2079
2109
|
return { result: null, rebuildReason: null };
|
|
@@ -2096,7 +2126,11 @@ async function readIncrementalPreflightReuse(SQL, databasePath, projectRoot, cur
|
|
|
2096
2126
|
if (!hasTable(database, 'indexed_files')) {
|
|
2097
2127
|
return { result: null, rebuildReason: 'indexed_files_missing' };
|
|
2098
2128
|
}
|
|
2099
|
-
if (!
|
|
2129
|
+
if (!indexedFileMetadataMatch(database, currentFiles)) {
|
|
2130
|
+
return { result: null, rebuildReason: 'file_fingerprint_mismatch' };
|
|
2131
|
+
}
|
|
2132
|
+
const hashedCurrentFiles = hashIndexedFileMetadataRecords(projectRoot, currentFiles);
|
|
2133
|
+
if (!indexedFilesMatch(database, hashedCurrentFiles)) {
|
|
2100
2134
|
return { result: null, rebuildReason: 'file_fingerprint_mismatch' };
|
|
2101
2135
|
}
|
|
2102
2136
|
const capabilities = readStoredSearchCapabilities(database);
|
|
@@ -2170,7 +2204,7 @@ export async function createLocalIndex(projectRoot, options = {}) {
|
|
|
2170
2204
|
capabilities = detectLocalSearchCapabilities(capabilityDatabase);
|
|
2171
2205
|
capabilityDatabase.close();
|
|
2172
2206
|
if (incremental) {
|
|
2173
|
-
const preflightFiles =
|
|
2207
|
+
const preflightFiles = collectFastPreflightIndexedFileMetadataRecords(projectRoot, includeSource, sourceConfig);
|
|
2174
2208
|
const preflightReuse = await readIncrementalPreflightReuse(SQL, databasePath, projectRoot, preflightFiles, sourceScopeHash, dryRun, indexMode);
|
|
2175
2209
|
if (preflightReuse.result) {
|
|
2176
2210
|
return preflightReuse.result;
|
|
@@ -2245,7 +2279,7 @@ export async function createLocalIndex(projectRoot, options = {}) {
|
|
|
2245
2279
|
max_snippet_bytes_per_document: MAX_SNIPPET_BYTES_PER_DOCUMENT,
|
|
2246
2280
|
excluded_raw_data_kinds: [...LOCAL_INDEX_EXCLUDED_RAW_DATA_KINDS],
|
|
2247
2281
|
indexed_file_count: indexedFiles.length,
|
|
2248
|
-
indexed_paths:
|
|
2282
|
+
indexed_paths: indexedFiles.map((file) => file.path),
|
|
2249
2283
|
};
|
|
2250
2284
|
}
|
|
2251
2285
|
function readStoredSchemaVersion(database) {
|
|
@@ -2828,8 +2862,8 @@ function getSectionHeadings(database, documentPath) {
|
|
|
2828
2862
|
function getDocumentTerms(database, documentPath) {
|
|
2829
2863
|
return queryRows(database, 'SELECT term FROM document_terms WHERE document_path = ? ORDER BY term', [documentPath]).map((row) => toSearchString(row.term));
|
|
2830
2864
|
}
|
|
2831
|
-
function
|
|
2832
|
-
return
|
|
2865
|
+
function commandEffectFromRow(row) {
|
|
2866
|
+
return {
|
|
2833
2867
|
intent: toSearchString(row.intent),
|
|
2834
2868
|
source: toSearchString(row.source),
|
|
2835
2869
|
access: toSearchString(row.access),
|
|
@@ -2837,7 +2871,35 @@ function getCommandEffects(database, intent) {
|
|
|
2837
2871
|
path: row.path === null || row.path === undefined ? null : toSearchString(row.path),
|
|
2838
2872
|
lock: toSearchString(row.lock),
|
|
2839
2873
|
concurrency: toSearchString(row.concurrency),
|
|
2840
|
-
}
|
|
2874
|
+
};
|
|
2875
|
+
}
|
|
2876
|
+
function sqlPlaceholders(values) {
|
|
2877
|
+
return values.map(() => '?').join(', ');
|
|
2878
|
+
}
|
|
2879
|
+
function queryCandidateRows(database, sql, keyColumn, candidates, indexedMatches) {
|
|
2880
|
+
if (!indexedMatches.active || candidates.size === 0) {
|
|
2881
|
+
return queryRows(database, sql);
|
|
2882
|
+
}
|
|
2883
|
+
const keys = [...candidates].sort((left, right) => left.localeCompare(right));
|
|
2884
|
+
return queryRows(database, `${sql} WHERE ${keyColumn} IN (${sqlPlaceholders(keys)})`, keys);
|
|
2885
|
+
}
|
|
2886
|
+
function getCommandEffectsByIntent(database, intents) {
|
|
2887
|
+
const uniqueIntents = [...new Set(intents)].sort((left, right) => left.localeCompare(right));
|
|
2888
|
+
const effectsByIntent = new Map(uniqueIntents.map((intent) => [intent, []]));
|
|
2889
|
+
if (uniqueIntents.length === 0) {
|
|
2890
|
+
return effectsByIntent;
|
|
2891
|
+
}
|
|
2892
|
+
for (const row of queryRows(database, `SELECT intent, source, access, mode, path, lock, concurrency
|
|
2893
|
+
FROM command_effects
|
|
2894
|
+
WHERE intent IN (${sqlPlaceholders(uniqueIntents)})
|
|
2895
|
+
ORDER BY intent, lock, path, mode`, uniqueIntents)) {
|
|
2896
|
+
const effect = commandEffectFromRow(row);
|
|
2897
|
+
const effects = effectsByIntent.get(effect.intent);
|
|
2898
|
+
if (effects) {
|
|
2899
|
+
effects.push(effect);
|
|
2900
|
+
}
|
|
2901
|
+
}
|
|
2902
|
+
return effectsByIntent;
|
|
2841
2903
|
}
|
|
2842
2904
|
const EMPTY_INDEXED_SEARCH_MATCHES = {
|
|
2843
2905
|
active: false,
|
|
@@ -2921,11 +2983,11 @@ function getIndexedSearchMatches(database, query) {
|
|
|
2921
2983
|
}
|
|
2922
2984
|
}
|
|
2923
2985
|
function matchesIndexedOrTableScan(fields, query, indexedMatches, matchSet, key) {
|
|
2924
|
-
return
|
|
2986
|
+
return indexedMatches.active && matchSet.size > 0 ? matchSet.has(key) : isMatched(fields, query);
|
|
2925
2987
|
}
|
|
2926
2988
|
function scoreIndexedOrTableScan(primaryFields, secondaryFields, query, indexedMatches, matchSet, key) {
|
|
2927
2989
|
const tableScore = scoreMatch(primaryFields, secondaryFields, query);
|
|
2928
|
-
return indexedMatches.active && matchSet.has(key) ? Math.max(tableScore, 20) : tableScore;
|
|
2990
|
+
return indexedMatches.active && matchSet.size > 0 && matchSet.has(key) ? Math.max(tableScore, 20) : tableScore;
|
|
2929
2991
|
}
|
|
2930
2992
|
/**
|
|
2931
2993
|
* mf:anchor cli.search.local-index
|
|
@@ -2958,7 +3020,7 @@ export async function searchLocalIndex(projectRoot, query, options = {}) {
|
|
|
2958
3020
|
throw new Error(`Local mustflow index is stale: ${stalePaths.join(', ')}. Run \`mf index\` before searching. Refresh command: mf index`);
|
|
2959
3021
|
}
|
|
2960
3022
|
if (scope === 'workflow' || scope === 'all') {
|
|
2961
|
-
for (const row of
|
|
3023
|
+
for (const row of queryCandidateRows(database, 'SELECT path, type, title, content_snippet FROM documents', 'path', indexedMatches.documents, indexedMatches)) {
|
|
2962
3024
|
const pathValue = toSearchString(row.path);
|
|
2963
3025
|
const typeValue = toSearchString(row.type);
|
|
2964
3026
|
const title = toSearchString(row.title);
|
|
@@ -2981,7 +3043,7 @@ export async function searchLocalIndex(projectRoot, query, options = {}) {
|
|
|
2981
3043
|
score: scoreIndexedOrTableScan(primaryFields, secondaryFields, normalizedQuery, indexedMatches, indexedMatches.documents, pathValue),
|
|
2982
3044
|
}, cacheLayers));
|
|
2983
3045
|
}
|
|
2984
|
-
for (const row of
|
|
3046
|
+
for (const row of queryCandidateRows(database, 'SELECT name, path, title FROM skills', 'name', indexedMatches.skills, indexedMatches)) {
|
|
2985
3047
|
const name = toSearchString(row.name);
|
|
2986
3048
|
const pathValue = toSearchString(row.path);
|
|
2987
3049
|
const title = toSearchString(row.title);
|
|
@@ -2999,7 +3061,8 @@ export async function searchLocalIndex(projectRoot, query, options = {}) {
|
|
|
2999
3061
|
score: scoreIndexedOrTableScan([name, pathValue, title], [], normalizedQuery, indexedMatches, indexedMatches.skills, name),
|
|
3000
3062
|
}, cacheLayers));
|
|
3001
3063
|
}
|
|
3002
|
-
|
|
3064
|
+
const matchedSkillRouteNames = new Set([...indexedMatches.skillRoutes].map((routeKey) => routeKey.split('\u0000')[0] ?? ''));
|
|
3065
|
+
for (const row of queryCandidateRows(database, 'SELECT skill_name, skill_path, trigger, required_input, edit_scope, risk, verification_intents, expected_output FROM skill_routes', 'skill_name', matchedSkillRouteNames, indexedMatches)) {
|
|
3003
3066
|
const name = toSearchString(row.skill_name);
|
|
3004
3067
|
const pathValue = toSearchString(row.skill_path);
|
|
3005
3068
|
const trigger = toSearchString(row.trigger);
|
|
@@ -3012,7 +3075,8 @@ export async function searchLocalIndex(projectRoot, query, options = {}) {
|
|
|
3012
3075
|
const secondaryFields = [pathValue, requiredInput, editScope, risk, expectedOutput];
|
|
3013
3076
|
const fields = [...primaryFields, ...secondaryFields];
|
|
3014
3077
|
const routeKey = skillRouteKey({ skillName: name, trigger });
|
|
3015
|
-
|
|
3078
|
+
const indexedRouteMatch = indexedMatches.active && indexedMatches.skillRoutes.has(routeKey);
|
|
3079
|
+
if (!indexedRouteMatch && !isMatched(fields, normalizedQuery)) {
|
|
3016
3080
|
continue;
|
|
3017
3081
|
}
|
|
3018
3082
|
results.push(withCacheHint({
|
|
@@ -3025,16 +3089,20 @@ export async function searchLocalIndex(projectRoot, query, options = {}) {
|
|
|
3025
3089
|
verification_intents: verificationIntents,
|
|
3026
3090
|
...skillAuthority(),
|
|
3027
3091
|
match: getMatchSnippet(fields, normalizedQuery),
|
|
3028
|
-
score:
|
|
3092
|
+
score: indexedRouteMatch
|
|
3093
|
+
? Math.max(scoreMatch(primaryFields, secondaryFields, normalizedQuery), 20)
|
|
3094
|
+
: scoreMatch(primaryFields, secondaryFields, normalizedQuery),
|
|
3029
3095
|
}, cacheLayers));
|
|
3030
3096
|
}
|
|
3031
|
-
|
|
3097
|
+
const commandRows = queryCandidateRows(database, 'SELECT name, status, lifecycle, run_policy, description FROM command_intents', 'name', indexedMatches.commandIntents, indexedMatches);
|
|
3098
|
+
const effectsByIntent = getCommandEffectsByIntent(database, commandRows.map((row) => toSearchString(row.name)));
|
|
3099
|
+
for (const row of commandRows) {
|
|
3032
3100
|
const name = toSearchString(row.name);
|
|
3033
3101
|
const status = toSearchString(row.status);
|
|
3034
3102
|
const lifecycle = toSearchString(row.lifecycle);
|
|
3035
3103
|
const runPolicy = toSearchString(row.run_policy);
|
|
3036
3104
|
const description = toSearchString(row.description);
|
|
3037
|
-
const effects =
|
|
3105
|
+
const effects = effectsByIntent.get(name) ?? [];
|
|
3038
3106
|
const effectLocks = [...new Set(effects.map((effect) => effect.lock))].sort((left, right) => left.localeCompare(right));
|
|
3039
3107
|
const effectPaths = [
|
|
3040
3108
|
...new Set(effects.map((effect) => effect.path).filter((effectPath) => effectPath !== null)),
|
|
@@ -3060,7 +3128,7 @@ export async function searchLocalIndex(projectRoot, query, options = {}) {
|
|
|
3060
3128
|
}
|
|
3061
3129
|
}
|
|
3062
3130
|
if (scope === 'source' || scope === 'all') {
|
|
3063
|
-
for (const row of
|
|
3131
|
+
for (const row of queryCandidateRows(database, 'SELECT source_anchors.id, path, line_start, purpose, search_terms, invariant, risk, source_anchors.navigation_only, source_anchors.can_instruct_agent, status, confidence FROM source_anchors LEFT JOIN source_anchor_status ON source_anchor_status.anchor_id = source_anchors.id', 'source_anchors.id', indexedMatches.sourceAnchors, indexedMatches)) {
|
|
3064
3132
|
const id = toSearchString(row.id);
|
|
3065
3133
|
const pathValue = toSearchString(row.path);
|
|
3066
3134
|
const purpose = toSearchString(row.purpose);
|
|
@@ -17,12 +17,11 @@ function validateEffectPath(projectRoot, intent, rawPath) {
|
|
|
17
17
|
const cwd = resolveSafeProjectCwd(projectRoot, readString(intent, 'cwd'));
|
|
18
18
|
const resolved = path.resolve(cwd, rawPath);
|
|
19
19
|
const root = path.resolve(projectRoot);
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
if (resolvedLower !== rootLower && !resolvedLower.startsWith(`${rootLower}${path.sep}`)) {
|
|
20
|
+
const relative = path.relative(root, resolved);
|
|
21
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
23
22
|
throw new Error(`Command effect path must stay inside the current root: ${rawPath}`);
|
|
24
23
|
}
|
|
25
|
-
return normalizeRelativePath(
|
|
24
|
+
return normalizeRelativePath(relative);
|
|
26
25
|
}
|
|
27
26
|
function readResourcePaths(commandContract, lock) {
|
|
28
27
|
const resource = commandContract.resources[lock];
|
|
@@ -11,7 +11,7 @@ const GIT_STATUS_UNTRACKED_MODE = 'normal';
|
|
|
11
11
|
const MAX_HASH_BYTES = 5 * 1024 * 1024;
|
|
12
12
|
const RECURSIVE_SNAPSHOT_ENV = 'MUSTFLOW_WRITE_DRIFT_SNAPSHOT';
|
|
13
13
|
const EXCLUDED_DIRECTORY_NAMES = new Set(['.git', 'node_modules']);
|
|
14
|
-
const EXCLUDED_RELATIVE_DIRECTORY_PATHS = new Set(['.mustflow/state/runs']);
|
|
14
|
+
const EXCLUDED_RELATIVE_DIRECTORY_PATHS = new Set(['.mustflow/state/perf', '.mustflow/state/runs']);
|
|
15
15
|
function isRecursiveSnapshotEnabled() {
|
|
16
16
|
const value = process.env[RECURSIVE_SNAPSHOT_ENV];
|
|
17
17
|
return value === '1' || value?.toLowerCase() === 'true';
|
|
@@ -186,42 +186,132 @@ function truncatePaths(paths) {
|
|
|
186
186
|
}
|
|
187
187
|
return { paths: paths.slice(0, MAX_REPORTED_PATHS), truncated: true };
|
|
188
188
|
}
|
|
189
|
-
|
|
189
|
+
function uniqueSortedPaths(paths) {
|
|
190
|
+
return [...new Set([...paths].map(normalizeRelativePath))].sort((left, right) => left.localeCompare(right));
|
|
191
|
+
}
|
|
192
|
+
function pathsCoverObservedPath(declaredPaths, observedPath) {
|
|
193
|
+
return declaredPaths.some((declaredPath) => declaredPathCoversObservedPath(declaredPath, observedPath));
|
|
194
|
+
}
|
|
195
|
+
function createUnavailableWriteDriftReceipt(declaredPaths, reason) {
|
|
196
|
+
return {
|
|
197
|
+
status: 'unavailable',
|
|
198
|
+
declared_paths: declaredPaths,
|
|
199
|
+
observed_paths: [],
|
|
200
|
+
declared_observed_paths: [],
|
|
201
|
+
undeclared_paths: [],
|
|
202
|
+
observed_count: 0,
|
|
203
|
+
undeclared_count: 0,
|
|
204
|
+
has_undeclared_changes: false,
|
|
205
|
+
truncated: false,
|
|
206
|
+
reason,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
export function startRunWriteTracking(projectRoot, contract, intentName, options = {}) {
|
|
210
|
+
const declaredPaths = [
|
|
211
|
+
...listDeclaredWritePaths(projectRoot, contract, intentName),
|
|
212
|
+
...(options.additionalDeclaredPaths ?? []).map(normalizeRelativePath),
|
|
213
|
+
];
|
|
214
|
+
return {
|
|
215
|
+
projectRoot,
|
|
216
|
+
declaredPaths: [...new Set(declaredPaths)].sort((left, right) => left.localeCompare(right)),
|
|
217
|
+
before: captureSnapshot(projectRoot),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
export function startRunWriteBatchTracking(projectRoot) {
|
|
190
221
|
return {
|
|
191
222
|
projectRoot,
|
|
192
|
-
declaredPaths: listDeclaredWritePaths(projectRoot, contract, intentName),
|
|
193
223
|
before: captureSnapshot(projectRoot),
|
|
194
224
|
};
|
|
195
225
|
}
|
|
226
|
+
export function finishRunWriteBatchTracking(tracker, intents) {
|
|
227
|
+
const chunkIntents = intents.map((intent) => intent.intentName).sort((left, right) => left.localeCompare(right));
|
|
228
|
+
const fallbackReceipts = new Map();
|
|
229
|
+
for (const intent of intents) {
|
|
230
|
+
fallbackReceipts.set(intent.intentName, createUnavailableWriteDriftReceipt(uniqueSortedPaths(intent.declaredPaths), tracker.before.reason));
|
|
231
|
+
}
|
|
232
|
+
if (tracker.before.status === 'unavailable') {
|
|
233
|
+
return fallbackReceipts;
|
|
234
|
+
}
|
|
235
|
+
const after = captureSnapshot(tracker.projectRoot);
|
|
236
|
+
if (after.status === 'unavailable') {
|
|
237
|
+
return new Map(intents.map((intent) => [
|
|
238
|
+
intent.intentName,
|
|
239
|
+
createUnavailableWriteDriftReceipt(uniqueSortedPaths(intent.declaredPaths), after.reason),
|
|
240
|
+
]));
|
|
241
|
+
}
|
|
242
|
+
const observedPaths = listObservedChangedPaths(tracker.before.entries, after.entries);
|
|
243
|
+
const declaredObservedByIntent = new Map();
|
|
244
|
+
const undeclaredByIntent = new Map();
|
|
245
|
+
const ambiguousByIntent = new Map();
|
|
246
|
+
for (const intent of intents) {
|
|
247
|
+
declaredObservedByIntent.set(intent.intentName, []);
|
|
248
|
+
undeclaredByIntent.set(intent.intentName, []);
|
|
249
|
+
ambiguousByIntent.set(intent.intentName, []);
|
|
250
|
+
}
|
|
251
|
+
for (const observedPath of observedPaths) {
|
|
252
|
+
const declaredOwners = intents.filter((intent) => pathsCoverObservedPath(intent.declaredPaths, observedPath));
|
|
253
|
+
if (declaredOwners.length === 1) {
|
|
254
|
+
declaredObservedByIntent.get(declaredOwners[0]?.intentName ?? '')?.push(observedPath);
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (declaredOwners.length > 1) {
|
|
258
|
+
for (const owner of declaredOwners) {
|
|
259
|
+
ambiguousByIntent.get(owner.intentName)?.push(observedPath);
|
|
260
|
+
}
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
const observedWitnesses = intents.filter((intent) => intent.observedPaths.some((intentObservedPath) => pathKey(intentObservedPath) === pathKey(observedPath)));
|
|
264
|
+
if (observedWitnesses.length === 1) {
|
|
265
|
+
undeclaredByIntent.get(observedWitnesses[0]?.intentName ?? '')?.push(observedPath);
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
const ambiguousTargets = observedWitnesses.length > 0 ? observedWitnesses : intents;
|
|
269
|
+
for (const intent of ambiguousTargets) {
|
|
270
|
+
ambiguousByIntent.get(intent.intentName)?.push(observedPath);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const status = tracker.before.status === 'partial' || after.status === 'partial' ? 'partial' : 'checked';
|
|
274
|
+
const reason = status === 'partial'
|
|
275
|
+
? tracker.before.reason ?? after.reason ?? 'partial_snapshot'
|
|
276
|
+
: null;
|
|
277
|
+
const receipts = new Map();
|
|
278
|
+
for (const intent of intents) {
|
|
279
|
+
const declaredPaths = uniqueSortedPaths(intent.declaredPaths);
|
|
280
|
+
const declaredObservedPaths = uniqueSortedPaths(declaredObservedByIntent.get(intent.intentName) ?? []);
|
|
281
|
+
const undeclaredPaths = uniqueSortedPaths(undeclaredByIntent.get(intent.intentName) ?? []);
|
|
282
|
+
const ambiguousPaths = uniqueSortedPaths(ambiguousByIntent.get(intent.intentName) ?? []);
|
|
283
|
+
const intentObservedPaths = uniqueSortedPaths([...declaredObservedPaths, ...undeclaredPaths, ...ambiguousPaths]);
|
|
284
|
+
const observed = truncatePaths(intentObservedPaths);
|
|
285
|
+
const declaredObserved = truncatePaths(declaredObservedPaths);
|
|
286
|
+
const undeclared = truncatePaths(undeclaredPaths);
|
|
287
|
+
const ambiguous = truncatePaths(ambiguousPaths);
|
|
288
|
+
receipts.set(intent.intentName, {
|
|
289
|
+
status,
|
|
290
|
+
declared_paths: declaredPaths,
|
|
291
|
+
observed_paths: observed.paths,
|
|
292
|
+
declared_observed_paths: declaredObserved.paths,
|
|
293
|
+
undeclared_paths: undeclared.paths,
|
|
294
|
+
observed_count: intentObservedPaths.length,
|
|
295
|
+
undeclared_count: undeclaredPaths.length,
|
|
296
|
+
has_undeclared_changes: undeclaredPaths.length > 0 || ambiguousPaths.length > 0,
|
|
297
|
+
truncated: observed.truncated || declaredObserved.truncated || undeclared.truncated || ambiguous.truncated,
|
|
298
|
+
reason,
|
|
299
|
+
attribution_mode: 'parallel_chunk',
|
|
300
|
+
chunk_intents: chunkIntents,
|
|
301
|
+
attributed_paths: uniqueSortedPaths([...declaredObservedPaths, ...undeclaredPaths]),
|
|
302
|
+
ambiguous_paths: ambiguous.paths,
|
|
303
|
+
ambiguous_count: ambiguousPaths.length,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
return receipts;
|
|
307
|
+
}
|
|
196
308
|
export function finishRunWriteTracking(tracker) {
|
|
197
309
|
if (tracker.before.status === 'unavailable') {
|
|
198
|
-
return
|
|
199
|
-
status: 'unavailable',
|
|
200
|
-
declared_paths: tracker.declaredPaths,
|
|
201
|
-
observed_paths: [],
|
|
202
|
-
declared_observed_paths: [],
|
|
203
|
-
undeclared_paths: [],
|
|
204
|
-
observed_count: 0,
|
|
205
|
-
undeclared_count: 0,
|
|
206
|
-
has_undeclared_changes: false,
|
|
207
|
-
truncated: false,
|
|
208
|
-
reason: tracker.before.reason,
|
|
209
|
-
};
|
|
310
|
+
return createUnavailableWriteDriftReceipt(tracker.declaredPaths, tracker.before.reason);
|
|
210
311
|
}
|
|
211
312
|
const after = captureSnapshot(tracker.projectRoot);
|
|
212
313
|
if (after.status === 'unavailable') {
|
|
213
|
-
return
|
|
214
|
-
status: 'unavailable',
|
|
215
|
-
declared_paths: tracker.declaredPaths,
|
|
216
|
-
observed_paths: [],
|
|
217
|
-
declared_observed_paths: [],
|
|
218
|
-
undeclared_paths: [],
|
|
219
|
-
observed_count: 0,
|
|
220
|
-
undeclared_count: 0,
|
|
221
|
-
has_undeclared_changes: false,
|
|
222
|
-
truncated: false,
|
|
223
|
-
reason: after.reason,
|
|
224
|
-
};
|
|
314
|
+
return createUnavailableWriteDriftReceipt(tracker.declaredPaths, after.reason);
|
|
225
315
|
}
|
|
226
316
|
const observedPaths = listObservedChangedPaths(tracker.before.entries, after.entries);
|
|
227
317
|
const declaredObservedPaths = observedPaths.filter((observedPath) => tracker.declaredPaths.some((declaredPath) => declaredPathCoversObservedPath(declaredPath, observedPath)));
|
|
@@ -114,6 +114,9 @@ export function readFileInsideWithoutSymlinks(parentPath, childPath) {
|
|
|
114
114
|
closeSync(fileDescriptor);
|
|
115
115
|
}
|
|
116
116
|
}
|
|
117
|
+
export function readUtf8FileInsideWithoutSymlinks(parentPath, childPath) {
|
|
118
|
+
return readFileInsideWithoutSymlinks(parentPath, childPath).toString('utf8');
|
|
119
|
+
}
|
|
117
120
|
export function writeFileInsideWithoutSymlinks(parentPath, childPath, content) {
|
|
118
121
|
const absoluteChildPath = path.resolve(childPath);
|
|
119
122
|
const directoryPath = path.dirname(absoluteChildPath);
|
|
@@ -180,7 +180,7 @@ export function createVerificationSchedule(projectRoot, commandContract, candida
|
|
|
180
180
|
'Only entries backed by explicit effects are marked parallel eligible; writes fallback remains serial-only.',
|
|
181
181
|
...uniqueSorted(latestUndeclaredWriteIntents).map((intent) => `Latest receipt for ${intent} reported undeclared writes; it is not parallel eligible.`),
|
|
182
182
|
'If a future parallel batch has already started, let it finish and stop before the next batch on failure.',
|
|
183
|
-
'mf verify
|
|
183
|
+
'The runner names the default copied-command path; mf verify --parallel may execute eligible entries in bounded chunks and writes the latest run summary after each batch completes.',
|
|
184
184
|
],
|
|
185
185
|
};
|
|
186
186
|
}
|
package/package.json
CHANGED
|
@@ -281,7 +281,21 @@
|
|
|
281
281
|
"undeclared_count": { "type": "integer" },
|
|
282
282
|
"has_undeclared_changes": { "type": "boolean" },
|
|
283
283
|
"truncated": { "type": "boolean" },
|
|
284
|
-
"reason": { "type": ["string", "null"] }
|
|
284
|
+
"reason": { "type": ["string", "null"] },
|
|
285
|
+
"attribution_mode": { "const": "parallel_chunk" },
|
|
286
|
+
"chunk_intents": {
|
|
287
|
+
"type": "array",
|
|
288
|
+
"items": { "type": "string" }
|
|
289
|
+
},
|
|
290
|
+
"attributed_paths": {
|
|
291
|
+
"type": "array",
|
|
292
|
+
"items": { "type": "string" }
|
|
293
|
+
},
|
|
294
|
+
"ambiguous_paths": {
|
|
295
|
+
"type": "array",
|
|
296
|
+
"items": { "type": "string" }
|
|
297
|
+
},
|
|
298
|
+
"ambiguous_count": { "type": "integer", "minimum": 0 }
|
|
285
299
|
}
|
|
286
300
|
}
|
|
287
301
|
}
|
|
@@ -64,6 +64,7 @@
|
|
|
64
64
|
"skipped": { "type": "integer" }
|
|
65
65
|
}
|
|
66
66
|
},
|
|
67
|
+
"parallelism": { "$ref": "#/$defs/parallelism" },
|
|
67
68
|
"repro_evidence": { "$ref": "#/$defs/reproEvidence" },
|
|
68
69
|
"external_checks": {
|
|
69
70
|
"type": "array",
|
|
@@ -113,6 +114,33 @@
|
|
|
113
114
|
}
|
|
114
115
|
},
|
|
115
116
|
"$defs": {
|
|
117
|
+
"parallelism": {
|
|
118
|
+
"type": "object",
|
|
119
|
+
"additionalProperties": false,
|
|
120
|
+
"required": [
|
|
121
|
+
"requested",
|
|
122
|
+
"effective",
|
|
123
|
+
"repository_max",
|
|
124
|
+
"cpu_available",
|
|
125
|
+
"capped",
|
|
126
|
+
"mode",
|
|
127
|
+
"note"
|
|
128
|
+
],
|
|
129
|
+
"properties": {
|
|
130
|
+
"requested": { "type": "integer", "minimum": 1 },
|
|
131
|
+
"effective": { "type": "integer", "minimum": 1 },
|
|
132
|
+
"repository_max": { "type": "integer", "minimum": 1 },
|
|
133
|
+
"cpu_available": {
|
|
134
|
+
"anyOf": [
|
|
135
|
+
{ "type": "integer", "minimum": 1 },
|
|
136
|
+
{ "type": "null" }
|
|
137
|
+
]
|
|
138
|
+
},
|
|
139
|
+
"capped": { "type": "boolean" },
|
|
140
|
+
"mode": { "enum": ["serial", "parallel_chunks"] },
|
|
141
|
+
"note": { "type": "string" }
|
|
142
|
+
}
|
|
143
|
+
},
|
|
116
144
|
"failureFingerprint": {
|
|
117
145
|
"type": "object",
|
|
118
146
|
"additionalProperties": false,
|