@pho9ubenaa/siro 0.5.1 → 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.
@@ -1,16 +1,17 @@
1
+ import { closeSync, constants, fstatSync, lstatSync, openSync, opendirSync, readSync, realpathSync, statSync } from "node:fs";
1
2
  import path from "node:path";
2
- import { lstatSync, 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";
6
6
  import { lt, parse } from "semver";
7
7
  import valid from "semver/functions/valid.js";
8
- import { GLOBSTAR, Minimatch, braceExpand } from "minimatch";
8
+ 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
- //#region src/shared/errors.ts
14
+ //#region src/core/contracts/errors.ts
14
15
  var SiroError = class extends Error {
15
16
  exitCode;
16
17
  constructor(message, exitCode) {
@@ -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,13 +44,188 @@ 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
50
- //#region src/shared/paths.ts
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
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) => {
53
231
  if (!isRelPath(value)) throw new TypeError("Expected a repository-relative path without parent traversal.");
@@ -62,66 +240,21 @@ const asAbsPath = (value) => {
62
240
  };
63
241
  const nodePaths = {
64
242
  isAbsolute: isAbsPath,
65
- resolve: (root, relative) => asAbsPath(path.join(root, asRelPath(relative))),
66
- normalizePattern: (pattern) => path.posix.normalize(pattern)
67
- };
68
- //#endregion
69
- //#region src/adapters/node-errors.ts
70
- /** Only the Error and code properties are established by this guard. */
71
- const isNodeError = (value) => value instanceof Error && "code" in value && typeof value.code === "string";
72
- //#endregion
73
- //#region src/adapters/node-file-system.ts
74
- const assertRegularFile = (filePath) => {
75
- if (!statSync(filePath).isFile()) throw new ConfigError(`${filePath}: expected a regular file.`);
76
- };
77
- const nodeFileSystem = {
78
- readDirectories(directory) {
79
- return readdirSync(directory, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
243
+ resolve(root, relative) {
244
+ if (typeof relative !== "string" || relative.includes("\0") || path.isAbsolute(relative) || relative.split(path.sep === "\\" ? /[\\/]/u : /\//u).includes("..")) throw new ConfigError(`Invalid repository-relative path: ${String(relative)}`);
245
+ const resolved = path.join(root, relative);
246
+ const within = path.relative(root, resolved);
247
+ if (within === ".." || within.startsWith(`..${path.sep}`) || path.isAbsolute(within)) throw new ConfigError("Path escapes repository root.");
248
+ return asAbsPath(resolved);
80
249
  },
81
- resolveDirectory(parent, name) {
82
- let target;
83
- try {
84
- target = lstatSync(path.join(parent, name), { bigint: true });
85
- } catch (error) {
86
- if (isNodeError(error) && error.code === "ENOENT") return void 0;
87
- throw error;
88
- }
89
- if (!target.isDirectory()) return void 0;
90
- const names = readdirSync(parent, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
91
- if (names.includes(name)) return name;
92
- const matches = names.filter((candidate) => {
93
- const entry = lstatSync(path.join(parent, candidate), { bigint: true });
94
- return entry.isDirectory() && entry.dev === target.dev && entry.ino === target.ino;
95
- });
96
- if (target.ino === 0n || matches.length !== 1) throw new ConfigError(`${parent}: cannot identify the resolved workspace directory ${name}.`);
97
- return matches[0];
98
- },
99
- exists(filePath) {
100
- try {
101
- assertRegularFile(filePath);
102
- return true;
103
- } catch (error) {
104
- if (isNodeError(error) && error.code === "ENOENT") return false;
105
- throw error;
106
- }
107
- },
108
- readText(filePath) {
109
- try {
110
- assertRegularFile(filePath);
111
- return readFileSync(filePath, "utf8");
112
- } catch (error) {
113
- if (isNodeError(error) && error.code === "ENOENT") return;
114
- throw error;
115
- }
250
+ child(parent, name) {
251
+ if (typeof name !== "string" || !name || name === "." || name === ".." || name.includes("\0") || name.includes("/") || path.sep === "\\" && (name.includes("\\") || name.includes(":"))) throw new ConfigError(`FileSystem.readDirectories returned an invalid child directory name: ${String(name)}`);
252
+ return parent === "." ? name : `${parent}/${name}`;
116
253
  }
117
254
  };
118
- const resolveIn = (root, relPath) => nodePaths.resolve(root, relPath);
119
- const assertDirectory = (root) => {
120
- if (!statSync(root).isDirectory()) throw new UsageError("The lint target must be a directory.");
121
- };
122
255
  //#endregion
123
256
  //#region package.json
124
- var version$1 = "0.5.1";
257
+ var version$1 = "0.6.1";
125
258
  var engines = { "node": "^22.18.0 || ^24.0.0" };
126
259
  //#endregion
127
260
  //#region src/version.ts
@@ -131,11 +264,12 @@ const SUPPORTED_NODE_RANGE = engines.node;
131
264
  //#region src/adapters/node-version.ts
132
265
  const isSupportedNodeVersion = (version) => satisfies(version, SUPPORTED_NODE_RANGE);
133
266
  //#endregion
134
- //#region src/domain/entities/pms.ts
267
+ //#region src/core/contracts/pms.ts
135
268
  /**
136
269
  * Single source of truth for package managers and severities.
137
- * Types, runtime sets, and ordering are all derived from these tuples — add a
138
- * package manager here and the rest of the codebase follows.
270
+ * Types, runtime sets, and ordering derive from these tuples. Adding a manager
271
+ * still requires local detection signals, applicable rule bindings and verified
272
+ * policy/availability evidence; the tuple alone does not implement support.
139
273
  */
140
274
  const PMS = [
141
275
  "npm",
@@ -171,24 +305,68 @@ const parsePackageManagerField = (value) => {
171
305
  if (typeof name !== "undefined" && isPM(name)) return name;
172
306
  };
173
307
  //#endregion
174
- //#region src/domain/entities/project-type.ts
175
- const PROJECT_TYPES = ["application", "package"];
176
- const isProjectType = (value) => PROJECT_TYPES.some((projectType) => projectType === value);
308
+ //#region src/core/pm-versions.ts
309
+ /** Release history establishes stable versions, not ranges or prerelease builds. */
310
+ const isStableVersion = (value) => {
311
+ if (typeof value !== "string" || !/^\d/u.test(value) || value.trim() !== value) return false;
312
+ const version = parse(value);
313
+ return version !== null && version.prerelease.length === 0;
314
+ };
315
+ /** Keep name-only detection independent from whether the declaration pins a version. */
316
+ const declaredPMVersion = (declaration) => {
317
+ const [pm, version, ...extra] = declaration?.trim().split("@") ?? [];
318
+ return pm && isPM(pm) && isStableVersion(version) && extra.length === 0 ? { [pm]: version } : {};
319
+ };
177
320
  //#endregion
178
- //#region src/shared/records.ts
179
- /** Configuration maps are ordinary objects or null-prototype dictionaries. */
180
- const isPlainRecord = (value) => {
181
- if (typeof value !== "object" || value === null) return false;
182
- const prototype = Object.getPrototypeOf(value);
183
- return prototype === Object.prototype || prototype === null;
321
+ //#region src/core/inspection-options.ts
322
+ const denseArray = (value, name) => {
323
+ if (!Array.isArray(value)) throw new ConfigError(`${name} must be a dense array.`);
324
+ for (let index = 0; index < value.length; index += 1) if (!Object.hasOwn(value, index)) throw new ConfigError(`${name} must be a dense array.`);
325
+ return value;
326
+ };
327
+ const relativeInput = (value, name) => {
328
+ if (typeof value !== "string" || !value || value.includes("\0") || name === "exclude" && (value.includes("\\") || /^[a-z]:/iu.test(value)) || /^[\\/]/u.test(value) || /^[a-z]:[\\/]/iu.test(value) || value.split("/").includes("..")) throw new ConfigError(`${name}: expected a nonempty cwd-relative /-separated path without parent traversal.`);
329
+ return value.split("/").filter((part) => part && part !== ".").join("/") || ".";
330
+ };
331
+ const parseExcludes = (value) => [...new Set(denseArray(value, "exclude").map((input) => {
332
+ const pattern = relativeInput(input, "exclude");
333
+ if (pattern === "." || pattern.startsWith("!")) throw new ConfigError("exclude cannot exclude cwd (.) or re-include with leading !.");
334
+ return pattern;
335
+ }))];
336
+ const parseInstallationRoots = (value) => {
337
+ const roots = /* @__PURE__ */ new Map();
338
+ for (const input of denseArray(value, "installationRoots")) {
339
+ if (typeof input !== "string" && (!isPlainRecord(input) || Object.keys(input).some((key) => ![
340
+ "path",
341
+ "pm",
342
+ "pmVersion"
343
+ ].includes(key)))) throw new ConfigError("installationRoots entries must be paths or { path, pm?, pmVersion? }.");
344
+ const entry = typeof input === "string" ? { path: input } : input;
345
+ const path = relativeInput(entry.path, "installationRoots");
346
+ if (entry.pm !== void 0 && (typeof entry.pm !== "string" || !isPM(entry.pm)) || entry.pmVersion !== void 0 && (!entry.pm || typeof entry.pmVersion !== "string" || !isStableVersion(entry.pmVersion))) throw new ConfigError(`${path}: pmVersion requires pm and an exact stable version; pm must be a supported manager.`);
347
+ if (path === "." && (entry.pm !== void 0 || entry.pmVersion !== void 0)) throw new ConfigError("Use top-level pm / pmVersion / pms / pmVersions for installation root \".\".");
348
+ const root = {
349
+ path,
350
+ pm: entry.pm,
351
+ pmVersion: entry.pmVersion
352
+ };
353
+ const previous = roots.get(path);
354
+ if (previous && (previous.pm !== root.pm || previous.pmVersion !== root.pmVersion)) throw new ConfigError(`${path}: conflicting installationRoots entries.`);
355
+ roots.set(path, root);
356
+ }
357
+ return [...roots.values()];
184
358
  };
185
359
  //#endregion
186
- //#region src/domain/ports/reporter.ts
360
+ //#region src/core/contracts/project-type.ts
361
+ const PROJECT_TYPES = ["application", "package"];
362
+ const isProjectType = (value) => PROJECT_TYPES.some((projectType) => projectType === value);
363
+ //#endregion
364
+ //#region src/core/contracts/reporter.ts
187
365
  const isReporterShape = (value) => {
188
366
  return isPlainRecord(value) && typeof value.name === "string" && typeof value.format === "function";
189
367
  };
190
368
  //#endregion
191
- //#region src/domain/entities/config-value.ts
369
+ //#region src/core/contracts/config-value.ts
192
370
  const CODEC_KINDS = [
193
371
  "json",
194
372
  "npmrc",
@@ -208,17 +386,17 @@ const getByPath = (config, keyPath) => {
208
386
  /** Accept only mapping roots while preserving unvalidated values inside them. */
209
387
  const toParsedConfig = (value) => {
210
388
  if (isPlainRecord(value)) return value;
211
- throw new TypeError("Config root must be a mapping.");
389
+ throw new ConfigParseError("Config root must be a mapping.");
212
390
  };
213
391
  //#endregion
214
- //#region src/domain/entities/config-file-ref.ts
392
+ //#region src/core/contracts/config-file-ref.ts
215
393
  const CONFIG_FILE_KINDS = new Set(CODEC_KINDS);
216
394
  const isConfigFileRefShape = (value) => {
217
395
  if (!isPlainRecord(value) || typeof value.kind !== "string" || !isRelPath(value.path)) return false;
218
396
  return CONFIG_FILE_KINDS.has(value.kind);
219
397
  };
220
398
  //#endregion
221
- //#region src/domain/entities/rule.ts
399
+ //#region src/core/contracts/rule.ts
222
400
  const defineRule = (rule) => rule;
223
401
  const isOptionalString = (value) => typeof value === "undefined" || typeof value === "string";
224
402
  const isConfigValueShape = (value) => typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value);
@@ -252,22 +430,26 @@ const isRuleShape = (value) => {
252
430
  return typeof value.id === "string" && typeof value.title === "string" && typeof value.description === "string" && typeof value.severity === "string" && isSeverity(value.severity) && isOptionalString(value.docs) && isProjectTypesShape(value.projectTypes) && isBindingsShape(value.bindings);
253
431
  };
254
432
  //#endregion
255
- //#region src/domain/services/pm-versions.ts
256
- /** Release history establishes stable versions, not ranges or prerelease builds. */
257
- const isStableVersion = (value) => {
258
- if (typeof value !== "string" || !/^\d/u.test(value) || value.trim() !== value) return false;
259
- const version = parse(value);
260
- return version !== null && version.prerelease.length === 0;
261
- };
262
- /** Keep name-only detection independent from whether the declaration pins a version. */
263
- const declaredPMVersion = (declaration) => {
264
- const [pm, version, ...extra] = declaration?.trim().split("@") ?? [];
265
- return pm && isPM(pm) && isStableVersion(version) && extra.length === 0 ? { [pm]: version } : {};
433
+ //#region src/core/contracts/synchronous.ts
434
+ /** Reject only the submitted async value, without a process-wide rejection handler. */
435
+ const assertSynchronous = (value, label) => {
436
+ if (value !== null && (typeof value === "object" || typeof value === "function") && "then" in value && typeof value.then === "function") {
437
+ Promise.resolve(value).catch(() => {});
438
+ throw new ConfigError(`${label} must be synchronous; Promise/thenable values are not supported.`);
439
+ }
266
440
  };
267
441
  //#endregion
268
- //#region src/domain/services/parse-siro-config.ts
442
+ //#region src/core/parse-siro-config.ts
269
443
  const RuleSettingSchema = vb.union([vb.picklist(SEVERITIES), vb.literal("off")]);
270
444
  const ConfigSchema = vb.strictObject({
445
+ exclude: vb.optional(vb.custom((value) => {
446
+ parseExcludes(value);
447
+ return true;
448
+ })),
449
+ installationRoots: vb.optional(vb.custom((value) => {
450
+ parseInstallationRoots(value);
451
+ return true;
452
+ })),
271
453
  customRules: vb.optional(vb.array(vb.custom(isRuleShape, "must be a structurally valid rule"))),
272
454
  pms: vb.optional(vb.pipe(vb.array(vb.picklist(PMS)), vb.minLength(1, "must not be empty (omit the key to auto-detect, or list at least one PM)"))),
273
455
  projectType: vb.optional(vb.picklist(PROJECT_TYPES)),
@@ -298,13 +480,14 @@ const formatIssues = (issues) => issues.map((issue) => {
298
480
  return issue.message;
299
481
  }).join("; ");
300
482
  const parseConfig = (candidate, name = "siro.config") => {
483
+ assertSynchronous(candidate, name);
301
484
  if (!isPlainRecord(candidate)) throw new ConfigError(`${name} must export a config object (got ${Array.isArray(candidate) ? "an array" : typeof candidate}).`);
302
485
  const result = vb.safeParse(ConfigSchema, Object.fromEntries(Object.entries(candidate)));
303
486
  if (!result.success) throw new ConfigError(`${name}: ${formatIssues(result.issues)}`);
304
487
  return result.output;
305
488
  };
306
489
  //#endregion
307
- //#region src/adapters/config-loader.ts
490
+ //#region src/load-config.ts
308
491
  const CONFIG_NAMES = [
309
492
  "siro.config.ts",
310
493
  "siro.config.mjs",
@@ -328,7 +511,7 @@ const loadConfig = async (cwd, nodeVersion = process.versions.node) => {
328
511
  return parseConfig(mod !== null && typeof mod === "object" && "default" in mod ? mod.default : mod, name);
329
512
  };
330
513
  //#endregion
331
- //#region src/domain/entities/config-files.ts
514
+ //#region src/core/config-files.ts
332
515
  /** Known configuration locations and their parsers. */
333
516
  const CONFIG_FILES = {
334
517
  aubeWorkspace: {
@@ -361,7 +544,7 @@ const CONFIG_FILES = {
361
544
  }
362
545
  };
363
546
  //#endregion
364
- //#region src/domain/setting-availability.ts
547
+ //#region src/core/rules/setting-availability.ts
365
548
  const { npmrc: npmrc$7, packageJson: packageJson$2, pnpmWorkspace: pnpmWorkspace$11, yarnrc: yarnrc$10, bunfig: bunfig$6 } = CONFIG_FILES;
366
549
  const settingAvailability = [
367
550
  {
@@ -482,7 +665,7 @@ const settingAvailability = [
482
665
  }
483
666
  ];
484
667
  //#endregion
485
- //#region src/domain/services/remediation-availability.ts
668
+ //#region src/core/rules/remediation-availability.ts
486
669
  const sameSetting = (setting, target) => target.file.path === setting.file.path && target.file.kind === setting.file.kind && target.keyPath.length === setting.keyPath.length && target.keyPath.every((key, index) => key === setting.keyPath[index]);
487
670
  /** Undefined means the target version or the setting's introduction is unknown. */
488
671
  const settingSupportedByTarget = (pm, version, target) => {
@@ -501,7 +684,7 @@ const guardRemediationAvailability = (pm, version, remediation, targets = remedi
501
684
  };
502
685
  };
503
686
  //#endregion
504
- //#region src/domain/rules/remediation.ts
687
+ //#region src/core/rules/remediation.ts
505
688
  /** Scalar writes must not discard an existing container or replace a non-object parent. */
506
689
  const proposeChanges = (config, operations) => {
507
690
  for (const operation of operations) {
@@ -525,7 +708,7 @@ const proposeChanges = (config, operations) => {
525
708
  };
526
709
  };
527
710
  //#endregion
528
- //#region src/domain/rules/builders/require-config-key.ts
711
+ //#region src/core/rules/builders/require-config-key.ts
529
712
  const overrideBindings = (rule, overrides) => ({
530
713
  ...rule,
531
714
  bindings: {
@@ -536,7 +719,7 @@ const overrideBindings = (rule, overrides) => ({
536
719
  const accepts = (spec, actual) => spec.accept ? spec.accept(actual) : actual === spec.value;
537
720
  const checkKeyValue = (spec, config) => {
538
721
  const actual = getByPath(config, spec.keyPath);
539
- const coveredByDefault = actual === void 0 && spec.documentedDefault !== void 0 && spec.versionNote?.defaultSafeSince === void 0 && accepts(spec, spec.documentedDefault);
722
+ const coveredByDefault = actual === void 0 && spec.documentedDefault !== void 0 && spec.defaultSafety === "unconditional" && accepts(spec, spec.documentedDefault);
540
723
  if (!coveredByDefault && accepts(spec, actual)) return { state: "ok" };
541
724
  const severity = coveredByDefault ? spec.defaultSatisfiedSeverity ?? "info" : void 0;
542
725
  if (severity === "off") return { state: "ok" };
@@ -589,7 +772,7 @@ const requireConfigKey = (options) => {
589
772
  };
590
773
  };
591
774
  //#endregion
592
- //#region src/domain/rules/builders/with-aube-paranoid.ts
775
+ //#region src/core/rules/builders/with-aube-paranoid.ts
593
776
  const withAubeParanoid = (rule) => {
594
777
  const { aube } = rule.bindings;
595
778
  if (!aube) return rule;
@@ -602,13 +785,14 @@ const withAubeParanoid = (rule) => {
602
785
  } });
603
786
  };
604
787
  //#endregion
605
- //#region src/domain/rules/advisory-check.ts
788
+ //#region src/core/rules/advisory-check.ts
606
789
  const { aubeWorkspace: aubeWorkspace$8 } = CONFIG_FILES;
607
790
  const advisoryCheck = withAubeParanoid(requireConfigKey({
608
791
  bindings: { aube: {
609
792
  accept: (value) => value === "on" || value === "required",
610
793
  docs: "https://aube.jdx.dev/security.html",
611
794
  documentedDefault: "on",
795
+ defaultSafety: "unconditional",
612
796
  file: aubeWorkspace$8,
613
797
  keyPath: ["advisoryCheck"],
614
798
  message: "Set `advisoryCheck: on` (or `required`) in aube-workspace.yaml to check dependencies against known-malicious advisories.",
@@ -621,11 +805,11 @@ const advisoryCheck = withAubeParanoid(requireConfigKey({
621
805
  title: "Enable malicious-package advisory checks"
622
806
  }));
623
807
  //#endregion
624
- //#region src/domain/rules/config-predicates.ts
808
+ //#region src/core/rules/config-predicates.ts
625
809
  const isNonBlankString = (value) => typeof value === "string" && value.trim().length > 0;
626
810
  const isStringList = (value) => Array.isArray(value) && Array.from(value).every(isNonBlankString);
627
811
  //#endregion
628
- //#region src/domain/rules/approved-git-repos.ts
812
+ //#region src/core/rules/approved-git-repos.ts
629
813
  const { yarnrc: yarnrc$9 } = CONFIG_FILES;
630
814
  const message = "Set `approvedGitRepositories: []` in .yarnrc.yml to block all git: protocol dependencies (or list approved repository globs).";
631
815
  const approvedGitRepos = defineRule({
@@ -655,7 +839,7 @@ const approvedGitRepos = defineRule({
655
839
  title: "Approve git repository dependencies"
656
840
  });
657
841
  //#endregion
658
- //#region src/domain/rules/audit-suppression.ts
842
+ //#region src/core/rules/audit-suppression.ts
659
843
  const { yarnrc: yarnrc$8 } = CONFIG_FILES;
660
844
  const isNonEmptyArray = (value) => Array.isArray(value) && value.length > 0;
661
845
  const SUPPRESSION_KEYS = ["npmAuditIgnoreAdvisories", "npmAuditExcludePackages"];
@@ -688,7 +872,7 @@ const auditSuppression = defineRule({
688
872
  title: "Review audit suppression entries"
689
873
  });
690
874
  //#endregion
691
- //#region src/domain/rules/block-auto-install.ts
875
+ //#region src/core/rules/block-auto-install.ts
692
876
  const { bunfig: bunfig$5 } = CONFIG_FILES;
693
877
  const blockAutoInstall = requireConfigKey({
694
878
  bindings: { bun: {
@@ -706,7 +890,7 @@ const blockAutoInstall = requireConfigKey({
706
890
  title: "Block automatic package installation"
707
891
  });
708
892
  //#endregion
709
- //#region src/domain/rules/block-exotic-subdeps.ts
893
+ //#region src/core/rules/block-exotic-subdeps.ts
710
894
  const { aubeWorkspace: aubeWorkspace$7, npmrc: npmrc$6, pnpmWorkspace: pnpmWorkspace$10 } = CONFIG_FILES;
711
895
  const isRootOrNone = (value) => value === "root" || value === "none";
712
896
  const npmMessage = "Set `allow-git=none` and `allow-remote=none` in .npmrc to block git/tarball URLs; use `root` only when direct URL dependencies are required.";
@@ -743,14 +927,16 @@ const blockExoticSubdeps = overrideBindings(requireConfigKey({
743
927
  aube: {
744
928
  docs: "https://aube.jdx.dev/security.html",
745
929
  documentedDefault: true,
930
+ defaultSafety: "unconditional",
746
931
  file: aubeWorkspace$7,
747
932
  keyPath: ["blockExoticSubdeps"],
748
933
  message: "Set `blockExoticSubdeps: true` in aube-workspace.yaml to block git/tarball URLs in transitive dependencies.",
749
934
  value: true
750
935
  },
751
936
  pnpm: {
752
- docs: "https://pnpm.io/settings#blockexoticsubdeps",
937
+ docs: "https://pnpm.io/settings/dependency-resolution#blockexoticsubdeps",
753
938
  documentedDefault: true,
939
+ defaultSafety: "conditional",
754
940
  file: pnpmWorkspace$10,
755
941
  keyPath: ["blockExoticSubdeps"],
756
942
  message: "Set `blockExoticSubdeps: true` in pnpm-workspace.yaml to block git/tarball URLs in transitive dependencies.",
@@ -762,13 +948,13 @@ const blockExoticSubdeps = overrideBindings(requireConfigKey({
762
948
  }
763
949
  },
764
950
  description: "Refuse to install transitive dependencies sourced from git or tarball URLs, which bypass registry integrity checking.",
765
- docs: "https://pnpm.io/settings#blockexoticsubdeps",
951
+ docs: "https://pnpm.io/settings/dependency-resolution#blockexoticsubdeps",
766
952
  id: "block-exotic-subdeps",
767
953
  severity: "warn",
768
954
  title: "Block exotic subdependencies"
769
955
  }), { npm: npmBinding$1 });
770
956
  //#endregion
771
- //#region src/domain/rules/bun-security-scanner.ts
957
+ //#region src/core/rules/bun-security-scanner.ts
772
958
  const { bunfig: bunfig$4 } = CONFIG_FILES;
773
959
  const bunSecurityScanner = defineRule({
774
960
  bindings: { bun: {
@@ -807,12 +993,13 @@ const bunSecurityScanner = defineRule({
807
993
  title: "Enable a bun install-time security scanner"
808
994
  });
809
995
  //#endregion
810
- //#region src/domain/rules/checksum-verification.ts
996
+ //#region src/core/rules/checksum-verification.ts
811
997
  const { yarnrc: yarnrc$7 } = CONFIG_FILES;
812
998
  const checksumVerification = requireConfigKey({
813
999
  bindings: { yarn: {
814
1000
  docs: "https://yarnpkg.com/configuration/yarnrc#checksumBehavior",
815
1001
  documentedDefault: "throw",
1002
+ defaultSafety: "unconditional",
816
1003
  file: yarnrc$7,
817
1004
  keyPath: ["checksumBehavior"],
818
1005
  message: "Set `checksumBehavior: throw` in .yarnrc.yml to reject packages whose checksum does not match the lockfile.",
@@ -825,7 +1012,7 @@ const checksumVerification = requireConfigKey({
825
1012
  title: "Verify package checksums"
826
1013
  });
827
1014
  //#endregion
828
- //#region src/domain/entities/signals.ts
1015
+ //#region src/core/signals.ts
829
1016
  const PM_SIGNALS = {
830
1017
  aube: {
831
1018
  configs: [CONFIG_FILES.aubeWorkspace.path],
@@ -860,11 +1047,11 @@ const PM_SIGNALS = {
860
1047
  }
861
1048
  };
862
1049
  //#endregion
863
- //#region src/domain/rules/commit-lockfile.ts
1050
+ //#region src/core/rules/commit-lockfile.ts
864
1051
  const LOCKFILE_DOCS = {
865
1052
  aube: "https://github.com/aubepkg/aube/blob/main/docs/package-manager/lockfiles.md",
866
1053
  bun: "https://bun.com/docs/install/lockfile",
867
- deno: "https://docs.deno.com/runtime/fundamentals/modules/#integrity-checking-and-lock-files",
1054
+ deno: "https://docs.deno.com/runtime/reference/deno_json/#lockfile",
868
1055
  npm: "https://docs.npmjs.com/cli/v12/configuring-npm/package-lock-json",
869
1056
  pnpm: "https://pnpm.io/git#lockfiles",
870
1057
  yarn: "https://yarnpkg.com/getting-started/qa#should-lockfiles-be-committed-to-the-repository"
@@ -873,7 +1060,7 @@ const LOCKFILE_VERSION_NOTES = { deno: { configAvailableSince: "deno 1.28.0" } }
873
1060
  const lockfileBinding = (pm) => {
874
1061
  const { lockfiles, reusesLockfiles } = PM_SIGNALS[pm];
875
1062
  const [primary] = lockfiles;
876
- const accepted = [...lockfiles, ...reusesLockfiles ?? []].filter((lockfile) => pm !== "npm" || lockfile === primary);
1063
+ const accepted = [...lockfiles, ...reusesLockfiles ?? []];
877
1064
  return {
878
1065
  check(ctx) {
879
1066
  if (pm === "aube" && ctx.exists(asRelPath("bun.lockb")) && !ctx.exists(asRelPath("bun.lock"))) return {
@@ -884,7 +1071,20 @@ const lockfileBinding = (pm) => {
884
1071
  steps: ["Run `bun install --save-text-lockfile` to generate bun.lock, then commit it."]
885
1072
  }
886
1073
  };
887
- if (accepted.some((lf) => ctx.exists(asRelPath(lf)))) return { state: "ok" };
1074
+ if (pm === "npm") {
1075
+ if (ctx.exists(asRelPath(primary))) return { state: "ok" };
1076
+ if (ctx.exists(asRelPath("npm-shrinkwrap.json"))) {
1077
+ if (ctx.pmVersion && lt(ctx.pmVersion, "12.0.0")) return { state: "ok" };
1078
+ return {
1079
+ state: "violation",
1080
+ message: ctx.pmVersion ? `npm ${ctx.pmVersion} no longer reads npm-shrinkwrap.json. Use package-lock.json.` : "Found npm-shrinkwrap.json, but the npm target version is unknown; npm 12 no longer reads it.",
1081
+ remediation: {
1082
+ kind: "manual",
1083
+ steps: ["Verify and declare the npm version used by this project. For npm 12 or newer, migrate to package-lock.json and review the resulting dependency resolutions."]
1084
+ }
1085
+ };
1086
+ }
1087
+ } else if (accepted.some((lf) => ctx.exists(asRelPath(lf)))) return { state: "ok" };
888
1088
  return {
889
1089
  remediation: {
890
1090
  kind: "manual",
@@ -935,13 +1135,13 @@ const commitLockfile = defineRule({
935
1135
  yarn: lockfileBinding("yarn")
936
1136
  },
937
1137
  description: "Lockfiles pin the full dependency tree and integrity hashes, enabling reproducible, verifiable installs (e.g. `npm ci`).",
938
- docs: "https://github.com/bodadotsh/npm-security-best-practices#2-include-lockfiles",
1138
+ docs: "https://github.com/bodadotsh/npm-security-best-practices#3-include-lockfiles",
939
1139
  id: "commit-lockfile",
940
1140
  severity: "error",
941
1141
  title: "Commit a lockfile"
942
1142
  });
943
1143
  //#endregion
944
- //#region src/domain/rules/dependency-overrides.ts
1144
+ //#region src/core/rules/dependency-overrides.ts
945
1145
  const { aubeWorkspace: aubeWorkspace$6, pnpmWorkspace: pnpmWorkspace$9 } = CONFIG_FILES;
946
1146
  const isNonEmptyObject$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length > 0;
947
1147
  const checkOverrides = (config, file) => {
@@ -967,16 +1167,16 @@ const makeBinding = (file, fileName, docs) => ({
967
1167
  const dependencyOverrides = defineRule({
968
1168
  bindings: {
969
1169
  aube: makeBinding(aubeWorkspace$6, "aube-workspace.yaml", "https://aube.sh/settings/"),
970
- pnpm: makeBinding(pnpmWorkspace$9, "pnpm-workspace.yaml", "https://pnpm.io/settings#overrides")
1170
+ pnpm: makeBinding(pnpmWorkspace$9, "pnpm-workspace.yaml", "https://pnpm.io/settings/dependency-resolution#overrides")
971
1171
  },
972
1172
  description: "Flag dependency overrides that can replace transitive packages with arbitrary versions or forks — a supply-chain injection vector.",
973
- docs: "https://pnpm.io/settings#overrides",
1173
+ docs: "https://pnpm.io/settings/dependency-resolution#overrides",
974
1174
  id: "dependency-overrides",
975
1175
  severity: "info",
976
1176
  title: "Review dependency overrides"
977
1177
  });
978
1178
  //#endregion
979
- //#region src/domain/rules/disable-lifecycle-scripts.ts
1179
+ //#region src/core/rules/disable-lifecycle-scripts.ts
980
1180
  const { npmrc: npmrc$5, pnpmWorkspace: pnpmWorkspace$8, yarnrc: yarnrc$6, aubeWorkspace: aubeWorkspace$5, bunfig: bunfig$3 } = CONFIG_FILES;
981
1181
  const pnpmBinding$4 = {
982
1182
  check(ctx, config) {
@@ -1050,7 +1250,7 @@ const pnpmBinding$4 = {
1050
1250
  }])
1051
1251
  };
1052
1252
  },
1053
- docs: "https://pnpm.io/settings#strictdepbuilds",
1253
+ docs: "https://pnpm.io/settings/build#strictdepbuilds",
1054
1254
  file: pnpmWorkspace$8,
1055
1255
  versionNote: {
1056
1256
  configAvailableSince: "pnpm 10.3.0",
@@ -1137,6 +1337,7 @@ const disableLifecycleScripts = withAubeParanoid(overrideBindings(requireConfigK
1137
1337
  yarn: {
1138
1338
  docs: "https://yarnpkg.com/configuration/yarnrc#enableScripts",
1139
1339
  documentedDefault: false,
1340
+ defaultSafety: "conditional",
1140
1341
  file: yarnrc$6,
1141
1342
  keyPath: ["enableScripts"],
1142
1343
  message: "Set `enableScripts: false` in .yarnrc.yml to pin the policy across versions.",
@@ -1148,7 +1349,7 @@ const disableLifecycleScripts = withAubeParanoid(overrideBindings(requireConfigK
1148
1349
  }
1149
1350
  },
1150
1351
  description: "Malicious postinstall scripts are a primary supply-chain attack vector. Prevent automatic execution of dependency lifecycle scripts.",
1151
- docs: "https://github.com/bodadotsh/npm-security-best-practices#3-disable-lifecycle-scripts",
1352
+ docs: "https://github.com/bodadotsh/npm-security-best-practices#1-disable-lifecycle-scripts",
1152
1353
  id: "disable-lifecycle-scripts",
1153
1354
  severity: "error",
1154
1355
  title: "Disable dependency lifecycle scripts"
@@ -1158,7 +1359,7 @@ const disableLifecycleScripts = withAubeParanoid(overrideBindings(requireConfigK
1158
1359
  pnpm: pnpmBinding$4
1159
1360
  }));
1160
1361
  //#endregion
1161
- //#region src/domain/rules/enforce-strict-ssl.ts
1362
+ //#region src/core/rules/enforce-strict-ssl.ts
1162
1363
  const { npmrc: npmrc$4, yarnrc: yarnrc$5 } = CONFIG_FILES;
1163
1364
  const yarnMessage = "Set `enableStrictSsl: true` in .yarnrc.yml to enforce SSL certificate validation for registry connections.";
1164
1365
  const yarnBinding = {
@@ -1197,6 +1398,7 @@ const enforceStrictSsl = overrideBindings(requireConfigKey({
1197
1398
  bindings: { npm: {
1198
1399
  docs: "https://docs.npmjs.com/cli/v11/using-npm/config#strict-ssl",
1199
1400
  documentedDefault: true,
1401
+ defaultSafety: "unconditional",
1200
1402
  file: npmrc$4,
1201
1403
  keyPath: ["strict-ssl"],
1202
1404
  message: "Set `strict-ssl=true` in .npmrc to enforce SSL certificate validation.",
@@ -1209,7 +1411,7 @@ const enforceStrictSsl = overrideBindings(requireConfigKey({
1209
1411
  title: "Enforce SSL for registry connections"
1210
1412
  }), { yarn: yarnBinding });
1211
1413
  //#endregion
1212
- //#region src/domain/services/project-type.ts
1414
+ //#region src/core/resolve-project-type.ts
1213
1415
  const isPublishableName = (value) => typeof value === "string" && value.trim().length > 0;
1214
1416
  const resolveProjectType = (selected, inferredPackage) => selected ?? (inferredPackage ? "package" : "application");
1215
1417
  const resolvePackageJsonProjectType = (ctx) => {
@@ -1219,10 +1421,10 @@ const resolvePackageJsonProjectType = (ctx) => {
1219
1421
  };
1220
1422
  const resolveDenoProjectType = (ctx, denoConfig) => resolveProjectType(ctx.projectType, getByPath(denoConfig, ["publish"]) !== false && isPublishableName(getByPath(denoConfig, ["name"])));
1221
1423
  //#endregion
1222
- //#region src/domain/rules/publishable.ts
1424
+ //#region src/core/rules/publishable.ts
1223
1425
  const isPublishable = (ctx) => resolvePackageJsonProjectType(ctx) === "package";
1224
1426
  //#endregion
1225
- //#region src/domain/rules/files-field.ts
1427
+ //#region src/core/rules/files-field.ts
1226
1428
  const { packageJson: packageJson$1, denoJson: denoJson$3 } = CONFIG_FILES;
1227
1429
  const packageJsonFilesBinding = {
1228
1430
  check(ctx) {
@@ -1242,47 +1444,48 @@ const packageJsonFilesBinding = {
1242
1444
  docs: "https://docs.npmjs.com/cli/v11/configuring-npm/package-json#files",
1243
1445
  file: packageJson$1
1244
1446
  };
1447
+ const denoPublishBinding = {
1448
+ check(ctx, config) {
1449
+ if (resolveDenoProjectType(ctx, config) !== "package") return { state: "na" };
1450
+ const include = getByPath(config, ["publish", "include"]);
1451
+ if (isStringList(include) && include.length > 0) return { state: "ok" };
1452
+ return {
1453
+ remediation: {
1454
+ kind: "manual",
1455
+ steps: ["Add a non-empty `publish.include` array to deno.json; use `publish.exclude` for additional exclusions."]
1456
+ },
1457
+ actual: include,
1458
+ message: "Add `publish.include` to deno.json to limit what gets published.",
1459
+ state: "violation"
1460
+ };
1461
+ },
1462
+ docs: "https://docs.deno.com/runtime/reference/deno_json/#publish---override-.gitignore",
1463
+ file: denoJson$3
1464
+ };
1245
1465
  const filesField = defineRule({
1246
1466
  bindings: {
1247
1467
  aube: packageJsonFilesBinding,
1248
1468
  bun: packageJsonFilesBinding,
1249
- deno: {
1250
- check(ctx, config) {
1251
- if (resolveDenoProjectType(ctx, config) !== "package") return { state: "na" };
1252
- const include = getByPath(config, ["publish", "include"]);
1253
- if (isStringList(include) && include.length > 0) return { state: "ok" };
1254
- return {
1255
- remediation: {
1256
- kind: "manual",
1257
- steps: ["Add a non-empty `publish.include` array to deno.json; use `publish.exclude` for additional exclusions."]
1258
- },
1259
- actual: include,
1260
- message: "Add `publish.include` to deno.json to limit what gets published.",
1261
- state: "violation"
1262
- };
1263
- },
1264
- docs: "https://docs.deno.com/runtime/reference/cli/publish/#how-publishing-works",
1265
- file: denoJson$3
1266
- },
1469
+ deno: denoPublishBinding,
1267
1470
  npm: packageJsonFilesBinding,
1268
1471
  pnpm: packageJsonFilesBinding,
1269
1472
  yarn: packageJsonFilesBinding
1270
1473
  },
1271
1474
  description: "An explicit `files` array in package.json restricts what gets published, preventing accidental inclusion of secrets or local files.",
1272
- docs: "https://github.com/bodadotsh/npm-security-best-practices#11-review-published-files",
1475
+ docs: "https://github.com/bodadotsh/npm-security-best-practices#12-review-published-files",
1273
1476
  id: "files-field",
1274
1477
  projectTypes: ["package"],
1275
1478
  severity: "info",
1276
1479
  title: "Declare a published files allow-list"
1277
1480
  });
1278
1481
  //#endregion
1279
- //#region src/domain/rules/frozen-lockfile.ts
1482
+ //#region src/core/rules/frozen-lockfile.ts
1280
1483
  const { pnpmWorkspace: pnpmWorkspace$7, yarnrc: yarnrc$4, bunfig: bunfig$2, denoJson: denoJson$2 } = CONFIG_FILES;
1281
1484
  const aubeFrozenCommand = "Use `aube ci` or `aube install --frozen-lockfile` to fail when the lockfile would change.";
1282
1485
  const frozenLockfile = overrideBindings(requireConfigKey({
1283
1486
  bindings: {
1284
1487
  deno: {
1285
- docs: "https://docs.deno.com/runtime/fundamentals/configuration/#lock",
1488
+ docs: "https://docs.deno.com/runtime/reference/deno_json/#lockfile",
1286
1489
  file: denoJson$2,
1287
1490
  keyPath: ["lock", "frozen"],
1288
1491
  message: "Set `lock.frozen: true` in deno.json for reproducible, verified installs.",
@@ -1297,7 +1500,7 @@ const frozenLockfile = overrideBindings(requireConfigKey({
1297
1500
  versionNote: { configAvailableSince: "bun 0.6.10" }
1298
1501
  },
1299
1502
  pnpm: {
1300
- docs: "https://pnpm.io/settings#frozenlockfile",
1503
+ docs: "https://pnpm.io/cli/install#--frozen-lockfile",
1301
1504
  file: pnpmWorkspace$7,
1302
1505
  keyPath: ["frozenLockfile"],
1303
1506
  message: "pnpm defaults frozenLockfile to true in CI — set it explicitly in pnpm-workspace.yaml to pin the policy outside CI too.",
@@ -1306,6 +1509,7 @@ const frozenLockfile = overrideBindings(requireConfigKey({
1306
1509
  yarn: {
1307
1510
  docs: "https://yarnpkg.com/configuration/yarnrc#enableImmutableInstalls",
1308
1511
  documentedDefault: true,
1512
+ defaultSafety: "conditional",
1309
1513
  file: yarnrc$4,
1310
1514
  keyPath: ["enableImmutableInstalls"],
1311
1515
  message: "Set `enableImmutableInstalls: true` in .yarnrc.yml to pin the policy outside CI too.",
@@ -1317,7 +1521,7 @@ const frozenLockfile = overrideBindings(requireConfigKey({
1317
1521
  }
1318
1522
  },
1319
1523
  description: "Refuse to mutate the lockfile on install so unexpected dependency changes fail loudly.",
1320
- docs: "https://github.com/bodadotsh/npm-security-best-practices#2-include-lockfiles",
1524
+ docs: "https://github.com/bodadotsh/npm-security-best-practices#3-include-lockfiles",
1321
1525
  id: "frozen-lockfile",
1322
1526
  severity: "warn",
1323
1527
  title: "Freeze the lockfile"
@@ -1336,7 +1540,7 @@ const frozenLockfile = overrideBindings(requireConfigKey({
1336
1540
  severity: "info"
1337
1541
  } });
1338
1542
  //#endregion
1339
- //#region src/domain/rules/frozen-store.ts
1543
+ //#region src/core/rules/frozen-store.ts
1340
1544
  const { pnpmWorkspace: pnpmWorkspace$6 } = CONFIG_FILES;
1341
1545
  const frozenStore = defineRule({
1342
1546
  bindings: { pnpm: {
@@ -1367,12 +1571,13 @@ const frozenStore = defineRule({
1367
1571
  title: "Consider a pre-populated read-only store"
1368
1572
  });
1369
1573
  //#endregion
1370
- //#region src/domain/rules/hardened-mode.ts
1574
+ //#region src/core/rules/hardened-mode.ts
1371
1575
  const { yarnrc: yarnrc$3 } = CONFIG_FILES;
1372
1576
  const hardenedMode = requireConfigKey({
1373
1577
  bindings: { yarn: {
1374
1578
  docs: "https://yarnpkg.com/configuration/yarnrc#enableHardenedMode",
1375
1579
  documentedDefault: true,
1580
+ defaultSafety: "conditional",
1376
1581
  file: yarnrc$3,
1377
1582
  keyPath: ["enableHardenedMode"],
1378
1583
  message: "yarn enables hardened mode automatically only for pull requests on public repositories — set `enableHardenedMode: true` in .yarnrc.yml to extend end-to-end install verification to every install.",
@@ -1389,7 +1594,7 @@ const hardenedMode = requireConfigKey({
1389
1594
  title: "Enable hardened mode (yarn)"
1390
1595
  });
1391
1596
  //#endregion
1392
- //#region src/domain/services/deno-release-age.ts
1597
+ //#region src/core/rules/deno-release-age.ts
1393
1598
  const DENO_DURATION = /^\+?P(?:\+?\d+[Ww]|(?:\d+[Dd])*(?:T(?:\d+[HhMm]|\d+(?:\.\d+)?[Ss])+)?)$/u;
1394
1599
  const DENO_MIN_TIMESTAMP = Date.UTC(-262143, 0, 1);
1395
1600
  const DENO_UNIT_SECONDS = {
@@ -1427,7 +1632,7 @@ const isActiveDenoReleaseAge = (value, now, parse) => {
1427
1632
  return !Number.isNaN(midnight.valueOf()) && midnight.toISOString().slice(0, 10) === date && timestamp < now;
1428
1633
  };
1429
1634
  //#endregion
1430
- //#region src/domain/rules/minimum-release-age.ts
1635
+ //#region src/core/rules/minimum-release-age.ts
1431
1636
  const HOURS_PER_DAY = 24;
1432
1637
  const MINUTES_PER_HOUR = 60;
1433
1638
  const SECONDS_PER_MINUTE = 60;
@@ -1444,7 +1649,7 @@ const isNonDisabledDenoReleaseAge = (value, now, parse) => {
1444
1649
  if (!isPlainRecord(value)) return isActiveDenoReleaseAge(value, now, parse);
1445
1650
  if (Object.keys(value).some((key) => key !== "age" && key !== "exclude")) return false;
1446
1651
  if (value.exclude !== void 0 && !isStringList(value.exclude)) return false;
1447
- return value.age == null || isActiveDenoReleaseAge(value.age, now, parse);
1652
+ return value.age != null && isActiveDenoReleaseAge(value.age, now, parse);
1448
1653
  };
1449
1654
  const denoAgeUsesFallback = (value) => {
1450
1655
  if (value === void 0 || value === null) return true;
@@ -1453,13 +1658,14 @@ const denoAgeUsesFallback = (value) => {
1453
1658
  if (value.exclude !== void 0 && !isStringList(value.exclude)) return false;
1454
1659
  return value.age == null;
1455
1660
  };
1456
- const isPositiveDenoNpmrcDays = (value) => typeof value === "number" && Number.isSafeInteger(value) && value > 0;
1457
- const baseRule$1 = requireConfigKey({
1661
+ const isPositiveDenoNpmrcDays = (value, now, parse) => typeof value === "number" && Number.isSafeInteger(value) && isActiveDenoReleaseAge(value * MINUTES_PER_DAY, now, parse);
1662
+ const baseRule$2 = requireConfigKey({
1458
1663
  bindings: {
1459
1664
  aube: {
1460
1665
  accept: isPositiveNumber,
1461
1666
  docs: "https://aube.sh/settings/",
1462
1667
  documentedDefault: DOCUMENTED_DEFAULT_MINUTES,
1668
+ defaultSafety: "unconditional",
1463
1669
  file: aubeWorkspace$4,
1464
1670
  keyPath: ["minimumReleaseAge"],
1465
1671
  message: `Set minimumReleaseAge (~${RECOMMENDED_RELEASE_AGE_MINUTES} minutes for a 3-day cooldown) in aube-workspace.yaml.`,
@@ -1476,8 +1682,9 @@ const baseRule$1 = requireConfigKey({
1476
1682
  },
1477
1683
  pnpm: {
1478
1684
  accept: isPositiveNumber,
1479
- docs: "https://pnpm.io/settings#minimumreleaseage",
1685
+ docs: "https://pnpm.io/settings/dependency-resolution#minimumreleaseage",
1480
1686
  documentedDefault: DOCUMENTED_DEFAULT_MINUTES,
1687
+ defaultSafety: "conditional",
1481
1688
  file: pnpmWorkspace$5,
1482
1689
  keyPath: ["minimumReleaseAge"],
1483
1690
  message: `Set minimumReleaseAge (~${RECOMMENDED_RELEASE_AGE_MINUTES} minutes for a 3-day cooldown) in pnpm-workspace.yaml.`,
@@ -1491,6 +1698,7 @@ const baseRule$1 = requireConfigKey({
1491
1698
  accept: isPositiveYarnDuration,
1492
1699
  docs: "https://yarnpkg.com/configuration/yarnrc#npmMinimalAgeGate",
1493
1700
  documentedDefault: DOCUMENTED_DEFAULT_MINUTES,
1701
+ defaultSafety: "conditional",
1494
1702
  file: yarnrc$2,
1495
1703
  keyPath: ["npmMinimalAgeGate"],
1496
1704
  message: `Set npmMinimalAgeGate (~${RECOMMENDED_RELEASE_AGE_MINUTES} minutes for a 3-day cooldown) in .yarnrc.yml.`,
@@ -1502,7 +1710,7 @@ const baseRule$1 = requireConfigKey({
1502
1710
  }
1503
1711
  },
1504
1712
  description: "Refuse to install releases newer than a cooldown window so freshly published (possibly compromised) versions are skipped.",
1505
- docs: "https://github.com/bodadotsh/npm-security-best-practices#set-minimal-release-age",
1713
+ docs: "https://github.com/bodadotsh/npm-security-best-practices#2-set-cooldowns--minimum-release-age",
1506
1714
  id: "minimum-release-age",
1507
1715
  severity: "warn",
1508
1716
  title: "Set a minimum release age"
@@ -1563,10 +1771,11 @@ const createDenoBinding = (time) => ({
1563
1771
  },
1564
1772
  check(ctx, config) {
1565
1773
  const actual = getByPath(config, ["minimumDependencyAge"]);
1774
+ const now = time.now();
1566
1775
  if (denoAgeUsesFallback(actual)) {
1567
1776
  const npmrcConfig = ctx.readConfig(npmrc$3);
1568
1777
  const npmrcAge = getByPath(npmrcConfig, ["min-release-age"]);
1569
- if (isPositiveDenoNpmrcDays(npmrcAge)) return { state: "ok" };
1778
+ if (isPositiveDenoNpmrcDays(npmrcAge, now, time.parse)) return { state: "ok" };
1570
1779
  if (npmrcAge === 0) return {
1571
1780
  state: "violation",
1572
1781
  actual: npmrcAge,
@@ -1581,7 +1790,7 @@ const createDenoBinding = (time) => ({
1581
1790
  }])
1582
1791
  };
1583
1792
  }
1584
- if (isNonDisabledDenoReleaseAge(actual, time.now(), time.parse)) return { state: "ok" };
1793
+ if (isNonDisabledDenoReleaseAge(actual, now, time.parse)) return { state: "ok" };
1585
1794
  const objectAge = isPlainRecord(actual);
1586
1795
  const invalidObject = objectAge && (Object.keys(actual).some((key) => key !== "age" && key !== "exclude") || actual.exclude !== void 0 && !isStringList(actual.exclude));
1587
1796
  return {
@@ -1601,12 +1810,12 @@ const createDenoBinding = (time) => ({
1601
1810
  };
1602
1811
  }
1603
1812
  });
1604
- const createMinimumReleaseAge = (time) => overrideBindings(baseRule$1, {
1813
+ const createMinimumReleaseAge = (time) => overrideBindings(baseRule$2, {
1605
1814
  npm: createNpmBinding(time),
1606
1815
  deno: createDenoBinding(time)
1607
1816
  });
1608
1817
  //#endregion
1609
- //#region src/domain/rules/named-registries.ts
1818
+ //#region src/core/rules/named-registries.ts
1610
1819
  const { pnpmWorkspace: pnpmWorkspace$4 } = CONFIG_FILES;
1611
1820
  const isNonEmptyObject$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length > 0;
1612
1821
  const namedRegistries = defineRule({
@@ -1624,17 +1833,17 @@ const namedRegistries = defineRule({
1624
1833
  state: "violation"
1625
1834
  };
1626
1835
  },
1627
- docs: "https://pnpm.io/settings#namedregistries",
1836
+ docs: "https://pnpm.io/settings/dependency-resolution#namedregistries",
1628
1837
  file: pnpmWorkspace$4
1629
1838
  } },
1630
1839
  description: "Flag named registry mappings that redirect package resolution to custom registries, which may enable dependency confusion attacks.",
1631
- docs: "https://pnpm.io/settings#namedregistries",
1840
+ docs: "https://pnpm.io/settings/dependency-resolution#namedregistries",
1632
1841
  id: "named-registries",
1633
1842
  severity: "info",
1634
1843
  title: "Review named registry mappings"
1635
1844
  });
1636
1845
  //#endregion
1637
- //#region src/domain/rules/paranoid-mode.ts
1846
+ //#region src/core/rules/paranoid-mode.ts
1638
1847
  const { aubeWorkspace: aubeWorkspace$3 } = CONFIG_FILES;
1639
1848
  const paranoidMode = requireConfigKey({
1640
1849
  bindings: { aube: {
@@ -1651,7 +1860,7 @@ const paranoidMode = requireConfigKey({
1651
1860
  title: "Enable paranoid security mode"
1652
1861
  });
1653
1862
  //#endregion
1654
- //#region src/domain/rules/patched-dependencies.ts
1863
+ //#region src/core/rules/patched-dependencies.ts
1655
1864
  const { pnpmWorkspace: pnpmWorkspace$3 } = CONFIG_FILES;
1656
1865
  const isNonEmptyObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length > 0;
1657
1866
  const patchedDependencies = defineRule({
@@ -1669,17 +1878,17 @@ const patchedDependencies = defineRule({
1669
1878
  state: "violation"
1670
1879
  };
1671
1880
  },
1672
- docs: "https://pnpm.io/settings#patcheddependencies",
1881
+ docs: "https://pnpm.io/cli/patch#patcheddependencies",
1673
1882
  file: pnpmWorkspace$3
1674
1883
  } },
1675
1884
  description: "Review local patches separately from the registry artifacts they modify.",
1676
- docs: "https://pnpm.io/settings#patcheddependencies",
1885
+ docs: "https://pnpm.io/cli/patch#patcheddependencies",
1677
1886
  id: "patched-dependencies",
1678
1887
  severity: "info",
1679
1888
  title: "Review patched dependencies"
1680
1889
  });
1681
1890
  //#endregion
1682
- //#region src/domain/rules/pin-exact-versions.ts
1891
+ //#region src/core/rules/pin-exact-versions.ts
1683
1892
  const { npmrc: npmrc$2, pnpmWorkspace: pnpmWorkspace$2, yarnrc: yarnrc$1, bunfig, denoJson } = CONFIG_FILES;
1684
1893
  const REGISTRY_VERSION = /^(?:npm|jsr):(?:@[^/@]+\/)?[^/@]+@(?<version>[^/]*)/u;
1685
1894
  const isUnpinnedRegistryImport = (specifier) => {
@@ -1688,9 +1897,14 @@ const isUnpinnedRegistryImport = (specifier) => {
1688
1897
  const normalized = version?.startsWith("=") ? version.slice(1) : version;
1689
1898
  return normalized === void 0 || valid(normalized) === null;
1690
1899
  };
1691
- const collectUnpinnedImports = (imports) => {
1900
+ const collectUnpinnedImports = (imports, location) => {
1901
+ if (!isPlainRecord(imports)) throw new ConfigError(`deno.json: ${location} must be an import mapping object.`);
1692
1902
  const offenders = [];
1693
- for (const [name, value] of Object.entries(imports)) if (typeof value === "string" && isUnpinnedRegistryImport(value)) offenders.push(`${name}=${value}`);
1903
+ for (const [name, value] of Object.entries(imports)) {
1904
+ if (value === null) continue;
1905
+ if (typeof value !== "string") throw new ConfigError(`deno.json: ${location}.${name} must be a string or null.`);
1906
+ if (isUnpinnedRegistryImport(value)) offenders.push(`${location}.${name}=${value}`);
1907
+ }
1694
1908
  return offenders;
1695
1909
  };
1696
1910
  const MAX_SAMPLE_COUNT = 3;
@@ -1701,23 +1915,26 @@ const formatOffenders = (offenders) => {
1701
1915
  return {
1702
1916
  remediation: {
1703
1917
  kind: "manual",
1704
- steps: ["Run `deno add --save-exact <pkg>` for each unpinned registry import in deno.json, or rewrite the `imports` entries to use exact versions."]
1918
+ steps: ["Pin each reported imports/scopes entry in deno.json to an exact version. For top-level imports, `deno add --save-exact <pkg>` can help; edit scoped mappings at their reported locations."]
1705
1919
  },
1706
1920
  message: `${offenders.length} deno imports are not pinned: ${sample}${more}. Use \`deno add --save-exact\` or pin manually.`,
1707
1921
  state: "violation"
1708
1922
  };
1709
1923
  };
1710
1924
  const OK = { state: "ok" };
1711
- const extractImportsRecord = (config) => {
1925
+ const unpinnedInlineImports = (config) => {
1712
1926
  const imports = getByPath(config, ["imports"]);
1713
- if (!isPlainRecord(imports)) return;
1714
- return imports;
1927
+ const offenders = imports == null ? [] : collectUnpinnedImports(imports, "imports");
1928
+ const scopes = getByPath(config, ["scopes"]);
1929
+ if (scopes != null) {
1930
+ if (!isPlainRecord(scopes)) throw new ConfigError("deno.json: scopes must be an object.");
1931
+ for (const [scope, mapping] of Object.entries(scopes)) for (const offender of collectUnpinnedImports(mapping, `scopes[${JSON.stringify(scope)}]`)) offenders.push(offender);
1932
+ }
1933
+ return offenders;
1715
1934
  };
1716
1935
  const denoBinding = {
1717
1936
  check(_ctx, config) {
1718
- const imports = extractImportsRecord(config);
1719
- if (!imports) return OK;
1720
- const offenders = collectUnpinnedImports(imports);
1937
+ const offenders = unpinnedInlineImports(config);
1721
1938
  if (offenders.length === 0) return OK;
1722
1939
  return formatOffenders(offenders);
1723
1940
  },
@@ -1748,7 +1965,7 @@ const npmBinding = {
1748
1965
  };
1749
1966
  const aubeBinding = {
1750
1967
  file: npmrc$2,
1751
- docs: "https://aube.jdx.dev/settings/#saveprefix",
1968
+ docs: "https://aube.sh/settings/#setting-saveprefix",
1752
1969
  check(_ctx, config) {
1753
1970
  const prefixes = ["save-prefix", "savePrefix"].filter((key) => Object.hasOwn(config, key)).map((key) => config[key]);
1754
1971
  if (prefixes.length > 0 && prefixes.every((value) => value === "")) return OK;
@@ -1789,8 +2006,8 @@ const pinExactVersions = overrideBindings(requireConfigKey({
1789
2006
  versionNote: { configAvailableSince: "yarn 2.0.0" }
1790
2007
  }
1791
2008
  },
1792
- description: "Semver ranges (^, ~) auto-adopt new releases, including compromised ones. Save exact versions by default.",
1793
- docs: "https://github.com/bodadotsh/npm-security-best-practices#1-pin-dependency-versions",
2009
+ description: "Semver ranges (^, ~) auto-adopt new releases, including compromised ones. Save exact versions by default; for Deno, inspect registry mappings in both inline imports and scopes (not external import maps).",
2010
+ docs: "https://github.com/bodadotsh/npm-security-best-practices#4-pin-dependency-versions",
1794
2011
  id: "pin-exact-versions",
1795
2012
  severity: "error",
1796
2013
  title: "Pin exact dependency versions"
@@ -1800,7 +2017,7 @@ const pinExactVersions = overrideBindings(requireConfigKey({
1800
2017
  npm: npmBinding
1801
2018
  });
1802
2019
  //#endregion
1803
- //#region src/domain/rules/provenance.ts
2020
+ //#region src/core/rules/provenance.ts
1804
2021
  const { npmrc: npmrc$1, yarnrc } = CONFIG_FILES;
1805
2022
  const npmrcProvenance = {
1806
2023
  file: npmrc$1,
@@ -1808,7 +2025,7 @@ const npmrcProvenance = {
1808
2025
  message: "Set `provenance=true` (and publish from CI) to attest releases.",
1809
2026
  value: true
1810
2027
  };
1811
- const provenance = requireConfigKey({
2028
+ const baseRule = requireConfigKey({
1812
2029
  applies: isPublishable,
1813
2030
  bindings: {
1814
2031
  bun: {
@@ -1834,14 +2051,37 @@ const provenance = requireConfigKey({
1834
2051
  }
1835
2052
  },
1836
2053
  description: "Provenance statements (via Sigstore) bind a published artifact to its recorded source and build.",
1837
- docs: "https://github.com/bodadotsh/npm-security-best-practices#10-generate-provenance-statements",
2054
+ docs: "https://github.com/bodadotsh/npm-security-best-practices#11-generate-provenance-statements",
1838
2055
  id: "provenance",
1839
2056
  projectTypes: ["package"],
1840
2057
  severity: "warn",
1841
2058
  title: "Publish with provenance"
1842
2059
  });
2060
+ const provenance = overrideBindings(baseRule, { npm: {
2061
+ ...baseRule.bindings.npm,
2062
+ check(ctx, config) {
2063
+ if (!isPublishable(ctx)) return { state: "na" };
2064
+ const publishConfig = ctx.packageJson?.publishConfig;
2065
+ if (!publishConfig || !Object.hasOwn(publishConfig, "provenance")) return baseRule.bindings.npm.check(ctx, config);
2066
+ if (publishConfig.provenance === true) return { state: "ok" };
2067
+ const file = CONFIG_FILES.packageJson;
2068
+ return {
2069
+ state: "violation",
2070
+ file: file.path,
2071
+ actual: publishConfig.provenance,
2072
+ expected: true,
2073
+ message: "Set publishConfig.provenance=true in package.json (and publish from CI) to attest releases.",
2074
+ remediation: guardRemediationAvailability("npm", ctx.pmVersion, proposeChanges(ctx.readConfig(file), [{
2075
+ file,
2076
+ op: "setKey",
2077
+ keyPath: ["publishConfig", "provenance"],
2078
+ value: true
2079
+ }]))
2080
+ };
2081
+ }
2082
+ } });
1843
2083
  //#endregion
1844
- //#region src/domain/rules/publish-access.ts
2084
+ //#region src/core/rules/publish-access.ts
1845
2085
  const { packageJson } = CONFIG_FILES;
1846
2086
  const publishAccessBinding = {
1847
2087
  check(ctx) {
@@ -1884,7 +2124,7 @@ const publishAccess = defineRule({
1884
2124
  title: "Declare publish access explicitly"
1885
2125
  });
1886
2126
  //#endregion
1887
- //#region src/domain/rules/store-server.ts
2127
+ //#region src/core/rules/store-server.ts
1888
2128
  const { pnpmWorkspace: pnpmWorkspace$1 } = CONFIG_FILES;
1889
2129
  const storeServer = defineRule({
1890
2130
  bindings: { pnpm: {
@@ -1901,17 +2141,17 @@ const storeServer = defineRule({
1901
2141
  state: "violation"
1902
2142
  };
1903
2143
  },
1904
- docs: "https://pnpm.io/settings#userunningStoreserver",
2144
+ docs: "https://pnpm.io/settings/store#userunningstoreserver",
1905
2145
  file: pnpmWorkspace$1
1906
2146
  } },
1907
2147
  description: "Flag use of an external store server process, which introduces a trust boundary where tampered packages could be served.",
1908
- docs: "https://pnpm.io/settings#userunningStoreserver",
2148
+ docs: "https://pnpm.io/settings/store#userunningstoreserver",
1909
2149
  id: "store-server",
1910
2150
  severity: "info",
1911
2151
  title: "Review store server usage"
1912
2152
  });
1913
2153
  //#endregion
1914
- //#region src/domain/rules/strict-allow-scripts.ts
2154
+ //#region src/core/rules/strict-allow-scripts.ts
1915
2155
  const { npmrc } = CONFIG_FILES;
1916
2156
  const strictAllowScripts = defineRule({
1917
2157
  bindings: { npm: {
@@ -1953,7 +2193,7 @@ const strictAllowScripts = defineRule({
1953
2193
  title: "Require explicit script allow-listing"
1954
2194
  });
1955
2195
  //#endregion
1956
- //#region src/domain/rules/strict-release-age.ts
2196
+ //#region src/core/rules/strict-release-age.ts
1957
2197
  const { aubeWorkspace: aubeWorkspace$2 } = CONFIG_FILES;
1958
2198
  const strictReleaseAge = withAubeParanoid(requireConfigKey({
1959
2199
  bindings: { aube: {
@@ -1970,7 +2210,7 @@ const strictReleaseAge = withAubeParanoid(requireConfigKey({
1970
2210
  title: "Enforce strict release age gate"
1971
2211
  }));
1972
2212
  //#endregion
1973
- //#region src/domain/rules/strict-store-integrity.ts
2213
+ //#region src/core/rules/strict-store-integrity.ts
1974
2214
  const { aubeWorkspace: aubeWorkspace$1 } = CONFIG_FILES;
1975
2215
  const strictStoreIntegrity = defineRule({
1976
2216
  bindings: { aube: {
@@ -2010,20 +2250,21 @@ const strictStoreIntegrity = defineRule({
2010
2250
  title: "Require tarball integrity metadata"
2011
2251
  });
2012
2252
  //#endregion
2013
- //#region src/domain/rules/trust-policy.ts
2253
+ //#region src/core/rules/trust-policy.ts
2014
2254
  const { aubeWorkspace, pnpmWorkspace } = CONFIG_FILES;
2015
2255
  const trustPolicy = withAubeParanoid(requireConfigKey({
2016
2256
  bindings: {
2017
2257
  aube: {
2018
2258
  docs: "https://aube.jdx.dev/security.html",
2019
2259
  documentedDefault: "no-downgrade",
2260
+ defaultSafety: "unconditional",
2020
2261
  file: aubeWorkspace,
2021
2262
  keyPath: ["trustPolicy"],
2022
2263
  message: "Set `trustPolicy: no-downgrade` in aube-workspace.yaml to block packages whose trust level has decreased.",
2023
2264
  value: "no-downgrade"
2024
2265
  },
2025
2266
  pnpm: {
2026
- docs: "https://pnpm.io/settings#trustpolicy",
2267
+ docs: "https://pnpm.io/settings/dependency-resolution#trustpolicy",
2027
2268
  file: pnpmWorkspace,
2028
2269
  keyPath: ["trustPolicy"],
2029
2270
  message: "Set `trustPolicy: no-downgrade` in pnpm-workspace.yaml to block packages whose trust level has decreased.",
@@ -2032,15 +2273,15 @@ const trustPolicy = withAubeParanoid(requireConfigKey({
2032
2273
  }
2033
2274
  },
2034
2275
  description: "Fail installation when a package trust level has decreased compared to previous releases, catching publisher credential downgrades.",
2035
- docs: "https://pnpm.io/settings#trustpolicy",
2276
+ docs: "https://pnpm.io/settings/dependency-resolution#trustpolicy",
2036
2277
  id: "trust-policy",
2037
2278
  severity: "warn",
2038
2279
  title: "Enforce trust policy on dependency updates"
2039
2280
  }));
2040
2281
  //#endregion
2041
- //#region src/domain/rules/unsupported-settings.ts
2042
- const bindingFor = (pm) => {
2043
- const settings = settingAvailability.filter((setting) => setting.pm === pm);
2282
+ //#region src/core/rules/unsupported-settings.ts
2283
+ const bindingFor = (pm, includesFile) => {
2284
+ const settings = settingAvailability.filter((setting) => setting.pm === pm && includesFile(setting.file.path));
2044
2285
  return { check(ctx) {
2045
2286
  const version = ctx.pmVersion;
2046
2287
  if (version === void 0) return { state: "na" };
@@ -2063,22 +2304,23 @@ const bindingFor = (pm) => {
2063
2304
  } : { state: "ok" };
2064
2305
  } };
2065
2306
  };
2066
- const unsupportedSettings = defineRule({
2307
+ const createUnsupportedSettings = (includesFile) => defineRule({
2067
2308
  id: "unsupported-settings",
2068
2309
  title: "Use settings available in the target PM version",
2069
- description: "Report configured settings whose recorded introduction version is newer than the declared or explicit stable PM target. Reports each affected file separately, grouping its unsupported keys. Workspace findings retain their member directory. Only the coverage table below is checked. Unknown targets and unlisted settings are not evaluated for availability.",
2310
+ description: "Report configured settings whose recorded introduction version is newer than the declared or explicit stable PM target. Reports each affected file separately, grouping its unsupported keys. Findings retain their evaluation directory. Only the coverage table below is checked. Unknown targets and unlisted settings are not evaluated for availability.",
2070
2311
  severity: "error",
2071
2312
  docs: "https://github.com/pHo9UBenaA/siro/blob/main/docs/rules.md#unsupported-settings--error",
2072
2313
  bindings: {
2073
- npm: bindingFor("npm"),
2074
- pnpm: bindingFor("pnpm"),
2075
- yarn: bindingFor("yarn"),
2076
- bun: bindingFor("bun"),
2077
- deno: bindingFor("deno")
2314
+ npm: bindingFor("npm", includesFile),
2315
+ pnpm: bindingFor("pnpm", includesFile),
2316
+ yarn: bindingFor("yarn", includesFile),
2317
+ bun: bindingFor("bun", includesFile),
2318
+ deno: bindingFor("deno", includesFile)
2078
2319
  }
2079
2320
  });
2321
+ const unsupportedSettings = createUnsupportedSettings(() => true);
2080
2322
  //#endregion
2081
- //#region src/domain/builtin-rules.ts
2323
+ //#region src/core/rules/builtin-rules.ts
2082
2324
  const createBuiltinRules = (time) => [
2083
2325
  advisoryCheck,
2084
2326
  approvedGitRepos,
@@ -2109,448 +2351,57 @@ const createBuiltinRules = (time) => [
2109
2351
  trustPolicy,
2110
2352
  unsupportedSettings
2111
2353
  ];
2112
- //#endregion
2113
- //#region src/composition/rules.ts
2114
- /** Read time at evaluation, never at module initialization. */
2115
- const rules = createBuiltinRules({
2116
- now: () => Date.now(),
2117
- parse: (value) => Date.parse(value)
2118
- });
2119
- //#endregion
2120
- //#region src/adapters/workspace-globs.ts
2121
- const MAX_WORKSPACE_GLOB_ALTERNATIVES = 8192;
2122
- const BRACE_EXPANSION_PROBE_LIMIT = 8193;
2123
- const boundedBraceExpand = (pattern) => {
2124
- const alternatives = braceExpand(pattern, { braceExpandMax: BRACE_EXPANSION_PROBE_LIMIT });
2125
- if (alternatives.length > MAX_WORKSPACE_GLOB_ALTERNATIVES) throw new ConfigError(`Workspace pattern expands beyond ${MAX_WORKSPACE_GLOB_ALTERNATIVES} alternatives.`);
2126
- return alternatives;
2127
- };
2128
- /** Expand with minimatch's own bounded brace semantics. */
2129
- const expandWorkspaceGlob = (pattern) => {
2130
- try {
2131
- return boundedBraceExpand(pattern);
2132
- } catch (error) {
2133
- if (error instanceof TypeError) throw new ConfigError(`Invalid workspace pattern: ${error.message}.`);
2134
- throw error;
2135
- }
2136
- };
2137
- /** Engine choices live here; the application supplies only PM matching policy. */
2138
- const engineOptions = (options) => {
2139
- if (options.kind === "declaration") return {};
2140
- return {
2141
- platform: "linux",
2142
- windowsPathsNoEscape: true,
2143
- nonegate: true,
2144
- nocomment: !options.hashComments,
2145
- nocase: options.caseInsensitive,
2146
- dot: options.includeDotDirectories ?? false,
2147
- nobrace: options.syntax === "wildcards",
2148
- noext: options.syntax === "wildcards" || options.extendedPatterns === false,
2149
- optimizationLevel: options.syntax === "shell" ? 2 : 1
2150
- };
2151
- };
2152
- /** Compile once so membership, exclusions, and traversal share glob semantics. */
2153
- const compileWorkspaceGlob = (pattern, options) => {
2154
- let matcher;
2354
+ const builtinScope = {
2355
+ "advisory-check": "installation",
2356
+ "approved-git-repos": "installation",
2357
+ "audit-suppression": "installation",
2358
+ "block-auto-install": "installation",
2359
+ "block-exotic-subdeps": "installation",
2360
+ "bun-security-scanner": "installation",
2361
+ "checksum-verification": "installation",
2362
+ "commit-lockfile": "installation",
2363
+ "dependency-overrides": "installation",
2364
+ "disable-lifecycle-scripts": "installation",
2365
+ "enforce-strict-ssl": "installation",
2366
+ "files-field": "manifest",
2367
+ "frozen-lockfile": "installation",
2368
+ "frozen-store": "installation",
2369
+ "hardened-mode": "installation",
2370
+ "minimum-release-age": "installation",
2371
+ "named-registries": "installation",
2372
+ "paranoid-mode": "installation",
2373
+ "patched-dependencies": "installation",
2374
+ "pin-exact-versions": "installation",
2375
+ provenance: "installation",
2376
+ "publish-access": "manifest",
2377
+ "store-server": "installation",
2378
+ "strict-allow-scripts": "installation",
2379
+ "strict-release-age": "installation",
2380
+ "strict-store-integrity": "installation",
2381
+ "trust-policy": "installation",
2382
+ "unsupported-settings": "split"
2383
+ };
2384
+ const scopeOf = (id) => Object.hasOwn(builtinScope, id) ? builtinScope[id] : "custom";
2385
+ //#endregion
2386
+ //#region src/adapters/exclusions.ts
2387
+ const compileExclusions = (patterns) => {
2155
2388
  try {
2156
- const settings = engineOptions(options);
2157
- if (!settings.nobrace) boundedBraceExpand(pattern);
2158
- matcher = new Minimatch(options.kind === "directory" && options.syntax === "wildcards" ? pattern.replace(/[[\]]/gu, (character) => character === "[" ? "[[]" : "[]]") : pattern, {
2159
- ...settings,
2160
- braceExpandMax: BRACE_EXPANSION_PROBE_LIMIT
2161
- });
2389
+ const matchers = patterns.map((pattern) => new Minimatch(pattern.replace(/(?:\/\*\*)+$/u, "").replace(/[\\[\]]/gu, "\\$&"), {
2390
+ dot: true,
2391
+ nocase: false,
2392
+ nonegate: true,
2393
+ nocomment: true,
2394
+ nobrace: true,
2395
+ noext: true,
2396
+ platform: "linux"
2397
+ }));
2398
+ return (directory) => matchers.some((matcher) => matcher.match(directory));
2162
2399
  } catch (error) {
2163
- if (error instanceof TypeError) throw new ConfigError(`Invalid workspace pattern: ${error.message}.`);
2164
- throw error;
2400
+ throw new ConfigError(`exclude: ${error instanceof Error ? error.message : String(error)}`);
2165
2401
  }
2166
- return {
2167
- matches: (directory) => matcher.match(directory),
2168
- canDescend(directory) {
2169
- const parts = directory.split("/");
2170
- return matcher.set.some((alternative) => (alternative.includes(GLOBSTAR) || parts.length < alternative.length) && matcher.matchOne(parts, alternative, true));
2171
- }
2172
- };
2173
- };
2174
- const minimatchGlobs = {
2175
- expand: expandWorkspaceGlob,
2176
- compile: compileWorkspaceGlob
2177
- };
2178
- //#endregion
2179
- //#region src/domain/services/parse-config-file.ts
2180
- const EMPTY_FILE = Object.freeze({});
2181
- /** Create a fresh parser and cache for one lint command. */
2182
- const createConfigParser = (codecFor, ctx) => {
2183
- const cache = /* @__PURE__ */ new Map();
2184
- return (file) => {
2185
- if (file === void 0) return EMPTY_FILE;
2186
- const cacheKey = `${file.kind}:${file.path}`;
2187
- const cached = cache.get(cacheKey);
2188
- if (cached !== void 0) return cached;
2189
- const text = ctx.readText(file.path);
2190
- if (text === void 0) {
2191
- cache.set(cacheKey, EMPTY_FILE);
2192
- return EMPTY_FILE;
2193
- }
2194
- const parsed = wrapCodecError(file.path, () => codecFor(file.kind).parse(text));
2195
- cache.set(cacheKey, parsed);
2196
- return parsed;
2197
- };
2198
2402
  };
2199
2403
  //#endregion
2200
- //#region src/application/workspace-pattern.ts
2201
- /** Detect the basic * and ? markers shared across workspace dialects. */
2202
- const hasBasicWorkspaceWildcard = (pattern) => pattern.includes("*") || pattern.includes("?");
2203
- /** Workspace declarations use POSIX separators regardless of the host platform. */
2204
- const stripTrailingWorkspaceSlashes = (pattern) => {
2205
- let end = pattern.length;
2206
- while (end > 0 && pattern[end - 1] === "/") end -= 1;
2207
- return pattern.slice(0, end);
2208
- };
2209
- //#endregion
2210
- //#region src/application/workspace-prefix.ts
2211
- /** Anchor Deno's native literal prefix before applying its glob suffix. */
2212
- const anchorWorkspacePrefix = (pattern, glob, resolveChild) => {
2213
- const parts = pattern.split("/");
2214
- const firstWildcard = parts.findIndex(hasBasicWorkspaceWildcard);
2215
- const prefix = parts.slice(0, firstWildcard < 0 ? parts.length : firstWildcard);
2216
- if (prefix.length === 0 || pattern === ".") return glob;
2217
- const declared = prefix.join("/");
2218
- let resolved;
2219
- let attempted = false;
2220
- const resolve = () => {
2221
- if (!attempted) {
2222
- attempted = true;
2223
- let current = ".";
2224
- for (const part of prefix) {
2225
- const child = resolveChild(current, part);
2226
- if (child === void 0) return void 0;
2227
- current = current === "." ? child : `${current}/${child}`;
2228
- }
2229
- resolved = current;
2230
- }
2231
- return resolved;
2232
- };
2233
- const rewrite = (directory, actual) => directory === actual ? declared : directory.startsWith(`${actual}/`) ? declared + directory.slice(actual.length) : void 0;
2234
- return {
2235
- matches(directory) {
2236
- if (directory === ".") return false;
2237
- const actual = resolve();
2238
- const rewritten = actual === void 0 ? void 0 : rewrite(directory, actual);
2239
- return rewritten !== void 0 && glob.matches(rewritten);
2240
- },
2241
- canDescend(directory) {
2242
- const actual = resolve();
2243
- if (actual === void 0) return false;
2244
- if (directory === "." || actual.startsWith(`${directory}/`)) return true;
2245
- const rewritten = rewrite(directory, actual);
2246
- return rewritten !== void 0 && glob.canDescend(rewritten);
2247
- }
2248
- };
2249
- };
2250
- //#endregion
2251
- //#region src/application/workspace-glob-policy.ts
2252
- const workspaceGlobOptions = (caseInsensitive) => ({
2253
- kind: "directory",
2254
- syntax: "shell",
2255
- caseInsensitive
2256
- });
2257
- //#endregion
2258
- //#region src/application/workspace-dialects.ts
2259
- const containsUnsupportedAubeGlobSyntax = (pattern) => /[[\]{}()]/u.test(pattern);
2260
- const matchesCrossPathGlob = (pattern, value) => {
2261
- const patternCharacters = [...pattern];
2262
- const valueCharacters = [...value];
2263
- let patternIndex = 0;
2264
- let valueIndex = 0;
2265
- let starIndex = -1;
2266
- let retryIndex = 0;
2267
- while (valueIndex < valueCharacters.length) {
2268
- const character = patternCharacters[patternIndex];
2269
- if (character === "?" || character === valueCharacters[valueIndex]) {
2270
- patternIndex += 1;
2271
- valueIndex += 1;
2272
- } else if (character === "*") {
2273
- starIndex = patternIndex;
2274
- retryIndex = valueIndex;
2275
- patternIndex += 1;
2276
- } else if (starIndex >= 0) {
2277
- patternIndex = starIndex + 1;
2278
- retryIndex += 1;
2279
- valueIndex = retryIndex;
2280
- } else return false;
2281
- }
2282
- while (patternCharacters[patternIndex] === "*") patternIndex += 1;
2283
- return patternIndex === patternCharacters.length;
2284
- };
2285
- /** The bounded glob subset verified against the recorded Deno/Aube sources. */
2286
- const compileAdditionalWorkspaceGlob = (pattern, pm, excluded, caseInsensitive, globs) => {
2287
- const parts = pattern.split("/");
2288
- if (parts.some((part) => part.includes("**") && part !== "**")) throw new ConfigError(`${pm}: ** must occupy an entire workspace path component.`);
2289
- if (pm === "aube" && (containsUnsupportedAubeGlobSyntax(pattern) || pattern.includes("**") && (parts.at(-1) !== "**" || hasBasicWorkspaceWildcard(parts.slice(0, -1).join("/"))))) throw new ConfigError("Aube workspace inspection supports literals, * and ?, and a trailing ** after a literal prefix; other glob forms are not yet supported.");
2290
- const matcher = globs.compile(pattern, {
2291
- kind: "directory",
2292
- syntax: "wildcards",
2293
- includeDotDirectories: pm === "aube",
2294
- caseInsensitive: pm === "deno" && (hasBasicWorkspaceWildcard(pattern) || caseInsensitive)
2295
- });
2296
- if (pm === "deno" && hasBasicWorkspaceWildcard(pattern)) {
2297
- const files = globs.compile(`${pattern}/package.json`, {
2298
- kind: "directory",
2299
- syntax: "wildcards",
2300
- caseInsensitive: true
2301
- });
2302
- return {
2303
- ...matcher,
2304
- matches: (directory) => files.matches(directory === "." ? "package.json" : `${directory}/package.json`)
2305
- };
2306
- }
2307
- if (pm !== "aube" || !excluded) return matcher;
2308
- const self = pattern.endsWith("/**") ? pattern.slice(0, -3) : void 0;
2309
- return {
2310
- ...matcher,
2311
- matches: (directory) => matchesCrossPathGlob(pattern, directory) || directory === self
2312
- };
2313
- };
2314
- //#endregion
2315
- //#region src/application/workspaces.ts
2316
- /** npm cancels an earlier exclusion when a later positive pattern matches it. */
2317
- const splitNpmPattern = (raw) => {
2318
- const prefix = /^!+/u.exec(raw)?.[0] ?? "";
2319
- return {
2320
- excluded: prefix.length % 2 === 1,
2321
- pattern: raw.slice(prefix.length)
2322
- };
2323
- };
2324
- const npmPatterns = (patterns, globs) => {
2325
- const positive = [];
2326
- let negative = [];
2327
- for (const raw of patterns) {
2328
- const parsed = splitNpmPattern(raw);
2329
- const pattern = parsed.pattern.replace(/^\.?\/+/u, "");
2330
- if (parsed.excluded) negative.push({
2331
- pattern,
2332
- glob: globs.compile(pattern, { kind: "declaration" })
2333
- });
2334
- else {
2335
- for (let index = 0; index < negative.length; index += 1) if (negative[index]?.glob.matches(pattern)) negative.splice(index, 1);
2336
- positive.push(pattern);
2337
- }
2338
- }
2339
- return [...positive, ...negative.map(({ pattern }) => `!${pattern}`)];
2340
- };
2341
- /** Read declaration sources separately so Deno's two manifest sets stay distinct. */
2342
- const workspaceDefinitions = (ctx, pm, dependencies) => {
2343
- const { codecFor, paths, globs } = dependencies;
2344
- const parse = createConfigParser(codecFor, ctx);
2345
- const validate = (value, source, denoManifests = false) => {
2346
- if (value === void 0) return {
2347
- patterns: [],
2348
- denoManifests
2349
- };
2350
- if (!Array.isArray(value) || !Array.from(value).every((item) => typeof item === "string")) throw new ConfigError(`${source}: expected an array of directory patterns.`);
2351
- for (const pattern of value) {
2352
- const positive = pm === "npm" ? splitNpmPattern(pattern).pattern : pattern.startsWith("!") ? pattern.slice(1) : pattern;
2353
- if ((pm === "deno" || pm === "aube" ? [positive] : globs.expand(positive)).some((alternative) => !isRelPath(alternative) || alternative.includes("\\") || alternative.startsWith("!"))) throw new ConfigError(`${source}: use relative directory patterns without parent traversal: ${JSON.stringify(pattern)}.`);
2354
- if (pm === "deno" && denoManifests && !pattern.startsWith("!") && paths.normalizePattern(positive) === ".") throw new ConfigError(`${source}: a Deno workspace cannot contain itself.`);
2355
- }
2356
- return {
2357
- patterns: pm === "npm" ? npmPatterns(value, globs) : value,
2358
- denoManifests
2359
- };
2360
- };
2361
- const packageDefinition = () => {
2362
- let value = ctx.packageJson?.workspaces;
2363
- if (isPlainRecord(value)) {
2364
- value = value.packages;
2365
- if (value === void 0) throw new ConfigError("package.json#workspaces: expected a packages array.");
2366
- }
2367
- return validate(value, "package.json#workspaces");
2368
- };
2369
- if (pm === "pnpm") return [validate(ctx.exists(CONFIG_FILES.pnpmWorkspace.path) ? parse(CONFIG_FILES.pnpmWorkspace).packages : void 0, "pnpm-workspace.yaml#packages")];
2370
- if (pm === "aube") {
2371
- const file = [CONFIG_FILES.aubeWorkspace, CONFIG_FILES.pnpmWorkspace].find((candidate) => ctx.exists(candidate.path));
2372
- return [file ? validate(parse(file).packages, `${file.path}#packages`) : packageDefinition()];
2373
- }
2374
- if (pm === "deno") return [validate(parse(CONFIG_FILES.denoJson).workspace, "deno.json#workspace", true), packageDefinition()];
2375
- return [packageDefinition()];
2376
- };
2377
- /** Traverse only ordinary directories that can fall under a positive pattern. */
2378
- const workspaceDirectories = (ctx, fs, definition, pm, dependencies) => {
2379
- const { codecFor, paths, globs, caseInsensitiveGlobs } = dependencies;
2380
- const defaultWorkspaceGlobOptions = workspaceGlobOptions(caseInsensitiveGlobs);
2381
- const { patterns } = definition;
2382
- const skipVendor = pm === "deno" && createConfigParser(codecFor, ctx)(CONFIG_FILES.denoJson).vendor === true;
2383
- const positive = patterns.filter((pattern) => !pattern.startsWith("!")).map((pattern) => stripTrailingWorkspaceSlashes(paths.normalizePattern(pattern)));
2384
- const negative = patterns.filter((pattern) => pattern.startsWith("!")).map((pattern) => stripTrailingWorkspaceSlashes(paths.normalizePattern(pattern.slice(1))));
2385
- const directoryCache = /* @__PURE__ */ new Map();
2386
- const readDirectories = (directory) => {
2387
- const cached = directoryCache.get(directory);
2388
- if (cached) return cached;
2389
- if (!fs.readDirectories) throw new UsageError("Workspace discovery requires FileSystem.readDirectories; no host filesystem fallback is used.");
2390
- const names = fs.readDirectories(paths.resolve(ctx.root, asRelPath(directory)));
2391
- if (!Array.isArray(names)) throw new ConfigError("FileSystem.readDirectories must return an array of directory names.");
2392
- for (const name of Array.from(names)) if (!isRelPath(name) || name === "." || name === ".." || /[\\/]/u.test(name)) throw new ConfigError("FileSystem.readDirectories returned an invalid child directory name.");
2393
- const result = [...new Set(names)].sort();
2394
- directoryCache.set(directory, result);
2395
- return result;
2396
- };
2397
- const resolveChild = (parent, name) => {
2398
- if (name === ".git" || name === "node_modules") return void 0;
2399
- const names = readDirectories(parent);
2400
- const resolved = names.includes(name) ? name : fs.resolveDirectory?.(paths.resolve(ctx.root, asRelPath(parent)), name);
2401
- if (resolved !== void 0 && !names.includes(resolved)) throw new ConfigError("FileSystem.resolveDirectory must return an enumerated ordinary child directory name.");
2402
- return resolved === ".git" || resolved === "node_modules" ? void 0 : resolved;
2403
- };
2404
- const compile = (pattern, excluded = false) => {
2405
- if (pm !== "deno" && pm !== "aube") return globs.compile(pattern, pm === "npm" ? {
2406
- ...defaultWorkspaceGlobOptions,
2407
- hashComments: true
2408
- } : defaultWorkspaceGlobOptions);
2409
- const glob = compileAdditionalWorkspaceGlob(pattern, pm, excluded, caseInsensitiveGlobs, globs);
2410
- return pm === "deno" && !excluded ? anchorWorkspacePrefix(pattern, glob, resolveChild) : glob;
2411
- };
2412
- const included = positive.map((pattern) => compile(pattern));
2413
- const excluded = negative.map((pattern) => {
2414
- return {
2415
- glob: compile(pattern, true),
2416
- subtree: pm === "deno" || pm === "aube" || pm === "bun" ? void 0 : pattern === "**" ? { matches: () => true } : pattern.endsWith("/**") ? compile(pattern.slice(0, -3), true) : void 0
2417
- };
2418
- });
2419
- const declaredBase = (pattern) => {
2420
- const parts = pattern.split("/");
2421
- const wildcard = parts.findIndex(hasBasicWorkspaceWildcard);
2422
- return parts.slice(0, wildcard < 0 ? parts.length : wildcard).join("/") || ".";
2423
- };
2424
- const relatedBases = (left, right) => left === "." || right === "." || left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`);
2425
- const denoPositive = pm === "deno" ? positive.filter(hasBasicWorkspaceWildcard).map((pattern) => ({
2426
- base: declaredBase(pattern),
2427
- scope: anchorWorkspacePrefix(declaredBase(pattern), {
2428
- matches: () => true,
2429
- canDescend: () => true
2430
- }, resolveChild)
2431
- })) : [];
2432
- const denoOrdered = pm === "deno" ? patterns.filter((pattern) => hasBasicWorkspaceWildcard(pattern) || pattern.startsWith("!")).map((raw) => {
2433
- const isExcluded = raw.startsWith("!");
2434
- const pattern = stripTrailingWorkspaceSlashes(paths.normalizePattern(isExcluded ? raw.slice(1) : raw));
2435
- const glob = compile(pattern, isExcluded);
2436
- if (!isExcluded) return {
2437
- excluded: false,
2438
- glob
2439
- };
2440
- return {
2441
- excluded: true,
2442
- glob: {
2443
- ...glob,
2444
- matches(directory) {
2445
- if (!hasBasicWorkspaceWildcard(pattern)) return directory === pattern;
2446
- if (!glob.matches(directory)) return false;
2447
- const applicable = denoPositive.filter((item) => item.scope.matches(directory)).map((item) => relatedBases(item.base, declaredBase(pattern)));
2448
- if (applicable.some(Boolean) && applicable.some((value) => !value)) throw new ConfigError("Deno workspace exclusion has ambiguous overlapping bases; use consistent prefix spelling or separate non-overlapping patterns.");
2449
- return applicable.some(Boolean);
2450
- }
2451
- }
2452
- };
2453
- }) : [];
2454
- const bunPatterns = pm === "bun" ? patterns.map((raw) => {
2455
- const isExcluded = raw.startsWith("!");
2456
- const pattern = stripTrailingWorkspaceSlashes(paths.normalizePattern(isExcluded ? raw.slice(1) : raw));
2457
- const usesGlob = isExcluded || hasBasicWorkspaceWildcard(pattern) || pattern.includes("{") || pattern.includes("[");
2458
- const glob = globs.compile(pattern, {
2459
- ...defaultWorkspaceGlobOptions,
2460
- extendedPatterns: false
2461
- });
2462
- const subtree = isExcluded && pattern.endsWith("/**") ? globs.compile(pattern.slice(0, -3), {
2463
- ...defaultWorkspaceGlobOptions,
2464
- extendedPatterns: false
2465
- }) : void 0;
2466
- return {
2467
- isExcluded,
2468
- usesGlob,
2469
- glob,
2470
- matches: (directory) => glob.matches(directory) || glob.matches(`${directory}/`) || subtree?.matches(directory) === true
2471
- };
2472
- }) : [];
2473
- const bunOrdered = bunPatterns.filter(({ usesGlob }) => usesGlob);
2474
- const bunLiterals = bunPatterns.filter(({ isExcluded, usesGlob }) => !isExcluded && !usesGlob);
2475
- const bunTraversal = bunPatterns.filter(({ isExcluded }) => !isExcluded).map(({ glob }) => glob);
2476
- const denoLiteral = pm === "deno" ? positive.filter((pattern) => !hasBasicWorkspaceWildcard(pattern)).map((pattern) => compile(pattern)) : [];
2477
- if (definition.denoManifests && denoOrdered.findLast(({ glob }) => glob.matches("."))?.excluded === false) throw new ConfigError("deno.json#workspace: a Deno workspace cannot contain itself.");
2478
- if (positive.length === 0 || positive.every((pattern) => pattern === ".")) return [];
2479
- if (!fs.readDirectories) throw new UsageError("Workspace discovery requires FileSystem.readDirectories; no host filesystem fallback is used.");
2480
- const result = [];
2481
- const pending = ["."];
2482
- while (pending.length > 0) {
2483
- const current = pending.pop();
2484
- if (current === void 0) break;
2485
- for (const name of readDirectories(current)) {
2486
- if (name === ".git" || name === "node_modules") continue;
2487
- const directory = asRelPath(current === "." ? name : `${current}/${name}`);
2488
- if (pm === "bun" && name === "CMakeFiles" && !bunLiterals.some(({ glob }) => glob.matches(directory) || glob.canDescend(directory))) continue;
2489
- const inVendor = skipVendor && (directory === "vendor" || directory.startsWith("vendor/"));
2490
- if (inVendor && !denoLiteral.some((pattern) => pattern.matches(directory) || pattern.canDescend(directory))) continue;
2491
- const isExcluded = pm !== "deno" && excluded.some(({ glob }) => glob.matches(directory) || glob.matches(`${directory}/`));
2492
- if (excluded.some(({ subtree }) => subtree?.matches(directory))) continue;
2493
- const lastDenoMatch = denoOrdered.findLast(({ glob }) => glob.matches(directory));
2494
- const lastBunMatch = bunOrdered.findLast(({ matches }) => matches(directory));
2495
- if (pm === "deno" ? denoLiteral.some((pattern) => pattern.matches(directory)) || !inVendor && lastDenoMatch?.excluded === false : pm === "bun" ? bunLiterals.some(({ glob }) => glob.matches(directory)) || lastBunMatch?.isExcluded === false : !isExcluded && included.some((pattern) => pattern.matches(directory))) result.push(directory);
2496
- if ((pm === "bun" ? bunTraversal : inVendor ? denoLiteral : included).some((pattern) => pattern.canDescend(directory))) pending.push(directory);
2497
- }
2498
- }
2499
- return result.sort();
2500
- };
2501
- //#endregion
2502
- //#region src/application/workspace-contexts.ts
2503
- /** Expand each manager's declarations into isolated publication contexts. */
2504
- const collectWorkspaceMembers = (ctx, fs, pms, projectType, dependencies) => {
2505
- const { codecFor, createRepoContext, paths } = dependencies;
2506
- return pms.flatMap((pm) => {
2507
- const seen = /* @__PURE__ */ new Set();
2508
- return workspaceDefinitions(ctx, pm, dependencies).flatMap((definition) => workspaceDirectories(ctx, fs, definition, pm, dependencies).flatMap((directory) => {
2509
- const root = paths.resolve(ctx.root, directory);
2510
- const manifest = paths.resolve(root, asRelPath("package.json"));
2511
- const denoManifest = paths.resolve(root, asRelPath("deno.json"));
2512
- const denoJsonc = paths.resolve(root, asRelPath("deno.jsonc"));
2513
- const allowed = new Set(pm === "deno" ? [
2514
- manifest,
2515
- denoManifest,
2516
- denoJsonc
2517
- ] : [manifest]);
2518
- const memberFs = {
2519
- exists: (file) => allowed.has(file) && fs.exists(file),
2520
- readText: (file) => allowed.has(file) ? fs.readText(file) : void 0
2521
- };
2522
- try {
2523
- const explicitMember = definition.patterns.some((pattern) => !pattern.startsWith("!") && !hasBasicWorkspaceWildcard(pattern) && stripTrailingWorkspaceSlashes(paths.normalizePattern(pattern)) === directory);
2524
- if (!definition.denoManifests && !fs.exists(manifest)) {
2525
- if (pm === "deno" && explicitMember) throw new ConfigError("Declared npm workspace member has no package.json.");
2526
- return [];
2527
- }
2528
- if (pm === "deno" && !fs.exists(denoManifest) && fs.exists(denoJsonc)) throw new ConfigError("deno.jsonc is not supported; use strict deno.json.");
2529
- const memberCtx = createRepoContext(root, memberFs, projectType);
2530
- if (!definition.denoManifests && !memberCtx.packageJson) return [];
2531
- if (!memberCtx.packageJson && !memberCtx.exists(asRelPath("deno.json"))) {
2532
- if (definition.denoManifests && explicitMember) throw new ConfigError("Declared Deno member has no deno.json or package.json.");
2533
- return [];
2534
- }
2535
- if (pm === "deno") {
2536
- if (createConfigParser(codecFor, memberCtx)(CONFIG_FILES.denoJson).workspace !== void 0 || memberCtx.packageJson?.workspaces !== void 0) throw new ConfigError("Nested workspace declarations are not supported by Deno.");
2537
- }
2538
- if (seen.has(directory)) return [];
2539
- seen.add(directory);
2540
- return [{
2541
- directory,
2542
- pm,
2543
- ctx: memberCtx
2544
- }];
2545
- } catch (error) {
2546
- if (error instanceof ConfigError) throw new ConfigError(`${directory}/${error.message}`);
2547
- throw error;
2548
- }
2549
- }));
2550
- });
2551
- };
2552
- //#endregion
2553
- //#region src/domain/services/apply-config.ts
2404
+ //#region src/core/apply-config.ts
2554
2405
  /** Select active rules and keep user severity choices as explicit runtime data. */
2555
2406
  const applyConfig = (base, config) => {
2556
2407
  const merged = [...base, ...config?.customRules ?? []];
@@ -2578,7 +2429,7 @@ const applyConfig = (base, config) => {
2578
2429
  };
2579
2430
  };
2580
2431
  //#endregion
2581
- //#region src/domain/services/detect-pms.ts
2432
+ //#region src/core/detect-pms.ts
2582
2433
  const registerSignals = (signals, pm, owner) => {
2583
2434
  for (const file of signals) {
2584
2435
  const prior = owner.get(file);
@@ -2610,24 +2461,25 @@ const detectPMs = (ctx) => {
2610
2461
  return PMS.filter((pm) => found.has(pm));
2611
2462
  };
2612
2463
  //#endregion
2613
- //#region src/domain/services/resolve-pms.ts
2464
+ //#region src/core/resolve-pms.ts
2614
2465
  /** Apply the explicit selection or detection, then the configured allow-list. */
2615
2466
  const resolvePMs = (ctx, opts) => {
2616
2467
  const { pmOverride, allowed } = opts;
2617
2468
  const detected = pmOverride ? [pmOverride] : detectPMs(ctx);
2618
2469
  const pms = allowed ? detected.filter((pm) => allowed.includes(pm)) : detected;
2619
2470
  if (pms.length > 0) return pms;
2471
+ if (opts.optional && detected.length === 0) return [];
2620
2472
  if (!allowed) throw new UsageError(`No package manager detected. Pass --pm <${PMS.join("|")}> to be explicit.`);
2621
2473
  if (pmOverride) throw new UsageError(`--pm ${pmOverride} is not in siro.config.ts pms (${allowed.join(", ")}). Adjust the config or the flag.`);
2622
2474
  if (detected.length > 0) throw new UsageError(`Detected PMs (${detected.join(", ")}) do not match siro.config.ts pms (${allowed.join(", ")}). Adjust the config or remove the restriction.`);
2623
2475
  throw new UsageError(`No package manager detected, and siro.config.ts restricts pms to ${allowed.join(", ")}. Adjust the config or remove the restriction.`);
2624
2476
  };
2625
2477
  //#endregion
2626
- //#region src/domain/services/decide-severity.ts
2478
+ //#region src/core/decide-severity.ts
2627
2479
  /** Resolve the most specific severity signal for one finding. */
2628
2480
  const decideSeverity = (status, binding, rule, userOverride) => userOverride ?? status.severity ?? binding.severity ?? rule.severity;
2629
2481
  //#endregion
2630
- //#region src/domain/services/render-version-note.ts
2482
+ //#region src/core/render-version-note.ts
2631
2483
  const buildVersionNoteParts = (versionNote) => {
2632
2484
  const parts = [];
2633
2485
  if (typeof versionNote.configAvailableSince !== "undefined") parts.push(`available since ${versionNote.configAvailableSince}`);
@@ -2642,130 +2494,375 @@ const renderVersionNoteMessage = (message, versionNote) => {
2642
2494
  return `${message} (${parts.join("; ")})`;
2643
2495
  };
2644
2496
  //#endregion
2645
- //#region src/application/run-lint.ts
2497
+ //#region src/core/evaluate-binding.ts
2498
+ /** One response boundary for generic and PM-specific checks. Selection stays with callers. */
2499
+ const evaluateBinding = (repository, rule, binding, overrides, target) => {
2500
+ const response = binding.check({
2501
+ ...repository.ctx,
2502
+ readConfig: repository.parseConfig,
2503
+ pmVersion: target?.version
2504
+ }, repository.parseConfig(binding.file));
2505
+ assertSynchronous(response, `Rule '${rule.id}' check`);
2506
+ if (!isCheckStatusShape(response)) throw new ConfigError(`Rule '${rule.id}' returned an invalid check result.`);
2507
+ return (response.state === "violations" ? response.violations : [response]).flatMap((status) => status.state !== "violation" ? [] : [{
2508
+ ruleId: rule.id,
2509
+ directory: ".",
2510
+ ...target ? { pm: target.pm } : {},
2511
+ severity: decideSeverity(status, binding, rule, overrides?.get(rule.id)),
2512
+ message: renderVersionNoteMessage(status.message, binding.versionNote),
2513
+ file: status.file ?? binding.file?.path,
2514
+ docs: binding.docs ?? rule.docs,
2515
+ actual: status.actual,
2516
+ expected: status.expected,
2517
+ remediation: target ? guardRemediationAvailability(target.pm, target.version, status.remediation) : status.remediation
2518
+ }]);
2519
+ };
2520
+ //#endregion
2521
+ //#region src/core/run-lint.ts
2646
2522
  const resolveBindingProjectType = (ctx, pm, parseConfig) => {
2647
2523
  if (ctx.projectType !== void 0) return ctx.projectType;
2648
- if (pm === "deno") return resolveDenoProjectType(ctx, parseConfig(CONFIG_FILES.denoJson));
2649
- return resolvePackageJsonProjectType(ctx);
2524
+ return pm === "deno" ? resolveDenoProjectType(ctx, parseConfig(CONFIG_FILES.denoJson)) : resolvePackageJsonProjectType(ctx);
2650
2525
  };
2651
- /** Evaluate every applicable rule binding and collect violations. */
2526
+ /** Select applicable PM bindings; response handling is shared with manifest checks. */
2652
2527
  const runLint = (opts) => {
2653
- const { ctx, pms, ruleSet, severityOverrides, codecFor } = opts;
2528
+ const { repository, pms, ruleSet, severityOverrides } = opts;
2654
2529
  const findings = [];
2655
- const summary = {
2656
- error: 0,
2657
- info: 0,
2658
- warn: 0
2659
- };
2660
- const parseConfig = createConfigParser(codecFor, ctx);
2661
2530
  for (const rule of ruleSet) for (const pm of pms) {
2662
2531
  const binding = rule.bindings[pm];
2663
- if (!binding || rule.projectTypes && !rule.projectTypes.includes(resolveBindingProjectType(ctx, pm, parseConfig))) continue;
2664
- const ruleContext = {
2665
- ...ctx,
2666
- readConfig: parseConfig,
2667
- pmVersion: opts.pmVersions?.[pm]
2668
- };
2669
- const response = binding.check(ruleContext, parseConfig(binding.file));
2670
- if (!isCheckStatusShape(response)) throw new ConfigError(`Rule '${rule.id}' returned an invalid check result.`);
2671
- const statuses = response.state === "violations" ? response.violations : [response];
2672
- for (const status of statuses) {
2673
- if (status.state !== "violation") continue;
2674
- const finding = {
2675
- ruleId: rule.id,
2676
- pm,
2677
- severity: decideSeverity(status, binding, rule, severityOverrides?.get(rule.id)),
2678
- message: renderVersionNoteMessage(status.message, binding.versionNote),
2679
- file: status.file ?? binding.file?.path,
2680
- docs: binding.docs ?? rule.docs,
2681
- actual: status.actual,
2682
- expected: status.expected,
2683
- remediation: guardRemediationAvailability(pm, ruleContext.pmVersion, status.remediation)
2684
- };
2685
- findings.push(finding);
2686
- summary[finding.severity] += 1;
2532
+ if (!binding || rule.projectTypes && !rule.projectTypes.includes(resolveBindingProjectType(repository.ctx, pm, repository.parseConfig))) continue;
2533
+ findings.push(...evaluateBinding(repository, rule, binding, severityOverrides, {
2534
+ pm,
2535
+ version: opts.pmVersions?.[pm]
2536
+ }));
2537
+ }
2538
+ return { findings };
2539
+ };
2540
+ //#endregion
2541
+ //#region src/core/contracts/deno-json.ts
2542
+ /** Validate consumed publication metadata, not Deno's entire versioned schema. */
2543
+ const validateDenoMetadata = (config) => {
2544
+ const name = getByPath(config, ["name"]);
2545
+ if (name != null && typeof name !== "string") throw new ConfigError("deno.json: name must be a string or null.");
2546
+ const publish = getByPath(config, ["publish"]);
2547
+ if (publish == null || typeof publish === "boolean") return;
2548
+ if (!isPlainRecord(publish)) throw new ConfigError("deno.json: publish must be a boolean, object or null.");
2549
+ const include = getByPath(publish, ["include"]);
2550
+ if (include != null && (!Array.isArray(include) || !Array.from(include).every((item) => typeof item === "string"))) throw new ConfigError("deno.json: publish.include must be a string array or null.");
2551
+ };
2552
+ //#endregion
2553
+ //#region src/core/parse-config-file.ts
2554
+ const EMPTY_FILE = Object.freeze({});
2555
+ /** Create a lazy parser for one repository context; callers own its lifetime. */
2556
+ const createConfigParser = (codecFor, ctx, maxDepth = DEFAULT_SCAN_LIMITS.maxConfigDepth) => {
2557
+ const cache = /* @__PURE__ */ new Map();
2558
+ return (file) => {
2559
+ if (file === void 0) return EMPTY_FILE;
2560
+ const cacheKey = `${file.kind}:${file.path}`;
2561
+ const cached = cache.get(cacheKey);
2562
+ if (cached !== void 0) return cached;
2563
+ const text = ctx.readText(file.path);
2564
+ if (text === void 0) {
2565
+ cache.set(cacheKey, EMPTY_FILE);
2566
+ return EMPTY_FILE;
2567
+ }
2568
+ const parsed = wrapCodecError(file.path, () => codecFor(file.kind).parse(text));
2569
+ checkConfigDepth(parsed, maxDepth);
2570
+ cache.set(cacheKey, parsed);
2571
+ return parsed;
2572
+ };
2573
+ };
2574
+ /** Pair the context with a fresh lazy parser; construction performs no reads. */
2575
+ const createRepositoryEvaluation = (ctx, codecFor, maxDepth = DEFAULT_SCAN_LIMITS.maxConfigDepth) => ({
2576
+ ctx,
2577
+ parseConfig: createConfigParser(codecFor, ctx, maxDepth)
2578
+ });
2579
+ //#endregion
2580
+ //#region src/core/discovery.ts
2581
+ /** One context per selected directory, shared by publication and installation passes. */
2582
+ const discover = (cwd, fs, excluded, projectType, dependencies, limits = DEFAULT_SCAN_LIMITS) => {
2583
+ const { paths, createRepoContext, codecFor } = dependencies;
2584
+ const pending = [asRelPath(".")];
2585
+ const directories = [];
2586
+ while (pending.length) {
2587
+ const directory = pending.pop();
2588
+ checkLimit("maxDirectories", directories.length + pending.length + 1, limits);
2589
+ checkLimit("maxDirectoryDepth", directory === "." ? 0 : directory.split("/").length, limits);
2590
+ const absolute = paths.resolve(cwd, directory);
2591
+ let repository;
2592
+ const manifests = [];
2593
+ try {
2594
+ repository = createRepositoryEvaluation(createRepoContext(absolute, fs, projectType), codecFor, limits.maxConfigDepth);
2595
+ const { ctx, parseConfig } = repository;
2596
+ checkConfigDepth(ctx.packageJson, limits.maxConfigDepth);
2597
+ if (ctx.packageJson !== void 0) manifests.push("package.json");
2598
+ if (ctx.readText(CONFIG_FILES.denoJson.path) !== void 0) {
2599
+ validateDenoMetadata(parseConfig(CONFIG_FILES.denoJson));
2600
+ manifests.push("deno.json");
2601
+ } else if (ctx.exists(asRelPath("deno.jsonc"))) throw new ConfigError("deno.jsonc is not supported; use strict deno.json.");
2602
+ } catch (error) {
2603
+ if (error instanceof ConfigError && directory !== ".") throw new ConfigError(`${directory}/${error.message}`);
2604
+ throw error;
2605
+ }
2606
+ directories.push({
2607
+ directory,
2608
+ repository,
2609
+ manifests
2610
+ });
2611
+ const names = fs.readDirectories(absolute);
2612
+ if (!Array.isArray(names)) throw new ConfigError(`${directory}: FileSystem.readDirectories must return a dense array.`);
2613
+ const children = /* @__PURE__ */ new Set();
2614
+ for (let i = 0; i < names.length; i += 1) {
2615
+ if (!Object.hasOwn(names, i)) throw new ConfigError(`${directory}: FileSystem.readDirectories must return a dense array.`);
2616
+ const child = paths.child(directory, names[i]);
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);
2620
+ children.add(child);
2621
+ pending.push(child);
2622
+ }
2623
+ }
2624
+ return directories.sort((a, b) => a.directory < b.directory ? -1 : a.directory > b.directory ? 1 : 0);
2625
+ };
2626
+ //#endregion
2627
+ //#region src/core/manifest-checks.ts
2628
+ const manifestProjectType = (repository, file) => file === "deno.json" ? resolveDenoProjectType(repository.ctx, repository.parseConfig(CONFIG_FILES.denoJson)) : resolvePackageJsonProjectType(repository.ctx);
2629
+ const selectBinding = {
2630
+ "files-field": (file) => file === "deno.json" ? denoPublishBinding : packageJsonFilesBinding,
2631
+ "publish-access": (file, repository, targets) => {
2632
+ if (file !== "package.json") return void 0;
2633
+ return repository.ctx.packageJson?.publishConfig?.access === "private" && targets.length > 0 ? "pm" : publishAccessBinding;
2634
+ }
2635
+ };
2636
+ const checkManifest = (repository, file, targets, rules, overrides) => {
2637
+ const findings = [];
2638
+ const projectType = manifestProjectType(repository, file);
2639
+ const pms = targets.map((target) => target.pm);
2640
+ const pmVersions = Object.fromEntries(targets.map((target) => [target.pm, target.version]));
2641
+ for (const rule of rules) {
2642
+ if (rule.projectTypes && !rule.projectTypes.includes(projectType)) continue;
2643
+ if (rule.id === "unsupported-settings") findings.push(...runLint({
2644
+ repository,
2645
+ pms,
2646
+ pmVersions,
2647
+ ruleSet: [{
2648
+ ...rule,
2649
+ bindings: createUnsupportedSettings((path) => path === file).bindings
2650
+ }],
2651
+ severityOverrides: overrides
2652
+ }).findings);
2653
+ else if (Object.hasOwn(selectBinding, rule.id)) {
2654
+ const binding = selectBinding[rule.id](file, repository, targets);
2655
+ if (binding === "pm") findings.push(...runLint({
2656
+ repository,
2657
+ pms,
2658
+ pmVersions,
2659
+ ruleSet: [rule],
2660
+ severityOverrides: overrides
2661
+ }).findings);
2662
+ else if (binding) findings.push(...evaluateBinding(repository, rule, binding, overrides));
2687
2663
  }
2688
2664
  }
2665
+ return findings;
2666
+ };
2667
+ //#endregion
2668
+ //#region src/core/rebase-finding.ts
2669
+ /** Context-local rule output becomes cwd-relative once, without mutating rule-owned data. */
2670
+ const rebaseFinding = (directory, finding) => {
2671
+ if (directory === ".") return {
2672
+ ...finding,
2673
+ directory
2674
+ };
2675
+ const remediation = finding.remediation;
2676
+ const rebaseOperation = (operation) => ({
2677
+ ...operation,
2678
+ file: {
2679
+ ...operation.file,
2680
+ path: `${directory}/${operation.file.path}`
2681
+ }
2682
+ });
2689
2683
  return {
2690
- findings,
2691
- summary
2684
+ ...finding,
2685
+ directory,
2686
+ ...finding.file === void 0 ? {} : { file: `${directory}/${finding.file}` },
2687
+ remediation: remediation?.kind === "automatic" ? {
2688
+ ...remediation,
2689
+ operations: [rebaseOperation(remediation.operations[0]), ...remediation.operations.slice(1).map(rebaseOperation)]
2690
+ } : remediation?.kind === "manual" ? {
2691
+ ...remediation,
2692
+ steps: [`Work in ${directory} for this finding.`, ...remediation.steps]
2693
+ } : remediation
2692
2694
  };
2693
2695
  };
2694
2696
  //#endregion
2695
- //#region src/application/lint.ts
2696
- /** Evaluate a repository without reporting or executing configuration files. */
2697
- const prepareLint = (options, dependencies) => {
2698
- const { paths, codecFor, createRepoContext } = dependencies;
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
2729
+ //#region src/core/lint.ts
2730
+ const validateLintOptions = (options, paths) => {
2699
2731
  if (!options || !paths.isAbsolute(options.cwd)) throw new UsageError("cwd must be an absolute filesystem path.");
2732
+ if ("workspaces" in options) throw new UsageError("workspaces was removed in 0.6.0. Package discovery is recursive by default; use exclude and installationRoots to specify scope.");
2700
2733
  if ("customRules" in options || "reporters" in options) throw new UsageError("Pass customRules and reporters inside the config option.");
2701
2734
  if (options.pm !== void 0 && !isPM(options.pm)) throw new UsageError(`Unknown package manager: ${String(options.pm)}`);
2702
2735
  if (options.projectType !== void 0 && !isProjectType(options.projectType)) throw new UsageError(`Unknown project type: ${String(options.projectType)}`);
2703
2736
  if (options.pmVersion !== void 0 && (!options.pm || !isStableVersion(options.pmVersion))) throw new UsageError("pmVersion / --pm-version requires pm / --pm and an exact stable version such as 10.16.0.");
2704
- if (options.workspaces !== void 0 && typeof options.workspaces !== "boolean") throw new UsageError("workspaces must be a boolean.");
2737
+ };
2738
+ /** Validate inputs and prepare shared contexts, without running rules or child executable configs. */
2739
+ const prepareLint = (options, dependencies) => {
2740
+ validateLintOptions(options, dependencies.paths);
2705
2741
  const config = options.config === void 0 ? void 0 : parseConfig(options.config);
2706
- const fs = options.fs === void 0 ? dependencies.fileSystem : options.fs;
2707
- const ctx = createRepoContext(options.cwd, fs, options.projectType ?? config?.projectType);
2708
- const pms = resolvePMs(ctx, {
2709
- allowed: config?.pms,
2710
- pmOverride: options.pm
2711
- });
2712
- if (pms.includes("deno") && !ctx.exists(asRelPath("deno.json")) && ctx.exists(asRelPath("deno.jsonc"))) throw new ConfigError("deno.jsonc is not supported. siro currently reads strict JSON from deno.json only.");
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);
2746
+ const excluded = dependencies.compileExclusions(parseExcludes(options.exclude !== void 0 ? options.exclude : config?.exclude ?? []));
2747
+ const installations = parseInstallationRoots(options.installationRoots !== void 0 ? options.installationRoots : config?.installationRoots ?? ["."]);
2713
2748
  const configured = applyConfig(dependencies.rules, config);
2749
+ const needsCustomTarget = configured.rules.some((rule) => scopeOf(rule.id) === "custom");
2750
+ const discovered = discover(options.cwd, fs, excluded, options.projectType ?? config?.projectType, dependencies, limits);
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).`);
2714
2752
  return {
2715
- ctx,
2716
- pms,
2717
- pmVersions: {
2718
- ...declaredPMVersion(ctx.packageJson?.packageManager),
2719
- ...config?.pmVersions,
2720
- ...options.pm && options.pmVersion ? { [options.pm]: options.pmVersion } : {}
2753
+ evaluation: {
2754
+ directories: discovered.map((item) => {
2755
+ const { ctx } = item.repository;
2756
+ const entry = installations.find((root) => root.path === item.directory);
2757
+ let pms;
2758
+ let versions = declaredPMVersion(ctx.packageJson?.packageManager);
2759
+ if (item.directory === ".") {
2760
+ pms = resolvePMs(ctx, {
2761
+ allowed: config?.pms,
2762
+ pmOverride: options.pm,
2763
+ optional: !entry && !needsCustomTarget
2764
+ });
2765
+ versions = {
2766
+ ...versions,
2767
+ ...config?.pmVersions,
2768
+ ...options.pm && options.pmVersion ? { [options.pm]: options.pmVersion } : {}
2769
+ };
2770
+ } else if (entry) {
2771
+ try {
2772
+ pms = resolvePMs(ctx, { pmOverride: entry.pm });
2773
+ } catch (error) {
2774
+ if (error instanceof UsageError) throw new UsageError(`${item.directory}: ${error.message}`);
2775
+ throw error;
2776
+ }
2777
+ if (entry.pm && entry.pmVersion) versions = {
2778
+ ...versions,
2779
+ [entry.pm]: entry.pmVersion
2780
+ };
2781
+ } else {
2782
+ const declared = ctx.packageJson?.packageManager === void 0 ? void 0 : parsePackageManagerField(ctx.packageJson.packageManager);
2783
+ pms = PMS.filter((pm) => pm === declared || pm === "deno" && item.manifests.includes("deno.json"));
2784
+ }
2785
+ return {
2786
+ ...item,
2787
+ installation: entry !== void 0,
2788
+ targets: pms.map((pm) => ({
2789
+ pm,
2790
+ ...versions[pm] === void 0 ? {} : { version: versions[pm] }
2791
+ }))
2792
+ };
2793
+ }),
2794
+ ruleSet: configured.rules,
2795
+ severityOverrides: configured.severityOverrides,
2796
+ limits
2721
2797
  },
2722
- members: options.workspaces ? collectWorkspaceMembers(ctx, fs, pms, options.projectType ?? config?.projectType, dependencies) : [],
2723
- ruleSet: configured.rules,
2724
- severityOverrides: configured.severityOverrides,
2725
- codecFor,
2726
2798
  reporters: config?.reporters ?? []
2727
2799
  };
2728
2800
  };
2729
- /** Reuse the existing manifest checks; installation policy remains rooted at cwd. */
2730
- const runPreparedLint = (prepared) => {
2731
- const result = runLint(prepared);
2732
- const findings = [...result.findings];
2733
- const summary = { ...result.summary };
2734
- const memberRules = prepared.ruleSet.filter((rule) => [
2735
- "files-field",
2736
- "publish-access",
2737
- "unsupported-settings"
2738
- ].includes(rule.id));
2739
- for (const member of prepared.members) {
2740
- const child = runLint({
2741
- ...prepared,
2742
- ctx: member.ctx,
2743
- pms: [member.pm],
2744
- ruleSet: memberRules
2801
+ const runPreparedLint = (evaluation) => {
2802
+ const { directories, ruleSet, severityOverrides, limits } = evaluation;
2803
+ const findings = [];
2804
+ const manifests = [];
2805
+ const installationRoots = [];
2806
+ const ruleOrder = new Map(ruleSet.map((rule, index) => [rule.id, index]));
2807
+ for (const item of directories) {
2808
+ const { directory, repository, targets, installation } = item;
2809
+ const local = [];
2810
+ for (const file of item.manifests) {
2811
+ const manifestTargets = targets.filter(({ pm }) => file === "deno.json" ? pm === "deno" : pm !== "deno");
2812
+ manifests.push({
2813
+ path: directory === "." ? file : `${directory}/${file}`,
2814
+ projectType: manifestProjectType(repository, file),
2815
+ targets: manifestTargets
2816
+ });
2817
+ local.push(...checkManifest(repository, file, manifestTargets, ruleSet, severityOverrides));
2818
+ }
2819
+ if (installation) installationRoots.push({
2820
+ directory,
2821
+ targets
2745
2822
  });
2746
- for (const finding of child.findings) findings.push({
2747
- ...finding,
2748
- file: `${member.directory}/${finding.file ?? "package.json"}`,
2749
- message: `${member.directory}: ${finding.message}`,
2750
- remediation: finding.remediation?.kind === "manual" ? {
2751
- kind: "manual",
2752
- steps: [`Work in ${member.directory} for this finding.`, ...finding.remediation.steps]
2753
- } : finding.remediation
2823
+ const scopedRules = ruleSet.flatMap((rule) => {
2824
+ const scope = scopeOf(rule.id);
2825
+ if (scope === "installation" && installation || scope === "custom" && directory === ".") return [rule];
2826
+ if (scope === "split" && installation) return [{
2827
+ ...rule,
2828
+ bindings: createUnsupportedSettings((file) => file !== "package.json" && file !== "deno.json").bindings
2829
+ }];
2830
+ return [];
2754
2831
  });
2755
- for (const level of [
2756
- "error",
2757
- "warn",
2758
- "info"
2759
- ]) summary[level] += child.summary[level];
2832
+ try {
2833
+ local.push(...runLint({
2834
+ repository,
2835
+ pms: targets.map(({ pm }) => pm),
2836
+ pmVersions: Object.fromEntries(targets.map(({ pm, version }) => [pm, version])),
2837
+ ruleSet: scopedRules,
2838
+ severityOverrides
2839
+ }).findings);
2840
+ } catch (error) {
2841
+ if (error instanceof ConfigError && directory !== ".") throw new ConfigError(`${directory}/${error.message}`);
2842
+ throw error;
2843
+ }
2844
+ local.sort((a, b) => (ruleOrder.get(a.ruleId) ?? 0) - (ruleOrder.get(b.ruleId) ?? 0));
2845
+ checkLimit("maxFindings", findings.length + local.length, limits);
2846
+ for (const finding of local) findings.push(rebaseFinding(directory, finding));
2760
2847
  }
2848
+ const summary = {
2849
+ error: 0,
2850
+ warn: 0,
2851
+ info: 0
2852
+ };
2853
+ for (const finding of findings) summary[finding.severity] += 1;
2761
2854
  return {
2762
2855
  findings,
2763
- summary
2856
+ summary,
2857
+ inspection: {
2858
+ manifests,
2859
+ installationRoots
2860
+ }
2764
2861
  };
2765
2862
  };
2766
- const lint$1 = (options, dependencies) => runPreparedLint(prepareLint(options, dependencies));
2863
+ const lint$1 = (options, dependencies) => runPreparedLint(prepareLint(options, dependencies).evaluation);
2767
2864
  //#endregion
2768
- //#region src/domain/services/filter.ts
2865
+ //#region src/core/filter.ts
2769
2866
  const EXIT_SUCCESS = 0;
2770
2867
  const EXIT_FAILURE = 1;
2771
2868
  const meetsThreshold = (severity, threshold) => SEVERITY_RANK[severity] >= SEVERITY_RANK[threshold];
@@ -2782,6 +2879,7 @@ const filterBySeverity = (result, threshold) => {
2782
2879
  summary[finding.severity] += 1;
2783
2880
  }
2784
2881
  return {
2882
+ ...result,
2785
2883
  findings,
2786
2884
  summary
2787
2885
  };
@@ -2795,7 +2893,7 @@ const exitCodeForLint = (result, threshold = "error") => {
2795
2893
  return EXIT_SUCCESS;
2796
2894
  };
2797
2895
  //#endregion
2798
- //#region src/application/commands/lint.ts
2896
+ //#region src/core/lint-command.ts
2799
2897
  /** Evaluate and report. Executable config loading belongs to the CLI adapter. */
2800
2898
  const lintCommand$1 = async (options, io, dependencies, reporters) => {
2801
2899
  if (!options) throw new UsageError("Lint options are required.");
@@ -2805,23 +2903,58 @@ const lintCommand$1 = async (options, io, dependencies, reporters) => {
2805
2903
  const selection = options.reporter ?? reporters.defaultName;
2806
2904
  const reporter = typeof selection === "string" ? registry.get(selection) : selection;
2807
2905
  if (!isReporterShape(reporter)) throw new UsageError(`${typeof selection === "string" ? "Unknown" : "Invalid"} reporter: ${String(selection)} (available: ${[...registry.keys()].join(", ")})`);
2808
- const result = runPreparedLint(prepared);
2906
+ const result = runPreparedLint(prepared.evaluation);
2809
2907
  const exitCode = exitCodeForLint(result, options.severity ?? "error");
2810
- await reporter.format(filterBySeverity(result, options.severity ?? "info"), io);
2908
+ const writes = [];
2909
+ const consumeOutput = outputBudget(prepared.evaluation.limits.maxOutputBytes);
2910
+ let outputFailed = false;
2911
+ let outputFailure;
2912
+ const recordFailure = (error) => {
2913
+ if (!outputFailed) {
2914
+ outputFailed = true;
2915
+ outputFailure = error;
2916
+ }
2917
+ };
2918
+ const track = (write) => (line) => {
2919
+ try {
2920
+ consumeOutput(`${line}\n`);
2921
+ const written = write(line);
2922
+ writes.push(Promise.resolve(written).then(() => {}, recordFailure));
2923
+ return written;
2924
+ } catch (error) {
2925
+ recordFailure(error);
2926
+ throw error;
2927
+ }
2928
+ };
2929
+ try {
2930
+ await reporter.format(filterBySeverity(result, options.severity ?? "info"), {
2931
+ stdout: track((line) => io.stdout(line)),
2932
+ stderr: track((line) => io.stderr(line))
2933
+ }, {
2934
+ cwd: options.cwd,
2935
+ limits: prepared.evaluation.limits
2936
+ });
2937
+ } finally {
2938
+ await Promise.all(writes);
2939
+ }
2940
+ if (outputFailed) throw outputFailure;
2811
2941
  return exitCode;
2812
2942
  };
2813
2943
  //#endregion
2814
- //#region src/domain/schemas/package-json.ts
2944
+ //#region src/core/contracts/package-json.ts
2815
2945
  const PackageJsonSchema = vb.looseObject({
2816
2946
  files: vb.optional(vb.array(vb.string())),
2817
2947
  name: vb.optional(vb.string()),
2818
2948
  packageManager: vb.optional(vb.string()),
2819
2949
  private: vb.optional(vb.boolean()),
2820
- publishConfig: vb.optional(vb.looseObject({ access: vb.optional(vb.picklist([
2821
- "public",
2822
- "restricted",
2823
- "private"
2824
- ])) })),
2950
+ publishConfig: vb.optional(vb.looseObject({
2951
+ access: vb.optional(vb.picklist([
2952
+ "public",
2953
+ "restricted",
2954
+ "private"
2955
+ ])),
2956
+ provenance: vb.optional(vb.boolean())
2957
+ })),
2825
2958
  trustedDependencies: vb.optional(vb.array(vb.string()))
2826
2959
  });
2827
2960
  const parsePackageJson = (value) => {
@@ -2833,26 +2966,29 @@ const parsePackageJson = (value) => {
2833
2966
  throw new ConfigError(`package.json: ${field ? `${field}: ` : ""}${issue.message}`);
2834
2967
  };
2835
2968
  //#endregion
2836
- //#region src/adapters/repo-context.ts
2837
- 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;
2838
2973
  try {
2839
- return JSON.parse(text);
2840
- } catch (error) {
2841
- let errMsg = String(error);
2842
- if (error instanceof Error) errMsg = error.message;
2843
- throw new ConfigError(`package.json: invalid JSON — ${errMsg}`);
2974
+ value = JSON.parse(text.trim());
2975
+ } catch {
2976
+ throw new ConfigParseError("invalid JSON");
2844
2977
  }
2978
+ checkConfigDepth(value, maxDepth);
2979
+ return value;
2845
2980
  };
2846
- const readPackageJson = (raw) => {
2847
- return parsePackageJson(tryParseJson(raw.trim()));
2848
- };
2849
- const createRepoContext = (root, fs = nodeFileSystem, projectType) => {
2850
- if (fs === nodeFileSystem) assertDirectory(root);
2851
- const readText = (relPath) => fs.readText(resolveIn(root, relPath));
2852
- const exists = (relPath) => fs.exists(resolveIn(root, relPath));
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));
2853
2989
  const raw = readText(asRelPath("package.json"));
2854
- let packageJson = void 0;
2855
- if (typeof raw !== "undefined") packageJson = readPackageJson(raw);
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));
2856
2992
  return {
2857
2993
  exists,
2858
2994
  packageJson,
@@ -2875,37 +3011,71 @@ const coerce = (raw) => {
2875
3011
  if (typeof raw !== "string") return raw;
2876
3012
  return coerceBoolean(raw) ?? coerceInteger(raw) ?? raw;
2877
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
+ } };
3043
+ //#endregion
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.");
3051
+ if (document.contents === null) return {};
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);
3065
+ };
2878
3066
  //#endregion
2879
3067
  //#region src/adapters/codecs/store.ts
2880
3068
  const CODECS = {
2881
- json: { parse(text) {
2882
- const trimmed = text.trim();
2883
- return toParsedConfig(JSON.parse(trimmed));
2884
- } },
2885
- npmrc: { parse(text) {
2886
- const raw = toParsedConfig(ini.parse(text));
2887
- const config = {};
2888
- for (const [key, value] of Object.entries(raw)) if (Array.isArray(value)) config[key] = value.map((elem) => coerce(elem));
2889
- else if (typeof value === "object" && value !== null) config[key] = toParsedConfig(value);
2890
- else config[key] = coerce(value);
2891
- return config;
2892
- } },
2893
- toml: { parse(text) {
2894
- if (text.trim() === "") return {};
2895
- return toParsedConfig(parse$1(text.startsWith("") ? text.slice(1) : text));
2896
- } },
2897
- yaml: { parse(text) {
2898
- if (text.trim() === "") return {};
2899
- const document = parseDocument(text);
2900
- if (document.errors.length > 0) throw document.errors[0];
2901
- if (document.contents === null) return {};
2902
- return toParsedConfig(document.toJS({ onAnchor: (value, count) => {
2903
- if (count > 1) JSON.stringify(value);
2904
- } }));
2905
- } }
2906
- };
2907
- /** Look up the codec for a parseable kind. Total — every CodecKind has one. */
2908
- const codecFor = (kind) => CODECS[kind];
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
+ } });
2909
3079
  //#endregion
2910
3080
  //#region src/adapters/reporters/github.ts
2911
3081
  const COMMAND = {
@@ -2917,33 +3087,103 @@ const escapeData = (raw) => raw.replaceAll("%", "%25").replaceAll("\r", "%0D").r
2917
3087
  const escapeProp = (raw) => escapeData(raw).replaceAll(":", "%3A").replaceAll(",", "%2C");
2918
3088
  /** Emit GitHub Actions workflow commands (annotations on PRs). */
2919
3089
  const githubReporter = {
2920
- format(result, io) {
3090
+ async format(result, io, context) {
3091
+ const consume = outputBudget(context.limits?.maxOutputBytes ?? DEFAULT_SCAN_LIMITS.maxOutputBytes);
2921
3092
  for (const finding of result.findings) {
2922
3093
  let file = "";
2923
- if (finding.file) file = `file=${escapeProp(finding.file)},`;
2924
- let body = `[${finding.pm}] ${finding.message}`;
3094
+ if (finding.file) file = `file=${escapeProp(path.resolve(context.cwd, finding.file))},`;
3095
+ let body = `[${finding.pm ?? "package"}] ${finding.directory}: ${finding.message}`;
2925
3096
  if (finding.docs) body += ` (${finding.docs})`;
2926
- 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);
2927
3100
  }
2928
3101
  },
2929
3102
  name: "github"
2930
3103
  };
2931
3104
  //#endregion
3105
+ //#region src/adapters/safe-text.ts
3106
+ /** Visible escaping for untrusted human-facing text; never rewrite API/native paths. */
3107
+ const safeText = (text) => text.replace(/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/gu, (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`).replaceAll("##[", "#\\u0023[").replace(/^(\s*)::/u, "$1\\u003a:");
3108
+ /** JSON syntax and decoded values stay identical; CI must not parse legacy commands in it. */
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[");
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
2932
3171
  //#region src/adapters/reporters/json.ts
2933
3172
  /**
2934
3173
  * Versioned machine-readable output — the public contract consumed by
2935
3174
  * external fixers (see docs/json-output.md). Bump `schemaVersion` on any
2936
3175
  * breaking shape change and update that doc in the same commit.
2937
3176
  */
2938
- const SCHEMA_VERSION = 2;
3177
+ const SCHEMA_VERSION = 3;
2939
3178
  const jsonReporter = {
2940
- format(result, io) {
2941
- io.stdout(JSON.stringify({
3179
+ async format(result, io, context) {
3180
+ await io.stdout(boundedJson({
2942
3181
  schemaVersion: SCHEMA_VERSION,
2943
3182
  siroVersion: version,
2944
3183
  findings: result.findings,
2945
- summary: result.summary
2946
- }, void 0, 2));
3184
+ summary: result.summary,
3185
+ inspection: result.inspection
3186
+ }, context.limits?.maxOutputBytes ?? DEFAULT_SCAN_LIMITS.maxOutputBytes, (context.limits?.maxConfigDepth ?? DEFAULT_SCAN_LIMITS.maxConfigDepth) + 16));
2947
3187
  },
2948
3188
  name: "json"
2949
3189
  };
@@ -2970,12 +3210,11 @@ const colorSupportedNow = () => {
2970
3210
  return pc.isColorSupported;
2971
3211
  };
2972
3212
  const renderFinding = (finding, ctx) => {
2973
- let where = "";
2974
- if (finding.file) where = ctx.colors.dim(` (${finding.file})`);
2975
- ctx.io.stdout(`${ctx.tag[finding.severity](GLYPH[finding.severity])} [${finding.pm}] ${ctx.colors.bold(finding.ruleId)}${where}`);
2976
- ctx.io.stdout(` ${finding.message}`);
2977
- for (const step of finding.remediation?.kind === "manual" ? finding.remediation.steps : []) ctx.io.stdout(` ↳ ${step}`);
2978
- if (finding.docs) ctx.io.stdout(ctx.colors.dim(` → ${finding.docs}`));
3213
+ const where = ctx.colors.dim(` (${safeText(finding.file ?? finding.directory)})`);
3214
+ ctx.io.stdout(`${ctx.tag[finding.severity](GLYPH[finding.severity])} [${finding.pm ?? "package"}] ${ctx.colors.bold(safeText(finding.ruleId))}${where}`);
3215
+ ctx.io.stdout(` ${safeText(finding.message)}`);
3216
+ for (const step of finding.remediation?.kind === "manual" ? finding.remediation.steps : []) ctx.io.stdout(` ↳ ${safeText(step)}`);
3217
+ if (finding.docs) ctx.io.stdout(ctx.colors.dim(` → ${safeText(finding.docs)}`));
2979
3218
  };
2980
3219
  const buildRenderCtx = (io) => {
2981
3220
  const colors = pc.createColors(colorSupportedNow());
@@ -2990,16 +3229,27 @@ const buildRenderCtx = (io) => {
2990
3229
  };
2991
3230
  };
2992
3231
  const prettyReporter = {
2993
- format(result, io) {
2994
- const ctx = buildRenderCtx(io);
2995
- if (result.findings.length === 0) {
2996
- io.stdout(ctx.colors.green("✔ No security best-practice issues found."));
2997
- return;
3232
+ async format(result, io, context) {
3233
+ const lines = [];
3234
+ const consume = outputBudget(context.limits?.maxOutputBytes ?? DEFAULT_SCAN_LIMITS.maxOutputBytes);
3235
+ const collect = (line) => {
3236
+ consume(`${line}\n`);
3237
+ lines.push(line);
3238
+ };
3239
+ const ctx = buildRenderCtx({
3240
+ stdout: collect,
3241
+ stderr: collect
3242
+ });
3243
+ collect(`Inspection: ${result.inspection.manifests.length} manifests; installation roots: ${result.inspection.installationRoots.map((root) => safeText(root.directory)).join(", ") || "none"}.`);
3244
+ collect("Unknown PM/version targets have no availability assessment; installation scope is explicit.");
3245
+ if (result.findings.length === 0) collect(ctx.colors.green("✔ No security best-practice issues found."));
3246
+ else {
3247
+ for (const finding of result.findings) renderFinding(finding, ctx);
3248
+ const { error, warn, info } = result.summary;
3249
+ collect("");
3250
+ collect(`Summary: ${error} error, ${warn} warn, ${info} info`);
2998
3251
  }
2999
- for (const finding of result.findings) renderFinding(finding, ctx);
3000
- const { error, warn, info } = result.summary;
3001
- io.stdout("");
3002
- io.stdout(`Summary: ${error} error, ${warn} warn, ${info} info`);
3252
+ await io.stdout(lines.join("\n"));
3003
3253
  },
3004
3254
  name: "pretty"
3005
3255
  };
@@ -3017,29 +3267,81 @@ const BUILTIN_REPORTER_NAMES = BUILTINS.map((rep) => rep.name);
3017
3267
  /** Later registrations replace earlier reporters with the same name. */
3018
3268
  const createRegistry = (extras = []) => new Map([...BUILTINS, ...extras].map((reporter) => [reporter.name, reporter]));
3019
3269
  //#endregion
3020
- //#region src/composition/lint.ts
3021
- const dependencies = {
3022
- rules,
3023
- fileSystem: nodeFileSystem,
3024
- paths: nodePaths,
3025
- createRepoContext,
3026
- codecFor,
3027
- globs: minimatchGlobs,
3028
- caseInsensitiveGlobs: process.platform === "darwin" || process.platform === "win32"
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
+ };
3029
3291
  };
3292
+ const nativeOptions = (options) => options?.fs === nodeFileSystem ? {
3293
+ ...options,
3294
+ fs: void 0
3295
+ } : options;
3030
3296
  /** Public Node API: callers can replace the filesystem without assembling the application. */
3031
- const lint = (options) => lint$1(options, dependencies);
3032
- 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), {
3033
3299
  defaultName: DEFAULT_REPORTER_NAME,
3034
3300
  createRegistry
3035
3301
  });
3036
3302
  //#endregion
3037
3303
  //#region src/adapters/node-io.ts
3038
- const nodeIO = {
3039
- stderr: (line) => process.stderr.write(`${line}\n`),
3040
- stdout: (line) => process.stdout.write(`${line}\n`)
3304
+ const writer = (stream) => {
3305
+ let failure;
3306
+ const pending = /* @__PURE__ */ new Set();
3307
+ const fail = (cause) => {
3308
+ failure ??= new Error(`Output failed: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
3309
+ for (const reject of pending) reject(failure);
3310
+ pending.clear();
3311
+ return failure;
3312
+ };
3313
+ let observing = false;
3314
+ return (line) => {
3315
+ if (!observing) {
3316
+ observing = true;
3317
+ stream.on("error", fail);
3318
+ stream.on("close", () => fail(/* @__PURE__ */ new Error("Output stream closed.")));
3319
+ }
3320
+ return new Promise((resolve, reject) => {
3321
+ if (failure || stream.destroyed) {
3322
+ reject(failure ?? fail(/* @__PURE__ */ new Error("Output stream is destroyed.")));
3323
+ return;
3324
+ }
3325
+ pending.add(reject);
3326
+ try {
3327
+ stream.write(`${line}\n`, (error) => {
3328
+ pending.delete(reject);
3329
+ if (error) reject(fail(error));
3330
+ else resolve();
3331
+ });
3332
+ } catch (error) {
3333
+ fail(error);
3334
+ }
3335
+ });
3336
+ };
3041
3337
  };
3338
+ /** Node-only construction; keep streams and their errors outside the core. */
3339
+ const createNodeIO = (stdout, stderr) => ({
3340
+ stdout: writer(stdout),
3341
+ stderr: writer(stderr)
3342
+ });
3343
+ const nodeIO = createNodeIO(process.stdout, process.stderr);
3042
3344
  //#endregion
3043
- export { ConfigError as A, SUPPORTED_NODE_RANGE as C, isNodeError as D, nodeFileSystem as E, UsageError as M, asAbsPath as O, isSupportedNodeVersion as S, assertDirectory as T, isProjectType as _, DEFAULT_REPORTER_NAME as a, isPM as b, jsonReporter as c, requireConfigKey as d, CONFIG_FILES as f, PROJECT_TYPES as g, getByPath as h, BUILTIN_REPORTER_NAMES as i, SiroError as j, asRelPath as k, githubReporter as l, defineRule as m, lint as n, JSON_REPORTER_NAME as o, loadConfig as p, lintCommand as r, prettyReporter as s, nodeIO as t, overrideBindings as u, PMS as v, version as w, isSeverity as x, SEVERITIES 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 };
3044
3346
 
3045
- //# sourceMappingURL=node-io-BDHg4J2w.mjs.map
3347
+ //# sourceMappingURL=node-io-BpAHyeBG.mjs.map