@planu/cli 5.3.57 → 5.3.59
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 +31 -0
- package/dist/.planu-build.json +1 -1
- package/dist/cli/commands/doctor.d.ts +3 -1
- package/dist/cli/commands/doctor.js +59 -3
- package/dist/engine/cascade-hooks/core/append-releases.d.ts +2 -2
- package/dist/engine/cascade-hooks/core/append-releases.js +46 -26
- package/dist/engine/cascade-hooks/hooks/review-enricher.hook.js +13 -2
- package/dist/engine/cascade-hooks/integrity.d.ts +2 -0
- package/dist/engine/cascade-hooks/integrity.js +40 -0
- package/dist/engine/technical-enricher/index.js +2 -0
- package/dist/tools/schemas/validate-output-schema.d.ts +8 -0
- package/dist/tools/schemas/validate-output-schema.js +2 -0
- package/dist/tools/validate-graph-coverage.js +19 -7
- package/dist/tools/validate.js +4 -3
- package/dist/types/project-knowledge-graph.d.ts +1 -0
- package/package.json +1 -1
- package/planu-plugin.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,34 @@
|
|
|
1
|
+
## [5.3.59] - 2026-08-26
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
- fix(SPEC-1631): report graph coverage as unavailable instead of zero gaps
|
|
5
|
+
|
|
6
|
+
### Chores
|
|
7
|
+
- chore(planu): close SPEC-1631
|
|
8
|
+
- chore(planu): record SPEC-1631 implementing transition
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
## [5.3.58] - 2026-08-26
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
- fix(SPEC-1317): assert the dynamic-import edge for the pending-release helper
|
|
15
|
+
- fix(SPEC-1319): close review findings on enricher integrity wiring
|
|
16
|
+
- fix(SPEC-1317): align doctor deep-check count with the installation-integrity check
|
|
17
|
+
- fix(SPEC-1319): block stale review-enricher runtimes from truncating canonical specs
|
|
18
|
+
- fix(SPEC-1317): diagnose and recover incomplete CLI installs without crashing planu status
|
|
19
|
+
|
|
20
|
+
### Refactoring
|
|
21
|
+
- refactor(SPEC-1319): move enrichment integrity validator to a neutral module
|
|
22
|
+
- refactor(SPEC-1317): extract pending-ledger rewrite to satisfy the function-length gate
|
|
23
|
+
|
|
24
|
+
### Chores
|
|
25
|
+
- chore(planu): close SPEC-1317 and SPEC-1319
|
|
26
|
+
- chore(SPEC-1319): integrate review-enricher integrity and stale-dist guard
|
|
27
|
+
- chore(SPEC-1317): integrate incomplete-install doctor and release-metadata degradation
|
|
28
|
+
- chore(planu): record SPEC-1317 implementing state
|
|
29
|
+
- chore(planu): record SPEC-1319 implementing and fix SPEC-1317 files ownership
|
|
30
|
+
|
|
31
|
+
|
|
1
32
|
## [5.3.57] - 2026-08-26
|
|
2
33
|
|
|
3
34
|
### Bug Fixes
|
package/dist/.planu-build.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"schemaVersion":1,"commit":"
|
|
1
|
+
{"schemaVersion":1,"commit":"8ad23a1b29d2ffe8c44122aba263f9a6e9577d80"}
|
|
@@ -25,8 +25,10 @@ declare function checkDataDirWritable(): DeepCheckResult;
|
|
|
25
25
|
declare function checkRuntimeDatabase(): DeepCheckResult;
|
|
26
26
|
/** Check (4): the active locale resolves to a real translated message. */
|
|
27
27
|
declare function checkLocaleResolution(): DeepCheckResult;
|
|
28
|
+
/** Check (5): every package-owned runtime helper compiled CLI code imports is present. */
|
|
29
|
+
declare function checkInstallationIntegrity(): DeepCheckResult;
|
|
28
30
|
declare function runDeepChecks(): DeepCheckResult[];
|
|
29
|
-
export { checkNodeVersion, checkDataDirWritable, checkRuntimeDatabase, checkLocaleResolution, runDeepChecks, };
|
|
31
|
+
export { checkNodeVersion, checkDataDirWritable, checkRuntimeDatabase, checkLocaleResolution, checkInstallationIntegrity, runDeepChecks, };
|
|
30
32
|
export { checkTool };
|
|
31
33
|
export declare const doctorCommand: CliCommand;
|
|
32
34
|
//# sourceMappingURL=doctor.d.ts.map
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// cli/commands/doctor.ts — planu doctor: verify MCP installations health (SPEC-236)
|
|
2
2
|
// Deep diagnostics (Node version, storage writability, runtime DB,
|
|
3
3
|
// locale resolution) added by SPEC-1346 / SPEC-1344.
|
|
4
|
-
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
|
-
import { dirname, join } from 'node:path';
|
|
6
|
+
import { dirname, isAbsolute, join, relative } from 'node:path';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
import { bold, cyan, green, yellow, red, dim } from '../colors.js';
|
|
9
9
|
import { readJsonFile, hasPlanuEntry } from './install.js';
|
|
@@ -97,6 +97,8 @@ function checkTool(check) {
|
|
|
97
97
|
// ---------------------------------------------------------------------------
|
|
98
98
|
/** Root of the package, resolved relative to this module so it works from src/ and dist/. */
|
|
99
99
|
const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '../../../package.json');
|
|
100
|
+
/** Package-relative runtime helpers compiled CLI code imports outside dist/. */
|
|
101
|
+
const REQUIRED_RUNTIME_HELPERS = ['scripts/lib/pending-release-file.mjs'];
|
|
100
102
|
function parseVersionTuple(version) {
|
|
101
103
|
const match = /(\d+)\.(\d+)\.(\d+)/.exec(version);
|
|
102
104
|
return [Number(match?.[1] ?? 0), Number(match?.[2] ?? 0), Number(match?.[3] ?? 0)];
|
|
@@ -196,12 +198,66 @@ function checkLocaleResolution() {
|
|
|
196
198
|
}
|
|
197
199
|
return { name: 'Locale resolution', status: 'ok', detail: `Resolved locale: ${locale}` };
|
|
198
200
|
}
|
|
201
|
+
function readInstalledVersion() {
|
|
202
|
+
try {
|
|
203
|
+
const pkg = JSON.parse(readFileSync(PACKAGE_ROOT, 'utf-8'));
|
|
204
|
+
return pkg.version ?? 'unknown';
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return 'unknown';
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function readInstalledPackageName() {
|
|
211
|
+
try {
|
|
212
|
+
const pkg = JSON.parse(readFileSync(PACKAGE_ROOT, 'utf-8'));
|
|
213
|
+
return pkg.name ?? '@planu/cli';
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return '@planu/cli';
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
function isContainedInPackageRoot(packageRoot, candidate) {
|
|
220
|
+
const rel = relative(packageRoot, candidate);
|
|
221
|
+
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
|
|
222
|
+
}
|
|
223
|
+
function isRegularFileWithinPackage(packageRoot, relativePath) {
|
|
224
|
+
const candidate = join(packageRoot, relativePath);
|
|
225
|
+
if (!isContainedInPackageRoot(packageRoot, candidate)) {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
return statSync(candidate).isFile();
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/** Check (5): every package-owned runtime helper compiled CLI code imports is present. */
|
|
236
|
+
function checkInstallationIntegrity() {
|
|
237
|
+
const packageRoot = dirname(PACKAGE_ROOT);
|
|
238
|
+
const missing = REQUIRED_RUNTIME_HELPERS.filter((helper) => !isRegularFileWithinPackage(packageRoot, helper));
|
|
239
|
+
if (missing.length === 0) {
|
|
240
|
+
return {
|
|
241
|
+
name: 'Installation integrity',
|
|
242
|
+
status: 'ok',
|
|
243
|
+
detail: 'All package-owned runtime helpers are present.',
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
const version = readInstalledVersion();
|
|
247
|
+
const packageName = readInstalledPackageName();
|
|
248
|
+
return {
|
|
249
|
+
name: 'Installation integrity',
|
|
250
|
+
status: 'fail',
|
|
251
|
+
detail: `INCOMPLETE_INSTALLATION: ${packageName}@${version} is missing ${missing.join(', ')}. Reinstall with: npm install -g ${packageName}@${version}`,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
199
254
|
function runDeepChecks() {
|
|
200
255
|
return [
|
|
201
256
|
checkNodeVersion(),
|
|
202
257
|
checkDataDirWritable(),
|
|
203
258
|
checkRuntimeDatabase(),
|
|
204
259
|
checkLocaleResolution(),
|
|
260
|
+
checkInstallationIntegrity(),
|
|
205
261
|
];
|
|
206
262
|
}
|
|
207
263
|
function deepCheckIcon(status) {
|
|
@@ -226,7 +282,7 @@ function printDeepChecks(results) {
|
|
|
226
282
|
}
|
|
227
283
|
}
|
|
228
284
|
// Exported for tests
|
|
229
|
-
export { checkNodeVersion, checkDataDirWritable, checkRuntimeDatabase, checkLocaleResolution, runDeepChecks, };
|
|
285
|
+
export { checkNodeVersion, checkDataDirWritable, checkRuntimeDatabase, checkLocaleResolution, checkInstallationIntegrity, runDeepChecks, };
|
|
230
286
|
function statusIcon(status) {
|
|
231
287
|
switch (status) {
|
|
232
288
|
case 'ok':
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import type { CascadeContext, CoreActionResult, PendingReleaseEntry } from '../../../types/cascade-hooks.js';
|
|
2
|
-
import {
|
|
2
|
+
import type { ReleaseTagInfo } from '../../../../scripts/lib/pending-release-file.mjs';
|
|
3
3
|
export declare function normalizePendingReleaseEntries(value: unknown): (PendingReleaseEntry & {
|
|
4
4
|
implementationCommit?: string;
|
|
5
5
|
})[];
|
|
6
6
|
export declare function reconcilePublishedPendingEntries(entries: readonly (PendingReleaseEntry & {
|
|
7
7
|
implementationCommit?: string;
|
|
8
|
-
})[], release: ReleaseTagInfo | null): (PendingReleaseEntry & {
|
|
8
|
+
})[], release: ReleaseTagInfo | null, isCommitReachable?: (repoRoot: string, commit: string, target: string) => boolean): (PendingReleaseEntry & {
|
|
9
9
|
implementationCommit?: string;
|
|
10
10
|
})[];
|
|
11
11
|
export declare function appendReleasesPending(ctx: CascadeContext): Promise<CoreActionResult>;
|
|
@@ -4,12 +4,17 @@
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { readFile, mkdir } from 'node:fs/promises';
|
|
6
6
|
import { execFile as execFileCallback } from 'node:child_process';
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
7
8
|
import { promisify } from 'node:util';
|
|
8
9
|
import { specStore } from '../../../storage/index.js';
|
|
9
10
|
import { reportClassifiedDegradation } from '../../../errors/classified-degradation.js';
|
|
10
|
-
import { isCommitReachable, resolveLatestReleaseTag, withPendingReleaseLock, writePendingReleaseLedger, } from '../../../../scripts/lib/pending-release-file.mjs';
|
|
11
11
|
const CORE_ACTION_BUDGET_MS = 2000;
|
|
12
12
|
const execFile = promisify(execFileCallback);
|
|
13
|
+
const requireFromHere = createRequire(import.meta.url);
|
|
14
|
+
function requireIsCommitReachable(repoRoot, commit, target) {
|
|
15
|
+
const helpers = requireFromHere('../../../../scripts/lib/pending-release-file.mjs');
|
|
16
|
+
return helpers.isCommitReachable(repoRoot, commit, target);
|
|
17
|
+
}
|
|
13
18
|
function isValidPendingReleaseEntry(value) {
|
|
14
19
|
if (typeof value !== 'object' || value === null) {
|
|
15
20
|
return false;
|
|
@@ -37,7 +42,7 @@ export function normalizePendingReleaseEntries(value) {
|
|
|
37
42
|
}
|
|
38
43
|
return [...deduped.values()];
|
|
39
44
|
}
|
|
40
|
-
export function reconcilePublishedPendingEntries(entries, release) {
|
|
45
|
+
export function reconcilePublishedPendingEntries(entries, release, isCommitReachable = requireIsCommitReachable) {
|
|
41
46
|
if (!release) {
|
|
42
47
|
return [...entries];
|
|
43
48
|
}
|
|
@@ -72,6 +77,27 @@ async function captureHeadCommit(projectPath) {
|
|
|
72
77
|
return undefined;
|
|
73
78
|
}
|
|
74
79
|
}
|
|
80
|
+
async function rewritePendingLedger(input) {
|
|
81
|
+
let pendingList = [];
|
|
82
|
+
try {
|
|
83
|
+
const raw = await readFile(input.pendingPath, 'utf-8');
|
|
84
|
+
pendingList = normalizePendingReleaseEntries(JSON.parse(raw));
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
if (error.code !== 'ENOENT') {
|
|
88
|
+
reportClassifiedDegradation('PENDING_RELEASE_LEDGER_UNREADABLE', error);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
pendingList = reconcilePublishedPendingEntries(pendingList, input.releaseInfo, input.isCommitReachable).filter((entry) => entry.specId !== input.specId);
|
|
93
|
+
pendingList.push({
|
|
94
|
+
specId: input.specId,
|
|
95
|
+
title: input.title,
|
|
96
|
+
completedAt: new Date().toISOString().substring(0, 10),
|
|
97
|
+
...(input.implementationCommit ? { implementationCommit: input.implementationCommit } : {}),
|
|
98
|
+
});
|
|
99
|
+
await input.writePendingReleaseLedger(input.pendingPath, pendingList);
|
|
100
|
+
}
|
|
75
101
|
async function actuallyAppendReleasesPending(ctx, opts) {
|
|
76
102
|
const { projectPath, projectId, specId } = ctx;
|
|
77
103
|
if (!projectPath || ctx.newStatus !== 'done') {
|
|
@@ -80,6 +106,15 @@ async function actuallyAppendReleasesPending(ctx, opts) {
|
|
|
80
106
|
if (opts.signal.aborted) {
|
|
81
107
|
throw new Error('append-releases-pending aborted before start');
|
|
82
108
|
}
|
|
109
|
+
let helpers;
|
|
110
|
+
try {
|
|
111
|
+
helpers = await import('../../../../scripts/lib/pending-release-file.mjs');
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
reportClassifiedDegradation('RELEASE_METADATA_UNAVAILABLE', error);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const { isCommitReachable, resolveLatestReleaseTag, withPendingReleaseLock, writePendingReleaseLedger, } = helpers;
|
|
83
118
|
const releasesDir = join(projectPath, 'planu', 'releases');
|
|
84
119
|
const pendingPath = join(releasesDir, 'pending.json');
|
|
85
120
|
await new Promise((resolve, reject) => {
|
|
@@ -91,30 +126,15 @@ async function actuallyAppendReleasesPending(ctx, opts) {
|
|
|
91
126
|
const spec = await specStore.getSpec(projectId, specId);
|
|
92
127
|
const releaseInfo = await resolveLatestReleaseTag(projectPath);
|
|
93
128
|
const implementationCommit = await captureHeadCommit(projectPath);
|
|
94
|
-
await withPendingReleaseLock(pendingPath, { timeoutMs: 1_500, signal: opts.signal },
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
reportClassifiedDegradation('PENDING_RELEASE_LEDGER_UNREADABLE', error);
|
|
104
|
-
return;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
pendingList = reconcilePublishedPendingEntries(pendingList, releaseInfo);
|
|
108
|
-
const completedAt = new Date().toISOString().substring(0, 10);
|
|
109
|
-
pendingList = pendingList.filter((entry) => entry.specId !== specId);
|
|
110
|
-
pendingList.push({
|
|
111
|
-
specId,
|
|
112
|
-
title: spec?.title ?? specId,
|
|
113
|
-
completedAt,
|
|
114
|
-
...(implementationCommit ? { implementationCommit } : {}),
|
|
115
|
-
});
|
|
116
|
-
await writePendingReleaseLedger(pendingPath, pendingList);
|
|
117
|
-
});
|
|
129
|
+
await withPendingReleaseLock(pendingPath, { timeoutMs: 1_500, signal: opts.signal }, () => rewritePendingLedger({
|
|
130
|
+
pendingPath,
|
|
131
|
+
specId,
|
|
132
|
+
title: spec?.title ?? specId,
|
|
133
|
+
implementationCommit,
|
|
134
|
+
releaseInfo,
|
|
135
|
+
isCommitReachable,
|
|
136
|
+
writePendingReleaseLedger,
|
|
137
|
+
}));
|
|
118
138
|
void (async () => {
|
|
119
139
|
try {
|
|
120
140
|
const { planuAutoCommit } = await import('../../git/planu-autocommit.js');
|
|
@@ -1,18 +1,29 @@
|
|
|
1
1
|
import { reportClassifiedDegradation } from '../../../errors/classified-degradation.js';
|
|
2
|
+
import { assertLocalBuildFreshness } from '../../build-freshness.js';
|
|
3
|
+
function isEnrichmentIntegrityFailure(error) {
|
|
4
|
+
return error instanceof Error && error.message.startsWith('SPEC_ENRICHMENT_INTEGRITY_FAILED');
|
|
5
|
+
}
|
|
2
6
|
async function handler(ctx) {
|
|
3
7
|
const { projectPath, specId, spec } = ctx;
|
|
4
8
|
if (!projectPath) {
|
|
5
9
|
return;
|
|
6
10
|
}
|
|
7
|
-
// Skip trivial-scope specs
|
|
8
11
|
if (spec.scope === 'trivial') {
|
|
9
12
|
return;
|
|
10
13
|
}
|
|
11
14
|
try {
|
|
15
|
+
if (assertLocalBuildFreshness().status === 'stale') {
|
|
16
|
+
reportClassifiedDegradation('STALE_LOCAL_DIST', new Error('Local build output is stale relative to the active checkout. Run `pnpm build:ts` before retrying.'));
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
12
19
|
const { runTechnicalEnricher } = await import('../../../engine/technical-enricher/index.js');
|
|
13
20
|
await runTechnicalEnricher({ specId, projectPath, specScope: spec.scope });
|
|
14
21
|
}
|
|
15
|
-
catch {
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (isEnrichmentIntegrityFailure(error)) {
|
|
24
|
+
reportClassifiedDegradation('SPEC_ENRICHMENT_INTEGRITY_FAILED', new Error('Review enrichment output failed the canonical spec integrity contract'));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
16
27
|
reportClassifiedDegradation('REVIEW_ENRICHER_FAILED', new Error('Technical review enrichment failed'));
|
|
17
28
|
}
|
|
18
29
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
const HISTORICAL_TRUNCATION_MARKER = '[truncated — spec too large, enrich remaining files manually]';
|
|
2
|
+
const REQUIRED_WORD_MARKERS = ['GIVEN', 'WHEN', 'THEN', 'AND'];
|
|
3
|
+
const REQUIRED_LITERAL_MARKERS = ['FILES:', 'FUNCTIONS:', 'TEST:'];
|
|
4
|
+
const HEADING_LINE_PATTERN = /^#{1,6}[ \t]+.+$/gm;
|
|
5
|
+
function countLiteralOccurrences(haystack, needle) {
|
|
6
|
+
let count = 0;
|
|
7
|
+
let index = haystack.indexOf(needle);
|
|
8
|
+
while (index !== -1) {
|
|
9
|
+
count += 1;
|
|
10
|
+
index = haystack.indexOf(needle, index + needle.length);
|
|
11
|
+
}
|
|
12
|
+
return count;
|
|
13
|
+
}
|
|
14
|
+
function countWordOccurrences(haystack, word) {
|
|
15
|
+
return (haystack.match(new RegExp(`\\b${word}\\b`, 'g')) ?? []).length;
|
|
16
|
+
}
|
|
17
|
+
function integrityFailure(hookId, reason) {
|
|
18
|
+
return new Error(`SPEC_ENRICHMENT_INTEGRITY_FAILED: ${hookId} ${reason}`);
|
|
19
|
+
}
|
|
20
|
+
export function assertEnrichmentPreservesContract(before, after, hookId) {
|
|
21
|
+
if (after.includes(HISTORICAL_TRUNCATION_MARKER)) {
|
|
22
|
+
throw integrityFailure(hookId, 'produced the historical truncation marker');
|
|
23
|
+
}
|
|
24
|
+
const afterHeadings = new Set(after.match(HEADING_LINE_PATTERN) ?? []);
|
|
25
|
+
const beforeHeadings = before.match(HEADING_LINE_PATTERN) ?? [];
|
|
26
|
+
if (beforeHeadings.some((heading) => !afterHeadings.has(heading))) {
|
|
27
|
+
throw integrityFailure(hookId, 'removed a pre-existing heading');
|
|
28
|
+
}
|
|
29
|
+
for (const marker of REQUIRED_WORD_MARKERS) {
|
|
30
|
+
if (countWordOccurrences(after, marker) < countWordOccurrences(before, marker)) {
|
|
31
|
+
throw integrityFailure(hookId, `reduced "${marker}" occurrences`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
for (const marker of REQUIRED_LITERAL_MARKERS) {
|
|
35
|
+
if (countLiteralOccurrences(after, marker) < countLiteralOccurrences(before, marker)) {
|
|
36
|
+
throw integrityFailure(hookId, `reduced "${marker}" occurrences`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=integrity.js.map
|
|
@@ -9,6 +9,7 @@ import { hasGeneratedEnrichment, renderEnriched } from './render-enriched.js';
|
|
|
9
9
|
import { findMarkdownSectionRange } from '../spec-format/markdown-sections.js';
|
|
10
10
|
import { resolveContainedProjectFile } from '../safety/contained-project-file.js';
|
|
11
11
|
import { atomicWriteFile } from '../safety/atomic-write-file.js';
|
|
12
|
+
import { assertEnrichmentPreservesContract } from '../cascade-hooks/integrity.js';
|
|
12
13
|
export async function enrichTechnicalContent(input) {
|
|
13
14
|
const { technicalContent, specScope, projectPath } = input;
|
|
14
15
|
if (hasGeneratedEnrichment(technicalContent)) {
|
|
@@ -63,6 +64,7 @@ async function runEnricherFromSpecSection(opts) {
|
|
|
63
64
|
});
|
|
64
65
|
if (result.enriched && result.enrichedContent) {
|
|
65
66
|
const updated = `${specRaw.slice(0, section.contentStart)}${result.enrichedContent}${specRaw.slice(section.end)}`;
|
|
67
|
+
assertEnrichmentPreservesContract(specRaw, updated, 'review-enricher');
|
|
66
68
|
await atomicWriteFile(physicalSpecPath, updated);
|
|
67
69
|
}
|
|
68
70
|
}
|
|
@@ -19,6 +19,10 @@ export declare const ValidateCompletionOutputSchema: {
|
|
|
19
19
|
failing: "failing";
|
|
20
20
|
passing: "passing";
|
|
21
21
|
}>;
|
|
22
|
+
graphAvailability: z.ZodEnum<{
|
|
23
|
+
available: "available";
|
|
24
|
+
unavailable: "unavailable";
|
|
25
|
+
}>;
|
|
22
26
|
}, z.core.$strip>;
|
|
23
27
|
counts: z.ZodObject<{
|
|
24
28
|
matches: z.ZodNumber;
|
|
@@ -198,6 +202,10 @@ export declare const ValidateCompletionOutputSchema: {
|
|
|
198
202
|
}, z.core.$strip>>;
|
|
199
203
|
}, z.core.$strip>>;
|
|
200
204
|
graphCoverage: z.ZodObject<{
|
|
205
|
+
availability: z.ZodEnum<{
|
|
206
|
+
available: "available";
|
|
207
|
+
unavailable: "unavailable";
|
|
208
|
+
}>;
|
|
201
209
|
gapsCount: z.ZodNumber;
|
|
202
210
|
gapsTop5: z.ZodArray<z.ZodString>;
|
|
203
211
|
freshness: z.ZodOptional<z.ZodObject<{
|
|
@@ -40,6 +40,7 @@ export const ValidateCompletionOutputSchema = {
|
|
|
40
40
|
failed: z.number(),
|
|
41
41
|
total: z.number(),
|
|
42
42
|
status: z.enum(['passing', 'failing']),
|
|
43
|
+
graphAvailability: z.enum(['available', 'unavailable']),
|
|
43
44
|
}),
|
|
44
45
|
counts: z.object({
|
|
45
46
|
matches: z.number(),
|
|
@@ -162,6 +163,7 @@ export const ValidateCompletionOutputSchema = {
|
|
|
162
163
|
})
|
|
163
164
|
.optional(),
|
|
164
165
|
graphCoverage: z.object({
|
|
166
|
+
availability: z.enum(['available', 'unavailable']),
|
|
165
167
|
gapsCount: z.number(),
|
|
166
168
|
gapsTop5: z.array(z.string()),
|
|
167
169
|
freshness: z
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
// tools/validate-graph-coverage.ts — Graph coverage helpers for validate.
|
|
2
|
+
import { reportClassifiedDegradation } from '../errors/classified-degradation.js';
|
|
2
3
|
import { queryProjectGraphSlice } from '../engine/project-graph/index.js';
|
|
3
4
|
function graphCoverageGaps(slice) {
|
|
4
|
-
if (slice === null) {
|
|
5
|
-
return [];
|
|
6
|
-
}
|
|
7
5
|
const criteria = slice.nodes.filter((node) => node.type === 'criterion');
|
|
8
6
|
return criteria
|
|
9
7
|
.filter((criterion) => {
|
|
@@ -14,15 +12,29 @@ function graphCoverageGaps(slice) {
|
|
|
14
12
|
.slice(0, 10);
|
|
15
13
|
}
|
|
16
14
|
export async function buildGraphCoverageReport(args) {
|
|
17
|
-
|
|
15
|
+
let graphSlice;
|
|
16
|
+
try {
|
|
17
|
+
graphSlice = await queryProjectGraphSlice(args);
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
reportClassifiedDegradation('GRAPH_COVERAGE_UNAVAILABLE', error);
|
|
21
|
+
graphSlice = null;
|
|
22
|
+
}
|
|
23
|
+
if (graphSlice === null) {
|
|
24
|
+
return { availability: 'unavailable', gaps: [], compactNodes: 0, compactEdges: 0 };
|
|
25
|
+
}
|
|
18
26
|
return {
|
|
19
|
-
|
|
27
|
+
availability: 'available',
|
|
28
|
+
freshness: graphSlice.freshness,
|
|
20
29
|
gaps: graphCoverageGaps(graphSlice),
|
|
21
|
-
compactNodes: graphSlice
|
|
22
|
-
compactEdges: graphSlice
|
|
30
|
+
compactNodes: graphSlice.nodes.length,
|
|
31
|
+
compactEdges: graphSlice.edges.length,
|
|
23
32
|
};
|
|
24
33
|
}
|
|
25
34
|
export function formatGraphCoverageText(report) {
|
|
35
|
+
if (report.availability === 'unavailable') {
|
|
36
|
+
return '\nGRAPH unavailable (project graph unreadable)';
|
|
37
|
+
}
|
|
26
38
|
return report.gaps.length > 0
|
|
27
39
|
? `\nGRAPH ${String(report.gaps.length)} graph-backed coverage gap(s)`
|
|
28
40
|
: '';
|
package/dist/tools/validate.js
CHANGED
|
@@ -583,7 +583,7 @@ export async function executeValidate(args, server, onProgress) {
|
|
|
583
583
|
});
|
|
584
584
|
const graphText = formatGraphCoverageText(graphCoverage);
|
|
585
585
|
// SPEC-512: Compact structuredContent — essential fields only at top level
|
|
586
|
-
const compactSummary = buildCompactSummary(specId, effectiveResult.score === null ? null : effectiveScore, passedCount, failedCount + (qualityGateSummary.passed ? 0 : 1), totalCriteria);
|
|
586
|
+
const compactSummary = buildCompactSummary(specId, effectiveResult.score === null ? null : effectiveScore, passedCount, failedCount + (qualityGateSummary.passed ? 0 : 1), totalCriteria, graphCoverage.availability);
|
|
587
587
|
const structuredBase = buildValidateStructuredContent({
|
|
588
588
|
specId,
|
|
589
589
|
title: spec.title,
|
|
@@ -718,7 +718,7 @@ function buildEffectiveValidationResult(result, specCompliance) {
|
|
|
718
718
|
indeterminate: executableCoverage.indeterminate,
|
|
719
719
|
};
|
|
720
720
|
}
|
|
721
|
-
function buildCompactSummary(specId, score, passedCount, failedCount, totalCriteria) {
|
|
721
|
+
function buildCompactSummary(specId, score, passedCount, failedCount, totalCriteria, graphAvailability) {
|
|
722
722
|
return {
|
|
723
723
|
specId,
|
|
724
724
|
score,
|
|
@@ -726,6 +726,7 @@ function buildCompactSummary(specId, score, passedCount, failedCount, totalCrite
|
|
|
726
726
|
failed: failedCount,
|
|
727
727
|
total: totalCriteria,
|
|
728
728
|
status: failedCount === 0 ? 'passing' : 'failing',
|
|
729
|
+
graphAvailability,
|
|
729
730
|
};
|
|
730
731
|
}
|
|
731
732
|
function buildValidateStructuredContent(args) {
|
|
@@ -859,10 +860,10 @@ function buildValidateStructuredContent(args) {
|
|
|
859
860
|
}
|
|
860
861
|
: undefined,
|
|
861
862
|
graphCoverage: {
|
|
863
|
+
availability: args.graphCoverage.availability,
|
|
862
864
|
gapsCount: args.graphCoverage.gaps.length,
|
|
863
865
|
gapsTop5: args.graphCoverage.gaps.slice(0, 5),
|
|
864
866
|
freshness: args.graphCoverage.freshness,
|
|
865
|
-
summary: args.graphCoverage.summary,
|
|
866
867
|
},
|
|
867
868
|
suggestionsTop5: args.suggestions.slice(0, 5),
|
|
868
869
|
});
|
package/package.json
CHANGED
package/planu-plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "dev.planu.cli",
|
|
3
3
|
"displayName": "Planu — Spec Driven Development",
|
|
4
4
|
"description": "Manage software specs, estimations, and autonomous SDD workflows. Language-agnostic MCP server for Claude Code.",
|
|
5
|
-
"version": "5.3.
|
|
5
|
+
"version": "5.3.59",
|
|
6
6
|
"icon": "assets/plugin/icon.svg",
|
|
7
7
|
"command": ["npx", "@planu/cli@latest"],
|
|
8
8
|
"packageName": "@planu/cli",
|