@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
|
@@ -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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
231
|
-
|
|
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');
|
|
@@ -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,
|
package/dist/engine/validator.js
CHANGED
|
@@ -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
|
-
.
|
|
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 (
|
|
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 =
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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.
|
|
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
|
}
|