javi-forge 1.26.0 → 1.27.0

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.
@@ -1,6 +1,7 @@
1
1
  import path from "node:path";
2
2
  import fs from "fs-extra";
3
3
  import { STACK_CONTEXT_MAP } from "../constants.js";
4
+ import { describeSafeReadFailure, safeReadFile } from "./safe-read.js";
4
5
  // =============================================================================
5
6
  // Internal helpers
6
7
  // =============================================================================
@@ -53,95 +54,135 @@ ${stack}-based project scaffolded with javi-forge.
53
54
  // =============================================================================
54
55
  // Dependency detection
55
56
  // =============================================================================
57
+ const MAX_DEPS = 10;
56
58
  /**
57
- * Detect top-level dependencies from project manifest files.
58
- * Returns up to 10 dependency names (key deps only, not devDeps).
59
+ * Byte ceiling for a dependency manifest. A package.json or go.mod past this is
60
+ * not a manifest we can learn anything useful from it is generated noise.
59
61
  */
60
- export async function detectDependencies(projectDir, stack) {
61
- const MAX_DEPS = 10;
62
- try {
63
- switch (stack) {
64
- case "node": {
65
- const pkgPath = path.join(projectDir, "package.json");
66
- if (!(await fs.pathExists(pkgPath)))
67
- return [];
68
- const pkg = await fs.readJson(pkgPath).catch(() => ({}));
69
- const deps = Object.keys(pkg.dependencies ?? {});
70
- return deps.slice(0, MAX_DEPS);
62
+ const MAX_MANIFEST_BYTES = 512 * 1024;
63
+ /** Read a manifest under a byte budget; returns null and a warning on failure. */
64
+ async function readManifest(manifestPath, warnings) {
65
+ const read = await safeReadFile(manifestPath, {
66
+ hardRejectOverBytes: MAX_MANIFEST_BYTES,
67
+ });
68
+ if (!read.ok) {
69
+ warnings.push(`${path.basename(manifestPath)}: ${describeSafeReadFailure(read)}`);
70
+ return null;
71
+ }
72
+ if (read.truncated) {
73
+ warnings.push(`${path.basename(manifestPath)}: truncated at ${read.bytesRead} of ${read.totalBytes} bytes`);
74
+ }
75
+ return read.content;
76
+ }
77
+ /**
78
+ * Detect top-level dependencies from project manifest files, reporting why a
79
+ * manifest was skipped. Returns up to 10 dependency names (key deps only).
80
+ */
81
+ export async function detectDependenciesDetailed(projectDir, stack) {
82
+ const warnings = [];
83
+ switch (stack) {
84
+ case "node": {
85
+ const pkgPath = path.join(projectDir, "package.json");
86
+ if (!(await fs.pathExists(pkgPath)))
87
+ return { dependencies: [], warnings };
88
+ const content = await readManifest(pkgPath, warnings);
89
+ if (content === null)
90
+ return { dependencies: [], warnings };
91
+ let pkg;
92
+ try {
93
+ pkg = JSON.parse(content);
71
94
  }
72
- case "python": {
73
- const pyprojectPath = path.join(projectDir, "pyproject.toml");
74
- if (await fs.pathExists(pyprojectPath)) {
75
- const content = await fs.readFile(pyprojectPath, "utf-8");
76
- const match = content.match(/dependencies\s*=\s*\[([\s\S]*?)\]/);
77
- if (match?.[1]) {
78
- const deps = match[1]
79
- .split("\n")
80
- .map((l) => l.replace(/[",]/g, "").trim())
81
- .filter((l) => l.length > 0 && !l.startsWith("#"))
82
- .map((l) => l.split(/[>=<~!]/)[0].trim())
83
- .filter(Boolean);
84
- return deps.slice(0, MAX_DEPS);
85
- }
86
- }
87
- const reqPath = path.join(projectDir, "requirements.txt");
88
- if (await fs.pathExists(reqPath)) {
89
- const content = await fs.readFile(reqPath, "utf-8");
90
- const deps = content
95
+ catch (err) {
96
+ warnings.push(`package.json: invalid JSON (${err instanceof Error ? err.message : String(err)})`);
97
+ return { dependencies: [], warnings };
98
+ }
99
+ const deps = Object.keys(pkg?.dependencies ?? {});
100
+ return { dependencies: deps.slice(0, MAX_DEPS), warnings };
101
+ }
102
+ case "python": {
103
+ const pyprojectPath = path.join(projectDir, "pyproject.toml");
104
+ if (await fs.pathExists(pyprojectPath)) {
105
+ const content = await readManifest(pyprojectPath, warnings);
106
+ const match = content?.match(/dependencies\s*=\s*\[([\s\S]*?)\]/);
107
+ if (match?.[1]) {
108
+ const deps = match[1]
91
109
  .split("\n")
92
- .map((l) => l.trim())
93
- .filter((l) => l.length > 0 && !l.startsWith("#") && !l.startsWith("-"))
110
+ .map((l) => l.replace(/[",]/g, "").trim())
111
+ .filter((l) => l.length > 0 && !l.startsWith("#"))
94
112
  .map((l) => l.split(/[>=<~!]/)[0].trim())
95
113
  .filter(Boolean);
96
- return deps.slice(0, MAX_DEPS);
114
+ return { dependencies: deps.slice(0, MAX_DEPS), warnings };
97
115
  }
98
- return [];
99
116
  }
100
- case "go": {
101
- const goModPath = path.join(projectDir, "go.mod");
102
- if (!(await fs.pathExists(goModPath)))
103
- return [];
104
- const content = await fs.readFile(goModPath, "utf-8");
105
- const requireBlock = content.match(/require\s*\(([\s\S]*?)\)/);
106
- if (requireBlock?.[1]) {
107
- const deps = requireBlock[1]
108
- .split("\n")
109
- .map((l) => l.trim())
110
- .filter((l) => l.length > 0 && !l.startsWith("//"))
111
- .map((l) => {
112
- const parts = l.split(/\s+/);
113
- const mod = parts[0] ?? "";
114
- return mod.split("/").pop() ?? mod;
115
- })
116
- .filter(Boolean);
117
- return deps.slice(0, MAX_DEPS);
118
- }
119
- return [];
117
+ const reqPath = path.join(projectDir, "requirements.txt");
118
+ if (await fs.pathExists(reqPath)) {
119
+ const content = await readManifest(reqPath, warnings);
120
+ if (content === null)
121
+ return { dependencies: [], warnings };
122
+ const deps = content
123
+ .split("\n")
124
+ .map((l) => l.trim())
125
+ .filter((l) => l.length > 0 && !l.startsWith("#") && !l.startsWith("-"))
126
+ .map((l) => l.split(/[>=<~!]/)[0].trim())
127
+ .filter(Boolean);
128
+ return { dependencies: deps.slice(0, MAX_DEPS), warnings };
120
129
  }
121
- case "rust": {
122
- const cargoPath = path.join(projectDir, "Cargo.toml");
123
- if (!(await fs.pathExists(cargoPath)))
124
- return [];
125
- const content = await fs.readFile(cargoPath, "utf-8");
126
- const depsSection = content.match(/\[dependencies\]([\s\S]*?)(?=\n\[|$)/);
127
- if (depsSection?.[1]) {
128
- const deps = depsSection[1]
129
- .split("\n")
130
- .map((l) => l.trim())
131
- .filter((l) => l.length > 0 && !l.startsWith("#"))
132
- .map((l) => l.split(/\s*=/)[0].trim())
133
- .filter(Boolean);
134
- return deps.slice(0, MAX_DEPS);
135
- }
136
- return [];
130
+ return { dependencies: [], warnings };
131
+ }
132
+ case "go": {
133
+ const goModPath = path.join(projectDir, "go.mod");
134
+ if (!(await fs.pathExists(goModPath)))
135
+ return { dependencies: [], warnings };
136
+ const content = await readManifest(goModPath, warnings);
137
+ const requireBlock = content?.match(/require\s*\(([\s\S]*?)\)/);
138
+ if (requireBlock?.[1]) {
139
+ const deps = requireBlock[1]
140
+ .split("\n")
141
+ .map((l) => l.trim())
142
+ .filter((l) => l.length > 0 && !l.startsWith("//"))
143
+ .map((l) => {
144
+ const parts = l.split(/\s+/);
145
+ const mod = parts[0] ?? "";
146
+ return mod.split("/").pop() ?? mod;
147
+ })
148
+ .filter(Boolean);
149
+ return { dependencies: deps.slice(0, MAX_DEPS), warnings };
137
150
  }
138
- default:
139
- return [];
151
+ return { dependencies: [], warnings };
140
152
  }
153
+ case "rust": {
154
+ const cargoPath = path.join(projectDir, "Cargo.toml");
155
+ if (!(await fs.pathExists(cargoPath)))
156
+ return { dependencies: [], warnings };
157
+ const content = await readManifest(cargoPath, warnings);
158
+ const depsSection = content?.match(/\[dependencies\]([\s\S]*?)(?=\n\[|$)/);
159
+ if (depsSection?.[1]) {
160
+ const deps = depsSection[1]
161
+ .split("\n")
162
+ .map((l) => l.trim())
163
+ .filter((l) => l.length > 0 && !l.startsWith("#"))
164
+ .map((l) => l.split(/\s*=/)[0].trim())
165
+ .filter(Boolean);
166
+ return { dependencies: deps.slice(0, MAX_DEPS), warnings };
167
+ }
168
+ return { dependencies: [], warnings };
169
+ }
170
+ default:
171
+ return { dependencies: [], warnings };
141
172
  }
142
- catch {
143
- return [];
144
- }
173
+ }
174
+ /**
175
+ * Detect top-level dependencies from project manifest files.
176
+ * Returns up to 10 dependency names (key deps only, not devDeps).
177
+ *
178
+ * List-returning convenience over `detectDependenciesDetailed`. It DISCARDS the
179
+ * read/parse warnings — an unreadable manifest is indistinguishable from an
180
+ * honest empty list through this function. Callers that must tell those two
181
+ * apart (like `refreshContextDir`) call `detectDependenciesDetailed` directly.
182
+ */
183
+ export async function detectDependencies(projectDir, stack) {
184
+ const { dependencies } = await detectDependenciesDetailed(projectDir, stack);
185
+ return dependencies;
145
186
  }
146
187
  // =============================================================================
147
188
  // Public API
@@ -190,7 +231,7 @@ export async function refreshContextDir(projectDir) {
190
231
  return null;
191
232
  }
192
233
  const stackCtx = getStackContext(manifest.stack);
193
- const dependencies = await detectDependencies(projectDir, manifest.stack);
234
+ const { dependencies, warnings } = await detectDependenciesDetailed(projectDir, manifest.stack);
194
235
  const index = buildIndexMd(manifest.projectName, stackCtx, manifest.ciProvider, manifest.memory);
195
236
  const summary = buildSummaryMd(manifest.projectName, manifest.stack, manifest.ciProvider, manifest.memory, manifest.modules, dependencies);
196
237
  // Write updated files
@@ -199,6 +240,6 @@ export async function refreshContextDir(projectDir) {
199
240
  // Update manifest timestamp
200
241
  manifest.updatedAt = new Date().toISOString();
201
242
  await fs.writeJson(manifestPath, manifest, { spaces: 2 });
202
- return { index, summary, updated: true };
243
+ return { index, summary, updated: true, warnings };
203
244
  }
204
245
  //# sourceMappingURL=context.js.map
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Bounded, non-throwing file reads.
3
+ *
4
+ * Every whole-file read in this CLI used to be an unguarded
5
+ * `fs.readFile(path, "utf-8")`: a 400 MB log, a minified bundle or a binary
6
+ * blob under a scanned directory could exhaust memory or stall a scan. This
7
+ * module is the single guarded entry point — it caps bytes during the read,
8
+ * rejects binaries by content sniffing, clamps pathological single lines, and
9
+ * returns a discriminated union instead of throwing for expected conditions.
10
+ *
11
+ * Deliberately NOT included: no path allow/block list. This is a local CLI
12
+ * operating on the user's own repository, so any path they can name they can
13
+ * already `cat`; a blocklist would add friction without adding a boundary.
14
+ */
15
+ /** Default byte budget for a single read (1 MiB). */
16
+ export declare const DEFAULT_MAX_BYTES: number;
17
+ /** Default per-line character clamp — catches minified bundles and data URIs. */
18
+ export declare const DEFAULT_MAX_LINE_LENGTH = 10000;
19
+ export interface SafeReadOptions {
20
+ /** Maximum bytes kept. Anything beyond is dropped and `truncated` is set. */
21
+ maxBytes?: number;
22
+ /** Per-line character clamp. Use `0` or `Infinity` to disable. */
23
+ maxLineLength?: number;
24
+ /**
25
+ * If the file is larger than this, fail with `too-large` instead of
26
+ * truncating. Off by default — truncation is the normal behavior.
27
+ */
28
+ hardRejectOverBytes?: number;
29
+ }
30
+ export type SafeReadFailureReason = "not-found" | "not-a-file" | "binary" | "too-large" | "io-error";
31
+ export interface SafeReadSuccess {
32
+ ok: true;
33
+ content: string;
34
+ /** True when the file had more bytes than the budget allowed. */
35
+ truncated: boolean;
36
+ /** Bytes actually decoded into `content` (before BOM stripping). */
37
+ bytesRead: number;
38
+ /** File size reported by `stat` at the time of the read. */
39
+ totalBytes: number;
40
+ /** True when at least one line hit the per-line clamp. */
41
+ longLinesClamped: boolean;
42
+ }
43
+ export interface SafeReadFailure {
44
+ ok: false;
45
+ reason: SafeReadFailureReason;
46
+ detail?: string;
47
+ }
48
+ export type SafeReadResult = SafeReadSuccess | SafeReadFailure;
49
+ /**
50
+ * Read a text file with a byte budget, binary rejection and line clamping.
51
+ *
52
+ * Never throws for expected conditions (missing file, directory, binary,
53
+ * oversized, permission denied) — inspect `result.ok` and branch on `reason`.
54
+ *
55
+ * Newlines are returned verbatim: CRLF is NOT normalized, because the callers
56
+ * migrated to this helper already tolerate `\r` (they `trim()` split lines) and
57
+ * silently rewriting bytes would make reported offsets diverge from the file.
58
+ */
59
+ export declare function safeReadFile(filePath: string, opts?: SafeReadOptions): Promise<SafeReadResult>;
60
+ /** Human-readable one-liner for a failed read — for CLI notes and findings. */
61
+ export declare function describeSafeReadFailure(failure: SafeReadFailure): string;
62
+ //# sourceMappingURL=safe-read.d.ts.map
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Bounded, non-throwing file reads.
3
+ *
4
+ * Every whole-file read in this CLI used to be an unguarded
5
+ * `fs.readFile(path, "utf-8")`: a 400 MB log, a minified bundle or a binary
6
+ * blob under a scanned directory could exhaust memory or stall a scan. This
7
+ * module is the single guarded entry point — it caps bytes during the read,
8
+ * rejects binaries by content sniffing, clamps pathological single lines, and
9
+ * returns a discriminated union instead of throwing for expected conditions.
10
+ *
11
+ * Deliberately NOT included: no path allow/block list. This is a local CLI
12
+ * operating on the user's own repository, so any path they can name they can
13
+ * already `cat`; a blocklist would add friction without adding a boundary.
14
+ */
15
+ import { open, stat } from "node:fs/promises";
16
+ // =============================================================================
17
+ // Constants
18
+ // =============================================================================
19
+ /** Default byte budget for a single read (1 MiB). */
20
+ export const DEFAULT_MAX_BYTES = 1024 * 1024;
21
+ /** Default per-line character clamp — catches minified bundles and data URIs. */
22
+ export const DEFAULT_MAX_LINE_LENGTH = 10_000;
23
+ /** Size of each read from the file handle. */
24
+ const READ_CHUNK_BYTES = 64 * 1024;
25
+ /** Bytes of the first chunk sniffed for NUL when classifying binary content. */
26
+ const BINARY_SNIFF_BYTES = 8 * 1024;
27
+ const BOM = "\uFEFF";
28
+ // =============================================================================
29
+ // Internal helpers
30
+ // =============================================================================
31
+ function errnoOf(err) {
32
+ return typeof err === "object" && err !== null && "code" in err
33
+ ? String(err.code)
34
+ : undefined;
35
+ }
36
+ function messageOf(err) {
37
+ return err instanceof Error ? err.message : String(err);
38
+ }
39
+ /**
40
+ * Drop a trailing partial UTF-8 sequence so a byte-capped buffer never decodes
41
+ * into a replacement character. Scans back at most 3 bytes (max lead distance
42
+ * for a 4-byte sequence) looking for a lead byte whose sequence would run past
43
+ * the end of the buffer.
44
+ */
45
+ function trimPartialUtf8(buf) {
46
+ const maxLookback = Math.min(3, buf.length);
47
+ for (let back = 0; back < maxLookback; back++) {
48
+ const index = buf.length - 1 - back;
49
+ const byte = buf[index];
50
+ // Continuation byte (10xxxxxx): keep walking back to its lead byte.
51
+ if ((byte & 0xc0) === 0x80)
52
+ continue;
53
+ // ASCII byte: the buffer ends on a complete codepoint.
54
+ if ((byte & 0x80) === 0)
55
+ return buf;
56
+ // Lead byte: does its full sequence fit in what we kept?
57
+ let needed = 0;
58
+ if ((byte & 0xe0) === 0xc0)
59
+ needed = 2;
60
+ else if ((byte & 0xf0) === 0xe0)
61
+ needed = 3;
62
+ else if ((byte & 0xf8) === 0xf0)
63
+ needed = 4;
64
+ else
65
+ return buf; // invalid lead byte — leave it to the decoder
66
+ const available = buf.length - index;
67
+ return available >= needed ? buf : buf.subarray(0, index);
68
+ }
69
+ return buf;
70
+ }
71
+ /** Clamp lines longer than `maxLineLength`, marking how much was dropped. */
72
+ function clampLongLines(content, maxLineLength) {
73
+ if (!Number.isFinite(maxLineLength) || maxLineLength <= 0) {
74
+ return { content, clamped: false };
75
+ }
76
+ if (content.length <= maxLineLength)
77
+ return { content, clamped: false };
78
+ const lines = content.split("\n");
79
+ let clamped = false;
80
+ for (let i = 0; i < lines.length; i++) {
81
+ const line = lines[i];
82
+ if (line.length <= maxLineLength)
83
+ continue;
84
+ const dropped = line.length - maxLineLength;
85
+ lines[i] = `${line.slice(0, maxLineLength)}…[clamped ${dropped} chars]`;
86
+ clamped = true;
87
+ }
88
+ return clamped
89
+ ? { content: lines.join("\n"), clamped }
90
+ : { content, clamped };
91
+ }
92
+ // =============================================================================
93
+ // Public API
94
+ // =============================================================================
95
+ /**
96
+ * Read a text file with a byte budget, binary rejection and line clamping.
97
+ *
98
+ * Never throws for expected conditions (missing file, directory, binary,
99
+ * oversized, permission denied) — inspect `result.ok` and branch on `reason`.
100
+ *
101
+ * Newlines are returned verbatim: CRLF is NOT normalized, because the callers
102
+ * migrated to this helper already tolerate `\r` (they `trim()` split lines) and
103
+ * silently rewriting bytes would make reported offsets diverge from the file.
104
+ */
105
+ export async function safeReadFile(filePath, opts = {}) {
106
+ const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
107
+ const maxLineLength = opts.maxLineLength ?? DEFAULT_MAX_LINE_LENGTH;
108
+ const hardRejectOverBytes = opts.hardRejectOverBytes;
109
+ // -- Stat first: classify directories, sockets and missing paths cheaply ---
110
+ let totalBytes;
111
+ try {
112
+ const stats = await stat(filePath);
113
+ if (!stats.isFile()) {
114
+ return {
115
+ ok: false,
116
+ reason: "not-a-file",
117
+ detail: stats.isDirectory()
118
+ ? "path is a directory"
119
+ : "not a regular file",
120
+ };
121
+ }
122
+ totalBytes = stats.size;
123
+ }
124
+ catch (err) {
125
+ const code = errnoOf(err);
126
+ if (code === "ENOENT" || code === "ENOTDIR") {
127
+ return { ok: false, reason: "not-found", detail: filePath };
128
+ }
129
+ return { ok: false, reason: "io-error", detail: messageOf(err) };
130
+ }
131
+ if (hardRejectOverBytes !== undefined && totalBytes > hardRejectOverBytes) {
132
+ return {
133
+ ok: false,
134
+ reason: "too-large",
135
+ detail: `${totalBytes} bytes exceeds limit of ${hardRejectOverBytes}`,
136
+ };
137
+ }
138
+ if (maxBytes <= 0) {
139
+ return {
140
+ ok: true,
141
+ content: "",
142
+ truncated: totalBytes > 0,
143
+ bytesRead: 0,
144
+ totalBytes,
145
+ longLinesClamped: false,
146
+ };
147
+ }
148
+ // -- Read in chunks, enforcing the budget as we go (never buffer it all) ---
149
+ const chunks = [];
150
+ let collected = 0;
151
+ let truncated = false;
152
+ let handle;
153
+ try {
154
+ handle = await open(filePath, "r");
155
+ while (collected < maxBytes) {
156
+ const want = Math.min(READ_CHUNK_BYTES, maxBytes - collected);
157
+ const buf = Buffer.allocUnsafe(want);
158
+ const { bytesRead } = await handle.read(buf, 0, want, null);
159
+ if (bytesRead === 0)
160
+ break;
161
+ const chunk = buf.subarray(0, bytesRead);
162
+ // Binary sniff on the first chunk only — by content, never extension.
163
+ if (chunks.length === 0) {
164
+ const sniff = chunk.subarray(0, BINARY_SNIFF_BYTES);
165
+ if (sniff.includes(0)) {
166
+ return {
167
+ ok: false,
168
+ reason: "binary",
169
+ detail: "NUL byte in first chunk",
170
+ };
171
+ }
172
+ }
173
+ chunks.push(chunk);
174
+ collected += bytesRead;
175
+ }
176
+ // One probe byte tells us whether the budget actually cut something off.
177
+ if (collected >= maxBytes) {
178
+ const probe = Buffer.allocUnsafe(1);
179
+ const { bytesRead } = await handle.read(probe, 0, 1, null);
180
+ truncated = bytesRead > 0;
181
+ }
182
+ }
183
+ catch (err) {
184
+ return { ok: false, reason: "io-error", detail: messageOf(err) };
185
+ }
186
+ finally {
187
+ await handle?.close().catch(() => { });
188
+ }
189
+ let buffer = Buffer.concat(chunks, collected);
190
+ if (truncated)
191
+ buffer = trimPartialUtf8(buffer);
192
+ const bytesRead = buffer.length;
193
+ let content = buffer.toString("utf-8");
194
+ if (content.startsWith(BOM))
195
+ content = content.slice(BOM.length);
196
+ const { content: clampedContent, clamped } = clampLongLines(content, maxLineLength);
197
+ return {
198
+ ok: true,
199
+ content: clampedContent,
200
+ truncated,
201
+ bytesRead,
202
+ totalBytes,
203
+ longLinesClamped: clamped,
204
+ };
205
+ }
206
+ /** Human-readable one-liner for a failed read — for CLI notes and findings. */
207
+ export function describeSafeReadFailure(failure) {
208
+ switch (failure.reason) {
209
+ case "not-found":
210
+ return "file not found";
211
+ case "not-a-file":
212
+ return failure.detail ?? "not a regular file";
213
+ case "binary":
214
+ return "binary file";
215
+ case "too-large":
216
+ return failure.detail ? `too large (${failure.detail})` : "too large";
217
+ case "io-error":
218
+ return failure.detail ? `read error: ${failure.detail}` : "read error";
219
+ }
220
+ }
221
+ //# sourceMappingURL=safe-read.js.map
@@ -25,12 +25,29 @@ export interface SecurityAnalysisReport {
25
25
  projectDir: string;
26
26
  findings: SecurityAnalysisFinding[];
27
27
  summary: SecurityAnalysisSummary;
28
+ /**
29
+ * Files the pattern pass never saw — binary, oversized or unreadable. A scan
30
+ * that silently skipped files is not a clean scan, so they are reported.
31
+ */
32
+ skipped?: SecurityAnalysisSkippedFile[];
33
+ }
34
+ export interface SecurityAnalysisSkippedFile {
35
+ /** Path relative to `projectDir`, matching the finding paths. */
36
+ file: string;
37
+ reason: string;
28
38
  }
29
39
  export interface SecurityAnalysisSummary {
30
40
  total: number;
31
41
  bySeverity: Record<SecuritySeverity, number>;
32
42
  byCategory: Record<string, number>;
33
43
  passed: boolean;
44
+ /**
45
+ * True when at least one file was skipped or only partially scanned (binary,
46
+ * oversized, I/O error, truncated, or clamped). The scan did not see the whole
47
+ * codebase, so `passed` is forced to `false` — a clean result over an
48
+ * incomplete scan would be a fail-open lie.
49
+ */
50
+ incomplete: boolean;
34
51
  failThreshold: SecuritySeverity;
35
52
  }
36
53
  export interface SecurityAnalysisOptions {
@@ -64,8 +81,8 @@ export declare function detectFileLanguage(filePath: string): string | null;
64
81
  export declare function matchRule(rule: SemgrepRule, content: string, filePath: string): SecurityAnalysisFinding[];
65
82
  export declare function collectFiles(dir: string): Promise<string[]>;
66
83
  export declare function severityAtOrAbove(severity: SecuritySeverity, threshold: SecuritySeverity): boolean;
67
- export declare function buildSummary(findings: SecurityAnalysisFinding[], failThreshold: SecuritySeverity): SecurityAnalysisSummary;
68
- export declare function buildReport(findings: SecurityAnalysisFinding[], projectDir: string, options?: SecurityAnalysisOptions): SecurityAnalysisReport;
84
+ export declare function buildSummary(findings: SecurityAnalysisFinding[], failThreshold: SecuritySeverity, incomplete?: boolean): SecurityAnalysisSummary;
85
+ export declare function buildReport(findings: SecurityAnalysisFinding[], projectDir: string, options?: SecurityAnalysisOptions, skipped?: SecurityAnalysisSkippedFile[]): SecurityAnalysisReport;
69
86
  export declare function filterRules(rules: SemgrepRule[], options?: SecurityAnalysisOptions): SemgrepRule[];
70
87
  export declare function loadCustomRules(rulesDir: string): Promise<SemgrepRule[]>;
71
88
  export declare function runSecurityAnalysis(projectDir: string, options?: SecurityAnalysisOptions): Promise<SecurityAnalysisReport>;