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.
- package/README.md +90 -24
- package/dist/cli/commands/evidencePack.js +2 -0
- package/dist/cli/commands/evidencePack.js.map +1 -1
- package/dist/cli/commands/prove.js +253 -23
- package/dist/cli/commands/prove.js.map +1 -1
- package/dist/cli/commands/startConsole.d.ts +2 -2
- package/dist/cli/commands/startConsole.js +2 -260
- package/dist/cli/commands/startConsole.js.map +1 -1
- package/dist/cli/commands/startConsoleExecution.d.ts +5 -0
- package/dist/cli/commands/startConsoleExecution.js +108 -0
- package/dist/cli/commands/startConsoleExecution.js.map +1 -0
- package/dist/cli/commands/startConsoleMission.d.ts +6 -0
- package/dist/cli/commands/startConsoleMission.js +157 -0
- package/dist/cli/commands/startConsoleMission.js.map +1 -0
- package/dist/cli/commands/startMissionBundle.js +24 -27
- package/dist/cli/commands/startMissionBundle.js.map +1 -1
- package/dist/core/adoption.d.ts +8 -81
- package/dist/core/adoption.js +4 -549
- package/dist/core/adoption.js.map +1 -1
- package/dist/core/adoptionFirstRunDiagnostics.d.ts +20 -0
- package/dist/core/adoptionFirstRunDiagnostics.js +240 -0
- package/dist/core/adoptionFirstRunDiagnostics.js.map +1 -0
- package/dist/core/adoptionMcpConfig.d.ts +27 -0
- package/dist/core/adoptionMcpConfig.js +123 -0
- package/dist/core/adoptionMcpConfig.js.map +1 -0
- package/dist/core/adoptionMcpDoctor.d.ts +23 -0
- package/dist/core/adoptionMcpDoctor.js +87 -0
- package/dist/core/adoptionMcpDoctor.js.map +1 -0
- package/dist/core/adoptionWorkflowRecipes.d.ts +14 -0
- package/dist/core/adoptionWorkflowRecipes.js +110 -0
- package/dist/core/adoptionWorkflowRecipes.js.map +1 -0
- package/dist/core/bugHunt.js +26 -255
- package/dist/core/bugHunt.js.map +1 -1
- package/dist/core/bugHuntPreflightFindings.d.ts +2 -1
- package/dist/core/bugHuntPreflightFindings.js +20 -0
- package/dist/core/bugHuntPreflightFindings.js.map +1 -1
- package/dist/core/bugHuntReportAssembly.d.ts +20 -0
- package/dist/core/bugHuntReportAssembly.js +179 -0
- package/dist/core/bugHuntReportAssembly.js.map +1 -0
- package/dist/core/bugHuntSourceFindings.d.ts +3 -0
- package/dist/core/bugHuntSourceFindings.js +61 -0
- package/dist/core/bugHuntSourceFindings.js.map +1 -0
- package/dist/core/dogfood.js +4 -393
- package/dist/core/dogfood.js.map +1 -1
- package/dist/core/dogfoodMarketValidation.d.ts +5 -0
- package/dist/core/dogfoodMarketValidation.js +265 -0
- package/dist/core/dogfoodMarketValidation.js.map +1 -0
- package/dist/core/dogfoodRepoEvaluation.d.ts +4 -0
- package/dist/core/dogfoodRepoEvaluation.js +137 -0
- package/dist/core/dogfoodRepoEvaluation.js.map +1 -0
- package/dist/core/evidenceComment.js +50 -13
- package/dist/core/evidenceComment.js.map +1 -1
- package/dist/core/feedback.js +2 -252
- package/dist/core/feedback.js.map +1 -1
- package/dist/core/feedbackIntakeClassifier.d.ts +2 -0
- package/dist/core/feedbackIntakeClassifier.js +255 -0
- package/dist/core/feedbackIntakeClassifier.js.map +1 -0
- package/dist/core/intentRouterCatalog.js +34 -0
- package/dist/core/intentRouterCatalog.js.map +1 -1
- package/dist/core/intentRouterKeywordToolGuards.js +2 -46
- package/dist/core/intentRouterKeywordToolGuards.js.map +1 -1
- package/dist/core/intentRouterKeywordWeights.js +13 -28
- package/dist/core/intentRouterKeywordWeights.js.map +1 -1
- package/dist/core/intentRouterProductGuardSignals.d.ts +3 -0
- package/dist/core/intentRouterProductGuardSignals.js +59 -0
- package/dist/core/intentRouterProductGuardSignals.js.map +1 -0
- package/dist/core/intentRouterWorkflowKeywordWeights.js +29 -0
- package/dist/core/intentRouterWorkflowKeywordWeights.js.map +1 -1
- package/dist/core/markdownSafety.d.ts +3 -0
- package/dist/core/markdownSafety.js +14 -0
- package/dist/core/markdownSafety.js.map +1 -0
- package/dist/core/preflight.d.ts +2 -0
- package/dist/core/preflight.js.map +1 -1
- package/dist/core/preflightChangedFiles.d.ts +2 -0
- package/dist/core/preflightChangedFiles.js +1 -1
- package/dist/core/preflightChangedFiles.js.map +1 -1
- package/dist/core/preflightInputs.d.ts +2 -0
- package/dist/core/preflightInputs.js +5 -2
- package/dist/core/preflightInputs.js.map +1 -1
- package/dist/core/proofLedger.d.ts +6 -1
- package/dist/core/proofLedger.js +174 -15
- package/dist/core/proofLedger.js.map +1 -1
- package/dist/core/proofReplay.d.ts +9 -0
- package/dist/core/proofReplay.js +164 -0
- package/dist/core/proofReplay.js.map +1 -0
- package/dist/core/proofSufficiency.d.ts +19 -0
- package/dist/core/proofSufficiency.js +425 -0
- package/dist/core/proofSufficiency.js.map +1 -0
- package/dist/core/prove.d.ts +8 -0
- package/dist/core/prove.js +578 -88
- package/dist/core/prove.js.map +1 -1
- package/dist/core/qualityScorecard.js +8 -238
- package/dist/core/qualityScorecard.js.map +1 -1
- package/dist/core/qualityScorecardDimensions.d.ts +14 -0
- package/dist/core/qualityScorecardDimensions.js +99 -0
- package/dist/core/qualityScorecardDimensions.js.map +1 -0
- package/dist/core/qualityScorecardRisks.d.ts +8 -0
- package/dist/core/qualityScorecardRisks.js +107 -0
- package/dist/core/qualityScorecardRisks.js.map +1 -0
- package/dist/core/qualityScorecardSignals.d.ts +20 -0
- package/dist/core/qualityScorecardSignals.js +59 -0
- package/dist/core/qualityScorecardSignals.js.map +1 -0
- package/dist/core/releaseEvidence.d.ts +1 -0
- package/dist/core/releaseEvidence.js +15 -40
- package/dist/core/releaseEvidence.js.map +1 -1
- package/dist/core/releaseEvidenceBaseline.js +4 -1
- package/dist/core/releaseEvidenceBaseline.js.map +1 -1
- package/dist/core/releaseEvidenceProofReceipt.d.ts +6 -0
- package/dist/core/releaseEvidenceProofReceipt.js +140 -0
- package/dist/core/releaseEvidenceProofReceipt.js.map +1 -0
- package/dist/core/releaseEvidenceVerdict.d.ts +5 -2
- package/dist/core/releaseEvidenceVerdict.js +39 -1
- package/dist/core/releaseEvidenceVerdict.js.map +1 -1
- package/dist/core/repositoryScanner.d.ts +1 -0
- package/dist/core/repositoryScanner.js +5 -4
- package/dist/core/repositoryScanner.js.map +1 -1
- package/dist/core/sessionResources.d.ts +14 -2
- package/dist/core/sessionResources.js +3 -3
- package/dist/core/sessionResources.js.map +1 -1
- package/dist/core/startFixedRouteCriteria.js +4 -0
- package/dist/core/startFixedRouteCriteria.js.map +1 -1
- package/dist/core/startInputs.d.ts +1 -1
- package/dist/core/startIntentTargets.d.ts +1 -1
- package/dist/core/startIntentTargets.js +1 -16
- package/dist/core/startIntentTargets.js.map +1 -1
- package/dist/core/startMissionInputStatusPolicy.d.ts +7 -0
- package/dist/core/startMissionInputStatusPolicy.js +74 -0
- package/dist/core/startMissionInputStatusPolicy.js.map +1 -0
- package/dist/core/startMissionPolicy.d.ts +6 -15
- package/dist/core/startMissionPolicy.js +4 -305
- package/dist/core/startMissionPolicy.js.map +1 -1
- package/dist/core/startMissionProofPolicy.d.ts +6 -0
- package/dist/core/startMissionProofPolicy.js +84 -0
- package/dist/core/startMissionProofPolicy.js.map +1 -0
- package/dist/core/startMissionRiskPolicy.d.ts +4 -0
- package/dist/core/startMissionRiskPolicy.js +85 -0
- package/dist/core/startMissionRiskPolicy.js.map +1 -0
- package/dist/core/startMissionRoutingPolicy.d.ts +6 -0
- package/dist/core/startMissionRoutingPolicy.js +67 -0
- package/dist/core/startMissionRoutingPolicy.js.map +1 -0
- package/dist/core/startMode.d.ts +1 -2
- package/dist/core/startMode.js +4 -151
- package/dist/core/startMode.js.map +1 -1
- package/dist/core/startModeIntentPolicy.d.ts +12 -0
- package/dist/core/startModeIntentPolicy.js +41 -0
- package/dist/core/startModeIntentPolicy.js.map +1 -0
- package/dist/core/startModeRoutingPolicy.d.ts +4 -0
- package/dist/core/startModeRoutingPolicy.js +117 -0
- package/dist/core/startModeRoutingPolicy.js.map +1 -0
- package/dist/core/startRouteActions.js +5 -0
- package/dist/core/startRouteActions.js.map +1 -1
- package/dist/core/startSearchQueryTargets.d.ts +1 -0
- package/dist/core/startSearchQueryTargets.js +17 -0
- package/dist/core/startSearchQueryTargets.js.map +1 -0
- package/dist/core/workplan.d.ts +3 -2
- package/dist/core/workplan.js +11 -585
- package/dist/core/workplan.js.map +1 -1
- package/dist/core/workplanCoordinationTasks.d.ts +3 -0
- package/dist/core/workplanCoordinationTasks.js +82 -0
- package/dist/core/workplanCoordinationTasks.js.map +1 -0
- package/dist/core/workplanModeTasks.d.ts +2 -0
- package/dist/core/workplanModeTasks.js +192 -0
- package/dist/core/workplanModeTasks.js.map +1 -0
- package/dist/core/workplanPreflightTasks.d.ts +2 -0
- package/dist/core/workplanPreflightTasks.js +126 -0
- package/dist/core/workplanPreflightTasks.js.map +1 -0
- package/dist/core/workplanQualitySignals.d.ts +7 -0
- package/dist/core/workplanQualitySignals.js +63 -0
- package/dist/core/workplanQualitySignals.js.map +1 -0
- package/dist/core/workplanReport.d.ts +4 -0
- package/dist/core/workplanReport.js +79 -0
- package/dist/core/workplanReport.js.map +1 -0
- package/dist/core/workplanRiskOwnership.d.ts +5 -0
- package/dist/core/workplanRiskOwnership.js +97 -0
- package/dist/core/workplanRiskOwnership.js.map +1 -0
- package/dist/core/workplanSuggestedActions.d.ts +2 -0
- package/dist/core/workplanSuggestedActions.js +43 -0
- package/dist/core/workplanSuggestedActions.js.map +1 -0
- package/dist/mcp/tools/prove.js +24 -18
- package/dist/mcp/tools/prove.js.map +1 -1
- package/dist/projscan-sbom.cdx.json +6 -6
- package/dist/tool-manifest.json +3 -3
- package/dist/types/config.d.ts +15 -0
- package/dist/types/evidencePack.d.ts +21 -0
- package/dist/types/proofLedger.d.ts +1 -1
- package/dist/types/prove.d.ts +96 -1
- package/dist/utils/changedFiles.js +57 -16
- package/dist/utils/changedFiles.js.map +1 -1
- package/dist/utils/config.js +2 -0
- package/dist/utils/config.js.map +1 -1
- package/dist/utils/configProofRecipes.d.ts +2 -0
- package/dist/utils/configProofRecipes.js +91 -0
- package/dist/utils/configProofRecipes.js.map +1 -0
- package/docs/GUIDE.md +145 -25
- package/package.json +1 -1
package/dist/core/prove.js
CHANGED
|
@@ -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,
|
|
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 = [
|
|
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,
|
|
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({
|
|
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
|
|
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
|
|
221
|
-
|
|
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
|
-
|
|
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
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
];
|
|
274
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
|
722
|
+
const latestLedgerByCommand = latestProofRecordsByCommand(ledger);
|
|
305
723
|
const commandEvidence = proofCommands.map((command) => {
|
|
306
|
-
const record =
|
|
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:
|
|
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
|
|
381
|
-
return
|
|
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:
|
|
408
|
-
allowedProduction:
|
|
409
|
-
expectedTest:
|
|
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 =
|
|
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 =
|
|
453
|
-
await
|
|
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 ||
|
|
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' ||
|
|
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
|
|
762
|
-
return (
|
|
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
|
-
|
|
806
|
-
|
|
807
|
-
|
|
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)];
|