@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.
Files changed (91) hide show
  1. package/CHANGELOG.md +129 -22
  2. package/dist/engine/abortable-process-runner.d.ts +8 -0
  3. package/dist/engine/abortable-process-runner.js +107 -0
  4. package/dist/engine/cascade-hooks/core/append-releases.d.ts +1 -0
  5. package/dist/engine/cascade-hooks/core/append-releases.js +51 -14
  6. package/dist/engine/cascade-hooks/hooks/orchestration-plan.hook.js +16 -21
  7. package/dist/engine/ci-generator/yaml-builder.js +5 -1
  8. package/dist/engine/context-md-generator.js +3 -1
  9. package/dist/engine/criterion-identity.d.ts +6 -0
  10. package/dist/engine/criterion-identity.js +63 -0
  11. package/dist/engine/evidence-gates/lifecycle-gate.js +22 -44
  12. package/dist/engine/evidence-index/index-builder.js +7 -19
  13. package/dist/engine/frontmatter-parser.d.ts +5 -0
  14. package/dist/engine/frontmatter-parser.js +24 -0
  15. package/dist/engine/project-command-runtime.d.ts +5 -0
  16. package/dist/{tools/validate-runtime.js → engine/project-command-runtime.js} +8 -21
  17. package/dist/engine/project-graph/builder.js +1 -35
  18. package/dist/engine/project-graph/cache.js +37 -1
  19. package/dist/engine/qa-gate.d.ts +4 -1
  20. package/dist/engine/qa-gate.js +72 -59
  21. package/dist/engine/safety/cross-process-lock.d.ts +1 -6
  22. package/dist/engine/safety/cross-process-lock.js +18 -16
  23. package/dist/engine/safety/lock-scavenger.d.ts +9 -0
  24. package/dist/engine/safety/lock-scavenger.js +114 -0
  25. package/dist/engine/session-context-generator.js +24 -2
  26. package/dist/engine/session-safeguard/learnings-buffer.js +7 -2
  27. package/dist/engine/session-state/writer.js +15 -8
  28. package/dist/engine/skill-registry/installer.js +1 -1
  29. package/dist/engine/spec-format/acceptance-criteria.js +8 -26
  30. package/dist/engine/spec-grounding/contract.d.ts +3 -1
  31. package/dist/engine/spec-grounding/contract.js +39 -14
  32. package/dist/engine/universal-rules/installer.js +2 -2
  33. package/dist/engine/validation-evidence-digest.d.ts +4 -0
  34. package/dist/engine/validation-evidence-digest.js +83 -0
  35. package/dist/engine/validation-evidence-ledger.d.ts +30 -0
  36. package/dist/engine/validation-evidence-ledger.js +274 -0
  37. package/dist/engine/validation-impact-planner.d.ts +4 -0
  38. package/dist/engine/validation-impact-planner.js +71 -0
  39. package/dist/engine/validator/spec-compliance-runner.d.ts +1 -1
  40. package/dist/engine/validator/spec-compliance-runner.js +37 -40
  41. package/dist/engine/validator/validation-report-writer.d.ts +1 -0
  42. package/dist/engine/validator/validation-report-writer.js +1 -1
  43. package/dist/engine/validator.js +7 -11
  44. package/dist/storage/convention-baseline.js +5 -0
  45. package/dist/storage/session-state-store.js +13 -6
  46. package/dist/storage/spec-store.d.ts +5 -0
  47. package/dist/storage/spec-store.js +7 -0
  48. package/dist/tools/git/git-helpers.d.ts +5 -1
  49. package/dist/tools/git/git-helpers.js +3 -2
  50. package/dist/tools/init-project/conventions-writer.js +1 -1
  51. package/dist/tools/init-project/scaffold-writer.js +1 -1
  52. package/dist/tools/safe-handler.d.ts +5 -1
  53. package/dist/tools/safe-handler.js +73 -12
  54. package/dist/tools/status-handler.d.ts +1 -1
  55. package/dist/tools/status-handler.js +27 -66
  56. package/dist/tools/update-status/dod-gates.js +7 -31
  57. package/dist/tools/update-status/evidence-gate.js +2 -4
  58. package/dist/tools/update-status/force-done-audit.d.ts +2 -0
  59. package/dist/tools/update-status/force-done-audit.js +23 -0
  60. package/dist/tools/update-status/qa-gate.d.ts +1 -0
  61. package/dist/tools/update-status/qa-gate.js +7 -51
  62. package/dist/tools/update-status-convention-gate.js +132 -97
  63. package/dist/tools/validate-lint.d.ts +1 -1
  64. package/dist/tools/validate-lint.js +78 -165
  65. package/dist/tools/validate.js +3 -1
  66. package/dist/transports/http-transport.js +22 -8
  67. package/dist/transports/mcp-types.d.ts +2 -2
  68. package/dist/transports/transport-factory.js +110 -12
  69. package/dist/types/abortable-process.d.ts +26 -0
  70. package/dist/types/abortable-process.js +2 -0
  71. package/dist/types/criterion-identity.d.ts +12 -0
  72. package/dist/types/criterion-identity.js +2 -0
  73. package/dist/types/index.d.ts +2 -0
  74. package/dist/types/index.js +2 -0
  75. package/dist/types/qa-gate.d.ts +0 -5
  76. package/dist/types/spec-format.d.ts +2 -0
  77. package/dist/types/spec-lock-v2.d.ts +7 -0
  78. package/dist/types/validation-evidence.d.ts +62 -0
  79. package/dist/types/validation-evidence.js +2 -0
  80. package/package.json +43 -23
  81. package/planu-native.json +1 -1
  82. package/planu-plugin.json +1 -1
  83. package/dist/storage/qa-gate-store.d.ts +0 -4
  84. package/dist/storage/qa-gate-store.js +0 -24
  85. package/dist/tools/validate-runtime.d.ts +0 -14
  86. package/dist/types/data/estimation.d.ts +0 -147
  87. package/dist/types/data/estimation.js +0 -2
  88. package/dist/types/data/index.d.ts +0 -5
  89. package/dist/types/data/index.js +0 -6
  90. package/dist/types/data/velocity.d.ts +0 -168
  91. 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, lintCommand) {
71
- // Prefer tsc --noEmit; fall back to checking if lint command already covers it
72
- const hasTsc = lintCommand?.includes('tsc') ?? false;
73
- if (hasTsc) {
74
- return []; // lint will cover compile
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 { output, ok } = await execGate('npx tsc --noEmit', projectPath, 15_000);
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
- return [];
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(`[Planu] convention-gate: lintCommand contains unsafe characters — skipping`);
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 cmd = lintCmd ?? 'npx eslint src --max-warnings=0 --format=compact';
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, _context) {
167
+ async function checkTestGate(projectPath, testCmd, context) {
105
168
  if (testCmd !== null && !isSafeCommand(testCmd)) {
106
- console.warn(`[Planu] convention-gate: testCommand contains unsafe characters — skipping`);
107
- return [];
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 fingerprintBefore = await readRepositoryFingerprint(projectPath);
111
- if (fingerprintBefore && hasFreshProcessValidation(projectPath, cmd, fingerprintBefore)) {
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
- const fingerprintAfter = await readRepositoryFingerprint(projectPath);
119
- if (fingerprintBefore && fingerprintAfter === fingerprintBefore) {
120
- processValidationReceipts.set(projectPath, {
121
- command: cmd,
122
- repositoryFingerprint: fingerprintAfter,
123
- completedAtMs: Date.now(),
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
- processValidationReceipts.clear();
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
- ? checkCompileGate(projectPath, knowledge?.lintCommand ?? null)
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 { execFileSync } from 'node:child_process';
3
- import { createHash } from 'node:crypto';
4
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
5
- import { dirname, join } from 'node:path';
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: true,
64
+ passed: false,
48
65
  command: lintCommand,
49
- issueCount: 0,
50
- output: '(skipped — unsafe command)',
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 fingerprint = calculateLintFingerprint(projectPath);
60
- const cached = readPassedLintCache(projectPath, command, fingerprint);
61
- if (cached !== null) {
62
- return cached;
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
- runProjectCommandPlan(plan, projectPath, timeoutMs);
67
- writePassedLintCache(projectPath, command, fingerprint);
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
- return { passed: false, command, issueCount, output };
83
- }
84
- }
85
- function readExternalPassedLintEvidence(context, command) {
86
- if (!context.projectId || !context.specId) {
87
- return null;
88
- }
89
- try {
90
- const path = join(projectDataDir(context.projectId), 'handoffs', context.specId, 'traceability-matrix.json');
91
- if (!existsSync(path)) {
92
- return null;
93
- }
94
- const parsed = JSON.parse(readFileSync(path, 'utf-8'));
95
- const rows = Array.isArray(parsed.rows) ? parsed.rows : [];
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
@@ -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({