@planu/cli 4.3.8 → 4.3.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +12 -1
  2. package/dist/engine/hooks/file-watcher.js +1 -2
  3. package/dist/engine/pr-description-generator.js +5 -2
  4. package/dist/engine/validator/checklist.js +1 -1
  5. package/dist/resources/process.js +1 -1
  6. package/dist/resources/templates.js +1 -1
  7. package/dist/types/index.d.ts +0 -1
  8. package/dist/types/index.js +0 -1
  9. package/dist/types/spec-format.d.ts +2 -2
  10. package/package.json +9 -9
  11. package/dist/engine/context-intelligence/index.d.ts +0 -7
  12. package/dist/engine/context-intelligence/index.js +0 -6
  13. package/dist/engine/legacy/ast-analyzer.d.ts +0 -21
  14. package/dist/engine/legacy/ast-analyzer.js +0 -113
  15. package/dist/engine/legacy/characterization-generator.d.ts +0 -6
  16. package/dist/engine/legacy/characterization-generator.js +0 -146
  17. package/dist/engine/legacy/hyrum-scanner.d.ts +0 -6
  18. package/dist/engine/legacy/hyrum-scanner.js +0 -137
  19. package/dist/engine/legacy/safety-net-orchestrator.d.ts +0 -7
  20. package/dist/engine/legacy/safety-net-orchestrator.js +0 -114
  21. package/dist/engine/legacy/seam-finder.d.ts +0 -6
  22. package/dist/engine/legacy/seam-finder.js +0 -139
  23. package/dist/tools/legacy/characterize-legacy-code.d.ts +0 -9
  24. package/dist/tools/legacy/characterize-legacy-code.js +0 -63
  25. package/dist/tools/legacy/detect-hyrum-risks.d.ts +0 -8
  26. package/dist/tools/legacy/detect-hyrum-risks.js +0 -47
  27. package/dist/tools/legacy/refactor-with-safety-net.d.ts +0 -10
  28. package/dist/tools/legacy/refactor-with-safety-net.js +0 -55
  29. package/dist/tools/legacy/seams-detector.d.ts +0 -8
  30. package/dist/tools/legacy/seams-detector.js +0 -47
  31. package/dist/types/legacy.d.ts +0 -151
  32. package/dist/types/legacy.js +0 -5
package/CHANGELOG.md CHANGED
@@ -1,3 +1,14 @@
1
+ ## [4.3.9] - 2026-05-25
2
+
3
+ **Tarball SHA-256:** `a47146af1f2f8e695247fb6ee92cc8da8de00b2ab7967734a7faade7f3659aeb`
4
+
5
+ ### Bug Fixes
6
+ - fix: keep MCP stdio output JSON-only
7
+
8
+ ### Chores
9
+ - chore: sync release banner version
10
+
11
+
1
12
  ## [4.3.5] - 2026-05-24
2
13
 
3
14
  ### Features
@@ -3948,4 +3959,4 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) · Versioning:
3948
3959
  - Mermaid diagram generation (architecture, sequence, state machine, ER, data flow)
3949
3960
  - Multi-language i18n (EN/ES/PT) for generated specs
3950
3961
  - Clean Architecture (hexagonal) — engine, tools, storage, types layers
3951
- - 10,857 tests with ≥95% coverage
3962
+ - 10,857 tests with ≥95% coverage
@@ -108,8 +108,7 @@ export class FileWatcher extends EventEmitter {
108
108
  }
109
109
  try {
110
110
  if (isNativeActive()) {
111
- // eslint-disable-next-line no-console
112
- console.log('[Planu-RS] Starting high-performance Rust file watcher');
111
+ console.error('[Planu-RS] Starting high-performance Rust file watcher');
113
112
  startNativeWatcher(this.rootDir, (err, filename) => {
114
113
  if (err || !filename || this.closed) {
115
114
  return;
@@ -1,3 +1,6 @@
1
+ function technicalSectionRef(spec) {
2
+ return `${spec.specPath}#technical`;
3
+ }
1
4
  // ---------------------------------------------------------------------------
2
5
  // Title generation
3
6
  // ---------------------------------------------------------------------------
@@ -46,7 +49,7 @@ function buildGithubMarkdown(spec, audience, includeChecklist) {
46
49
  sections.push('');
47
50
  sections.push(`## Changes`);
48
51
  sections.push(`- Spec: \`${spec.specPath}\``);
49
- sections.push(`- Technical: \`${spec.technicalPath}\``);
52
+ sections.push(`- Technical design: \`${technicalSectionRef(spec)}\``);
50
53
  if (spec.tags.length > 0) {
51
54
  sections.push(`- Tags: ${spec.tags.join(', ')}`);
52
55
  }
@@ -86,7 +89,7 @@ function buildGitlabMarkdown(spec, audience, includeChecklist) {
86
89
  sections.push(`Required by Planu spec ${spec.id}. Risk level: **${spec.risk}**. Difficulty: **${spec.difficulty}/5**.`);
87
90
  sections.push('');
88
91
  sections.push(`## How`);
89
- sections.push(`See technical spec at \`${spec.technicalPath}\` for implementation details.`);
92
+ sections.push(`See \`${technicalSectionRef(spec)}\` for implementation details.`);
90
93
  if (spec.tags.length > 0) {
91
94
  sections.push(`Tags: ${spec.tags.join(', ')}`);
92
95
  }
@@ -29,7 +29,7 @@ function buildCompletenessItems(spec) {
29
29
  },
30
30
  {
31
31
  id: 'cl-comp-2',
32
- question: 'Is the technical design documented in FICHA-TECNICA?',
32
+ question: 'Is the technical design documented in spec.md under ## Technical?',
33
33
  category: 'completeness',
34
34
  answer: spec.technicalPath ? 'pending' : 'no',
35
35
  autoChecked: false,
@@ -39,7 +39,7 @@ function getSddProcess() {
39
39
  id: 'creating-spec',
40
40
  name: '3. Create Spec',
41
41
  tools: ['create_spec', 'reverse_engineer', 'estimate'],
42
- description: 'Create a full SDD spec (HU.md + FICHA-TECNICA.md) from requirements or ' +
42
+ description: 'Create a full SDD spec.md from requirements or ' +
43
43
  'reverse-engineer from existing code. Estimate effort, cost, and tokens.',
44
44
  },
45
45
  {
@@ -11,7 +11,7 @@ function getBuiltInTemplates() {
11
11
  {
12
12
  id: 'feature',
13
13
  name: 'Feature Spec',
14
- description: 'Full feature specification with HU.md and FICHA-TECNICA.md',
14
+ description: 'Full feature specification using unified spec.md sections',
15
15
  type: 'feature',
16
16
  sections: [
17
17
  'metadata',
@@ -228,7 +228,6 @@ export * from './spec-from-issue.js';
228
228
  export * from './plan-mode.js';
229
229
  export * from './self-healing.js';
230
230
  export * from './deploy.js';
231
- export * from './legacy.js';
232
231
  export * from './multi-agent-review.js';
233
232
  export * from './delete-first.js';
234
233
  export * from './auto-checkpoint.js';
@@ -225,7 +225,6 @@ export * from './spec-from-issue.js';
225
225
  export * from './plan-mode.js';
226
226
  export * from './self-healing.js';
227
227
  export * from './deploy.js';
228
- export * from './legacy.js';
229
228
  export * from './multi-agent-review.js';
230
229
  export * from './delete-first.js';
231
230
  export * from './auto-checkpoint.js';
@@ -32,7 +32,7 @@ export interface LeanSpecInput {
32
32
  /** SPEC-481: Acceptance criteria format. Defaults to 'checkbox'. */
33
33
  acFormat?: 'checkbox' | 'bdd';
34
34
  }
35
- /** A file entry in the lean technical.md. */
35
+ /** A file entry in the unified spec.md Technical section. */
36
36
  export interface LeanFileEntry {
37
37
  path: string;
38
38
  status: 'pending' | 'done';
@@ -102,7 +102,7 @@ export interface ReplaceSectionResult {
102
102
  replaced: boolean;
103
103
  appended: boolean;
104
104
  }
105
- /** Input for lean technical.md generation. */
105
+ /** Input for lean spec.md Technical section generation. */
106
106
  export interface LeanTechnicalInput {
107
107
  specId: string;
108
108
  filesToCreate?: LeanFileEntry[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planu/cli",
3
- "version": "4.3.8",
3
+ "version": "4.3.10",
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.8",
36
- "@planu/core-darwin-x64": "4.3.8",
37
- "@planu/core-linux-arm64-gnu": "4.3.8",
38
- "@planu/core-linux-arm64-musl": "4.3.8",
39
- "@planu/core-linux-x64-gnu": "4.3.8",
40
- "@planu/core-linux-x64-musl": "4.3.8",
41
- "@planu/core-win32-arm64-msvc": "4.3.8",
42
- "@planu/core-win32-x64-msvc": "4.3.8"
35
+ "@planu/core-darwin-arm64": "4.3.10",
36
+ "@planu/core-darwin-x64": "4.3.10",
37
+ "@planu/core-linux-arm64-gnu": "4.3.10",
38
+ "@planu/core-linux-arm64-musl": "4.3.10",
39
+ "@planu/core-linux-x64-gnu": "4.3.10",
40
+ "@planu/core-linux-x64-musl": "4.3.10",
41
+ "@planu/core-win32-arm64-msvc": "4.3.10",
42
+ "@planu/core-win32-x64-msvc": "4.3.10"
43
43
  },
44
44
  "engines": {
45
45
  "node": ">=24.0.0"
@@ -1,7 +0,0 @@
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
@@ -1,6 +0,0 @@
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
@@ -1,21 +0,0 @@
1
- import type { LegacyFunctionInfo, LegacyFileAnalysis } from '../../types/index.js';
2
- export type { LegacyFunctionInfo, LegacyFileAnalysis };
3
- /**
4
- * Checks if the given file extension is a supported TypeScript/JavaScript file.
5
- */
6
- export declare function isSupportedFile(filePath: string): boolean;
7
- /**
8
- * Extracts a short snippet of lines around a given line number.
9
- */
10
- export declare function extractSnippet(lines: string[], lineIndex: number, context?: number): string;
11
- /**
12
- * Parses function names and their line numbers from source code.
13
- * Uses regex-based detection to avoid external AST dependencies.
14
- */
15
- export declare function extractFunctions(content: string): LegacyFunctionInfo[];
16
- /**
17
- * Reads and analyzes a TypeScript/JavaScript source file.
18
- * Returns null if the file is not supported or cannot be read.
19
- */
20
- export declare function analyzeFile(filePath: string): Promise<LegacyFileAnalysis | null>;
21
- //# sourceMappingURL=ast-analyzer.d.ts.map
@@ -1,113 +0,0 @@
1
- // Planu — engine/legacy/ast-analyzer.ts
2
- // Minimal TypeScript/JavaScript AST analyzer using regex-based patterns.
3
- // No external dependencies beyond Node built-ins.
4
- import { readFile } from 'node:fs/promises';
5
- import { extname } from 'node:path';
6
- // Regex patterns for function detection
7
- const FUNCTION_PATTERNS = [
8
- // export function foo(
9
- /^(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(([^)]*)\)/m,
10
- // const foo = (
11
- /(?:export\s+)?(?:const|let)\s+(\w+)\s*=\s*(?:async\s*)?\(([^)]*)\)\s*(?::\s*\S+\s*)?=>/m,
12
- // foo(
13
- /^(?:export\s+)?(?:async\s+)?(\w+)\s*\(([^)]*)\)\s*(?::\s*\S+\s*)?{/m,
14
- ];
15
- /**
16
- * Checks if the given file extension is a supported TypeScript/JavaScript file.
17
- */
18
- export function isSupportedFile(filePath) {
19
- const ext = extname(filePath).toLowerCase();
20
- return ['.ts', '.tsx', '.js', '.jsx', '.mts', '.mjs'].includes(ext);
21
- }
22
- /**
23
- * Extracts a short snippet of lines around a given line number.
24
- */
25
- export function extractSnippet(lines, lineIndex, context = 3) {
26
- const start = Math.max(0, lineIndex - 1);
27
- const end = Math.min(lines.length, lineIndex + context);
28
- return lines
29
- .slice(start, end)
30
- .map((l) => l.trimEnd())
31
- .join('\n');
32
- }
33
- /**
34
- * Parses function names and their line numbers from source code.
35
- * Uses regex-based detection to avoid external AST dependencies.
36
- */
37
- export function extractFunctions(content) {
38
- const lines = content.split('\n');
39
- const functions = [];
40
- const seen = new Set();
41
- for (let i = 0; i < lines.length; i++) {
42
- const line = lines[i] ?? '';
43
- const trimmed = line.trim();
44
- // Skip comments and empty lines
45
- if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed === '') {
46
- continue;
47
- }
48
- for (const pattern of FUNCTION_PATTERNS) {
49
- const match = trimmed.match(pattern);
50
- if (match) {
51
- const name = match[1];
52
- const paramsRaw = match[2] ?? '';
53
- if (name === undefined || name === '' || seen.has(`${name}:${i}`)) {
54
- break;
55
- }
56
- seen.add(`${name}:${i}`);
57
- const params = paramsRaw
58
- .split(',')
59
- .map((p) => p.trim().split(':')[0]?.trim() ?? '')
60
- .filter((p) => p.length > 0);
61
- // Collect function body (up to closing brace or end of arrow fn)
62
- const bodyLines = [];
63
- let braceCount = 0;
64
- let bodyStarted = false;
65
- for (let j = i; j < Math.min(lines.length, i + 80); j++) {
66
- const bl = lines[j] ?? '';
67
- bodyLines.push(bl);
68
- for (const ch of bl) {
69
- if (ch === '{') {
70
- braceCount++;
71
- bodyStarted = true;
72
- }
73
- else if (ch === '}') {
74
- braceCount--;
75
- }
76
- }
77
- if (bodyStarted && braceCount === 0) {
78
- break;
79
- }
80
- }
81
- functions.push({
82
- name,
83
- line: i + 1,
84
- params,
85
- body: bodyLines.join('\n'),
86
- snippet: extractSnippet(lines, i + 1),
87
- });
88
- break;
89
- }
90
- }
91
- }
92
- return functions;
93
- }
94
- /**
95
- * Reads and analyzes a TypeScript/JavaScript source file.
96
- * Returns null if the file is not supported or cannot be read.
97
- */
98
- export async function analyzeFile(filePath) {
99
- if (!isSupportedFile(filePath)) {
100
- return null;
101
- }
102
- let content;
103
- try {
104
- content = await readFile(filePath, 'utf-8');
105
- }
106
- catch {
107
- return null;
108
- }
109
- const lines = content.split('\n');
110
- const functions = extractFunctions(content);
111
- return { filePath, functions, lines };
112
- }
113
- //# sourceMappingURL=ast-analyzer.js.map
@@ -1,6 +0,0 @@
1
- import type { CharacterizationResult } from '../../types/index.js';
2
- /**
3
- * Generates characterization test stubs for functions in the given files.
4
- */
5
- export declare function generateCharacterizationTests(projectPath: string, files?: string[], outputFile?: string): Promise<CharacterizationResult>;
6
- //# sourceMappingURL=characterization-generator.d.ts.map
@@ -1,146 +0,0 @@
1
- // Planu — engine/legacy/characterization-generator.ts
2
- // Generates characterization test stubs for existing functions.
3
- // The stubs call the function with detected sample inputs and leave
4
- // `// TODO: assert current output` placeholders for the developer to fill in.
5
- import { glob } from 'glob';
6
- import { join, relative, basename } from 'node:path';
7
- import { analyzeFile } from './ast-analyzer.js';
8
- // ---------------------------------------------------------------------------
9
- // Sample input inference
10
- // ---------------------------------------------------------------------------
11
- // Keyword groups for sample value inference
12
- const PATH_KEYWORDS = ['path', 'dir', 'file'];
13
- const URL_KEYWORDS = ['url', 'endpoint'];
14
- const ID_KEYWORDS = ['id', 'key'];
15
- const NUM_KEYWORDS = ['count', 'size', 'limit', 'max', 'min', 'num', 'index'];
16
- const BOOL_KEYWORDS = ['flag', 'enabled', 'active', 'is', 'has'];
17
- const ARRAY_KEYWORDS = ['list', 'items', 'arr'];
18
- const OBJ_KEYWORDS = ['opts', 'options', 'config', 'args'];
19
- function matchesAny(lower, keywords) {
20
- return keywords.some((k) => lower.includes(k));
21
- }
22
- /**
23
- * Infers a plausible sample value for a parameter based on its name/type hints.
24
- */
25
- function inferSampleValue(paramName) {
26
- const lower = paramName.toLowerCase();
27
- if (matchesAny(lower, PATH_KEYWORDS)) {
28
- return { name: paramName, value: '"/tmp/sample"', type: 'string' };
29
- }
30
- if (matchesAny(lower, URL_KEYWORDS)) {
31
- return { name: paramName, value: '"https://example.com"', type: 'string' };
32
- }
33
- if (matchesAny(lower, ID_KEYWORDS)) {
34
- return { name: paramName, value: '"test-id-001"', type: 'string' };
35
- }
36
- if (matchesAny(lower, NUM_KEYWORDS)) {
37
- return { name: paramName, value: '10', type: 'number' };
38
- }
39
- if (matchesAny(lower, BOOL_KEYWORDS)) {
40
- return { name: paramName, value: 'true', type: 'boolean' };
41
- }
42
- if (matchesAny(lower, ARRAY_KEYWORDS)) {
43
- return { name: paramName, value: '[]', type: 'unknown[]' };
44
- }
45
- if (matchesAny(lower, OBJ_KEYWORDS)) {
46
- return { name: paramName, value: '{}', type: 'Record<string, unknown>' };
47
- }
48
- return { name: paramName, value: `"${paramName}-value"`, type: 'string' };
49
- }
50
- /**
51
- * Generates a test stub for a single function.
52
- */
53
- function generateTestStub(fn, sourceFile, importPath, testId) {
54
- const params = fn.params;
55
- const sampleInputs = params.map(inferSampleValue);
56
- const paramList = sampleInputs.map((s) => s.value).join(', ');
57
- const isAsync = fn.body.includes('await') || fn.body.includes('async');
58
- const awaitKeyword = isAsync ? 'await ' : '';
59
- const testModifier = isAsync ? 'async ' : '';
60
- const stubLines = [
61
- `// Characterization test for ${fn.name}`,
62
- `// File: ${sourceFile}`,
63
- `// Generated by Planu — fill in the expected values.`,
64
- ``,
65
- `import { ${fn.name} } from '${importPath}';`,
66
- ``,
67
- `describe('${fn.name} (characterization)', () => {`,
68
- ` it('should behave as currently observed', ${testModifier}() => {`,
69
- ...sampleInputs.map((s) => ` const ${s.name} = ${s.value}; // ${s.type}`),
70
- ``,
71
- ` const result = ${awaitKeyword}${fn.name}(${paramList});`,
72
- ``,
73
- ` // TODO: Replace with actual observed output:`,
74
- ` // expect(result).toEqual(/* observed value */);`,
75
- ` expect(result).toBeDefined();`,
76
- ` });`,
77
- `});`,
78
- ];
79
- const testCode = stubLines.join('\n');
80
- return {
81
- id: testId,
82
- sourceFile,
83
- functionName: fn.name,
84
- testCode,
85
- sampleInputs,
86
- developerNote: 'Run the function manually with these inputs, observe the output, then replace the TODO assertion with the actual expected value.',
87
- };
88
- }
89
- // ---------------------------------------------------------------------------
90
- // File resolution
91
- // ---------------------------------------------------------------------------
92
- async function resolveFiles(projectPath, files) {
93
- if (files !== undefined && files.length > 0) {
94
- return files;
95
- }
96
- return glob('src/**/*.{ts,tsx,js,jsx}', {
97
- cwd: projectPath,
98
- absolute: true,
99
- ignore: ['**/node_modules/**', '**/dist/**', '**/*.test.*', '**/*.spec.*', '**/index.ts'],
100
- });
101
- }
102
- // ---------------------------------------------------------------------------
103
- // Main entry point
104
- // ---------------------------------------------------------------------------
105
- let testCounter = 0;
106
- function makeTestId() {
107
- testCounter++;
108
- return `CT-${String(testCounter).padStart(4, '0')}`;
109
- }
110
- /**
111
- * Generates characterization test stubs for functions in the given files.
112
- */
113
- export async function generateCharacterizationTests(projectPath, files, outputFile) {
114
- testCounter = 0;
115
- const resolvedFiles = await resolveFiles(projectPath, files);
116
- const allTests = [];
117
- for (const filePath of resolvedFiles) {
118
- const absPath = filePath.startsWith('/') ? filePath : join(projectPath, filePath);
119
- const analysis = await analyzeFile(absPath);
120
- if (analysis === null || analysis.functions.length === 0) {
121
- continue;
122
- }
123
- const relPath = relative(projectPath, absPath);
124
- // Convert to a relative import path usable in tests/
125
- const importBase = basename(absPath).replace(/\.(ts|tsx|js|jsx)$/, '.js');
126
- const importDir = relative(join(projectPath, 'tests'), join(projectPath, relPath.replace(/\/[^/]+$/, '')));
127
- const importPath = `${importDir}/${importBase}`;
128
- for (const fn of analysis.functions) {
129
- // Skip private-looking functions (underscore prefix)
130
- if (fn.name.startsWith('_')) {
131
- continue;
132
- }
133
- allTests.push(generateTestStub(fn, relPath, importPath, makeTestId()));
134
- }
135
- }
136
- const defaultOutput = join(projectPath, 'tests', 'characterization', 'characterization.test.ts');
137
- const outFile = outputFile ?? defaultOutput;
138
- return {
139
- projectPath,
140
- filesAnalyzed: resolvedFiles.length,
141
- tests: allTests,
142
- outputFile: outFile,
143
- summary: `Analyzed ${resolvedFiles.length} files. Generated ${allTests.length} characterization test stubs.`,
144
- };
145
- }
146
- //# sourceMappingURL=characterization-generator.js.map
@@ -1,6 +0,0 @@
1
- import type { HyrumScanResult } from '../../types/index.js';
2
- /**
3
- * Scans the given files (or project) for Hyrum's Law risks.
4
- */
5
- export declare function scanHyrumRisks(projectPath: string, files?: string[]): Promise<HyrumScanResult>;
6
- //# sourceMappingURL=hyrum-scanner.d.ts.map
@@ -1,137 +0,0 @@
1
- // Planu — engine/legacy/hyrum-scanner.ts
2
- // Detects Hyrum's Law risks in public API surfaces:
3
- // observable behaviors that callers may depend on even if undocumented.
4
- import { glob } from 'glob';
5
- import { analyzeFile, extractSnippet } from './ast-analyzer.js';
6
- // ---------------------------------------------------------------------------
7
- // Risk detection patterns
8
- // ---------------------------------------------------------------------------
9
- const RISK_PATTERNS = [
10
- {
11
- category: 'exception-type',
12
- regex: /throw\s+new\s+(\w+Error|Error)\s*\(/,
13
- severity: 'high',
14
- describe: (m) => `Throws ${m[1] ?? 'Error'} — callers may catch this specific type`,
15
- },
16
- {
17
- category: 'return-shape',
18
- regex: /return\s*\{\s*(\w+)\s*:/,
19
- severity: 'medium',
20
- describe: (m) => `Returns object with key "${m[1] ?? '?'}" — shape may be depended on`,
21
- },
22
- {
23
- category: 'null-vs-undefined',
24
- regex: /return\s+(null|undefined)\b/,
25
- severity: 'high',
26
- describe: (m) => `Returns ${m[1] ?? 'null/undefined'} — callers may distinguish null vs undefined`,
27
- },
28
- {
29
- category: 'ordering-guarantee',
30
- regex: /\.sort\s*\(/,
31
- severity: 'medium',
32
- describe: () => 'Array.sort() used — output ordering may be depended on by callers',
33
- },
34
- {
35
- category: 'error-message-text',
36
- regex: /throw\s+new\s+\w+\s*\(\s*[`'"]/,
37
- severity: 'medium',
38
- describe: () => 'Error with string literal — error message text may be relied upon by callers',
39
- },
40
- {
41
- category: 'implicit-return',
42
- regex: /=>\s*[^{(\n][^;\n]*$/m,
43
- severity: 'low',
44
- describe: () => 'Arrow function with implicit return — return value shape may be an implicit contract',
45
- },
46
- ];
47
- // ---------------------------------------------------------------------------
48
- // Scanner
49
- // ---------------------------------------------------------------------------
50
- let riskCounter = 0;
51
- function makeRiskId() {
52
- riskCounter++;
53
- return `HR-${String(riskCounter).padStart(4, '0')}`;
54
- }
55
- /**
56
- * Scans a single file for Hyrum risks.
57
- */
58
- async function scanFile(filePath) {
59
- const analysis = await analyzeFile(filePath);
60
- if (analysis === null) {
61
- return [];
62
- }
63
- const risks = [];
64
- for (const fn of analysis.functions) {
65
- const bodyLines = fn.body.split('\n');
66
- for (const pattern of RISK_PATTERNS) {
67
- for (let i = 0; i < bodyLines.length; i++) {
68
- const line = bodyLines[i] ?? '';
69
- const match = line.match(pattern.regex);
70
- if (match) {
71
- const absoluteLine = fn.line + i;
72
- risks.push({
73
- id: makeRiskId(),
74
- category: pattern.category,
75
- file: filePath,
76
- line: absoluteLine,
77
- functionName: fn.name,
78
- description: pattern.describe(match),
79
- severity: pattern.severity,
80
- snippet: extractSnippet(analysis.lines, absoluteLine),
81
- });
82
- }
83
- }
84
- }
85
- }
86
- return risks;
87
- }
88
- /**
89
- * Resolves the list of files to scan.
90
- * If `files` is provided, uses that list. Otherwise globs for TS/JS files.
91
- */
92
- async function resolveFiles(projectPath, files) {
93
- if (files !== undefined && files.length > 0) {
94
- return files;
95
- }
96
- return glob('src/**/*.{ts,tsx,js,jsx}', {
97
- cwd: projectPath,
98
- absolute: true,
99
- ignore: ['**/node_modules/**', '**/dist/**', '**/*.test.*', '**/*.spec.*'],
100
- });
101
- }
102
- /**
103
- * Scans the given files (or project) for Hyrum's Law risks.
104
- */
105
- export async function scanHyrumRisks(projectPath, files) {
106
- riskCounter = 0;
107
- const resolvedFiles = await resolveFiles(projectPath, files);
108
- // Process in parallel with a concurrency limit
109
- const BATCH = 20;
110
- const allRisks = [];
111
- for (let i = 0; i < resolvedFiles.length; i += BATCH) {
112
- const batch = resolvedFiles.slice(i, i + BATCH);
113
- const batchResults = await Promise.all(batch.map((f) => scanFile(f)));
114
- for (const r of batchResults) {
115
- allRisks.push(...r);
116
- }
117
- }
118
- // Sort by severity then file
119
- const severityOrder = { high: 0, medium: 1, low: 2 };
120
- allRisks.sort((a, b) => {
121
- const sv = severityOrder[a.severity] - severityOrder[b.severity];
122
- if (sv !== 0) {
123
- return sv;
124
- }
125
- return a.file.localeCompare(b.file);
126
- });
127
- const highCount = allRisks.filter((r) => r.severity === 'high').length;
128
- const medCount = allRisks.filter((r) => r.severity === 'medium').length;
129
- const lowCount = allRisks.filter((r) => r.severity === 'low').length;
130
- return {
131
- projectPath,
132
- filesScanned: resolvedFiles.length,
133
- risks: allRisks,
134
- summary: `Scanned ${resolvedFiles.length} files. Found ${allRisks.length} Hyrum risks: ${highCount} high, ${medCount} medium, ${lowCount} low.`,
135
- };
136
- }
137
- //# sourceMappingURL=hyrum-scanner.js.map
@@ -1,7 +0,0 @@
1
- import type { SafetyNetPlan } from '../../types/index.js';
2
- /**
3
- * Builds a safety-net refactoring plan for the given project and target files.
4
- * Pure function — returns a plan without executing anything.
5
- */
6
- export declare function buildSafetyNetPlan(projectPath: string, targetFiles?: string[], coverageThreshold?: number): Promise<SafetyNetPlan>;
7
- //# sourceMappingURL=safety-net-orchestrator.d.ts.map