mustflow 2.21.2 → 2.22.1

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.
@@ -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
- child.stdout?.on('data', (chunk) => {
96
- stdout.append(chunk);
97
- stdoutBytes += chunk.byteLength;
98
- if (streamOutput) {
99
- writeStreamChunk(reporter, 'stdout', chunk);
96
+ const writeOutputLimitMarkerOnce = () => {
97
+ if (!streamOutput || outputLimitMarkerWritten) {
98
+ return;
100
99
  }
101
- if (enforceOutputLimit && stdoutBytes > maxOutputBytes) {
102
- stopForOutputLimit('stdout');
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
- writeStreamChunk(reporter, 'stderr', chunk);
116
+ if (exceedsLimit) {
117
+ writeStreamChunkPrefix(reporter, stream, chunk, remainingStreamBytes);
118
+ writeOutputLimitMarkerOnce();
119
+ }
120
+ else {
121
+ writeStreamChunk(reporter, stream, chunk);
122
+ }
110
123
  }
111
- if (enforceOutputLimit && stderrBytes > maxOutputBytes) {
112
- stopForOutputLimit('stderr');
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,
@@ -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
- export function readInputFromClassificationReport(projectRoot, inputPath) {
244
- let parsed;
245
- const planPath = resolvePlanPath(projectRoot, inputPath);
243
+ function readJsonInputFile(projectRoot, inputPath, invalidCode) {
244
+ const inputFilePath = resolvePlanPath(projectRoot, inputPath);
245
+ let content;
246
246
  try {
247
- parsed = JSON.parse(readFileSync(planPath, 'utf8'));
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('invalid_plan_file');
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
- let parsed;
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
- let parsed;
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':
@@ -677,19 +677,52 @@ async function runVerificationEntriesSequentially(entries, lang, verificationPla
677
677
  }
678
678
  return results;
679
679
  }
680
- async function runVerificationEntriesInParallelChunks(entries, parallelism, lang, verificationPlanId, scheduledTestTargets) {
680
+ async function runVerificationEntriesInParallelChunks(projectRoot, entries, parallelism, lang, verificationPlanId, scheduledTestTargets) {
681
681
  const results = [];
682
682
  for (let index = 0; index < entries.length; index += parallelism) {
683
683
  const chunk = entries.slice(index, index + parallelism);
684
- const batchDeclaredWritePaths = [
685
- ...new Set(chunk.flatMap((entry) => entry.effects
686
- .filter((effect) => effect.access === 'write' && typeof effect.path === 'string')
687
- .map((effect) => effect.path))),
688
- ].sort((left, right) => left.localeCompare(right));
689
- results.push(...(await Promise.all(chunk.map((entry) => runVerificationIntent(entry.intent, lang, verificationPlanId, scheduledTestTargets.get(entry.intent) ?? [], batchDeclaredWritePaths)))));
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)));
690
692
  }
691
693
  return results;
692
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
+ }
693
726
  function verificationResultFailed(result) {
694
727
  return (!result.skipped &&
695
728
  (result.status === 'failed' ||
@@ -697,7 +730,7 @@ function verificationResultFailed(result) {
697
730
  result.status === 'start_failed' ||
698
731
  result.status === 'output_limit_exceeded'));
699
732
  }
700
- async function runScheduledVerificationIntents(report, lang, verificationPlanId, scheduledTestTargets, parallelism) {
733
+ async function runScheduledVerificationIntents(report, projectRoot, lang, verificationPlanId, scheduledTestTargets, parallelism) {
701
734
  const results = [];
702
735
  for (let batchIndex = 0; batchIndex < report.schedule.batches.length; batchIndex += 1) {
703
736
  const batch = report.schedule.batches[batchIndex];
@@ -709,7 +742,7 @@ async function runScheduledVerificationIntents(report, lang, verificationPlanId,
709
742
  if (entries.length > 1 && entries.every((entry) => entry.parallelEligible)) {
710
743
  batchResults =
711
744
  parallelism > DEFAULT_VERIFY_PARALLELISM
712
- ? await runVerificationEntriesInParallelChunks(entries, parallelism, lang, verificationPlanId, scheduledTestTargets)
745
+ ? await runVerificationEntriesInParallelChunks(projectRoot, entries, parallelism, lang, verificationPlanId, scheduledTestTargets)
713
746
  : await runVerificationEntriesSequentially(entries, lang, verificationPlanId, scheduledTestTargets);
714
747
  }
715
748
  else {
@@ -1001,7 +1034,7 @@ function readVerificationFailureFingerprint(value) {
1001
1034
  }
1002
1035
  function readPreviousVerifyLatestSummary(projectRoot) {
1003
1036
  try {
1004
- const parsed = JSON.parse(readFileSync(path.join(projectRoot, LATEST_RUN_RECEIPT_PATH), 'utf8'));
1037
+ const parsed = JSON.parse(readUtf8FileInsideWithoutSymlinks(projectRoot, path.join(projectRoot, LATEST_RUN_RECEIPT_PATH)));
1005
1038
  if (parsed.command !== 'verify' ||
1006
1039
  parsed.kind !== 'verify_run_summary' ||
1007
1040
  typeof parsed.verification_plan_id !== 'string' ||
@@ -1061,6 +1094,17 @@ function createVerificationPlanId(report, contract) {
1061
1094
  };
1062
1095
  return hashTextSha256(stableJson(fingerprintSource));
1063
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
+ }
1064
1108
  function writeVerifyRunReceipts(projectRoot, output, report, sourceAnchorRisks, scopeDiffRisks, validationRatchetRisks, reproEvidence, externalChecks) {
1065
1109
  const statePaths = createVerifyRunStatePaths(projectRoot);
1066
1110
  const receipts = [];
@@ -1210,7 +1254,7 @@ function writeVerifyRunReceipts(projectRoot, output, report, sourceAnchorRisks,
1210
1254
  writeJsonFileInsideWithoutSymlinks(projectRoot, path.join(projectRoot, LATEST_RUN_RECEIPT_PATH), latest);
1211
1255
  return outputWithReceiptPaths;
1212
1256
  }
1213
- 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) {
1214
1258
  const contract = readCommandContract(projectRoot);
1215
1259
  const report = createChangeVerificationReport(input.classificationReport, contract, projectRoot);
1216
1260
  const verificationPlanId = createVerificationPlanId(report, contract);
@@ -1223,7 +1267,7 @@ async function createVerifyOutput(input, planSource, projectRoot, lang, reproEvi
1223
1267
  const reproEvidenceRisks = createReproEvidenceRisks(reproEvidence, { verificationPlanId });
1224
1268
  const reproEvidenceVerdictEffects = countReproEvidenceVerdictEffects(reproEvidenceRisks);
1225
1269
  const externalEvidenceRisks = createExternalEvidenceRisks(externalChecks);
1226
- const results = await runScheduledVerificationIntents(report, lang, verificationPlanId, scheduledTestTargets, parallelism);
1270
+ const results = await runScheduledVerificationIntents(report, projectRoot, lang, verificationPlanId, scheduledTestTargets, parallelism);
1227
1271
  results.push(...createSkippedResults(report.candidates, scheduledIntents, report.gaps));
1228
1272
  const summary = summarizeResults(results);
1229
1273
  const status = getVerificationStatus(summary);
@@ -1291,6 +1335,7 @@ async function createVerifyOutput(input, planSource, projectRoot, lang, reproEvi
1291
1335
  failure_fingerprint: failureFingerprint,
1292
1336
  repeated_failure_summary: null,
1293
1337
  summary,
1338
+ ...(parallelismReport ? { parallelism: parallelismReport } : {}),
1294
1339
  ...(reproEvidence ? { repro_evidence: reproEvidence } : {}),
1295
1340
  ...(externalChecks.length > 0 ? { external_checks: externalChecks } : {}),
1296
1341
  run_dir: '',
@@ -1346,9 +1391,15 @@ function renderVerifyOutput(output, lang) {
1346
1391
  `passed: ${output.summary.passed}`,
1347
1392
  `failed: ${output.summary.failed}`,
1348
1393
  `skipped: ${output.summary.skipped}`,
1349
- '',
1350
- t(lang, 'verify.label.results'),
1351
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'));
1352
1403
  for (const result of output.results) {
1353
1404
  const intent = result.intent ?? t(lang, 'value.none');
1354
1405
  const reason = result.reason ? ` (${result.reason})` : '';
@@ -1466,7 +1517,9 @@ export async function runVerify(args, reporter, lang = 'en') {
1466
1517
  reporter.stdout(JSON.stringify(await createPlanOnlyOutput(input, projectRoot), null, 2));
1467
1518
  return 0;
1468
1519
  }
1469
- const output = await createVerifyOutput(input, parsed.fromClassification ?? parsed.fromPlan ?? (parsed.changed ? 'changed' : null), projectRoot, lang, reproEvidence, externalChecks, parsed.parallelism ?? DEFAULT_VERIFY_PARALLELISM);
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);
1470
1523
  if (parsed.json) {
1471
1524
  reporter.stdout(JSON.stringify(output, null, 2));
1472
1525
  }
@@ -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",
@@ -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",
@@ -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",
@@ -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 तक साथ चलाएं; default 1 है",
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 इस्तेमाल करना चाहिए",
@@ -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": "안전하고 서로 충돌하지 않는 예정 실행 묶음을 이 개수까지 함께 실행합니다. 기본값은 1입니다",
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를 사용해야 합니다",
@@ -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": "最多并行执行这个数量的安全、无冲突计划批次命令;默认值为 1",
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,4 +1,4 @@
1
- import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
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
4
  import { readFileInsideWithoutSymlinks, writeFileInsideWithoutSymlinks, } from '../../core/safe-filesystem.js';
@@ -9,12 +9,35 @@ export function copyFileInsideWithoutSymlinks(sourceParentPath, sourcePath, targ
9
9
  const content = readFileInsideWithoutSymlinks(sourceParentPath, sourcePath);
10
10
  writeFileInsideWithoutSymlinks(targetParentPath, targetPath, content);
11
11
  }
12
+ function pathExistsWithoutFollowingLeaf(filePath) {
13
+ try {
14
+ lstatSync(filePath);
15
+ return true;
16
+ }
17
+ catch (error) {
18
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
19
+ return false;
20
+ }
21
+ throw error;
22
+ }
23
+ }
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;
32
+ }
33
+ return currentPath;
34
+ }
12
35
  export function copyFileIfMissing(sourcePath, targetPath, relativePath) {
13
- if (existsSync(targetPath)) {
36
+ if (pathExistsWithoutFollowingLeaf(targetPath)) {
14
37
  return { status: 'skipped', relativePath };
15
38
  }
16
- mkdirSync(path.dirname(targetPath), { recursive: true });
17
- copyFileSync(sourcePath, targetPath);
39
+ const content = readFileInsideWithoutSymlinks(path.dirname(sourcePath), sourcePath);
40
+ writeFileInsideWithoutSymlinks(nearestExistingAncestor(targetPath), targetPath, content);
18
41
  return { status: 'created', relativePath };
19
42
  }
20
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 collectFastPreflightIndexedFileRecords(projectRoot, includeSource, sourceConfig) {
344
+ function collectFastPreflightIndexedFileMetadataRecords(projectRoot, includeSource, sourceConfig) {
336
345
  const records = new Map();
337
346
  for (const relativePath of getExistingIndexablePaths(projectRoot)) {
338
- records.set(relativePath, readIndexedFileRecord(projectRoot, relativePath, 'workflow'));
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, readIndexedFileRecord(projectRoot, sourcePath, 'source_anchor'));
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, readIndexedFileRecord(projectRoot, LATEST_RUN_STATE_RELATIVE_PATH, 'state'));
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 (!indexedFilesMatch(database, currentFiles)) {
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 = collectFastPreflightIndexedFileRecords(projectRoot, includeSource, sourceConfig);
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;
@@ -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 getCommandEffects(database, intent) {
2832
- return queryRows(database, 'SELECT intent, source, access, mode, path, lock, concurrency FROM command_effects WHERE intent = ? ORDER BY lock, path, mode', [intent]).map((row) => ({
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 (indexedMatches.active && matchSet.has(key)) || isMatched(fields, query);
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 queryRows(database, 'SELECT path, type, title, content_snippet FROM documents')) {
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 queryRows(database, 'SELECT name, path, title FROM skills')) {
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
- for (const row of queryRows(database, 'SELECT skill_name, skill_path, trigger, required_input, edit_scope, risk, verification_intents, expected_output FROM skill_routes')) {
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
- if (!matchesIndexedOrTableScan(fields, normalizedQuery, indexedMatches, indexedMatches.skillRoutes, routeKey)) {
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: scoreIndexedOrTableScan(primaryFields, secondaryFields, normalizedQuery, indexedMatches, indexedMatches.skillRoutes, routeKey),
3092
+ score: indexedRouteMatch
3093
+ ? Math.max(scoreMatch(primaryFields, secondaryFields, normalizedQuery), 20)
3094
+ : scoreMatch(primaryFields, secondaryFields, normalizedQuery),
3029
3095
  }, cacheLayers));
3030
3096
  }
3031
- for (const row of queryRows(database, 'SELECT name, status, lifecycle, run_policy, description FROM command_intents')) {
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 = getCommandEffects(database, name);
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 queryRows(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')) {
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);
@@ -186,6 +186,26 @@ function truncatePaths(paths) {
186
186
  }
187
187
  return { paths: paths.slice(0, MAX_REPORTED_PATHS), truncated: true };
188
188
  }
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
+ }
189
209
  export function startRunWriteTracking(projectRoot, contract, intentName, options = {}) {
190
210
  const declaredPaths = [
191
211
  ...listDeclaredWritePaths(projectRoot, contract, intentName),
@@ -197,35 +217,101 @@ export function startRunWriteTracking(projectRoot, contract, intentName, options
197
217
  before: captureSnapshot(projectRoot),
198
218
  };
199
219
  }
220
+ export function startRunWriteBatchTracking(projectRoot) {
221
+ return {
222
+ projectRoot,
223
+ before: captureSnapshot(projectRoot),
224
+ };
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
+ }
200
308
  export function finishRunWriteTracking(tracker) {
201
309
  if (tracker.before.status === 'unavailable') {
202
- return {
203
- status: 'unavailable',
204
- declared_paths: tracker.declaredPaths,
205
- observed_paths: [],
206
- declared_observed_paths: [],
207
- undeclared_paths: [],
208
- observed_count: 0,
209
- undeclared_count: 0,
210
- has_undeclared_changes: false,
211
- truncated: false,
212
- reason: tracker.before.reason,
213
- };
310
+ return createUnavailableWriteDriftReceipt(tracker.declaredPaths, tracker.before.reason);
214
311
  }
215
312
  const after = captureSnapshot(tracker.projectRoot);
216
313
  if (after.status === 'unavailable') {
217
- return {
218
- status: 'unavailable',
219
- declared_paths: tracker.declaredPaths,
220
- observed_paths: [],
221
- declared_observed_paths: [],
222
- undeclared_paths: [],
223
- observed_count: 0,
224
- undeclared_count: 0,
225
- has_undeclared_changes: false,
226
- truncated: false,
227
- reason: after.reason,
228
- };
314
+ return createUnavailableWriteDriftReceipt(tracker.declaredPaths, after.reason);
229
315
  }
230
316
  const observedPaths = listObservedChangedPaths(tracker.before.entries, after.entries);
231
317
  const declaredObservedPaths = observedPaths.filter((observedPath) => tracker.declaredPaths.some((declaredPath) => declaredPathCoversObservedPath(declaredPath, observedPath)));
@@ -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 still executes copied commands serially and writes the latest run summary after the batch completes.',
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mustflow",
3
- "version": "2.21.2",
3
+ "version": "2.22.1",
4
4
  "description": "Agent workflow documents and CLI for mustflow repository roots.",
5
5
  "type": "module",
6
6
  "license": "MIT-0",
@@ -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,
@@ -1,6 +1,6 @@
1
1
  id = "default"
2
2
  name = "default"
3
- version = "2.21.2"
3
+ version = "2.22.1"
4
4
  description = "Minimal workflow for LLM agents to read, edit, and verify their work in a repository."
5
5
  common_root = "common"
6
6
  locales_root = "locales"