@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
@@ -0,0 +1,274 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { existsSync, lstatSync, mkdirSync, linkSync, appendFileSync, readFileSync, unlinkSync, writeFileSync, } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { projectDataDir } from '../storage/base-store.js';
5
+ import { VALIDATION_EVIDENCE_SCHEMA_VERSION, } from '../types/validation-evidence.js';
6
+ import { digestValidationPaths, resolveValidationToolchain } from './validation-evidence-digest.js';
7
+ const MAX_RECEIPT_BYTES = 128 * 1024;
8
+ const PACKAGE_LOCKS = ['pnpm-lock.yaml', 'package-lock.json', 'yarn.lock', 'bun.lockb'];
9
+ const LOCK_PATHS = {
10
+ typecheck: PACKAGE_LOCKS,
11
+ lint: PACKAGE_LOCKS,
12
+ 'test-related': PACKAGE_LOCKS,
13
+ 'test-full': PACKAGE_LOCKS,
14
+ security: [...PACKAGE_LOCKS, 'Cargo.lock'],
15
+ native: [...PACKAGE_LOCKS, 'Cargo.lock'],
16
+ website: PACKAGE_LOCKS,
17
+ reproducibility: [...PACKAGE_LOCKS, 'Cargo.lock'],
18
+ };
19
+ const CONFIG_PATHS = {
20
+ typecheck: ['package.json', 'pnpm-workspace.yaml', 'tsconfig.json', 'tsconfig.build.json'],
21
+ lint: ['package.json', 'eslint.config.js', 'eslint.config.mjs', 'eslint.config.cjs'],
22
+ 'test-related': ['package.json', 'vitest.config.ts', 'vitest.config.mts', 'vitest.config.js'],
23
+ 'test-full': ['package.json', 'vitest.config.ts', 'vitest.config.mts', 'vitest.config.js'],
24
+ security: ['package.json', 'pnpm-workspace.yaml', 'Cargo.toml'],
25
+ native: ['package.json', 'pnpm-workspace.yaml', 'Cargo.toml'],
26
+ website: ['package.json', 'website/package.json'],
27
+ reproducibility: ['package.json', 'pnpm-workspace.yaml', 'tsconfig.build.json', 'Cargo.toml'],
28
+ };
29
+ const CAPABILITY_PATHS = {
30
+ typecheck: [
31
+ 'src',
32
+ 'tests',
33
+ 'package.json',
34
+ 'pnpm-lock.yaml',
35
+ 'tsconfig.json',
36
+ 'tsconfig.build.json',
37
+ ],
38
+ lint: ['src', 'tests', 'package.json', 'pnpm-lock.yaml', 'eslint.config.js', 'eslint.config.mjs'],
39
+ 'test-related': ['src', 'tests', 'package.json', 'pnpm-lock.yaml', 'vitest.config.ts'],
40
+ 'test-full': ['src', 'tests', 'package.json', 'pnpm-lock.yaml', 'vitest.config.ts'],
41
+ security: [
42
+ 'package.json',
43
+ 'pnpm-lock.yaml',
44
+ 'pnpm-workspace.yaml',
45
+ 'Cargo.toml',
46
+ 'Cargo.lock',
47
+ 'npm',
48
+ ],
49
+ native: ['rust', 'npm', 'Cargo.toml', 'Cargo.lock', 'package.json', 'pnpm-lock.yaml'],
50
+ website: ['website', 'package.json', 'pnpm-lock.yaml'],
51
+ reproducibility: [
52
+ 'src',
53
+ 'rust',
54
+ 'npm',
55
+ 'scripts',
56
+ 'package.json',
57
+ 'pnpm-lock.yaml',
58
+ 'tsconfig.build.json',
59
+ ],
60
+ };
61
+ export function normalizeValidationCommand(command) {
62
+ return command.trim().split(/\s+/).join(' ');
63
+ }
64
+ export function computeValidationEvidenceIdentity(input) {
65
+ const relevantPaths = input.relevantPaths ?? CAPABILITY_PATHS[input.capability];
66
+ const toolchain = resolveValidationToolchain(input);
67
+ return {
68
+ schemaVersion: VALIDATION_EVIDENCE_SCHEMA_VERSION,
69
+ capability: input.capability,
70
+ normalizedCommand: normalizeValidationCommand(input.command),
71
+ sourceTreeDigest: digestValidationPaths(input.projectPath, relevantPaths),
72
+ lockDigest: digestValidationPaths(input.projectPath, LOCK_PATHS[input.capability]),
73
+ configDigest: digestValidationPaths(input.projectPath, CONFIG_PATHS[input.capability]),
74
+ toolchainDigest: digestJson(toolchain),
75
+ selectedScopeDigest: digestJson([...new Set(input.scope.map(normalizeRelativePath))].sort()),
76
+ };
77
+ }
78
+ export function computeValidationEvidenceKey(identity) {
79
+ return digestJson(identity);
80
+ }
81
+ export function validationReceiptPath(projectId, key) {
82
+ if (!/^[a-f0-9]{64}$/.test(key)) {
83
+ throw new Error('Validation evidence key must be a SHA-256 hex digest.');
84
+ }
85
+ return join(projectDataDir(projectId), 'validation-evidence', `${key}.json`);
86
+ }
87
+ export function readMatchingValidationEvidence(projectId, input) {
88
+ const identity = computeValidationEvidenceIdentity(input);
89
+ const key = computeValidationEvidenceKey(identity);
90
+ const path = validationReceiptPath(projectId, key);
91
+ try {
92
+ const stat = lstatSync(path);
93
+ if (!stat.isFile() || stat.size <= 0 || stat.size > MAX_RECEIPT_BYTES) {
94
+ return null;
95
+ }
96
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'));
97
+ if (!isMatchingSuccessfulReceipt(parsed, key, identity)) {
98
+ return null;
99
+ }
100
+ recordValidationEvidenceAudit(projectId, auditValidationEvidence(parsed, 'reused'));
101
+ return parsed;
102
+ }
103
+ catch {
104
+ return null;
105
+ }
106
+ }
107
+ export function recordSuccessfulValidationEvidence(args) {
108
+ const identity = computeValidationEvidenceIdentity(args.input);
109
+ const key = computeValidationEvidenceKey(identity);
110
+ if (args.expectedKey !== undefined && args.expectedKey !== key) {
111
+ recordValidationEvidenceAudit(args.projectId, {
112
+ key,
113
+ capability: args.input.capability,
114
+ action: 'invalidated',
115
+ producer: bounded(args.producer),
116
+ status: 'invalidated',
117
+ durationMs: Math.max(0, Math.round(args.durationMs)),
118
+ outputDigest: digestText(args.output),
119
+ reason: 'Validation inputs changed before evidence persistence.',
120
+ source: bounded(args.source ?? `local:${args.producer}`),
121
+ completedAt: new Date().toISOString(),
122
+ timeSavedMs: 0,
123
+ });
124
+ return null;
125
+ }
126
+ const existing = readMatchingValidationEvidence(args.projectId, args.input);
127
+ if (existing) {
128
+ return existing;
129
+ }
130
+ const receipt = {
131
+ schemaVersion: VALIDATION_EVIDENCE_SCHEMA_VERSION,
132
+ key,
133
+ identity,
134
+ producer: bounded(args.producer),
135
+ status: 'passed',
136
+ durationMs: Math.max(0, Math.round(args.durationMs)),
137
+ outputDigest: digestText(args.output),
138
+ reason: bounded(args.reason),
139
+ source: bounded(args.source ?? `local:${args.producer}`),
140
+ completedAt: new Date().toISOString(),
141
+ timeSavedMs: 0,
142
+ };
143
+ const persisted = writeReceiptAtomically(args.projectId, receipt);
144
+ recordValidationEvidenceAudit(args.projectId, auditValidationEvidence(persisted, 'executed'));
145
+ return persisted;
146
+ }
147
+ export function auditValidationEvidence(receipt, action) {
148
+ return {
149
+ key: receipt.key,
150
+ capability: receipt.identity.capability,
151
+ action,
152
+ producer: receipt.producer,
153
+ status: receipt.status,
154
+ durationMs: action === 'reused' ? 0 : receipt.durationMs,
155
+ outputDigest: receipt.outputDigest,
156
+ reason: receipt.reason,
157
+ source: receipt.source,
158
+ completedAt: receipt.completedAt,
159
+ timeSavedMs: action === 'reused' ? receipt.durationMs : receipt.timeSavedMs,
160
+ };
161
+ }
162
+ export function recordValidationEvidenceAudit(projectId, audit) {
163
+ const directory = join(projectDataDir(projectId), 'validation-evidence');
164
+ mkdirSync(directory, { recursive: true });
165
+ appendFileSync(join(directory, 'audit.jsonl'), `${JSON.stringify(audit)}\n`, {
166
+ encoding: 'utf-8',
167
+ flag: 'a',
168
+ });
169
+ }
170
+ export function recordValidationOutcome(args) {
171
+ const identity = computeValidationEvidenceIdentity(args.input);
172
+ recordValidationEvidenceAudit(args.projectId, {
173
+ key: computeValidationEvidenceKey(identity),
174
+ capability: args.input.capability,
175
+ action: args.action,
176
+ producer: bounded(args.producer),
177
+ status: args.status,
178
+ durationMs: Math.max(0, Math.round(args.durationMs)),
179
+ outputDigest: digestText(args.output),
180
+ reason: bounded(args.reason),
181
+ source: bounded(args.source ?? `local:${args.producer}`),
182
+ completedAt: new Date().toISOString(),
183
+ timeSavedMs: 0,
184
+ });
185
+ }
186
+ function writeReceiptAtomically(projectId, receipt) {
187
+ const path = validationReceiptPath(projectId, receipt.key);
188
+ mkdirSync(join(projectDataDir(projectId), 'validation-evidence'), { recursive: true });
189
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
190
+ try {
191
+ writeFileSync(temporary, `${JSON.stringify(receipt, null, 2)}\n`, {
192
+ encoding: 'utf-8',
193
+ flag: 'wx',
194
+ });
195
+ try {
196
+ linkSync(temporary, path);
197
+ }
198
+ catch (error) {
199
+ if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) {
200
+ throw error;
201
+ }
202
+ const existing = JSON.parse(readFileSync(path, 'utf-8'));
203
+ if (!isMatchingSuccessfulReceipt(existing, receipt.key, receipt.identity) ||
204
+ stableJson(existing) !== stableJson(receipt)) {
205
+ throw new Error('Conflicting validation evidence already exists for the canonical key.', {
206
+ cause: error,
207
+ });
208
+ }
209
+ return existing;
210
+ }
211
+ return receipt;
212
+ }
213
+ finally {
214
+ if (existsSync(temporary)) {
215
+ unlinkSync(temporary);
216
+ }
217
+ }
218
+ }
219
+ function isMatchingSuccessfulReceipt(value, key, identity) {
220
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
221
+ return false;
222
+ }
223
+ const receipt = value;
224
+ if (receipt.schemaVersion !== VALIDATION_EVIDENCE_SCHEMA_VERSION ||
225
+ receipt.key !== key ||
226
+ receipt.status !== 'passed' ||
227
+ !receipt.identity ||
228
+ digestJson(receipt.identity) !== digestJson(identity) ||
229
+ typeof receipt.producer !== 'string' ||
230
+ receipt.producer.trim() === '' ||
231
+ typeof receipt.durationMs !== 'number' ||
232
+ receipt.durationMs < 0 ||
233
+ typeof receipt.outputDigest !== 'string' ||
234
+ !/^[a-f0-9]{64}$/.test(receipt.outputDigest) ||
235
+ typeof receipt.reason !== 'string' ||
236
+ receipt.reason.trim() === '' ||
237
+ typeof receipt.source !== 'string' ||
238
+ receipt.source.trim() === '' ||
239
+ typeof receipt.completedAt !== 'string' ||
240
+ !Number.isFinite(Date.parse(receipt.completedAt)) ||
241
+ typeof receipt.timeSavedMs !== 'number') {
242
+ return false;
243
+ }
244
+ return computeValidationEvidenceKey(receipt.identity) === key;
245
+ }
246
+ function normalizeRelativePath(path) {
247
+ return path.trim().replaceAll('\\', '/').replace(/^\.\//, '');
248
+ }
249
+ function digestJson(value) {
250
+ return digestText(stableJson(value));
251
+ }
252
+ function digestText(value) {
253
+ return createHash('sha256').update(value).digest('hex');
254
+ }
255
+ function stableJson(value) {
256
+ if (Array.isArray(value)) {
257
+ return `[${value.map(stableJson).join(',')}]`;
258
+ }
259
+ if (value && typeof value === 'object') {
260
+ return `{${Object.entries(value)
261
+ .sort(([a], [b]) => a.localeCompare(b))
262
+ .map(([key, nested]) => `${JSON.stringify(key)}:${stableJson(nested)}`)
263
+ .join(',')}}`;
264
+ }
265
+ return JSON.stringify(value);
266
+ }
267
+ function bounded(value) {
268
+ const normalized = value.trim();
269
+ if (normalized.length === 0 || normalized.length > 2_000) {
270
+ throw new Error('Validation evidence strings must contain 1..2000 characters.');
271
+ }
272
+ return normalized;
273
+ }
274
+ //# sourceMappingURL=validation-evidence-ledger.js.map
@@ -0,0 +1,4 @@
1
+ import type { ValidationGateCapability, ValidationImpactPlan } from '../types/validation-evidence.js';
2
+ export declare const VALIDATION_COMMANDS: Readonly<Record<ValidationGateCapability, string>>;
3
+ export declare function planValidationImpact(paths: readonly string[]): ValidationImpactPlan;
4
+ //# sourceMappingURL=validation-impact-planner.d.ts.map
@@ -0,0 +1,71 @@
1
+ export const VALIDATION_COMMANDS = {
2
+ typecheck: 'pnpm typecheck',
3
+ lint: 'pnpm lint',
4
+ 'test-related': 'pnpm test --run --changed',
5
+ 'test-full': 'pnpm test',
6
+ security: 'pnpm check:deps:fresh',
7
+ native: 'pnpm native:project-graph:e2e',
8
+ website: 'pnpm --dir website build',
9
+ reproducibility: 'bash scripts/check-reproducibility.sh',
10
+ };
11
+ const LIFECYCLE_RE = /^(?:planu\/|docs\/|README(?:\.|$)|CHANGELOG\.md$|[^/]+\.md$)/;
12
+ const SOURCE_RE = /^(?:src\/|tests\/)/;
13
+ const DEPENDENCY_RE = /^(?:package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|npm\/[^/]+\/package\.json)/;
14
+ const NATIVE_RE = /^(?:rust\/|Cargo\.(?:toml|lock)$|npm\/core-)/;
15
+ const SHARED_RE = /^(?:\.github\/|\.husky\/|scripts\/|tsconfig(?:\.[^/]+)?\.json$|eslint\.config\.[^/]+$|vitest\.config\.[^/]+$)/;
16
+ export function planValidationImpact(paths) {
17
+ const changedPaths = [...new Set(paths.map(normalizePath).filter(Boolean))].sort();
18
+ if (changedPaths.length === 0) {
19
+ return plan('none', [], false, 'No changed paths.', changedPaths);
20
+ }
21
+ if (changedPaths.every((path) => LIFECYCLE_RE.test(path))) {
22
+ return plan('lifecycle', [], false, 'Lifecycle/documentation-only change.', changedPaths);
23
+ }
24
+ if (changedPaths.some((path) => SHARED_RE.test(path))) {
25
+ return fullPlan('Shared validation, workflow, hook, or unknown infrastructure changed.', changedPaths);
26
+ }
27
+ const gates = new Set();
28
+ let kind = 'none';
29
+ for (const path of changedPaths) {
30
+ if (SOURCE_RE.test(path)) {
31
+ kind = kind === 'none' ? 'source' : kind;
32
+ gates.add('typecheck');
33
+ gates.add('lint');
34
+ gates.add('test-related');
35
+ }
36
+ else if (DEPENDENCY_RE.test(path)) {
37
+ kind = 'dependency';
38
+ gates.add('security');
39
+ gates.add('typecheck');
40
+ gates.add('lint');
41
+ gates.add('test-full');
42
+ }
43
+ else if (NATIVE_RE.test(path)) {
44
+ kind = 'native';
45
+ gates.add('native');
46
+ gates.add('test-full');
47
+ }
48
+ else if (path.startsWith('website/')) {
49
+ kind = kind === 'none' ? 'website' : kind;
50
+ gates.add('website');
51
+ }
52
+ else if (!LIFECYCLE_RE.test(path)) {
53
+ return fullPlan(`Unknown path selects fail-safe full validation: ${path}`, changedPaths);
54
+ }
55
+ }
56
+ return plan(kind, [...gates], gates.has('test-full'), `Selected by changed path class: ${kind}.`, changedPaths);
57
+ }
58
+ function fullPlan(reason, changedPaths) {
59
+ const gates = ['typecheck', 'lint', 'test-full', 'security'];
60
+ if (changedPaths.some((path) => path.startsWith('website/'))) {
61
+ gates.push('website');
62
+ }
63
+ return plan('full', gates, true, reason, changedPaths);
64
+ }
65
+ function plan(kind, gates, heavy, reason, changedPaths) {
66
+ return { kind, gates, heavy, reason, changedPaths };
67
+ }
68
+ function normalizePath(path) {
69
+ return path.trim().replaceAll('\\', '/').replace(/^\.\//, '');
70
+ }
71
+ //# sourceMappingURL=validation-impact-planner.js.map
@@ -40,7 +40,7 @@ export declare function parseFrontmatterScenarios(raw: string): FrontmatterScena
40
40
  * Run vitest-backed spec-compliance for the given spec.
41
41
  * Each frontmatter scenario must have a `tests` array; missing links count as `fail`.
42
42
  */
43
- export declare function runSpecCompliance(spec: Spec, projectPath: string): Promise<SpecComplianceResult>;
43
+ export declare function runSpecCompliance(spec: Spec, projectPath: string, signal?: AbortSignal): Promise<SpecComplianceResult>;
44
44
  /**
45
45
  * Returns a list of scenario titles that lack a `tests` array.
46
46
  * Used by check-readiness to block `approved` when scenarios are untested.
@@ -3,9 +3,10 @@
3
3
  // Replaces the text-matching approach in validateSpec with actual test execution.
4
4
  // Parses the `tests` array from each frontmatter scenario, spawns vitest, and
5
5
  // produces a per-scenario verdict based on real test outcomes.
6
- import { spawn } from 'node:child_process';
7
6
  import { access } from 'node:fs/promises';
8
7
  import { join } from 'node:path';
8
+ import { readMatchingValidationEvidence } from '../validation-evidence-ledger.js';
9
+ import { runAbortableProcess } from '../abortable-process-runner.js';
9
10
  // ---------------------------------------------------------------------------
10
11
  // Frontmatter scenario parser
11
12
  // ---------------------------------------------------------------------------
@@ -123,41 +124,26 @@ export function parseFrontmatterScenarios(raw) {
123
124
  * Spawn vitest with --reporter=json and collect the JSON output.
124
125
  * Returns null on timeout or spawn error.
125
126
  */
126
- function runVitest(testPaths, projectPath, timeoutMs = 120_000) {
127
- return new Promise((resolve) => {
128
- const args = ['--reporter=json', '--run', ...testPaths];
129
- const child = spawn('pnpm', ['exec', 'vitest', ...args], {
130
- cwd: projectPath,
131
- stdio: ['ignore', 'pipe', 'pipe'],
132
- });
133
- let stdout = '';
134
- child.stdout.on('data', (chunk) => {
135
- stdout += chunk.toString();
136
- });
137
- const timer = setTimeout(() => {
138
- child.kill('SIGTERM');
139
- resolve(null);
140
- }, timeoutMs);
141
- child.on('close', () => {
142
- clearTimeout(timer);
143
- try {
144
- // vitest --reporter=json may include non-JSON lines; find the JSON object
145
- const jsonStart = stdout.indexOf('{');
146
- if (jsonStart === -1) {
147
- resolve(null);
148
- return;
149
- }
150
- const parsed = JSON.parse(stdout.slice(jsonStart));
151
- resolve(parsed);
152
- }
153
- catch {
154
- resolve(null);
155
- }
156
- });
157
- child.on('error', () => {
158
- clearTimeout(timer);
159
- resolve(null);
160
- });
127
+ function runVitest(testPaths, projectPath, timeoutMs = 120_000, signal) {
128
+ const args = ['exec', 'vitest', '--reporter=json', '--run', ...testPaths];
129
+ return runAbortableProcess('pnpm', args, {
130
+ cwd: projectPath,
131
+ timeoutMs,
132
+ maxBufferBytes: 64 * 1024 * 1024,
133
+ signal,
134
+ }).then((result) => {
135
+ if (result.status !== 0 || result.error) {
136
+ return null;
137
+ }
138
+ try {
139
+ const jsonStart = result.stdout.indexOf('{');
140
+ return jsonStart === -1
141
+ ? null
142
+ : JSON.parse(result.stdout.slice(jsonStart));
143
+ }
144
+ catch {
145
+ return null;
146
+ }
161
147
  });
162
148
  }
163
149
  function resolveVitestTestFilePath(result) {
@@ -185,7 +171,7 @@ function resolveScenarioTestPath(link) {
185
171
  * Each frontmatter scenario must have a `tests` array; missing links count as `fail`.
186
172
  */
187
173
  // eslint-disable-next-line max-lines-per-function, complexity -- spec compliance runner: each section handles a discrete verification phase (file access, scenario parsing, test execution, verdict aggregation)
188
- export async function runSpecCompliance(spec, projectPath) {
174
+ export async function runSpecCompliance(spec, projectPath, signal) {
189
175
  const { readFile } = await import('node:fs/promises');
190
176
  let raw;
191
177
  try {
@@ -227,11 +213,22 @@ export async function runSpecCompliance(spec, projectPath) {
227
213
  // ── Phase 3: run vitest for existing paths ───────────────────────────────
228
214
  const existingPaths = [...allTestPaths].filter((p) => existenceMap.get(p) === true);
229
215
  let vitestResult = null;
230
- if (existingPaths.length > 0) {
231
- vitestResult = await runVitest(existingPaths, projectPath);
216
+ const fullTestReceipt = readMatchingValidationEvidence(spec.projectId, {
217
+ projectPath,
218
+ capability: 'test-full',
219
+ command: 'pnpm test',
220
+ scope: ['src', 'tests'],
221
+ });
222
+ if (existingPaths.length > 0 && !fullTestReceipt) {
223
+ vitestResult = await runVitest(existingPaths, projectPath, 120_000, signal);
232
224
  }
233
225
  // Build a map: testFilePath -> pass/fail
234
226
  const testFileStatus = new Map();
227
+ if (fullTestReceipt) {
228
+ for (const path of existingPaths) {
229
+ testFileStatus.set(path, 'pass');
230
+ }
231
+ }
235
232
  if (vitestResult) {
236
233
  for (const tr of vitestResult.testResults) {
237
234
  const testFilePath = resolveVitestTestFilePath(tr);
@@ -272,7 +269,7 @@ export async function runSpecCompliance(spec, projectPath) {
272
269
  verdicts.push('missing');
273
270
  continue;
274
271
  }
275
- const status = testFileStatus.get(linkPath) ?? (vitestResult ? 'fail' : 'missing');
272
+ const status = testFileStatus.get(linkPath) ?? (vitestResult || fullTestReceipt ? 'fail' : 'missing');
276
273
  if (status === 'pass') {
277
274
  evidence.push(`${linkPath}: passed`);
278
275
  verdicts.push('pass');
@@ -10,6 +10,7 @@ export declare function writeImplementationReviewReport(input: {
10
10
  lintPassed?: boolean;
11
11
  conventionRegression?: boolean;
12
12
  minimalityReport?: MinimalImplementationReport;
13
+ signal?: AbortSignal;
13
14
  }): Promise<{
14
15
  written: boolean;
15
16
  path?: string;
@@ -1,7 +1,7 @@
1
1
  import { appendArtifact, readArtifact } from '../handoff-artifacts/io.js';
2
2
  import { runSpecCompliance, } from './spec-compliance-runner.js';
3
3
  export async function writeImplementationReviewReport(input) {
4
- const specCompliance = await runSpecCompliance(input.spec, input.projectPath);
4
+ const specCompliance = await runSpecCompliance(input.spec, input.projectPath, input.signal);
5
5
  const reportScore = resolveExecutableValidationScore(input.score, specCompliance);
6
6
  const gates = buildGates({
7
7
  score: reportScore,
@@ -7,6 +7,7 @@ import { scanCodeForSpec, checkCriterionEvidence, classifyDriftSeverity, quickQu
7
7
  import { resolveApplicableDimensions } from './validator/scope-resolver.js';
8
8
  import { buildHolisticReportFromFlatScore } from './validator/holistic-report.js';
9
9
  import { readEvidenceArtifacts } from './evidence-gates/artifact-reader.js';
10
+ import { createCriterionIdentity } from './criterion-identity.js';
10
11
  // Re-export the full public API so external callers never need to know
11
12
  // about the sub-module layout.
12
13
  export { generateDoR, generateDoD } from './validator/dor-dod.js';
@@ -14,14 +15,6 @@ export { generateChecklist } from './validator/checklist.js';
14
15
  // SPEC-730: export scope-resolver and holistic-report for consumers
15
16
  export { resolveApplicableDimensions, normaliseScore } from './validator/scope-resolver.js';
16
17
  export { buildHolisticReportFromFlatScore } from './validator/holistic-report.js';
17
- function normalizeCriterionForEvidence(value) {
18
- return value
19
- .replace(/^-\s*\[[ xX]\]\s*/i, '')
20
- .replace(/^[A-Z]{2}\d+\s*[:.)-]?\s*/i, '')
21
- .replace(/\s+/g, ' ')
22
- .trim()
23
- .toLowerCase();
24
- }
25
18
  function rowHasValidationEvidence(row) {
26
19
  const hasImplementationEvidence = [
27
20
  row.scenario?.trim(),
@@ -45,7 +38,7 @@ async function readTraceabilityEvidence(spec, projectPath) {
45
38
  const rows = artifacts.traceabilityMatrix?.rows ?? [];
46
39
  return new Map(rows
47
40
  .filter(rowHasValidationEvidence)
48
- .map((row) => [normalizeCriterionForEvidence(row.acceptanceCriterion), row]));
41
+ .flatMap((row, index) => createCriterionIdentity(row.acceptanceCriterion, { index }).aliases.map((alias) => [alias, row])));
49
42
  }
50
43
  catch {
51
44
  return new Map();
@@ -100,9 +93,12 @@ export async function validateSpec(spec, projectPath) {
100
93
  const indeterminate = [];
101
94
  const extra = [];
102
95
  const qualityIssues = [];
103
- for (const criterion of criteria) {
96
+ for (let criterionIndex = 0; criterionIndex < criteria.length; criterionIndex += 1) {
97
+ const criterion = criteria[criterionIndex] ?? '';
104
98
  const verifyBlock = verifyBlockMap.get(criterion);
105
- const evidenceRow = traceabilityEvidence.get(normalizeCriterionForEvidence(criterion));
99
+ const evidenceRow = createCriterionIdentity(criterion, { index: criterionIndex })
100
+ .aliases.map((alias) => traceabilityEvidence.get(alias))
101
+ .find((row) => row !== undefined);
106
102
  if (evidenceRow !== undefined) {
107
103
  matches.push(criterion);
108
104
  continue;
@@ -24,6 +24,11 @@ export async function getBaseline(projectId) {
24
24
  * Two violations with the same key are considered the same issue.
25
25
  */
26
26
  function violationKey(v) {
27
+ const claudeRule = /^(claude-md-[a-z-]+)-\d+$/.exec(v.ruleId);
28
+ if (claudeRule) {
29
+ const stableMessage = v.message.replace(/File has \d+ lines/, 'File exceeds line limit');
30
+ return `${claudeRule[1]}::${v.file}::${stableMessage}`;
31
+ }
27
32
  return `${v.ruleId}::${v.file}::${v.line ?? 0}`;
28
33
  }
29
34
  /**
@@ -3,6 +3,8 @@
3
3
  import { readJson, writeJson } from './base-store.js';
4
4
  import { join } from 'node:path';
5
5
  import { execSync } from 'node:child_process';
6
+ import { readFile } from 'node:fs/promises';
7
+ import { getUniqueTopLevelFrontmatterString } from '../engine/frontmatter-parser.js';
6
8
  // ---------------------------------------------------------------------------
7
9
  // Constants
8
10
  // ---------------------------------------------------------------------------
@@ -44,18 +46,23 @@ export async function getActiveSpecIds(projectPath) {
44
46
  try {
45
47
  const { glob } = await import('glob');
46
48
  const specFiles = await glob('planu/specs/*/spec.md', { cwd: projectPath });
47
- const active = [];
49
+ const active = new Set();
48
50
  for (const f of specFiles) {
49
- const { readFile } = await import('node:fs/promises');
50
- const content = await readFile(join(projectPath, f), 'utf-8');
51
- if (content.includes('status: implementing') || content.includes("status: 'implementing'")) {
51
+ try {
52
+ const content = await readFile(join(projectPath, f), 'utf-8');
53
+ if (getUniqueTopLevelFrontmatterString(content, 'status') !== 'implementing') {
54
+ continue;
55
+ }
52
56
  const match = /SPEC-\d+/.exec(f);
53
57
  if (match) {
54
- active.push(match[0]);
58
+ active.add(match[0]);
55
59
  }
56
60
  }
61
+ catch {
62
+ // A single unreadable candidate must not suppress other valid active specs.
63
+ }
57
64
  }
58
- return active;
65
+ return [...active];
59
66
  }
60
67
  catch {
61
68
  return [];
@@ -12,6 +12,11 @@ export declare function invalidateSpecsCache(projectId: string): void;
12
12
  * List every spec for a project.
13
13
  */
14
14
  export declare function listSpecs(projectId: string): Promise<Spec[]>;
15
+ /**
16
+ * Run a read-modify side effect against a fresh spec snapshot while holding the
17
+ * same cross-process lock used by status mutations.
18
+ */
19
+ export declare function withFreshSpecsLock<T>(projectId: string, fn: (specs: Spec[]) => Promise<T>): Promise<T>;
15
20
  /**
16
21
  * Get a single spec by ID. Returns `null` when not found.
17
22
  */
@@ -131,6 +131,13 @@ export function invalidateSpecsCache(projectId) {
131
131
  export async function listSpecs(projectId) {
132
132
  return loadAll(projectId);
133
133
  }
134
+ /**
135
+ * Run a read-modify side effect against a fresh spec snapshot while holding the
136
+ * same cross-process lock used by status mutations.
137
+ */
138
+ export async function withFreshSpecsLock(projectId, fn) {
139
+ return withSpecMutationLock(projectId, async () => fn(await loadAll(projectId)));
140
+ }
134
141
  /**
135
142
  * Get a single spec by ID. Returns `null` when not found.
136
143
  */
@@ -1,6 +1,10 @@
1
1
  import { execFile as execFileCb } from 'node:child_process';
2
2
  import type { GitConfig, GitFlowType } from '../../types/index.js';
3
3
  export declare const execFile: typeof execFileCb.__promisify__;
4
+ export interface GitExecutionOptions {
5
+ signal?: AbortSignal;
6
+ timeoutMs?: number;
7
+ }
4
8
  /** Default branch prefixes by spec type. */
5
9
  export declare const DEFAULT_BRANCH_PREFIXES: Record<string, string>;
6
10
  /** Default protected branches. */
@@ -8,7 +12,7 @@ export declare const DEFAULT_PROTECTED_BRANCHES: string[];
8
12
  /** Default staleness threshold (commits behind). */
9
13
  export declare const DEFAULT_STALENESS_THRESHOLD = 50;
10
14
  /** Execute a git command in the project directory. */
11
- export declare function git(projectPath: string, args: string[]): Promise<{
15
+ export declare function git(projectPath: string, args: string[], options?: GitExecutionOptions): Promise<{
12
16
  stdout: string;
13
17
  stderr: string;
14
18
  }>;
@@ -17,12 +17,13 @@ export const DEFAULT_PROTECTED_BRANCHES = ['main', 'master', 'develop', 'product
17
17
  /** Default staleness threshold (commits behind). */
18
18
  export const DEFAULT_STALENESS_THRESHOLD = 50;
19
19
  /** Execute a git command in the project directory. */
20
- export async function git(projectPath, args) {
20
+ export async function git(projectPath, args, options = {}) {
21
21
  try {
22
22
  const result = await execFile('git', args, {
23
23
  cwd: projectPath,
24
24
  maxBuffer: 1024 * 1024 * 10,
25
- timeout: 30_000,
25
+ timeout: options.timeoutMs ?? 30_000,
26
+ signal: options.signal,
26
27
  });
27
28
  return { stdout: result.stdout, stderr: result.stderr };
28
29
  }