@planu/cli 4.3.5 → 4.3.6
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/dist/engine/ai-cost-estimator/core.d.ts +1 -1
- package/dist/engine/ai-cost-estimator/core.js +2 -2
- package/dist/engine/ai-cost-estimator/spec-loader.d.ts +2 -2
- package/dist/engine/ai-cost-estimator/spec-loader.js +17 -46
- package/dist/engine/ai-cost-estimator/token-estimator.d.ts +1 -1
- package/dist/engine/ai-cost-estimator/token-estimator.js +1 -1
- package/dist/engine/context-intelligence/compression-guards.d.ts +8 -0
- package/dist/engine/context-intelligence/compression-guards.js +74 -0
- package/dist/engine/context-intelligence/context-graph-provider.d.ts +9 -0
- package/dist/engine/context-intelligence/context-graph-provider.js +98 -0
- package/dist/engine/context-intelligence/eval-harness.d.ts +8 -0
- package/dist/engine/context-intelligence/eval-harness.js +45 -0
- package/dist/engine/context-intelligence/impact-map.d.ts +6 -0
- package/dist/engine/context-intelligence/impact-map.js +47 -0
- package/dist/engine/context-intelligence/index.d.ts +7 -0
- package/dist/engine/context-intelligence/index.js +6 -0
- package/dist/engine/context-intelligence/safe-context-compressor.d.ts +3 -0
- package/dist/engine/context-intelligence/safe-context-compressor.js +75 -0
- package/dist/engine/dashboard/data-loader.js +9 -11
- package/dist/engine/dashboard/templates-project.d.ts +1 -1
- package/dist/engine/dashboard/templates-project.js +6 -4
- package/dist/engine/docs-site-generator/index.js +2 -11
- package/dist/engine/drift-monitor.js +13 -11
- package/dist/engine/qa-gate.js +6 -1
- package/dist/engine/readiness-checker.js +3 -3
- package/dist/engine/spec-conflict-graph.d.ts +1 -1
- package/dist/engine/spec-conflict-graph.js +2 -3
- package/dist/engine/spec-format/read-technical-section.d.ts +1 -7
- package/dist/engine/spec-format/read-technical-section.js +4 -30
- package/dist/engine/spec-registry/scorer.d.ts +1 -1
- package/dist/engine/spec-registry/scorer.js +3 -4
- package/dist/engine/validator/extractors.js +4 -2
- package/dist/engine/validator.d.ts +1 -1
- package/dist/engine/validator.js +40 -2
- package/dist/tools/update-status/dod-gates.js +1 -1
- package/dist/types/context-intelligence.d.ts +61 -0
- package/dist/types/context-intelligence.js +2 -0
- package/dist/types/qa-gate.d.ts +1 -1
- package/package.json +9 -9
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { AiCostEstimateResult, EstimateAiCostInput } from '../../types/index.js';
|
|
2
2
|
/**
|
|
3
3
|
* Estimates the AI cost for implementing a spec.
|
|
4
|
-
* Reads the spec from planu/specs
|
|
4
|
+
* Reads the canonical spec.md from planu/specs/, loads pricing from config JSON,
|
|
5
5
|
* and returns a full AiCostEstimateResult with per-model breakdown.
|
|
6
6
|
*/
|
|
7
7
|
export declare function estimateAiCost(input: EstimateAiCostInput): Promise<AiCostEstimateResult>;
|
|
@@ -34,13 +34,13 @@ function getComplexityLevel(acCount) {
|
|
|
34
34
|
// ---------------------------------------------------------------------------
|
|
35
35
|
/**
|
|
36
36
|
* Estimates the AI cost for implementing a spec.
|
|
37
|
-
* Reads the spec from planu/specs
|
|
37
|
+
* Reads the canonical spec.md from planu/specs/, loads pricing from config JSON,
|
|
38
38
|
* and returns a full AiCostEstimateResult with per-model breakdown.
|
|
39
39
|
*/
|
|
40
40
|
export async function estimateAiCost(input) {
|
|
41
41
|
const { specId, projectPath } = input;
|
|
42
42
|
// 1. Load spec content
|
|
43
|
-
const { content: specContent } = await loadSpecContent(specId);
|
|
43
|
+
const { content: specContent } = await loadSpecContent(specId, projectPath);
|
|
44
44
|
const acCount = countAcceptanceCriteria(specContent);
|
|
45
45
|
// 2. Load pricing config
|
|
46
46
|
const config = await loadPricingConfig();
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Reads spec content from
|
|
2
|
+
* Reads canonical spec content from planu/specs.
|
|
3
3
|
* Tries to find the spec directory by matching specId prefix.
|
|
4
4
|
*/
|
|
5
|
-
export declare function loadSpecContent(specId: string): Promise<{
|
|
5
|
+
export declare function loadSpecContent(specId: string, projectPath?: string): Promise<{
|
|
6
6
|
content: string;
|
|
7
7
|
specDir: string;
|
|
8
8
|
}>;
|
|
@@ -1,66 +1,37 @@
|
|
|
1
1
|
// @crash-shield-ignore-file — config/cache reader for Planu-controlled JSON; writer is this codebase, shape guaranteed by build/seed.
|
|
2
2
|
// engine/ai-cost-estimator/spec-loader.ts — Loads spec content and counts acceptance criteria.
|
|
3
3
|
// Extracted from core.ts to keep file sizes within limits.
|
|
4
|
-
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { stripFrontmatter } from '../frontmatter-parser.js';
|
|
7
7
|
/**
|
|
8
|
-
* Reads spec content from
|
|
8
|
+
* Reads canonical spec content from planu/specs.
|
|
9
9
|
* Tries to find the spec directory by matching specId prefix.
|
|
10
10
|
*/
|
|
11
|
-
export async function loadSpecContent(specId) {
|
|
12
|
-
|
|
13
|
-
let
|
|
11
|
+
export async function loadSpecContent(specId, projectPath = process.cwd()) {
|
|
12
|
+
const specsRoot = join(projectPath, 'planu', 'specs');
|
|
13
|
+
let topEntries;
|
|
14
14
|
try {
|
|
15
|
-
|
|
16
|
-
await fsAccess(specsRoot);
|
|
17
|
-
}
|
|
18
|
-
catch {
|
|
19
|
-
specsRoot = 'docs/sdd/specs';
|
|
20
|
-
}
|
|
21
|
-
const { readdir } = await import('node:fs/promises');
|
|
22
|
-
let specDir = null;
|
|
23
|
-
try {
|
|
24
|
-
const topEntries = await readdir(specsRoot);
|
|
25
|
-
// Flat layout: planu/specs/SPEC-069-name/ (or legacy docs/sdd/specs/SPEC-069-name/)
|
|
26
|
-
const flatMatch = topEntries.find((e) => e.startsWith(specId));
|
|
27
|
-
if (flatMatch) {
|
|
28
|
-
specDir = join(specsRoot, flatMatch);
|
|
29
|
-
}
|
|
15
|
+
topEntries = await readdir(specsRoot);
|
|
30
16
|
}
|
|
31
17
|
catch {
|
|
32
18
|
throw new Error(`Spec directory not found at "${specsRoot}". ` +
|
|
33
19
|
'Make sure you are running from the project root.');
|
|
34
20
|
}
|
|
35
|
-
|
|
36
|
-
|
|
21
|
+
const flatMatch = topEntries.find((entry) => entry.startsWith(specId));
|
|
22
|
+
if (!flatMatch) {
|
|
23
|
+
throw new Error(`Spec "${specId}" not found in "${specsRoot}". Check that the spec directory exists.`);
|
|
37
24
|
}
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
]) {
|
|
44
|
-
let found = false;
|
|
45
|
-
for (const filename of [modern, legacy]) {
|
|
46
|
-
if (found) {
|
|
47
|
-
break;
|
|
48
|
-
}
|
|
49
|
-
try {
|
|
50
|
-
const raw = await readFile(join(specDir, filename), 'utf-8');
|
|
51
|
-
parts.push(stripFrontmatter(raw));
|
|
52
|
-
found = true;
|
|
53
|
-
}
|
|
54
|
-
catch {
|
|
55
|
-
// File not present — try next
|
|
56
|
-
}
|
|
57
|
-
}
|
|
25
|
+
const specDir = join(specsRoot, flatMatch);
|
|
26
|
+
const specPath = join(specDir, 'spec.md');
|
|
27
|
+
try {
|
|
28
|
+
const raw = await readFile(specPath, 'utf-8');
|
|
29
|
+
return { content: stripFrontmatter(raw), specDir };
|
|
58
30
|
}
|
|
59
|
-
|
|
60
|
-
throw new Error(`Spec "${specId}" has no spec
|
|
61
|
-
'
|
|
31
|
+
catch {
|
|
32
|
+
throw new Error(`Spec "${specId}" has no canonical spec.md at "${specPath}". ` +
|
|
33
|
+
'Run Planu migration/strict cleanup before estimating cost.');
|
|
62
34
|
}
|
|
63
|
-
return { content: parts.join('\n\n'), specDir };
|
|
64
35
|
}
|
|
65
36
|
/** Counts acceptance criteria (lines starting with "- [ ]" or "- [x]") in spec content. */
|
|
66
37
|
export function countAcceptanceCriteria(specContent) {
|
|
@@ -6,7 +6,7 @@ import type { TokenEstimate, AiModelPricingConfig } from '../../types/index.js';
|
|
|
6
6
|
export declare function detectProjectLanguage(projectPath: string): Promise<string>;
|
|
7
7
|
/**
|
|
8
8
|
* Estimates input tokens for an AI agent session based on three components:
|
|
9
|
-
* 1. Spec content (
|
|
9
|
+
* 1. Spec content (canonical spec.md)
|
|
10
10
|
* 2. Codebase context (if projectPath is provided)
|
|
11
11
|
* 3. MCP tool definitions overhead (fixed constant)
|
|
12
12
|
*/
|
|
@@ -119,7 +119,7 @@ function isSourceFile(filename) {
|
|
|
119
119
|
// ---------------------------------------------------------------------------
|
|
120
120
|
/**
|
|
121
121
|
* Estimates input tokens for an AI agent session based on three components:
|
|
122
|
-
* 1. Spec content (
|
|
122
|
+
* 1. Spec content (canonical spec.md)
|
|
123
123
|
* 2. Codebase context (if projectPath is provided)
|
|
124
124
|
* 3. MCP tool definitions overhead (fixed constant)
|
|
125
125
|
*/
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare function getSensitivePathRefusal(path: string): string | null;
|
|
2
|
+
export declare function collectPreservedFragments(content: string): Map<string, string[]>;
|
|
3
|
+
export declare function verifyPreservedFragments(original: string, candidate: string): {
|
|
4
|
+
ok: boolean;
|
|
5
|
+
checks: string[];
|
|
6
|
+
};
|
|
7
|
+
export declare function shouldBypassLeanMode(flow: string): boolean;
|
|
8
|
+
//# sourceMappingURL=compression-guards.d.ts.map
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
2
|
+
const SENSITIVE_PATH_SEGMENTS = new Set([
|
|
3
|
+
'.ssh',
|
|
4
|
+
'.aws',
|
|
5
|
+
'.gnupg',
|
|
6
|
+
'.kube',
|
|
7
|
+
'.docker',
|
|
8
|
+
'secrets',
|
|
9
|
+
'credentials',
|
|
10
|
+
]);
|
|
11
|
+
const SENSITIVE_FILE_PATTERNS = [
|
|
12
|
+
/^\.env(?:\.|$)/i,
|
|
13
|
+
/^\.npmrc$/i,
|
|
14
|
+
/(?:secret|credential|password|token|private[-_]?key|api[-_]?key)/i,
|
|
15
|
+
/\.(?:p12|pfx|pem|key)$/i,
|
|
16
|
+
];
|
|
17
|
+
const PRESERVE_PATTERNS = [
|
|
18
|
+
{ label: 'spec-id', pattern: /\bSPEC-\d+\b/g },
|
|
19
|
+
{ label: 'acceptance-criteria', pattern: /^-\s*\[[ xX]\]\s+.+$/gm },
|
|
20
|
+
{ label: 'fenced-code', pattern: /```[\s\S]*?```|~~~[\s\S]*?~~~/g },
|
|
21
|
+
{ label: 'inline-code', pattern: /`[^`\n]+`/g },
|
|
22
|
+
{ label: 'url', pattern: /\bhttps?:\/\/[^\s)]+/g },
|
|
23
|
+
{ label: 'file-path', pattern: /\b[\w.-]+(?:\/[\w.-]+)+\.[A-Za-z0-9]+\b/g },
|
|
24
|
+
{ label: 'command', pattern: /\b(?:pnpm|npm|bun|node|git|gh|npx|tsx|tsc)\s+[^\n]+/g },
|
|
25
|
+
{ label: 'hash', pattern: /\b[a-f0-9]{32,64}\b/gi },
|
|
26
|
+
{
|
|
27
|
+
label: 'validation-failure',
|
|
28
|
+
pattern: /\b(?:error|failed|failure|blocked|validate|validation)\b[^\n]*/gi,
|
|
29
|
+
},
|
|
30
|
+
{ label: 'approval-request', pattern: /\b(?:approve|approval|approved|forceApprove)\b[^\n]*/gi },
|
|
31
|
+
{
|
|
32
|
+
label: 'security-warning',
|
|
33
|
+
pattern: /\b(?:security|secret|credential|token|password|destructive)\b[^\n]*/gi,
|
|
34
|
+
},
|
|
35
|
+
];
|
|
36
|
+
export function getSensitivePathRefusal(path) {
|
|
37
|
+
const normalized = path.replace(/\\/g, '/');
|
|
38
|
+
const segments = normalized.split('/').filter(Boolean);
|
|
39
|
+
if (segments.some((segment) => SENSITIVE_PATH_SEGMENTS.has(segment.toLowerCase()))) {
|
|
40
|
+
return `Refusing sensitive path segment in ${path}`;
|
|
41
|
+
}
|
|
42
|
+
const fileName = basename(normalized);
|
|
43
|
+
if (SENSITIVE_FILE_PATTERNS.some((pattern) => pattern.test(fileName))) {
|
|
44
|
+
return `Refusing sensitive file name ${path}`;
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
export function collectPreservedFragments(content) {
|
|
49
|
+
const fragments = new Map();
|
|
50
|
+
for (const { label, pattern } of PRESERVE_PATTERNS) {
|
|
51
|
+
const matches = [...content.matchAll(pattern)].map((match) => match[0]);
|
|
52
|
+
if (matches.length > 0) {
|
|
53
|
+
fragments.set(label, [...new Set(matches)]);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return fragments;
|
|
57
|
+
}
|
|
58
|
+
export function verifyPreservedFragments(original, candidate) {
|
|
59
|
+
const fragments = collectPreservedFragments(original);
|
|
60
|
+
const checks = [];
|
|
61
|
+
for (const [label, values] of fragments.entries()) {
|
|
62
|
+
for (const value of values) {
|
|
63
|
+
if (!candidate.includes(value)) {
|
|
64
|
+
return { ok: false, checks: [`missing:${label}:${value.slice(0, 80)}`] };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
checks.push(label);
|
|
68
|
+
}
|
|
69
|
+
return { ok: true, checks };
|
|
70
|
+
}
|
|
71
|
+
export function shouldBypassLeanMode(flow) {
|
|
72
|
+
return /\b(security|validation|validate|approval|approved|release|destructive|delete|rollback|force)\b/i.test(flow);
|
|
73
|
+
}
|
|
74
|
+
//# sourceMappingURL=compression-guards.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ContextGraphInput, ContextGraphProvider, ContextImpactMap } from '../../types/context-intelligence.js';
|
|
2
|
+
export declare class PlanuContextGraphProvider implements ContextGraphProvider {
|
|
3
|
+
buildImpactMap(input: ContextGraphInput): Promise<ContextImpactMap>;
|
|
4
|
+
private resolveCandidateFiles;
|
|
5
|
+
private readSpecReferencedPaths;
|
|
6
|
+
private mapFile;
|
|
7
|
+
}
|
|
8
|
+
export declare function createContextGraphProvider(): ContextGraphProvider;
|
|
9
|
+
//# sourceMappingURL=context-graph-provider.d.ts.map
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
import { glob } from 'glob';
|
|
4
|
+
import { emptyImpactMap, estimateImpactBudget, mergeImpactMaps } from './impact-map.js';
|
|
5
|
+
const SOURCE_EXTENSIONS = '{ts,tsx,js,jsx,mjs,cjs,py,go,rs,java,rb,cs}';
|
|
6
|
+
function normalizePath(path) {
|
|
7
|
+
return path.replace(/\\/g, '/');
|
|
8
|
+
}
|
|
9
|
+
function extractPaths(text) {
|
|
10
|
+
const matches = text.match(/\b(?:src|tests|packages|lib)\/[\w./-]+\.[A-Za-z0-9]+\b/g) ?? [];
|
|
11
|
+
return [...new Set(matches.map(normalizePath))];
|
|
12
|
+
}
|
|
13
|
+
function extractImports(content) {
|
|
14
|
+
const imports = new Set();
|
|
15
|
+
const patterns = [
|
|
16
|
+
/from\s+['"]([^'"]+)['"]/g,
|
|
17
|
+
/import\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
|
|
18
|
+
/require\(\s*['"]([^'"]+)['"]\s*\)/g,
|
|
19
|
+
];
|
|
20
|
+
for (const pattern of patterns) {
|
|
21
|
+
for (const match of content.matchAll(pattern)) {
|
|
22
|
+
if (match[1]?.startsWith('.')) {
|
|
23
|
+
imports.add(match[1]);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return [...imports];
|
|
28
|
+
}
|
|
29
|
+
function extractSymbols(content, path) {
|
|
30
|
+
const symbols = [];
|
|
31
|
+
const lines = content.split('\n');
|
|
32
|
+
const pattern = /\b(?:export\s+)?(?:async\s+)?(?:function|class|interface|type|const)\s+([A-Za-z_][\w]*)/;
|
|
33
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
34
|
+
const match = pattern.exec(lines[index] ?? '');
|
|
35
|
+
if (match?.[1]) {
|
|
36
|
+
symbols.push({ name: match[1], path, line: index + 1, confidence: 0.75 });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return symbols;
|
|
40
|
+
}
|
|
41
|
+
export class PlanuContextGraphProvider {
|
|
42
|
+
async buildImpactMap(input) {
|
|
43
|
+
const candidates = await this.resolveCandidateFiles(input);
|
|
44
|
+
if (candidates.length === 0) {
|
|
45
|
+
return emptyImpactMap();
|
|
46
|
+
}
|
|
47
|
+
const maps = await Promise.all(candidates.map((path) => this.mapFile(input.projectPath, path)));
|
|
48
|
+
return mergeImpactMaps(maps, input.tokenBudget);
|
|
49
|
+
}
|
|
50
|
+
async resolveCandidateFiles(input) {
|
|
51
|
+
const fromChanged = (input.changedFiles ?? []).map(normalizePath);
|
|
52
|
+
const fromSpec = input.specId ? await this.readSpecReferencedPaths(input) : [];
|
|
53
|
+
const direct = [...new Set([...fromChanged, ...fromSpec])].filter(Boolean);
|
|
54
|
+
if (direct.length > 0) {
|
|
55
|
+
return direct.slice(0, estimateImpactBudget(input.tokenBudget) * 2);
|
|
56
|
+
}
|
|
57
|
+
const maxFiles = estimateImpactBudget(input.tokenBudget);
|
|
58
|
+
return (await glob(`**/*.${SOURCE_EXTENSIONS}`, {
|
|
59
|
+
cwd: input.projectPath,
|
|
60
|
+
nodir: true,
|
|
61
|
+
ignore: ['**/node_modules/**', '**/dist/**', '**/.git/**', '**/coverage/**'],
|
|
62
|
+
}))
|
|
63
|
+
.map(normalizePath)
|
|
64
|
+
.slice(0, maxFiles);
|
|
65
|
+
}
|
|
66
|
+
async readSpecReferencedPaths(input) {
|
|
67
|
+
const specFiles = await glob(`planu/specs/${input.specId}-*/spec.md`, {
|
|
68
|
+
cwd: input.projectPath,
|
|
69
|
+
nodir: true,
|
|
70
|
+
});
|
|
71
|
+
const specPath = specFiles[0];
|
|
72
|
+
if (!specPath) {
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
const content = await readFile(join(input.projectPath, specPath), 'utf-8').catch(() => '');
|
|
76
|
+
return extractPaths(content);
|
|
77
|
+
}
|
|
78
|
+
async mapFile(projectPath, path) {
|
|
79
|
+
const normalized = normalizePath(path);
|
|
80
|
+
const absolute = join(projectPath, normalized);
|
|
81
|
+
const content = await readFile(absolute, 'utf-8').catch(() => '');
|
|
82
|
+
const imports = extractImports(content);
|
|
83
|
+
return {
|
|
84
|
+
files: [{ path: normalized, reason: 'direct-context-candidate', confidence: 0.9 }],
|
|
85
|
+
symbols: extractSymbols(content, normalized),
|
|
86
|
+
edges: imports.map((target) => ({
|
|
87
|
+
from: normalized,
|
|
88
|
+
to: normalizePath(relative(projectPath, join(absolute, '..', target))),
|
|
89
|
+
type: 'import',
|
|
90
|
+
})),
|
|
91
|
+
truncated: false,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
export function createContextGraphProvider() {
|
|
96
|
+
return new PlanuContextGraphProvider();
|
|
97
|
+
}
|
|
98
|
+
//# sourceMappingURL=context-graph-provider.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ContextEvalCase, ContextEvalMode, ContextEvalResult } from '../../types/context-intelligence.js';
|
|
2
|
+
export declare function evaluateContextModes(cases: ContextEvalCase[]): ContextEvalResult[];
|
|
3
|
+
export declare function summarizeContextEval(results: ContextEvalResult[]): {
|
|
4
|
+
cases: number;
|
|
5
|
+
averageSavingsVsBaselinePct: number;
|
|
6
|
+
bestModeCounts: Record<ContextEvalMode, number>;
|
|
7
|
+
};
|
|
8
|
+
//# sourceMappingURL=eval-harness.d.ts.map
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
function pctSaved(baseline, candidate) {
|
|
2
|
+
if (baseline <= 0) {
|
|
3
|
+
return 0;
|
|
4
|
+
}
|
|
5
|
+
return Math.max(0, Math.round(((baseline - candidate) / baseline) * 1000) / 10);
|
|
6
|
+
}
|
|
7
|
+
export function evaluateContextModes(cases) {
|
|
8
|
+
return cases.map((item) => {
|
|
9
|
+
const modes = {
|
|
10
|
+
baseline: item.baselineTokens,
|
|
11
|
+
'spec-guided': item.specGuidedTokens,
|
|
12
|
+
'graph-guided': item.graphGuidedTokens,
|
|
13
|
+
lean: item.leanTokens,
|
|
14
|
+
'graph+lean': item.graphLeanTokens,
|
|
15
|
+
};
|
|
16
|
+
const bestMode = Object.entries(modes).sort((a, b) => a[1] - b[1])[0]?.[0] ??
|
|
17
|
+
'baseline';
|
|
18
|
+
return {
|
|
19
|
+
id: item.id,
|
|
20
|
+
bestMode,
|
|
21
|
+
savingsVsBaselinePct: pctSaved(item.baselineTokens, modes[bestMode]),
|
|
22
|
+
modes,
|
|
23
|
+
};
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
export function summarizeContextEval(results) {
|
|
27
|
+
const bestModeCounts = {
|
|
28
|
+
baseline: 0,
|
|
29
|
+
'spec-guided': 0,
|
|
30
|
+
'graph-guided': 0,
|
|
31
|
+
lean: 0,
|
|
32
|
+
'graph+lean': 0,
|
|
33
|
+
};
|
|
34
|
+
let totalSavings = 0;
|
|
35
|
+
for (const result of results) {
|
|
36
|
+
bestModeCounts[result.bestMode] += 1;
|
|
37
|
+
totalSavings += result.savingsVsBaselinePct;
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
cases: results.length,
|
|
41
|
+
averageSavingsVsBaselinePct: results.length === 0 ? 0 : Math.round((totalSavings / results.length) * 10) / 10,
|
|
42
|
+
bestModeCounts,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=eval-harness.js.map
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ContextImpactMap } from '../../types/context-intelligence.js';
|
|
2
|
+
export declare function emptyImpactMap(): ContextImpactMap;
|
|
3
|
+
export declare function clampConfidence(value: number): number;
|
|
4
|
+
export declare function estimateImpactBudget(tokenBudget: number): number;
|
|
5
|
+
export declare function mergeImpactMaps(maps: ContextImpactMap[], tokenBudget: number): ContextImpactMap;
|
|
6
|
+
//# sourceMappingURL=impact-map.d.ts.map
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export function emptyImpactMap() {
|
|
2
|
+
return { files: [], symbols: [], edges: [], truncated: false };
|
|
3
|
+
}
|
|
4
|
+
export function clampConfidence(value) {
|
|
5
|
+
return Math.max(0, Math.min(1, Number.isFinite(value) ? value : 0));
|
|
6
|
+
}
|
|
7
|
+
export function estimateImpactBudget(tokenBudget) {
|
|
8
|
+
if (tokenBudget <= 0) {
|
|
9
|
+
return 1;
|
|
10
|
+
}
|
|
11
|
+
return Math.max(1, Math.min(50, Math.floor(tokenBudget / 120)));
|
|
12
|
+
}
|
|
13
|
+
export function mergeImpactMaps(maps, tokenBudget) {
|
|
14
|
+
const maxFiles = estimateImpactBudget(tokenBudget);
|
|
15
|
+
const files = new Map();
|
|
16
|
+
const symbols = new Map();
|
|
17
|
+
const edges = new Map();
|
|
18
|
+
let sourceCount = 0;
|
|
19
|
+
for (const map of maps) {
|
|
20
|
+
for (const file of map.files) {
|
|
21
|
+
sourceCount += 1;
|
|
22
|
+
const existing = files.get(file.path);
|
|
23
|
+
if (!existing || existing.confidence < file.confidence) {
|
|
24
|
+
files.set(file.path, { ...file, confidence: clampConfidence(file.confidence) });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
for (const symbol of map.symbols) {
|
|
28
|
+
symbols.set(`${symbol.path}:${symbol.name}:${String(symbol.line ?? '')}`, {
|
|
29
|
+
...symbol,
|
|
30
|
+
confidence: clampConfidence(symbol.confidence),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
for (const edge of map.edges) {
|
|
34
|
+
edges.set(`${edge.from}->${edge.to}:${edge.type}`, edge);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const rankedFiles = [...files.values()].sort((a, b) => b.confidence - a.confidence);
|
|
38
|
+
const selectedFiles = rankedFiles.slice(0, maxFiles);
|
|
39
|
+
const selectedPaths = new Set(selectedFiles.map((file) => file.path));
|
|
40
|
+
return {
|
|
41
|
+
files: selectedFiles,
|
|
42
|
+
symbols: [...symbols.values()].filter((symbol) => selectedPaths.has(symbol.path)),
|
|
43
|
+
edges: [...edges.values()].filter((edge) => selectedPaths.has(edge.from) || selectedPaths.has(edge.to)),
|
|
44
|
+
truncated: rankedFiles.length < sourceCount || rankedFiles.length > selectedFiles.length,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
//# sourceMappingURL=impact-map.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { PlanuContextGraphProvider, createContextGraphProvider } from './context-graph-provider.js';
|
|
2
|
+
export { getSensitivePathRefusal, collectPreservedFragments, verifyPreservedFragments, shouldBypassLeanMode, } from './compression-guards.js';
|
|
3
|
+
export { compressSafeContext } from './safe-context-compressor.js';
|
|
4
|
+
export { evaluateContextModes, summarizeContextEval } from './eval-harness.js';
|
|
5
|
+
export { emptyImpactMap, estimateImpactBudget, mergeImpactMaps } from './impact-map.js';
|
|
6
|
+
export type { ContextEvalCase, ContextEvalMode, ContextEvalResult, ContextGraphInput, ContextGraphProvider, ContextImpactEdge, ContextImpactFile, ContextImpactMap, ContextImpactSymbol, SafeCompressionResult, SafeContextCompressionInput, } from '../../types/context-intelligence.js';
|
|
7
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { PlanuContextGraphProvider, createContextGraphProvider } from './context-graph-provider.js';
|
|
2
|
+
export { getSensitivePathRefusal, collectPreservedFragments, verifyPreservedFragments, shouldBypassLeanMode, } from './compression-guards.js';
|
|
3
|
+
export { compressSafeContext } from './safe-context-compressor.js';
|
|
4
|
+
export { evaluateContextModes, summarizeContextEval } from './eval-harness.js';
|
|
5
|
+
export { emptyImpactMap, estimateImpactBudget, mergeImpactMaps } from './impact-map.js';
|
|
6
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { getSensitivePathRefusal, verifyPreservedFragments, shouldBypassLeanMode, } from './compression-guards.js';
|
|
3
|
+
const DEFAULT_MAX_BYTES = 200_000;
|
|
4
|
+
function byteLength(value) {
|
|
5
|
+
return Buffer.byteLength(value, 'utf-8');
|
|
6
|
+
}
|
|
7
|
+
function compressParagraph(paragraph) {
|
|
8
|
+
if (paragraph.startsWith('```') ||
|
|
9
|
+
paragraph.startsWith('~~~') ||
|
|
10
|
+
paragraph.startsWith('- [') ||
|
|
11
|
+
/`[^`\n]+`|\bSPEC-\d+\b|\bhttps?:\/\//.test(paragraph)) {
|
|
12
|
+
return paragraph;
|
|
13
|
+
}
|
|
14
|
+
return paragraph
|
|
15
|
+
.split('\n')
|
|
16
|
+
.map((line) => line.replace(/\s+/g, ' ').trim())
|
|
17
|
+
.filter(Boolean)
|
|
18
|
+
.join(' ');
|
|
19
|
+
}
|
|
20
|
+
export function compressSafeContext(input) {
|
|
21
|
+
const originalBytes = byteLength(input.content);
|
|
22
|
+
if (input.sourcePath) {
|
|
23
|
+
const refusal = getSensitivePathRefusal(input.sourcePath);
|
|
24
|
+
if (refusal) {
|
|
25
|
+
return {
|
|
26
|
+
ok: false,
|
|
27
|
+
originalBytes,
|
|
28
|
+
compressedBytes: originalBytes,
|
|
29
|
+
refusedReason: refusal,
|
|
30
|
+
preservedChecks: [],
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (originalBytes > (input.maxBytes ?? DEFAULT_MAX_BYTES)) {
|
|
35
|
+
return {
|
|
36
|
+
ok: false,
|
|
37
|
+
originalBytes,
|
|
38
|
+
compressedBytes: originalBytes,
|
|
39
|
+
refusedReason: `Refusing content over ${String(input.maxBytes ?? DEFAULT_MAX_BYTES)} bytes`,
|
|
40
|
+
preservedChecks: [],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
if (input.flow && shouldBypassLeanMode(input.flow)) {
|
|
44
|
+
return {
|
|
45
|
+
ok: true,
|
|
46
|
+
originalBytes,
|
|
47
|
+
compressedBytes: originalBytes,
|
|
48
|
+
preservedChecks: ['bypass-sensitive-flow'],
|
|
49
|
+
content: input.content,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const compressed = input.content
|
|
53
|
+
.split(/\n{2,}/)
|
|
54
|
+
.map((paragraph) => compressParagraph(paragraph.trim()))
|
|
55
|
+
.filter(Boolean)
|
|
56
|
+
.join('\n\n');
|
|
57
|
+
const preserved = verifyPreservedFragments(input.content, compressed);
|
|
58
|
+
if (!preserved.ok) {
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
originalBytes,
|
|
62
|
+
compressedBytes: byteLength(compressed),
|
|
63
|
+
refusedReason: `Compression would drop preserved fragment: ${preserved.checks[0] ?? 'unknown'}`,
|
|
64
|
+
preservedChecks: preserved.checks,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
ok: true,
|
|
69
|
+
originalBytes,
|
|
70
|
+
compressedBytes: byteLength(compressed),
|
|
71
|
+
preservedChecks: preserved.checks,
|
|
72
|
+
content: compressed,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
//# sourceMappingURL=safe-context-compressor.js.map
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { computeDashboardMetrics, computeHealthScore } from './kanban-logic.js';
|
|
6
|
+
import { extractSectionBody } from '../spec-format/read-technical-section.js';
|
|
6
7
|
/** Root data directory for all project data */
|
|
7
8
|
const DATA_DIR = join(process.cwd(), 'data');
|
|
8
9
|
/** Safely read and parse a JSON file; returns null on any error */
|
|
@@ -190,11 +191,10 @@ export function computeHealthMetrics(project) {
|
|
|
190
191
|
/** Read spec markdown files for the detail view */
|
|
191
192
|
export async function loadSpecDetail(projectId, specId) {
|
|
192
193
|
const specDir = join(DATA_DIR, 'projects', projectId, 'specs', specId);
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
]);
|
|
194
|
+
const specMd = await readTextFile(join(specDir, 'spec.md'));
|
|
195
|
+
const hu = specMd;
|
|
196
|
+
const ficha = specMd !== null ? extractSectionBody(specMd, 'Technical') || null : null;
|
|
197
|
+
const progress = specMd !== null ? extractSectionBody(specMd, 'Progress') || null : null;
|
|
198
198
|
return { hu, ficha, progress };
|
|
199
199
|
}
|
|
200
200
|
/** Extract code links (file references) from a technical markdown */
|
|
@@ -257,12 +257,10 @@ export function parseFrontmatter(content) {
|
|
|
257
257
|
/** Load enhanced spec detail with code links and frontmatter */
|
|
258
258
|
export async function loadSpecDetailEnhanced(projectId, specId) {
|
|
259
259
|
const specDir = join(DATA_DIR, 'projects', projectId, 'specs', specId);
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
readTextFile(join(specDir, 'spec.md')),
|
|
265
|
-
]);
|
|
260
|
+
const specMd = await readTextFile(join(specDir, 'spec.md'));
|
|
261
|
+
const hu = specMd;
|
|
262
|
+
const ficha = specMd !== null ? extractSectionBody(specMd, 'Technical') || null : null;
|
|
263
|
+
const progress = specMd !== null ? extractSectionBody(specMd, 'Progress') || null : null;
|
|
266
264
|
const codeLinks = ficha ? extractCodeLinks(ficha) : [];
|
|
267
265
|
const frontmatter = specMd ? parseFrontmatter(specMd) : {};
|
|
268
266
|
return { hu, ficha, progress, codeLinks, frontmatter };
|
|
@@ -3,7 +3,7 @@ import type { DashboardProjectSummary, DashboardUsageStats, ProjectHealthMetrics
|
|
|
3
3
|
export declare function renderHomePage(projects: DashboardProjectSummary[], usage: DashboardUsageStats): string;
|
|
4
4
|
/** Render the project detail page — spec cards + kanban board + health panel */
|
|
5
5
|
export declare function renderProjectPage(project: DashboardProjectSummary, metrics: ProjectHealthMetrics, usage: DashboardUsageStats): string;
|
|
6
|
-
/** Render the spec detail page —
|
|
6
|
+
/** Render the spec detail page — canonical spec.md sections */
|
|
7
7
|
export declare function renderSpecPage(projectId: string, projectName: string, specId: string, hu: string | null, ficha: string | null, progress: string | null): string;
|
|
8
8
|
/** Render a 404 not found page */
|
|
9
9
|
export declare function render404Page(path: string): string;
|
|
@@ -226,15 +226,17 @@ export function renderProjectPage(project, metrics, usage) {
|
|
|
226
226
|
`;
|
|
227
227
|
return renderLayout(project.name, body, getKanbanStyles(), getKanbanScript());
|
|
228
228
|
}
|
|
229
|
-
/** Render the spec detail page —
|
|
229
|
+
/** Render the spec detail page — canonical spec.md sections */
|
|
230
230
|
export function renderSpecPage(projectId, projectName, specId, hu, ficha, progress) {
|
|
231
|
-
const huHtml = hu !== null
|
|
231
|
+
const huHtml = hu !== null
|
|
232
|
+
? markdownToHtml(hu)
|
|
233
|
+
: '<p style="color:var(--text-muted)">spec.md no encontrado</p>';
|
|
232
234
|
const fichaHtml = ficha !== null
|
|
233
235
|
? markdownToHtml(ficha)
|
|
234
|
-
: '<p style="color:var(--text-muted)"
|
|
236
|
+
: '<p style="color:var(--text-muted)">## Technical no encontrado en spec.md</p>';
|
|
235
237
|
const progressHtml = progress !== null
|
|
236
238
|
? markdownToHtml(progress)
|
|
237
|
-
: '<p style="color:var(--text-muted)"
|
|
239
|
+
: '<p style="color:var(--text-muted)">## Progress no encontrado en spec.md</p>';
|
|
238
240
|
const body = `
|
|
239
241
|
<div class="breadcrumb">
|
|
240
242
|
<a href="/">Inicio</a> ›
|
|
@@ -7,6 +7,7 @@ import { renderSpecPage, renderSpecIndex } from './spec-renderer.js';
|
|
|
7
7
|
import { renderToolsCatalog } from './tools-renderer.js';
|
|
8
8
|
import { renderDiagramsPage } from './diagrams-renderer.js';
|
|
9
9
|
import { stripFrontmatter } from '../frontmatter-parser.js';
|
|
10
|
+
import { extractSectionBody } from '../spec-format/read-technical-section.js';
|
|
10
11
|
export async function generateDocsSite(config) {
|
|
11
12
|
const { projectPath, outputDir, title = 'Planu Documentation' } = config;
|
|
12
13
|
const [specs, metrics] = await Promise.all([
|
|
@@ -33,7 +34,6 @@ export async function generateDocsSite(config) {
|
|
|
33
34
|
});
|
|
34
35
|
for (const spec of specs) {
|
|
35
36
|
let specContent = '';
|
|
36
|
-
let techContent = '';
|
|
37
37
|
try {
|
|
38
38
|
const raw = await readFile(spec.specPath, 'utf-8');
|
|
39
39
|
specContent = stripFrontmatter(raw);
|
|
@@ -41,16 +41,7 @@ export async function generateDocsSite(config) {
|
|
|
41
41
|
catch {
|
|
42
42
|
/* missing */
|
|
43
43
|
}
|
|
44
|
-
|
|
45
|
-
const techPath = spec.specPath
|
|
46
|
-
.replace(/spec\.md$/, 'technical.md')
|
|
47
|
-
.replace(/HU\.md$/, 'FICHA-TECNICA.md');
|
|
48
|
-
const rawTech = await readFile(techPath, 'utf-8');
|
|
49
|
-
techContent = stripFrontmatter(rawTech);
|
|
50
|
-
}
|
|
51
|
-
catch {
|
|
52
|
-
/* missing */
|
|
53
|
-
}
|
|
44
|
+
const techContent = extractSectionBody(specContent, 'Technical');
|
|
54
45
|
pages.push({
|
|
55
46
|
path: `specs/${spec.slug}.html`,
|
|
56
47
|
title: spec.title,
|
|
@@ -3,6 +3,7 @@ import fs from 'node:fs';
|
|
|
3
3
|
import { readFile, stat } from 'node:fs/promises';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { fastScanProjectMetadata, isNativeActive, fastDetectDriftParallel, } from './core-bridge.js';
|
|
6
|
+
import { readSpecTechnicalSection } from './spec-format/read-technical-section.js';
|
|
6
7
|
// ---------------------------------------------------------------------------
|
|
7
8
|
// Config builder
|
|
8
9
|
// ---------------------------------------------------------------------------
|
|
@@ -107,14 +108,14 @@ const TEMPORAL_DRIFT_DAYS = 30;
|
|
|
107
108
|
* Note: score represents HEALTH (100 = no drift). Callers may invert if needed.
|
|
108
109
|
*/
|
|
109
110
|
export async function computeDriftScore(projectPath, specId, spec, technicalPath, metadataMap) {
|
|
110
|
-
const
|
|
111
|
+
const resolvedSpec = resolveSpecPath(projectPath, specId, technicalPath);
|
|
111
112
|
let content = '';
|
|
112
|
-
if (
|
|
113
|
+
if (resolvedSpec !== null) {
|
|
113
114
|
try {
|
|
114
|
-
content = await
|
|
115
|
+
content = await readSpecTechnicalSection({ specPath: resolvedSpec });
|
|
115
116
|
}
|
|
116
117
|
catch {
|
|
117
|
-
// cannot read
|
|
118
|
+
// cannot read spec.md technical section — treat all layers as fully healthy
|
|
118
119
|
}
|
|
119
120
|
}
|
|
120
121
|
const filePaths = extractFilePaths(content, projectPath);
|
|
@@ -311,13 +312,14 @@ async function computeAcCoverageScore(criteria, filePaths) {
|
|
|
311
312
|
// ---------------------------------------------------------------------------
|
|
312
313
|
// Internal helpers
|
|
313
314
|
// ---------------------------------------------------------------------------
|
|
314
|
-
/** Resolve the path to a spec
|
|
315
|
-
function
|
|
316
|
-
/* v8 ignore next
|
|
315
|
+
/** Resolve the path to a canonical spec.md file. */
|
|
316
|
+
function resolveSpecPath(projectPath, specId, technicalPath) {
|
|
317
|
+
/* v8 ignore next 5 */
|
|
317
318
|
if (technicalPath !== undefined && fs.existsSync(technicalPath)) {
|
|
318
|
-
|
|
319
|
+
const specPathFromLegacyHint = path.join(path.dirname(technicalPath), 'spec.md');
|
|
320
|
+
return fs.existsSync(specPathFromLegacyHint) ? specPathFromLegacyHint : null;
|
|
319
321
|
}
|
|
320
|
-
// Convention: planu/specs/<specId>-<slug>/
|
|
322
|
+
// Convention: planu/specs/<specId>-<slug>/spec.md
|
|
321
323
|
const specsDir = path.join(projectPath, 'planu', 'specs');
|
|
322
324
|
if (!fs.existsSync(specsDir)) {
|
|
323
325
|
return null;
|
|
@@ -334,10 +336,10 @@ function resolveTechnicalPath(projectPath, specId, technicalPath) {
|
|
|
334
336
|
if (match === undefined) {
|
|
335
337
|
return null;
|
|
336
338
|
}
|
|
337
|
-
const candidate = path.join(specsDir, match, '
|
|
339
|
+
const candidate = path.join(specsDir, match, 'spec.md');
|
|
338
340
|
return fs.existsSync(candidate) ? candidate : null;
|
|
339
341
|
}
|
|
340
|
-
/** Extract implementation file paths from
|
|
342
|
+
/** Extract implementation file paths from a spec's inline ## Technical content. */
|
|
341
343
|
function extractFilePaths(content, projectPath) {
|
|
342
344
|
const FILE_PATTERN = /\b([\w./-]+\.(?:ts|tsx|js|jsx|py|go|rs|java|rb|cs))\b/g;
|
|
343
345
|
const found = new Set();
|
package/dist/engine/qa-gate.js
CHANGED
|
@@ -26,7 +26,12 @@ export function runQaGate(spec, projectPath) {
|
|
|
26
26
|
const coverageThreshold = extractCoverageThreshold(spec);
|
|
27
27
|
const checks = [];
|
|
28
28
|
checks.push(runCheck('typecheck', 'pnpm', ['typecheck'], projectPath));
|
|
29
|
-
|
|
29
|
+
if (coverageThreshold === null) {
|
|
30
|
+
checks.push(runCheck('test', 'pnpm', ['test'], projectPath));
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
checks.push(runCheck('test-coverage', 'pnpm', ['test:coverage'], projectPath));
|
|
34
|
+
}
|
|
30
35
|
const passed = checks.every((c) => c.passed);
|
|
31
36
|
return Promise.resolve({
|
|
32
37
|
specId: spec.id,
|
|
@@ -155,7 +155,7 @@ function scoreFilesIdentified(spec, fichaContent) {
|
|
|
155
155
|
}
|
|
156
156
|
else {
|
|
157
157
|
points = 0;
|
|
158
|
-
warnings.push('No
|
|
158
|
+
warnings.push('No inline ## Technical section found in spec.md — files to create/modify are not identified.');
|
|
159
159
|
}
|
|
160
160
|
return { points, blockers, warnings };
|
|
161
161
|
}
|
|
@@ -180,7 +180,7 @@ function buildRecommendations(score, huPoints, criteriaPoints, filesPoints, deps
|
|
|
180
180
|
recs.push('Add more acceptance criteria (aim for 5+ testable criteria)');
|
|
181
181
|
}
|
|
182
182
|
if (filesPoints < 20) {
|
|
183
|
-
recs.push('
|
|
183
|
+
recs.push('Add an inline ## Technical section to spec.md with key files');
|
|
184
184
|
}
|
|
185
185
|
if (depsPoints < 30) {
|
|
186
186
|
recs.push('Verify all spec dependencies are in "done" status');
|
|
@@ -360,7 +360,7 @@ export function checkReadinessInternal(body) {
|
|
|
360
360
|
// Vague criteria detection (re-uses existing logic)
|
|
361
361
|
const criteriaResult = scoreCriteria(criteriaLines.length > 0 ? criteriaLines : [], vagueWords);
|
|
362
362
|
warnings.push(...criteriaResult.warnings);
|
|
363
|
-
// Technical files presence (proxy for
|
|
363
|
+
// Technical files presence (proxy for inline ## Technical — check src/ paths in body)
|
|
364
364
|
const hasTechnicalFiles = /\bsrc\/[^\s]+\.[jt]s\b/.test(bodyContent);
|
|
365
365
|
const technicalScore = hasTechnicalFiles ? 30 : 0;
|
|
366
366
|
if (!hasTechnicalFiles && criteriaCount > 0) {
|
|
@@ -2,7 +2,7 @@ import type { SpecConflictSafety, SpecConflictCheckResult, ConflictGraphEntry, S
|
|
|
2
2
|
export type { SpecConflictSafety, SpecConflictCheckResult, ConflictGraphEntry, SpecConflictGraph };
|
|
3
3
|
/**
|
|
4
4
|
* SPEC-657: Update the conflict graph entry for a single spec.
|
|
5
|
-
* Called whenever a spec's
|
|
5
|
+
* Called whenever a spec's inline ## Technical section changes.
|
|
6
6
|
*/
|
|
7
7
|
export declare function updateConflictGraphEntry(projectPath: string, specId: string): Promise<void>;
|
|
8
8
|
/**
|
|
@@ -39,8 +39,7 @@ async function readTechnicalFiles(projectPath, specId) {
|
|
|
39
39
|
return [];
|
|
40
40
|
}
|
|
41
41
|
const specPath = join(specsDir, folder, 'spec.md');
|
|
42
|
-
const
|
|
43
|
-
const content = await readSpecTechnicalSection({ specPath, technicalPath });
|
|
42
|
+
const content = await readSpecTechnicalSection({ specPath });
|
|
44
43
|
if (content.length === 0) {
|
|
45
44
|
return [];
|
|
46
45
|
}
|
|
@@ -62,7 +61,7 @@ async function saveGraph(projectPath, graph) {
|
|
|
62
61
|
}
|
|
63
62
|
/**
|
|
64
63
|
* SPEC-657: Update the conflict graph entry for a single spec.
|
|
65
|
-
* Called whenever a spec's
|
|
64
|
+
* Called whenever a spec's inline ## Technical section changes.
|
|
66
65
|
*/
|
|
67
66
|
export async function updateConflictGraphEntry(projectPath, specId) {
|
|
68
67
|
const files = await readTechnicalFiles(projectPath, specId);
|
|
@@ -2,14 +2,8 @@ import type { Spec } from '../../types/index.js';
|
|
|
2
2
|
/**
|
|
3
3
|
* Read the `## Technical` section body from a spec's unified `spec.md`.
|
|
4
4
|
* Returns "" when the spec file is unreadable or the section is missing.
|
|
5
|
-
*
|
|
6
|
-
* The legacy `spec.technicalPath` is consulted as a transitional fallback:
|
|
7
|
-
* if the spec.md does not yet contain a `## Technical` section but a
|
|
8
|
-
* standalone `technical.md` still exists on disk (pre-migration data),
|
|
9
|
-
* we read that file instead and strip its YAML frontmatter so callers
|
|
10
|
-
* receive the body content uniformly.
|
|
11
5
|
*/
|
|
12
|
-
export declare function readSpecTechnicalSection(spec: Pick<Spec, 'specPath'
|
|
6
|
+
export declare function readSpecTechnicalSection(spec: Pick<Spec, 'specPath'>): Promise<string>;
|
|
13
7
|
/**
|
|
14
8
|
* Extract the body of a top-level (`## `) section from a markdown document.
|
|
15
9
|
* Returns the content AFTER the heading line, up to the next top-level
|
|
@@ -1,35 +1,16 @@
|
|
|
1
1
|
// engine/spec-format/read-technical-section.ts — SPEC-1010 PR-B
|
|
2
2
|
//
|
|
3
3
|
// SSR back-migration (SPEC-752, v2.4.0) folded `technical.md` into the
|
|
4
|
-
// unified `spec.md` as a `## Technical` section.
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// is the single source of truth for "give me the technical body for
|
|
8
|
-
// this spec": it extracts the `## Technical` section from `spec.md`
|
|
9
|
-
// and falls back to the legacy `technical.md` only when the unified
|
|
10
|
-
// section is empty (transitional safety net for specs not yet auto-
|
|
11
|
-
// migrated).
|
|
4
|
+
// unified `spec.md` as a `## Technical` section. This helper is the
|
|
5
|
+
// single source of truth for "give me the technical body for this spec":
|
|
6
|
+
// it extracts only the canonical inline `## Technical` section.
|
|
12
7
|
import { readFile } from 'node:fs/promises';
|
|
13
8
|
/**
|
|
14
9
|
* Read the `## Technical` section body from a spec's unified `spec.md`.
|
|
15
10
|
* Returns "" when the spec file is unreadable or the section is missing.
|
|
16
|
-
*
|
|
17
|
-
* The legacy `spec.technicalPath` is consulted as a transitional fallback:
|
|
18
|
-
* if the spec.md does not yet contain a `## Technical` section but a
|
|
19
|
-
* standalone `technical.md` still exists on disk (pre-migration data),
|
|
20
|
-
* we read that file instead and strip its YAML frontmatter so callers
|
|
21
|
-
* receive the body content uniformly.
|
|
22
11
|
*/
|
|
23
12
|
export async function readSpecTechnicalSection(spec) {
|
|
24
|
-
|
|
25
|
-
if (fromUnified.length > 0) {
|
|
26
|
-
return fromUnified;
|
|
27
|
-
}
|
|
28
|
-
if (!spec.technicalPath) {
|
|
29
|
-
return '';
|
|
30
|
-
}
|
|
31
|
-
const legacy = await readFile(spec.technicalPath, 'utf-8').catch(() => '');
|
|
32
|
-
return stripFrontmatter(legacy).trim();
|
|
13
|
+
return extractFromUnified(spec.specPath);
|
|
33
14
|
}
|
|
34
15
|
async function extractFromUnified(specPath) {
|
|
35
16
|
const content = await readFile(specPath, 'utf-8').catch(() => '');
|
|
@@ -85,13 +66,6 @@ function maskFencedBlocks(body) {
|
|
|
85
66
|
return block.replace(/[^\n]/g, ' ');
|
|
86
67
|
});
|
|
87
68
|
}
|
|
88
|
-
/**
|
|
89
|
-
* Strip a leading YAML frontmatter block (between `---` fences at the top
|
|
90
|
-
* of the document). No-op if frontmatter is absent.
|
|
91
|
-
*/
|
|
92
|
-
function stripFrontmatter(content) {
|
|
93
|
-
return content.replace(/^---\n[\s\S]*?\n---\n?/, '');
|
|
94
|
-
}
|
|
95
69
|
function escapeRegex(input) {
|
|
96
70
|
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
97
71
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ScoreBreakdown } from '../../types/index.js';
|
|
2
2
|
/**
|
|
3
3
|
* Calculate a completeness score (0-100) for a spec directory.
|
|
4
|
-
* Reads spec.md
|
|
4
|
+
* Reads canonical spec.md, evaluates 6 quality categories.
|
|
5
5
|
*/
|
|
6
6
|
export declare function calculateCompletenessScore(specDir: string): Promise<{
|
|
7
7
|
score: number;
|
|
@@ -81,7 +81,7 @@ function scoreKeyFiles(specContent, technicalContent) {
|
|
|
81
81
|
/* v8 ignore next -- files header present but no file-like entries */
|
|
82
82
|
return 0;
|
|
83
83
|
}
|
|
84
|
-
/** Score technical
|
|
84
|
+
/** Score inline technical content based on existence and length. */
|
|
85
85
|
function scoreTechnicalCompleteness(technicalContent) {
|
|
86
86
|
if (!technicalContent) {
|
|
87
87
|
return 0;
|
|
@@ -126,14 +126,13 @@ function scoreFrontmatter(specContent) {
|
|
|
126
126
|
// ---------------------------------------------------------------------------
|
|
127
127
|
/**
|
|
128
128
|
* Calculate a completeness score (0-100) for a spec directory.
|
|
129
|
-
* Reads spec.md
|
|
129
|
+
* Reads canonical spec.md, evaluates 6 quality categories.
|
|
130
130
|
*/
|
|
131
131
|
export async function calculateCompletenessScore(specDir) {
|
|
132
132
|
const specPath = join(specDir, 'spec.md');
|
|
133
|
-
const technicalPath = join(specDir, 'technical.md');
|
|
134
133
|
const [specContent, technicalContent] = await Promise.all([
|
|
135
134
|
readFileOrEmpty(specPath),
|
|
136
|
-
readSpecTechnicalSection({ specPath
|
|
135
|
+
readSpecTechnicalSection({ specPath }),
|
|
137
136
|
]);
|
|
138
137
|
const breakdown = {
|
|
139
138
|
acceptanceCriteria: scoreAcceptanceCriteria(specContent),
|
|
@@ -17,8 +17,10 @@ export async function extractCriteria(spec) {
|
|
|
17
17
|
const items = extractListItems(acSection);
|
|
18
18
|
criteria.push(...items);
|
|
19
19
|
}
|
|
20
|
-
// Also check for "Given/When/Then" patterns
|
|
21
|
-
|
|
20
|
+
// Also check for explicit "Given/When/Then" patterns at line start.
|
|
21
|
+
// Keep this anchored so narrative text containing "when" is not treated
|
|
22
|
+
// as an acceptance criterion.
|
|
23
|
+
const gwtMatches = huContent.matchAll(/^\s*(?:[-*+]\s+|\d+[.)]\s+)?(?:Given|Dado|When|Cuando|Then|Entonces)\s+(.+)$/gim);
|
|
22
24
|
for (const match of gwtMatches) {
|
|
23
25
|
/* v8 ignore next 3 -- defensive regex capture group guard */
|
|
24
26
|
if (match[1]) {
|
|
@@ -7,7 +7,7 @@ export { buildHolisticReportFromFlatScore } from './validator/holistic-report.js
|
|
|
7
7
|
export type { HolisticValidateReport } from '../types/analysis.js';
|
|
8
8
|
/**
|
|
9
9
|
* Validate a spec against the actual codebase.
|
|
10
|
-
* Reads the spec
|
|
10
|
+
* Reads the canonical spec.md, extracts criteria,
|
|
11
11
|
* then checks the filesystem/code for compliance.
|
|
12
12
|
*
|
|
13
13
|
* SPEC-730: The returned ValidateResult is enriched with `holisticReport`
|
package/dist/engine/validator.js
CHANGED
|
@@ -6,6 +6,7 @@ import { extractCriteria, extractVerifyBlocks } from './validator/extractors.js'
|
|
|
6
6
|
import { scanCodeForSpec, checkCriterion, classifyDriftSeverity, quickQualityCheck, } from './validator/analyzer.js';
|
|
7
7
|
import { resolveApplicableDimensions } from './validator/scope-resolver.js';
|
|
8
8
|
import { buildHolisticReportFromFlatScore } from './validator/holistic-report.js';
|
|
9
|
+
import { readEvidenceArtifacts } from './evidence-gates/artifact-reader.js';
|
|
9
10
|
// Re-export the full public API so external callers never need to know
|
|
10
11
|
// about the sub-module layout.
|
|
11
12
|
export { generateDoR, generateDoD } from './validator/dor-dod.js';
|
|
@@ -13,9 +14,43 @@ export { generateChecklist } from './validator/checklist.js';
|
|
|
13
14
|
// SPEC-730: export scope-resolver and holistic-report for consumers
|
|
14
15
|
export { resolveApplicableDimensions, normaliseScore } from './validator/scope-resolver.js';
|
|
15
16
|
export { buildHolisticReportFromFlatScore } from './validator/holistic-report.js';
|
|
17
|
+
function normalizeCriterionForEvidence(value) {
|
|
18
|
+
return value
|
|
19
|
+
.replace(/^-\s*\[[ xX]\]\s*/i, '')
|
|
20
|
+
.replace(/\s+/g, ' ')
|
|
21
|
+
.trim()
|
|
22
|
+
.toLowerCase();
|
|
23
|
+
}
|
|
24
|
+
function rowHasValidationEvidence(row) {
|
|
25
|
+
const hasEvidence = [
|
|
26
|
+
row.testEvidence?.length,
|
|
27
|
+
row.contractEvidence?.length,
|
|
28
|
+
row.manualEvidence?.trim(),
|
|
29
|
+
row.validationEvidence?.trim(),
|
|
30
|
+
row.reviewerEvidence?.trim(),
|
|
31
|
+
].some((value) => Boolean(value));
|
|
32
|
+
return row.changedFiles.length > 0 && hasEvidence;
|
|
33
|
+
}
|
|
34
|
+
async function readTraceabilityEvidence(spec, projectPath) {
|
|
35
|
+
try {
|
|
36
|
+
const artifacts = await readEvidenceArtifacts({
|
|
37
|
+
spec,
|
|
38
|
+
projectId: spec.projectId,
|
|
39
|
+
specId: spec.id,
|
|
40
|
+
projectPath,
|
|
41
|
+
});
|
|
42
|
+
const rows = artifacts.traceabilityMatrix?.rows ?? [];
|
|
43
|
+
return new Map(rows
|
|
44
|
+
.filter(rowHasValidationEvidence)
|
|
45
|
+
.map((row) => [normalizeCriterionForEvidence(row.acceptanceCriterion), row]));
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return new Map();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
16
51
|
/**
|
|
17
52
|
* Validate a spec against the actual codebase.
|
|
18
|
-
* Reads the spec
|
|
53
|
+
* Reads the canonical spec.md, extracts criteria,
|
|
19
54
|
* then checks the filesystem/code for compliance.
|
|
20
55
|
*
|
|
21
56
|
* SPEC-730: The returned ValidateResult is enriched with `holisticReport`
|
|
@@ -45,6 +80,7 @@ export async function validateSpec(spec, projectPath) {
|
|
|
45
80
|
};
|
|
46
81
|
}
|
|
47
82
|
const criteria = await extractCriteria(spec);
|
|
83
|
+
const traceabilityEvidence = await readTraceabilityEvidence(spec, projectPath);
|
|
48
84
|
const codeState = await scanCodeForSpec(spec, projectPath);
|
|
49
85
|
// Extract verify blocks from spec file (forgiving — empty map if file unreadable)
|
|
50
86
|
let verifyBlockMap = new Map();
|
|
@@ -61,7 +97,9 @@ export async function validateSpec(spec, projectPath) {
|
|
|
61
97
|
const qualityIssues = [];
|
|
62
98
|
for (const criterion of criteria) {
|
|
63
99
|
const verifyBlock = verifyBlockMap.get(criterion);
|
|
64
|
-
const
|
|
100
|
+
const evidenceRow = traceabilityEvidence.get(normalizeCriterionForEvidence(criterion));
|
|
101
|
+
const found = evidenceRow !== undefined ||
|
|
102
|
+
(await checkCriterion(criterion, projectPath, codeState, verifyBlock));
|
|
65
103
|
if (found) {
|
|
66
104
|
matches.push(criterion);
|
|
67
105
|
}
|
|
@@ -525,7 +525,7 @@ export async function checkQaGate(specId, projectPath, force) {
|
|
|
525
525
|
error: 'qa_gate_not_passed',
|
|
526
526
|
code: 422,
|
|
527
527
|
context: { specId, reason },
|
|
528
|
-
fixHint: 'Run typecheck + test:coverage
|
|
528
|
+
fixHint: 'Run the QA gate and record the result: typecheck + test by default, or typecheck + test:coverage when the spec declares a coverage threshold. Then retry update_status(done). Or use force:true to bypass.',
|
|
529
529
|
},
|
|
530
530
|
};
|
|
531
531
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export interface ContextGraphInput {
|
|
2
|
+
projectPath: string;
|
|
3
|
+
specId?: string;
|
|
4
|
+
changedFiles?: string[];
|
|
5
|
+
tokenBudget: number;
|
|
6
|
+
}
|
|
7
|
+
export interface ContextImpactFile {
|
|
8
|
+
path: string;
|
|
9
|
+
reason: string;
|
|
10
|
+
confidence: number;
|
|
11
|
+
}
|
|
12
|
+
export interface ContextImpactSymbol {
|
|
13
|
+
name: string;
|
|
14
|
+
path: string;
|
|
15
|
+
line?: number;
|
|
16
|
+
confidence: number;
|
|
17
|
+
}
|
|
18
|
+
export interface ContextImpactEdge {
|
|
19
|
+
from: string;
|
|
20
|
+
to: string;
|
|
21
|
+
type: 'import' | 'call' | 'test' | 'spec';
|
|
22
|
+
}
|
|
23
|
+
export interface ContextImpactMap {
|
|
24
|
+
files: ContextImpactFile[];
|
|
25
|
+
symbols: ContextImpactSymbol[];
|
|
26
|
+
edges: ContextImpactEdge[];
|
|
27
|
+
truncated: boolean;
|
|
28
|
+
}
|
|
29
|
+
export interface ContextGraphProvider {
|
|
30
|
+
buildImpactMap(input: ContextGraphInput): Promise<ContextImpactMap>;
|
|
31
|
+
}
|
|
32
|
+
export interface SafeCompressionResult {
|
|
33
|
+
ok: boolean;
|
|
34
|
+
originalBytes: number;
|
|
35
|
+
compressedBytes: number;
|
|
36
|
+
refusedReason?: string;
|
|
37
|
+
preservedChecks: string[];
|
|
38
|
+
content?: string;
|
|
39
|
+
}
|
|
40
|
+
export interface SafeContextCompressionInput {
|
|
41
|
+
content: string;
|
|
42
|
+
sourcePath?: string;
|
|
43
|
+
maxBytes?: number;
|
|
44
|
+
flow?: string;
|
|
45
|
+
}
|
|
46
|
+
export interface ContextEvalCase {
|
|
47
|
+
id: string;
|
|
48
|
+
baselineTokens: number;
|
|
49
|
+
specGuidedTokens: number;
|
|
50
|
+
graphGuidedTokens: number;
|
|
51
|
+
leanTokens: number;
|
|
52
|
+
graphLeanTokens: number;
|
|
53
|
+
}
|
|
54
|
+
export type ContextEvalMode = 'baseline' | 'spec-guided' | 'graph-guided' | 'lean' | 'graph+lean';
|
|
55
|
+
export interface ContextEvalResult {
|
|
56
|
+
id: string;
|
|
57
|
+
bestMode: ContextEvalMode;
|
|
58
|
+
savingsVsBaselinePct: number;
|
|
59
|
+
modes: Record<ContextEvalMode, number>;
|
|
60
|
+
}
|
|
61
|
+
//# sourceMappingURL=context-intelligence.d.ts.map
|
package/dist/types/qa-gate.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@planu/cli",
|
|
3
|
-
"version": "4.3.
|
|
3
|
+
"version": "4.3.6",
|
|
4
4
|
"description": "Planu — MCP Server for Spec Driven Development with native Rust acceleration for hot paths. Cross-platform (Linux/macOS/Windows, x64/arm64, glibc/musl).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -32,14 +32,14 @@
|
|
|
32
32
|
"packageName": "@planu/core"
|
|
33
33
|
},
|
|
34
34
|
"optionalDependencies": {
|
|
35
|
-
"@planu/core-darwin-arm64": "4.3.
|
|
36
|
-
"@planu/core-darwin-x64": "4.3.
|
|
37
|
-
"@planu/core-linux-arm64-gnu": "4.3.
|
|
38
|
-
"@planu/core-linux-arm64-musl": "4.3.
|
|
39
|
-
"@planu/core-linux-x64-gnu": "4.3.
|
|
40
|
-
"@planu/core-linux-x64-musl": "4.3.
|
|
41
|
-
"@planu/core-win32-arm64-msvc": "4.3.
|
|
42
|
-
"@planu/core-win32-x64-msvc": "4.3.
|
|
35
|
+
"@planu/core-darwin-arm64": "4.3.6",
|
|
36
|
+
"@planu/core-darwin-x64": "4.3.6",
|
|
37
|
+
"@planu/core-linux-arm64-gnu": "4.3.6",
|
|
38
|
+
"@planu/core-linux-arm64-musl": "4.3.6",
|
|
39
|
+
"@planu/core-linux-x64-gnu": "4.3.6",
|
|
40
|
+
"@planu/core-linux-x64-musl": "4.3.6",
|
|
41
|
+
"@planu/core-win32-arm64-msvc": "4.3.6",
|
|
42
|
+
"@planu/core-win32-x64-msvc": "4.3.6"
|
|
43
43
|
},
|
|
44
44
|
"engines": {
|
|
45
45
|
"node": ">=24.0.0"
|