@planu/cli 4.11.9 → 4.12.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +129 -22
- package/dist/engine/abortable-process-runner.d.ts +8 -0
- package/dist/engine/abortable-process-runner.js +107 -0
- package/dist/engine/cascade-hooks/core/append-releases.d.ts +1 -0
- package/dist/engine/cascade-hooks/core/append-releases.js +51 -14
- package/dist/engine/cascade-hooks/hooks/orchestration-plan.hook.js +16 -21
- package/dist/engine/ci-generator/yaml-builder.js +5 -1
- package/dist/engine/context-md-generator.js +3 -1
- package/dist/engine/criterion-identity.d.ts +6 -0
- package/dist/engine/criterion-identity.js +63 -0
- package/dist/engine/evidence-gates/lifecycle-gate.js +22 -44
- package/dist/engine/evidence-index/index-builder.js +7 -19
- package/dist/engine/frontmatter-parser.d.ts +5 -0
- package/dist/engine/frontmatter-parser.js +24 -0
- package/dist/engine/project-command-runtime.d.ts +5 -0
- package/dist/{tools/validate-runtime.js → engine/project-command-runtime.js} +8 -21
- package/dist/engine/project-graph/builder.js +1 -35
- package/dist/engine/project-graph/cache.js +37 -1
- package/dist/engine/qa-gate.d.ts +4 -1
- package/dist/engine/qa-gate.js +72 -59
- package/dist/engine/safety/cross-process-lock.d.ts +1 -6
- package/dist/engine/safety/cross-process-lock.js +18 -16
- package/dist/engine/safety/lock-scavenger.d.ts +9 -0
- package/dist/engine/safety/lock-scavenger.js +114 -0
- package/dist/engine/session-context-generator.js +24 -2
- package/dist/engine/session-safeguard/learnings-buffer.js +7 -2
- package/dist/engine/session-state/writer.js +15 -8
- package/dist/engine/skill-registry/installer.js +1 -1
- package/dist/engine/spec-format/acceptance-criteria.js +8 -26
- package/dist/engine/spec-grounding/contract.d.ts +3 -1
- package/dist/engine/spec-grounding/contract.js +39 -14
- package/dist/engine/universal-rules/installer.js +2 -2
- package/dist/engine/validation-evidence-digest.d.ts +4 -0
- package/dist/engine/validation-evidence-digest.js +83 -0
- package/dist/engine/validation-evidence-ledger.d.ts +30 -0
- package/dist/engine/validation-evidence-ledger.js +274 -0
- package/dist/engine/validation-impact-planner.d.ts +4 -0
- package/dist/engine/validation-impact-planner.js +71 -0
- package/dist/engine/validator/spec-compliance-runner.d.ts +1 -1
- package/dist/engine/validator/spec-compliance-runner.js +37 -40
- package/dist/engine/validator/validation-report-writer.d.ts +1 -0
- package/dist/engine/validator/validation-report-writer.js +1 -1
- package/dist/engine/validator.js +7 -11
- package/dist/storage/convention-baseline.js +5 -0
- package/dist/storage/session-state-store.js +13 -6
- package/dist/storage/spec-store.d.ts +5 -0
- package/dist/storage/spec-store.js +7 -0
- package/dist/tools/git/git-helpers.d.ts +5 -1
- package/dist/tools/git/git-helpers.js +3 -2
- package/dist/tools/init-project/conventions-writer.js +1 -1
- package/dist/tools/init-project/scaffold-writer.js +1 -1
- package/dist/tools/safe-handler.d.ts +5 -1
- package/dist/tools/safe-handler.js +73 -12
- package/dist/tools/status-handler.d.ts +1 -1
- package/dist/tools/status-handler.js +27 -66
- package/dist/tools/update-status/dod-gates.js +7 -31
- package/dist/tools/update-status/evidence-gate.js +2 -4
- package/dist/tools/update-status/force-done-audit.d.ts +2 -0
- package/dist/tools/update-status/force-done-audit.js +23 -0
- package/dist/tools/update-status/qa-gate.d.ts +1 -0
- package/dist/tools/update-status/qa-gate.js +7 -51
- package/dist/tools/update-status-convention-gate.js +132 -97
- package/dist/tools/validate-lint.d.ts +1 -1
- package/dist/tools/validate-lint.js +78 -165
- package/dist/tools/validate.js +3 -1
- package/dist/transports/http-transport.js +22 -8
- package/dist/transports/mcp-types.d.ts +2 -2
- package/dist/transports/transport-factory.js +110 -12
- package/dist/types/abortable-process.d.ts +26 -0
- package/dist/types/abortable-process.js +2 -0
- package/dist/types/criterion-identity.d.ts +12 -0
- package/dist/types/criterion-identity.js +2 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.js +2 -0
- package/dist/types/qa-gate.d.ts +0 -5
- package/dist/types/spec-format.d.ts +2 -0
- package/dist/types/spec-lock-v2.d.ts +7 -0
- package/dist/types/validation-evidence.d.ts +62 -0
- package/dist/types/validation-evidence.js +2 -0
- package/package.json +43 -23
- package/planu-native.json +1 -1
- package/planu-plugin.json +1 -1
- package/dist/storage/qa-gate-store.d.ts +0 -4
- package/dist/storage/qa-gate-store.js +0 -24
- package/dist/tools/validate-runtime.d.ts +0 -14
- package/dist/types/data/estimation.d.ts +0 -147
- package/dist/types/data/estimation.js +0 -2
- package/dist/types/data/index.d.ts +0 -5
- package/dist/types/data/index.js +0 -6
- package/dist/types/data/velocity.d.ts +0 -168
- package/dist/types/data/velocity.js +0 -4
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
// tools/update-status-convention-gate.ts — SPEC-190
|
|
2
2
|
// Convention and constitution compliance gates for update_status transitions.
|
|
3
3
|
import { execFile as execFileCb } from 'node:child_process';
|
|
4
|
-
import { createHash } from 'node:crypto';
|
|
5
4
|
import { readFile } from 'node:fs/promises';
|
|
6
5
|
import { join } from 'node:path';
|
|
7
6
|
import { promisify } from 'node:util';
|
|
8
7
|
import { knowledgeStore } from '../storage/index.js';
|
|
9
8
|
import { checkConstitutionOnTransition } from './update-status-actions.js';
|
|
9
|
+
import { computeValidationEvidenceIdentity, computeValidationEvidenceKey, readMatchingValidationEvidence, recordSuccessfulValidationEvidence, recordValidationOutcome, } from '../engine/validation-evidence-ledger.js';
|
|
10
|
+
import { getToolExecutionSignal } from './safe-handler.js';
|
|
11
|
+
import { VALIDATION_COMMANDS } from '../engine/validation-impact-planner.js';
|
|
10
12
|
const execFile = promisify(execFileCb);
|
|
11
|
-
const MAX_PROCESS_VALIDATION_AGE_MS = 30 * 60 * 1_000;
|
|
12
13
|
const MAX_GATE_OUTPUT_BUFFER_BYTES = 64 * 1024 * 1024;
|
|
13
|
-
const processValidationReceipts = new Map();
|
|
14
14
|
// ---------------------------------------------------------------------------
|
|
15
15
|
// Helpers
|
|
16
16
|
// ---------------------------------------------------------------------------
|
|
@@ -26,6 +26,7 @@ async function execGate(cmd, cwd, timeoutMs) {
|
|
|
26
26
|
cwd,
|
|
27
27
|
timeout: timeoutMs,
|
|
28
28
|
maxBuffer: MAX_GATE_OUTPUT_BUFFER_BYTES,
|
|
29
|
+
signal: getToolExecutionSignal(),
|
|
29
30
|
});
|
|
30
31
|
return { output: mergeCommandOutput(stdout, stderr), ok: true, timedOut: false };
|
|
31
32
|
}
|
|
@@ -67,64 +68,164 @@ function countLintIssues(output) {
|
|
|
67
68
|
// ---------------------------------------------------------------------------
|
|
68
69
|
// Individual gates
|
|
69
70
|
// ---------------------------------------------------------------------------
|
|
70
|
-
async function checkCompileGate(projectPath,
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
71
|
+
async function checkCompileGate(projectPath, projectId) {
|
|
72
|
+
const input = {
|
|
73
|
+
projectPath,
|
|
74
|
+
capability: 'typecheck',
|
|
75
|
+
command: VALIDATION_COMMANDS.typecheck,
|
|
76
|
+
scope: ['src', 'tests'],
|
|
77
|
+
};
|
|
78
|
+
if (readMatchingValidationEvidence(projectId, input)) {
|
|
79
|
+
return [];
|
|
75
80
|
}
|
|
76
|
-
const
|
|
81
|
+
const expectedKey = computeValidationEvidenceKey(computeValidationEvidenceIdentity(input));
|
|
82
|
+
const { output, ok, timedOut, errorMessage } = await execGate(VALIDATION_COMMANDS.typecheck, projectPath, 120_000);
|
|
77
83
|
if (ok) {
|
|
84
|
+
recordSuccessfulValidationEvidence({
|
|
85
|
+
projectId,
|
|
86
|
+
input,
|
|
87
|
+
expectedKey,
|
|
88
|
+
producer: 'update-status-compliance',
|
|
89
|
+
durationMs: 0,
|
|
90
|
+
output,
|
|
91
|
+
reason: 'Typecheck passed during compliance validation.',
|
|
92
|
+
});
|
|
78
93
|
return [];
|
|
79
94
|
}
|
|
95
|
+
recordValidationOutcome({
|
|
96
|
+
projectId,
|
|
97
|
+
input,
|
|
98
|
+
action: timedOut || getToolExecutionSignal()?.aborted === true ? 'aborted' : 'executed',
|
|
99
|
+
producer: 'update-status-compliance',
|
|
100
|
+
status: timedOut || getToolExecutionSignal()?.aborted === true ? 'cancelled' : 'failed',
|
|
101
|
+
durationMs: 0,
|
|
102
|
+
output,
|
|
103
|
+
reason: 'Typecheck did not pass during compliance validation.',
|
|
104
|
+
source: 'local:update-status',
|
|
105
|
+
});
|
|
80
106
|
const lines = output.split('\n').filter((l) => l.includes('error TS'));
|
|
81
107
|
if (lines.length === 0) {
|
|
82
|
-
|
|
108
|
+
const reason = timedOut ? 'timed out or was aborted' : (errorMessage ?? 'command failed');
|
|
109
|
+
return [`TypeScript compile: ${reason}. Command: ${VALIDATION_COMMANDS.typecheck}`];
|
|
83
110
|
}
|
|
84
111
|
const preview = lines.slice(0, 3).join('; ');
|
|
85
112
|
return [`TypeScript compile: ${lines.length} error(s). ${preview}`];
|
|
86
113
|
}
|
|
87
|
-
async function checkLintGate(projectPath, lintCmd) {
|
|
114
|
+
async function checkLintGate(projectPath, lintCmd, projectId) {
|
|
115
|
+
const cmd = lintCmd ?? VALIDATION_COMMANDS.lint;
|
|
116
|
+
const input = { projectPath, capability: 'lint', command: cmd, scope: ['src', 'tests'] };
|
|
88
117
|
if (lintCmd !== null && !isSafeCommand(lintCmd)) {
|
|
89
|
-
console.warn(
|
|
118
|
+
console.warn('[Planu] convention-gate: lintCommand contains unsafe characters — skipping');
|
|
119
|
+
recordValidationOutcome({
|
|
120
|
+
projectId,
|
|
121
|
+
input,
|
|
122
|
+
action: 'skipped',
|
|
123
|
+
producer: 'update-status-compliance',
|
|
124
|
+
status: 'failed',
|
|
125
|
+
durationMs: 0,
|
|
126
|
+
output: 'Unsafe configured lint command was blocked.',
|
|
127
|
+
reason: 'Configured lint command failed the command allowlist.',
|
|
128
|
+
source: 'local:update-status',
|
|
129
|
+
});
|
|
130
|
+
return ['Lint: unsafe configured command was blocked. Fix the project lintCommand.'];
|
|
131
|
+
}
|
|
132
|
+
if (readMatchingValidationEvidence(projectId, input)) {
|
|
90
133
|
return [];
|
|
91
134
|
}
|
|
92
|
-
const
|
|
93
|
-
const { output, ok } = await execGate(cmd, projectPath, 10_000);
|
|
135
|
+
const expectedKey = computeValidationEvidenceKey(computeValidationEvidenceIdentity(input));
|
|
136
|
+
const { output, ok, timedOut } = await execGate(cmd, projectPath, 10_000);
|
|
94
137
|
if (ok) {
|
|
138
|
+
recordSuccessfulValidationEvidence({
|
|
139
|
+
projectId,
|
|
140
|
+
input,
|
|
141
|
+
expectedKey,
|
|
142
|
+
producer: 'update-status-compliance',
|
|
143
|
+
durationMs: 0,
|
|
144
|
+
output,
|
|
145
|
+
reason: 'Lint passed during compliance validation.',
|
|
146
|
+
});
|
|
95
147
|
return [];
|
|
96
148
|
}
|
|
149
|
+
recordValidationOutcome({
|
|
150
|
+
projectId,
|
|
151
|
+
input,
|
|
152
|
+
action: timedOut || getToolExecutionSignal()?.aborted === true ? 'aborted' : 'executed',
|
|
153
|
+
producer: 'update-status-compliance',
|
|
154
|
+
status: timedOut || getToolExecutionSignal()?.aborted === true ? 'cancelled' : 'failed',
|
|
155
|
+
durationMs: 0,
|
|
156
|
+
output,
|
|
157
|
+
reason: 'Lint did not pass during compliance validation.',
|
|
158
|
+
source: 'local:update-status',
|
|
159
|
+
});
|
|
97
160
|
const issueCount = countLintIssues(output);
|
|
98
161
|
if (issueCount === 0) {
|
|
99
|
-
return [];
|
|
162
|
+
return [`Lint: command failed without diagnostics. Command: ${cmd}`];
|
|
100
163
|
}
|
|
101
164
|
const preview = output.split('\n').find((l) => l.trim().length > 0) ?? '';
|
|
102
165
|
return [`Lint: ${issueCount} issue(s). ${preview}`];
|
|
103
166
|
}
|
|
104
|
-
async function checkTestGate(projectPath, testCmd,
|
|
167
|
+
async function checkTestGate(projectPath, testCmd, context) {
|
|
105
168
|
if (testCmd !== null && !isSafeCommand(testCmd)) {
|
|
106
|
-
console.warn(
|
|
107
|
-
|
|
169
|
+
console.warn('[Planu] convention-gate: testCommand contains unsafe characters — skipping');
|
|
170
|
+
recordValidationOutcome({
|
|
171
|
+
projectId: context.projectId,
|
|
172
|
+
input: {
|
|
173
|
+
projectPath,
|
|
174
|
+
capability: 'test-full',
|
|
175
|
+
command: testCmd,
|
|
176
|
+
scope: ['src', 'tests'],
|
|
177
|
+
},
|
|
178
|
+
action: 'skipped',
|
|
179
|
+
producer: 'update-status-compliance',
|
|
180
|
+
status: 'failed',
|
|
181
|
+
durationMs: 0,
|
|
182
|
+
output: 'Unsafe configured test command was blocked.',
|
|
183
|
+
reason: 'Configured test command failed the command allowlist.',
|
|
184
|
+
source: 'local:update-status',
|
|
185
|
+
});
|
|
186
|
+
return ['Tests: unsafe configured command was blocked. Fix the project testCommand.'];
|
|
108
187
|
}
|
|
109
188
|
const cmd = testCmd ?? (await resolveCanonicalTestCommand(projectPath));
|
|
110
|
-
const
|
|
111
|
-
|
|
189
|
+
const input = {
|
|
190
|
+
projectPath,
|
|
191
|
+
capability: 'test-full',
|
|
192
|
+
command: cmd,
|
|
193
|
+
scope: ['src', 'tests'],
|
|
194
|
+
};
|
|
195
|
+
const expectedKey = computeValidationEvidenceKey(computeValidationEvidenceIdentity(input));
|
|
196
|
+
if (readMatchingValidationEvidence(context.projectId, input)) {
|
|
112
197
|
return [];
|
|
113
198
|
}
|
|
114
199
|
// Run tests with a reasonable timeout — only a warning if they fail
|
|
115
200
|
const timeoutMs = 120_000;
|
|
116
201
|
const { output, ok, timedOut, errorMessage, errorCode } = await execGate(cmd, projectPath, timeoutMs);
|
|
117
202
|
if (ok) {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
203
|
+
recordSuccessfulValidationEvidence({
|
|
204
|
+
projectId: context.projectId,
|
|
205
|
+
input,
|
|
206
|
+
expectedKey,
|
|
207
|
+
producer: 'update-status-compliance',
|
|
208
|
+
durationMs: 0,
|
|
209
|
+
output,
|
|
210
|
+
reason: context.specId
|
|
211
|
+
? `Full tests passed while closing ${context.specId}.`
|
|
212
|
+
: 'Full tests passed during compliance validation.',
|
|
213
|
+
});
|
|
126
214
|
return [];
|
|
127
215
|
}
|
|
216
|
+
recordValidationOutcome({
|
|
217
|
+
projectId: context.projectId,
|
|
218
|
+
input,
|
|
219
|
+
action: timedOut || getToolExecutionSignal()?.aborted === true ? 'aborted' : 'executed',
|
|
220
|
+
producer: 'update-status-compliance',
|
|
221
|
+
status: timedOut || getToolExecutionSignal()?.aborted === true ? 'cancelled' : 'failed',
|
|
222
|
+
durationMs: 0,
|
|
223
|
+
output,
|
|
224
|
+
reason: context.specId
|
|
225
|
+
? `Tests did not pass while closing ${context.specId}.`
|
|
226
|
+
: 'Tests did not pass during compliance validation.',
|
|
227
|
+
source: 'local:update-status',
|
|
228
|
+
});
|
|
128
229
|
if (timedOut) {
|
|
129
230
|
return [`Tests: timed out after ${String(timeoutMs)}ms. Command: ${cmd}`];
|
|
130
231
|
}
|
|
@@ -146,70 +247,6 @@ async function checkTestGate(projectPath, testCmd, _context) {
|
|
|
146
247
|
'No failure summary available.';
|
|
147
248
|
return [`Tests: failed. ${summary.trim()}`];
|
|
148
249
|
}
|
|
149
|
-
function hasFreshProcessValidation(projectPath, command, repositoryFingerprint) {
|
|
150
|
-
const receipt = processValidationReceipts.get(projectPath);
|
|
151
|
-
if (!receipt) {
|
|
152
|
-
return false;
|
|
153
|
-
}
|
|
154
|
-
const ageMs = Date.now() - receipt.completedAtMs;
|
|
155
|
-
return (receipt.command === command &&
|
|
156
|
-
receipt.repositoryFingerprint === repositoryFingerprint &&
|
|
157
|
-
ageMs >= 0 &&
|
|
158
|
-
ageMs <= MAX_PROCESS_VALIDATION_AGE_MS);
|
|
159
|
-
}
|
|
160
|
-
async function readGitOutput(projectPath, args) {
|
|
161
|
-
return new Promise((resolveOutput) => {
|
|
162
|
-
execFileCb('git', args, { cwd: projectPath, timeout: 5_000, maxBuffer: MAX_GATE_OUTPUT_BUFFER_BYTES }, (error, stdout) => {
|
|
163
|
-
if (error) {
|
|
164
|
-
resolveOutput(null);
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
167
|
-
const output = Buffer.isBuffer(stdout) ? stdout.toString('utf-8') : stdout;
|
|
168
|
-
resolveOutput(output);
|
|
169
|
-
});
|
|
170
|
-
});
|
|
171
|
-
}
|
|
172
|
-
async function readRepositoryFingerprint(projectPath) {
|
|
173
|
-
const [headOutput, statusOutput, indexDiff, worktreeDiff, untrackedOutput] = await Promise.all([
|
|
174
|
-
readGitOutput(projectPath, ['rev-parse', 'HEAD']),
|
|
175
|
-
readGitOutput(projectPath, ['status', '--porcelain=v1', '-z', '--untracked-files=all']),
|
|
176
|
-
readGitOutput(projectPath, ['diff', '--cached', '--binary', '--no-ext-diff', 'HEAD', '--']),
|
|
177
|
-
readGitOutput(projectPath, ['diff', '--binary', '--no-ext-diff', '--']),
|
|
178
|
-
readGitOutput(projectPath, ['ls-files', '--others', '--exclude-standard', '-z']),
|
|
179
|
-
]);
|
|
180
|
-
const head = headOutput
|
|
181
|
-
?.split('\n')
|
|
182
|
-
.map((line) => line.trim())
|
|
183
|
-
.find((line) => /^[a-f0-9]{40,64}$/i.test(line));
|
|
184
|
-
if (!head ||
|
|
185
|
-
statusOutput === null ||
|
|
186
|
-
indexDiff === null ||
|
|
187
|
-
worktreeDiff === null ||
|
|
188
|
-
untrackedOutput === null) {
|
|
189
|
-
return null;
|
|
190
|
-
}
|
|
191
|
-
const untrackedPaths = untrackedOutput.split('\0').filter(Boolean).sort();
|
|
192
|
-
const untrackedHashes = await Promise.all(untrackedPaths.map((path) => readGitOutput(projectPath, ['hash-object', '--no-filters', '--', path])));
|
|
193
|
-
if (untrackedHashes.some((hash) => hash === null)) {
|
|
194
|
-
return null;
|
|
195
|
-
}
|
|
196
|
-
const hash = createHash('sha256')
|
|
197
|
-
.update(head)
|
|
198
|
-
.update('\0')
|
|
199
|
-
.update(statusOutput)
|
|
200
|
-
.update('\0')
|
|
201
|
-
.update(indexDiff)
|
|
202
|
-
.update('\0')
|
|
203
|
-
.update(worktreeDiff);
|
|
204
|
-
for (let index = 0; index < untrackedPaths.length; index += 1) {
|
|
205
|
-
hash
|
|
206
|
-
.update('\0')
|
|
207
|
-
.update(untrackedPaths[index] ?? '')
|
|
208
|
-
.update('\0')
|
|
209
|
-
.update(untrackedHashes[index] ?? '');
|
|
210
|
-
}
|
|
211
|
-
return hash.digest('hex');
|
|
212
|
-
}
|
|
213
250
|
async function resolveCanonicalTestCommand(projectPath) {
|
|
214
251
|
try {
|
|
215
252
|
const raw = await readFile(join(projectPath, 'package.json'), 'utf-8');
|
|
@@ -238,7 +275,7 @@ async function resolveCanonicalTestCommand(projectPath) {
|
|
|
238
275
|
}
|
|
239
276
|
/** Test-only reset to prevent process-cache state leaking between cases. */
|
|
240
277
|
export function resetProcessValidationCacheForTests() {
|
|
241
|
-
|
|
278
|
+
// Compatibility hook retained for callers; canonical receipts are immutable on disk.
|
|
242
279
|
}
|
|
243
280
|
function isInfrastructureError(errorCode, errorMessage) {
|
|
244
281
|
return (errorCode === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' ||
|
|
@@ -273,11 +310,9 @@ export async function runComplianceGates(projectId, specTitle, specTags, newStat
|
|
|
273
310
|
isApprovedOrDone
|
|
274
311
|
? checkConstitutionOnTransition(projectId, specTitle, specTags)
|
|
275
312
|
: Promise.resolve([]),
|
|
313
|
+
isDone && projectPath ? checkCompileGate(projectPath, projectId) : Promise.resolve([]),
|
|
276
314
|
isDone && projectPath
|
|
277
|
-
?
|
|
278
|
-
: Promise.resolve([]),
|
|
279
|
-
isDone && projectPath
|
|
280
|
-
? checkLintGate(projectPath, knowledge?.lintCommand ?? null)
|
|
315
|
+
? checkLintGate(projectPath, knowledge?.lintCommand ?? null, projectId)
|
|
281
316
|
: Promise.resolve([]),
|
|
282
317
|
isDone && projectPath
|
|
283
318
|
? checkTestGate(projectPath, knowledge?.testCommand ?? null, { projectId, specId })
|
|
@@ -3,6 +3,6 @@ interface LintCheckContext {
|
|
|
3
3
|
projectId?: string;
|
|
4
4
|
specId?: string;
|
|
5
5
|
}
|
|
6
|
-
export declare function runLintCheck(projectPath: string, lintCommand: string | null, context?: LintCheckContext): LintCheckResult
|
|
6
|
+
export declare function runLintCheck(projectPath: string, lintCommand: string | null, context?: LintCheckContext): Promise<LintCheckResult>;
|
|
7
7
|
export {};
|
|
8
8
|
//# sourceMappingURL=validate-lint.d.ts.map
|
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
// tools/validate-lint.ts — Lint gate for validate.
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { projectDataDir } from '../storage/base-store.js';
|
|
7
|
-
import { formatLintFailureDiagnostics, resolveProjectCommandPlan, runProjectCommandPlan, } from './validate-runtime.js';
|
|
2
|
+
import { computeValidationEvidenceIdentity, computeValidationEvidenceKey, readMatchingValidationEvidence, recordSuccessfulValidationEvidence, recordValidationOutcome, } from '../engine/validation-evidence-ledger.js';
|
|
3
|
+
import { formatLintFailureDiagnostics, resolveProjectCommandPlan, runProjectCommandPlan, } from '../engine/project-command-runtime.js';
|
|
4
|
+
import { getToolExecutionSignal } from './safe-handler.js';
|
|
5
|
+
import { VALIDATION_COMMANDS } from '../engine/validation-impact-planner.js';
|
|
8
6
|
/** Allowlist: alphanumerics, spaces, and safe shell chars. Rejects ; | $ ` & < > */
|
|
9
7
|
const SAFE_COMMAND_RE = /^[\w\s./:@~=-]+$/;
|
|
10
8
|
const DEFAULT_LINT_CHECK_TIMEOUT_MS = 60_000;
|
|
11
|
-
const LINT_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
12
9
|
function isSafeCommand(cmd) {
|
|
13
10
|
return SAFE_COMMAND_RE.test(cmd);
|
|
14
11
|
}
|
|
@@ -40,31 +37,74 @@ function parseLintIssueCount(output) {
|
|
|
40
37
|
}
|
|
41
38
|
return output.split('\n').filter((l) => l.trim().length > 0).length;
|
|
42
39
|
}
|
|
43
|
-
export function runLintCheck(projectPath, lintCommand, context = {}) {
|
|
40
|
+
export async function runLintCheck(projectPath, lintCommand, context = {}) {
|
|
41
|
+
const command = lintCommand ?? VALIDATION_COMMANDS.lint;
|
|
42
|
+
const evidenceInput = {
|
|
43
|
+
projectPath,
|
|
44
|
+
capability: 'lint',
|
|
45
|
+
command,
|
|
46
|
+
scope: ['src', 'tests'],
|
|
47
|
+
};
|
|
44
48
|
if (lintCommand !== null && !isSafeCommand(lintCommand)) {
|
|
45
49
|
console.warn(`[Planu] validate: lintCommand contains unsafe characters — skipping execution`);
|
|
50
|
+
if (context.projectId) {
|
|
51
|
+
recordValidationOutcome({
|
|
52
|
+
projectId: context.projectId,
|
|
53
|
+
input: evidenceInput,
|
|
54
|
+
action: 'skipped',
|
|
55
|
+
producer: 'planu-validate',
|
|
56
|
+
status: 'failed',
|
|
57
|
+
durationMs: 0,
|
|
58
|
+
output: 'Unsafe configured lint command was blocked.',
|
|
59
|
+
reason: 'Configured lint command failed the command allowlist.',
|
|
60
|
+
source: 'local:planu-validate',
|
|
61
|
+
});
|
|
62
|
+
}
|
|
46
63
|
return {
|
|
47
|
-
passed:
|
|
64
|
+
passed: false,
|
|
48
65
|
command: lintCommand,
|
|
49
|
-
issueCount:
|
|
50
|
-
output: '(
|
|
66
|
+
issueCount: 1,
|
|
67
|
+
output: '(blocked — unsafe lint command configuration)',
|
|
51
68
|
};
|
|
52
69
|
}
|
|
53
|
-
const command = lintCommand ?? 'pnpm lint';
|
|
54
|
-
const externalEvidence = readExternalPassedLintEvidence(context, command);
|
|
55
|
-
if (externalEvidence !== null) {
|
|
56
|
-
return externalEvidence;
|
|
57
|
-
}
|
|
58
70
|
const plan = resolveProjectCommandPlan(projectPath, command);
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
71
|
+
const expectedKey = computeValidationEvidenceKey(computeValidationEvidenceIdentity(evidenceInput));
|
|
72
|
+
if (context.projectId) {
|
|
73
|
+
const receipt = readMatchingValidationEvidence(context.projectId, evidenceInput);
|
|
74
|
+
if (receipt) {
|
|
75
|
+
return {
|
|
76
|
+
passed: true,
|
|
77
|
+
command,
|
|
78
|
+
issueCount: 0,
|
|
79
|
+
output: `(reused canonical validation receipt ${receipt.key})`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
63
82
|
}
|
|
64
83
|
const timeoutMs = lintCheckTimeoutMs();
|
|
65
84
|
try {
|
|
66
|
-
|
|
67
|
-
|
|
85
|
+
const startedAt = Date.now();
|
|
86
|
+
const processResult = await runProjectCommandPlan(plan, projectPath, timeoutMs, getToolExecutionSignal());
|
|
87
|
+
if (processResult.error || processResult.status !== 0) {
|
|
88
|
+
throw Object.assign(processResult.error ?? new Error('Lint command failed'), {
|
|
89
|
+
stdout: processResult.stdout,
|
|
90
|
+
stderr: processResult.stderr,
|
|
91
|
+
killed: processResult.timedOut || processResult.aborted,
|
|
92
|
+
signal: processResult.signal,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
if (context.projectId) {
|
|
96
|
+
recordSuccessfulValidationEvidence({
|
|
97
|
+
projectId: context.projectId,
|
|
98
|
+
input: evidenceInput,
|
|
99
|
+
expectedKey,
|
|
100
|
+
producer: 'planu-validate',
|
|
101
|
+
durationMs: Date.now() - startedAt,
|
|
102
|
+
output: processResult.stdout,
|
|
103
|
+
reason: context.specId
|
|
104
|
+
? `Lint passed while validating ${context.specId}.`
|
|
105
|
+
: 'Lint passed during canonical validation.',
|
|
106
|
+
});
|
|
107
|
+
}
|
|
68
108
|
return { passed: true, command, issueCount: 0, output: '' };
|
|
69
109
|
}
|
|
70
110
|
catch (err) {
|
|
@@ -77,43 +117,25 @@ export function runLintCheck(projectPath, lintCommand, context = {}) {
|
|
|
77
117
|
}
|
|
78
118
|
const raw = commandOutput(err);
|
|
79
119
|
const output = formatLintFailureDiagnostics(plan, raw);
|
|
80
|
-
const issueCount = parseLintIssueCount(raw.trim());
|
|
120
|
+
const issueCount = Math.max(1, parseLintIssueCount(raw.trim()));
|
|
81
121
|
console.warn(`[Planu] lintCheck failed: command="${plan.executedCommand}" cwd="${projectPath}" issues=${String(issueCount)}`);
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (rows.length === 0 ||
|
|
97
|
-
!rows.every((row) => isPassedFullValidationEvidence(row.validationEvidence))) {
|
|
98
|
-
return null;
|
|
122
|
+
if (context.projectId) {
|
|
123
|
+
recordValidationOutcome({
|
|
124
|
+
projectId: context.projectId,
|
|
125
|
+
input: evidenceInput,
|
|
126
|
+
action: isCommandTimeout(err) ? 'aborted' : 'executed',
|
|
127
|
+
producer: 'planu-validate',
|
|
128
|
+
status: isCommandTimeout(err) ? 'cancelled' : 'failed',
|
|
129
|
+
durationMs: timeoutMs,
|
|
130
|
+
output,
|
|
131
|
+
reason: context.specId
|
|
132
|
+
? `Lint did not pass while validating ${context.specId}.`
|
|
133
|
+
: 'Lint did not pass during canonical validation.',
|
|
134
|
+
source: 'local:planu-validate',
|
|
135
|
+
});
|
|
99
136
|
}
|
|
100
|
-
return {
|
|
101
|
-
passed: true,
|
|
102
|
-
command,
|
|
103
|
-
issueCount: 0,
|
|
104
|
-
output: `(reused external validation evidence from handoffs/${context.specId}/traceability-matrix.json)`,
|
|
105
|
-
};
|
|
106
|
-
}
|
|
107
|
-
catch {
|
|
108
|
-
return null;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
function isPassedFullValidationEvidence(value) {
|
|
112
|
-
if (typeof value !== 'string') {
|
|
113
|
-
return false;
|
|
137
|
+
return { passed: false, command, issueCount, output };
|
|
114
138
|
}
|
|
115
|
-
return (/\bpnpm\s+validate\b[\s\S]{0,160}\bpass(?:ed|ing)?\b/i.test(value) ||
|
|
116
|
-
/\bpass(?:ed|ing)?\b[\s\S]{0,160}\bpnpm\s+validate\b/i.test(value));
|
|
117
139
|
}
|
|
118
140
|
function lintCheckTimeoutMs() {
|
|
119
141
|
const raw = process.env.PLANU_VALIDATE_LINT_TIMEOUT_MS;
|
|
@@ -126,113 +148,4 @@ function lintCheckTimeoutMs() {
|
|
|
126
148
|
}
|
|
127
149
|
return Math.min(300_000, Math.max(1_000, parsed));
|
|
128
150
|
}
|
|
129
|
-
function lintCachePath(projectPath) {
|
|
130
|
-
return join(projectPath, 'planu', 'state', 'lint-evidence.json');
|
|
131
|
-
}
|
|
132
|
-
function readPassedLintCache(projectPath, command, fingerprint) {
|
|
133
|
-
try {
|
|
134
|
-
const path = lintCachePath(projectPath);
|
|
135
|
-
if (!existsSync(path)) {
|
|
136
|
-
return null;
|
|
137
|
-
}
|
|
138
|
-
const cache = JSON.parse(readFileSync(path, 'utf-8'));
|
|
139
|
-
if (cache.schemaVersion !== '1.0.0' ||
|
|
140
|
-
cache.command !== command ||
|
|
141
|
-
cache.fingerprint !== fingerprint ||
|
|
142
|
-
typeof cache.checkedAt !== 'string') {
|
|
143
|
-
return null;
|
|
144
|
-
}
|
|
145
|
-
const ageMs = Date.now() - Date.parse(cache.checkedAt);
|
|
146
|
-
if (!Number.isFinite(ageMs) || ageMs < 0 || ageMs > LINT_CACHE_MAX_AGE_MS) {
|
|
147
|
-
return null;
|
|
148
|
-
}
|
|
149
|
-
return {
|
|
150
|
-
passed: true,
|
|
151
|
-
command,
|
|
152
|
-
issueCount: 0,
|
|
153
|
-
output: `(reused local lint evidence from ${cache.checkedAt})`,
|
|
154
|
-
};
|
|
155
|
-
}
|
|
156
|
-
catch {
|
|
157
|
-
return null;
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
function writePassedLintCache(projectPath, command, fingerprint) {
|
|
161
|
-
try {
|
|
162
|
-
const path = lintCachePath(projectPath);
|
|
163
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
164
|
-
const cache = {
|
|
165
|
-
schemaVersion: '1.0.0',
|
|
166
|
-
command,
|
|
167
|
-
fingerprint,
|
|
168
|
-
checkedAt: new Date().toISOString(),
|
|
169
|
-
};
|
|
170
|
-
writeFileSync(path, `${JSON.stringify(cache, null, 2)}\n`, 'utf-8');
|
|
171
|
-
}
|
|
172
|
-
catch {
|
|
173
|
-
// Cache is a performance optimization only; lint result remains authoritative.
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
function calculateLintFingerprint(projectPath) {
|
|
177
|
-
const hash = createHash('sha256');
|
|
178
|
-
hash.update(gitOutput(projectPath, ['rev-parse', 'HEAD']));
|
|
179
|
-
hash.update('\0');
|
|
180
|
-
hash.update(gitOutput(projectPath, [
|
|
181
|
-
'diff',
|
|
182
|
-
'--binary',
|
|
183
|
-
'HEAD',
|
|
184
|
-
'--',
|
|
185
|
-
'src',
|
|
186
|
-
'tests',
|
|
187
|
-
'package.json',
|
|
188
|
-
'pnpm-lock.yaml',
|
|
189
|
-
'eslint.config.js',
|
|
190
|
-
'eslint.config.mjs',
|
|
191
|
-
'eslint.config.cjs',
|
|
192
|
-
'tsconfig.json',
|
|
193
|
-
'tsconfig.build.json',
|
|
194
|
-
]));
|
|
195
|
-
hash.update('\0');
|
|
196
|
-
const untracked = gitOutput(projectPath, [
|
|
197
|
-
'ls-files',
|
|
198
|
-
'--others',
|
|
199
|
-
'--exclude-standard',
|
|
200
|
-
'--',
|
|
201
|
-
'src',
|
|
202
|
-
'tests',
|
|
203
|
-
])
|
|
204
|
-
.split('\n')
|
|
205
|
-
.map((line) => line.trim())
|
|
206
|
-
.filter(Boolean)
|
|
207
|
-
.sort();
|
|
208
|
-
for (const file of untracked) {
|
|
209
|
-
hash.update(file);
|
|
210
|
-
hash.update('\0');
|
|
211
|
-
try {
|
|
212
|
-
hash.update(readFileSync(join(projectPath, file)));
|
|
213
|
-
}
|
|
214
|
-
catch {
|
|
215
|
-
hash.update('unreadable');
|
|
216
|
-
}
|
|
217
|
-
hash.update('\0');
|
|
218
|
-
}
|
|
219
|
-
return hash.digest('hex');
|
|
220
|
-
}
|
|
221
|
-
function gitOutput(projectPath, args) {
|
|
222
|
-
try {
|
|
223
|
-
const output = execFileSync('git', args, {
|
|
224
|
-
cwd: projectPath,
|
|
225
|
-
encoding: 'utf-8',
|
|
226
|
-
timeout: 5_000,
|
|
227
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
228
|
-
});
|
|
229
|
-
if (Buffer.isBuffer(output)) {
|
|
230
|
-
return output.toString('utf-8');
|
|
231
|
-
}
|
|
232
|
-
return typeof output === 'string' ? output : String(output);
|
|
233
|
-
}
|
|
234
|
-
catch {
|
|
235
|
-
return '';
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
151
|
//# sourceMappingURL=validate-lint.js.map
|
package/dist/tools/validate.js
CHANGED
|
@@ -21,6 +21,7 @@ import { buildValidateFailureFallback, validateStrictLayoutOrError } from './val
|
|
|
21
21
|
import { runLintCheck } from './validate-lint.js';
|
|
22
22
|
import { checkPlanuUncommittedChanges, runAssuranceGates } from './validate-assurance.js';
|
|
23
23
|
import { applyValidateStructuredCompat } from './validate-structured-compat.js';
|
|
24
|
+
import { getToolExecutionSignal } from './safe-handler.js';
|
|
24
25
|
// Re-export for external use (SPEC-018)
|
|
25
26
|
export { validateContractCompliance };
|
|
26
27
|
// SPEC-595: server? param enables elicitation on failures
|
|
@@ -74,7 +75,7 @@ export async function handleValidate(args, server) {
|
|
|
74
75
|
const implementationQualityScore = calcQualityScore(result.qualityIssues);
|
|
75
76
|
const auditedFiles = [...new Set(result.qualityIssues.map((i) => i.file))];
|
|
76
77
|
const { conventionViolations, regressionDetected } = await scanProjectConventions(projectId, projectPath);
|
|
77
|
-
const lintCheck = runLintCheck(projectPath, knowledge.lintCommand ?? null, {
|
|
78
|
+
const lintCheck = await runLintCheck(projectPath, knowledge.lintCommand ?? null, {
|
|
78
79
|
projectId,
|
|
79
80
|
specId,
|
|
80
81
|
});
|
|
@@ -98,6 +99,7 @@ export async function handleValidate(args, server) {
|
|
|
98
99
|
lintPassed: lintCheck.passed,
|
|
99
100
|
conventionRegression: regressionDetected,
|
|
100
101
|
minimalityReport: minimalityReport ?? undefined,
|
|
102
|
+
signal: getToolExecutionSignal(),
|
|
101
103
|
});
|
|
102
104
|
const effectiveResult = buildEffectiveValidationResult(result, validationReport.specCompliance);
|
|
103
105
|
const qualityGateSummary = buildQualityGateSummary({
|