@planu/cli 4.11.6 → 4.11.8
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 +16 -0
- package/dist/cli/commands/serve.js +4 -0
- package/dist/config/license-plans.json +1 -0
- package/dist/engine/actuals/git-analyzer.js +4 -4
- package/dist/engine/browser-validator.js +26 -21
- package/dist/engine/crash-shield/file-collector.d.ts +20 -3
- package/dist/engine/crash-shield/file-collector.js +137 -8
- package/dist/engine/crash-shield/index.d.ts +18 -1
- package/dist/engine/crash-shield/index.js +58 -17
- package/dist/engine/diff-spec-generator.js +12 -5
- package/dist/engine/dogfooding/runtime-gap-detector.d.ts +3 -0
- package/dist/engine/dogfooding/runtime-gap-detector.js +386 -0
- package/dist/engine/figma/visual-qa.d.ts +2 -1
- package/dist/engine/figma/visual-qa.js +8 -7
- package/dist/engine/git-safe-input.d.ts +6 -0
- package/dist/engine/git-safe-input.js +41 -0
- package/dist/engine/qa-gate.js +2 -1
- package/dist/engine/spec-state-machine/transition-spec.d.ts +16 -1
- package/dist/engine/spec-state-machine/transition-spec.js +19 -4
- package/dist/engine/triagier/classifier.d.ts +2 -2
- package/dist/engine/triagier/classifier.js +12 -15
- package/dist/index.js +12 -4
- package/dist/storage/approval-operation-lock.d.ts +10 -0
- package/dist/storage/approval-operation-lock.js +44 -0
- package/dist/storage/approval-store.d.ts +2 -0
- package/dist/storage/approval-store.js +9 -1
- package/dist/storage/spec-store.d.ts +29 -2
- package/dist/storage/spec-store.js +307 -7
- package/dist/tools/approval-handler.js +255 -124
- package/dist/tools/browser-validate-handler.js +17 -3
- package/dist/tools/code-impact-handler.js +4 -2
- package/dist/tools/dogfood-watch.d.ts +6 -0
- package/dist/tools/dogfood-watch.js +48 -0
- package/dist/tools/figma/visual-qa.js +2 -1
- package/dist/tools/tool-registry/core-tools.js +12 -0
- package/dist/tools/tool-registry/group-quality-compliance.js +12 -1
- package/dist/tools/update-status/file-sync.js +3 -2
- package/dist/tools/update-status/index.d.ts +2 -0
- package/dist/tools/update-status/index.js +1086 -812
- package/dist/tools/update-status/response-builder.js +11 -0
- package/dist/tools/update-status/side-effects.d.ts +16 -1
- package/dist/tools/update-status/side-effects.js +140 -0
- package/dist/tools/update-status/transition-guard.js +1 -1
- package/dist/tools/update-status-actions.d.ts +10 -2
- package/dist/tools/update-status-actions.js +166 -192
- package/dist/tools/update-status-convention-gate.d.ts +3 -1
- package/dist/tools/update-status-convention-gate.js +135 -7
- package/dist/types/browser-validator.d.ts +2 -0
- package/dist/types/dogfooding.d.ts +34 -0
- package/dist/types/dogfooding.js +2 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/spec/core.d.ts +28 -1
- package/package.json +25 -25
- package/planu-native.json +1 -1
- package/planu-plugin.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
## [4.11.8] - 2026-07-20
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
- fix: make pre-commit checks fail closed
|
|
5
|
+
- fix: pin release-eligible transitive dependencies
|
|
6
|
+
- fix: sync release lockfile before build
|
|
7
|
+
- fix: stabilize quality gate execution
|
|
8
|
+
- fix: harden lifecycle and release reliability
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
## [4.11.7] - 2026-07-18
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
- fix: harden shell execution paths
|
|
15
|
+
|
|
16
|
+
|
|
1
17
|
## [4.11.6] - 2026-07-18
|
|
2
18
|
|
|
3
19
|
### Bug Fixes
|
|
@@ -29,6 +29,10 @@ export const serveCommand = {
|
|
|
29
29
|
registerSpecTools(server);
|
|
30
30
|
registerPlatformTools(server);
|
|
31
31
|
await selectTransport(server, ['--transport', 'http', '--port', port], () => server);
|
|
32
|
+
const { recoverPendingPostCommitTasksAtStartup } = await import('../../tools/update-status/index.js');
|
|
33
|
+
void recoverPendingPostCommitTasksAtStartup().catch((error) => {
|
|
34
|
+
process.stderr.write(`Planu post-commit recovery failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
35
|
+
});
|
|
32
36
|
},
|
|
33
37
|
};
|
|
34
38
|
//# sourceMappingURL=serve.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Planu — engine/actuals/git-analyzer.ts (SPEC-061)
|
|
2
2
|
// Derives actuals from git log history for a given spec.
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { isCanonicalSpecId } from '../../tools/schemas/spec-id.js';
|
|
5
5
|
const MAX_SESSION_GAP_MS = 2 * 60 * 60 * 1000; // 2 hours
|
|
6
6
|
const REVIEW_HOURS_RATIO = 0.15;
|
|
7
7
|
const MINIMUM_DEV_HOURS = 1;
|
|
@@ -121,7 +121,7 @@ export function calculateLinesChanged(commits) {
|
|
|
121
121
|
* Returns null if specId is invalid or no commits are found.
|
|
122
122
|
*/
|
|
123
123
|
export function deriveActualsFromGit(projectPath, specId) {
|
|
124
|
-
if (!
|
|
124
|
+
if (!isCanonicalSpecId(specId)) {
|
|
125
125
|
return Promise.resolve(null);
|
|
126
126
|
}
|
|
127
127
|
const gitOutput = runGitLog(projectPath, specId);
|
|
@@ -155,7 +155,7 @@ export function deriveActualsFromGit(projectPath, specId) {
|
|
|
155
155
|
}
|
|
156
156
|
function runGitLog(projectPath, specId) {
|
|
157
157
|
try {
|
|
158
|
-
const result =
|
|
158
|
+
const result = execFileSync('git', ['log', '--format=%H|%aI|%s', '--numstat', '--all', '--grep', specId], {
|
|
159
159
|
cwd: projectPath,
|
|
160
160
|
encoding: 'utf-8',
|
|
161
161
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
@@ -87,62 +87,67 @@ export function extractUIAssertions(criteriaMarkdown) {
|
|
|
87
87
|
}
|
|
88
88
|
return assertions;
|
|
89
89
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
90
|
+
function escapeRegExpLiteral(value) {
|
|
91
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
92
|
+
}
|
|
93
|
+
function safeComment(value) {
|
|
94
|
+
return value.replace(/[\r\n\u2028\u2029]+/g, ' ');
|
|
93
95
|
}
|
|
94
96
|
/**
|
|
95
97
|
* Generate a complete Playwright test file content from UI assertions.
|
|
96
98
|
* Uses @playwright/test syntax with test.step() per assertion.
|
|
97
99
|
*/
|
|
98
100
|
export function generatePlaywrightTest(assertions, specId, baseUrl) {
|
|
101
|
+
const manualStep = (message) => `throw new Error(${JSON.stringify(message)});`;
|
|
99
102
|
const steps = assertions.map((a, i) => {
|
|
100
|
-
const stepName =
|
|
101
|
-
const locator = a.locator ?
|
|
103
|
+
const stepName = JSON.stringify(`${String(i + 1)}. ${a.criterion}`);
|
|
104
|
+
const locator = a.locator ? JSON.stringify(a.locator) : null;
|
|
102
105
|
let body;
|
|
103
106
|
switch (a.type) {
|
|
104
107
|
case 'visibility':
|
|
105
108
|
body = locator
|
|
106
|
-
? `await expect(page.locator(
|
|
107
|
-
:
|
|
109
|
+
? `await expect(page.locator(${locator})).toBeVisible();`
|
|
110
|
+
: manualStep(`Manual locator required for visibility assertion: ${a.criterion}`);
|
|
108
111
|
break;
|
|
109
112
|
case 'text':
|
|
110
113
|
body =
|
|
111
114
|
locator && a.expected
|
|
112
|
-
? `await expect(page.locator(
|
|
115
|
+
? `await expect(page.locator(${locator})).toContainText(${JSON.stringify(a.expected)});`
|
|
113
116
|
: locator
|
|
114
|
-
? `await expect(page.locator(
|
|
115
|
-
:
|
|
117
|
+
? `await expect(page.locator(${locator})).toBeVisible();`
|
|
118
|
+
: manualStep(`Manual text verification required: ${a.criterion}`);
|
|
116
119
|
break;
|
|
117
120
|
case 'navigation':
|
|
118
121
|
body = a.expected
|
|
119
|
-
? `await expect(page).toHaveURL(
|
|
120
|
-
:
|
|
122
|
+
? `await expect(page).toHaveURL(new RegExp(${JSON.stringify(escapeRegExpLiteral(a.expected))}));`
|
|
123
|
+
: manualStep(`Manual navigation verification required: ${a.criterion}`);
|
|
121
124
|
break;
|
|
122
125
|
case 'form':
|
|
123
126
|
body = locator
|
|
124
|
-
? `await expect(page.locator(
|
|
125
|
-
:
|
|
127
|
+
? `await expect(page.locator(${locator})).toBeVisible();`
|
|
128
|
+
: manualStep(`Manual form interaction required: ${a.criterion}`);
|
|
126
129
|
break;
|
|
127
130
|
case 'generic':
|
|
128
|
-
body =
|
|
131
|
+
body = manualStep(`Manual assertion required: ${a.criterion}`);
|
|
129
132
|
break;
|
|
130
133
|
default: {
|
|
131
134
|
const _exhaustive = a.type;
|
|
132
|
-
body =
|
|
135
|
+
body = manualStep(`Unsupported assertion type requires manual validation: ${String(_exhaustive)}`);
|
|
133
136
|
}
|
|
134
137
|
}
|
|
135
|
-
return ` await test.step(
|
|
138
|
+
return ` await test.step(${stepName}, async () => {\n ${body}\n });`;
|
|
136
139
|
});
|
|
140
|
+
const testTitle = JSON.stringify(`${specId} — UI acceptance criteria`);
|
|
141
|
+
const serializedBaseUrl = JSON.stringify(baseUrl);
|
|
137
142
|
return [
|
|
138
143
|
`import { test, expect } from '@playwright/test';`,
|
|
139
144
|
``,
|
|
140
145
|
`// Generated by Planu — SPEC-308 browser validator`,
|
|
141
|
-
`// Spec: ${specId}`,
|
|
142
|
-
`// Base URL: ${baseUrl}`,
|
|
146
|
+
`// Spec: ${safeComment(specId)}`,
|
|
147
|
+
`// Base URL: ${safeComment(baseUrl)}`,
|
|
143
148
|
``,
|
|
144
|
-
`test(
|
|
145
|
-
` await page.goto(
|
|
149
|
+
`test(${testTitle}, async ({ page }) => {`,
|
|
150
|
+
` await page.goto(${serializedBaseUrl});`,
|
|
146
151
|
``,
|
|
147
152
|
...steps,
|
|
148
153
|
`});`,
|
|
@@ -1,10 +1,27 @@
|
|
|
1
1
|
import type { FileCollectionResult } from '../../types/index.js';
|
|
2
2
|
export type { FileCollectionResult };
|
|
3
|
+
export declare const DEFAULT_FILE_COLLECTION_BUDGET: Readonly<{
|
|
4
|
+
maxFiles: 1000;
|
|
5
|
+
maxBytes: number;
|
|
6
|
+
}>;
|
|
3
7
|
/**
|
|
4
|
-
* Collects
|
|
5
|
-
*
|
|
8
|
+
* Collects eligible runtime files in deterministic order within an explicit budget.
|
|
9
|
+
* Coverage metadata makes every partial collection visible to callers.
|
|
6
10
|
*/
|
|
7
|
-
export declare function collectFiles(projectPath: string, enabledLanguages?: string[]
|
|
11
|
+
export declare function collectFiles(projectPath: string, enabledLanguages?: string[], budget?: {
|
|
12
|
+
maxFiles?: number;
|
|
13
|
+
maxBytes?: number;
|
|
14
|
+
}): Promise<FileCollectionResult & {
|
|
15
|
+
coverage: {
|
|
16
|
+
complete: boolean;
|
|
17
|
+
discoveredFiles: number;
|
|
18
|
+
selectedFiles: number;
|
|
19
|
+
selectedBytes: number;
|
|
20
|
+
maxFiles: number;
|
|
21
|
+
maxBytes: number;
|
|
22
|
+
truncatedBy: ('file-count' | 'byte-budget' | 'metadata-error')[];
|
|
23
|
+
};
|
|
24
|
+
}>;
|
|
8
25
|
/**
|
|
9
26
|
* Processes items in sequential batches to avoid unbounded concurrency.
|
|
10
27
|
* Each batch runs in parallel internally, but batches are processed one at a time.
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
// engine/crash-shield/file-collector.ts — File discovery for crash shield (SPEC-442)
|
|
2
2
|
import { glob } from 'glob';
|
|
3
|
-
import {
|
|
3
|
+
import { stat } from 'node:fs/promises';
|
|
4
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
5
|
+
export const DEFAULT_FILE_COLLECTION_BUDGET = Object.freeze({
|
|
6
|
+
maxFiles: 1_000,
|
|
7
|
+
maxBytes: 50 * 1024 * 1024,
|
|
8
|
+
});
|
|
4
9
|
const STACK_EXTENSIONS = {
|
|
5
10
|
TypeScript: ['.ts', '.tsx'],
|
|
6
11
|
JavaScript: ['.js', '.jsx', '.mjs', '.cjs'],
|
|
@@ -11,30 +16,154 @@ const STACK_EXTENSIONS = {
|
|
|
11
16
|
PHP: ['.php'],
|
|
12
17
|
Ruby: ['.rb'],
|
|
13
18
|
};
|
|
14
|
-
const MAX_FILES = 400;
|
|
15
19
|
const IGNORE_PATTERNS = [
|
|
16
20
|
'**/node_modules/**',
|
|
21
|
+
'**/vendor/**',
|
|
17
22
|
'**/dist/**',
|
|
23
|
+
'**/build/**',
|
|
24
|
+
'**/target/**',
|
|
25
|
+
'**/coverage/**',
|
|
26
|
+
'**/.next/**',
|
|
27
|
+
'**/.nuxt/**',
|
|
28
|
+
'**/.cache/**',
|
|
18
29
|
'**/.git/**',
|
|
30
|
+
'**/tests/**',
|
|
31
|
+
'**/test/**',
|
|
32
|
+
'**/__tests__/**',
|
|
33
|
+
'**/fixtures/**',
|
|
34
|
+
'**/__fixtures__/**',
|
|
35
|
+
'**/generated/**',
|
|
36
|
+
'**/*.test.*',
|
|
37
|
+
'**/*.spec.*',
|
|
38
|
+
'**/test_*.py',
|
|
39
|
+
'**/*_test.go',
|
|
19
40
|
'**/*.d.ts',
|
|
20
41
|
'**/*.min.js',
|
|
21
42
|
];
|
|
22
43
|
/**
|
|
23
|
-
* Collects
|
|
24
|
-
*
|
|
44
|
+
* Collects eligible runtime files in deterministic order within an explicit budget.
|
|
45
|
+
* Coverage metadata makes every partial collection visible to callers.
|
|
25
46
|
*/
|
|
26
|
-
export async function collectFiles(projectPath, enabledLanguages) {
|
|
47
|
+
export async function collectFiles(projectPath, enabledLanguages, budget = {}) {
|
|
27
48
|
const extensions = buildExtensionList(enabledLanguages);
|
|
28
49
|
const pattern = buildGlobPattern(extensions);
|
|
50
|
+
const limits = normalizeBudget(budget);
|
|
29
51
|
const allFiles = await glob(pattern, {
|
|
30
52
|
cwd: projectPath,
|
|
31
53
|
ignore: IGNORE_PATTERNS,
|
|
32
54
|
absolute: true,
|
|
33
55
|
nodir: true,
|
|
34
56
|
});
|
|
35
|
-
const
|
|
36
|
-
const detectedStack = detectStack(
|
|
37
|
-
|
|
57
|
+
const eligibleFiles = normalizeEligibleFiles(projectPath, allFiles);
|
|
58
|
+
const detectedStack = detectStack(eligibleFiles);
|
|
59
|
+
const files = [];
|
|
60
|
+
let selectedBytes = 0;
|
|
61
|
+
let metadataErrors = 0;
|
|
62
|
+
let byteBudgetReached = false;
|
|
63
|
+
for (const file of eligibleFiles) {
|
|
64
|
+
if (files.length >= limits.maxFiles) {
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
let fileBytes;
|
|
68
|
+
try {
|
|
69
|
+
fileBytes = (await stat(join(projectPath, file))).size;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
metadataErrors += 1;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (selectedBytes + fileBytes > limits.maxBytes) {
|
|
76
|
+
byteBudgetReached = true;
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
files.push(file);
|
|
80
|
+
selectedBytes += fileBytes;
|
|
81
|
+
}
|
|
82
|
+
const truncatedBy = [];
|
|
83
|
+
if (files.length + metadataErrors < eligibleFiles.length && files.length >= limits.maxFiles) {
|
|
84
|
+
truncatedBy.push('file-count');
|
|
85
|
+
}
|
|
86
|
+
if (byteBudgetReached) {
|
|
87
|
+
truncatedBy.push('byte-budget');
|
|
88
|
+
}
|
|
89
|
+
if (metadataErrors > 0) {
|
|
90
|
+
truncatedBy.push('metadata-error');
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
files,
|
|
94
|
+
detectedStack,
|
|
95
|
+
coverage: {
|
|
96
|
+
complete: truncatedBy.length === 0,
|
|
97
|
+
discoveredFiles: eligibleFiles.length,
|
|
98
|
+
selectedFiles: files.length,
|
|
99
|
+
selectedBytes,
|
|
100
|
+
maxFiles: limits.maxFiles,
|
|
101
|
+
maxBytes: limits.maxBytes,
|
|
102
|
+
truncatedBy,
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function normalizeEligibleFiles(projectPath, files) {
|
|
107
|
+
return [
|
|
108
|
+
...new Set(files
|
|
109
|
+
.map((file) => normalizeProjectRelativePath(projectPath, file))
|
|
110
|
+
.filter((file) => file !== null)
|
|
111
|
+
.filter(isRuntimeSourcePath)),
|
|
112
|
+
].sort((left, right) => left.localeCompare(right));
|
|
113
|
+
}
|
|
114
|
+
function normalizeProjectRelativePath(projectPath, filePath) {
|
|
115
|
+
const projectRoot = resolve(projectPath);
|
|
116
|
+
const absolutePath = isAbsolute(filePath) ? resolve(filePath) : resolve(projectRoot, filePath);
|
|
117
|
+
const relativePath = relative(projectRoot, absolutePath);
|
|
118
|
+
if (relativePath.length === 0 ||
|
|
119
|
+
relativePath === '..' ||
|
|
120
|
+
relativePath.startsWith(`..${sep}`) ||
|
|
121
|
+
isAbsolute(relativePath)) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
return relativePath;
|
|
125
|
+
}
|
|
126
|
+
function normalizeBudget(budget) {
|
|
127
|
+
return {
|
|
128
|
+
maxFiles: normalizePositiveInteger(budget.maxFiles, DEFAULT_FILE_COLLECTION_BUDGET.maxFiles),
|
|
129
|
+
maxBytes: normalizePositiveInteger(budget.maxBytes, DEFAULT_FILE_COLLECTION_BUDGET.maxBytes),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function normalizePositiveInteger(value, fallback) {
|
|
133
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
|
134
|
+
? Math.floor(value)
|
|
135
|
+
: fallback;
|
|
136
|
+
}
|
|
137
|
+
function isRuntimeSourcePath(filePath) {
|
|
138
|
+
const normalized = filePath.replaceAll('\\', '/');
|
|
139
|
+
const segments = normalized.split('/');
|
|
140
|
+
const excludedDirectories = new Set([
|
|
141
|
+
'node_modules',
|
|
142
|
+
'vendor',
|
|
143
|
+
'dist',
|
|
144
|
+
'build',
|
|
145
|
+
'target',
|
|
146
|
+
'coverage',
|
|
147
|
+
'.next',
|
|
148
|
+
'.nuxt',
|
|
149
|
+
'.cache',
|
|
150
|
+
'.git',
|
|
151
|
+
'tests',
|
|
152
|
+
'test',
|
|
153
|
+
'__tests__',
|
|
154
|
+
'fixtures',
|
|
155
|
+
'__fixtures__',
|
|
156
|
+
'generated',
|
|
157
|
+
]);
|
|
158
|
+
if (segments.some((segment) => excludedDirectories.has(segment))) {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
const fileName = segments.at(-1) ?? '';
|
|
162
|
+
return (!/\.(?:test|spec)\.[^.]+$/i.test(fileName) &&
|
|
163
|
+
!/^test_.*\.py$/i.test(fileName) &&
|
|
164
|
+
!/_test\.go$/i.test(fileName) &&
|
|
165
|
+
!/\.d\.ts$/i.test(fileName) &&
|
|
166
|
+
!/\.min\.js$/i.test(fileName));
|
|
38
167
|
}
|
|
39
168
|
function buildExtensionList(enabledLanguages) {
|
|
40
169
|
if (!enabledLanguages || enabledLanguages.length === 0) {
|
|
@@ -4,6 +4,23 @@ export { fixCrashRisks } from './auto-fixer/index.js';
|
|
|
4
4
|
* Scans a project for crash risks and returns a SafetyReport.
|
|
5
5
|
* @param projectPath - absolute path to the project root
|
|
6
6
|
* @param stackHint - optional list of languages to restrict scanning (e.g. ['TypeScript'])
|
|
7
|
+
* @param options - optional file, byte, and read-batch budgets
|
|
7
8
|
*/
|
|
8
|
-
export declare function scanCrashRisks(projectPath: string, stackHint?: string[]
|
|
9
|
+
export declare function scanCrashRisks(projectPath: string, stackHint?: string[], options?: {
|
|
10
|
+
maxFiles?: number;
|
|
11
|
+
maxBytes?: number;
|
|
12
|
+
readBatchSize?: number;
|
|
13
|
+
}): Promise<SafetyReport & {
|
|
14
|
+
coverage: {
|
|
15
|
+
complete: boolean;
|
|
16
|
+
discoveredFiles: number;
|
|
17
|
+
selectedFiles: number;
|
|
18
|
+
selectedBytes: number;
|
|
19
|
+
maxFiles: number;
|
|
20
|
+
maxBytes: number;
|
|
21
|
+
truncatedBy: ('file-count' | 'byte-budget' | 'metadata-error')[];
|
|
22
|
+
scannedFiles: number;
|
|
23
|
+
unreadableFiles: number;
|
|
24
|
+
};
|
|
25
|
+
}>;
|
|
9
26
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
2
3
|
import { collectFiles } from './file-collector.js';
|
|
3
4
|
import { calculateScore } from './score-calculator.js';
|
|
4
5
|
import { typescriptDetector } from './detectors/typescript-detector.js';
|
|
@@ -17,41 +18,81 @@ const ALL_DETECTORS = [
|
|
|
17
18
|
javaDetector,
|
|
18
19
|
frameworkDetector,
|
|
19
20
|
];
|
|
21
|
+
const DEFAULT_READ_BATCH_SIZE = 50;
|
|
20
22
|
/**
|
|
21
23
|
* Scans a project for crash risks and returns a SafetyReport.
|
|
22
24
|
* @param projectPath - absolute path to the project root
|
|
23
25
|
* @param stackHint - optional list of languages to restrict scanning (e.g. ['TypeScript'])
|
|
26
|
+
* @param options - optional file, byte, and read-batch budgets
|
|
24
27
|
*/
|
|
25
|
-
export async function scanCrashRisks(projectPath, stackHint) {
|
|
28
|
+
export async function scanCrashRisks(projectPath, stackHint, options = {}) {
|
|
26
29
|
const startTime = Date.now();
|
|
27
|
-
const { files, detectedStack } = await collectFiles(projectPath, stackHint);
|
|
28
|
-
const
|
|
30
|
+
const { files, detectedStack, coverage } = await collectFiles(projectPath, stackHint, options);
|
|
31
|
+
const scan = await scanFiles(projectPath, files, detectedStack, normalizeBatchSize(options.readBatchSize));
|
|
32
|
+
const allRisks = scan.risks.sort(compareRisks);
|
|
29
33
|
const score = calculateScore(allRisks);
|
|
30
34
|
const scanDurationMs = Date.now() - startTime;
|
|
31
35
|
return {
|
|
32
36
|
score,
|
|
33
37
|
risks: allRisks,
|
|
34
|
-
scannedFiles:
|
|
38
|
+
scannedFiles: scan.scannedFiles,
|
|
35
39
|
detectedStack,
|
|
36
40
|
scanDurationMs,
|
|
41
|
+
coverage: {
|
|
42
|
+
...coverage,
|
|
43
|
+
complete: coverage.complete && scan.unreadableFiles === 0,
|
|
44
|
+
scannedFiles: scan.scannedFiles,
|
|
45
|
+
unreadableFiles: scan.unreadableFiles,
|
|
46
|
+
},
|
|
37
47
|
};
|
|
38
48
|
}
|
|
39
|
-
|
|
49
|
+
function compareRisks(left, right) {
|
|
50
|
+
return (left.file.localeCompare(right.file) ||
|
|
51
|
+
left.line - right.line ||
|
|
52
|
+
left.pattern.localeCompare(right.pattern) ||
|
|
53
|
+
left.severity.localeCompare(right.severity));
|
|
54
|
+
}
|
|
55
|
+
async function scanFiles(projectPath, files, detectedStack, batchSize) {
|
|
40
56
|
const applicableDetectors = selectDetectors(detectedStack);
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
57
|
+
const risks = [];
|
|
58
|
+
let scannedFiles = 0;
|
|
59
|
+
let unreadableFiles = 0;
|
|
60
|
+
for (let offset = 0; offset < files.length; offset += batchSize) {
|
|
61
|
+
const batch = files.slice(offset, offset + batchSize);
|
|
62
|
+
const absoluteToRelative = new Map(batch.map((file) => [join(projectPath, file), file]));
|
|
63
|
+
const fileContents = fastReadFiles([...absoluteToRelative.keys()]);
|
|
64
|
+
const seen = new Set(fileContents.map((file) => file.path));
|
|
65
|
+
for (const { path, content } of fileContents) {
|
|
66
|
+
risks.push(...detectRisksInContent(absoluteToRelative.get(path) ?? path, content, applicableDetectors));
|
|
67
|
+
scannedFiles += 1;
|
|
49
68
|
}
|
|
50
|
-
|
|
51
|
-
|
|
69
|
+
const fallbackResults = await Promise.all(batch
|
|
70
|
+
.filter((file) => !seen.has(join(projectPath, file)))
|
|
71
|
+
.map(async (file) => {
|
|
72
|
+
try {
|
|
73
|
+
const content = await readFile(join(projectPath, file), 'utf-8');
|
|
74
|
+
return detectRisksInContent(file, content, applicableDetectors);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}));
|
|
80
|
+
for (const result of fallbackResults) {
|
|
81
|
+
if (result === null) {
|
|
82
|
+
unreadableFiles += 1;
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
risks.push(...result);
|
|
86
|
+
scannedFiles += 1;
|
|
87
|
+
}
|
|
52
88
|
}
|
|
53
|
-
}
|
|
54
|
-
return
|
|
89
|
+
}
|
|
90
|
+
return { risks, scannedFiles, unreadableFiles };
|
|
91
|
+
}
|
|
92
|
+
function normalizeBatchSize(value) {
|
|
93
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
|
94
|
+
? Math.floor(value)
|
|
95
|
+
: DEFAULT_READ_BATCH_SIZE;
|
|
55
96
|
}
|
|
56
97
|
function detectRisksInContent(path, content, applicableDetectors) {
|
|
57
98
|
const fileDetectors = applicableDetectors.filter((d) => d.fileExtensions.some((ext) => path.endsWith(ext)));
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// engine/diff-spec-generator.ts — Diff-to-Spec generator (SPEC-350)
|
|
2
|
-
import {
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { assertPositivePrNumber, assertSafeGitDiffRange } from './git-safe-input.js';
|
|
3
4
|
// ---------------------------------------------------------------------------
|
|
4
5
|
// Diff analysis
|
|
5
6
|
// ---------------------------------------------------------------------------
|
|
@@ -88,9 +89,12 @@ export function generateSpecDraft(analysis, sourceType, sourceRef, titleOverride
|
|
|
88
89
|
// ---------------------------------------------------------------------------
|
|
89
90
|
export function runGitDiff(repoPath, commitRange) {
|
|
90
91
|
const range = commitRange ?? 'HEAD~1..HEAD';
|
|
91
|
-
|
|
92
|
+
assertSafeGitDiffRange(range);
|
|
92
93
|
try {
|
|
93
|
-
return
|
|
94
|
+
return execFileSync('git', ['-C', repoPath, 'diff', range], {
|
|
95
|
+
encoding: 'utf-8',
|
|
96
|
+
timeout: 15_000,
|
|
97
|
+
});
|
|
94
98
|
}
|
|
95
99
|
catch (err) {
|
|
96
100
|
throw new Error(`Failed to run git diff: ${err instanceof Error ? err.message : String(err)}. ` +
|
|
@@ -98,9 +102,12 @@ export function runGitDiff(repoPath, commitRange) {
|
|
|
98
102
|
}
|
|
99
103
|
}
|
|
100
104
|
export function runGhPrDiff(repoPath, prNumber) {
|
|
101
|
-
|
|
105
|
+
assertPositivePrNumber(prNumber);
|
|
102
106
|
try {
|
|
103
|
-
return
|
|
107
|
+
return execFileSync('gh', ['pr', 'diff', String(prNumber), '--repo', repoPath], {
|
|
108
|
+
encoding: 'utf-8',
|
|
109
|
+
timeout: 20_000,
|
|
110
|
+
});
|
|
104
111
|
}
|
|
105
112
|
catch (err) {
|
|
106
113
|
throw new Error(`Failed to run gh pr diff: ${err instanceof Error ? err.message : String(err)}. ` +
|