@planu/cli 4.11.9 → 4.12.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +129 -22
- package/dist/engine/abortable-process-runner.d.ts +8 -0
- package/dist/engine/abortable-process-runner.js +107 -0
- package/dist/engine/cascade-hooks/core/append-releases.d.ts +1 -0
- package/dist/engine/cascade-hooks/core/append-releases.js +51 -14
- package/dist/engine/cascade-hooks/hooks/orchestration-plan.hook.js +16 -21
- package/dist/engine/ci-generator/yaml-builder.js +5 -1
- package/dist/engine/context-md-generator.js +3 -1
- package/dist/engine/criterion-identity.d.ts +6 -0
- package/dist/engine/criterion-identity.js +63 -0
- package/dist/engine/evidence-gates/lifecycle-gate.js +22 -44
- package/dist/engine/evidence-index/index-builder.js +7 -19
- package/dist/engine/frontmatter-parser.d.ts +5 -0
- package/dist/engine/frontmatter-parser.js +24 -0
- package/dist/engine/project-command-runtime.d.ts +5 -0
- package/dist/{tools/validate-runtime.js → engine/project-command-runtime.js} +8 -21
- package/dist/engine/project-graph/builder.js +1 -35
- package/dist/engine/project-graph/cache.js +37 -1
- package/dist/engine/qa-gate.d.ts +4 -1
- package/dist/engine/qa-gate.js +72 -59
- package/dist/engine/safety/cross-process-lock.d.ts +1 -6
- package/dist/engine/safety/cross-process-lock.js +18 -16
- package/dist/engine/safety/lock-scavenger.d.ts +9 -0
- package/dist/engine/safety/lock-scavenger.js +114 -0
- package/dist/engine/session-context-generator.js +24 -2
- package/dist/engine/session-safeguard/learnings-buffer.js +7 -2
- package/dist/engine/session-state/writer.js +15 -8
- package/dist/engine/skill-registry/installer.js +1 -1
- package/dist/engine/spec-format/acceptance-criteria.js +8 -26
- package/dist/engine/spec-grounding/contract.d.ts +3 -1
- package/dist/engine/spec-grounding/contract.js +39 -14
- package/dist/engine/universal-rules/installer.js +2 -2
- package/dist/engine/validation-evidence-digest.d.ts +4 -0
- package/dist/engine/validation-evidence-digest.js +83 -0
- package/dist/engine/validation-evidence-ledger.d.ts +30 -0
- package/dist/engine/validation-evidence-ledger.js +274 -0
- package/dist/engine/validation-impact-planner.d.ts +4 -0
- package/dist/engine/validation-impact-planner.js +71 -0
- package/dist/engine/validator/spec-compliance-runner.d.ts +1 -1
- package/dist/engine/validator/spec-compliance-runner.js +37 -40
- package/dist/engine/validator/validation-report-writer.d.ts +1 -0
- package/dist/engine/validator/validation-report-writer.js +1 -1
- package/dist/engine/validator.js +7 -11
- package/dist/storage/convention-baseline.js +5 -0
- package/dist/storage/session-state-store.js +13 -6
- package/dist/storage/spec-store.d.ts +5 -0
- package/dist/storage/spec-store.js +7 -0
- package/dist/tools/git/git-helpers.d.ts +5 -1
- package/dist/tools/git/git-helpers.js +3 -2
- package/dist/tools/init-project/conventions-writer.js +1 -1
- package/dist/tools/init-project/scaffold-writer.js +1 -1
- package/dist/tools/safe-handler.d.ts +5 -1
- package/dist/tools/safe-handler.js +73 -12
- package/dist/tools/status-handler.d.ts +1 -1
- package/dist/tools/status-handler.js +27 -66
- package/dist/tools/update-status/dod-gates.js +7 -31
- package/dist/tools/update-status/evidence-gate.js +2 -4
- package/dist/tools/update-status/force-done-audit.d.ts +2 -0
- package/dist/tools/update-status/force-done-audit.js +23 -0
- package/dist/tools/update-status/qa-gate.d.ts +1 -0
- package/dist/tools/update-status/qa-gate.js +7 -51
- package/dist/tools/update-status-convention-gate.js +132 -97
- package/dist/tools/validate-lint.d.ts +1 -1
- package/dist/tools/validate-lint.js +78 -165
- package/dist/tools/validate.js +3 -1
- package/dist/transports/http-transport.js +22 -8
- package/dist/transports/mcp-types.d.ts +2 -2
- package/dist/transports/transport-factory.js +110 -12
- package/dist/types/abortable-process.d.ts +26 -0
- package/dist/types/abortable-process.js +2 -0
- package/dist/types/criterion-identity.d.ts +12 -0
- package/dist/types/criterion-identity.js +2 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.js +2 -0
- package/dist/types/qa-gate.d.ts +0 -5
- package/dist/types/spec-format.d.ts +2 -0
- package/dist/types/spec-lock-v2.d.ts +7 -0
- package/dist/types/validation-evidence.d.ts +62 -0
- package/dist/types/validation-evidence.js +2 -0
- package/package.json +43 -23
- package/planu-native.json +1 -1
- package/planu-plugin.json +1 -1
- package/dist/storage/qa-gate-store.d.ts +0 -4
- package/dist/storage/qa-gate-store.js +0 -24
- package/dist/tools/validate-runtime.d.ts +0 -14
- package/dist/types/data/estimation.d.ts +0 -147
- package/dist/types/data/estimation.js +0 -2
- package/dist/types/data/index.d.ts +0 -5
- package/dist/types/data/index.js +0 -6
- package/dist/types/data/velocity.d.ts +0 -168
- package/dist/types/data/velocity.js +0 -4
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { hasAnyAffirmedMatch } from '../text-signal-boundaries.js';
|
|
2
|
+
import { createCriterionIdentity } from '../criterion-identity.js';
|
|
2
3
|
const CONTRACT_HINTS = {
|
|
3
4
|
api: [
|
|
4
5
|
/\b(?:public|external|rest|http)[ -]?api\b/i,
|
|
@@ -20,30 +21,6 @@ const CONTRACT_HINTS = {
|
|
|
20
21
|
function isNonTrivial(spec) {
|
|
21
22
|
return spec.scope !== 'trivial';
|
|
22
23
|
}
|
|
23
|
-
function normalizeCriterion(value) {
|
|
24
|
-
const withoutCheckbox = value.replace(/^-\s*\[[ x]\]\s*/i, '').trim();
|
|
25
|
-
const criterionOnly = /^([A-Z]{2}\d+)\s*[:.)-]?\s*$/i.exec(withoutCheckbox)?.[1];
|
|
26
|
-
if (criterionOnly) {
|
|
27
|
-
return criterionOnly.toLowerCase();
|
|
28
|
-
}
|
|
29
|
-
return withoutCheckbox
|
|
30
|
-
.replace(/^[A-Z]{2}\d+\s*[:.)-]?\s*/i, '')
|
|
31
|
-
.replace(/\s+/g, ' ')
|
|
32
|
-
.trim()
|
|
33
|
-
.toLowerCase();
|
|
34
|
-
}
|
|
35
|
-
function criterionAliases(value, index) {
|
|
36
|
-
const aliases = new Set([
|
|
37
|
-
normalizeCriterion(value),
|
|
38
|
-
`ac${String(index + 1)}`,
|
|
39
|
-
`ab${String(index + 1)}`,
|
|
40
|
-
]);
|
|
41
|
-
const explicit = /^([A-Z]{2}\d+)\b/i.exec(value)?.[1];
|
|
42
|
-
if (explicit) {
|
|
43
|
-
aliases.add(normalizeCriterion(explicit));
|
|
44
|
-
}
|
|
45
|
-
return [...aliases].filter((alias) => alias.length > 0);
|
|
46
|
-
}
|
|
47
24
|
function hasText(value) {
|
|
48
25
|
return value !== undefined && value.trim().length > 0;
|
|
49
26
|
}
|
|
@@ -90,8 +67,8 @@ export function checkTaskPlanGate(spec, criteria, artifacts) {
|
|
|
90
67
|
},
|
|
91
68
|
];
|
|
92
69
|
}
|
|
93
|
-
const covered = new Set(artifacts.taskPlan.tasks.flatMap((task) => task.acceptanceCriteria.
|
|
94
|
-
const uncovered = criteria.filter((criterion, index) => !
|
|
70
|
+
const covered = new Set(artifacts.taskPlan.tasks.flatMap((task) => task.acceptanceCriteria.flatMap((criterion, index) => createCriterionIdentity(criterion, { index }).aliases)));
|
|
71
|
+
const uncovered = criteria.filter((criterion, index) => !createCriterionIdentity(criterion, { index }).aliases.some((alias) => covered.has(alias)));
|
|
95
72
|
if (uncovered.length === 0) {
|
|
96
73
|
return [];
|
|
97
74
|
}
|
|
@@ -108,6 +85,22 @@ function rowHasEvidence(row) {
|
|
|
108
85
|
(row.contractEvidence !== undefined && row.contractEvidence.length > 0) ||
|
|
109
86
|
hasText(row.manualEvidence));
|
|
110
87
|
}
|
|
88
|
+
function describeIncompleteRow(row) {
|
|
89
|
+
const missing = [];
|
|
90
|
+
if (!rowHasEvidence(row)) {
|
|
91
|
+
missing.push('scenario/testEvidence/contractEvidence/manualEvidence');
|
|
92
|
+
}
|
|
93
|
+
if (row.changedFiles.length === 0) {
|
|
94
|
+
missing.push('changedFiles');
|
|
95
|
+
}
|
|
96
|
+
if (!hasText(row.validationEvidence)) {
|
|
97
|
+
missing.push('validationEvidence');
|
|
98
|
+
}
|
|
99
|
+
if (!hasText(row.reviewerEvidence)) {
|
|
100
|
+
missing.push('reviewerEvidence');
|
|
101
|
+
}
|
|
102
|
+
return `${row.acceptanceCriterion}: missing ${missing.join(', ')}`;
|
|
103
|
+
}
|
|
111
104
|
export function checkDoneEvidenceGate(spec, criteria, artifacts) {
|
|
112
105
|
const issues = [];
|
|
113
106
|
const matrix = artifacts.traceabilityMatrix;
|
|
@@ -118,8 +111,8 @@ export function checkDoneEvidenceGate(spec, criteria, artifacts) {
|
|
|
118
111
|
});
|
|
119
112
|
}
|
|
120
113
|
else {
|
|
121
|
-
const covered = new Set(matrix.rows.
|
|
122
|
-
const uncovered = criteria.filter((criterion, index) => !
|
|
114
|
+
const covered = new Set(matrix.rows.flatMap((row, index) => createCriterionIdentity(row.acceptanceCriterion, { index }).aliases));
|
|
115
|
+
const uncovered = criteria.filter((criterion, index) => !createCriterionIdentity(criterion, { index }).aliases.some((alias) => covered.has(alias)));
|
|
123
116
|
if (uncovered.length > 0) {
|
|
124
117
|
issues.push({
|
|
125
118
|
code: 'traceability_uncovered_criteria',
|
|
@@ -131,22 +124,7 @@ export function checkDoneEvidenceGate(spec, criteria, artifacts) {
|
|
|
131
124
|
!hasText(row.validationEvidence) ||
|
|
132
125
|
!hasText(row.reviewerEvidence));
|
|
133
126
|
if (incompleteRows.length > 0) {
|
|
134
|
-
const rowDetails = incompleteRows.map(
|
|
135
|
-
const missing = [];
|
|
136
|
-
if (!rowHasEvidence(row)) {
|
|
137
|
-
missing.push('scenario/testEvidence/contractEvidence/manualEvidence');
|
|
138
|
-
}
|
|
139
|
-
if (row.changedFiles.length === 0) {
|
|
140
|
-
missing.push('changedFiles');
|
|
141
|
-
}
|
|
142
|
-
if (!hasText(row.validationEvidence)) {
|
|
143
|
-
missing.push('validationEvidence');
|
|
144
|
-
}
|
|
145
|
-
if (!hasText(row.reviewerEvidence)) {
|
|
146
|
-
missing.push('reviewerEvidence');
|
|
147
|
-
}
|
|
148
|
-
return `${row.acceptanceCriterion}: missing ${missing.join(', ')}`;
|
|
149
|
-
});
|
|
127
|
+
const rowDetails = incompleteRows.map(describeIncompleteRow);
|
|
150
128
|
issues.push({
|
|
151
129
|
code: 'traceability_incomplete_rows',
|
|
152
130
|
message: `Traceability rows must include evidence, changed files, validation evidence, and reviewer evidence. Incomplete rows: ${rowDetails.join('; ')}`,
|
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
1
|
import { stat } from 'node:fs/promises';
|
|
3
2
|
import { isAbsolute, join, normalize } from 'node:path';
|
|
3
|
+
import { createCriterionIdentity, criterionIdentitiesMatch } from '../criterion-identity.js';
|
|
4
4
|
export function criterionKey(criterion) {
|
|
5
|
-
|
|
6
|
-
return createHash('sha256').update(normalized).digest('hex').slice(0, 16);
|
|
5
|
+
return createCriterionIdentity(criterion).stableKey;
|
|
7
6
|
}
|
|
8
7
|
export async function buildSpecEvidenceIndex(args) {
|
|
9
8
|
const diagnostics = [...args.artifacts.invalidArtifacts];
|
|
10
9
|
const matrixRows = args.artifacts.traceabilityMatrix?.rows ?? [];
|
|
11
10
|
const contractValidations = args.artifacts.contractValidations;
|
|
12
|
-
const entries = await Promise.all(args.criteria.map(async (criterion) => {
|
|
13
|
-
const
|
|
11
|
+
const entries = await Promise.all(args.criteria.map(async (criterion, criterionIndex) => {
|
|
12
|
+
const identity = createCriterionIdentity(criterion, { index: criterionIndex });
|
|
13
|
+
const rows = matrixRows.filter((row, rowIndex) => criterionIdentitiesMatch(createCriterionIdentity(row.acceptanceCriterion, { index: rowIndex }), identity));
|
|
14
14
|
const evidence = await collectEvidence({
|
|
15
15
|
rows,
|
|
16
16
|
contractValidations,
|
|
@@ -19,7 +19,7 @@ export async function buildSpecEvidenceIndex(args) {
|
|
|
19
19
|
});
|
|
20
20
|
return {
|
|
21
21
|
criterion,
|
|
22
|
-
criterionKey:
|
|
22
|
+
criterionKey: identity.stableKey,
|
|
23
23
|
evidence,
|
|
24
24
|
};
|
|
25
25
|
}));
|
|
@@ -149,7 +149,7 @@ function looksLikeShellCommand(value) {
|
|
|
149
149
|
if (/\s/.test(trimmed) && !/\.[A-Za-z0-9]+$/.test(trimmed)) {
|
|
150
150
|
return true;
|
|
151
151
|
}
|
|
152
|
-
return /^(?:pnpm|npm|yarn|bun|npx|node|tsx|tsc|vitest|jest|git|bash|sh)
|
|
152
|
+
return /^(?:pnpm|npm|yarn|bun|npx|node|tsx|tsc|vitest|jest|git|bash|sh)(?:\s|$)/.test(trimmed);
|
|
153
153
|
}
|
|
154
154
|
function looksLikeGlob(value) {
|
|
155
155
|
return /[*?[\]{}]/.test(value);
|
|
@@ -157,16 +157,4 @@ function looksLikeGlob(value) {
|
|
|
157
157
|
function record(kind, value, status, source, reason) {
|
|
158
158
|
return { kind, value, status, source, ...(reason ? { reason } : {}) };
|
|
159
159
|
}
|
|
160
|
-
function normalizeCriterion(value) {
|
|
161
|
-
const withoutCheckbox = value.replace(/^-\s*\[[ x]\]\s*/i, '').trim();
|
|
162
|
-
const criterionOnly = /^([A-Z]{2}\d+)\s*[:.)-]?\s*$/i.exec(withoutCheckbox)?.[1];
|
|
163
|
-
if (criterionOnly) {
|
|
164
|
-
return criterionOnly.toLowerCase();
|
|
165
|
-
}
|
|
166
|
-
return withoutCheckbox
|
|
167
|
-
.replace(/^[A-Z]{2}\d+\s*[:.)-]?\s*/i, '')
|
|
168
|
-
.replace(/\s+/g, ' ')
|
|
169
|
-
.trim()
|
|
170
|
-
.toLowerCase();
|
|
171
|
-
}
|
|
172
160
|
//# sourceMappingURL=index-builder.js.map
|
|
@@ -5,6 +5,11 @@ import type { FrontmatterResult } from '../types/frontmatter.js';
|
|
|
5
5
|
* Returns the original content as body if no frontmatter is found.
|
|
6
6
|
*/
|
|
7
7
|
export declare function parseFrontmatter(content: string): FrontmatterResult;
|
|
8
|
+
/**
|
|
9
|
+
* Read one unambiguous top-level string field from the canonical frontmatter block.
|
|
10
|
+
* Missing, malformed, nested, non-string, or duplicated fields fail closed.
|
|
11
|
+
*/
|
|
12
|
+
export declare function getUniqueTopLevelFrontmatterString(content: string, key: string): string | null;
|
|
8
13
|
/**
|
|
9
14
|
* Strip YAML frontmatter from markdown content, returning only the body.
|
|
10
15
|
* If no frontmatter is present, returns the original content unchanged.
|
|
@@ -36,6 +36,30 @@ export function parseFrontmatter(content) {
|
|
|
36
36
|
}
|
|
37
37
|
return { metadata, body: match[2] };
|
|
38
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Read one unambiguous top-level string field from the canonical frontmatter block.
|
|
41
|
+
* Missing, malformed, nested, non-string, or duplicated fields fail closed.
|
|
42
|
+
*/
|
|
43
|
+
export function getUniqueTopLevelFrontmatterString(content, key) {
|
|
44
|
+
if (!/^[A-Za-z_][A-Za-z0-9_-]*$/.test(key)) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
const match = FRONTMATTER_RE.exec(content);
|
|
48
|
+
if (!match?.[1] || match[2] === undefined) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const fieldPattern = new RegExp(`^${key}:\\s*(.*)$`);
|
|
52
|
+
const values = match[1]
|
|
53
|
+
.split('\n')
|
|
54
|
+
.map((line) => fieldPattern.exec(line)?.[1])
|
|
55
|
+
.filter((value) => value !== undefined);
|
|
56
|
+
const value = values[0];
|
|
57
|
+
if (values.length !== 1 || value === undefined) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
const parsed = parseScalar(value.trim());
|
|
61
|
+
return typeof parsed === 'string' && parsed.length > 0 ? parsed : null;
|
|
62
|
+
}
|
|
39
63
|
function parseScalar(value) {
|
|
40
64
|
if (value.startsWith('[') && value.endsWith(']')) {
|
|
41
65
|
return value
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { AbortableProcessResult, ProjectCommandPlan } from '../types/abortable-process.js';
|
|
2
|
+
export declare function resolveProjectCommandPlan(projectPath: string, requestedCommand: string, corepackAvailable?: boolean): ProjectCommandPlan;
|
|
3
|
+
export declare function formatLintFailureDiagnostics(plan: ProjectCommandPlan, output: string): string;
|
|
4
|
+
export declare function runProjectCommandPlan(plan: ProjectCommandPlan, projectPath: string, timeoutMs: number, signal?: AbortSignal): Promise<AbortableProcessResult>;
|
|
5
|
+
//# sourceMappingURL=project-command-runtime.d.ts.map
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
// tools/validate-runtime.ts — Project-aware command execution helpers for validate.
|
|
2
|
-
import { execFileSync } from 'node:child_process';
|
|
3
1
|
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { runAbortableProcess } from './abortable-process-runner.js';
|
|
4
3
|
function readProjectPackageManager(projectPath) {
|
|
5
4
|
try {
|
|
6
5
|
const raw = readFileSync(`${projectPath}/package.json`, 'utf-8');
|
|
@@ -14,20 +13,7 @@ function readProjectPackageManager(projectPath) {
|
|
|
14
13
|
function shellTokens(command) {
|
|
15
14
|
return command.split(/\s+/).filter((token) => token.length > 0);
|
|
16
15
|
}
|
|
17
|
-
function
|
|
18
|
-
try {
|
|
19
|
-
execFileSync('corepack', ['--version'], {
|
|
20
|
-
cwd: projectPath,
|
|
21
|
-
stdio: ['ignore', 'ignore', 'ignore'],
|
|
22
|
-
timeout: 5_000,
|
|
23
|
-
});
|
|
24
|
-
return true;
|
|
25
|
-
}
|
|
26
|
-
catch {
|
|
27
|
-
return false;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
export function resolveProjectCommandPlan(projectPath, requestedCommand, corepackAvailable = canRunCorepack(projectPath)) {
|
|
16
|
+
export function resolveProjectCommandPlan(projectPath, requestedCommand, corepackAvailable = true) {
|
|
31
17
|
const projectPackageManager = readProjectPackageManager(projectPath);
|
|
32
18
|
const tokens = shellTokens(requestedCommand);
|
|
33
19
|
const usesPnpm = tokens[0] === 'pnpm';
|
|
@@ -75,11 +61,12 @@ export function formatLintFailureDiagnostics(plan, output) {
|
|
|
75
61
|
}
|
|
76
62
|
return lines.filter((line, index) => index !== 0 || line.length > 0).join('\n');
|
|
77
63
|
}
|
|
78
|
-
export function runProjectCommandPlan(plan, projectPath, timeoutMs) {
|
|
79
|
-
|
|
64
|
+
export function runProjectCommandPlan(plan, projectPath, timeoutMs, signal) {
|
|
65
|
+
return runAbortableProcess(plan.executable, plan.args, {
|
|
80
66
|
cwd: projectPath,
|
|
81
|
-
|
|
82
|
-
|
|
67
|
+
timeoutMs,
|
|
68
|
+
maxBufferBytes: 64 * 1024 * 1024,
|
|
69
|
+
signal,
|
|
83
70
|
});
|
|
84
71
|
}
|
|
85
|
-
//# sourceMappingURL=
|
|
72
|
+
//# sourceMappingURL=project-command-runtime.js.map
|
|
@@ -135,37 +135,6 @@ function nativeCoreCacheVersion(runtime) {
|
|
|
135
135
|
: 'typescript-fallback';
|
|
136
136
|
return `${NATIVE_PROJECT_GRAPH_IMPLEMENTATION_VERSION}:${mode}`;
|
|
137
137
|
}
|
|
138
|
-
async function collectGitSource(projectPath) {
|
|
139
|
-
try {
|
|
140
|
-
const { execFile } = await import('node:child_process');
|
|
141
|
-
const stdout = await new Promise((resolveOutput, reject) => {
|
|
142
|
-
execFile('git', ['rev-parse', 'HEAD'], { cwd: projectPath }, (error, output) => {
|
|
143
|
-
if (error) {
|
|
144
|
-
reject(new Error('git rev-parse failed', { cause: error }));
|
|
145
|
-
return;
|
|
146
|
-
}
|
|
147
|
-
resolveOutput(output);
|
|
148
|
-
});
|
|
149
|
-
});
|
|
150
|
-
const head = stdout.trim();
|
|
151
|
-
if (head.length === 0) {
|
|
152
|
-
return null;
|
|
153
|
-
}
|
|
154
|
-
return {
|
|
155
|
-
id: 'project-git',
|
|
156
|
-
kind: 'project-git',
|
|
157
|
-
path: '.git/log',
|
|
158
|
-
content: head,
|
|
159
|
-
hash: hashText(head),
|
|
160
|
-
size: Buffer.byteLength(head),
|
|
161
|
-
mtimeMs: 0,
|
|
162
|
-
contentLoaded: true,
|
|
163
|
-
};
|
|
164
|
-
}
|
|
165
|
-
catch {
|
|
166
|
-
return null;
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
138
|
async function extractSourceFragment(args) {
|
|
170
139
|
if (args.source.kind === 'spec') {
|
|
171
140
|
return extractSpecGraph(args.source, args.policy);
|
|
@@ -209,16 +178,13 @@ async function buildProjectKnowledgeGraphUnlocked(args) {
|
|
|
209
178
|
const cacheResult = await readProjectGraphCache(args.projectId, args.policy);
|
|
210
179
|
const previousCache = cacheResult.cache;
|
|
211
180
|
const previousRecords = previousCache?.records ?? {};
|
|
212
|
-
|
|
181
|
+
let sources = await collectProjectGraphSources({
|
|
213
182
|
projectId: args.projectId,
|
|
214
183
|
projectPath: args.projectPath,
|
|
215
184
|
policy: args.policy,
|
|
216
185
|
previous: previousRecords,
|
|
217
186
|
nativeCoreVersion,
|
|
218
187
|
});
|
|
219
|
-
const gitSource = await collectGitSource(args.projectPath);
|
|
220
|
-
let sources = gitSource === null ? collected : [...collected, gitSource];
|
|
221
|
-
sources.sort((a, b) => a.id.localeCompare(b.id));
|
|
222
188
|
let diff = diffSourceHashes(sources, previousRecords, nativeCoreVersion, {
|
|
223
189
|
fragments: previousCache?.fragments,
|
|
224
190
|
knownSourceIds: cacheResult.knownSourceIds,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/* eslint-disable max-lines -- cache validation, collection, and freshness share one artifact contract. */
|
|
2
2
|
// @crash-shield-ignore-file - graph artifacts are Planu-owned JSON written by this subsystem.
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
4
5
|
import { open, readdir, readFile, stat } from 'node:fs/promises';
|
|
5
6
|
import { dirname, extname, join, relative, resolve } from 'node:path';
|
|
6
7
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
@@ -422,6 +423,36 @@ export async function hydrateProjectGraphSource(source, maxFileBytes) {
|
|
|
422
423
|
maxFileBytes,
|
|
423
424
|
});
|
|
424
425
|
}
|
|
426
|
+
async function collectGitSource(projectPath) {
|
|
427
|
+
try {
|
|
428
|
+
const stdout = await new Promise((resolveOutput, reject) => {
|
|
429
|
+
execFile('git', ['rev-parse', 'HEAD'], { cwd: projectPath }, (error, output) => {
|
|
430
|
+
if (error) {
|
|
431
|
+
reject(new Error('git rev-parse failed', { cause: error }));
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
resolveOutput(output);
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
const head = stdout.trim();
|
|
438
|
+
if (head.length === 0) {
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
return {
|
|
442
|
+
id: 'project-git',
|
|
443
|
+
kind: 'project-git',
|
|
444
|
+
path: '.git/log',
|
|
445
|
+
content: head,
|
|
446
|
+
hash: hashText(head),
|
|
447
|
+
size: Buffer.byteLength(head),
|
|
448
|
+
mtimeMs: 0,
|
|
449
|
+
contentLoaded: true,
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
catch {
|
|
453
|
+
return null;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
425
456
|
// eslint-disable-next-line max-lines-per-function -- source inventory is normalized in one deterministic pass.
|
|
426
457
|
export async function collectProjectGraphSources(args) {
|
|
427
458
|
const projectRoot = resolve(args.projectPath);
|
|
@@ -482,7 +513,12 @@ export async function collectProjectGraphSources(args) {
|
|
|
482
513
|
nativeCoreVersion: args.nativeCoreVersion,
|
|
483
514
|
maxFileBytes,
|
|
484
515
|
})));
|
|
485
|
-
|
|
516
|
+
const available = sources.filter((source) => source !== null);
|
|
517
|
+
const gitSource = await collectGitSource(projectRoot);
|
|
518
|
+
if (gitSource !== null) {
|
|
519
|
+
available.push(gitSource);
|
|
520
|
+
}
|
|
521
|
+
return available.sort((left, right) => left.id.localeCompare(right.id));
|
|
486
522
|
}
|
|
487
523
|
export async function getProjectGraphFreshness(args) {
|
|
488
524
|
const policy = args.policy ?? (await loadProjectGraphPolicy());
|
package/dist/engine/qa-gate.d.ts
CHANGED
|
@@ -2,5 +2,8 @@ import type { QaGateResult } from '../types/qa-gate.js';
|
|
|
2
2
|
import type { Spec } from '../types/spec/core.js';
|
|
3
3
|
export declare function extractCoverageThreshold(spec: Spec): number | null;
|
|
4
4
|
export declare function computeQaGateFingerprint(spec: Spec, projectPath: string): string;
|
|
5
|
-
export declare function runQaGate(spec: Spec, projectPath: string
|
|
5
|
+
export declare function runQaGate(spec: Spec, projectPath: string, options?: {
|
|
6
|
+
projectId?: string;
|
|
7
|
+
signal?: AbortSignal;
|
|
8
|
+
}): Promise<QaGateResult>;
|
|
6
9
|
//# sourceMappingURL=qa-gate.d.ts.map
|
package/dist/engine/qa-gate.js
CHANGED
|
@@ -1,22 +1,12 @@
|
|
|
1
1
|
// engine/qa-gate.ts — SPEC-642
|
|
2
|
-
import {
|
|
3
|
-
import { createHash } from 'node:crypto';
|
|
4
|
-
import { readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
5
3
|
import { join } from 'node:path';
|
|
6
|
-
import { resolveProjectCommandPlan } from '
|
|
4
|
+
import { resolveProjectCommandPlan } from './project-command-runtime.js';
|
|
5
|
+
import { computeValidationEvidenceIdentity, computeValidationEvidenceKey, readMatchingValidationEvidence, recordSuccessfulValidationEvidence, recordValidationOutcome, } from './validation-evidence-ledger.js';
|
|
6
|
+
import { runAbortableProcess } from './abortable-process-runner.js';
|
|
7
|
+
import { VALIDATION_COMMANDS } from './validation-impact-planner.js';
|
|
7
8
|
const TIMEOUT_MS = 120_000;
|
|
8
9
|
const MAX_OUTPUT_BUFFER_BYTES = 64 * 1024 * 1024;
|
|
9
|
-
const QA_FINGERPRINT_FILES = [
|
|
10
|
-
'package.json',
|
|
11
|
-
'pnpm-lock.yaml',
|
|
12
|
-
'package-lock.json',
|
|
13
|
-
'yarn.lock',
|
|
14
|
-
'bun.lockb',
|
|
15
|
-
'tsconfig.json',
|
|
16
|
-
'vitest.config.ts',
|
|
17
|
-
'vitest.config.mts',
|
|
18
|
-
'vitest.config.js',
|
|
19
|
-
];
|
|
20
10
|
export function extractCoverageThreshold(spec) {
|
|
21
11
|
const text = [spec.title, ...spec.tags].join(' ');
|
|
22
12
|
const match = /coverage\s*[>=]+\s*(\d+)%/i.exec(text);
|
|
@@ -25,34 +15,75 @@ export function extractCoverageThreshold(spec) {
|
|
|
25
15
|
}
|
|
26
16
|
return parseInt(match[1], 10);
|
|
27
17
|
}
|
|
28
|
-
function runCheck(name, command, args, cwd) {
|
|
18
|
+
async function runCheck(name, command, args, cwd, signal) {
|
|
29
19
|
const start = Date.now();
|
|
30
|
-
const result =
|
|
20
|
+
const result = await runAbortableProcess(command, args, {
|
|
31
21
|
cwd,
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
22
|
+
timeoutMs: TIMEOUT_MS,
|
|
23
|
+
maxBufferBytes: MAX_OUTPUT_BUFFER_BYTES,
|
|
24
|
+
signal,
|
|
35
25
|
});
|
|
36
26
|
const durationMs = Date.now() - start;
|
|
37
27
|
const passed = result.status === 0 && !result.error;
|
|
38
28
|
const output = [result.stdout, result.stderr].join('\n').trim().slice(0, 2000);
|
|
39
|
-
const errorCode = result.error && 'code' in result.error
|
|
40
|
-
? String(result.error.code)
|
|
41
|
-
: undefined;
|
|
42
|
-
const timedOut = errorCode === 'ETIMEDOUT' || result.signal === 'SIGTERM';
|
|
43
29
|
return {
|
|
44
30
|
name,
|
|
45
31
|
passed,
|
|
46
32
|
output,
|
|
47
33
|
durationMs,
|
|
48
34
|
command: [command, ...args].join(' '),
|
|
49
|
-
timedOut,
|
|
35
|
+
timedOut: result.timedOut,
|
|
50
36
|
errorMessage: result.error?.message,
|
|
51
37
|
};
|
|
52
38
|
}
|
|
53
|
-
function runRequestedCheck(name, requestedCommand, cwd) {
|
|
39
|
+
async function runRequestedCheck(name, requestedCommand, cwd, projectId, signal) {
|
|
54
40
|
const plan = resolveProjectCommandPlan(cwd, requestedCommand);
|
|
55
|
-
|
|
41
|
+
const input = {
|
|
42
|
+
projectPath: cwd,
|
|
43
|
+
capability: name === 'typecheck' ? 'typecheck' : 'test-full',
|
|
44
|
+
command: requestedCommand,
|
|
45
|
+
scope: ['src', 'tests'],
|
|
46
|
+
};
|
|
47
|
+
const expectedKey = computeValidationEvidenceKey(computeValidationEvidenceIdentity(input));
|
|
48
|
+
if (projectId) {
|
|
49
|
+
const receipt = readMatchingValidationEvidence(projectId, input);
|
|
50
|
+
if (receipt) {
|
|
51
|
+
return {
|
|
52
|
+
name,
|
|
53
|
+
passed: true,
|
|
54
|
+
output: `reused canonical validation receipt ${receipt.key}`,
|
|
55
|
+
durationMs: 0,
|
|
56
|
+
command: plan.executedCommand,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const result = await runCheck(name, plan.executable, plan.args, cwd, signal);
|
|
61
|
+
if (projectId && result.passed) {
|
|
62
|
+
recordSuccessfulValidationEvidence({
|
|
63
|
+
projectId,
|
|
64
|
+
input,
|
|
65
|
+
expectedKey,
|
|
66
|
+
producer: 'planu-qa-gate',
|
|
67
|
+
durationMs: result.durationMs,
|
|
68
|
+
output: result.output,
|
|
69
|
+
reason: `${name} passed during the canonical QA gate.`,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
else if (projectId) {
|
|
73
|
+
recordValidationOutcome({
|
|
74
|
+
projectId,
|
|
75
|
+
input,
|
|
76
|
+
action: result.timedOut || signal?.aborted === true ? 'aborted' : 'executed',
|
|
77
|
+
producer: 'planu-qa-gate',
|
|
78
|
+
status: result.timedOut || signal?.aborted === true ? 'cancelled' : 'failed',
|
|
79
|
+
durationMs: result.durationMs,
|
|
80
|
+
output: result.output,
|
|
81
|
+
reason: result.timedOut || signal?.aborted === true
|
|
82
|
+
? `${name} was cancelled before evidence persistence.`
|
|
83
|
+
: `${name} failed during the canonical QA gate.`,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return result;
|
|
56
87
|
}
|
|
57
88
|
function readProjectPackageManager(projectPath) {
|
|
58
89
|
try {
|
|
@@ -67,57 +98,39 @@ function readProjectPackageManager(projectPath) {
|
|
|
67
98
|
function resolveRequestedQaCommand(projectPath, check) {
|
|
68
99
|
const manager = readProjectPackageManager(projectPath)?.split('@')[0] ?? 'pnpm';
|
|
69
100
|
if (check === 'typecheck') {
|
|
70
|
-
return `${manager} typecheck`;
|
|
101
|
+
return manager === 'pnpm' ? VALIDATION_COMMANDS.typecheck : `${manager} typecheck`;
|
|
71
102
|
}
|
|
72
103
|
if (check === 'test-coverage') {
|
|
73
104
|
return `${manager} test:coverage`;
|
|
74
105
|
}
|
|
75
|
-
return `${manager} test`;
|
|
76
|
-
}
|
|
77
|
-
function asStableString(value) {
|
|
78
|
-
return typeof value === 'string' ? value : '';
|
|
79
|
-
}
|
|
80
|
-
function asStableStringArray(value) {
|
|
81
|
-
return Array.isArray(value)
|
|
82
|
-
? value.filter((item) => typeof item === 'string')
|
|
83
|
-
: [];
|
|
106
|
+
return manager === 'pnpm' ? VALIDATION_COMMANDS['test-full'] : `${manager} test`;
|
|
84
107
|
}
|
|
85
108
|
export function computeQaGateFingerprint(spec, projectPath) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const fullPath = join(projectPath, file);
|
|
93
|
-
try {
|
|
94
|
-
const stats = statSync(fullPath);
|
|
95
|
-
hash.update(`${file}:${String(stats.mtimeMs)}:${String(stats.size)}`);
|
|
96
|
-
}
|
|
97
|
-
catch {
|
|
98
|
-
hash.update(`${file}:missing`);
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
return hash.digest('hex');
|
|
109
|
+
return computeValidationEvidenceKey(computeValidationEvidenceIdentity({
|
|
110
|
+
projectPath,
|
|
111
|
+
capability: extractCoverageThreshold(spec) === null ? 'test-full' : 'test-full',
|
|
112
|
+
command: resolveRequestedQaCommand(projectPath, extractCoverageThreshold(spec) === null ? 'test' : 'test-coverage'),
|
|
113
|
+
scope: ['src', 'tests'],
|
|
114
|
+
}));
|
|
102
115
|
}
|
|
103
|
-
export function runQaGate(spec, projectPath) {
|
|
116
|
+
export async function runQaGate(spec, projectPath, options = {}) {
|
|
104
117
|
const coverageThreshold = extractCoverageThreshold(spec);
|
|
105
118
|
const checks = [];
|
|
106
|
-
checks.push(runRequestedCheck('typecheck', resolveRequestedQaCommand(projectPath, 'typecheck'), projectPath));
|
|
119
|
+
checks.push(await runRequestedCheck('typecheck', resolveRequestedQaCommand(projectPath, 'typecheck'), projectPath, options.projectId, options.signal));
|
|
107
120
|
if (coverageThreshold === null) {
|
|
108
|
-
checks.push(runRequestedCheck('test', resolveRequestedQaCommand(projectPath, 'test'), projectPath));
|
|
121
|
+
checks.push(await runRequestedCheck('test', resolveRequestedQaCommand(projectPath, 'test'), projectPath, options.projectId, options.signal));
|
|
109
122
|
}
|
|
110
123
|
else {
|
|
111
|
-
checks.push(runRequestedCheck('test-coverage', resolveRequestedQaCommand(projectPath, 'test-coverage'), projectPath));
|
|
124
|
+
checks.push(await runRequestedCheck('test-coverage', resolveRequestedQaCommand(projectPath, 'test-coverage'), projectPath, options.projectId, options.signal));
|
|
112
125
|
}
|
|
113
126
|
const passed = checks.every((c) => c.passed);
|
|
114
|
-
return
|
|
127
|
+
return {
|
|
115
128
|
specId: spec.id,
|
|
116
129
|
passed,
|
|
117
130
|
checks,
|
|
118
131
|
ranAt: new Date().toISOString(),
|
|
119
132
|
coverageThreshold,
|
|
120
133
|
fingerprint: computeQaGateFingerprint(spec, projectPath),
|
|
121
|
-
}
|
|
134
|
+
};
|
|
122
135
|
}
|
|
123
136
|
//# sourceMappingURL=qa-gate.js.map
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CrossProcessLockMetadata, AcquireLockOptions, LockHandle, LockInfo } from '../../types/spec-lock-v2.js';
|
|
2
|
+
export { getLockScavengeMetrics, resetLockScavengeMetricsForTests, scavengeStaleLockArtifacts, } from './lock-scavenger.js';
|
|
2
3
|
declare const HEARTBEAT_INTERVAL_MS = 30000;
|
|
3
4
|
export { HEARTBEAT_INTERVAL_MS };
|
|
4
5
|
/** Thrown by withSpecLock when the lock is held by another live session. */
|
|
@@ -13,12 +14,6 @@ export declare class LockBusyError extends Error {
|
|
|
13
14
|
* Returns a LockHandle on success, or null if the lock is held by another live session.
|
|
14
15
|
* Stale locks (PID dead, heartbeat expired, or TTL expired) are reclaimed automatically.
|
|
15
16
|
*
|
|
16
|
-
* Implementation:
|
|
17
|
-
* - Attempt `fs.open(lockPath, 'wx')` (O_EXCL) — succeeds only when no lockfile exists.
|
|
18
|
-
* - On EEXIST, read the existing lockfile and check staleness.
|
|
19
|
-
* - If stale: overwrite atomically via SPEC-718 writeJsonSafe and return a new handle.
|
|
20
|
-
* - If live: log a structured warning and return null.
|
|
21
|
-
*
|
|
22
17
|
* @remarks
|
|
23
18
|
* NFS / SMB caveat: O_EXCL is not guaranteed atomic on network filesystems.
|
|
24
19
|
* This implementation is reliable on local POSIX filesystems (APFS, HFS+, ext4, tmpfs).
|
|
@@ -1,21 +1,16 @@
|
|
|
1
1
|
// src/engine/safety/cross-process-lock.ts — Cross-process spec lockfile (SPEC-719)
|
|
2
2
|
//
|
|
3
|
-
// Provides disk-backed mutual exclusion across Node.js processes for spec.md mutations.
|
|
4
|
-
// Stale lock detection uses three signals: PID death, heartbeat expiry, TTL expiry.
|
|
5
|
-
//
|
|
6
|
-
// NFS / Network FS caveat: lockfile correctness assumes local POSIX filesystem semantics.
|
|
7
|
-
// SMB / NFSv3 may produce false acquires because O_EXCL is not guaranteed atomic on
|
|
8
|
-
// network filesystems. For local-disk POSIX (HFS+, APFS, ext4, tmpfs) this is reliable.
|
|
9
|
-
//
|
|
10
3
|
// Lockfiles live at: <projectPath>/data/.locks/planu/<specId>.lock
|
|
11
4
|
// File mode: 0o600 (owner read/write only)
|
|
12
|
-
import { open, unlink, readFile, mkdir, chmod } from 'node:fs/promises';
|
|
5
|
+
import { open, unlink, readFile, mkdir, chmod, readdir } from 'node:fs/promises';
|
|
13
6
|
import { existsSync } from 'node:fs';
|
|
14
|
-
import { join } from 'node:path';
|
|
7
|
+
import { basename, dirname, join } from 'node:path';
|
|
15
8
|
import { hostname as osHostname } from 'node:os';
|
|
16
9
|
import { randomUUID } from 'node:crypto';
|
|
17
10
|
import { writeJsonSafe } from './atomic-writer.js';
|
|
18
11
|
import { enrichWithStaleness } from './lock-staleness.js';
|
|
12
|
+
import { readValidatedLockFile, scavengeStaleLockArtifacts } from './lock-scavenger.js';
|
|
13
|
+
export { getLockScavengeMetrics, resetLockScavengeMetricsForTests, scavengeStaleLockArtifacts, } from './lock-scavenger.js';
|
|
19
14
|
// ---------------------------------------------------------------------------
|
|
20
15
|
// Constants
|
|
21
16
|
// ---------------------------------------------------------------------------
|
|
@@ -139,17 +134,12 @@ function buildHandle(metadata, path) {
|
|
|
139
134
|
* Returns a LockHandle on success, or null if the lock is held by another live session.
|
|
140
135
|
* Stale locks (PID dead, heartbeat expired, or TTL expired) are reclaimed automatically.
|
|
141
136
|
*
|
|
142
|
-
* Implementation:
|
|
143
|
-
* - Attempt `fs.open(lockPath, 'wx')` (O_EXCL) — succeeds only when no lockfile exists.
|
|
144
|
-
* - On EEXIST, read the existing lockfile and check staleness.
|
|
145
|
-
* - If stale: overwrite atomically via SPEC-718 writeJsonSafe and return a new handle.
|
|
146
|
-
* - If live: log a structured warning and return null.
|
|
147
|
-
*
|
|
148
137
|
* @remarks
|
|
149
138
|
* NFS / SMB caveat: O_EXCL is not guaranteed atomic on network filesystems.
|
|
150
139
|
* This implementation is reliable on local POSIX filesystems (APFS, HFS+, ext4, tmpfs).
|
|
151
140
|
*/
|
|
152
141
|
export async function acquireLock(projectPath, specId, opts) {
|
|
142
|
+
await scavengeStaleLockArtifacts(projectPath, specId);
|
|
153
143
|
const path = lockPath(projectPath, specId);
|
|
154
144
|
const now = Date.now();
|
|
155
145
|
const sessionId = resolveSessionId(opts);
|
|
@@ -236,6 +226,18 @@ export async function releaseLock(handle) {
|
|
|
236
226
|
}
|
|
237
227
|
throw err;
|
|
238
228
|
}
|
|
229
|
+
const dir = dirname(path);
|
|
230
|
+
const base = basename(path);
|
|
231
|
+
const backups = await readdir(dir).catch(() => []);
|
|
232
|
+
await Promise.all(backups
|
|
233
|
+
.filter((entry) => entry.startsWith(`${base}.bak.`))
|
|
234
|
+
.map(async (entry) => {
|
|
235
|
+
const backupPath = join(dir, entry);
|
|
236
|
+
const backup = await readValidatedLockFile(backupPath);
|
|
237
|
+
if (backup?.metadata.sessionId === handle.metadata.sessionId) {
|
|
238
|
+
await unlink(backupPath).catch(() => undefined);
|
|
239
|
+
}
|
|
240
|
+
}));
|
|
239
241
|
}
|
|
240
242
|
/**
|
|
241
243
|
* Read the current lockfile state for a spec.
|
|
@@ -259,7 +261,7 @@ export async function listActiveCrossProcessLocks(projectPath) {
|
|
|
259
261
|
if (!existsSync(dir)) {
|
|
260
262
|
return [];
|
|
261
263
|
}
|
|
262
|
-
|
|
264
|
+
await scavengeStaleLockArtifacts(projectPath);
|
|
263
265
|
let entries;
|
|
264
266
|
try {
|
|
265
267
|
entries = await readdir(dir);
|