@planu/cli 4.11.7 → 4.11.9
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/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/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/qa-gate.js +2 -1
- package/dist/engine/session-safeguard/checkpoint-runner.js +3 -7
- 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/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 +1083 -821
- 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.9] - 2026-07-20
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
- fix: keep release session context current
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
## [4.11.8] - 2026-07-20
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
- fix: make pre-commit checks fail closed
|
|
11
|
+
- fix: pin release-eligible transitive dependencies
|
|
12
|
+
- fix: sync release lockfile before build
|
|
13
|
+
- fix: stabilize quality gate execution
|
|
14
|
+
- fix: harden lifecycle and release reliability
|
|
15
|
+
|
|
16
|
+
|
|
1
17
|
## [4.11.7] - 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
|
|
@@ -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)));
|