@pho9ubenaa/siro 0.6.0 → 0.6.1

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 CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.1] — 2026-10-02
4
+
5
+ - Add `--no-config` to skip executable repository configuration, and
6
+ `--strict-filesystem` / API `rejectSymlinks` to reject native input-path symlinks.
7
+ Defaults retain automatic config and symlink resolution; neither option is a sandbox.
8
+ - Add finite scan/output budgets, caller overrides (`limits`, CLI `--max-*`) and
9
+ `DEFAULT_SCAN_LIMITS` / `ScanLimits` exports. Oversized/deep inputs now fail with
10
+ exit 2; output overflow exits 70 rather than producing a truncated success report.
11
+ - Omit source excerpts from JSON/YAML/TOML syntax diagnostics. Observed finding
12
+ values are not secret-redacted.
13
+ - Fix argument-limit failures when inspecting large Deno scoped import mappings.
14
+ - Update locked transitive dependencies to brace-expansion 5.0.12, markdown-it 14.3.1
15
+ and fast-uri 3.1.8 for upstream security fixes.
16
+ - Correct first-run safety examples to use `npx` without a global install.
17
+ - Separate build/verification from OIDC staging of the verified artifact, with
18
+ release identity, digest and main-ancestry checks.
19
+
20
+ Node requirements, direct dependency versions and decoded JSON schema 3 remain unchanged.
21
+
3
22
  ## [0.6.0] — 2026-09-29
4
23
 
5
24
  ### Breaking changes and inspection scope
package/README.md CHANGED
@@ -33,8 +33,9 @@ Discovery does not imply that every package's installation settings were inspect
33
33
  siro detects managers from `packageManager`, lockfiles, and configuration files. If it cannot
34
34
  detect one, choose it explicitly, for example `npx @pho9ubenaa/siro lint --pm npm`.
35
35
  The CLI may download code through `npx` and imports a repository's `siro.config.*` as executable
36
- code. Review configurations before running it on an unfamiliar project; see the
37
- [threat model](docs/threat-model.md).
36
+ code by default. For an unfamiliar project, use `npx @pho9ubenaa/siro lint --no-config --strict-filesystem`
37
+ in an isolated environment without credentials. These flags do not sandbox npx or
38
+ extensions; see the [threat model](docs/threat-model.md).
38
39
 
39
40
  ## Read findings and add CI
40
41
 
@@ -70,6 +71,11 @@ For exclusions and rule overrides, see the [configuration examples](docs/configu
70
71
  | `--severity <error\|warn\|info>` | Set both the display and CI failure threshold; default failure threshold is `error`. |
71
72
  | `--reporter <pretty\|json\|github>` | Choose terminal, JSON, or GitHub Actions output; `--json` is a JSON shortcut. |
72
73
 
74
+ `--no-config` disables executable configuration; `--strict-filesystem` rejects
75
+ symlink input paths in native data reads. Scans also have finite caller-controlled
76
+ [file, tree, nesting, finding and output budgets](docs/configuration.md#strict-filesystem-and-scan-budgets).
77
+ Overflow fails the check rather than silently skipping inputs.
78
+
73
79
  For a walkthrough and deeper reference, use these guides:
74
80
 
75
81
  - [Getting started](docs/getting-started.md) walks through findings and CI; [configuration](docs/configuration.md) covers local PM/version selection, discovery and explicit installation scope, executable config, exit codes, and migration from the removed `--workspaces` flag.
package/dist/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { C as isSupportedNodeVersion, E as assertDirectory, M as SiroError, N as UsageError, O as isNodeError, S as isSeverity, T as version, _ as PROJECT_TYPES, a as DEFAULT_REPORTER_NAME, b as SEVERITIES, i as BUILTIN_REPORTER_NAMES, k as asAbsPath, l as safeText, m as loadConfig, o as JSON_REPORTER_NAME, r as lintCommand, t as nodeIO, v as isProjectType, w as SUPPORTED_NODE_RANGE, x as isPM, y as PMS } from "./node-io-CAszdmko.mjs";
2
+ import { C as isSeverity, D as asAbsPath, E as version, F as SiroError, I as UsageError, M as DEFAULT_SCAN_LIMITS, N as resolveScanLimits, S as isPM, T as SUPPORTED_NODE_RANGE, _ as PROJECT_TYPES, a as DEFAULT_REPORTER_NAME, b as PMS, i as BUILTIN_REPORTER_NAMES, j as isNodeError, k as assertDirectory, l as safeText, m as loadConfig, o as JSON_REPORTER_NAME, r as lintCommand, t as nodeIO, v as isProjectType, w as isSupportedNodeVersion, x as SEVERITIES, y as isStableVersion } from "./node-io-BpAHyeBG.mjs";
3
3
  import path from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
5
  import { parseArgs } from "node:util";
@@ -31,6 +31,7 @@ const COMMANDS = ["lint", "check"];
31
31
  const isCommandName = (value) => COMMANDS.some((cmd) => cmd === value);
32
32
  //#endregion
33
33
  //#region src/cli/parse-args.ts
34
+ const LIMIT_FLAGS = new Map(Object.keys(DEFAULT_SCAN_LIMITS).map((key) => [key.replace(/[A-Z]/gu, (letter) => `-${letter.toLowerCase()}`), key]));
34
35
  const REPEATABLE_FLAGS = new Set(["exclude", "installation-root"]);
35
36
  const VALUE_FLAGS = new Set([
36
37
  "pm",
@@ -38,12 +39,15 @@ const VALUE_FLAGS = new Set([
38
39
  "project-type",
39
40
  "reporter",
40
41
  "severity",
41
- ...REPEATABLE_FLAGS
42
+ ...REPEATABLE_FLAGS,
43
+ ...LIMIT_FLAGS.keys()
42
44
  ]);
43
45
  const BOOLEAN_FLAGS = new Set([
44
46
  "help",
45
47
  "version",
46
- "json"
48
+ "json",
49
+ "no-config",
50
+ "strict-filesystem"
47
51
  ]);
48
52
  const parseCommand = (argv) => {
49
53
  const { tokens } = parseArgs({
@@ -78,7 +82,11 @@ const parseCommand = (argv) => {
78
82
  }
79
83
  if (BOOLEAN_FLAGS.has(token.name)) if (token.value !== void 0) error ??= `Flag ${token.rawName} does not accept a value.`;
80
84
  else {
81
- if (token.name === "json" && flags.has("json")) error ??= "--json must be specified only once.";
85
+ if ([
86
+ "json",
87
+ "no-config",
88
+ "strict-filesystem"
89
+ ].includes(token.name) && flags.has(token.name)) error ??= `${token.rawName} must be specified only once.`;
82
90
  flags.set(token.name, true);
83
91
  }
84
92
  else if (VALUE_FLAGS.has(token.name)) {
@@ -116,15 +124,29 @@ const parseCommand = (argv) => {
116
124
  if (flags.has("reporter") && flags.has("json")) throw new UsageError("Invalid reporter selection: use either --reporter or --json.");
117
125
  const reporter = flags.get("reporter");
118
126
  const pmVersion = flags.get("pm-version");
127
+ const pm = parsePmFlag(flags.get("pm"));
128
+ if (pmVersion !== void 0 && (!pm || !isStableVersion(pmVersion))) throw new UsageError("--pm-version requires --pm and an exact stable version.");
129
+ const limits = {};
130
+ for (const [flag, key] of LIMIT_FLAGS) {
131
+ const value = flags.get(flag);
132
+ if (value !== void 0) {
133
+ if (typeof value !== "string" || !/^\d+$/u.test(value)) throw new UsageError(`${flag} must be a positive safe integer.`);
134
+ limits[key] = Number(value);
135
+ }
136
+ }
137
+ resolveScanLimits(limits);
119
138
  return {
120
139
  kind: "lint",
121
140
  cwd: asAbsPath(path.resolve(cwd ?? process.cwd())),
122
- pm: parsePmFlag(flags.get("pm")),
141
+ pm,
123
142
  pmVersion: typeof pmVersion === "string" ? pmVersion : void 0,
124
143
  exclude: repeated.get("exclude"),
125
144
  installationRoots: repeated.get("installation-root"),
126
145
  projectType: parseProjectTypeFlag(flags.get("project-type")),
127
146
  severity: parseSeverityFlag(flags.get("severity")),
147
+ ...flags.has("no-config") ? { noConfig: true } : {},
148
+ ...flags.has("strict-filesystem") ? { rejectSymlinks: true } : {},
149
+ ...Object.keys(limits).length ? { limits } : {},
128
150
  reporter: typeof reporter === "string" ? reporter : flags.has("json") ? JSON_REPORTER_NAME : DEFAULT_REPORTER_NAME
129
151
  };
130
152
  };
@@ -136,6 +158,8 @@ const PROJECT_TYPES_LIST = PROJECT_TYPES.join("|");
136
158
  const SEVERITIES_LIST = SEVERITIES.join("|");
137
159
  const FLAG_LINES = {
138
160
  json: " --json Shortcut for --reporter json",
161
+ safety: " --no-config Do not probe or execute repository siro.config.*\n --strict-filesystem Reject symlink input paths (not a containment sandbox)",
162
+ limits: " --max-file-bytes <n> Bound each input file (default 8388608)\n --max-total-bytes <n> Bound all input reads (default 67108864)\n --max-entries <n> Bound native directory entries (default 100000)\n --max-directories <n> Bound discovered directories (default 10000)\n --max-directory-depth <n> Bound discovery depth (default 128)\n --max-config-depth <n> Bound configuration nesting (default 128)\n --max-findings <n> Bound unfiltered findings (default 50000)\n --max-output-bytes <n> Bound report output (default 33554432)",
139
163
  pm: ` --pm <name> Target a specific package manager (${PMS_LIST})`,
140
164
  pmVersion: " --pm-version <x.y.z> Target an exact stable PM version (requires --pm)",
141
165
  inspection: " --exclude <pattern> Exclude directories from recursive discovery (repeatable)\n --installation-root <path> Inspect local install policy here (repeatable; default .)",
@@ -161,12 +185,15 @@ const HELP_ROOT = [
161
185
  " --help Show help for siro or a command",
162
186
  "",
163
187
  "LINT FLAGS",
188
+ FLAG_LINES.safety,
189
+ FLAG_LINES.limits,
164
190
  FLAG_LINES.reporter,
165
191
  FLAG_LINES.json,
166
192
  FLAG_LINES.severity,
167
193
  "",
168
194
  "EXAMPLES",
169
195
  " $ siro lint # report violations in cwd",
196
+ " $ siro lint --no-config --strict-filesystem # data-only inspection",
170
197
  " $ siro lint --reporter github # GitHub Actions annotations",
171
198
  " $ siro lint --severity warn # also fail on warnings",
172
199
  "",
@@ -183,6 +210,8 @@ const HELP_LINT = [
183
210
  " siro check [path] [flags] (alias)",
184
211
  "",
185
212
  "FLAGS",
213
+ FLAG_LINES.safety,
214
+ FLAG_LINES.limits,
186
215
  FLAG_LINES.pm,
187
216
  FLAG_LINES.pmVersion,
188
217
  FLAG_LINES.inspection,
@@ -195,10 +224,11 @@ const HELP_LINT = [
195
224
  " 0 No findings at or above the active threshold",
196
225
  " 1 Findings at or above the threshold (default: error)",
197
226
  " 2 Usage error (bad flag, broken siro.config.ts, unreadable path, …)",
198
- " 70 Uncaught exception (a siro bug, or a reporter / custom rule that threw)",
227
+ " 70 Output failure/limit, or unexpected exception (including trusted extensions)",
199
228
  "",
200
229
  "EXAMPLES",
201
230
  " $ siro lint # default: pretty reporter, fail on errors",
231
+ " $ siro lint --no-config --strict-filesystem # data-only inspection",
202
232
  " $ siro lint --reporter github # GitHub Actions annotations",
203
233
  " $ siro lint --severity warn # tighten the gate"
204
234
  ].join("\n");
@@ -224,12 +254,16 @@ const dispatch = async (cmd, io) => {
224
254
  if (cmd.reason) await io.stderr(`${safeText(cmd.reason)}\n`);
225
255
  await io.stderr(renderHelp());
226
256
  return EXIT_USAGE;
227
- case "lint":
257
+ case "lint": {
228
258
  assertDirectory(cmd.cwd);
229
- return loadConfig(cmd.cwd).then((config) => lintCommand({
230
- ...cmd,
259
+ const { noConfig, ...options } = cmd;
260
+ if (noConfig) return lintCommand(options, io);
261
+ const config = await loadConfig(cmd.cwd);
262
+ return lintCommand({
263
+ ...options,
231
264
  config
232
- }, io));
265
+ }, io);
266
+ }
233
267
  default: throw new Error(`Unhandled command kind: ${String(cmd)}`);
234
268
  }
235
269
  };
package/dist/index.d.mts CHANGED
@@ -201,9 +201,24 @@ interface LintResult {
201
201
  readonly inspection: Inspection;
202
202
  }
203
203
  //#endregion
204
+ //#region src/core/contracts/scan-limits.d.ts
205
+ /** Finite per-scan budgets, controlled by the caller, never repository config. */
206
+ interface ScanLimits {
207
+ readonly maxFileBytes: number;
208
+ readonly maxTotalBytes: number;
209
+ readonly maxEntries: number;
210
+ readonly maxDirectories: number;
211
+ readonly maxDirectoryDepth: number;
212
+ readonly maxConfigDepth: number;
213
+ readonly maxFindings: number;
214
+ readonly maxOutputBytes: number;
215
+ }
216
+ declare const DEFAULT_SCAN_LIMITS: Readonly<ScanLimits>;
217
+ //#endregion
204
218
  //#region src/core/contracts/reporter.d.ts
205
219
  interface ReportContext {
206
220
  readonly cwd: AbsPath;
221
+ readonly limits?: ScanLimits;
207
222
  }
208
223
  interface Reporter<Name extends string = string> {
209
224
  readonly name: Name;
@@ -260,6 +275,10 @@ declare const defineConfig: (config: SiroConfig) => SiroConfig;
260
275
  interface LintOptions {
261
276
  readonly cwd: AbsPath;
262
277
  readonly fs?: FileSystem;
278
+ /** Caller-controlled finite budgets; omitted values use DEFAULT_SCAN_LIMITS. */
279
+ readonly limits?: Partial<ScanLimits>;
280
+ /** Reject file/path symlinks in the native adapter; not an atomic containment sandbox. */
281
+ readonly rejectSymlinks?: boolean;
263
282
  /** Applies only to cwd, never to discovered children or additional installation roots. */
264
283
  readonly pm?: PM;
265
284
  readonly pmVersion?: string;
@@ -397,5 +416,5 @@ declare const version: string;
397
416
  //#region src/adapters/node-paths.d.ts
398
417
  declare const asAbsPath: (value: string) => AbsPath;
399
418
  //#endregion
400
- export { type AbsPath, BUILTIN_REPORTER_NAMES, type BuiltinReporterName, CONFIG_FILES, type CheckStatus, ConfigError, type ConfigFileRef, type ConfigReadValue, type ConfigValue, type FileSystem, type Finding, type IO, type Inspection, type InstallationRootInput, type KeyPath, type LintCommandOptions, type LintOptions, type LintResult, type PM, PMS, PROJECT_TYPES, type ParsedConfig, type PolicyTarget, type ProjectType, type RelPath, type Remediation, type RepoContext, type ReportContext, type Reporter, type RequireConfigKeyOptions, type RequireConfigKeySpec, type Rule, type RuleBinding, type RuleContext, type RuleSetting, SEVERITIES, type SetKeyOperation, type Severity, type SiroConfig, SiroError, UsageError, type VersionNote, type ViolationStatus, asAbsPath, asRelPath, defineConfig, defineRule, getByPath, githubReporter, isPM, isSeverity, jsonReporter, lint, lintCommand, loadConfig, nodeFileSystem, nodeIO, overrideBindings, prettyReporter, requireConfigKey, version };
419
+ export { type AbsPath, BUILTIN_REPORTER_NAMES, type BuiltinReporterName, CONFIG_FILES, type CheckStatus, ConfigError, type ConfigFileRef, type ConfigReadValue, type ConfigValue, DEFAULT_SCAN_LIMITS, type FileSystem, type Finding, type IO, type Inspection, type InstallationRootInput, type KeyPath, type LintCommandOptions, type LintOptions, type LintResult, type PM, PMS, PROJECT_TYPES, type ParsedConfig, type PolicyTarget, type ProjectType, type RelPath, type Remediation, type RepoContext, type ReportContext, type Reporter, type RequireConfigKeyOptions, type RequireConfigKeySpec, type Rule, type RuleBinding, type RuleContext, type RuleSetting, SEVERITIES, type ScanLimits, type SetKeyOperation, type Severity, type SiroConfig, SiroError, UsageError, type VersionNote, type ViolationStatus, asAbsPath, asRelPath, defineConfig, defineRule, getByPath, githubReporter, isPM, isSeverity, jsonReporter, lint, lintCommand, loadConfig, nodeFileSystem, nodeIO, overrideBindings, prettyReporter, requireConfigKey, version };
401
420
  //# sourceMappingURL=index.d.mts.map
package/dist/index.mjs CHANGED
@@ -1,8 +1,8 @@
1
- import { A as asRelPath, D as nodeFileSystem, M as SiroError, N as UsageError, S as isSeverity, T as version, _ as PROJECT_TYPES, b as SEVERITIES, c as jsonReporter, d as overrideBindings, f as requireConfigKey, g as getByPath, h as defineRule, i as BUILTIN_REPORTER_NAMES, j as ConfigError, k as asAbsPath, m as loadConfig, n as lint, p as CONFIG_FILES, r as lintCommand, s as prettyReporter, t as nodeIO, u as githubReporter, x as isPM, y as PMS } from "./node-io-CAszdmko.mjs";
1
+ import { A as nodeFileSystem, C as isSeverity, D as asAbsPath, E as version, F as SiroError, I as UsageError, M as DEFAULT_SCAN_LIMITS, O as asRelPath, P as ConfigError, S as isPM, _ as PROJECT_TYPES, b as PMS, c as jsonReporter, d as overrideBindings, f as requireConfigKey, g as getByPath, h as defineRule, i as BUILTIN_REPORTER_NAMES, m as loadConfig, n as lint, p as CONFIG_FILES, r as lintCommand, s as prettyReporter, t as nodeIO, u as githubReporter, x as SEVERITIES } from "./node-io-BpAHyeBG.mjs";
2
2
  //#region src/core/siro-config.ts
3
3
  /** Identity helper for type-checked config files. */
4
4
  const defineConfig = (config) => config;
5
5
  //#endregion
6
- export { BUILTIN_REPORTER_NAMES, CONFIG_FILES, ConfigError, PMS, PROJECT_TYPES, SEVERITIES, SiroError, UsageError, asAbsPath, asRelPath, defineConfig, defineRule, getByPath, githubReporter, isPM, isSeverity, jsonReporter, lint, lintCommand, loadConfig, nodeFileSystem, nodeIO, overrideBindings, prettyReporter, requireConfigKey, version };
6
+ export { BUILTIN_REPORTER_NAMES, CONFIG_FILES, ConfigError, DEFAULT_SCAN_LIMITS, PMS, PROJECT_TYPES, SEVERITIES, SiroError, UsageError, asAbsPath, asRelPath, defineConfig, defineRule, getByPath, githubReporter, isPM, isSeverity, jsonReporter, lint, lintCommand, loadConfig, nodeFileSystem, nodeIO, overrideBindings, prettyReporter, requireConfigKey, version };
7
7
 
8
8
  //# sourceMappingURL=index.mjs.map
@@ -1,5 +1,5 @@
1
+ import { closeSync, constants, fstatSync, lstatSync, openSync, opendirSync, readSync, realpathSync, statSync } from "node:fs";
1
2
  import path from "node:path";
2
- import { readFileSync, readdirSync, statSync } from "node:fs";
3
3
  import { pathToFileURL } from "node:url";
4
4
  import satisfies from "semver/functions/satisfies.js";
5
5
  import * as vb from "valibot";
@@ -9,6 +9,7 @@ import { Minimatch } from "minimatch";
9
9
  import ini from "ini";
10
10
  import { parse as parse$1 } from "smol-toml";
11
11
  import { parseDocument } from "yaml";
12
+ import { types } from "node:util";
12
13
  import pc from "picocolors";
13
14
  //#region src/core/contracts/errors.ts
14
15
  var SiroError = class extends Error {
@@ -31,6 +32,8 @@ var UsageError = class extends SiroError {
31
32
  this.name = "UsageError";
32
33
  }
33
34
  };
35
+ /** Parser diagnostics constructed from constant classifications, never source excerpts. */
36
+ var ConfigParseError = class extends Error {};
34
37
  /**
35
38
  * Run `fn` and wrap any non-`ConfigError` failure as a `ConfigError` prefixed
36
39
  * with `filePath`. A `ConfigError` that bubbles up from a nested call is re-thrown unchanged so
@@ -41,12 +44,187 @@ const wrapCodecError = (filePath, fn) => {
41
44
  return fn();
42
45
  } catch (error) {
43
46
  if (error instanceof ConfigError) throw error;
44
- let msg = String(error);
45
- if (error instanceof Error) msg = error.message;
46
- throw new ConfigError(`${filePath}: ${msg}`);
47
+ throw new ConfigError(`${filePath}: ${error instanceof ConfigParseError ? error.message : "Invalid configuration."}`);
47
48
  }
48
49
  };
49
50
  //#endregion
51
+ //#region src/core/contracts/records.ts
52
+ /** Configuration maps are ordinary objects or null-prototype dictionaries. */
53
+ const isPlainRecord = (value) => {
54
+ if (typeof value !== "object" || value === null) return false;
55
+ const prototype = Object.getPrototypeOf(value);
56
+ return prototype === Object.prototype || prototype === null;
57
+ };
58
+ //#endregion
59
+ //#region src/core/contracts/scan-limits.ts
60
+ const DEFAULT_SCAN_LIMITS = Object.freeze({
61
+ maxFileBytes: 8 * 1024 * 1024,
62
+ maxTotalBytes: 64 * 1024 * 1024,
63
+ maxEntries: 1e5,
64
+ maxDirectories: 1e4,
65
+ maxDirectoryDepth: 128,
66
+ maxConfigDepth: 128,
67
+ maxFindings: 5e4,
68
+ maxOutputBytes: 32 * 1024 * 1024
69
+ });
70
+ const resolveScanLimits = (input) => {
71
+ if (input !== void 0 && (!isPlainRecord(input) || Object.keys(input).some((key) => !Object.hasOwn(DEFAULT_SCAN_LIMITS, key)))) throw new UsageError("limits must be an object of known scan limits.");
72
+ const limits = {
73
+ ...DEFAULT_SCAN_LIMITS,
74
+ ...input
75
+ };
76
+ for (const [key, value] of Object.entries(limits)) if (!Number.isSafeInteger(value) || value <= 0) throw new UsageError(`${key} must be a positive safe integer.`);
77
+ return limits;
78
+ };
79
+ const SCAN_SCOPE_ADVICE = "Reduce the scan scope. If the target contains independent projects, scan each project directory separately.";
80
+ const checkLimit = (name, value, limits) => {
81
+ if (value > limits[name]) {
82
+ const advice = name === "maxFileBytes" ? "Reduce the size of the input file." : SCAN_SCOPE_ADVICE;
83
+ throw new ConfigError(`Inspection exceeds ${name} (${limits[name]}). ${advice}`);
84
+ }
85
+ };
86
+ /** Counts UTF-8 without allocating another copy of a potentially large input. */
87
+ const utf8Bytes = (text) => {
88
+ let bytes = 0;
89
+ for (const character of text) {
90
+ const code = character.codePointAt(0);
91
+ bytes += code <= 127 ? 1 : code <= 2047 ? 2 : code <= 65535 ? 3 : 4;
92
+ }
93
+ return bytes;
94
+ };
95
+ /** Iterative traversal; shared YAML mappings are legal, cycles are not. Root depth is 1. */
96
+ const checkConfigDepth = (value, maxDepth) => {
97
+ const active = /* @__PURE__ */ new Set();
98
+ const seen = /* @__PURE__ */ new Map();
99
+ const pending = [{
100
+ value,
101
+ depth: 1
102
+ }];
103
+ while (pending.length) {
104
+ const item = pending.pop();
105
+ if (item.value === null || typeof item.value !== "object") continue;
106
+ if (item.leave) {
107
+ active.delete(item.value);
108
+ continue;
109
+ }
110
+ if (active.has(item.value)) throw new ConfigError("Configuration contains a circular reference.");
111
+ if (item.depth > maxDepth) throw new ConfigError(`Configuration exceeds maxConfigDepth (${maxDepth}). Simplify the nesting of the configuration.`);
112
+ if ((seen.get(item.value) ?? 0) >= item.depth) continue;
113
+ seen.set(item.value, item.depth);
114
+ active.add(item.value);
115
+ pending.push({
116
+ ...item,
117
+ leave: true
118
+ });
119
+ for (const child of Object.values(item.value)) pending.push({
120
+ value: child,
121
+ depth: item.depth + 1
122
+ });
123
+ }
124
+ };
125
+ /** Output budgets are output failures (70), not policy findings or incomplete input (2). */
126
+ const outputBudget = (maxBytes) => {
127
+ let bytes = 0;
128
+ return (text) => {
129
+ bytes += utf8Bytes(text);
130
+ if (bytes > maxBytes) throw new SiroError(`Output exceeds maxOutputBytes (${maxBytes}). ${SCAN_SCOPE_ADVICE}`, 70);
131
+ };
132
+ };
133
+ //#endregion
134
+ //#region src/adapters/node-errors.ts
135
+ /** Only the Error and code properties are established by this guard. */
136
+ const isNodeError = (value) => value instanceof Error && "code" in value && typeof value.code === "string";
137
+ //#endregion
138
+ //#region src/adapters/node-file-system.ts
139
+ /** Root ancestors are canonicalized; selected cwd and all below-root components must not be links. */
140
+ const strictPaths = (root) => {
141
+ const selected = path.resolve(root);
142
+ if (lstatSync(selected).isSymbolicLink()) throw new ConfigError("Strict filesystem rejects a symlink cwd.");
143
+ const canonical = realpathSync(selected);
144
+ return (file) => {
145
+ const relative = path.relative(root, file);
146
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) throw new ConfigError("Strict filesystem path escapes cwd.");
147
+ let current = canonical;
148
+ for (const component of relative.split(path.sep).filter(Boolean)) {
149
+ current = path.join(current, component);
150
+ if (lstatSync(current).isSymbolicLink()) throw new ConfigError(`${file}: strict filesystem rejects symlinks.`);
151
+ }
152
+ };
153
+ };
154
+ /** Construct fresh native budgets for each inspection. No counters survive another scan. */
155
+ const createNodeFileSystem = (limits = DEFAULT_SCAN_LIMITS, strictRoot) => {
156
+ const checkPath = strictRoot === void 0 ? () => {} : strictPaths(strictRoot);
157
+ let totalBytes = 0;
158
+ let entries = 0;
159
+ const regularFile = (file) => {
160
+ checkPath(file);
161
+ const stat = statSync(file);
162
+ if (!stat.isFile()) throw new ConfigError(`${file}: expected a regular file.`);
163
+ return stat;
164
+ };
165
+ return {
166
+ readDirectories(directory) {
167
+ checkPath(directory);
168
+ const stream = opendirSync(directory);
169
+ const names = [];
170
+ try {
171
+ for (let entry = stream.readSync(); entry !== null; entry = stream.readSync()) {
172
+ checkLimit("maxEntries", ++entries, limits);
173
+ if (entry.isDirectory()) names.push(entry.name);
174
+ }
175
+ } finally {
176
+ stream.closeSync();
177
+ }
178
+ return names;
179
+ },
180
+ exists(file) {
181
+ try {
182
+ regularFile(file);
183
+ return true;
184
+ } catch (error) {
185
+ if (isNodeError(error) && error.code === "ENOENT") return false;
186
+ throw error;
187
+ }
188
+ },
189
+ readText(file) {
190
+ let fd;
191
+ try {
192
+ const stat = regularFile(file);
193
+ checkLimit("maxFileBytes", stat.size, limits);
194
+ checkLimit("maxTotalBytes", totalBytes + stat.size, limits);
195
+ fd = openSync(file, constants.O_RDONLY | constants.O_NONBLOCK | (strictRoot === void 0 ? 0 : constants.O_NOFOLLOW ?? 0));
196
+ if (!fstatSync(fd).isFile()) throw new ConfigError(`${file}: expected a regular file.`);
197
+ const chunks = [];
198
+ let size = 0;
199
+ for (;;) {
200
+ const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, limits.maxFileBytes - size + 1, limits.maxTotalBytes - totalBytes + 1));
201
+ const read = readSync(fd, buffer);
202
+ if (read === 0) break;
203
+ size += read;
204
+ totalBytes += read;
205
+ checkLimit("maxFileBytes", size, limits);
206
+ checkLimit("maxTotalBytes", totalBytes, limits);
207
+ chunks.push(buffer.subarray(0, read));
208
+ }
209
+ return Buffer.concat(chunks, size).toString("utf8");
210
+ } catch (error) {
211
+ if (isNodeError(error) && error.code === "ENOENT") return;
212
+ throw error;
213
+ } finally {
214
+ if (fd !== void 0) closeSync(fd);
215
+ }
216
+ }
217
+ };
218
+ };
219
+ const nodeFileSystem = {
220
+ readDirectories: (directory) => createNodeFileSystem().readDirectories(directory),
221
+ exists: (file) => createNodeFileSystem().exists(file),
222
+ readText: (file) => createNodeFileSystem().readText(file)
223
+ };
224
+ const assertDirectory = (root) => {
225
+ if (!statSync(root).isDirectory()) throw new UsageError("The lint target must be a directory.");
226
+ };
227
+ //#endregion
50
228
  //#region src/core/contracts/paths.ts
51
229
  const isRelPath = (value) => typeof value === "string" && value.length > 0 && !value.includes("\0") && !/^[\\/]/u.test(value) && !/^[a-z]:/iu.test(value) && !value.split(/[\\/]/u).includes("..");
52
230
  const asRelPath = (value) => {
@@ -75,44 +253,8 @@ const nodePaths = {
75
253
  }
76
254
  };
77
255
  //#endregion
78
- //#region src/adapters/node-errors.ts
79
- /** Only the Error and code properties are established by this guard. */
80
- const isNodeError = (value) => value instanceof Error && "code" in value && typeof value.code === "string";
81
- //#endregion
82
- //#region src/adapters/node-file-system.ts
83
- const assertRegularFile = (filePath) => {
84
- if (!statSync(filePath).isFile()) throw new ConfigError(`${filePath}: expected a regular file.`);
85
- };
86
- const nodeFileSystem = {
87
- readDirectories(directory) {
88
- return readdirSync(directory, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
89
- },
90
- exists(filePath) {
91
- try {
92
- assertRegularFile(filePath);
93
- return true;
94
- } catch (error) {
95
- if (isNodeError(error) && error.code === "ENOENT") return false;
96
- throw error;
97
- }
98
- },
99
- readText(filePath) {
100
- try {
101
- assertRegularFile(filePath);
102
- return readFileSync(filePath, "utf8");
103
- } catch (error) {
104
- if (isNodeError(error) && error.code === "ENOENT") return;
105
- throw error;
106
- }
107
- }
108
- };
109
- const resolveIn = (root, relPath) => nodePaths.resolve(root, relPath);
110
- const assertDirectory = (root) => {
111
- if (!statSync(root).isDirectory()) throw new UsageError("The lint target must be a directory.");
112
- };
113
- //#endregion
114
256
  //#region package.json
115
- var version$1 = "0.6.0";
257
+ var version$1 = "0.6.1";
116
258
  var engines = { "node": "^22.18.0 || ^24.0.0" };
117
259
  //#endregion
118
260
  //#region src/version.ts
@@ -122,14 +264,6 @@ const SUPPORTED_NODE_RANGE = engines.node;
122
264
  //#region src/adapters/node-version.ts
123
265
  const isSupportedNodeVersion = (version) => satisfies(version, SUPPORTED_NODE_RANGE);
124
266
  //#endregion
125
- //#region src/core/contracts/records.ts
126
- /** Configuration maps are ordinary objects or null-prototype dictionaries. */
127
- const isPlainRecord = (value) => {
128
- if (typeof value !== "object" || value === null) return false;
129
- const prototype = Object.getPrototypeOf(value);
130
- return prototype === Object.prototype || prototype === null;
131
- };
132
- //#endregion
133
267
  //#region src/core/contracts/pms.ts
134
268
  /**
135
269
  * Single source of truth for package managers and severities.
@@ -252,7 +386,7 @@ const getByPath = (config, keyPath) => {
252
386
  /** Accept only mapping roots while preserving unvalidated values inside them. */
253
387
  const toParsedConfig = (value) => {
254
388
  if (isPlainRecord(value)) return value;
255
- throw new TypeError("Config root must be a mapping.");
389
+ throw new ConfigParseError("Config root must be a mapping.");
256
390
  };
257
391
  //#endregion
258
392
  //#region src/core/contracts/config-file-ref.ts
@@ -1794,7 +1928,7 @@ const unpinnedInlineImports = (config) => {
1794
1928
  const scopes = getByPath(config, ["scopes"]);
1795
1929
  if (scopes != null) {
1796
1930
  if (!isPlainRecord(scopes)) throw new ConfigError("deno.json: scopes must be an object.");
1797
- for (const [scope, mapping] of Object.entries(scopes)) offenders.push(...collectUnpinnedImports(mapping, `scopes[${JSON.stringify(scope)}]`));
1931
+ for (const [scope, mapping] of Object.entries(scopes)) for (const offender of collectUnpinnedImports(mapping, `scopes[${JSON.stringify(scope)}]`)) offenders.push(offender);
1798
1932
  }
1799
1933
  return offenders;
1800
1934
  };
@@ -2401,16 +2535,7 @@ const runLint = (opts) => {
2401
2535
  version: opts.pmVersions?.[pm]
2402
2536
  }));
2403
2537
  }
2404
- const summary = {
2405
- error: 0,
2406
- info: 0,
2407
- warn: 0
2408
- };
2409
- for (const finding of findings) summary[finding.severity] += 1;
2410
- return {
2411
- findings,
2412
- summary
2413
- };
2538
+ return { findings };
2414
2539
  };
2415
2540
  //#endregion
2416
2541
  //#region src/core/contracts/deno-json.ts
@@ -2428,7 +2553,7 @@ const validateDenoMetadata = (config) => {
2428
2553
  //#region src/core/parse-config-file.ts
2429
2554
  const EMPTY_FILE = Object.freeze({});
2430
2555
  /** Create a lazy parser for one repository context; callers own its lifetime. */
2431
- const createConfigParser = (codecFor, ctx) => {
2556
+ const createConfigParser = (codecFor, ctx, maxDepth = DEFAULT_SCAN_LIMITS.maxConfigDepth) => {
2432
2557
  const cache = /* @__PURE__ */ new Map();
2433
2558
  return (file) => {
2434
2559
  if (file === void 0) return EMPTY_FILE;
@@ -2441,31 +2566,34 @@ const createConfigParser = (codecFor, ctx) => {
2441
2566
  return EMPTY_FILE;
2442
2567
  }
2443
2568
  const parsed = wrapCodecError(file.path, () => codecFor(file.kind).parse(text));
2569
+ checkConfigDepth(parsed, maxDepth);
2444
2570
  cache.set(cacheKey, parsed);
2445
2571
  return parsed;
2446
2572
  };
2447
2573
  };
2448
2574
  /** Pair the context with a fresh lazy parser; construction performs no reads. */
2449
- const createRepositoryEvaluation = (ctx, codecFor) => ({
2575
+ const createRepositoryEvaluation = (ctx, codecFor, maxDepth = DEFAULT_SCAN_LIMITS.maxConfigDepth) => ({
2450
2576
  ctx,
2451
- parseConfig: createConfigParser(codecFor, ctx)
2577
+ parseConfig: createConfigParser(codecFor, ctx, maxDepth)
2452
2578
  });
2453
2579
  //#endregion
2454
2580
  //#region src/core/discovery.ts
2455
2581
  /** One context per selected directory, shared by publication and installation passes. */
2456
- const discover = (cwd, fs, excluded, projectType, dependencies) => {
2457
- if (typeof fs.readDirectories !== "function") throw new ConfigError("FileSystem.readDirectories is required for package discovery; no native filesystem fallback is used.");
2582
+ const discover = (cwd, fs, excluded, projectType, dependencies, limits = DEFAULT_SCAN_LIMITS) => {
2458
2583
  const { paths, createRepoContext, codecFor } = dependencies;
2459
2584
  const pending = [asRelPath(".")];
2460
2585
  const directories = [];
2461
2586
  while (pending.length) {
2462
2587
  const directory = pending.pop();
2588
+ checkLimit("maxDirectories", directories.length + pending.length + 1, limits);
2589
+ checkLimit("maxDirectoryDepth", directory === "." ? 0 : directory.split("/").length, limits);
2463
2590
  const absolute = paths.resolve(cwd, directory);
2464
2591
  let repository;
2465
2592
  const manifests = [];
2466
2593
  try {
2467
- repository = createRepositoryEvaluation(createRepoContext(absolute, fs, projectType), codecFor);
2594
+ repository = createRepositoryEvaluation(createRepoContext(absolute, fs, projectType), codecFor, limits.maxConfigDepth);
2468
2595
  const { ctx, parseConfig } = repository;
2596
+ checkConfigDepth(ctx.packageJson, limits.maxConfigDepth);
2469
2597
  if (ctx.packageJson !== void 0) manifests.push("package.json");
2470
2598
  if (ctx.readText(CONFIG_FILES.denoJson.path) !== void 0) {
2471
2599
  validateDenoMetadata(parseConfig(CONFIG_FILES.denoJson));
@@ -2487,6 +2615,8 @@ const discover = (cwd, fs, excluded, projectType, dependencies) => {
2487
2615
  if (!Object.hasOwn(names, i)) throw new ConfigError(`${directory}: FileSystem.readDirectories must return a dense array.`);
2488
2616
  const child = paths.child(directory, names[i]);
2489
2617
  if (names[i] === ".git" || names[i] === "node_modules" || excluded(child) || children.has(child)) continue;
2618
+ checkLimit("maxDirectoryDepth", child.split("/").length, limits);
2619
+ checkLimit("maxDirectories", directories.length + pending.length + 1, limits);
2490
2620
  children.add(child);
2491
2621
  pending.push(child);
2492
2622
  }
@@ -2564,6 +2694,38 @@ const rebaseFinding = (directory, finding) => {
2564
2694
  };
2565
2695
  };
2566
2696
  //#endregion
2697
+ //#region src/core/bounded-file-system.ts
2698
+ /** Bounds returned data from trusted injected IO; cannot bound allocations inside that IO. */
2699
+ const boundedFileSystem = (fs, limits) => {
2700
+ const texts = /* @__PURE__ */ new Map();
2701
+ let bytes = 0;
2702
+ let entries = 0;
2703
+ return {
2704
+ exists: (path) => fs.exists(path),
2705
+ readText(path) {
2706
+ if (!texts.has(path)) {
2707
+ const text = fs.readText(path);
2708
+ if (text !== void 0) {
2709
+ const size = utf8Bytes(text);
2710
+ checkLimit("maxFileBytes", size, limits);
2711
+ bytes += size;
2712
+ checkLimit("maxTotalBytes", bytes, limits);
2713
+ }
2714
+ texts.set(path, text);
2715
+ }
2716
+ return texts.get(path);
2717
+ },
2718
+ readDirectories(path) {
2719
+ const names = fs.readDirectories(path);
2720
+ if (Array.isArray(names)) {
2721
+ entries += names.length;
2722
+ checkLimit("maxEntries", entries, limits);
2723
+ }
2724
+ return names;
2725
+ }
2726
+ };
2727
+ };
2728
+ //#endregion
2567
2729
  //#region src/core/lint.ts
2568
2730
  const validateLintOptions = (options, paths) => {
2569
2731
  if (!options || !paths.isAbsolute(options.cwd)) throw new UsageError("cwd must be an absolute filesystem path.");
@@ -2577,12 +2739,15 @@ const validateLintOptions = (options, paths) => {
2577
2739
  const prepareLint = (options, dependencies) => {
2578
2740
  validateLintOptions(options, dependencies.paths);
2579
2741
  const config = options.config === void 0 ? void 0 : parseConfig(options.config);
2580
- const fs = options.fs === void 0 ? dependencies.fileSystem : options.fs;
2742
+ const limits = resolveScanLimits(options.limits);
2743
+ const sourceFs = options.fs === void 0 ? dependencies.fileSystem : options.fs;
2744
+ if (typeof sourceFs.readDirectories !== "function") throw new ConfigError("FileSystem.readDirectories is required for package discovery; no native filesystem fallback is used.");
2745
+ const fs = boundedFileSystem(sourceFs, limits);
2581
2746
  const excluded = dependencies.compileExclusions(parseExcludes(options.exclude !== void 0 ? options.exclude : config?.exclude ?? []));
2582
2747
  const installations = parseInstallationRoots(options.installationRoots !== void 0 ? options.installationRoots : config?.installationRoots ?? ["."]);
2583
2748
  const configured = applyConfig(dependencies.rules, config);
2584
2749
  const needsCustomTarget = configured.rules.some((rule) => scopeOf(rule.id) === "custom");
2585
- const discovered = discover(options.cwd, fs, excluded, options.projectType ?? config?.projectType, dependencies);
2750
+ const discovered = discover(options.cwd, fs, excluded, options.projectType ?? config?.projectType, dependencies, limits);
2586
2751
  for (const entry of installations) if (!discovered.some((item) => item.directory === entry.path)) throw new UsageError(`${entry.path}: installation root must be an existing, selected ordinary directory using its exact enumerated spelling (no symlinks, excluded or hard-skipped paths).`);
2587
2752
  return {
2588
2753
  evaluation: {
@@ -2627,13 +2792,14 @@ const prepareLint = (options, dependencies) => {
2627
2792
  };
2628
2793
  }),
2629
2794
  ruleSet: configured.rules,
2630
- severityOverrides: configured.severityOverrides
2795
+ severityOverrides: configured.severityOverrides,
2796
+ limits
2631
2797
  },
2632
2798
  reporters: config?.reporters ?? []
2633
2799
  };
2634
2800
  };
2635
2801
  const runPreparedLint = (evaluation) => {
2636
- const { directories, ruleSet, severityOverrides } = evaluation;
2802
+ const { directories, ruleSet, severityOverrides, limits } = evaluation;
2637
2803
  const findings = [];
2638
2804
  const manifests = [];
2639
2805
  const installationRoots = [];
@@ -2676,7 +2842,8 @@ const runPreparedLint = (evaluation) => {
2676
2842
  throw error;
2677
2843
  }
2678
2844
  local.sort((a, b) => (ruleOrder.get(a.ruleId) ?? 0) - (ruleOrder.get(b.ruleId) ?? 0));
2679
- findings.push(...local.map((finding) => rebaseFinding(directory, finding)));
2845
+ checkLimit("maxFindings", findings.length + local.length, limits);
2846
+ for (const finding of local) findings.push(rebaseFinding(directory, finding));
2680
2847
  }
2681
2848
  const summary = {
2682
2849
  error: 0,
@@ -2739,6 +2906,7 @@ const lintCommand$1 = async (options, io, dependencies, reporters) => {
2739
2906
  const result = runPreparedLint(prepared.evaluation);
2740
2907
  const exitCode = exitCodeForLint(result, options.severity ?? "error");
2741
2908
  const writes = [];
2909
+ const consumeOutput = outputBudget(prepared.evaluation.limits.maxOutputBytes);
2742
2910
  let outputFailed = false;
2743
2911
  let outputFailure;
2744
2912
  const recordFailure = (error) => {
@@ -2749,6 +2917,7 @@ const lintCommand$1 = async (options, io, dependencies, reporters) => {
2749
2917
  };
2750
2918
  const track = (write) => (line) => {
2751
2919
  try {
2920
+ consumeOutput(`${line}\n`);
2752
2921
  const written = write(line);
2753
2922
  writes.push(Promise.resolve(written).then(() => {}, recordFailure));
2754
2923
  return written;
@@ -2761,7 +2930,10 @@ const lintCommand$1 = async (options, io, dependencies, reporters) => {
2761
2930
  await reporter.format(filterBySeverity(result, options.severity ?? "info"), {
2762
2931
  stdout: track((line) => io.stdout(line)),
2763
2932
  stderr: track((line) => io.stderr(line))
2764
- }, { cwd: options.cwd });
2933
+ }, {
2934
+ cwd: options.cwd,
2935
+ limits: prepared.evaluation.limits
2936
+ });
2765
2937
  } finally {
2766
2938
  await Promise.all(writes);
2767
2939
  }
@@ -2794,32 +2966,29 @@ const parsePackageJson = (value) => {
2794
2966
  throw new ConfigError(`package.json: ${field ? `${field}: ` : ""}${issue.message}`);
2795
2967
  };
2796
2968
  //#endregion
2797
- //#region src/adapters/repo-context.ts
2798
- const tryParseJson = (text) => {
2969
+ //#region src/adapters/codecs/json.ts
2970
+ /** Strict JSON; runtime SyntaxError messages may disclose arbitrary source bytes. */
2971
+ const parseJson = (text, maxDepth = DEFAULT_SCAN_LIMITS.maxConfigDepth) => {
2972
+ let value;
2799
2973
  try {
2800
- return JSON.parse(text);
2801
- } catch (error) {
2802
- let errMsg = String(error);
2803
- if (error instanceof Error) errMsg = error.message;
2804
- throw new ConfigError(`package.json: invalid JSON — ${errMsg}`);
2974
+ value = JSON.parse(text.trim());
2975
+ } catch {
2976
+ throw new ConfigParseError("invalid JSON");
2805
2977
  }
2978
+ checkConfigDepth(value, maxDepth);
2979
+ return value;
2806
2980
  };
2807
- const readPackageJson = (raw) => {
2808
- return parsePackageJson(tryParseJson(raw.trim()));
2809
- };
2810
- const createRepoContext = (root, fs = nodeFileSystem, projectType) => {
2811
- if (fs === nodeFileSystem) assertDirectory(root);
2812
- const packageFile = resolveIn(root, asRelPath("package.json"));
2813
- const raw = fs.readText(packageFile);
2814
- const texts = new Map([[packageFile, raw]]);
2815
- const readText = (relPath) => {
2816
- const file = resolveIn(root, relPath);
2817
- if (!texts.has(file)) texts.set(file, fs.readText(file));
2818
- return texts.get(file);
2819
- };
2820
- const exists = (relPath) => fs.exists(resolveIn(root, relPath));
2821
- let packageJson = void 0;
2822
- if (typeof raw !== "undefined") packageJson = readPackageJson(raw);
2981
+ const jsonCodec = { parse(text) {
2982
+ return toParsedConfig(parseJson(text));
2983
+ } };
2984
+ //#endregion
2985
+ //#region src/adapters/repo-context.ts
2986
+ /** Read caching and budgets belong to the scan's supplied filesystem. */
2987
+ const createRepoContext = (root, fs, projectType, maxDepth = DEFAULT_SCAN_LIMITS.maxConfigDepth) => {
2988
+ const readText = (relative) => fs.readText(nodePaths.resolve(root, relative));
2989
+ const raw = readText(asRelPath("package.json"));
2990
+ const packageJson = raw === void 0 ? void 0 : parsePackageJson(wrapCodecError("package.json", () => parseJson(raw, maxDepth)));
2991
+ const exists = (relative) => fs.exists(nodePaths.resolve(root, relative));
2823
2992
  return {
2824
2993
  exists,
2825
2994
  packageJson,
@@ -2842,37 +3011,71 @@ const coerce = (raw) => {
2842
3011
  if (typeof raw !== "string") return raw;
2843
3012
  return coerceBoolean(raw) ?? coerceInteger(raw) ?? raw;
2844
3013
  };
3014
+ /**
3015
+ * Coerces unquoted scalars on read so rule checks can match by typed
3016
+ * value (`true === true`, `7 === 7`). Strings whose lexeme matches
3017
+ * `true`/`false`/`-?\d+` lose their string-ness — this is by design for
3018
+ * the npm schema, where rule values are booleans / integers / the empty
3019
+ * string. Don't model a customRule around a string like `"0"` on this
3020
+ * codec; pick yaml/json if you need lossless arbitrary-string reads.
3021
+ */
3022
+ const iniCodec = { parse(text) {
3023
+ const raw = toParsedConfig(ini.parse(text));
3024
+ const config = {};
3025
+ for (const [key, value] of Object.entries(raw)) if (Array.isArray(value)) config[key] = value.map((elem) => coerce(elem));
3026
+ else if (typeof value === "object" && value !== null) config[key] = toParsedConfig(value);
3027
+ else config[key] = coerce(value);
3028
+ return config;
3029
+ } };
3030
+ //#endregion
3031
+ //#region src/adapters/codecs/toml.ts
3032
+ const tomlCodec = { parse(text) {
3033
+ if (text.trim() === "") return {};
3034
+ const input = text.startsWith("") ? text.slice(1) : text;
3035
+ let value;
3036
+ try {
3037
+ value = parse$1(input);
3038
+ } catch {
3039
+ throw new ConfigParseError("Invalid TOML syntax.");
3040
+ }
3041
+ return toParsedConfig(value);
3042
+ } };
2845
3043
  //#endregion
2846
- //#region src/adapters/codecs/store.ts
2847
- const CODECS = {
2848
- json: { parse(text) {
2849
- const trimmed = text.trim();
2850
- return toParsedConfig(JSON.parse(trimmed));
2851
- } },
2852
- npmrc: { parse(text) {
2853
- const raw = toParsedConfig(ini.parse(text));
2854
- const config = {};
2855
- for (const [key, value] of Object.entries(raw)) if (Array.isArray(value)) config[key] = value.map((elem) => coerce(elem));
2856
- else if (typeof value === "object" && value !== null) config[key] = toParsedConfig(value);
2857
- else config[key] = coerce(value);
2858
- return config;
2859
- } },
2860
- toml: { parse(text) {
2861
- if (text.trim() === "") return {};
2862
- return toParsedConfig(parse$1(text.startsWith("") ? text.slice(1) : text));
2863
- } },
2864
- yaml: { parse(text) {
2865
- if (text.trim() === "") return {};
2866
- const document = parseDocument(text);
2867
- if (document.errors.length > 0) throw document.errors[0];
3044
+ //#region src/adapters/codecs/yaml.ts
3045
+ const parseYaml = (text, maxDepth = DEFAULT_SCAN_LIMITS.maxConfigDepth) => {
3046
+ if (text.trim() === "") return {};
3047
+ let value;
3048
+ try {
3049
+ const document = parseDocument(text, { prettyErrors: false });
3050
+ if (document.errors.length) throw new ConfigParseError("Invalid YAML syntax.");
2868
3051
  if (document.contents === null) return {};
2869
- return toParsedConfig(document.toJS({ onAnchor: (value, count) => {
2870
- if (count > 1) JSON.stringify(value);
2871
- } }));
2872
- } }
3052
+ value = document.toJS();
3053
+ } catch (error) {
3054
+ if (error instanceof ConfigParseError) throw error;
3055
+ if (error instanceof Error && error.message.includes("Excessive alias count")) throw new ConfigParseError("Excessive alias count in YAML.");
3056
+ throw new ConfigParseError("Invalid YAML.");
3057
+ }
3058
+ try {
3059
+ checkConfigDepth(value, maxDepth);
3060
+ } catch (error) {
3061
+ if (error instanceof ConfigError) throw error;
3062
+ throw new ConfigParseError("Invalid YAML structure.");
3063
+ }
3064
+ return toParsedConfig(value);
2873
3065
  };
2874
- /** Look up the codec for a parseable kind. Total — every CodecKind has one. */
2875
- const codecFor = (kind) => CODECS[kind];
3066
+ //#endregion
3067
+ //#region src/adapters/codecs/store.ts
3068
+ const CODECS = {
3069
+ json: jsonCodec,
3070
+ npmrc: iniCodec,
3071
+ toml: tomlCodec,
3072
+ yaml: { parse: (text) => parseYaml(text) }
3073
+ };
3074
+ const createCodecFor = (limits) => (kind) => ({ parse(text) {
3075
+ const parsed = kind === "json" ? toParsedConfig(parseJson(text, limits.maxConfigDepth)) : kind === "yaml" ? parseYaml(text, limits.maxConfigDepth) : CODECS[kind].parse(text);
3076
+ checkConfigDepth(parsed, limits.maxConfigDepth);
3077
+ return parsed;
3078
+ } });
2876
3079
  //#endregion
2877
3080
  //#region src/adapters/reporters/github.ts
2878
3081
  const COMMAND = {
@@ -2885,12 +3088,15 @@ const escapeProp = (raw) => escapeData(raw).replaceAll(":", "%3A").replaceAll(",
2885
3088
  /** Emit GitHub Actions workflow commands (annotations on PRs). */
2886
3089
  const githubReporter = {
2887
3090
  async format(result, io, context) {
3091
+ const consume = outputBudget(context.limits?.maxOutputBytes ?? DEFAULT_SCAN_LIMITS.maxOutputBytes);
2888
3092
  for (const finding of result.findings) {
2889
3093
  let file = "";
2890
3094
  if (finding.file) file = `file=${escapeProp(path.resolve(context.cwd, finding.file))},`;
2891
3095
  let body = `[${finding.pm ?? "package"}] ${finding.directory}: ${finding.message}`;
2892
3096
  if (finding.docs) body += ` (${finding.docs})`;
2893
- await io.stdout(`::${COMMAND[finding.severity]} ${file}title=${escapeProp(finding.ruleId)}::${escapeData(body)}`);
3097
+ const line = `::${COMMAND[finding.severity]} ${file}title=${escapeProp(finding.ruleId)}::${escapeData(body)}`;
3098
+ consume(`${line}\n`);
3099
+ await io.stdout(line);
2894
3100
  }
2895
3101
  },
2896
3102
  name: "github"
@@ -2902,6 +3108,66 @@ const safeText = (text) => text.replace(/[\u0000-\u001f\u007f-\u009f\u061c\u200e
2902
3108
  /** JSON syntax and decoded values stay identical; CI must not parse legacy commands in it. */
2903
3109
  const safeJsonText = (json) => json.replace(/[\u007f-\u009f\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/gu, (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`).replaceAll("##[", "\\u0023\\u0023[");
2904
3110
  //#endregion
3111
+ //#region src/adapters/reporters/bounded-json.ts
3112
+ const omitted = (value) => value === void 0 || typeof value === "function" || typeof value === "symbol";
3113
+ /** Serialize incrementally into bounded chunks before allocating the final JSON document.
3114
+ * JSON primitives use the native encoder; containers preserve its own-key/array/toJSON semantics.
3115
+ */
3116
+ const boundedJson = (input, maxBytes, maxDepth) => {
3117
+ const consume = outputBudget(maxBytes);
3118
+ const chunks = [];
3119
+ const active = /* @__PURE__ */ new Set();
3120
+ const emit = (text) => {
3121
+ const escaped = safeJsonText(text);
3122
+ consume(escaped);
3123
+ chunks.push(escaped);
3124
+ };
3125
+ const normalize = (value, key) => {
3126
+ if (value !== null && (typeof value === "object" || typeof value === "function" || typeof value === "bigint")) {
3127
+ const toJSON = value.toJSON;
3128
+ if (typeof toJSON === "function") value = Reflect.apply(toJSON, value, [key]);
3129
+ }
3130
+ if (types.isNumberObject(value)) return +value;
3131
+ if (types.isStringObject(value)) return `${value}`;
3132
+ if (types.isBooleanObject(value)) return Boolean.prototype.valueOf.call(value);
3133
+ if (types.isBigIntObject(value)) return BigInt.prototype.valueOf.call(value);
3134
+ return value;
3135
+ };
3136
+ const serialize = (value, depth) => {
3137
+ if (typeof value === "bigint") throw new TypeError("Do not know how to serialize a BigInt");
3138
+ if (value === null || typeof value !== "object") {
3139
+ emit(JSON.stringify(value) ?? "null");
3140
+ return;
3141
+ }
3142
+ if (active.has(value)) throw new TypeError("Cannot serialize a circular report.");
3143
+ if (depth > maxDepth) throw new TypeError("Report nesting exceeds the configured depth.");
3144
+ active.add(value);
3145
+ const array = Array.isArray(value);
3146
+ emit(array ? "[" : "{");
3147
+ let count = 0;
3148
+ const entry = (key, raw) => {
3149
+ const child = normalize(raw, key);
3150
+ if (!array && omitted(child)) return;
3151
+ emit(`${count++ === 0 ? "\n" : ",\n"}${" ".repeat(depth)}`);
3152
+ if (!array) {
3153
+ emit(JSON.stringify(key));
3154
+ emit(": ");
3155
+ }
3156
+ serialize(omitted(child) ? null : child, depth + 1);
3157
+ };
3158
+ if (array) {
3159
+ const length = value.length;
3160
+ for (let index = 0; index < length; index++) entry(String(index), value[index]);
3161
+ } else for (const key of Object.keys(value)) entry(key, value[key]);
3162
+ if (count) emit(`\n${" ".repeat(depth - 1)}`);
3163
+ emit(array ? "]" : "}");
3164
+ active.delete(value);
3165
+ };
3166
+ serialize(normalize(input, ""), 1);
3167
+ consume("\n");
3168
+ return chunks.join("");
3169
+ };
3170
+ //#endregion
2905
3171
  //#region src/adapters/reporters/json.ts
2906
3172
  /**
2907
3173
  * Versioned machine-readable output — the public contract consumed by
@@ -2910,14 +3176,14 @@ const safeJsonText = (json) => json.replace(/[\u007f-\u009f\u061c\u200e\u200f\u2
2910
3176
  */
2911
3177
  const SCHEMA_VERSION = 3;
2912
3178
  const jsonReporter = {
2913
- async format(result, io) {
2914
- await io.stdout(safeJsonText(JSON.stringify({
3179
+ async format(result, io, context) {
3180
+ await io.stdout(boundedJson({
2915
3181
  schemaVersion: SCHEMA_VERSION,
2916
3182
  siroVersion: version,
2917
3183
  findings: result.findings,
2918
3184
  summary: result.summary,
2919
3185
  inspection: result.inspection
2920
- }, void 0, 2)));
3186
+ }, context.limits?.maxOutputBytes ?? DEFAULT_SCAN_LIMITS.maxOutputBytes, (context.limits?.maxConfigDepth ?? DEFAULT_SCAN_LIMITS.maxConfigDepth) + 16));
2921
3187
  },
2922
3188
  name: "json"
2923
3189
  };
@@ -2963,9 +3229,11 @@ const buildRenderCtx = (io) => {
2963
3229
  };
2964
3230
  };
2965
3231
  const prettyReporter = {
2966
- async format(result, io) {
3232
+ async format(result, io, context) {
2967
3233
  const lines = [];
3234
+ const consume = outputBudget(context.limits?.maxOutputBytes ?? DEFAULT_SCAN_LIMITS.maxOutputBytes);
2968
3235
  const collect = (line) => {
3236
+ consume(`${line}\n`);
2969
3237
  lines.push(line);
2970
3238
  };
2971
3239
  const ctx = buildRenderCtx({
@@ -2998,20 +3266,36 @@ const JSON_REPORTER_NAME = jsonReporter.name;
2998
3266
  const BUILTIN_REPORTER_NAMES = BUILTINS.map((rep) => rep.name);
2999
3267
  /** Later registrations replace earlier reporters with the same name. */
3000
3268
  const createRegistry = (extras = []) => new Map([...BUILTINS, ...extras].map((reporter) => [reporter.name, reporter]));
3001
- const dependencies = {
3002
- rules: createBuiltinRules({
3003
- now: () => Date.now(),
3004
- parse: (value) => Date.parse(value)
3005
- }),
3006
- fileSystem: nodeFileSystem,
3007
- paths: nodePaths,
3008
- createRepoContext,
3009
- codecFor,
3010
- compileExclusions
3269
+ //#endregion
3270
+ //#region src/runtime.ts
3271
+ /** Read time at evaluation, never at module initialization. */
3272
+ const rules = createBuiltinRules({
3273
+ now: () => Date.now(),
3274
+ parse: (value) => Date.parse(value)
3275
+ });
3276
+ const dependenciesFor = (options) => {
3277
+ if (!options || !nodePaths.isAbsolute(options.cwd)) throw new UsageError("cwd must be an absolute filesystem path.");
3278
+ const limits = resolveScanLimits(options.limits);
3279
+ if (options.rejectSymlinks !== void 0 && typeof options.rejectSymlinks !== "boolean") throw new UsageError("rejectSymlinks must be a boolean.");
3280
+ const native = options.fs === void 0 || options.fs === nodeFileSystem;
3281
+ if (options.rejectSymlinks && !native) throw new UsageError("rejectSymlinks requires the native filesystem; an injected FileSystem is trusted code.");
3282
+ if (native) assertDirectory(options.cwd);
3283
+ return {
3284
+ rules,
3285
+ fileSystem: native ? createNodeFileSystem(limits, options.rejectSymlinks ? options.cwd : void 0) : options.fs,
3286
+ paths: nodePaths,
3287
+ createRepoContext: (root, fs, projectType) => createRepoContext(root, fs, projectType, limits.maxConfigDepth),
3288
+ codecFor: createCodecFor(limits),
3289
+ compileExclusions
3290
+ };
3011
3291
  };
3292
+ const nativeOptions = (options) => options?.fs === nodeFileSystem ? {
3293
+ ...options,
3294
+ fs: void 0
3295
+ } : options;
3012
3296
  /** Public Node API: callers can replace the filesystem without assembling the application. */
3013
- const lint = (options) => lint$1(options, dependencies);
3014
- const lintCommand = (options, io) => lintCommand$1(options, io, dependencies, {
3297
+ const lint = (options) => lint$1(nativeOptions(options), dependenciesFor(options));
3298
+ const lintCommand = async (options, io) => lintCommand$1(nativeOptions(options), io, dependenciesFor(options), {
3015
3299
  defaultName: DEFAULT_REPORTER_NAME,
3016
3300
  createRegistry
3017
3301
  });
@@ -3058,6 +3342,6 @@ const createNodeIO = (stdout, stderr) => ({
3058
3342
  });
3059
3343
  const nodeIO = createNodeIO(process.stdout, process.stderr);
3060
3344
  //#endregion
3061
- export { asRelPath as A, isSupportedNodeVersion as C, nodeFileSystem as D, assertDirectory as E, SiroError as M, UsageError as N, isNodeError as O, isSeverity as S, version as T, PROJECT_TYPES as _, DEFAULT_REPORTER_NAME as a, SEVERITIES as b, jsonReporter as c, overrideBindings as d, requireConfigKey as f, getByPath as g, defineRule as h, BUILTIN_REPORTER_NAMES as i, ConfigError as j, asAbsPath as k, safeText as l, loadConfig as m, lint as n, JSON_REPORTER_NAME as o, CONFIG_FILES as p, lintCommand as r, prettyReporter as s, nodeIO as t, githubReporter as u, isProjectType as v, SUPPORTED_NODE_RANGE as w, isPM as x, PMS as y };
3345
+ export { nodeFileSystem as A, isSeverity as C, asAbsPath as D, version as E, SiroError as F, UsageError as I, DEFAULT_SCAN_LIMITS as M, resolveScanLimits as N, asRelPath as O, ConfigError as P, isPM as S, SUPPORTED_NODE_RANGE as T, PROJECT_TYPES as _, DEFAULT_REPORTER_NAME as a, PMS as b, jsonReporter as c, overrideBindings as d, requireConfigKey as f, getByPath as g, defineRule as h, BUILTIN_REPORTER_NAMES as i, isNodeError as j, assertDirectory as k, safeText as l, loadConfig as m, lint as n, JSON_REPORTER_NAME as o, CONFIG_FILES as p, lintCommand as r, prettyReporter as s, nodeIO as t, githubReporter as u, isProjectType as v, isSupportedNodeVersion as w, SEVERITIES as x, isStableVersion as y };
3062
3346
 
3063
- //# sourceMappingURL=node-io-CAszdmko.mjs.map
3347
+ //# sourceMappingURL=node-io-BpAHyeBG.mjs.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pho9ubenaa/siro",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Security best-practices linter for the npm ecosystem (npm, pnpm, deno, bun, yarn, aube).",
5
5
  "keywords": [
6
6
  "aube",