projscan 4.13.0 → 4.15.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.
Files changed (195) hide show
  1. package/README.md +90 -24
  2. package/dist/cli/commands/evidencePack.js +2 -0
  3. package/dist/cli/commands/evidencePack.js.map +1 -1
  4. package/dist/cli/commands/prove.js +253 -23
  5. package/dist/cli/commands/prove.js.map +1 -1
  6. package/dist/cli/commands/startConsole.d.ts +2 -2
  7. package/dist/cli/commands/startConsole.js +2 -260
  8. package/dist/cli/commands/startConsole.js.map +1 -1
  9. package/dist/cli/commands/startConsoleExecution.d.ts +5 -0
  10. package/dist/cli/commands/startConsoleExecution.js +108 -0
  11. package/dist/cli/commands/startConsoleExecution.js.map +1 -0
  12. package/dist/cli/commands/startConsoleMission.d.ts +6 -0
  13. package/dist/cli/commands/startConsoleMission.js +157 -0
  14. package/dist/cli/commands/startConsoleMission.js.map +1 -0
  15. package/dist/cli/commands/startMissionBundle.js +24 -27
  16. package/dist/cli/commands/startMissionBundle.js.map +1 -1
  17. package/dist/core/adoption.d.ts +8 -81
  18. package/dist/core/adoption.js +4 -549
  19. package/dist/core/adoption.js.map +1 -1
  20. package/dist/core/adoptionFirstRunDiagnostics.d.ts +20 -0
  21. package/dist/core/adoptionFirstRunDiagnostics.js +240 -0
  22. package/dist/core/adoptionFirstRunDiagnostics.js.map +1 -0
  23. package/dist/core/adoptionMcpConfig.d.ts +27 -0
  24. package/dist/core/adoptionMcpConfig.js +123 -0
  25. package/dist/core/adoptionMcpConfig.js.map +1 -0
  26. package/dist/core/adoptionMcpDoctor.d.ts +23 -0
  27. package/dist/core/adoptionMcpDoctor.js +87 -0
  28. package/dist/core/adoptionMcpDoctor.js.map +1 -0
  29. package/dist/core/adoptionWorkflowRecipes.d.ts +14 -0
  30. package/dist/core/adoptionWorkflowRecipes.js +110 -0
  31. package/dist/core/adoptionWorkflowRecipes.js.map +1 -0
  32. package/dist/core/bugHunt.js +26 -255
  33. package/dist/core/bugHunt.js.map +1 -1
  34. package/dist/core/bugHuntPreflightFindings.d.ts +2 -1
  35. package/dist/core/bugHuntPreflightFindings.js +20 -0
  36. package/dist/core/bugHuntPreflightFindings.js.map +1 -1
  37. package/dist/core/bugHuntReportAssembly.d.ts +20 -0
  38. package/dist/core/bugHuntReportAssembly.js +179 -0
  39. package/dist/core/bugHuntReportAssembly.js.map +1 -0
  40. package/dist/core/bugHuntSourceFindings.d.ts +3 -0
  41. package/dist/core/bugHuntSourceFindings.js +61 -0
  42. package/dist/core/bugHuntSourceFindings.js.map +1 -0
  43. package/dist/core/dogfood.js +4 -393
  44. package/dist/core/dogfood.js.map +1 -1
  45. package/dist/core/dogfoodMarketValidation.d.ts +5 -0
  46. package/dist/core/dogfoodMarketValidation.js +265 -0
  47. package/dist/core/dogfoodMarketValidation.js.map +1 -0
  48. package/dist/core/dogfoodRepoEvaluation.d.ts +4 -0
  49. package/dist/core/dogfoodRepoEvaluation.js +137 -0
  50. package/dist/core/dogfoodRepoEvaluation.js.map +1 -0
  51. package/dist/core/evidenceComment.js +50 -13
  52. package/dist/core/evidenceComment.js.map +1 -1
  53. package/dist/core/feedback.js +2 -252
  54. package/dist/core/feedback.js.map +1 -1
  55. package/dist/core/feedbackIntakeClassifier.d.ts +2 -0
  56. package/dist/core/feedbackIntakeClassifier.js +255 -0
  57. package/dist/core/feedbackIntakeClassifier.js.map +1 -0
  58. package/dist/core/intentRouterCatalog.js +34 -0
  59. package/dist/core/intentRouterCatalog.js.map +1 -1
  60. package/dist/core/intentRouterKeywordToolGuards.js +2 -46
  61. package/dist/core/intentRouterKeywordToolGuards.js.map +1 -1
  62. package/dist/core/intentRouterKeywordWeights.js +13 -28
  63. package/dist/core/intentRouterKeywordWeights.js.map +1 -1
  64. package/dist/core/intentRouterProductGuardSignals.d.ts +3 -0
  65. package/dist/core/intentRouterProductGuardSignals.js +59 -0
  66. package/dist/core/intentRouterProductGuardSignals.js.map +1 -0
  67. package/dist/core/intentRouterWorkflowKeywordWeights.js +29 -0
  68. package/dist/core/intentRouterWorkflowKeywordWeights.js.map +1 -1
  69. package/dist/core/markdownSafety.d.ts +3 -0
  70. package/dist/core/markdownSafety.js +14 -0
  71. package/dist/core/markdownSafety.js.map +1 -0
  72. package/dist/core/preflight.d.ts +2 -0
  73. package/dist/core/preflight.js.map +1 -1
  74. package/dist/core/preflightChangedFiles.d.ts +2 -0
  75. package/dist/core/preflightChangedFiles.js +1 -1
  76. package/dist/core/preflightChangedFiles.js.map +1 -1
  77. package/dist/core/preflightInputs.d.ts +2 -0
  78. package/dist/core/preflightInputs.js +5 -2
  79. package/dist/core/preflightInputs.js.map +1 -1
  80. package/dist/core/proofLedger.d.ts +6 -1
  81. package/dist/core/proofLedger.js +174 -15
  82. package/dist/core/proofLedger.js.map +1 -1
  83. package/dist/core/proofReplay.d.ts +9 -0
  84. package/dist/core/proofReplay.js +164 -0
  85. package/dist/core/proofReplay.js.map +1 -0
  86. package/dist/core/proofSufficiency.d.ts +19 -0
  87. package/dist/core/proofSufficiency.js +425 -0
  88. package/dist/core/proofSufficiency.js.map +1 -0
  89. package/dist/core/prove.d.ts +8 -0
  90. package/dist/core/prove.js +578 -88
  91. package/dist/core/prove.js.map +1 -1
  92. package/dist/core/qualityScorecard.js +8 -238
  93. package/dist/core/qualityScorecard.js.map +1 -1
  94. package/dist/core/qualityScorecardDimensions.d.ts +14 -0
  95. package/dist/core/qualityScorecardDimensions.js +99 -0
  96. package/dist/core/qualityScorecardDimensions.js.map +1 -0
  97. package/dist/core/qualityScorecardRisks.d.ts +8 -0
  98. package/dist/core/qualityScorecardRisks.js +107 -0
  99. package/dist/core/qualityScorecardRisks.js.map +1 -0
  100. package/dist/core/qualityScorecardSignals.d.ts +20 -0
  101. package/dist/core/qualityScorecardSignals.js +59 -0
  102. package/dist/core/qualityScorecardSignals.js.map +1 -0
  103. package/dist/core/releaseEvidence.d.ts +1 -0
  104. package/dist/core/releaseEvidence.js +15 -40
  105. package/dist/core/releaseEvidence.js.map +1 -1
  106. package/dist/core/releaseEvidenceBaseline.js +4 -1
  107. package/dist/core/releaseEvidenceBaseline.js.map +1 -1
  108. package/dist/core/releaseEvidenceProofReceipt.d.ts +6 -0
  109. package/dist/core/releaseEvidenceProofReceipt.js +140 -0
  110. package/dist/core/releaseEvidenceProofReceipt.js.map +1 -0
  111. package/dist/core/releaseEvidenceVerdict.d.ts +5 -2
  112. package/dist/core/releaseEvidenceVerdict.js +39 -1
  113. package/dist/core/releaseEvidenceVerdict.js.map +1 -1
  114. package/dist/core/repositoryScanner.d.ts +1 -0
  115. package/dist/core/repositoryScanner.js +5 -4
  116. package/dist/core/repositoryScanner.js.map +1 -1
  117. package/dist/core/sessionResources.d.ts +14 -2
  118. package/dist/core/sessionResources.js +3 -3
  119. package/dist/core/sessionResources.js.map +1 -1
  120. package/dist/core/startFixedRouteCriteria.js +4 -0
  121. package/dist/core/startFixedRouteCriteria.js.map +1 -1
  122. package/dist/core/startInputs.d.ts +1 -1
  123. package/dist/core/startIntentTargets.d.ts +1 -1
  124. package/dist/core/startIntentTargets.js +1 -16
  125. package/dist/core/startIntentTargets.js.map +1 -1
  126. package/dist/core/startMissionInputStatusPolicy.d.ts +7 -0
  127. package/dist/core/startMissionInputStatusPolicy.js +74 -0
  128. package/dist/core/startMissionInputStatusPolicy.js.map +1 -0
  129. package/dist/core/startMissionPolicy.d.ts +6 -15
  130. package/dist/core/startMissionPolicy.js +4 -305
  131. package/dist/core/startMissionPolicy.js.map +1 -1
  132. package/dist/core/startMissionProofPolicy.d.ts +6 -0
  133. package/dist/core/startMissionProofPolicy.js +84 -0
  134. package/dist/core/startMissionProofPolicy.js.map +1 -0
  135. package/dist/core/startMissionRiskPolicy.d.ts +4 -0
  136. package/dist/core/startMissionRiskPolicy.js +85 -0
  137. package/dist/core/startMissionRiskPolicy.js.map +1 -0
  138. package/dist/core/startMissionRoutingPolicy.d.ts +6 -0
  139. package/dist/core/startMissionRoutingPolicy.js +67 -0
  140. package/dist/core/startMissionRoutingPolicy.js.map +1 -0
  141. package/dist/core/startMode.d.ts +1 -2
  142. package/dist/core/startMode.js +4 -151
  143. package/dist/core/startMode.js.map +1 -1
  144. package/dist/core/startModeIntentPolicy.d.ts +12 -0
  145. package/dist/core/startModeIntentPolicy.js +41 -0
  146. package/dist/core/startModeIntentPolicy.js.map +1 -0
  147. package/dist/core/startModeRoutingPolicy.d.ts +4 -0
  148. package/dist/core/startModeRoutingPolicy.js +117 -0
  149. package/dist/core/startModeRoutingPolicy.js.map +1 -0
  150. package/dist/core/startRouteActions.js +5 -0
  151. package/dist/core/startRouteActions.js.map +1 -1
  152. package/dist/core/startSearchQueryTargets.d.ts +1 -0
  153. package/dist/core/startSearchQueryTargets.js +17 -0
  154. package/dist/core/startSearchQueryTargets.js.map +1 -0
  155. package/dist/core/workplan.d.ts +3 -2
  156. package/dist/core/workplan.js +11 -585
  157. package/dist/core/workplan.js.map +1 -1
  158. package/dist/core/workplanCoordinationTasks.d.ts +3 -0
  159. package/dist/core/workplanCoordinationTasks.js +82 -0
  160. package/dist/core/workplanCoordinationTasks.js.map +1 -0
  161. package/dist/core/workplanModeTasks.d.ts +2 -0
  162. package/dist/core/workplanModeTasks.js +192 -0
  163. package/dist/core/workplanModeTasks.js.map +1 -0
  164. package/dist/core/workplanPreflightTasks.d.ts +2 -0
  165. package/dist/core/workplanPreflightTasks.js +126 -0
  166. package/dist/core/workplanPreflightTasks.js.map +1 -0
  167. package/dist/core/workplanQualitySignals.d.ts +7 -0
  168. package/dist/core/workplanQualitySignals.js +63 -0
  169. package/dist/core/workplanQualitySignals.js.map +1 -0
  170. package/dist/core/workplanReport.d.ts +4 -0
  171. package/dist/core/workplanReport.js +79 -0
  172. package/dist/core/workplanReport.js.map +1 -0
  173. package/dist/core/workplanRiskOwnership.d.ts +5 -0
  174. package/dist/core/workplanRiskOwnership.js +97 -0
  175. package/dist/core/workplanRiskOwnership.js.map +1 -0
  176. package/dist/core/workplanSuggestedActions.d.ts +2 -0
  177. package/dist/core/workplanSuggestedActions.js +43 -0
  178. package/dist/core/workplanSuggestedActions.js.map +1 -0
  179. package/dist/mcp/tools/prove.js +24 -18
  180. package/dist/mcp/tools/prove.js.map +1 -1
  181. package/dist/projscan-sbom.cdx.json +6 -6
  182. package/dist/tool-manifest.json +3 -3
  183. package/dist/types/config.d.ts +15 -0
  184. package/dist/types/evidencePack.d.ts +21 -0
  185. package/dist/types/proofLedger.d.ts +1 -1
  186. package/dist/types/prove.d.ts +96 -1
  187. package/dist/utils/changedFiles.js +57 -16
  188. package/dist/utils/changedFiles.js.map +1 -1
  189. package/dist/utils/config.js +2 -0
  190. package/dist/utils/config.js.map +1 -1
  191. package/dist/utils/configProofRecipes.d.ts +2 -0
  192. package/dist/utils/configProofRecipes.js +91 -0
  193. package/dist/utils/configProofRecipes.js.map +1 -0
  194. package/docs/GUIDE.md +145 -25
  195. package/package.json +1 -1
@@ -1,11 +1,20 @@
1
+ import crypto from 'node:crypto';
2
+ import { spawn } from 'node:child_process';
1
3
  import fs from 'node:fs/promises';
2
4
  import path from 'node:path';
3
5
  import { readFeedbackFile } from './feedback.js';
4
- import { appendProofLedgerRecord, changedFileFingerprint, latestProofRecordFor, readProofLedger, } from './proofLedger.js';
6
+ import { appendProofLedgerRecord, changedFileFingerprint, normalizeProofCommand, prepareProofArtifactReadPath, prepareProofArtifactWritePath, readLatestProofLedgerRecords, redactProofOutput, } from './proofLedger.js';
7
+ import { buildProofRequirements, isConfigPath, isDocumentationPath, isGeneratedPath, isProductionPath, isSecuritySensitivePath, isTestPath, proofRelevantChangedFiles, proofSufficiencyFor, } from './proofSufficiency.js';
8
+ import { buildProofReplay } from './proofReplay.js';
5
9
  import { quoteShellArg } from './startShellArgs.js';
6
10
  import { computeSimulation } from './simulate.js';
7
11
  import { getChangedFiles } from '../utils/changedFiles.js';
8
12
  const DEFAULT_CONTRACT_PATH = '.projscan/proof-contract.json';
13
+ const DEFAULT_RUN_TIMEOUT_MS = 10 * 60 * 1000;
14
+ const PROOF_RUN_TIMEOUT_EXIT_CODE = 124;
15
+ const COMMAND_NOT_FOUND_EXIT_CODE = 127;
16
+ const MAX_PROOF_RUN_OUTPUT_CHARS = 256 * 1024;
17
+ const MAX_PROOF_RUN_LOG_CHARS = 512 * 1024;
9
18
  const GENERATED_FORBIDDEN_PATTERNS = [
10
19
  '.agentflight/**',
11
20
  '.agentloop/**',
@@ -23,6 +32,7 @@ const HIGH_RISK_FORBIDDEN_FILES = [
23
32
  'package-lock.json',
24
33
  'package.json',
25
34
  ];
35
+ const PATH_MATCH_REGEX_CACHE = new Map();
26
36
  const CHANGED_FILE_RULES = [
27
37
  {
28
38
  kind: 'generated',
@@ -76,25 +86,49 @@ const CHANGED_FILE_RULES = [
76
86
  },
77
87
  ];
78
88
  const NEGATIVE_PROOF_OUTCOMES = new Set(['rejected', 'reverted', 'suppressed', 'noisy']);
79
- const CONFIG_BASENAMES = new Set([
80
- 'package.json',
81
- 'package-lock.json',
82
- 'pnpm-lock.yaml',
83
- 'yarn.lock',
84
- 'tsconfig.json',
85
- ]);
86
- const CONFIG_SUFFIXES = ['.config.js', '.config.cjs', '.config.mjs', '.config.ts'];
87
89
  export async function computeProve(rootPath, options = {}) {
88
- const modeCount = [Boolean(options.intent?.trim()), Boolean(options.changed), Boolean(options.recordCommand?.trim())].filter(Boolean).length;
90
+ const modeCount = [
91
+ Boolean(options.intent?.trim()),
92
+ Boolean(options.changed),
93
+ Boolean(options.recordCommand?.trim()),
94
+ options.runCommand !== undefined,
95
+ ].filter(Boolean).length;
89
96
  if (modeCount > 1) {
90
- throw new Error('prove accepts only one of --intent, --changed, or --record-command');
97
+ throw new Error('prove accepts only one of --intent, --changed, --record-command, or --run');
91
98
  }
99
+ if (options.runCommand !== undefined)
100
+ return computeRunProof(rootPath, options);
92
101
  if (options.recordCommand?.trim())
93
102
  return computeRecordProof(rootPath, options);
94
103
  if (options.changed)
95
104
  return computeChangedProof(rootPath, options);
96
105
  return computeIntentProof(rootPath, options);
97
106
  }
107
+ async function computeRunProof(rootPath, options) {
108
+ const run = await executeProofCommand(rootPath, options.runCommand ?? [], options.runTimeoutMs);
109
+ const changedFiles = await getChangedFiles(rootPath, options.baseRef);
110
+ const record = await appendProofLedgerRecord(rootPath, options.ledgerPath, {
111
+ command: run.command,
112
+ exitCode: run.exitCode,
113
+ durationMs: run.durationMs,
114
+ changedFiles: proofRelevantChangedFiles(changedFiles.files),
115
+ outputSummary: run.outputSummary,
116
+ logPath: run.logPath,
117
+ source: 'prove-run',
118
+ });
119
+ const verdict = record.status === 'passed' ? 'ready' : 'blocked';
120
+ const verifiedWorkflow = verifiedWorkflowForRecord(verdict, record.status);
121
+ return {
122
+ schemaVersion: 1,
123
+ mode: 'run',
124
+ verdict,
125
+ summary: `${verdict}: executed ${record.status} proof for ${record.command}`,
126
+ commands: [record.command],
127
+ warnings: changedFiles.available ? [] : [changedFiles.reason ?? 'Changed-file evidence is unavailable.'],
128
+ verifiedWorkflow,
129
+ ledgerRecord: record,
130
+ };
131
+ }
98
132
  async function computeRecordProof(rootPath, options) {
99
133
  const proof = recordProofInput(options);
100
134
  const changedFiles = await getChangedFiles(rootPath, options.baseRef);
@@ -105,9 +139,10 @@ async function computeRecordProof(rootPath, options) {
105
139
  changedFiles: proofRelevantChangedFiles(changedFiles.files),
106
140
  outputSummary: proof.summary,
107
141
  logPath: proof.logPath,
108
- source: 'prove-record',
142
+ source: options.recordSource ?? 'prove-record',
109
143
  });
110
144
  const verdict = record.status === 'passed' ? 'ready' : 'blocked';
145
+ const verifiedWorkflow = verifiedWorkflowForRecord(verdict, record.status);
111
146
  return {
112
147
  schemaVersion: 1,
113
148
  mode: 'record',
@@ -115,6 +150,7 @@ async function computeRecordProof(rootPath, options) {
115
150
  summary: `${verdict}: recorded ${record.status} proof for ${record.command}`,
116
151
  commands: [record.command],
117
152
  warnings: changedFiles.available ? [] : [changedFiles.reason ?? 'Changed-file evidence is unavailable.'],
153
+ verifiedWorkflow,
118
154
  ledgerRecord: record,
119
155
  };
120
156
  }
@@ -136,6 +172,167 @@ function recordProofInput(options) {
136
172
  logPath: options.logPath,
137
173
  };
138
174
  }
175
+ async function executeProofCommand(rootPath, command, timeoutMs) {
176
+ const commandVector = normalizeRunCommand(command);
177
+ const displayCommand = redactProofOutput(commandVector.map(quoteShellArg).join(' '));
178
+ const startedAtMs = Date.now();
179
+ const effectiveTimeoutMs = resolveRunTimeoutMs(timeoutMs);
180
+ const result = await spawnProofCommand(rootPath, commandVector, effectiveTimeoutMs);
181
+ const durationMs = Date.now() - startedAtMs;
182
+ const outputSummary = proofRunOutputSummary(result, effectiveTimeoutMs);
183
+ const logPath = await writeProofRunLog(rootPath, {
184
+ command: displayCommand,
185
+ exitCode: result.exitCode,
186
+ durationMs,
187
+ stdout: result.stdout,
188
+ stderr: result.stderr,
189
+ errorMessage: result.errorMessage,
190
+ timedOut: result.timedOut,
191
+ truncated: result.truncated,
192
+ });
193
+ return {
194
+ command: displayCommand,
195
+ exitCode: result.exitCode,
196
+ durationMs,
197
+ outputSummary,
198
+ logPath,
199
+ };
200
+ }
201
+ function normalizeRunCommand(command) {
202
+ const normalized = command.map((part) => String(part));
203
+ if (normalized.length === 0 || normalized[0]?.trim().length === 0) {
204
+ throw new Error('prove --run requires a command after --, for example: projscan prove --run -- npm test');
205
+ }
206
+ return normalized;
207
+ }
208
+ function resolveRunTimeoutMs(value) {
209
+ if (value === undefined)
210
+ return DEFAULT_RUN_TIMEOUT_MS;
211
+ if (!Number.isFinite(value) || value <= 0) {
212
+ throw new Error('prove --run-timeout-ms requires a positive number');
213
+ }
214
+ return Math.round(value);
215
+ }
216
+ function spawnProofCommand(rootPath, command, timeoutMs) {
217
+ return new Promise((resolve) => {
218
+ const [executable, ...args] = command;
219
+ let stdout = '';
220
+ let stderr = '';
221
+ let truncated = false;
222
+ let timedOut = false;
223
+ let finished = false;
224
+ let killTimer;
225
+ const timeout = setTimeout(() => {
226
+ timedOut = true;
227
+ child.kill('SIGTERM');
228
+ killTimer = setTimeout(() => child.kill('SIGKILL'), 1_000);
229
+ killTimer.unref();
230
+ }, timeoutMs);
231
+ timeout.unref();
232
+ const child = spawn(executable, args, {
233
+ cwd: rootPath,
234
+ env: process.env,
235
+ shell: false,
236
+ stdio: ['ignore', 'pipe', 'pipe'],
237
+ });
238
+ const finish = (exitCode, errorMessage) => {
239
+ if (finished)
240
+ return;
241
+ finished = true;
242
+ clearTimeout(timeout);
243
+ if (killTimer)
244
+ clearTimeout(killTimer);
245
+ resolve({
246
+ exitCode: timedOut ? PROOF_RUN_TIMEOUT_EXIT_CODE : exitCode,
247
+ stdout,
248
+ stderr,
249
+ ...(errorMessage ? { errorMessage } : {}),
250
+ timedOut,
251
+ truncated,
252
+ });
253
+ };
254
+ child.stdout?.on('data', (chunk) => {
255
+ const next = appendBoundedOutput(stdout, chunk);
256
+ stdout = next.value;
257
+ truncated ||= next.truncated;
258
+ });
259
+ child.stderr?.on('data', (chunk) => {
260
+ const next = appendBoundedOutput(stderr, chunk);
261
+ stderr = next.value;
262
+ truncated ||= next.truncated;
263
+ });
264
+ child.on('error', (error) => {
265
+ finish(COMMAND_NOT_FOUND_EXIT_CODE, error instanceof Error ? error.message : String(error));
266
+ });
267
+ child.on('close', (code, signal) => {
268
+ if (code === null) {
269
+ finish(signal ? 1 : 0);
270
+ return;
271
+ }
272
+ finish(code);
273
+ });
274
+ });
275
+ }
276
+ function appendBoundedOutput(current, chunk) {
277
+ const text = chunk.toString('utf-8');
278
+ const remaining = MAX_PROOF_RUN_OUTPUT_CHARS - current.length;
279
+ if (remaining <= 0)
280
+ return { value: current, truncated: text.length > 0 };
281
+ if (text.length > remaining) {
282
+ return { value: current + text.slice(0, remaining), truncated: true };
283
+ }
284
+ return { value: current + text, truncated: false };
285
+ }
286
+ function proofRunOutputSummary(result, timeoutMs) {
287
+ const parts = [
288
+ result.timedOut ? `timed out after ${timeoutMs}ms` : undefined,
289
+ result.errorMessage ? `start error: ${result.errorMessage}` : undefined,
290
+ result.stdout.trim() ? `stdout: ${result.stdout.trim()}` : undefined,
291
+ result.stderr.trim() ? `stderr: ${result.stderr.trim()}` : undefined,
292
+ result.truncated ? 'output truncated' : undefined,
293
+ ].filter((part) => Boolean(part));
294
+ return parts.join(' | ');
295
+ }
296
+ async function writeProofRunLog(rootPath, input) {
297
+ const relativePath = proofRunLogPath(input.command);
298
+ const fullPath = path.resolve(rootPath, relativePath);
299
+ const root = path.resolve(rootPath);
300
+ const relativeToRoot = path.relative(root, fullPath);
301
+ if (!relativeToRoot || relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot)) {
302
+ throw new Error('Proof log path must stay inside the project root.');
303
+ }
304
+ await prepareProofArtifactWritePath(rootPath, fullPath);
305
+ await fs.writeFile(fullPath, redactedProofRunLog(input), 'utf-8');
306
+ return relativePath;
307
+ }
308
+ function proofRunLogPath(command) {
309
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
310
+ const digest = crypto.createHash('sha256').update(command).digest('hex').slice(0, 10);
311
+ return `.projscan/proof-logs/prove-run-${stamp}-${digest}.log`;
312
+ }
313
+ function redactedProofRunLog(input) {
314
+ const raw = [
315
+ `command: ${input.command}`,
316
+ `exitCode: ${input.exitCode}`,
317
+ `durationMs: ${input.durationMs}`,
318
+ `timedOut: ${input.timedOut ? 'yes' : 'no'}`,
319
+ `truncated: ${input.truncated ? 'yes' : 'no'}`,
320
+ input.errorMessage ? `error: ${input.errorMessage}` : undefined,
321
+ '--- stdout ---',
322
+ input.stdout || '(empty)',
323
+ '--- stderr ---',
324
+ input.stderr || '(empty)',
325
+ ]
326
+ .filter((line) => typeof line === 'string')
327
+ .join('\n');
328
+ const redacted = redactProofOutput(raw);
329
+ return `${truncateText(redacted, MAX_PROOF_RUN_LOG_CHARS)}\n`;
330
+ }
331
+ function truncateText(value, maxLength) {
332
+ if (value.length <= maxLength)
333
+ return value;
334
+ return `${value.slice(0, maxLength - 24)}\n[projscan log truncated]\n`;
335
+ }
139
336
  function isNonNegativeFiniteNumber(value) {
140
337
  return typeof value === 'number' && Number.isFinite(value) && value >= 0;
141
338
  }
@@ -150,7 +347,12 @@ async function computeIntentProof(rootPath, options) {
150
347
  }),
151
348
  readTrustMemory(options.feedbackPath),
152
349
  ]);
153
- const contract = buildContract({ intent, simulation, trustMemory });
350
+ const contract = buildContract({
351
+ intent,
352
+ simulation,
353
+ trustMemory,
354
+ proofRecipes: options.proofRecipes,
355
+ });
154
356
  let savedContractPath;
155
357
  if (options.saveContractPath) {
156
358
  savedContractPath = await writeContract(rootPath, options.saveContractPath, contract);
@@ -163,16 +365,22 @@ async function computeIntentProof(rootPath, options) {
163
365
  contract,
164
366
  commands: contract.proofCommands,
165
367
  warnings: simulation.warnings,
368
+ verifiedWorkflow: contract.verifiedWorkflow,
166
369
  ...(savedContractPath ? { savedContractPath } : {}),
167
370
  };
168
371
  }
169
372
  async function computeChangedProof(rootPath, options) {
170
- const [contract, changedFiles, ledger] = await Promise.all([
373
+ const [contract, changedFiles] = await Promise.all([
171
374
  resolveContract(rootPath, options),
172
- getChangedFiles(rootPath, options.baseRef),
173
- readProofLedger(rootPath, options.ledgerPath),
375
+ options.changedFiles ? Promise.resolve(options.changedFiles) : getChangedFiles(rootPath, options.baseRef),
174
376
  ]);
175
377
  const quickPreflight = quickProofPreflight(changedFiles);
378
+ const relevantChangedFiles = proofRelevantChangedFiles(changedFiles.files);
379
+ const proofCommands = proofCommandsForReceipt(contract.contract);
380
+ const [currentChangedFileFingerprint, ledger] = await Promise.all([
381
+ changedFileFingerprint(rootPath, relevantChangedFiles),
382
+ readLatestProofLedgerRecords(rootPath, options.ledgerPath, proofCommands),
383
+ ]);
176
384
  const receipt = buildReceipt({
177
385
  contract: contract.contract,
178
386
  contractPath: contract.path,
@@ -183,6 +391,8 @@ async function computeChangedProof(rootPath, options) {
183
391
  newRisks: quickPreflight.risks,
184
392
  preflightVerdict: quickPreflight.verdict,
185
393
  ledger,
394
+ proofCommands,
395
+ currentChangedFileFingerprint,
186
396
  });
187
397
  return {
188
398
  schemaVersion: 1,
@@ -193,6 +403,7 @@ async function computeChangedProof(rootPath, options) {
193
403
  receipt,
194
404
  commands: receipt.proofStatus.commandsRequired,
195
405
  warnings: receipt.evidenceGaps,
406
+ verifiedWorkflow: receipt.verifiedWorkflow,
196
407
  };
197
408
  }
198
409
  function quickProofPreflight(changedFiles) {
@@ -217,15 +428,34 @@ function buildContract(input) {
217
428
  const simulationFiles = input.simulation.filesLikelyTouched.map((file) => file.path);
218
429
  const allowedFiles = unique(simulationFiles);
219
430
  const likelyTests = unique(input.simulation.testsLikelyAffected);
220
- const forbiddenFiles = forbiddenFilesFor(input.intent, [...allowedFiles, ...likelyTests]);
221
- const proofCommands = contractProofCommands(input.simulation.proofCommands);
431
+ const matchedRecipes = matchTeamProofRecipes(input.proofRecipes ?? [], [
432
+ ...allowedFiles,
433
+ ...likelyTests,
434
+ ]);
435
+ const forbiddenFiles = unique([
436
+ ...forbiddenFilesFor(input.intent, [...allowedFiles, ...likelyTests]),
437
+ ...matchedRecipes.flatMap((recipe) => recipe.forbiddenFiles ?? []),
438
+ ]);
439
+ const proofCommands = unique([
440
+ ...contractProofCommands(input.simulation.proofCommands),
441
+ ...matchedRecipes.flatMap((recipe) => recipe.requiredCommands),
442
+ ]);
443
+ const proofRequirements = [
444
+ ...buildProofRequirements({
445
+ allowedFiles,
446
+ likelyTests,
447
+ riskyContracts: riskyContractsFor(input.simulation.contractsLikelyAffected, allowedFiles),
448
+ proofCommands,
449
+ }),
450
+ ...recipeProofRequirements(matchedRecipes),
451
+ ];
222
452
  const evidenceGaps = unique([
223
453
  ...(input.simulation.warnings.length > 0 ? input.simulation.warnings : []),
224
454
  ...(likelyTests.length === 0 ? ['No likely regression test was inferred from the plan.'] : []),
225
455
  ...(input.trustMemory?.gaps ?? []),
226
456
  ]);
227
457
  const confidence = confidenceForTrustMemory(input.simulation.confidence, input.trustMemory);
228
- return {
458
+ const contract = {
229
459
  schemaVersion: 1,
230
460
  id: `proof-contract-${slug(input.intent)}`,
231
461
  intent: input.intent,
@@ -236,6 +466,8 @@ function buildContract(input) {
236
466
  likelyTests,
237
467
  missingRegressionTests: likelyTests.length > 0 ? [] : ['Add one regression test around the behavior named by the intent.'],
238
468
  proofCommands,
469
+ proofRequirements,
470
+ ...(matchedRecipes.length > 0 ? { teamProofRecipes: matchedRecipes } : {}),
239
471
  safeChangeShape: safeChangeShape(input.simulation.recommendedAlternative.summary),
240
472
  rollbackPlan: rollbackPlan([...allowedFiles, ...likelyTests]),
241
473
  confidence,
@@ -255,6 +487,10 @@ function buildContract(input) {
255
487
  receiptCommand: `projscan prove --changed --contract ${quoteShellArg(DEFAULT_CONTRACT_PATH)} --format markdown`,
256
488
  riskDelta: input.simulation.riskDelta,
257
489
  };
490
+ return {
491
+ ...contract,
492
+ verifiedWorkflow: verifiedWorkflowForContract(contract),
493
+ };
258
494
  }
259
495
  function contractProofCommands(simulationCommands) {
260
496
  return unique([
@@ -264,46 +500,228 @@ function contractProofCommands(simulationCommands) {
264
500
  'projscan preflight --mode before_commit --format json',
265
501
  ].filter((command) => typeof command === 'string'));
266
502
  }
503
+ function matchTeamProofRecipes(recipes, files) {
504
+ return recipes
505
+ .map((recipe) => {
506
+ const matchedFiles = unique(files.filter((file) => recipe.matches.some((pattern) => pathMatches(file, pattern))));
507
+ if (matchedFiles.length === 0)
508
+ return null;
509
+ return {
510
+ ...recipe,
511
+ matchedFiles,
512
+ };
513
+ })
514
+ .filter((recipe) => Boolean(recipe));
515
+ }
516
+ function recipeProofRequirements(recipes) {
517
+ return recipes.map((recipe) => ({
518
+ id: `recipe:${recipe.id}`,
519
+ surface: 'custom',
520
+ files: recipe.matchedFiles,
521
+ requiredCommands: recipe.requiredCommands,
522
+ requiredReview: recipe.requiredReviewers?.length
523
+ ? `require review from ${recipe.requiredReviewers.join(', ')}`
524
+ : `review Team Proof Recipe ${recipe.id}`,
525
+ reason: recipe.reason ?? `Team Proof Recipe ${recipe.id} matched ${recipe.matchedFiles.join(', ')}.`,
526
+ source: 'recipe',
527
+ recipeId: recipe.id,
528
+ ...(recipe.requiredReviewers ? { requiredReviewers: recipe.requiredReviewers } : {}),
529
+ }));
530
+ }
531
+ function teamProofRecipesForReceipt(recipes, changedFiles, proofStatus) {
532
+ return recipes
533
+ .map((recipe) => {
534
+ const matchedFiles = unique(changedFiles.filter((file) => recipe.matches.some((pattern) => pathMatches(file, pattern))));
535
+ const forbiddenTouched = unique(changedFiles.filter((file) => (recipe.forbiddenFiles ?? []).some((pattern) => pathMatches(file, pattern))));
536
+ if (matchedFiles.length === 0 && forbiddenTouched.length === 0)
537
+ return null;
538
+ const missingCommands = recipe.requiredCommands.filter((command) => proofStatus.missingCommands.includes(command));
539
+ const failedCommands = recipe.requiredCommands.filter((command) => proofStatus.failedCommands.includes(command));
540
+ const staleCommands = recipe.requiredCommands.filter((command) => proofStatus.staleCommands.includes(command));
541
+ return {
542
+ ...recipe,
543
+ matchedFiles,
544
+ ...(forbiddenTouched.length > 0 ? { forbiddenTouched } : {}),
545
+ ...(missingCommands.length > 0 ? { missingCommands } : {}),
546
+ ...(failedCommands.length > 0 ? { failedCommands } : {}),
547
+ ...(staleCommands.length > 0 ? { staleCommands } : {}),
548
+ };
549
+ })
550
+ .filter((recipe) => Boolean(recipe));
551
+ }
552
+ function recipeGapsFor(recipes) {
553
+ const gaps = [];
554
+ for (const recipe of recipes) {
555
+ for (const command of recipe.missingCommands ?? []) {
556
+ gaps.push(`${recipe.id} requires proof command: ${command}`);
557
+ }
558
+ for (const command of recipe.failedCommands ?? []) {
559
+ gaps.push(`${recipe.id} has failed proof command: ${command}`);
560
+ }
561
+ for (const command of recipe.staleCommands ?? []) {
562
+ gaps.push(`${recipe.id} has stale proof command: ${command}`);
563
+ }
564
+ }
565
+ return gaps;
566
+ }
267
567
  function buildReceipt(input) {
268
568
  const scope = scopeFor(input.contract, input.contractPath, input.changedFiles);
269
569
  const evidenceGaps = evidenceGapsFor(input);
270
- const proofCommands = input.contract?.proofCommands ?? [
271
- 'projscan assess --mode fix-first --format json',
272
- 'projscan preflight --mode before_commit --format json',
273
- ];
274
- const proofStatus = proofStatusFor(proofCommands, input.ledger, input.changedFiles);
570
+ const proofStatus = proofStatusFor(input.proofCommands, input.ledger, input.changedFiles, input.currentChangedFileFingerprint);
571
+ const teamProofRecipes = teamProofRecipesForReceipt(input.contract?.teamProofRecipes ?? [], input.changedFiles, proofStatus);
572
+ const requiredReviewers = unique(teamProofRecipes.flatMap((recipe) => recipe.requiredReviewers ?? []));
573
+ const recipeForbiddenTouched = unique(teamProofRecipes.flatMap((recipe) => recipe.forbiddenTouched ?? []));
574
+ const recipeGaps = recipeGapsFor(teamProofRecipes);
575
+ const proofSufficiency = proofSufficiencyFor({
576
+ contract: input.contract,
577
+ scope,
578
+ proofStatus,
579
+ });
275
580
  const commitReadiness = readinessFor({
276
581
  scopeStatus: scope.status,
277
582
  forbiddenTouched: scope.forbiddenTouched,
278
583
  preflightVerdict: input.preflightVerdict,
279
584
  evidenceGaps,
280
585
  proofStatus: proofStatus.status,
586
+ proofSufficiencyStatus: proofSufficiency.status,
281
587
  });
282
588
  const riskDeltaDirection = riskDeltaDirectionFor(input.riskDelta);
283
589
  const reviewerDecision = reviewerDecisionFor({
284
590
  commitReadiness,
285
591
  proofStatus: proofStatus.status,
592
+ proofSufficiencyStatus: proofSufficiency.status,
286
593
  scope,
287
594
  preflightVerdict: input.preflightVerdict,
288
595
  });
289
- return {
596
+ const proofReplay = buildProofReplay({
597
+ scope,
598
+ proofStatus,
599
+ proofSufficiency,
600
+ riskDeltaDirection,
601
+ reviewerDecision,
602
+ replayCommand: replayCommandForReceipt(scope.contractPath),
603
+ });
604
+ const receipt = {
290
605
  summary: summaryForReceipt(commitReadiness, scope),
291
606
  commitReadiness,
292
607
  scope,
293
608
  proofStatus,
609
+ proofSufficiency,
610
+ proofReplay,
611
+ ...(teamProofRecipes.length > 0 ? { teamProofRecipes } : {}),
612
+ ...(requiredReviewers.length > 0 ? { requiredReviewers } : {}),
613
+ ...(recipeForbiddenTouched.length > 0 ? { recipeForbiddenTouched } : {}),
614
+ ...(recipeForbiddenTouched.length > 0 ? { recipeDrift: recipeForbiddenTouched } : {}),
615
+ ...(recipeGaps.length > 0 ? { recipeGaps } : {}),
294
616
  riskDelta: input.riskDelta,
295
617
  riskDeltaDirection,
296
618
  reviewerDecision,
297
619
  newRisks: input.newRisks,
298
620
  evidenceGaps,
299
- reviewerGuidance: reviewerGuidanceFor(commitReadiness, scope, reviewerDecision, proofStatus.status),
621
+ reviewerGuidance: reviewerGuidanceFor(commitReadiness, scope, reviewerDecision, proofStatus.status, proofSufficiency.status),
622
+ };
623
+ return {
624
+ ...receipt,
625
+ verifiedWorkflow: verifiedWorkflowForReceipt(receipt),
300
626
  };
301
627
  }
302
- function proofStatusFor(proofCommands, ledger, changedFiles) {
628
+ function verifiedWorkflowForContract(contract) {
629
+ return {
630
+ phase: 'contract',
631
+ status: intentVerdict(contract),
632
+ nextAction: 'save the Proof Contract, make the bounded edit, then record proof commands',
633
+ nextCommand: contract.receiptCommand,
634
+ staleProof: false,
635
+ missingProof: contract.proofCommands.length > 0,
636
+ failedProof: false,
637
+ };
638
+ }
639
+ function verifiedWorkflowForRecord(verdict, recordStatus) {
640
+ const failedProof = recordStatus === 'failed';
641
+ return {
642
+ phase: 'record',
643
+ status: verdict,
644
+ nextAction: failedProof
645
+ ? 'fix the failed proof command, record it again, then replay changed proof'
646
+ : 'run projscan prove --changed to replay the ledger against the current diff',
647
+ nextCommand: 'projscan prove --changed --format markdown',
648
+ staleProof: false,
649
+ missingProof: false,
650
+ failedProof,
651
+ };
652
+ }
653
+ function verifiedWorkflowForReceipt(receipt) {
654
+ const proofStatus = receipt.proofStatus.status;
655
+ const proofSufficiencyStatus = receipt.proofSufficiency?.status ?? 'missing';
656
+ const staleProof = proofStatus === 'stale' || receipt.proofStatus.staleCommands.length > 0;
657
+ const missingProof = isMissingProofStatus(proofStatus) ||
658
+ receipt.proofStatus.missingCommands.length > 0;
659
+ const failedProof = proofStatus === 'failed' || receipt.proofStatus.failedCommands.length > 0;
660
+ return {
661
+ phase: 'receipt',
662
+ status: receipt.commitReadiness,
663
+ nextAction: nextActionForReceipt({
664
+ receipt,
665
+ staleProof,
666
+ missingProof,
667
+ failedProof,
668
+ }),
669
+ nextCommand: nextCommandForReceipt({
670
+ receipt,
671
+ staleProof,
672
+ missingProof,
673
+ failedProof,
674
+ }),
675
+ reviewerDecision: receipt.reviewerDecision,
676
+ scopeStatus: receipt.scope.status,
677
+ proofStatus,
678
+ proofSufficiencyStatus,
679
+ riskDeltaDirection: receipt.riskDeltaDirection,
680
+ staleProof,
681
+ missingProof,
682
+ failedProof,
683
+ };
684
+ }
685
+ function nextActionForReceipt(input) {
686
+ if (input.failedProof)
687
+ return 'fix failed proof commands before review';
688
+ if (input.staleProof)
689
+ return 'rerun stale proof commands before review';
690
+ if (input.receipt.proofStatus.status === 'not-run') {
691
+ return 'add required proof commands to the Proof Contract before review';
692
+ }
693
+ if (input.missingProof)
694
+ return 'record missing proof commands before review';
695
+ if (input.receipt.scope.status === 'drifted') {
696
+ return 'resolve scope drift or update the Proof Contract before review';
697
+ }
698
+ if (input.receipt.reviewerDecision === 'safe-to-review') {
699
+ return 'share the Proof Receipt with the reviewer';
700
+ }
701
+ return 'review focused scope and proof gaps before approval';
702
+ }
703
+ function nextCommandForReceipt(input) {
704
+ if (input.failedProof) {
705
+ return `projscan prove --record-command ${quoteShellArg(input.receipt.proofStatus.failedCommands[0] ?? '<command>')} --exit-code 0 --duration-ms <ms>`;
706
+ }
707
+ if (input.staleProof) {
708
+ return `projscan prove --record-command ${quoteShellArg(input.receipt.proofStatus.staleCommands[0] ?? '<command>')} --exit-code 0 --duration-ms <ms>`;
709
+ }
710
+ if (input.receipt.proofStatus.status === 'not-run') {
711
+ return 'projscan prove --intent "<change intent>" --save-contract .projscan/proof-contract.json';
712
+ }
713
+ if (input.missingProof) {
714
+ return 'projscan prove --record-command "<command>" --exit-code 0 --duration-ms <ms>';
715
+ }
716
+ if (input.receipt.scope.status === 'drifted')
717
+ return 'projscan prove --changed --format markdown';
718
+ return 'projscan evidence-pack --pr-comment';
719
+ }
720
+ function proofStatusFor(proofCommands, ledger, changedFiles, currentFingerprint) {
303
721
  const relevantChangedFiles = proofRelevantChangedFiles(changedFiles);
304
- const currentFingerprint = changedFileFingerprint(relevantChangedFiles);
722
+ const latestLedgerByCommand = latestProofRecordsByCommand(ledger);
305
723
  const commandEvidence = proofCommands.map((command) => {
306
- const record = latestProofRecordFor(ledger, command);
724
+ const record = latestLedgerByCommand.get(normalizeProofCommand(command));
307
725
  if (!record) {
308
726
  return {
309
727
  command,
@@ -320,18 +738,24 @@ function proofStatusFor(proofCommands, ledger, changedFiles) {
320
738
  exitCode: record.exitCode,
321
739
  durationMs: record.durationMs,
322
740
  completedAt: record.completedAt,
741
+ source: record.source,
742
+ recordedChangedFiles: record.changedFiles,
743
+ recordedChangedFileFingerprint: record.changedFileFingerprint,
323
744
  outputSummary: record.outputSummary,
324
745
  ...(record.logPath ? { logPath: record.logPath } : {}),
325
- staleReason: 'Recorded changed files differ from current changed files.',
746
+ staleReason: staleProofReason(record.changedFiles, relevantChangedFiles),
326
747
  };
327
748
  }
328
749
  return {
329
750
  command,
330
751
  status: record.exitCode === 0 ? 'passed' : 'failed',
331
752
  fresh: true,
753
+ source: record.source,
332
754
  exitCode: record.exitCode,
333
755
  durationMs: record.durationMs,
334
756
  completedAt: record.completedAt,
757
+ recordedChangedFiles: record.changedFiles,
758
+ recordedChangedFileFingerprint: record.changedFileFingerprint,
335
759
  outputSummary: record.outputSummary,
336
760
  ...(record.logPath ? { logPath: record.logPath } : {}),
337
761
  };
@@ -364,6 +788,32 @@ function proofStatusFor(proofCommands, ledger, changedFiles) {
364
788
  commandEvidence,
365
789
  };
366
790
  }
791
+ function latestProofRecordsByCommand(records) {
792
+ const latest = new Map();
793
+ for (const record of records) {
794
+ const existing = latest.get(record.normalizedCommand);
795
+ if (!existing || record.completedAt.localeCompare(existing.completedAt) >= 0) {
796
+ latest.set(record.normalizedCommand, record);
797
+ }
798
+ }
799
+ return latest;
800
+ }
801
+ function staleProofReason(recordedFiles, currentFiles) {
802
+ return sameStringSet(recordedFiles, currentFiles)
803
+ ? 'Recorded changed-file content fingerprint differs from current changed-file content.'
804
+ : 'Recorded changed files differ from current changed files.';
805
+ }
806
+ function sameStringSet(left, right) {
807
+ if (left.length !== right.length)
808
+ return false;
809
+ const rightSet = new Set(right);
810
+ return left.every((value) => rightSet.has(value));
811
+ }
812
+ function replayCommandForReceipt(contractPath) {
813
+ return contractPath
814
+ ? `projscan prove --changed --contract ${quoteShellArg(contractPath)} --format markdown`
815
+ : 'projscan prove --changed --format markdown';
816
+ }
367
817
  function proofStatusSummary(input) {
368
818
  if (input.requiredCount === 0)
369
819
  return 'not-run';
@@ -377,8 +827,11 @@ function proofStatusSummary(input) {
377
827
  return 'partial';
378
828
  return 'passed';
379
829
  }
380
- function proofRelevantChangedFiles(files) {
381
- return files.filter((file) => !isGeneratedPath(file));
830
+ function proofCommandsForReceipt(contract) {
831
+ return (contract?.proofCommands ?? [
832
+ 'projscan assess --mode fix-first --format json',
833
+ 'projscan preflight --mode before_commit --format json',
834
+ ]);
382
835
  }
383
836
  function scopeFor(contract, contractPath, changedFiles) {
384
837
  if (!contract) {
@@ -400,13 +853,16 @@ function scopeFor(contract, contractPath, changedFiles) {
400
853
  ...(contractPath ? [contractPath] : []),
401
854
  ]);
402
855
  const forbiddenTouched = changedFiles.filter((file) => contract.forbiddenFiles.some((pattern) => pathMatches(file, pattern)));
856
+ const forbiddenTouchedSet = new Set(forbiddenTouched);
857
+ const allowedProductionSet = new Set(contract.allowedFiles);
858
+ const likelyTestSet = new Set(contract.likelyTests);
403
859
  const allowedTouched = changedFiles.filter((file) => allowed.has(file));
404
- const outsideAllowed = changedFiles.filter((file) => !allowed.has(file));
860
+ const outsideAllowed = changedFiles.filter((file) => !allowed.has(file) && !isLocalProofArtifactPath(file));
405
861
  const classifications = changedFiles.map((file) => classifyChangedFile({
406
862
  file,
407
- forbidden: forbiddenTouched.includes(file),
408
- allowedProduction: contract.allowedFiles.includes(file),
409
- expectedTest: contract.likelyTests.includes(file),
863
+ forbidden: forbiddenTouchedSet.has(file),
864
+ allowedProduction: allowedProductionSet.has(file),
865
+ expectedTest: likelyTestSet.has(file),
410
866
  contractPath: contractPath === file,
411
867
  }));
412
868
  const status = forbiddenTouched.length > 0 || outsideAllowed.length > 0 ? 'drifted' : 'within-contract';
@@ -434,8 +890,9 @@ async function resolveContract(rootPath, options) {
434
890
  return contract ? { contract, path: DEFAULT_CONTRACT_PATH } : {};
435
891
  }
436
892
  async function readContract(rootPath, filePath, required) {
437
- const fullPath = path.resolve(rootPath, filePath);
893
+ const fullPath = resolveProofContractPath(rootPath, filePath);
438
894
  try {
895
+ await prepareProofArtifactReadPath(rootPath, fullPath);
439
896
  const parsed = JSON.parse(await fs.readFile(fullPath, 'utf-8'));
440
897
  if (parsed.schemaVersion !== 1 || !Array.isArray(parsed.allowedFiles) || !parsed.id) {
441
898
  throw new Error('invalid Proof Contract shape');
@@ -449,11 +906,28 @@ async function readContract(rootPath, filePath, required) {
449
906
  }
450
907
  }
451
908
  async function writeContract(rootPath, filePath, contract) {
452
- const fullPath = path.resolve(rootPath, filePath);
453
- await fs.mkdir(path.dirname(fullPath), { recursive: true });
909
+ const fullPath = resolveProofContractPath(rootPath, filePath);
910
+ await prepareProofArtifactWritePath(rootPath, fullPath);
454
911
  await fs.writeFile(fullPath, `${JSON.stringify(contract, null, 2)}\n`, 'utf-8');
455
912
  return filePath;
456
913
  }
914
+ function resolveProofContractPath(rootPath, filePath) {
915
+ const root = path.resolve(rootPath);
916
+ const requested = filePath.trim();
917
+ if (!requested)
918
+ throw new Error('Proof Contract path is required.');
919
+ const fullPath = path.resolve(root, requested);
920
+ const relative = path.relative(root, fullPath);
921
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
922
+ throw new Error('Proof Contract path must stay inside the project root.');
923
+ }
924
+ const normalizedRelative = relative.split(path.sep).join('/');
925
+ if (normalizedRelative !== DEFAULT_CONTRACT_PATH &&
926
+ !/^\.projscan\/proof-contracts\/[^/]+\.json$/.test(normalizedRelative)) {
927
+ throw new Error('Proof Contract path must be .projscan/proof-contract.json or .projscan/proof-contracts/<name>.json.');
928
+ }
929
+ return fullPath;
930
+ }
457
931
  function forbiddenFilesFor(intent, allowed) {
458
932
  const intentLower = intent.toLowerCase();
459
933
  const allowedSet = new Set(allowed);
@@ -497,16 +971,26 @@ function readinessFor(input) {
497
971
  return hasReviewReceiptSignal(input) ? 'needs-review' : 'ready';
498
972
  }
499
973
  function hasBlockingReceiptSignal(input) {
500
- return input.forbiddenTouched.length > 0 || input.preflightVerdict === 'block' || input.proofStatus === 'failed';
974
+ return (input.forbiddenTouched.length > 0 ||
975
+ input.preflightVerdict === 'block' ||
976
+ input.proofStatus === 'failed' ||
977
+ input.proofSufficiencyStatus === 'failed');
501
978
  }
502
979
  function hasReviewReceiptSignal(input) {
503
980
  return (input.scopeStatus !== 'within-contract' ||
504
981
  isIncompleteProofStatus(input.proofStatus) ||
982
+ isReviewProofSufficiencyStatus(input.proofSufficiencyStatus) ||
505
983
  input.preflightVerdict === 'caution' ||
506
984
  input.evidenceGaps.length > 0);
507
985
  }
508
986
  function isIncompleteProofStatus(status) {
509
- return status === 'missing' || status === 'partial' || status === 'stale';
987
+ return status === 'not-run' || status === 'missing' || status === 'partial' || status === 'stale';
988
+ }
989
+ function isMissingProofStatus(status) {
990
+ return status === 'not-run' || status === 'missing' || status === 'partial';
991
+ }
992
+ function isReviewProofSufficiencyStatus(status) {
993
+ return status === 'missing' || status === 'stale' || status === 'weak';
510
994
  }
511
995
  function riskDeltaDirectionFor(riskDelta) {
512
996
  if (riskDelta.delta > 0)
@@ -516,21 +1000,28 @@ function riskDeltaDirectionFor(riskDelta) {
516
1000
  return 'flat';
517
1001
  }
518
1002
  function reviewerDecisionFor(input) {
519
- if (input.commitReadiness === 'blocked' || input.proofStatus === 'failed')
1003
+ if (input.commitReadiness === 'blocked' ||
1004
+ input.proofStatus === 'failed' ||
1005
+ input.proofSufficiencyStatus === 'failed')
520
1006
  return 'stop';
521
1007
  if (input.commitReadiness === 'ready' &&
522
1008
  input.proofStatus === 'passed' &&
1009
+ (input.proofSufficiencyStatus === 'strong' || input.proofSufficiencyStatus === 'adequate') &&
523
1010
  input.scope.status === 'within-contract' &&
524
1011
  input.preflightVerdict === 'proceed') {
525
1012
  return 'safe-to-review';
526
1013
  }
527
1014
  return 'needs-focused-review';
528
1015
  }
529
- function reviewerGuidanceFor(verdict, scope, reviewerDecision, proofStatus) {
1016
+ function reviewerGuidanceFor(verdict, scope, reviewerDecision, proofStatus, proofSufficiencyStatus) {
530
1017
  return firstMatchingGuidance([
531
1018
  [reviewerDecision === 'stop', 'Stop this proof slice until failed proof commands, forbidden files, or preflight blockers are cleared.'],
1019
+ [proofSufficiencyStatus === 'failed', 'Fix failed proof for the affected risk surface before review.'],
532
1020
  [proofStatus === 'stale', 'Rerun the required proof commands; the ledger evidence is stale after newer file changes.'],
1021
+ [proofSufficiencyStatus === 'stale', 'Rerun stale proof for the affected risk surface before review.'],
533
1022
  [isIncompleteProofStatus(proofStatus), 'Record fresh proof-command evidence before approval. Missing or partial proof should not be treated as reviewer-ready.'],
1023
+ [proofSufficiencyStatus === 'missing', 'Record proof for each changed risk surface before approval.'],
1024
+ [proofSufficiencyStatus === 'weak', 'Review weak proof mapping before approval; a command passed but did not prove the changed surface strongly.'],
534
1025
  [verdict === 'blocked', 'Do not approve until forbidden files or preflight blockers are removed from this proof slice.'],
535
1026
  [scope.unexpectedProduction.length > 0, 'Review the unexpected production files first. Either update the Proof Contract intentionally or split those edits out.'],
536
1027
  [hasSensitiveScopeDrift(scope), 'Require explicit reviewer sign-off for config or security-sensitive drift before approving.'],
@@ -758,53 +1249,52 @@ function confidenceReasonForSimulation(confidence, simulationConfidence, trustMe
758
1249
  function normalizeIntent(value) {
759
1250
  return value?.trim().replace(/\s+/g, ' ') ?? '';
760
1251
  }
761
- function isDocumentationPath(file) {
762
- return (file === 'README.md' ||
763
- file.startsWith('docs/') ||
764
- file.endsWith('.md') ||
765
- file.endsWith('.mdx'));
766
- }
767
- function isGeneratedPath(file) {
768
- return (file.startsWith('.projscan/') ||
769
- file.startsWith('.projscan-memory/') ||
770
- file.startsWith('.agentloop/') ||
771
- file.startsWith('.agentflight/') ||
772
- file.startsWith('coverage/') ||
773
- file.startsWith('dist/'));
774
- }
775
- function isSecuritySensitivePath(file) {
776
- return (file === '.env' ||
777
- file.startsWith('.env.') ||
778
- file.includes('/auth') ||
779
- file.includes('/security') ||
780
- file.includes('/secrets') ||
781
- file.endsWith('.pem') ||
782
- file.endsWith('.key'));
783
- }
784
- function isConfigPath(file) {
785
- const basename = path.posix.basename(file);
786
- return (CONFIG_BASENAMES.has(basename) ||
787
- CONFIG_SUFFIXES.some((suffix) => basename.endsWith(suffix)) ||
788
- file.startsWith('.github/'));
789
- }
790
- function isTestPath(file) {
791
- return (file.startsWith('test/') ||
792
- file.startsWith('tests/') ||
793
- file.includes('/__tests__/') ||
794
- /\.test\.[cm]?[jt]sx?$/.test(file) ||
795
- /\.spec\.[cm]?[jt]sx?$/.test(file));
796
- }
797
- function isProductionPath(file) {
798
- return (file.startsWith('src/') ||
799
- file.startsWith('app/') ||
800
- file.startsWith('lib/') ||
801
- file.startsWith('packages/') ||
802
- file.startsWith('apps/'));
1252
+ function isLocalProofArtifactPath(file) {
1253
+ return file.startsWith('.projscan/');
803
1254
  }
804
1255
  function pathMatches(file, pattern) {
805
- if (pattern.endsWith('/**'))
806
- return file.startsWith(pattern.slice(0, -3));
807
- return file === pattern;
1256
+ const normalizedFile = normalizeRepoPath(file);
1257
+ const normalizedPattern = normalizeRepoPath(pattern);
1258
+ if (normalizedFile === normalizedPattern)
1259
+ return true;
1260
+ if (!normalizedPattern.includes('*'))
1261
+ return false;
1262
+ let regex = PATH_MATCH_REGEX_CACHE.get(normalizedPattern);
1263
+ if (!regex) {
1264
+ regex = globToRegExp(normalizedPattern);
1265
+ PATH_MATCH_REGEX_CACHE.set(normalizedPattern, regex);
1266
+ }
1267
+ return regex.test(normalizedFile);
1268
+ }
1269
+ function globToRegExp(pattern) {
1270
+ let source = '^';
1271
+ for (let index = 0; index < pattern.length; index += 1) {
1272
+ const char = pattern[index];
1273
+ if (char === '*') {
1274
+ if (pattern[index + 1] === '*') {
1275
+ if (pattern[index + 2] === '/') {
1276
+ source += '(?:.*/)?';
1277
+ index += 2;
1278
+ }
1279
+ else {
1280
+ source += '.*';
1281
+ index += 1;
1282
+ }
1283
+ }
1284
+ else {
1285
+ source += '[^/]*';
1286
+ }
1287
+ continue;
1288
+ }
1289
+ source += escapeRegExp(char);
1290
+ }
1291
+ return new RegExp(`${source}$`);
1292
+ }
1293
+ function escapeRegExp(value) {
1294
+ return value.replace(/[|\\{}()[\]^$+?.]/g, '\\$&');
1295
+ }
1296
+ function normalizeRepoPath(value) {
1297
+ return value.split(path.sep).join('/').replace(/^\.\//, '');
808
1298
  }
809
1299
  function unique(values) {
810
1300
  return [...new Set(values)];