@pho9ubenaa/siro 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,16 +1,16 @@
1
1
  import path from "node:path";
2
- import { lstatSync, readFileSync, readdirSync, statSync } from "node:fs";
2
+ import { readFileSync, readdirSync, statSync } from "node:fs";
3
3
  import { pathToFileURL } from "node:url";
4
4
  import satisfies from "semver/functions/satisfies.js";
5
5
  import * as vb from "valibot";
6
6
  import { lt, parse } from "semver";
7
- import pc from "picocolors";
7
+ import valid from "semver/functions/valid.js";
8
+ import { Minimatch } from "minimatch";
8
9
  import ini from "ini";
9
10
  import { parse as parse$1 } from "smol-toml";
10
11
  import { parseDocument } from "yaml";
11
- import { GLOBSTAR, Minimatch, braceExpand } from "minimatch";
12
- import valid from "semver/functions/valid.js";
13
- //#region src/shared/errors.ts
12
+ import pc from "picocolors";
13
+ //#region src/core/contracts/errors.ts
14
14
  var SiroError = class extends Error {
15
15
  exitCode;
16
16
  constructor(message, exitCode) {
@@ -47,16 +47,32 @@ const wrapCodecError = (filePath, fn) => {
47
47
  }
48
48
  };
49
49
  //#endregion
50
- //#region src/shared/paths.ts
50
+ //#region src/core/contracts/paths.ts
51
+ 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
+ const asRelPath = (value) => {
53
+ if (!isRelPath(value)) throw new TypeError("Expected a repository-relative path without parent traversal.");
54
+ return value;
55
+ };
56
+ //#endregion
57
+ //#region src/adapters/node-paths.ts
51
58
  const isAbsPath = (value) => typeof value === "string" && !value.includes("\0") && path.isAbsolute(value);
52
- const isRelPath = (value) => typeof value === "string" && value.length > 0 && !value.includes("\0") && !path.posix.isAbsolute(value) && !path.win32.isAbsolute(value) && !/^[a-z]:/iu.test(value) && !value.split(/[\\/]/u).includes("..");
53
59
  const asAbsPath = (value) => {
54
60
  if (!isAbsPath(value)) throw new TypeError("Expected an absolute filesystem path.");
55
61
  return value;
56
62
  };
57
- const asRelPath = (value) => {
58
- if (!isRelPath(value)) throw new TypeError("Expected a repository-relative path without parent traversal.");
59
- return value;
63
+ const nodePaths = {
64
+ isAbsolute: isAbsPath,
65
+ resolve(root, relative) {
66
+ 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)}`);
67
+ const resolved = path.join(root, relative);
68
+ const within = path.relative(root, resolved);
69
+ if (within === ".." || within.startsWith(`..${path.sep}`) || path.isAbsolute(within)) throw new ConfigError("Path escapes repository root.");
70
+ return asAbsPath(resolved);
71
+ },
72
+ child(parent, name) {
73
+ 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)}`);
74
+ return parent === "." ? name : `${parent}/${name}`;
75
+ }
60
76
  };
61
77
  //#endregion
62
78
  //#region src/adapters/node-errors.ts
@@ -71,24 +87,6 @@ const nodeFileSystem = {
71
87
  readDirectories(directory) {
72
88
  return readdirSync(directory, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
73
89
  },
74
- resolveDirectory(parent, name) {
75
- let target;
76
- try {
77
- target = lstatSync(path.join(parent, name), { bigint: true });
78
- } catch (error) {
79
- if (isNodeError(error) && error.code === "ENOENT") return void 0;
80
- throw error;
81
- }
82
- if (!target.isDirectory()) return void 0;
83
- const names = readdirSync(parent, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
84
- if (names.includes(name)) return name;
85
- const matches = names.filter((candidate) => {
86
- const entry = lstatSync(path.join(parent, candidate), { bigint: true });
87
- return entry.isDirectory() && entry.dev === target.dev && entry.ino === target.ino;
88
- });
89
- if (target.ino === 0n || matches.length !== 1) throw new ConfigError(`${parent}: cannot identify the resolved workspace directory ${name}.`);
90
- return matches[0];
91
- },
92
90
  exists(filePath) {
93
91
  try {
94
92
  assertRegularFile(filePath);
@@ -108,23 +106,36 @@ const nodeFileSystem = {
108
106
  }
109
107
  }
110
108
  };
111
- const resolveIn = (root, relPath) => asAbsPath(path.join(root, asRelPath(relPath)));
109
+ const resolveIn = (root, relPath) => nodePaths.resolve(root, relPath);
112
110
  const assertDirectory = (root) => {
113
111
  if (!statSync(root).isDirectory()) throw new UsageError("The lint target must be a directory.");
114
112
  };
115
113
  //#endregion
116
114
  //#region package.json
117
- var version$1 = "0.5.0";
115
+ var version$1 = "0.6.0";
116
+ var engines = { "node": "^22.18.0 || ^24.0.0" };
118
117
  //#endregion
119
- //#region src/shared/node-version.ts
120
- const SUPPORTED_NODE_RANGE = { "node": "^22.18.0 || ^24.0.0" }.node;
118
+ //#region src/version.ts
119
+ const version = version$1;
120
+ const SUPPORTED_NODE_RANGE = engines.node;
121
+ //#endregion
122
+ //#region src/adapters/node-version.ts
121
123
  const isSupportedNodeVersion = (version) => satisfies(version, SUPPORTED_NODE_RANGE);
122
124
  //#endregion
123
- //#region src/domain/entities/pms.ts
125
+ //#region src/core/contracts/records.ts
126
+ /** Configuration maps are ordinary objects or null-prototype dictionaries. */
127
+ const isPlainRecord = (value) => {
128
+ if (typeof value !== "object" || value === null) return false;
129
+ const prototype = Object.getPrototypeOf(value);
130
+ return prototype === Object.prototype || prototype === null;
131
+ };
132
+ //#endregion
133
+ //#region src/core/contracts/pms.ts
124
134
  /**
125
135
  * Single source of truth for package managers and severities.
126
- * Types, runtime sets, and ordering are all derived from these tuples — add a
127
- * package manager here and the rest of the codebase follows.
136
+ * Types, runtime sets, and ordering derive from these tuples. Adding a manager
137
+ * still requires local detection signals, applicable rule bindings and verified
138
+ * policy/availability evidence; the tuple alone does not implement support.
128
139
  */
129
140
  const PMS = [
130
141
  "npm",
@@ -160,24 +171,68 @@ const parsePackageManagerField = (value) => {
160
171
  if (typeof name !== "undefined" && isPM(name)) return name;
161
172
  };
162
173
  //#endregion
163
- //#region src/domain/entities/project-type.ts
164
- const PROJECT_TYPES = ["application", "package"];
165
- const isProjectType = (value) => PROJECT_TYPES.some((projectType) => projectType === value);
174
+ //#region src/core/pm-versions.ts
175
+ /** Release history establishes stable versions, not ranges or prerelease builds. */
176
+ const isStableVersion = (value) => {
177
+ if (typeof value !== "string" || !/^\d/u.test(value) || value.trim() !== value) return false;
178
+ const version = parse(value);
179
+ return version !== null && version.prerelease.length === 0;
180
+ };
181
+ /** Keep name-only detection independent from whether the declaration pins a version. */
182
+ const declaredPMVersion = (declaration) => {
183
+ const [pm, version, ...extra] = declaration?.trim().split("@") ?? [];
184
+ return pm && isPM(pm) && isStableVersion(version) && extra.length === 0 ? { [pm]: version } : {};
185
+ };
166
186
  //#endregion
167
- //#region src/shared/records.ts
168
- /** Configuration maps are ordinary objects or null-prototype dictionaries. */
169
- const isPlainRecord = (value) => {
170
- if (typeof value !== "object" || value === null) return false;
171
- const prototype = Object.getPrototypeOf(value);
172
- return prototype === Object.prototype || prototype === null;
187
+ //#region src/core/inspection-options.ts
188
+ const denseArray = (value, name) => {
189
+ if (!Array.isArray(value)) throw new ConfigError(`${name} must be a dense array.`);
190
+ for (let index = 0; index < value.length; index += 1) if (!Object.hasOwn(value, index)) throw new ConfigError(`${name} must be a dense array.`);
191
+ return value;
192
+ };
193
+ const relativeInput = (value, name) => {
194
+ 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.`);
195
+ return value.split("/").filter((part) => part && part !== ".").join("/") || ".";
196
+ };
197
+ const parseExcludes = (value) => [...new Set(denseArray(value, "exclude").map((input) => {
198
+ const pattern = relativeInput(input, "exclude");
199
+ if (pattern === "." || pattern.startsWith("!")) throw new ConfigError("exclude cannot exclude cwd (.) or re-include with leading !.");
200
+ return pattern;
201
+ }))];
202
+ const parseInstallationRoots = (value) => {
203
+ const roots = /* @__PURE__ */ new Map();
204
+ for (const input of denseArray(value, "installationRoots")) {
205
+ if (typeof input !== "string" && (!isPlainRecord(input) || Object.keys(input).some((key) => ![
206
+ "path",
207
+ "pm",
208
+ "pmVersion"
209
+ ].includes(key)))) throw new ConfigError("installationRoots entries must be paths or { path, pm?, pmVersion? }.");
210
+ const entry = typeof input === "string" ? { path: input } : input;
211
+ const path = relativeInput(entry.path, "installationRoots");
212
+ 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.`);
213
+ if (path === "." && (entry.pm !== void 0 || entry.pmVersion !== void 0)) throw new ConfigError("Use top-level pm / pmVersion / pms / pmVersions for installation root \".\".");
214
+ const root = {
215
+ path,
216
+ pm: entry.pm,
217
+ pmVersion: entry.pmVersion
218
+ };
219
+ const previous = roots.get(path);
220
+ if (previous && (previous.pm !== root.pm || previous.pmVersion !== root.pmVersion)) throw new ConfigError(`${path}: conflicting installationRoots entries.`);
221
+ roots.set(path, root);
222
+ }
223
+ return [...roots.values()];
173
224
  };
174
225
  //#endregion
175
- //#region src/domain/ports/reporter.ts
226
+ //#region src/core/contracts/project-type.ts
227
+ const PROJECT_TYPES = ["application", "package"];
228
+ const isProjectType = (value) => PROJECT_TYPES.some((projectType) => projectType === value);
229
+ //#endregion
230
+ //#region src/core/contracts/reporter.ts
176
231
  const isReporterShape = (value) => {
177
232
  return isPlainRecord(value) && typeof value.name === "string" && typeof value.format === "function";
178
233
  };
179
234
  //#endregion
180
- //#region src/domain/entities/config-value.ts
235
+ //#region src/core/contracts/config-value.ts
181
236
  const CODEC_KINDS = [
182
237
  "json",
183
238
  "npmrc",
@@ -200,14 +255,16 @@ const toParsedConfig = (value) => {
200
255
  throw new TypeError("Config root must be a mapping.");
201
256
  };
202
257
  //#endregion
203
- //#region src/domain/entities/rule.ts
204
- const defineRule = (rule) => rule;
205
- const isOptionalString = (value) => typeof value === "undefined" || typeof value === "string";
258
+ //#region src/core/contracts/config-file-ref.ts
206
259
  const CONFIG_FILE_KINDS = new Set(CODEC_KINDS);
207
260
  const isConfigFileRefShape = (value) => {
208
261
  if (!isPlainRecord(value) || typeof value.kind !== "string" || !isRelPath(value.path)) return false;
209
262
  return CONFIG_FILE_KINDS.has(value.kind);
210
263
  };
264
+ //#endregion
265
+ //#region src/core/contracts/rule.ts
266
+ const defineRule = (rule) => rule;
267
+ const isOptionalString = (value) => typeof value === "undefined" || typeof value === "string";
211
268
  const isConfigValueShape = (value) => typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value);
212
269
  const isDenseStringArray = (value) => Array.isArray(value) && Array.from(value).every((item) => typeof item === "string");
213
270
  const isCheckStatusShape = (value) => {
@@ -239,22 +296,26 @@ const isRuleShape = (value) => {
239
296
  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);
240
297
  };
241
298
  //#endregion
242
- //#region src/domain/services/pm-versions.ts
243
- /** Release history establishes stable versions, not ranges or prerelease builds. */
244
- const isStableVersion = (value) => {
245
- if (typeof value !== "string" || !/^\d/u.test(value) || value.trim() !== value) return false;
246
- const version = parse(value);
247
- return version !== null && version.prerelease.length === 0;
248
- };
249
- /** Keep name-only detection independent from whether the declaration pins a version. */
250
- const declaredPMVersion = (declaration) => {
251
- const [pm, version, ...extra] = declaration?.trim().split("@") ?? [];
252
- return pm && isPM(pm) && isStableVersion(version) && extra.length === 0 ? { [pm]: version } : {};
299
+ //#region src/core/contracts/synchronous.ts
300
+ /** Reject only the submitted async value, without a process-wide rejection handler. */
301
+ const assertSynchronous = (value, label) => {
302
+ if (value !== null && (typeof value === "object" || typeof value === "function") && "then" in value && typeof value.then === "function") {
303
+ Promise.resolve(value).catch(() => {});
304
+ throw new ConfigError(`${label} must be synchronous; Promise/thenable values are not supported.`);
305
+ }
253
306
  };
254
307
  //#endregion
255
- //#region src/application/config.ts
308
+ //#region src/core/parse-siro-config.ts
256
309
  const RuleSettingSchema = vb.union([vb.picklist(SEVERITIES), vb.literal("off")]);
257
310
  const ConfigSchema = vb.strictObject({
311
+ exclude: vb.optional(vb.custom((value) => {
312
+ parseExcludes(value);
313
+ return true;
314
+ })),
315
+ installationRoots: vb.optional(vb.custom((value) => {
316
+ parseInstallationRoots(value);
317
+ return true;
318
+ })),
258
319
  customRules: vb.optional(vb.array(vb.custom(isRuleShape, "must be a structurally valid rule"))),
259
320
  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)"))),
260
321
  projectType: vb.optional(vb.picklist(PROJECT_TYPES)),
@@ -285,13 +346,14 @@ const formatIssues = (issues) => issues.map((issue) => {
285
346
  return issue.message;
286
347
  }).join("; ");
287
348
  const parseConfig = (candidate, name = "siro.config") => {
349
+ assertSynchronous(candidate, name);
288
350
  if (!isPlainRecord(candidate)) throw new ConfigError(`${name} must export a config object (got ${Array.isArray(candidate) ? "an array" : typeof candidate}).`);
289
351
  const result = vb.safeParse(ConfigSchema, Object.fromEntries(Object.entries(candidate)));
290
352
  if (!result.success) throw new ConfigError(`${name}: ${formatIssues(result.issues)}`);
291
353
  return result.output;
292
354
  };
293
355
  //#endregion
294
- //#region src/adapters/config-loader.ts
356
+ //#region src/load-config.ts
295
357
  const CONFIG_NAMES = [
296
358
  "siro.config.ts",
297
359
  "siro.config.mjs",
@@ -315,150 +377,7 @@ const loadConfig = async (cwd, nodeVersion = process.versions.node) => {
315
377
  return parseConfig(mod !== null && typeof mod === "object" && "default" in mod ? mod.default : mod, name);
316
378
  };
317
379
  //#endregion
318
- //#region src/domain/services/filter.ts
319
- const EXIT_SUCCESS = 0;
320
- const EXIT_FAILURE = 1;
321
- const meetsThreshold = (severity, threshold) => SEVERITY_RANK[severity] >= SEVERITY_RANK[threshold];
322
- /** Keep only findings at or above `threshold`, recomputing the summary. */
323
- const filterBySeverity = (result, threshold) => {
324
- const findings = [];
325
- const summary = {
326
- error: 0,
327
- info: 0,
328
- warn: 0
329
- };
330
- for (const finding of result.findings) if (meetsThreshold(finding.severity, threshold)) {
331
- findings.push(finding);
332
- summary[finding.severity] += 1;
333
- }
334
- return {
335
- findings,
336
- summary
337
- };
338
- };
339
- /**
340
- * Exit code for a lint run. Non-zero when any finding meets `threshold`
341
- * (default: only `error` fails the run).
342
- */
343
- const exitCodeForLint = (result, threshold = "error") => {
344
- if (result.findings.some((finding) => meetsThreshold(finding.severity, threshold))) return EXIT_FAILURE;
345
- return EXIT_SUCCESS;
346
- };
347
- //#endregion
348
- //#region src/adapters/reporters/github.ts
349
- const COMMAND = {
350
- error: "error",
351
- info: "notice",
352
- warn: "warning"
353
- };
354
- const escapeData = (raw) => raw.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A");
355
- const escapeProp = (raw) => escapeData(raw).replaceAll(":", "%3A").replaceAll(",", "%2C");
356
- /** Emit GitHub Actions workflow commands (annotations on PRs). */
357
- const githubReporter = {
358
- format(result, io) {
359
- for (const finding of result.findings) {
360
- let file = "";
361
- if (finding.file) file = `file=${escapeProp(finding.file)},`;
362
- let body = `[${finding.pm}] ${finding.message}`;
363
- if (finding.docs) body += ` (${finding.docs})`;
364
- io.stdout(`::${COMMAND[finding.severity]} ${file}title=${escapeProp(finding.ruleId)}::${escapeData(body)}`);
365
- }
366
- },
367
- name: "github"
368
- };
369
- //#endregion
370
- //#region src/version.ts
371
- const version = version$1;
372
- //#endregion
373
- //#region src/adapters/reporters/json.ts
374
- /**
375
- * Versioned machine-readable output — the public contract consumed by
376
- * external fixers (see docs/json-output.md). Bump `schemaVersion` on any
377
- * breaking shape change and update that doc in the same commit.
378
- */
379
- const SCHEMA_VERSION = 2;
380
- const jsonReporter = {
381
- format(result, io) {
382
- io.stdout(JSON.stringify({
383
- schemaVersion: SCHEMA_VERSION,
384
- siroVersion: version,
385
- findings: result.findings,
386
- summary: result.summary
387
- }, void 0, 2));
388
- },
389
- name: "json"
390
- };
391
- //#endregion
392
- //#region src/adapters/reporters/pretty.ts
393
- const GLYPH = {
394
- error: "✖ error",
395
- info: "ℹ info",
396
- warn: "⚠ warn"
397
- };
398
- /**
399
- * Decide colour at call time, not at import time.
400
- *
401
- * picocolors snapshots `isColorSupported` when imported, so a later
402
- * `process.env.NO_COLOR = '1'` (or a different stdout) is ignored. The
403
- * reporter is invoked per `siro lint` run from many contexts (TTY, CI
404
- * with FORCE_COLOR, redirected output, …) — checking each call is the
405
- * only way NO_COLOR can truly win, as https://no-color.org/ requires.
406
- */
407
- const colorSupportedNow = () => {
408
- const { env } = process;
409
- if (typeof env.NO_COLOR !== "undefined" && env.NO_COLOR !== "") return false;
410
- if ("FORCE_COLOR" in env) return env.FORCE_COLOR !== "0" && env.FORCE_COLOR !== "";
411
- return pc.isColorSupported;
412
- };
413
- const renderFinding = (finding, ctx) => {
414
- let where = "";
415
- if (finding.file) where = ctx.colors.dim(` (${finding.file})`);
416
- ctx.io.stdout(`${ctx.tag[finding.severity](GLYPH[finding.severity])} [${finding.pm}] ${ctx.colors.bold(finding.ruleId)}${where}`);
417
- ctx.io.stdout(` ${finding.message}`);
418
- for (const step of finding.remediation?.kind === "manual" ? finding.remediation.steps : []) ctx.io.stdout(` ↳ ${step}`);
419
- if (finding.docs) ctx.io.stdout(ctx.colors.dim(` → ${finding.docs}`));
420
- };
421
- const buildRenderCtx = (io) => {
422
- const colors = pc.createColors(colorSupportedNow());
423
- return {
424
- colors,
425
- io,
426
- tag: {
427
- error: colors.red,
428
- info: colors.cyan,
429
- warn: colors.yellow
430
- }
431
- };
432
- };
433
- const prettyReporter = {
434
- format(result, io) {
435
- const ctx = buildRenderCtx(io);
436
- if (result.findings.length === 0) {
437
- io.stdout(ctx.colors.green("✔ No security best-practice issues found."));
438
- return;
439
- }
440
- for (const finding of result.findings) renderFinding(finding, ctx);
441
- const { error, warn, info } = result.summary;
442
- io.stdout("");
443
- io.stdout(`Summary: ${error} error, ${warn} warn, ${info} info`);
444
- },
445
- name: "pretty"
446
- };
447
- //#endregion
448
- //#region src/adapters/reporters/registry.ts
449
- const BUILTINS = [
450
- prettyReporter,
451
- jsonReporter,
452
- githubReporter
453
- ];
454
- const DEFAULT_REPORTER_NAME = prettyReporter.name;
455
- const JSON_REPORTER_NAME = jsonReporter.name;
456
- /** Names of every built-in reporter, in stable display order. */
457
- const BUILTIN_REPORTER_NAMES = BUILTINS.map((rep) => rep.name);
458
- /** Later registrations replace earlier reporters with the same name. */
459
- const createRegistry = (extras = []) => new Map([...BUILTINS, ...extras].map((reporter) => [reporter.name, reporter]));
460
- //#endregion
461
- //#region src/domain/entities/config-files.ts
380
+ //#region src/core/config-files.ts
462
381
  /** Known configuration locations and their parsers. */
463
382
  const CONFIG_FILES = {
464
383
  aubeWorkspace: {
@@ -491,509 +410,7 @@ const CONFIG_FILES = {
491
410
  }
492
411
  };
493
412
  //#endregion
494
- //#region src/domain/services/parse-config-file.ts
495
- const EMPTY_FILE = Object.freeze({});
496
- /** Create a fresh parser and cache for one lint command. */
497
- const createConfigParser = (codecFor, ctx) => {
498
- const cache = /* @__PURE__ */ new Map();
499
- return (file) => {
500
- if (file === void 0) return EMPTY_FILE;
501
- const cacheKey = `${file.kind}:${file.path}`;
502
- const cached = cache.get(cacheKey);
503
- if (cached !== void 0) return cached;
504
- const text = ctx.readText(file.path);
505
- if (text === void 0) {
506
- cache.set(cacheKey, EMPTY_FILE);
507
- return EMPTY_FILE;
508
- }
509
- const parsed = wrapCodecError(file.path, () => codecFor(file.kind).parse(text));
510
- cache.set(cacheKey, parsed);
511
- return parsed;
512
- };
513
- };
514
- //#endregion
515
- //#region src/adapters/codecs/ini.ts
516
- const INTEGER_PATTERN = /^-?\d+$/u;
517
- const coerceBoolean = (str) => {
518
- if (str === "true") return true;
519
- if (str === "false") return false;
520
- };
521
- const coerceInteger = (str) => {
522
- if (INTEGER_PATTERN.test(str)) return Number(str);
523
- };
524
- const coerce = (raw) => {
525
- if (typeof raw !== "string") return raw;
526
- return coerceBoolean(raw) ?? coerceInteger(raw) ?? raw;
527
- };
528
- //#endregion
529
- //#region src/adapters/codecs/store.ts
530
- const CODECS = {
531
- json: { parse(text) {
532
- const trimmed = text.trim();
533
- return toParsedConfig(JSON.parse(trimmed));
534
- } },
535
- npmrc: { parse(text) {
536
- const raw = toParsedConfig(ini.parse(text));
537
- const config = {};
538
- for (const [key, value] of Object.entries(raw)) if (Array.isArray(value)) config[key] = value.map((elem) => coerce(elem));
539
- else if (typeof value === "object" && value !== null) config[key] = toParsedConfig(value);
540
- else config[key] = coerce(value);
541
- return config;
542
- } },
543
- toml: { parse(text) {
544
- if (text.trim() === "") return {};
545
- return toParsedConfig(parse$1(text.replace(/^\uFEFF/u, "")));
546
- } },
547
- yaml: { parse(text) {
548
- if (text.trim() === "") return {};
549
- const document = parseDocument(text);
550
- if (document.errors.length > 0) throw document.errors[0];
551
- if (document.contents === null) return {};
552
- return toParsedConfig(document.toJS({ onAnchor: (value, count) => {
553
- if (count > 1) JSON.stringify(value);
554
- } }));
555
- } }
556
- };
557
- /** Look up the codec for a parseable kind. Total — every CodecKind has one. */
558
- const codecFor = (kind) => CODECS[kind];
559
- //#endregion
560
- //#region src/domain/schemas/package-json.ts
561
- const PackageJsonSchema = vb.looseObject({
562
- files: vb.optional(vb.array(vb.string())),
563
- name: vb.optional(vb.string()),
564
- packageManager: vb.optional(vb.string()),
565
- private: vb.optional(vb.boolean()),
566
- publishConfig: vb.optional(vb.looseObject({ access: vb.optional(vb.picklist([
567
- "public",
568
- "restricted",
569
- "private"
570
- ])) })),
571
- trustedDependencies: vb.optional(vb.array(vb.string()))
572
- });
573
- const parsePackageJson = (value) => {
574
- if (!isPlainRecord(value)) throw new ConfigError("package.json: expected an object at the root.");
575
- const result = vb.safeParse(PackageJsonSchema, value);
576
- if (result.success) return result.output;
577
- const issue = result.issues[0];
578
- const field = vb.getDotPath(issue);
579
- throw new ConfigError(`package.json: ${field ? `${field}: ` : ""}${issue.message}`);
580
- };
581
- //#endregion
582
- //#region src/adapters/repo-context.ts
583
- const tryParseJson = (text) => {
584
- try {
585
- return JSON.parse(text);
586
- } catch (error) {
587
- let errMsg = String(error);
588
- if (error instanceof Error) errMsg = error.message;
589
- throw new ConfigError(`package.json: invalid JSON — ${errMsg}`);
590
- }
591
- };
592
- const readPackageJson = (raw) => {
593
- return parsePackageJson(tryParseJson(raw.trim()));
594
- };
595
- const createRepoContext = (root, fs = nodeFileSystem, projectType) => {
596
- if (fs === nodeFileSystem) assertDirectory(root);
597
- const readText = (relPath) => fs.readText(resolveIn(root, relPath));
598
- const exists = (relPath) => fs.exists(resolveIn(root, relPath));
599
- const raw = readText(asRelPath("package.json"));
600
- let packageJson = void 0;
601
- if (typeof raw !== "undefined") packageJson = readPackageJson(raw);
602
- return {
603
- exists,
604
- packageJson,
605
- projectType,
606
- readText,
607
- root
608
- };
609
- };
610
- //#endregion
611
- //#region src/application/workspace-prefix.ts
612
- /** Anchor Deno's native literal prefix before applying its glob suffix. */
613
- const anchorWorkspacePrefix = (pattern, glob, resolveChild) => {
614
- const parts = pattern.split("/");
615
- const firstWildcard = parts.findIndex((part) => /[*?]/u.test(part));
616
- const prefix = parts.slice(0, firstWildcard < 0 ? parts.length : firstWildcard);
617
- if (prefix.length === 0 || pattern === ".") return glob;
618
- const declared = prefix.join("/");
619
- let resolved;
620
- let attempted = false;
621
- const resolve = () => {
622
- if (!attempted) {
623
- attempted = true;
624
- let current = ".";
625
- for (const part of prefix) {
626
- const child = resolveChild(current, part);
627
- if (child === void 0) return void 0;
628
- current = current === "." ? child : `${current}/${child}`;
629
- }
630
- resolved = current;
631
- }
632
- return resolved;
633
- };
634
- const rewrite = (directory, actual) => directory === actual ? declared : directory.startsWith(`${actual}/`) ? declared + directory.slice(actual.length) : void 0;
635
- return {
636
- matches(directory) {
637
- if (directory === ".") return false;
638
- const actual = resolve();
639
- const rewritten = actual === void 0 ? void 0 : rewrite(directory, actual);
640
- return rewritten !== void 0 && glob.matches(rewritten);
641
- },
642
- canDescend(directory) {
643
- const actual = resolve();
644
- if (actual === void 0) return false;
645
- if (directory === "." || actual.startsWith(`${directory}/`)) return true;
646
- const rewritten = rewrite(directory, actual);
647
- return rewritten !== void 0 && glob.canDescend(rewritten);
648
- }
649
- };
650
- };
651
- //#endregion
652
- //#region src/application/workspace-globs.ts
653
- const MAX_WORKSPACE_GLOB_ALTERNATIVES = 8192;
654
- const BRACE_EXPANSION_PROBE_LIMIT = 8193;
655
- const defaultWorkspaceGlobOptions = {
656
- platform: "linux",
657
- nocase: process.platform === "darwin" || process.platform === "win32",
658
- windowsPathsNoEscape: true,
659
- nonegate: true,
660
- nocomment: true,
661
- optimizationLevel: 2
662
- };
663
- const boundedBraceExpand = (pattern) => {
664
- const alternatives = braceExpand(pattern, { braceExpandMax: BRACE_EXPANSION_PROBE_LIMIT });
665
- if (alternatives.length > MAX_WORKSPACE_GLOB_ALTERNATIVES) throw new ConfigError(`Workspace pattern expands beyond ${MAX_WORKSPACE_GLOB_ALTERNATIVES} alternatives.`);
666
- return alternatives;
667
- };
668
- /** Expand with minimatch's own bounded brace semantics. */
669
- const expandWorkspaceGlob = (pattern) => {
670
- try {
671
- return boundedBraceExpand(pattern);
672
- } catch (error) {
673
- if (error instanceof TypeError) throw new ConfigError(`Invalid workspace pattern: ${error.message}.`);
674
- throw error;
675
- }
676
- };
677
- /** Compile once so membership, exclusions, and traversal share glob semantics. */
678
- const compileWorkspaceGlob = (pattern, options = defaultWorkspaceGlobOptions) => {
679
- let matcher;
680
- try {
681
- if (!options.nobrace) boundedBraceExpand(pattern);
682
- matcher = new Minimatch(pattern, {
683
- ...options,
684
- braceExpandMax: BRACE_EXPANSION_PROBE_LIMIT
685
- });
686
- } catch (error) {
687
- if (error instanceof TypeError) throw new ConfigError(`Invalid workspace pattern: ${error.message}.`);
688
- throw error;
689
- }
690
- return {
691
- matches: (directory) => matcher.match(directory),
692
- canDescend(directory) {
693
- const parts = directory.split("/");
694
- return matcher.set.some((alternative) => (alternative.includes(GLOBSTAR) || parts.length < alternative.length) && matcher.matchOne(parts, alternative, true));
695
- }
696
- };
697
- };
698
- //#endregion
699
- //#region src/application/workspace-dialects.ts
700
- const matchesCrossPathGlob = (pattern, value) => {
701
- const patternCharacters = [...pattern];
702
- const valueCharacters = [...value];
703
- let patternIndex = 0;
704
- let valueIndex = 0;
705
- let starIndex = -1;
706
- let retryIndex = 0;
707
- while (valueIndex < valueCharacters.length) {
708
- const character = patternCharacters[patternIndex];
709
- if (character === "?" || character === valueCharacters[valueIndex]) {
710
- patternIndex += 1;
711
- valueIndex += 1;
712
- } else if (character === "*") {
713
- starIndex = patternIndex;
714
- retryIndex = valueIndex;
715
- patternIndex += 1;
716
- } else if (starIndex >= 0) {
717
- patternIndex = starIndex + 1;
718
- retryIndex += 1;
719
- valueIndex = retryIndex;
720
- } else return false;
721
- }
722
- while (patternCharacters[patternIndex] === "*") patternIndex += 1;
723
- return patternIndex === patternCharacters.length;
724
- };
725
- /** The bounded glob subset verified against the recorded Deno/Aube sources. */
726
- const compileAdditionalWorkspaceGlob = (pattern, pm, excluded) => {
727
- const parts = pattern.split("/");
728
- if (parts.some((part) => part.includes("**") && part !== "**")) throw new ConfigError(`${pm}: ** must occupy an entire workspace path component.`);
729
- if (pm === "aube" && (/[[\]{}()]/u.test(pattern) || pattern.includes("**") && (parts.at(-1) !== "**" || /[*?]/u.test(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.");
730
- const escaped = pm === "deno" ? pattern.replace(/[[\]]/gu, (character) => character === "[" ? "[[]" : "[]]") : pattern;
731
- const matcher = compileWorkspaceGlob(escaped, {
732
- platform: "linux",
733
- windowsPathsNoEscape: true,
734
- nonegate: true,
735
- nocomment: true,
736
- nobrace: true,
737
- noext: true,
738
- dot: pm === "aube",
739
- nocase: pm === "deno" && (/[*?]/u.test(pattern) || process.platform === "darwin" || process.platform === "win32")
740
- });
741
- if (pm === "deno" && /[*?]/u.test(pattern)) {
742
- const files = compileWorkspaceGlob(`${escaped}/package.json`, {
743
- platform: "linux",
744
- windowsPathsNoEscape: true,
745
- nonegate: true,
746
- nocomment: true,
747
- nobrace: true,
748
- noext: true,
749
- nocase: true
750
- });
751
- return {
752
- ...matcher,
753
- matches: (directory) => files.matches(directory === "." ? "package.json" : `${directory}/package.json`)
754
- };
755
- }
756
- if (pm !== "aube" || !excluded) return matcher;
757
- const self = pattern.endsWith("/**") ? pattern.slice(0, -3) : void 0;
758
- return {
759
- ...matcher,
760
- matches: (directory) => matchesCrossPathGlob(pattern, directory) || directory === self
761
- };
762
- };
763
- //#endregion
764
- //#region src/application/workspaces.ts
765
- /** npm cancels an earlier exclusion when a later positive pattern matches it. */
766
- const splitNpmPattern = (raw) => {
767
- const prefix = /^!+/u.exec(raw)?.[0] ?? "";
768
- return {
769
- excluded: prefix.length % 2 === 1,
770
- pattern: raw.slice(prefix.length)
771
- };
772
- };
773
- const npmPatterns = (patterns) => {
774
- const positive = [];
775
- let negative = [];
776
- for (const raw of patterns) {
777
- const parsed = splitNpmPattern(raw);
778
- const pattern = parsed.pattern.replace(/^\.?\/+/u, "");
779
- if (parsed.excluded) negative.push({
780
- pattern,
781
- glob: compileWorkspaceGlob(pattern, {})
782
- });
783
- else {
784
- for (let index = 0; index < negative.length; index += 1) if (negative[index]?.glob.matches(pattern)) negative.splice(index, 1);
785
- positive.push(pattern);
786
- }
787
- }
788
- return [...positive, ...negative.map(({ pattern }) => `!${pattern}`)];
789
- };
790
- /** Read declaration sources separately so Deno's two manifest sets stay distinct. */
791
- const workspaceDefinitions = (ctx, pm) => {
792
- const parse = createConfigParser(codecFor, ctx);
793
- const validate = (value, source, denoManifests = false) => {
794
- if (value === void 0) return {
795
- patterns: [],
796
- denoManifests
797
- };
798
- if (!Array.isArray(value) || !Array.from(value).every((item) => typeof item === "string")) throw new ConfigError(`${source}: expected an array of directory patterns.`);
799
- for (const pattern of value) {
800
- const positive = pm === "npm" ? splitNpmPattern(pattern).pattern : pattern.startsWith("!") ? pattern.slice(1) : pattern;
801
- if ((pm === "deno" || pm === "aube" ? [positive] : expandWorkspaceGlob(positive)).some((alternative) => !isRelPath(alternative) || alternative.includes("\\") || alternative.startsWith("!"))) throw new ConfigError(`${source}: use relative directory patterns without parent traversal: ${JSON.stringify(pattern)}.`);
802
- if (pm === "deno" && denoManifests && !pattern.startsWith("!") && path.posix.normalize(positive) === ".") throw new ConfigError(`${source}: a Deno workspace cannot contain itself.`);
803
- }
804
- return {
805
- patterns: pm === "npm" ? npmPatterns(value) : value,
806
- denoManifests
807
- };
808
- };
809
- const packageDefinition = () => {
810
- let value = ctx.packageJson?.workspaces;
811
- if (isPlainRecord(value)) {
812
- value = value.packages;
813
- if (value === void 0) throw new ConfigError("package.json#workspaces: expected a packages array.");
814
- }
815
- return validate(value, "package.json#workspaces");
816
- };
817
- if (pm === "pnpm") return [validate(ctx.exists(CONFIG_FILES.pnpmWorkspace.path) ? parse(CONFIG_FILES.pnpmWorkspace).packages : void 0, "pnpm-workspace.yaml#packages")];
818
- if (pm === "aube") {
819
- const file = [CONFIG_FILES.aubeWorkspace, CONFIG_FILES.pnpmWorkspace].find((candidate) => ctx.exists(candidate.path));
820
- return [file ? validate(parse(file).packages, `${file.path}#packages`) : packageDefinition()];
821
- }
822
- if (pm === "deno") return [validate(parse(CONFIG_FILES.denoJson).workspace, "deno.json#workspace", true), packageDefinition()];
823
- return [packageDefinition()];
824
- };
825
- /** Traverse only ordinary directories that can fall under a positive pattern. */
826
- const workspaceDirectories = (ctx, fs, definition, pm) => {
827
- const { patterns } = definition;
828
- const skipVendor = pm === "deno" && createConfigParser(codecFor, ctx)(CONFIG_FILES.denoJson).vendor === true;
829
- const positive = patterns.filter((pattern) => !pattern.startsWith("!")).map((pattern) => path.posix.normalize(pattern).replace(/\/+$/u, ""));
830
- const negative = patterns.filter((pattern) => pattern.startsWith("!")).map((pattern) => path.posix.normalize(pattern.slice(1)).replace(/\/+$/u, ""));
831
- const directoryCache = /* @__PURE__ */ new Map();
832
- const readDirectories = (directory) => {
833
- const cached = directoryCache.get(directory);
834
- if (cached) return cached;
835
- if (!fs.readDirectories) throw new UsageError("Workspace discovery requires FileSystem.readDirectories; no host filesystem fallback is used.");
836
- const names = fs.readDirectories(resolveIn(ctx.root, asRelPath(directory)));
837
- if (!Array.isArray(names)) throw new ConfigError("FileSystem.readDirectories must return an array of directory names.");
838
- 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.");
839
- const result = [...new Set(names)].sort();
840
- directoryCache.set(directory, result);
841
- return result;
842
- };
843
- const resolveChild = (parent, name) => {
844
- if (name === ".git" || name === "node_modules") return void 0;
845
- const names = readDirectories(parent);
846
- const resolved = names.includes(name) ? name : fs.resolveDirectory?.(resolveIn(ctx.root, asRelPath(parent)), name);
847
- if (resolved !== void 0 && !names.includes(resolved)) throw new ConfigError("FileSystem.resolveDirectory must return an enumerated ordinary child directory name.");
848
- return resolved === ".git" || resolved === "node_modules" ? void 0 : resolved;
849
- };
850
- const compile = (pattern, excluded = false) => {
851
- if (pm !== "deno" && pm !== "aube") return compileWorkspaceGlob(pattern, pm === "npm" ? {
852
- ...defaultWorkspaceGlobOptions,
853
- nocomment: false
854
- } : defaultWorkspaceGlobOptions);
855
- const glob = compileAdditionalWorkspaceGlob(pattern, pm, excluded);
856
- return pm === "deno" && !excluded ? anchorWorkspacePrefix(pattern, glob, resolveChild) : glob;
857
- };
858
- const included = positive.map((pattern) => compile(pattern));
859
- const excluded = negative.map((pattern) => {
860
- return {
861
- glob: compile(pattern, true),
862
- subtree: pm === "deno" || pm === "aube" || pm === "bun" ? void 0 : pattern === "**" ? { matches: () => true } : pattern.endsWith("/**") ? compile(pattern.slice(0, -3), true) : void 0
863
- };
864
- });
865
- const declaredBase = (pattern) => {
866
- const parts = pattern.split("/");
867
- const wildcard = parts.findIndex((part) => /[*?]/u.test(part));
868
- return parts.slice(0, wildcard < 0 ? parts.length : wildcard).join("/") || ".";
869
- };
870
- const relatedBases = (left, right) => left === "." || right === "." || left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`);
871
- const denoPositive = pm === "deno" ? positive.filter((pattern) => /[*?]/u.test(pattern)).map((pattern) => ({
872
- base: declaredBase(pattern),
873
- scope: anchorWorkspacePrefix(declaredBase(pattern), {
874
- matches: () => true,
875
- canDescend: () => true
876
- }, resolveChild)
877
- })) : [];
878
- const denoOrdered = pm === "deno" ? patterns.filter((pattern) => /[*?]/u.test(pattern) || pattern.startsWith("!")).map((raw) => {
879
- const isExcluded = raw.startsWith("!");
880
- const pattern = path.posix.normalize(raw.replace(/^!/u, "")).replace(/\/+$/u, "");
881
- const glob = compile(pattern, isExcluded);
882
- if (!isExcluded) return {
883
- excluded: false,
884
- glob
885
- };
886
- return {
887
- excluded: true,
888
- glob: {
889
- ...glob,
890
- matches(directory) {
891
- if (!/[*?]/u.test(pattern)) return directory === pattern;
892
- if (!glob.matches(directory)) return false;
893
- const applicable = denoPositive.filter((item) => item.scope.matches(directory)).map((item) => relatedBases(item.base, declaredBase(pattern)));
894
- 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.");
895
- return applicable.some(Boolean);
896
- }
897
- }
898
- };
899
- }) : [];
900
- const bunPatterns = pm === "bun" ? patterns.map((raw) => {
901
- const isExcluded = raw.startsWith("!");
902
- const pattern = path.posix.normalize(raw.replace(/^!/u, "")).replace(/\/+$/u, "");
903
- const usesGlob = isExcluded || pattern.includes("*") || pattern.includes("?") || pattern.includes("{") || pattern.includes("[");
904
- const glob = compileWorkspaceGlob(pattern, {
905
- ...defaultWorkspaceGlobOptions,
906
- noext: true
907
- });
908
- const subtree = isExcluded && pattern.endsWith("/**") ? compileWorkspaceGlob(pattern.slice(0, -3), {
909
- ...defaultWorkspaceGlobOptions,
910
- noext: true
911
- }) : void 0;
912
- return {
913
- isExcluded,
914
- usesGlob,
915
- glob,
916
- matches: (directory) => glob.matches(directory) || glob.matches(`${directory}/`) || subtree?.matches(directory) === true
917
- };
918
- }) : [];
919
- const bunOrdered = bunPatterns.filter(({ usesGlob }) => usesGlob);
920
- const bunLiterals = bunPatterns.filter(({ isExcluded, usesGlob }) => !isExcluded && !usesGlob);
921
- const bunTraversal = bunPatterns.filter(({ isExcluded }) => !isExcluded).map(({ glob }) => glob);
922
- const denoLiteral = pm === "deno" ? positive.filter((pattern) => !/[*?]/u.test(pattern)).map((pattern) => compile(pattern)) : [];
923
- if (definition.denoManifests && denoOrdered.findLast(({ glob }) => glob.matches("."))?.excluded === false) throw new ConfigError("deno.json#workspace: a Deno workspace cannot contain itself.");
924
- if (positive.length === 0 || positive.every((pattern) => pattern === ".")) return [];
925
- if (!fs.readDirectories) throw new UsageError("Workspace discovery requires FileSystem.readDirectories; no host filesystem fallback is used.");
926
- const result = [];
927
- const pending = ["."];
928
- while (pending.length > 0) {
929
- const current = pending.pop();
930
- if (current === void 0) break;
931
- for (const name of readDirectories(current)) {
932
- if (name === ".git" || name === "node_modules") continue;
933
- const directory = asRelPath(current === "." ? name : `${current}/${name}`);
934
- if (pm === "bun" && name === "CMakeFiles" && !bunLiterals.some(({ glob }) => glob.matches(directory) || glob.canDescend(directory))) continue;
935
- const inVendor = skipVendor && (directory === "vendor" || directory.startsWith("vendor/"));
936
- if (inVendor && !denoLiteral.some((pattern) => pattern.matches(directory) || pattern.canDescend(directory))) continue;
937
- const isExcluded = pm !== "deno" && excluded.some(({ glob }) => glob.matches(directory) || glob.matches(`${directory}/`));
938
- if (excluded.some(({ subtree }) => subtree?.matches(directory))) continue;
939
- const lastDenoMatch = denoOrdered.findLast(({ glob }) => glob.matches(directory));
940
- const lastBunMatch = bunOrdered.findLast(({ matches }) => matches(directory));
941
- 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);
942
- if ((pm === "bun" ? bunTraversal : inVendor ? denoLiteral : included).some((pattern) => pattern.canDescend(directory))) pending.push(directory);
943
- }
944
- }
945
- return result.sort();
946
- };
947
- //#endregion
948
- //#region src/application/workspace-contexts.ts
949
- /** Expand each manager's declarations into isolated publication contexts. */
950
- const collectWorkspaceMembers = (ctx, fs, pms, projectType) => pms.flatMap((pm) => {
951
- const seen = /* @__PURE__ */ new Set();
952
- return workspaceDefinitions(ctx, pm).flatMap((definition) => workspaceDirectories(ctx, fs, definition, pm).flatMap((directory) => {
953
- const root = resolveIn(ctx.root, directory);
954
- const manifest = resolveIn(root, asRelPath("package.json"));
955
- const denoManifest = resolveIn(root, asRelPath("deno.json"));
956
- const denoJsonc = resolveIn(root, asRelPath("deno.jsonc"));
957
- const allowed = new Set(pm === "deno" ? [
958
- manifest,
959
- denoManifest,
960
- denoJsonc
961
- ] : [manifest]);
962
- const memberFs = {
963
- exists: (file) => allowed.has(file) && fs.exists(file),
964
- readText: (file) => allowed.has(file) ? fs.readText(file) : void 0
965
- };
966
- try {
967
- const explicitMember = definition.patterns.some((pattern) => !pattern.startsWith("!") && !/[*?]/u.test(pattern) && path.posix.normalize(pattern).replace(/\/+$/u, "") === directory);
968
- if (!definition.denoManifests && !fs.exists(manifest)) {
969
- if (pm === "deno" && explicitMember) throw new ConfigError("Declared npm workspace member has no package.json.");
970
- return [];
971
- }
972
- if (pm === "deno" && !fs.exists(denoManifest) && fs.exists(denoJsonc)) throw new ConfigError("deno.jsonc is not supported; use strict deno.json.");
973
- const memberCtx = createRepoContext(root, memberFs, projectType);
974
- if (!definition.denoManifests && !memberCtx.packageJson) return [];
975
- if (!memberCtx.packageJson && !memberCtx.exists(asRelPath("deno.json"))) {
976
- if (definition.denoManifests && explicitMember) throw new ConfigError("Declared Deno member has no deno.json or package.json.");
977
- return [];
978
- }
979
- if (pm === "deno") {
980
- 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.");
981
- }
982
- if (seen.has(directory)) return [];
983
- seen.add(directory);
984
- return [{
985
- directory,
986
- pm,
987
- ctx: memberCtx
988
- }];
989
- } catch (error) {
990
- if (error instanceof ConfigError) throw new ConfigError(`${directory}/${error.message}`);
991
- throw error;
992
- }
993
- }));
994
- });
995
- //#endregion
996
- //#region src/domain/setting-availability.ts
413
+ //#region src/core/rules/setting-availability.ts
997
414
  const { npmrc: npmrc$7, packageJson: packageJson$2, pnpmWorkspace: pnpmWorkspace$11, yarnrc: yarnrc$10, bunfig: bunfig$6 } = CONFIG_FILES;
998
415
  const settingAvailability = [
999
416
  {
@@ -1114,7 +531,7 @@ const settingAvailability = [
1114
531
  }
1115
532
  ];
1116
533
  //#endregion
1117
- //#region src/domain/services/remediation-availability.ts
534
+ //#region src/core/rules/remediation-availability.ts
1118
535
  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]);
1119
536
  /** Undefined means the target version or the setting's introduction is unknown. */
1120
537
  const settingSupportedByTarget = (pm, version, target) => {
@@ -1133,7 +550,7 @@ const guardRemediationAvailability = (pm, version, remediation, targets = remedi
1133
550
  };
1134
551
  };
1135
552
  //#endregion
1136
- //#region src/domain/rules/remediation.ts
553
+ //#region src/core/rules/remediation.ts
1137
554
  /** Scalar writes must not discard an existing container or replace a non-object parent. */
1138
555
  const proposeChanges = (config, operations) => {
1139
556
  for (const operation of operations) {
@@ -1157,7 +574,7 @@ const proposeChanges = (config, operations) => {
1157
574
  };
1158
575
  };
1159
576
  //#endregion
1160
- //#region src/domain/rules/builders/require-config-key.ts
577
+ //#region src/core/rules/builders/require-config-key.ts
1161
578
  const overrideBindings = (rule, overrides) => ({
1162
579
  ...rule,
1163
580
  bindings: {
@@ -1168,7 +585,7 @@ const overrideBindings = (rule, overrides) => ({
1168
585
  const accepts = (spec, actual) => spec.accept ? spec.accept(actual) : actual === spec.value;
1169
586
  const checkKeyValue = (spec, config) => {
1170
587
  const actual = getByPath(config, spec.keyPath);
1171
- const coveredByDefault = actual === void 0 && spec.documentedDefault !== void 0 && spec.versionNote?.defaultSafeSince === void 0 && accepts(spec, spec.documentedDefault);
588
+ const coveredByDefault = actual === void 0 && spec.documentedDefault !== void 0 && spec.defaultSafety === "unconditional" && accepts(spec, spec.documentedDefault);
1172
589
  if (!coveredByDefault && accepts(spec, actual)) return { state: "ok" };
1173
590
  const severity = coveredByDefault ? spec.defaultSatisfiedSeverity ?? "info" : void 0;
1174
591
  if (severity === "off") return { state: "ok" };
@@ -1221,7 +638,7 @@ const requireConfigKey = (options) => {
1221
638
  };
1222
639
  };
1223
640
  //#endregion
1224
- //#region src/domain/rules/builders/with-aube-paranoid.ts
641
+ //#region src/core/rules/builders/with-aube-paranoid.ts
1225
642
  const withAubeParanoid = (rule) => {
1226
643
  const { aube } = rule.bindings;
1227
644
  if (!aube) return rule;
@@ -1234,13 +651,14 @@ const withAubeParanoid = (rule) => {
1234
651
  } });
1235
652
  };
1236
653
  //#endregion
1237
- //#region src/domain/rules/advisory-check.ts
654
+ //#region src/core/rules/advisory-check.ts
1238
655
  const { aubeWorkspace: aubeWorkspace$8 } = CONFIG_FILES;
1239
656
  const advisoryCheck = withAubeParanoid(requireConfigKey({
1240
657
  bindings: { aube: {
1241
658
  accept: (value) => value === "on" || value === "required",
1242
659
  docs: "https://aube.jdx.dev/security.html",
1243
660
  documentedDefault: "on",
661
+ defaultSafety: "unconditional",
1244
662
  file: aubeWorkspace$8,
1245
663
  keyPath: ["advisoryCheck"],
1246
664
  message: "Set `advisoryCheck: on` (or `required`) in aube-workspace.yaml to check dependencies against known-malicious advisories.",
@@ -1253,11 +671,11 @@ const advisoryCheck = withAubeParanoid(requireConfigKey({
1253
671
  title: "Enable malicious-package advisory checks"
1254
672
  }));
1255
673
  //#endregion
1256
- //#region src/domain/rules/config-predicates.ts
674
+ //#region src/core/rules/config-predicates.ts
1257
675
  const isNonBlankString = (value) => typeof value === "string" && value.trim().length > 0;
1258
676
  const isStringList = (value) => Array.isArray(value) && Array.from(value).every(isNonBlankString);
1259
677
  //#endregion
1260
- //#region src/domain/rules/approved-git-repos.ts
678
+ //#region src/core/rules/approved-git-repos.ts
1261
679
  const { yarnrc: yarnrc$9 } = CONFIG_FILES;
1262
680
  const message = "Set `approvedGitRepositories: []` in .yarnrc.yml to block all git: protocol dependencies (or list approved repository globs).";
1263
681
  const approvedGitRepos = defineRule({
@@ -1287,7 +705,7 @@ const approvedGitRepos = defineRule({
1287
705
  title: "Approve git repository dependencies"
1288
706
  });
1289
707
  //#endregion
1290
- //#region src/domain/rules/audit-suppression.ts
708
+ //#region src/core/rules/audit-suppression.ts
1291
709
  const { yarnrc: yarnrc$8 } = CONFIG_FILES;
1292
710
  const isNonEmptyArray = (value) => Array.isArray(value) && value.length > 0;
1293
711
  const SUPPRESSION_KEYS = ["npmAuditIgnoreAdvisories", "npmAuditExcludePackages"];
@@ -1320,7 +738,7 @@ const auditSuppression = defineRule({
1320
738
  title: "Review audit suppression entries"
1321
739
  });
1322
740
  //#endregion
1323
- //#region src/domain/rules/block-auto-install.ts
741
+ //#region src/core/rules/block-auto-install.ts
1324
742
  const { bunfig: bunfig$5 } = CONFIG_FILES;
1325
743
  const blockAutoInstall = requireConfigKey({
1326
744
  bindings: { bun: {
@@ -1338,11 +756,11 @@ const blockAutoInstall = requireConfigKey({
1338
756
  title: "Block automatic package installation"
1339
757
  });
1340
758
  //#endregion
1341
- //#region src/domain/rules/block-exotic-subdeps.ts
759
+ //#region src/core/rules/block-exotic-subdeps.ts
1342
760
  const { aubeWorkspace: aubeWorkspace$7, npmrc: npmrc$6, pnpmWorkspace: pnpmWorkspace$10 } = CONFIG_FILES;
1343
761
  const isRootOrNone = (value) => value === "root" || value === "none";
1344
762
  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.";
1345
- const npmBinding$2 = {
763
+ const npmBinding$1 = {
1346
764
  check(_ctx, config) {
1347
765
  const git = getByPath(config, ["allow-git"]);
1348
766
  const remote = getByPath(config, ["allow-remote"]);
@@ -1375,14 +793,16 @@ const blockExoticSubdeps = overrideBindings(requireConfigKey({
1375
793
  aube: {
1376
794
  docs: "https://aube.jdx.dev/security.html",
1377
795
  documentedDefault: true,
796
+ defaultSafety: "unconditional",
1378
797
  file: aubeWorkspace$7,
1379
798
  keyPath: ["blockExoticSubdeps"],
1380
799
  message: "Set `blockExoticSubdeps: true` in aube-workspace.yaml to block git/tarball URLs in transitive dependencies.",
1381
800
  value: true
1382
801
  },
1383
802
  pnpm: {
1384
- docs: "https://pnpm.io/settings#blockexoticsubdeps",
803
+ docs: "https://pnpm.io/settings/dependency-resolution#blockexoticsubdeps",
1385
804
  documentedDefault: true,
805
+ defaultSafety: "conditional",
1386
806
  file: pnpmWorkspace$10,
1387
807
  keyPath: ["blockExoticSubdeps"],
1388
808
  message: "Set `blockExoticSubdeps: true` in pnpm-workspace.yaml to block git/tarball URLs in transitive dependencies.",
@@ -1394,13 +814,13 @@ const blockExoticSubdeps = overrideBindings(requireConfigKey({
1394
814
  }
1395
815
  },
1396
816
  description: "Refuse to install transitive dependencies sourced from git or tarball URLs, which bypass registry integrity checking.",
1397
- docs: "https://pnpm.io/settings#blockexoticsubdeps",
817
+ docs: "https://pnpm.io/settings/dependency-resolution#blockexoticsubdeps",
1398
818
  id: "block-exotic-subdeps",
1399
819
  severity: "warn",
1400
820
  title: "Block exotic subdependencies"
1401
- }), { npm: npmBinding$2 });
821
+ }), { npm: npmBinding$1 });
1402
822
  //#endregion
1403
- //#region src/domain/rules/bun-security-scanner.ts
823
+ //#region src/core/rules/bun-security-scanner.ts
1404
824
  const { bunfig: bunfig$4 } = CONFIG_FILES;
1405
825
  const bunSecurityScanner = defineRule({
1406
826
  bindings: { bun: {
@@ -1439,12 +859,13 @@ const bunSecurityScanner = defineRule({
1439
859
  title: "Enable a bun install-time security scanner"
1440
860
  });
1441
861
  //#endregion
1442
- //#region src/domain/rules/checksum-verification.ts
862
+ //#region src/core/rules/checksum-verification.ts
1443
863
  const { yarnrc: yarnrc$7 } = CONFIG_FILES;
1444
864
  const checksumVerification = requireConfigKey({
1445
865
  bindings: { yarn: {
1446
866
  docs: "https://yarnpkg.com/configuration/yarnrc#checksumBehavior",
1447
867
  documentedDefault: "throw",
868
+ defaultSafety: "unconditional",
1448
869
  file: yarnrc$7,
1449
870
  keyPath: ["checksumBehavior"],
1450
871
  message: "Set `checksumBehavior: throw` in .yarnrc.yml to reject packages whose checksum does not match the lockfile.",
@@ -1457,7 +878,7 @@ const checksumVerification = requireConfigKey({
1457
878
  title: "Verify package checksums"
1458
879
  });
1459
880
  //#endregion
1460
- //#region src/domain/entities/signals.ts
881
+ //#region src/core/signals.ts
1461
882
  const PM_SIGNALS = {
1462
883
  aube: {
1463
884
  configs: [CONFIG_FILES.aubeWorkspace.path],
@@ -1492,11 +913,11 @@ const PM_SIGNALS = {
1492
913
  }
1493
914
  };
1494
915
  //#endregion
1495
- //#region src/domain/rules/commit-lockfile.ts
916
+ //#region src/core/rules/commit-lockfile.ts
1496
917
  const LOCKFILE_DOCS = {
1497
918
  aube: "https://github.com/aubepkg/aube/blob/main/docs/package-manager/lockfiles.md",
1498
919
  bun: "https://bun.com/docs/install/lockfile",
1499
- deno: "https://docs.deno.com/runtime/fundamentals/modules/#integrity-checking-and-lock-files",
920
+ deno: "https://docs.deno.com/runtime/reference/deno_json/#lockfile",
1500
921
  npm: "https://docs.npmjs.com/cli/v12/configuring-npm/package-lock-json",
1501
922
  pnpm: "https://pnpm.io/git#lockfiles",
1502
923
  yarn: "https://yarnpkg.com/getting-started/qa#should-lockfiles-be-committed-to-the-repository"
@@ -1505,7 +926,7 @@ const LOCKFILE_VERSION_NOTES = { deno: { configAvailableSince: "deno 1.28.0" } }
1505
926
  const lockfileBinding = (pm) => {
1506
927
  const { lockfiles, reusesLockfiles } = PM_SIGNALS[pm];
1507
928
  const [primary] = lockfiles;
1508
- const accepted = [...lockfiles, ...reusesLockfiles ?? []].filter((lockfile) => pm !== "npm" || lockfile === primary);
929
+ const accepted = [...lockfiles, ...reusesLockfiles ?? []];
1509
930
  return {
1510
931
  check(ctx) {
1511
932
  if (pm === "aube" && ctx.exists(asRelPath("bun.lockb")) && !ctx.exists(asRelPath("bun.lock"))) return {
@@ -1516,7 +937,20 @@ const lockfileBinding = (pm) => {
1516
937
  steps: ["Run `bun install --save-text-lockfile` to generate bun.lock, then commit it."]
1517
938
  }
1518
939
  };
1519
- if (accepted.some((lf) => ctx.exists(asRelPath(lf)))) return { state: "ok" };
940
+ if (pm === "npm") {
941
+ if (ctx.exists(asRelPath(primary))) return { state: "ok" };
942
+ if (ctx.exists(asRelPath("npm-shrinkwrap.json"))) {
943
+ if (ctx.pmVersion && lt(ctx.pmVersion, "12.0.0")) return { state: "ok" };
944
+ return {
945
+ state: "violation",
946
+ 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.",
947
+ remediation: {
948
+ kind: "manual",
949
+ 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."]
950
+ }
951
+ };
952
+ }
953
+ } else if (accepted.some((lf) => ctx.exists(asRelPath(lf)))) return { state: "ok" };
1520
954
  return {
1521
955
  remediation: {
1522
956
  kind: "manual",
@@ -1530,7 +964,7 @@ const lockfileBinding = (pm) => {
1530
964
  versionNote: LOCKFILE_VERSION_NOTES[pm]
1531
965
  };
1532
966
  };
1533
- const denoBinding$2 = {
967
+ const denoBinding$1 = {
1534
968
  file: CONFIG_FILES.denoJson,
1535
969
  docs: LOCKFILE_DOCS.deno,
1536
970
  versionNote: LOCKFILE_VERSION_NOTES.deno,
@@ -1561,19 +995,19 @@ const commitLockfile = defineRule({
1561
995
  bindings: {
1562
996
  aube: lockfileBinding("aube"),
1563
997
  bun: lockfileBinding("bun"),
1564
- deno: denoBinding$2,
998
+ deno: denoBinding$1,
1565
999
  npm: lockfileBinding("npm"),
1566
1000
  pnpm: lockfileBinding("pnpm"),
1567
1001
  yarn: lockfileBinding("yarn")
1568
1002
  },
1569
1003
  description: "Lockfiles pin the full dependency tree and integrity hashes, enabling reproducible, verifiable installs (e.g. `npm ci`).",
1570
- docs: "https://github.com/bodadotsh/npm-security-best-practices#2-include-lockfiles",
1004
+ docs: "https://github.com/bodadotsh/npm-security-best-practices#3-include-lockfiles",
1571
1005
  id: "commit-lockfile",
1572
1006
  severity: "error",
1573
1007
  title: "Commit a lockfile"
1574
1008
  });
1575
1009
  //#endregion
1576
- //#region src/domain/rules/dependency-overrides.ts
1010
+ //#region src/core/rules/dependency-overrides.ts
1577
1011
  const { aubeWorkspace: aubeWorkspace$6, pnpmWorkspace: pnpmWorkspace$9 } = CONFIG_FILES;
1578
1012
  const isNonEmptyObject$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length > 0;
1579
1013
  const checkOverrides = (config, file) => {
@@ -1599,16 +1033,16 @@ const makeBinding = (file, fileName, docs) => ({
1599
1033
  const dependencyOverrides = defineRule({
1600
1034
  bindings: {
1601
1035
  aube: makeBinding(aubeWorkspace$6, "aube-workspace.yaml", "https://aube.sh/settings/"),
1602
- pnpm: makeBinding(pnpmWorkspace$9, "pnpm-workspace.yaml", "https://pnpm.io/settings#overrides")
1036
+ pnpm: makeBinding(pnpmWorkspace$9, "pnpm-workspace.yaml", "https://pnpm.io/settings/dependency-resolution#overrides")
1603
1037
  },
1604
1038
  description: "Flag dependency overrides that can replace transitive packages with arbitrary versions or forks — a supply-chain injection vector.",
1605
- docs: "https://pnpm.io/settings#overrides",
1039
+ docs: "https://pnpm.io/settings/dependency-resolution#overrides",
1606
1040
  id: "dependency-overrides",
1607
1041
  severity: "info",
1608
1042
  title: "Review dependency overrides"
1609
1043
  });
1610
1044
  //#endregion
1611
- //#region src/domain/rules/disable-lifecycle-scripts.ts
1045
+ //#region src/core/rules/disable-lifecycle-scripts.ts
1612
1046
  const { npmrc: npmrc$5, pnpmWorkspace: pnpmWorkspace$8, yarnrc: yarnrc$6, aubeWorkspace: aubeWorkspace$5, bunfig: bunfig$3 } = CONFIG_FILES;
1613
1047
  const pnpmBinding$4 = {
1614
1048
  check(ctx, config) {
@@ -1682,7 +1116,7 @@ const pnpmBinding$4 = {
1682
1116
  }])
1683
1117
  };
1684
1118
  },
1685
- docs: "https://pnpm.io/settings#strictdepbuilds",
1119
+ docs: "https://pnpm.io/settings/build#strictdepbuilds",
1686
1120
  file: pnpmWorkspace$8,
1687
1121
  versionNote: {
1688
1122
  configAvailableSince: "pnpm 10.3.0",
@@ -1769,6 +1203,7 @@ const disableLifecycleScripts = withAubeParanoid(overrideBindings(requireConfigK
1769
1203
  yarn: {
1770
1204
  docs: "https://yarnpkg.com/configuration/yarnrc#enableScripts",
1771
1205
  documentedDefault: false,
1206
+ defaultSafety: "conditional",
1772
1207
  file: yarnrc$6,
1773
1208
  keyPath: ["enableScripts"],
1774
1209
  message: "Set `enableScripts: false` in .yarnrc.yml to pin the policy across versions.",
@@ -1780,7 +1215,7 @@ const disableLifecycleScripts = withAubeParanoid(overrideBindings(requireConfigK
1780
1215
  }
1781
1216
  },
1782
1217
  description: "Malicious postinstall scripts are a primary supply-chain attack vector. Prevent automatic execution of dependency lifecycle scripts.",
1783
- docs: "https://github.com/bodadotsh/npm-security-best-practices#3-disable-lifecycle-scripts",
1218
+ docs: "https://github.com/bodadotsh/npm-security-best-practices#1-disable-lifecycle-scripts",
1784
1219
  id: "disable-lifecycle-scripts",
1785
1220
  severity: "error",
1786
1221
  title: "Disable dependency lifecycle scripts"
@@ -1790,7 +1225,7 @@ const disableLifecycleScripts = withAubeParanoid(overrideBindings(requireConfigK
1790
1225
  pnpm: pnpmBinding$4
1791
1226
  }));
1792
1227
  //#endregion
1793
- //#region src/domain/rules/enforce-strict-ssl.ts
1228
+ //#region src/core/rules/enforce-strict-ssl.ts
1794
1229
  const { npmrc: npmrc$4, yarnrc: yarnrc$5 } = CONFIG_FILES;
1795
1230
  const yarnMessage = "Set `enableStrictSsl: true` in .yarnrc.yml to enforce SSL certificate validation for registry connections.";
1796
1231
  const yarnBinding = {
@@ -1829,6 +1264,7 @@ const enforceStrictSsl = overrideBindings(requireConfigKey({
1829
1264
  bindings: { npm: {
1830
1265
  docs: "https://docs.npmjs.com/cli/v11/using-npm/config#strict-ssl",
1831
1266
  documentedDefault: true,
1267
+ defaultSafety: "unconditional",
1832
1268
  file: npmrc$4,
1833
1269
  keyPath: ["strict-ssl"],
1834
1270
  message: "Set `strict-ssl=true` in .npmrc to enforce SSL certificate validation.",
@@ -1841,7 +1277,7 @@ const enforceStrictSsl = overrideBindings(requireConfigKey({
1841
1277
  title: "Enforce SSL for registry connections"
1842
1278
  }), { yarn: yarnBinding });
1843
1279
  //#endregion
1844
- //#region src/domain/services/project-type.ts
1280
+ //#region src/core/resolve-project-type.ts
1845
1281
  const isPublishableName = (value) => typeof value === "string" && value.trim().length > 0;
1846
1282
  const resolveProjectType = (selected, inferredPackage) => selected ?? (inferredPackage ? "package" : "application");
1847
1283
  const resolvePackageJsonProjectType = (ctx) => {
@@ -1851,10 +1287,10 @@ const resolvePackageJsonProjectType = (ctx) => {
1851
1287
  };
1852
1288
  const resolveDenoProjectType = (ctx, denoConfig) => resolveProjectType(ctx.projectType, getByPath(denoConfig, ["publish"]) !== false && isPublishableName(getByPath(denoConfig, ["name"])));
1853
1289
  //#endregion
1854
- //#region src/domain/rules/publishable.ts
1290
+ //#region src/core/rules/publishable.ts
1855
1291
  const isPublishable = (ctx) => resolvePackageJsonProjectType(ctx) === "package";
1856
1292
  //#endregion
1857
- //#region src/domain/rules/files-field.ts
1293
+ //#region src/core/rules/files-field.ts
1858
1294
  const { packageJson: packageJson$1, denoJson: denoJson$3 } = CONFIG_FILES;
1859
1295
  const packageJsonFilesBinding = {
1860
1296
  check(ctx) {
@@ -1874,47 +1310,48 @@ const packageJsonFilesBinding = {
1874
1310
  docs: "https://docs.npmjs.com/cli/v11/configuring-npm/package-json#files",
1875
1311
  file: packageJson$1
1876
1312
  };
1313
+ const denoPublishBinding = {
1314
+ check(ctx, config) {
1315
+ if (resolveDenoProjectType(ctx, config) !== "package") return { state: "na" };
1316
+ const include = getByPath(config, ["publish", "include"]);
1317
+ if (isStringList(include) && include.length > 0) return { state: "ok" };
1318
+ return {
1319
+ remediation: {
1320
+ kind: "manual",
1321
+ steps: ["Add a non-empty `publish.include` array to deno.json; use `publish.exclude` for additional exclusions."]
1322
+ },
1323
+ actual: include,
1324
+ message: "Add `publish.include` to deno.json to limit what gets published.",
1325
+ state: "violation"
1326
+ };
1327
+ },
1328
+ docs: "https://docs.deno.com/runtime/reference/deno_json/#publish---override-.gitignore",
1329
+ file: denoJson$3
1330
+ };
1877
1331
  const filesField = defineRule({
1878
1332
  bindings: {
1879
1333
  aube: packageJsonFilesBinding,
1880
1334
  bun: packageJsonFilesBinding,
1881
- deno: {
1882
- check(ctx, config) {
1883
- if (resolveDenoProjectType(ctx, config) !== "package") return { state: "na" };
1884
- const include = getByPath(config, ["publish", "include"]);
1885
- if (isStringList(include) && include.length > 0) return { state: "ok" };
1886
- return {
1887
- remediation: {
1888
- kind: "manual",
1889
- steps: ["Add a non-empty `publish.include` array to deno.json; use `publish.exclude` for additional exclusions."]
1890
- },
1891
- actual: include,
1892
- message: "Add `publish.include` to deno.json to limit what gets published.",
1893
- state: "violation"
1894
- };
1895
- },
1896
- docs: "https://docs.deno.com/runtime/reference/cli/publish/#how-publishing-works",
1897
- file: denoJson$3
1898
- },
1335
+ deno: denoPublishBinding,
1899
1336
  npm: packageJsonFilesBinding,
1900
1337
  pnpm: packageJsonFilesBinding,
1901
1338
  yarn: packageJsonFilesBinding
1902
1339
  },
1903
1340
  description: "An explicit `files` array in package.json restricts what gets published, preventing accidental inclusion of secrets or local files.",
1904
- docs: "https://github.com/bodadotsh/npm-security-best-practices#11-review-published-files",
1341
+ docs: "https://github.com/bodadotsh/npm-security-best-practices#12-review-published-files",
1905
1342
  id: "files-field",
1906
1343
  projectTypes: ["package"],
1907
1344
  severity: "info",
1908
1345
  title: "Declare a published files allow-list"
1909
1346
  });
1910
1347
  //#endregion
1911
- //#region src/domain/rules/frozen-lockfile.ts
1348
+ //#region src/core/rules/frozen-lockfile.ts
1912
1349
  const { pnpmWorkspace: pnpmWorkspace$7, yarnrc: yarnrc$4, bunfig: bunfig$2, denoJson: denoJson$2 } = CONFIG_FILES;
1913
1350
  const aubeFrozenCommand = "Use `aube ci` or `aube install --frozen-lockfile` to fail when the lockfile would change.";
1914
1351
  const frozenLockfile = overrideBindings(requireConfigKey({
1915
1352
  bindings: {
1916
1353
  deno: {
1917
- docs: "https://docs.deno.com/runtime/fundamentals/configuration/#lock",
1354
+ docs: "https://docs.deno.com/runtime/reference/deno_json/#lockfile",
1918
1355
  file: denoJson$2,
1919
1356
  keyPath: ["lock", "frozen"],
1920
1357
  message: "Set `lock.frozen: true` in deno.json for reproducible, verified installs.",
@@ -1929,7 +1366,7 @@ const frozenLockfile = overrideBindings(requireConfigKey({
1929
1366
  versionNote: { configAvailableSince: "bun 0.6.10" }
1930
1367
  },
1931
1368
  pnpm: {
1932
- docs: "https://pnpm.io/settings#frozenlockfile",
1369
+ docs: "https://pnpm.io/cli/install#--frozen-lockfile",
1933
1370
  file: pnpmWorkspace$7,
1934
1371
  keyPath: ["frozenLockfile"],
1935
1372
  message: "pnpm defaults frozenLockfile to true in CI — set it explicitly in pnpm-workspace.yaml to pin the policy outside CI too.",
@@ -1938,6 +1375,7 @@ const frozenLockfile = overrideBindings(requireConfigKey({
1938
1375
  yarn: {
1939
1376
  docs: "https://yarnpkg.com/configuration/yarnrc#enableImmutableInstalls",
1940
1377
  documentedDefault: true,
1378
+ defaultSafety: "conditional",
1941
1379
  file: yarnrc$4,
1942
1380
  keyPath: ["enableImmutableInstalls"],
1943
1381
  message: "Set `enableImmutableInstalls: true` in .yarnrc.yml to pin the policy outside CI too.",
@@ -1949,7 +1387,7 @@ const frozenLockfile = overrideBindings(requireConfigKey({
1949
1387
  }
1950
1388
  },
1951
1389
  description: "Refuse to mutate the lockfile on install so unexpected dependency changes fail loudly.",
1952
- docs: "https://github.com/bodadotsh/npm-security-best-practices#2-include-lockfiles",
1390
+ docs: "https://github.com/bodadotsh/npm-security-best-practices#3-include-lockfiles",
1953
1391
  id: "frozen-lockfile",
1954
1392
  severity: "warn",
1955
1393
  title: "Freeze the lockfile"
@@ -1968,7 +1406,7 @@ const frozenLockfile = overrideBindings(requireConfigKey({
1968
1406
  severity: "info"
1969
1407
  } });
1970
1408
  //#endregion
1971
- //#region src/domain/rules/frozen-store.ts
1409
+ //#region src/core/rules/frozen-store.ts
1972
1410
  const { pnpmWorkspace: pnpmWorkspace$6 } = CONFIG_FILES;
1973
1411
  const frozenStore = defineRule({
1974
1412
  bindings: { pnpm: {
@@ -1999,12 +1437,13 @@ const frozenStore = defineRule({
1999
1437
  title: "Consider a pre-populated read-only store"
2000
1438
  });
2001
1439
  //#endregion
2002
- //#region src/domain/rules/hardened-mode.ts
1440
+ //#region src/core/rules/hardened-mode.ts
2003
1441
  const { yarnrc: yarnrc$3 } = CONFIG_FILES;
2004
1442
  const hardenedMode = requireConfigKey({
2005
1443
  bindings: { yarn: {
2006
1444
  docs: "https://yarnpkg.com/configuration/yarnrc#enableHardenedMode",
2007
1445
  documentedDefault: true,
1446
+ defaultSafety: "conditional",
2008
1447
  file: yarnrc$3,
2009
1448
  keyPath: ["enableHardenedMode"],
2010
1449
  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.",
@@ -2021,19 +1460,9 @@ const hardenedMode = requireConfigKey({
2021
1460
  title: "Enable hardened mode (yarn)"
2022
1461
  });
2023
1462
  //#endregion
2024
- //#region src/domain/rules/minimum-release-age.ts
2025
- const HOURS_PER_DAY = 24;
2026
- const MINUTES_PER_HOUR = 60;
2027
- const SECONDS_PER_MINUTE = 60;
2028
- const MINUTES_PER_DAY = HOURS_PER_DAY * MINUTES_PER_HOUR;
2029
- const SECONDS_PER_DAY = MINUTES_PER_DAY * SECONDS_PER_MINUTE;
2030
- const RECOMMENDED_RELEASE_AGE_DAYS = 3;
2031
- const RECOMMENDED_RELEASE_AGE_MINUTES = RECOMMENDED_RELEASE_AGE_DAYS * MINUTES_PER_DAY;
2032
- const RECOMMENDED_RELEASE_AGE_SECONDS = RECOMMENDED_RELEASE_AGE_DAYS * SECONDS_PER_DAY;
2033
- const DOCUMENTED_DEFAULT_MINUTES = MINUTES_PER_DAY;
2034
- const { npmrc: npmrc$3, pnpmWorkspace: pnpmWorkspace$5, yarnrc: yarnrc$2, bunfig: bunfig$1, denoJson: denoJson$1, aubeWorkspace: aubeWorkspace$4 } = CONFIG_FILES;
1463
+ //#region src/core/rules/deno-release-age.ts
2035
1464
  const DENO_DURATION = /^\+?P(?:\+?\d+[Ww]|(?:\d+[Dd])*(?:T(?:\d+[HhMm]|\d+(?:\.\d+)?[Ss])+)?)$/u;
2036
- const DENO_MIN_TIMESTAMP = Date.parse("-262143-01-01T00:00:00Z");
1465
+ const DENO_MIN_TIMESTAMP = Date.UTC(-262143, 0, 1);
2037
1466
  const DENO_UNIT_SECONDS = {
2038
1467
  w: 604800,
2039
1468
  d: 86400,
@@ -2044,33 +1473,49 @@ const DENO_UNIT_SECONDS = {
2044
1473
  const DENO_DATE = /^\d{4}-\d{2}-\d{2}$/u;
2045
1474
  const DENO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}[Tt ](?:[01]\d|2[0-3]):[0-5]\d:(?:[0-5]\d|60)(?:\.\d+)?(?:[Zz]|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/u;
2046
1475
  const DENO_OFFSET_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d(?::(?:[0-5]\d|60))?[+-](?:[01]\d|2[0-3]):?[0-5]\d$/u;
2047
- const isPositiveNumber = (value) => typeof value === "number" && Number.isFinite(value) && value > 0;
2048
- const isPositiveYarnDuration = (value) => isPositiveNumber(value) || typeof value === "string" && /^\d*\.?\d+(?:ms|s|m|h|d|w)?$/u.test(value) && Number.isFinite(Number.parseFloat(value)) && Number.parseFloat(value) > 0;
2049
- const isPositiveDenoSeconds = (seconds) => seconds > 0 && Number.isFinite(seconds) && Date.now() - seconds * 1e3 >= DENO_MIN_TIMESTAMP;
2050
- const isActiveDenoAge = (value) => {
2051
- if (typeof value === "number") return Number.isSafeInteger(value) && isPositiveDenoSeconds(value * 60);
2052
- if (typeof value !== "string") return false;
2053
- if (/^\d+$/u.test(value)) return Number.isSafeInteger(Number(value)) && isPositiveDenoSeconds(Number(value) * 60);
2054
- if (DENO_DURATION.test(value)) {
2055
- let seconds = 0;
2056
- for (const [, integer, fraction, unit] of value.matchAll(/(\d+)(?:\.(\d+))?([WDHMS])/giu)) {
2057
- const amount = Number(integer) + Number(`0.${(fraction ?? "").slice(0, 9) || "0"}`);
2058
- seconds += amount * (DENO_UNIT_SECONDS[unit?.toLowerCase() ?? ""] ?? 0);
2059
- }
2060
- return isPositiveDenoSeconds(seconds);
1476
+ const DENO_DURATION_TOKEN = /(\d+)(?:\.(\d+))?([WDHMS])/giu;
1477
+ const LEAP_SECOND = /:60(?=\.|Z|z|[+-])/u;
1478
+ const isPositiveDenoSeconds = (seconds, now) => seconds > 0 && Number.isFinite(seconds) && now - seconds * 1e3 >= DENO_MIN_TIMESTAMP;
1479
+ const durationSeconds = (value) => {
1480
+ let seconds = 0;
1481
+ for (const [, integer, fraction, unit] of value.matchAll(DENO_DURATION_TOKEN)) {
1482
+ const amount = Number(integer) + Number(`0.${(fraction ?? "").slice(0, 9) || "0"}`);
1483
+ seconds += amount * (DENO_UNIT_SECONDS[unit?.toLowerCase() ?? ""] ?? 0);
2061
1484
  }
1485
+ return seconds;
1486
+ };
1487
+ /** Match Deno's minimumDependencyAge value grammar and active-cutoff semantics. */
1488
+ const isActiveDenoReleaseAge = (value, now, parse) => {
1489
+ if (typeof value === "number") return Number.isSafeInteger(value) && isPositiveDenoSeconds(value * 60, now);
1490
+ if (typeof value !== "string") return false;
1491
+ if (/^\d+$/u.test(value)) return Number.isSafeInteger(Number(value)) && isPositiveDenoSeconds(Number(value) * 60, now);
1492
+ if (DENO_DURATION.test(value)) return isPositiveDenoSeconds(durationSeconds(value), now);
2062
1493
  if (!DENO_DATE.test(value) && !DENO_TIMESTAMP.test(value) && !DENO_OFFSET_TIMESTAMP.test(value)) return false;
2063
1494
  const date = value.slice(0, 10);
2064
1495
  const midnight = /* @__PURE__ */ new Date(`${date}T00:00:00Z`);
2065
- const leapSecond = /:60(?=\.|Z|z|[+-])/u.test(value);
2066
- const timestamp = Date.parse(value.replace(/:60(?=\.|Z|z|[+-])/u, ":59")) + (leapSecond ? 1e3 : 0);
2067
- return !Number.isNaN(midnight.valueOf()) && midnight.toISOString().slice(0, 10) === date && timestamp < Date.now();
1496
+ const leapSecond = LEAP_SECOND.test(value);
1497
+ const timestamp = parse(value.replace(LEAP_SECOND, ":59")) + (leapSecond ? 1e3 : 0);
1498
+ return !Number.isNaN(midnight.valueOf()) && midnight.toISOString().slice(0, 10) === date && timestamp < now;
2068
1499
  };
2069
- const isNonDisabledDenoDuration = (value) => {
2070
- if (!isPlainRecord(value)) return isActiveDenoAge(value);
1500
+ //#endregion
1501
+ //#region src/core/rules/minimum-release-age.ts
1502
+ const HOURS_PER_DAY = 24;
1503
+ const MINUTES_PER_HOUR = 60;
1504
+ const SECONDS_PER_MINUTE = 60;
1505
+ const MINUTES_PER_DAY = HOURS_PER_DAY * MINUTES_PER_HOUR;
1506
+ const SECONDS_PER_DAY = MINUTES_PER_DAY * SECONDS_PER_MINUTE;
1507
+ const RECOMMENDED_RELEASE_AGE_DAYS = 3;
1508
+ const RECOMMENDED_RELEASE_AGE_MINUTES = RECOMMENDED_RELEASE_AGE_DAYS * MINUTES_PER_DAY;
1509
+ const RECOMMENDED_RELEASE_AGE_SECONDS = RECOMMENDED_RELEASE_AGE_DAYS * SECONDS_PER_DAY;
1510
+ const DOCUMENTED_DEFAULT_MINUTES = MINUTES_PER_DAY;
1511
+ const { npmrc: npmrc$3, pnpmWorkspace: pnpmWorkspace$5, yarnrc: yarnrc$2, bunfig: bunfig$1, denoJson: denoJson$1, aubeWorkspace: aubeWorkspace$4 } = CONFIG_FILES;
1512
+ const isPositiveNumber = (value) => typeof value === "number" && Number.isFinite(value) && value > 0;
1513
+ const isPositiveYarnDuration = (value) => isPositiveNumber(value) || typeof value === "string" && /^\d*\.?\d+(?:ms|s|m|h|d|w)?$/u.test(value) && Number.isFinite(Number.parseFloat(value)) && Number.parseFloat(value) > 0;
1514
+ const isNonDisabledDenoReleaseAge = (value, now, parse) => {
1515
+ if (!isPlainRecord(value)) return isActiveDenoReleaseAge(value, now, parse);
2071
1516
  if (Object.keys(value).some((key) => key !== "age" && key !== "exclude")) return false;
2072
1517
  if (value.exclude !== void 0 && !isStringList(value.exclude)) return false;
2073
- return value.age == null || isActiveDenoAge(value.age);
1518
+ return value.age != null && isActiveDenoReleaseAge(value.age, now, parse);
2074
1519
  };
2075
1520
  const denoAgeUsesFallback = (value) => {
2076
1521
  if (value === void 0 || value === null) return true;
@@ -2079,13 +1524,14 @@ const denoAgeUsesFallback = (value) => {
2079
1524
  if (value.exclude !== void 0 && !isStringList(value.exclude)) return false;
2080
1525
  return value.age == null;
2081
1526
  };
2082
- const isPositiveDenoNpmrcDays = (value) => typeof value === "number" && Number.isSafeInteger(value) && value > 0;
2083
- const minimumReleaseAge = overrideBindings(requireConfigKey({
1527
+ const isPositiveDenoNpmrcDays = (value, now, parse) => typeof value === "number" && Number.isSafeInteger(value) && isActiveDenoReleaseAge(value * MINUTES_PER_DAY, now, parse);
1528
+ const baseRule$2 = requireConfigKey({
2084
1529
  bindings: {
2085
1530
  aube: {
2086
1531
  accept: isPositiveNumber,
2087
1532
  docs: "https://aube.sh/settings/",
2088
1533
  documentedDefault: DOCUMENTED_DEFAULT_MINUTES,
1534
+ defaultSafety: "unconditional",
2089
1535
  file: aubeWorkspace$4,
2090
1536
  keyPath: ["minimumReleaseAge"],
2091
1537
  message: `Set minimumReleaseAge (~${RECOMMENDED_RELEASE_AGE_MINUTES} minutes for a 3-day cooldown) in aube-workspace.yaml.`,
@@ -2102,8 +1548,9 @@ const minimumReleaseAge = overrideBindings(requireConfigKey({
2102
1548
  },
2103
1549
  pnpm: {
2104
1550
  accept: isPositiveNumber,
2105
- docs: "https://pnpm.io/settings#minimumreleaseage",
1551
+ docs: "https://pnpm.io/settings/dependency-resolution#minimumreleaseage",
2106
1552
  documentedDefault: DOCUMENTED_DEFAULT_MINUTES,
1553
+ defaultSafety: "conditional",
2107
1554
  file: pnpmWorkspace$5,
2108
1555
  keyPath: ["minimumReleaseAge"],
2109
1556
  message: `Set minimumReleaseAge (~${RECOMMENDED_RELEASE_AGE_MINUTES} minutes for a 3-day cooldown) in pnpm-workspace.yaml.`,
@@ -2117,6 +1564,7 @@ const minimumReleaseAge = overrideBindings(requireConfigKey({
2117
1564
  accept: isPositiveYarnDuration,
2118
1565
  docs: "https://yarnpkg.com/configuration/yarnrc#npmMinimalAgeGate",
2119
1566
  documentedDefault: DOCUMENTED_DEFAULT_MINUTES,
1567
+ defaultSafety: "conditional",
2120
1568
  file: yarnrc$2,
2121
1569
  keyPath: ["npmMinimalAgeGate"],
2122
1570
  message: `Set npmMinimalAgeGate (~${RECOMMENDED_RELEASE_AGE_MINUTES} minutes for a 3-day cooldown) in .yarnrc.yml.`,
@@ -2128,108 +1576,112 @@ const minimumReleaseAge = overrideBindings(requireConfigKey({
2128
1576
  }
2129
1577
  },
2130
1578
  description: "Refuse to install releases newer than a cooldown window so freshly published (possibly compromised) versions are skipped.",
2131
- docs: "https://github.com/bodadotsh/npm-security-best-practices#set-minimal-release-age",
1579
+ docs: "https://github.com/bodadotsh/npm-security-best-practices#2-set-cooldowns--minimum-release-age",
2132
1580
  id: "minimum-release-age",
2133
1581
  severity: "warn",
2134
1582
  title: "Set a minimum release age"
2135
- }), {
2136
- npm: {
2137
- file: npmrc$3,
2138
- docs: "https://docs.npmjs.com/cli/v12/using-npm/config#min-release-age",
2139
- versionNote: { note: "min-release-age available since npm 11.10.0" },
2140
- check(ctx, config) {
2141
- const now = Date.now();
2142
- if (Object.hasOwn(config, "before")) {
2143
- const actual = config.before;
2144
- if ((typeof actual === "string" || typeof actual === "number") && Date.parse(String(actual)) < now) return { state: "ok" };
2145
- return {
2146
- state: "violation",
2147
- actual,
2148
- expected: "a date in the past",
2149
- message: "Use a valid past before cutoff, or remove before and set min-release-age.",
2150
- remediation: {
2151
- kind: "manual",
2152
- steps: ["In .npmrc, set before to a valid past date. A future or disabled before overrides min-release-age in this file.", ...guardRemediationAvailability("npm", ctx.pmVersion, {
2153
- kind: "manual",
2154
- steps: [`Alternatively, remove before and set min-release-age to ~${RECOMMENDED_RELEASE_AGE_DAYS} days.`]
2155
- }, [{
2156
- file: npmrc$3,
2157
- keyPath: ["min-release-age"]
2158
- }])?.steps ?? []]
2159
- }
2160
- };
2161
- }
2162
- const actual = getByPath(config, ["min-release-age"]);
2163
- const age = typeof actual === "number" || typeof actual === "string" ? Number(actual) : NaN;
2164
- const cutoff = (/* @__PURE__ */ new Date(now - SECONDS_PER_DAY * 1e3 * age)).valueOf();
2165
- if (age > 0 && Number.isFinite(cutoff) && cutoff < now) return { state: "ok" };
1583
+ });
1584
+ const createNpmBinding = (time) => ({
1585
+ file: npmrc$3,
1586
+ docs: "https://docs.npmjs.com/cli/v12/using-npm/config#min-release-age",
1587
+ versionNote: { note: "min-release-age available since npm 11.10.0" },
1588
+ check(ctx, config) {
1589
+ const now = time.now();
1590
+ if (Object.hasOwn(config, "before")) {
1591
+ const actual = config.before;
1592
+ if ((typeof actual === "string" || typeof actual === "number") && time.parse(String(actual)) < now) return { state: "ok" };
2166
1593
  return {
2167
1594
  state: "violation",
2168
1595
  actual,
2169
- expected: RECOMMENDED_RELEASE_AGE_DAYS,
2170
- message: `Set min-release-age to ~${RECOMMENDED_RELEASE_AGE_DAYS} days to quarantine brand-new releases.`,
2171
- remediation: guardRemediationAvailability("npm", ctx.pmVersion, proposeChanges(config, [{
2172
- file: npmrc$3,
2173
- op: "setKey",
2174
- keyPath: ["min-release-age"],
2175
- value: RECOMMENDED_RELEASE_AGE_DAYS
2176
- }]), [{
2177
- file: npmrc$3,
2178
- keyPath: ["min-release-age"]
2179
- }])
1596
+ expected: "a date in the past",
1597
+ message: "Use a valid past before cutoff, or remove before and set min-release-age.",
1598
+ remediation: {
1599
+ kind: "manual",
1600
+ steps: ["In .npmrc, set before to a valid past date. A future or disabled before overrides min-release-age in this file.", ...guardRemediationAvailability("npm", ctx.pmVersion, {
1601
+ kind: "manual",
1602
+ steps: [`Alternatively, remove before and set min-release-age to ~${RECOMMENDED_RELEASE_AGE_DAYS} days.`]
1603
+ }, [{
1604
+ file: npmrc$3,
1605
+ keyPath: ["min-release-age"]
1606
+ }])?.steps ?? []]
1607
+ }
2180
1608
  };
2181
1609
  }
1610
+ const actual = getByPath(config, ["min-release-age"]);
1611
+ const age = typeof actual === "number" || typeof actual === "string" ? Number(actual) : NaN;
1612
+ const cutoff = (/* @__PURE__ */ new Date(now - SECONDS_PER_DAY * 1e3 * age)).valueOf();
1613
+ if (age > 0 && Number.isFinite(cutoff) && cutoff < now) return { state: "ok" };
1614
+ return {
1615
+ state: "violation",
1616
+ actual,
1617
+ expected: RECOMMENDED_RELEASE_AGE_DAYS,
1618
+ message: `Set min-release-age to ~${RECOMMENDED_RELEASE_AGE_DAYS} days to quarantine brand-new releases.`,
1619
+ remediation: guardRemediationAvailability("npm", ctx.pmVersion, proposeChanges(config, [{
1620
+ file: npmrc$3,
1621
+ op: "setKey",
1622
+ keyPath: ["min-release-age"],
1623
+ value: RECOMMENDED_RELEASE_AGE_DAYS
1624
+ }]), [{
1625
+ file: npmrc$3,
1626
+ keyPath: ["min-release-age"]
1627
+ }])
1628
+ };
1629
+ }
1630
+ });
1631
+ const createDenoBinding = (time) => ({
1632
+ file: denoJson$1,
1633
+ docs: "https://docs.deno.com/runtime/reference/deno_json/",
1634
+ versionNote: {
1635
+ defaultSafeSince: "deno 2.9.0 (1440 minutes)",
1636
+ note: "object age may be omitted; project .npmrc fallback available since deno 2.8.1"
2182
1637
  },
2183
- deno: {
2184
- file: denoJson$1,
2185
- docs: "https://docs.deno.com/runtime/reference/deno_json/",
2186
- versionNote: {
2187
- defaultSafeSince: "deno 2.9.0 (1440 minutes)",
2188
- note: "object age may be omitted; project .npmrc fallback available since deno 2.8.1"
2189
- },
2190
- check(ctx, config) {
2191
- const actual = getByPath(config, ["minimumDependencyAge"]);
2192
- if (denoAgeUsesFallback(actual)) {
2193
- const npmrcConfig = ctx.readConfig(npmrc$3);
2194
- const npmrcAge = getByPath(npmrcConfig, ["min-release-age"]);
2195
- if (isPositiveDenoNpmrcDays(npmrcAge)) return { state: "ok" };
2196
- if (npmrcAge === 0) return {
2197
- state: "violation",
2198
- actual: npmrcAge,
2199
- expected: RECOMMENDED_RELEASE_AGE_DAYS,
2200
- file: npmrc$3.path,
2201
- message: `Set min-release-age to ~${RECOMMENDED_RELEASE_AGE_DAYS} days in .npmrc, or set minimumDependencyAge in deno.json.`,
2202
- remediation: proposeChanges(npmrcConfig, [{
2203
- file: npmrc$3,
2204
- op: "setKey",
2205
- keyPath: ["min-release-age"],
2206
- value: RECOMMENDED_RELEASE_AGE_DAYS
2207
- }])
2208
- };
2209
- }
2210
- if (isNonDisabledDenoDuration(actual)) return { state: "ok" };
2211
- const objectAge = isPlainRecord(actual);
2212
- const invalidObject = objectAge && (Object.keys(actual).some((key) => key !== "age" && key !== "exclude") || actual.exclude !== void 0 && !isStringList(actual.exclude));
2213
- return {
1638
+ check(ctx, config) {
1639
+ const actual = getByPath(config, ["minimumDependencyAge"]);
1640
+ const now = time.now();
1641
+ if (denoAgeUsesFallback(actual)) {
1642
+ const npmrcConfig = ctx.readConfig(npmrc$3);
1643
+ const npmrcAge = getByPath(npmrcConfig, ["min-release-age"]);
1644
+ if (isPositiveDenoNpmrcDays(npmrcAge, now, time.parse)) return { state: "ok" };
1645
+ if (npmrcAge === 0) return {
2214
1646
  state: "violation",
2215
- actual,
2216
- expected: "P3D",
2217
- message: `Set minimumDependencyAge (e.g. "P3D" for a ${RECOMMENDED_RELEASE_AGE_DAYS}-day cooldown) in deno.json.`,
2218
- remediation: invalidObject ? {
2219
- kind: "manual",
2220
- steps: ["Use only age and exclude in minimumDependencyAge. Make exclude a list of package names and set age to a positive supported duration."]
2221
- } : proposeChanges(config, [{
2222
- file: denoJson$1,
1647
+ actual: npmrcAge,
1648
+ expected: RECOMMENDED_RELEASE_AGE_DAYS,
1649
+ file: npmrc$3.path,
1650
+ message: `Set min-release-age to ~${RECOMMENDED_RELEASE_AGE_DAYS} days in .npmrc, or set minimumDependencyAge in deno.json.`,
1651
+ remediation: proposeChanges(npmrcConfig, [{
1652
+ file: npmrc$3,
2223
1653
  op: "setKey",
2224
- keyPath: objectAge ? ["minimumDependencyAge", "age"] : ["minimumDependencyAge"],
2225
- value: "P3D"
1654
+ keyPath: ["min-release-age"],
1655
+ value: RECOMMENDED_RELEASE_AGE_DAYS
2226
1656
  }])
2227
1657
  };
2228
1658
  }
1659
+ if (isNonDisabledDenoReleaseAge(actual, now, time.parse)) return { state: "ok" };
1660
+ const objectAge = isPlainRecord(actual);
1661
+ const invalidObject = objectAge && (Object.keys(actual).some((key) => key !== "age" && key !== "exclude") || actual.exclude !== void 0 && !isStringList(actual.exclude));
1662
+ return {
1663
+ state: "violation",
1664
+ actual,
1665
+ expected: "P3D",
1666
+ message: `Set minimumDependencyAge (e.g. "P3D" for a ${RECOMMENDED_RELEASE_AGE_DAYS}-day cooldown) in deno.json.`,
1667
+ remediation: invalidObject ? {
1668
+ kind: "manual",
1669
+ steps: ["Use only age and exclude in minimumDependencyAge. Make exclude a list of package names and set age to a positive supported duration."]
1670
+ } : proposeChanges(config, [{
1671
+ file: denoJson$1,
1672
+ op: "setKey",
1673
+ keyPath: objectAge ? ["minimumDependencyAge", "age"] : ["minimumDependencyAge"],
1674
+ value: "P3D"
1675
+ }])
1676
+ };
2229
1677
  }
2230
1678
  });
1679
+ const createMinimumReleaseAge = (time) => overrideBindings(baseRule$2, {
1680
+ npm: createNpmBinding(time),
1681
+ deno: createDenoBinding(time)
1682
+ });
2231
1683
  //#endregion
2232
- //#region src/domain/rules/named-registries.ts
1684
+ //#region src/core/rules/named-registries.ts
2233
1685
  const { pnpmWorkspace: pnpmWorkspace$4 } = CONFIG_FILES;
2234
1686
  const isNonEmptyObject$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length > 0;
2235
1687
  const namedRegistries = defineRule({
@@ -2247,17 +1699,17 @@ const namedRegistries = defineRule({
2247
1699
  state: "violation"
2248
1700
  };
2249
1701
  },
2250
- docs: "https://pnpm.io/settings#namedregistries",
1702
+ docs: "https://pnpm.io/settings/dependency-resolution#namedregistries",
2251
1703
  file: pnpmWorkspace$4
2252
1704
  } },
2253
1705
  description: "Flag named registry mappings that redirect package resolution to custom registries, which may enable dependency confusion attacks.",
2254
- docs: "https://pnpm.io/settings#namedregistries",
1706
+ docs: "https://pnpm.io/settings/dependency-resolution#namedregistries",
2255
1707
  id: "named-registries",
2256
1708
  severity: "info",
2257
1709
  title: "Review named registry mappings"
2258
1710
  });
2259
1711
  //#endregion
2260
- //#region src/domain/rules/paranoid-mode.ts
1712
+ //#region src/core/rules/paranoid-mode.ts
2261
1713
  const { aubeWorkspace: aubeWorkspace$3 } = CONFIG_FILES;
2262
1714
  const paranoidMode = requireConfigKey({
2263
1715
  bindings: { aube: {
@@ -2274,7 +1726,7 @@ const paranoidMode = requireConfigKey({
2274
1726
  title: "Enable paranoid security mode"
2275
1727
  });
2276
1728
  //#endregion
2277
- //#region src/domain/rules/patched-dependencies.ts
1729
+ //#region src/core/rules/patched-dependencies.ts
2278
1730
  const { pnpmWorkspace: pnpmWorkspace$3 } = CONFIG_FILES;
2279
1731
  const isNonEmptyObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length > 0;
2280
1732
  const patchedDependencies = defineRule({
@@ -2292,27 +1744,33 @@ const patchedDependencies = defineRule({
2292
1744
  state: "violation"
2293
1745
  };
2294
1746
  },
2295
- docs: "https://pnpm.io/settings#patcheddependencies",
1747
+ docs: "https://pnpm.io/cli/patch#patcheddependencies",
2296
1748
  file: pnpmWorkspace$3
2297
1749
  } },
2298
1750
  description: "Review local patches separately from the registry artifacts they modify.",
2299
- docs: "https://pnpm.io/settings#patcheddependencies",
1751
+ docs: "https://pnpm.io/cli/patch#patcheddependencies",
2300
1752
  id: "patched-dependencies",
2301
1753
  severity: "info",
2302
1754
  title: "Review patched dependencies"
2303
1755
  });
2304
1756
  //#endregion
2305
- //#region src/domain/rules/pin-exact-versions.ts
1757
+ //#region src/core/rules/pin-exact-versions.ts
2306
1758
  const { npmrc: npmrc$2, pnpmWorkspace: pnpmWorkspace$2, yarnrc: yarnrc$1, bunfig, denoJson } = CONFIG_FILES;
2307
1759
  const REGISTRY_VERSION = /^(?:npm|jsr):(?:@[^/@]+\/)?[^/@]+@(?<version>[^/]*)/u;
2308
1760
  const isUnpinnedRegistryImport = (specifier) => {
2309
1761
  if (!specifier.startsWith("npm:") && !specifier.startsWith("jsr:")) return false;
2310
1762
  const version = REGISTRY_VERSION.exec(specifier)?.groups?.version;
2311
- return version === void 0 || valid(version.replace(/^=/u, "")) === null;
1763
+ const normalized = version?.startsWith("=") ? version.slice(1) : version;
1764
+ return normalized === void 0 || valid(normalized) === null;
2312
1765
  };
2313
- const collectUnpinnedImports = (imports) => {
1766
+ const collectUnpinnedImports = (imports, location) => {
1767
+ if (!isPlainRecord(imports)) throw new ConfigError(`deno.json: ${location} must be an import mapping object.`);
2314
1768
  const offenders = [];
2315
- for (const [name, value] of Object.entries(imports)) if (typeof value === "string" && isUnpinnedRegistryImport(value)) offenders.push(`${name}=${value}`);
1769
+ for (const [name, value] of Object.entries(imports)) {
1770
+ if (value === null) continue;
1771
+ if (typeof value !== "string") throw new ConfigError(`deno.json: ${location}.${name} must be a string or null.`);
1772
+ if (isUnpinnedRegistryImport(value)) offenders.push(`${location}.${name}=${value}`);
1773
+ }
2316
1774
  return offenders;
2317
1775
  };
2318
1776
  const MAX_SAMPLE_COUNT = 3;
@@ -2323,23 +1781,26 @@ const formatOffenders = (offenders) => {
2323
1781
  return {
2324
1782
  remediation: {
2325
1783
  kind: "manual",
2326
- steps: ["Run `deno add --save-exact <pkg>` for each unpinned registry import in deno.json, or rewrite the `imports` entries to use exact versions."]
1784
+ 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."]
2327
1785
  },
2328
1786
  message: `${offenders.length} deno imports are not pinned: ${sample}${more}. Use \`deno add --save-exact\` or pin manually.`,
2329
1787
  state: "violation"
2330
1788
  };
2331
1789
  };
2332
1790
  const OK = { state: "ok" };
2333
- const extractImportsRecord = (config) => {
1791
+ const unpinnedInlineImports = (config) => {
2334
1792
  const imports = getByPath(config, ["imports"]);
2335
- if (!isPlainRecord(imports)) return;
2336
- return imports;
1793
+ const offenders = imports == null ? [] : collectUnpinnedImports(imports, "imports");
1794
+ const scopes = getByPath(config, ["scopes"]);
1795
+ if (scopes != null) {
1796
+ if (!isPlainRecord(scopes)) throw new ConfigError("deno.json: scopes must be an object.");
1797
+ for (const [scope, mapping] of Object.entries(scopes)) offenders.push(...collectUnpinnedImports(mapping, `scopes[${JSON.stringify(scope)}]`));
1798
+ }
1799
+ return offenders;
2337
1800
  };
2338
1801
  const denoBinding = {
2339
1802
  check(_ctx, config) {
2340
- const imports = extractImportsRecord(config);
2341
- if (!imports) return OK;
2342
- const offenders = collectUnpinnedImports(imports);
1803
+ const offenders = unpinnedInlineImports(config);
2343
1804
  if (offenders.length === 0) return OK;
2344
1805
  return formatOffenders(offenders);
2345
1806
  },
@@ -2370,7 +1831,7 @@ const npmBinding = {
2370
1831
  };
2371
1832
  const aubeBinding = {
2372
1833
  file: npmrc$2,
2373
- docs: "https://aube.jdx.dev/settings/#saveprefix",
1834
+ docs: "https://aube.sh/settings/#setting-saveprefix",
2374
1835
  check(_ctx, config) {
2375
1836
  const prefixes = ["save-prefix", "savePrefix"].filter((key) => Object.hasOwn(config, key)).map((key) => config[key]);
2376
1837
  if (prefixes.length > 0 && prefixes.every((value) => value === "")) return OK;
@@ -2411,8 +1872,8 @@ const pinExactVersions = overrideBindings(requireConfigKey({
2411
1872
  versionNote: { configAvailableSince: "yarn 2.0.0" }
2412
1873
  }
2413
1874
  },
2414
- description: "Semver ranges (^, ~) auto-adopt new releases, including compromised ones. Save exact versions by default.",
2415
- docs: "https://github.com/bodadotsh/npm-security-best-practices#1-pin-dependency-versions",
1875
+ 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).",
1876
+ docs: "https://github.com/bodadotsh/npm-security-best-practices#4-pin-dependency-versions",
2416
1877
  id: "pin-exact-versions",
2417
1878
  severity: "error",
2418
1879
  title: "Pin exact dependency versions"
@@ -2422,7 +1883,7 @@ const pinExactVersions = overrideBindings(requireConfigKey({
2422
1883
  npm: npmBinding
2423
1884
  });
2424
1885
  //#endregion
2425
- //#region src/domain/rules/provenance.ts
1886
+ //#region src/core/rules/provenance.ts
2426
1887
  const { npmrc: npmrc$1, yarnrc } = CONFIG_FILES;
2427
1888
  const npmrcProvenance = {
2428
1889
  file: npmrc$1,
@@ -2430,7 +1891,7 @@ const npmrcProvenance = {
2430
1891
  message: "Set `provenance=true` (and publish from CI) to attest releases.",
2431
1892
  value: true
2432
1893
  };
2433
- const provenance = requireConfigKey({
1894
+ const baseRule = requireConfigKey({
2434
1895
  applies: isPublishable,
2435
1896
  bindings: {
2436
1897
  bun: {
@@ -2456,14 +1917,37 @@ const provenance = requireConfigKey({
2456
1917
  }
2457
1918
  },
2458
1919
  description: "Provenance statements (via Sigstore) bind a published artifact to its recorded source and build.",
2459
- docs: "https://github.com/bodadotsh/npm-security-best-practices#10-generate-provenance-statements",
1920
+ docs: "https://github.com/bodadotsh/npm-security-best-practices#11-generate-provenance-statements",
2460
1921
  id: "provenance",
2461
1922
  projectTypes: ["package"],
2462
1923
  severity: "warn",
2463
1924
  title: "Publish with provenance"
2464
1925
  });
1926
+ const provenance = overrideBindings(baseRule, { npm: {
1927
+ ...baseRule.bindings.npm,
1928
+ check(ctx, config) {
1929
+ if (!isPublishable(ctx)) return { state: "na" };
1930
+ const publishConfig = ctx.packageJson?.publishConfig;
1931
+ if (!publishConfig || !Object.hasOwn(publishConfig, "provenance")) return baseRule.bindings.npm.check(ctx, config);
1932
+ if (publishConfig.provenance === true) return { state: "ok" };
1933
+ const file = CONFIG_FILES.packageJson;
1934
+ return {
1935
+ state: "violation",
1936
+ file: file.path,
1937
+ actual: publishConfig.provenance,
1938
+ expected: true,
1939
+ message: "Set publishConfig.provenance=true in package.json (and publish from CI) to attest releases.",
1940
+ remediation: guardRemediationAvailability("npm", ctx.pmVersion, proposeChanges(ctx.readConfig(file), [{
1941
+ file,
1942
+ op: "setKey",
1943
+ keyPath: ["publishConfig", "provenance"],
1944
+ value: true
1945
+ }]))
1946
+ };
1947
+ }
1948
+ } });
2465
1949
  //#endregion
2466
- //#region src/domain/rules/publish-access.ts
1950
+ //#region src/core/rules/publish-access.ts
2467
1951
  const { packageJson } = CONFIG_FILES;
2468
1952
  const publishAccessBinding = {
2469
1953
  check(ctx) {
@@ -2506,7 +1990,7 @@ const publishAccess = defineRule({
2506
1990
  title: "Declare publish access explicitly"
2507
1991
  });
2508
1992
  //#endregion
2509
- //#region src/domain/rules/store-server.ts
1993
+ //#region src/core/rules/store-server.ts
2510
1994
  const { pnpmWorkspace: pnpmWorkspace$1 } = CONFIG_FILES;
2511
1995
  const storeServer = defineRule({
2512
1996
  bindings: { pnpm: {
@@ -2523,17 +2007,17 @@ const storeServer = defineRule({
2523
2007
  state: "violation"
2524
2008
  };
2525
2009
  },
2526
- docs: "https://pnpm.io/settings#userunningStoreserver",
2010
+ docs: "https://pnpm.io/settings/store#userunningstoreserver",
2527
2011
  file: pnpmWorkspace$1
2528
2012
  } },
2529
2013
  description: "Flag use of an external store server process, which introduces a trust boundary where tampered packages could be served.",
2530
- docs: "https://pnpm.io/settings#userunningStoreserver",
2014
+ docs: "https://pnpm.io/settings/store#userunningstoreserver",
2531
2015
  id: "store-server",
2532
2016
  severity: "info",
2533
2017
  title: "Review store server usage"
2534
2018
  });
2535
2019
  //#endregion
2536
- //#region src/domain/rules/strict-allow-scripts.ts
2020
+ //#region src/core/rules/strict-allow-scripts.ts
2537
2021
  const { npmrc } = CONFIG_FILES;
2538
2022
  const strictAllowScripts = defineRule({
2539
2023
  bindings: { npm: {
@@ -2575,7 +2059,7 @@ const strictAllowScripts = defineRule({
2575
2059
  title: "Require explicit script allow-listing"
2576
2060
  });
2577
2061
  //#endregion
2578
- //#region src/domain/rules/strict-release-age.ts
2062
+ //#region src/core/rules/strict-release-age.ts
2579
2063
  const { aubeWorkspace: aubeWorkspace$2 } = CONFIG_FILES;
2580
2064
  const strictReleaseAge = withAubeParanoid(requireConfigKey({
2581
2065
  bindings: { aube: {
@@ -2592,7 +2076,7 @@ const strictReleaseAge = withAubeParanoid(requireConfigKey({
2592
2076
  title: "Enforce strict release age gate"
2593
2077
  }));
2594
2078
  //#endregion
2595
- //#region src/domain/rules/strict-store-integrity.ts
2079
+ //#region src/core/rules/strict-store-integrity.ts
2596
2080
  const { aubeWorkspace: aubeWorkspace$1 } = CONFIG_FILES;
2597
2081
  const strictStoreIntegrity = defineRule({
2598
2082
  bindings: { aube: {
@@ -2632,20 +2116,21 @@ const strictStoreIntegrity = defineRule({
2632
2116
  title: "Require tarball integrity metadata"
2633
2117
  });
2634
2118
  //#endregion
2635
- //#region src/domain/rules/trust-policy.ts
2119
+ //#region src/core/rules/trust-policy.ts
2636
2120
  const { aubeWorkspace, pnpmWorkspace } = CONFIG_FILES;
2637
2121
  const trustPolicy = withAubeParanoid(requireConfigKey({
2638
2122
  bindings: {
2639
2123
  aube: {
2640
2124
  docs: "https://aube.jdx.dev/security.html",
2641
2125
  documentedDefault: "no-downgrade",
2126
+ defaultSafety: "unconditional",
2642
2127
  file: aubeWorkspace,
2643
2128
  keyPath: ["trustPolicy"],
2644
2129
  message: "Set `trustPolicy: no-downgrade` in aube-workspace.yaml to block packages whose trust level has decreased.",
2645
2130
  value: "no-downgrade"
2646
2131
  },
2647
2132
  pnpm: {
2648
- docs: "https://pnpm.io/settings#trustpolicy",
2133
+ docs: "https://pnpm.io/settings/dependency-resolution#trustpolicy",
2649
2134
  file: pnpmWorkspace,
2650
2135
  keyPath: ["trustPolicy"],
2651
2136
  message: "Set `trustPolicy: no-downgrade` in pnpm-workspace.yaml to block packages whose trust level has decreased.",
@@ -2654,15 +2139,15 @@ const trustPolicy = withAubeParanoid(requireConfigKey({
2654
2139
  }
2655
2140
  },
2656
2141
  description: "Fail installation when a package trust level has decreased compared to previous releases, catching publisher credential downgrades.",
2657
- docs: "https://pnpm.io/settings#trustpolicy",
2142
+ docs: "https://pnpm.io/settings/dependency-resolution#trustpolicy",
2658
2143
  id: "trust-policy",
2659
2144
  severity: "warn",
2660
2145
  title: "Enforce trust policy on dependency updates"
2661
2146
  }));
2662
2147
  //#endregion
2663
- //#region src/domain/rules/unsupported-settings.ts
2664
- const bindingFor = (pm) => {
2665
- const settings = settingAvailability.filter((setting) => setting.pm === pm);
2148
+ //#region src/core/rules/unsupported-settings.ts
2149
+ const bindingFor = (pm, includesFile) => {
2150
+ const settings = settingAvailability.filter((setting) => setting.pm === pm && includesFile(setting.file.path));
2666
2151
  return { check(ctx) {
2667
2152
  const version = ctx.pmVersion;
2668
2153
  if (version === void 0) return { state: "na" };
@@ -2685,9 +2170,24 @@ const bindingFor = (pm) => {
2685
2170
  } : { state: "ok" };
2686
2171
  } };
2687
2172
  };
2173
+ const createUnsupportedSettings = (includesFile) => defineRule({
2174
+ id: "unsupported-settings",
2175
+ title: "Use settings available in the target PM version",
2176
+ 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.",
2177
+ severity: "error",
2178
+ docs: "https://github.com/pHo9UBenaA/siro/blob/main/docs/rules.md#unsupported-settings--error",
2179
+ bindings: {
2180
+ npm: bindingFor("npm", includesFile),
2181
+ pnpm: bindingFor("pnpm", includesFile),
2182
+ yarn: bindingFor("yarn", includesFile),
2183
+ bun: bindingFor("bun", includesFile),
2184
+ deno: bindingFor("deno", includesFile)
2185
+ }
2186
+ });
2187
+ const unsupportedSettings = createUnsupportedSettings(() => true);
2688
2188
  //#endregion
2689
- //#region src/domain/builtin-rules.ts
2690
- const rules = [
2189
+ //#region src/core/rules/builtin-rules.ts
2190
+ const createBuiltinRules = (time) => [
2691
2191
  advisoryCheck,
2692
2192
  approvedGitRepos,
2693
2193
  auditSuppression,
@@ -2703,7 +2203,7 @@ const rules = [
2703
2203
  frozenLockfile,
2704
2204
  frozenStore,
2705
2205
  hardenedMode,
2706
- minimumReleaseAge,
2206
+ createMinimumReleaseAge(time),
2707
2207
  namedRegistries,
2708
2208
  paranoidMode,
2709
2209
  patchedDependencies,
@@ -2715,23 +2215,59 @@ const rules = [
2715
2215
  strictReleaseAge,
2716
2216
  strictStoreIntegrity,
2717
2217
  trustPolicy,
2718
- defineRule({
2719
- id: "unsupported-settings",
2720
- title: "Use settings available in the target PM version",
2721
- 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.",
2722
- severity: "error",
2723
- docs: "https://github.com/pHo9UBenaA/siro/blob/main/docs/rules.md#unsupported-settings--error",
2724
- bindings: {
2725
- npm: bindingFor("npm"),
2726
- pnpm: bindingFor("pnpm"),
2727
- yarn: bindingFor("yarn"),
2728
- bun: bindingFor("bun"),
2729
- deno: bindingFor("deno")
2730
- }
2731
- })
2218
+ unsupportedSettings
2732
2219
  ];
2220
+ const builtinScope = {
2221
+ "advisory-check": "installation",
2222
+ "approved-git-repos": "installation",
2223
+ "audit-suppression": "installation",
2224
+ "block-auto-install": "installation",
2225
+ "block-exotic-subdeps": "installation",
2226
+ "bun-security-scanner": "installation",
2227
+ "checksum-verification": "installation",
2228
+ "commit-lockfile": "installation",
2229
+ "dependency-overrides": "installation",
2230
+ "disable-lifecycle-scripts": "installation",
2231
+ "enforce-strict-ssl": "installation",
2232
+ "files-field": "manifest",
2233
+ "frozen-lockfile": "installation",
2234
+ "frozen-store": "installation",
2235
+ "hardened-mode": "installation",
2236
+ "minimum-release-age": "installation",
2237
+ "named-registries": "installation",
2238
+ "paranoid-mode": "installation",
2239
+ "patched-dependencies": "installation",
2240
+ "pin-exact-versions": "installation",
2241
+ provenance: "installation",
2242
+ "publish-access": "manifest",
2243
+ "store-server": "installation",
2244
+ "strict-allow-scripts": "installation",
2245
+ "strict-release-age": "installation",
2246
+ "strict-store-integrity": "installation",
2247
+ "trust-policy": "installation",
2248
+ "unsupported-settings": "split"
2249
+ };
2250
+ const scopeOf = (id) => Object.hasOwn(builtinScope, id) ? builtinScope[id] : "custom";
2251
+ //#endregion
2252
+ //#region src/adapters/exclusions.ts
2253
+ const compileExclusions = (patterns) => {
2254
+ try {
2255
+ const matchers = patterns.map((pattern) => new Minimatch(pattern.replace(/(?:\/\*\*)+$/u, "").replace(/[\\[\]]/gu, "\\$&"), {
2256
+ dot: true,
2257
+ nocase: false,
2258
+ nonegate: true,
2259
+ nocomment: true,
2260
+ nobrace: true,
2261
+ noext: true,
2262
+ platform: "linux"
2263
+ }));
2264
+ return (directory) => matchers.some((matcher) => matcher.match(directory));
2265
+ } catch (error) {
2266
+ throw new ConfigError(`exclude: ${error instanceof Error ? error.message : String(error)}`);
2267
+ }
2268
+ };
2733
2269
  //#endregion
2734
- //#region src/domain/services/apply-config.ts
2270
+ //#region src/core/apply-config.ts
2735
2271
  /** Select active rules and keep user severity choices as explicit runtime data. */
2736
2272
  const applyConfig = (base, config) => {
2737
2273
  const merged = [...base, ...config?.customRules ?? []];
@@ -2759,7 +2295,7 @@ const applyConfig = (base, config) => {
2759
2295
  };
2760
2296
  };
2761
2297
  //#endregion
2762
- //#region src/domain/services/detect-pms.ts
2298
+ //#region src/core/detect-pms.ts
2763
2299
  const registerSignals = (signals, pm, owner) => {
2764
2300
  for (const file of signals) {
2765
2301
  const prior = owner.get(file);
@@ -2791,24 +2327,25 @@ const detectPMs = (ctx) => {
2791
2327
  return PMS.filter((pm) => found.has(pm));
2792
2328
  };
2793
2329
  //#endregion
2794
- //#region src/domain/services/resolve-pms.ts
2330
+ //#region src/core/resolve-pms.ts
2795
2331
  /** Apply the explicit selection or detection, then the configured allow-list. */
2796
2332
  const resolvePMs = (ctx, opts) => {
2797
2333
  const { pmOverride, allowed } = opts;
2798
2334
  const detected = pmOverride ? [pmOverride] : detectPMs(ctx);
2799
2335
  const pms = allowed ? detected.filter((pm) => allowed.includes(pm)) : detected;
2800
2336
  if (pms.length > 0) return pms;
2337
+ if (opts.optional && detected.length === 0) return [];
2801
2338
  if (!allowed) throw new UsageError(`No package manager detected. Pass --pm <${PMS.join("|")}> to be explicit.`);
2802
2339
  if (pmOverride) throw new UsageError(`--pm ${pmOverride} is not in siro.config.ts pms (${allowed.join(", ")}). Adjust the config or the flag.`);
2803
2340
  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.`);
2804
2341
  throw new UsageError(`No package manager detected, and siro.config.ts restricts pms to ${allowed.join(", ")}. Adjust the config or remove the restriction.`);
2805
2342
  };
2806
2343
  //#endregion
2807
- //#region src/domain/services/decide-severity.ts
2344
+ //#region src/core/decide-severity.ts
2808
2345
  /** Resolve the most specific severity signal for one finding. */
2809
2346
  const decideSeverity = (status, binding, rule, userOverride) => userOverride ?? status.severity ?? binding.severity ?? rule.severity;
2810
2347
  //#endregion
2811
- //#region src/domain/services/render-version-note.ts
2348
+ //#region src/core/render-version-note.ts
2812
2349
  const buildVersionNoteParts = (versionNote) => {
2813
2350
  const parts = [];
2814
2351
  if (typeof versionNote.configAvailableSince !== "undefined") parts.push(`available since ${versionNote.configAvailableSince}`);
@@ -2823,151 +2360,704 @@ const renderVersionNoteMessage = (message, versionNote) => {
2823
2360
  return `${message} (${parts.join("; ")})`;
2824
2361
  };
2825
2362
  //#endregion
2826
- //#region src/application/run-lint.ts
2363
+ //#region src/core/evaluate-binding.ts
2364
+ /** One response boundary for generic and PM-specific checks. Selection stays with callers. */
2365
+ const evaluateBinding = (repository, rule, binding, overrides, target) => {
2366
+ const response = binding.check({
2367
+ ...repository.ctx,
2368
+ readConfig: repository.parseConfig,
2369
+ pmVersion: target?.version
2370
+ }, repository.parseConfig(binding.file));
2371
+ assertSynchronous(response, `Rule '${rule.id}' check`);
2372
+ if (!isCheckStatusShape(response)) throw new ConfigError(`Rule '${rule.id}' returned an invalid check result.`);
2373
+ return (response.state === "violations" ? response.violations : [response]).flatMap((status) => status.state !== "violation" ? [] : [{
2374
+ ruleId: rule.id,
2375
+ directory: ".",
2376
+ ...target ? { pm: target.pm } : {},
2377
+ severity: decideSeverity(status, binding, rule, overrides?.get(rule.id)),
2378
+ message: renderVersionNoteMessage(status.message, binding.versionNote),
2379
+ file: status.file ?? binding.file?.path,
2380
+ docs: binding.docs ?? rule.docs,
2381
+ actual: status.actual,
2382
+ expected: status.expected,
2383
+ remediation: target ? guardRemediationAvailability(target.pm, target.version, status.remediation) : status.remediation
2384
+ }]);
2385
+ };
2386
+ //#endregion
2387
+ //#region src/core/run-lint.ts
2827
2388
  const resolveBindingProjectType = (ctx, pm, parseConfig) => {
2828
2389
  if (ctx.projectType !== void 0) return ctx.projectType;
2829
- if (pm === "deno") return resolveDenoProjectType(ctx, parseConfig(CONFIG_FILES.denoJson));
2830
- return resolvePackageJsonProjectType(ctx);
2390
+ return pm === "deno" ? resolveDenoProjectType(ctx, parseConfig(CONFIG_FILES.denoJson)) : resolvePackageJsonProjectType(ctx);
2831
2391
  };
2832
- /** Evaluate every applicable rule binding and collect violations. */
2392
+ /** Select applicable PM bindings; response handling is shared with manifest checks. */
2833
2393
  const runLint = (opts) => {
2834
- const { ctx, pms, ruleSet, severityOverrides, codecFor } = opts;
2394
+ const { repository, pms, ruleSet, severityOverrides } = opts;
2835
2395
  const findings = [];
2396
+ for (const rule of ruleSet) for (const pm of pms) {
2397
+ const binding = rule.bindings[pm];
2398
+ if (!binding || rule.projectTypes && !rule.projectTypes.includes(resolveBindingProjectType(repository.ctx, pm, repository.parseConfig))) continue;
2399
+ findings.push(...evaluateBinding(repository, rule, binding, severityOverrides, {
2400
+ pm,
2401
+ version: opts.pmVersions?.[pm]
2402
+ }));
2403
+ }
2836
2404
  const summary = {
2837
2405
  error: 0,
2838
2406
  info: 0,
2839
2407
  warn: 0
2840
2408
  };
2841
- const parseConfig = createConfigParser(codecFor, ctx);
2842
- for (const rule of ruleSet) for (const pm of pms) {
2843
- const binding = rule.bindings[pm];
2844
- if (!binding || rule.projectTypes && !rule.projectTypes.includes(resolveBindingProjectType(ctx, pm, parseConfig))) continue;
2845
- const ruleContext = {
2846
- ...ctx,
2847
- readConfig: parseConfig,
2848
- pmVersion: opts.pmVersions?.[pm]
2849
- };
2850
- const response = binding.check(ruleContext, parseConfig(binding.file));
2851
- if (!isCheckStatusShape(response)) throw new ConfigError(`Rule '${rule.id}' returned an invalid check result.`);
2852
- const statuses = response.state === "violations" ? response.violations : [response];
2853
- for (const status of statuses) {
2854
- if (status.state !== "violation") continue;
2855
- const finding = {
2856
- ruleId: rule.id,
2857
- pm,
2858
- severity: decideSeverity(status, binding, rule, severityOverrides?.get(rule.id)),
2859
- message: renderVersionNoteMessage(status.message, binding.versionNote),
2860
- file: status.file ?? binding.file?.path,
2861
- docs: binding.docs ?? rule.docs,
2862
- actual: status.actual,
2863
- expected: status.expected,
2864
- remediation: guardRemediationAvailability(pm, ruleContext.pmVersion, status.remediation)
2865
- };
2866
- findings.push(finding);
2867
- summary[finding.severity] += 1;
2868
- }
2869
- }
2409
+ for (const finding of findings) summary[finding.severity] += 1;
2870
2410
  return {
2871
2411
  findings,
2872
2412
  summary
2873
2413
  };
2874
2414
  };
2875
2415
  //#endregion
2876
- //#region src/application/lint.ts
2877
- /** Evaluate a repository without reporting or executing configuration files. */
2878
- const prepareLint = (options) => {
2879
- if (!options || !isAbsPath(options.cwd)) throw new UsageError("cwd must be an absolute filesystem path.");
2416
+ //#region src/core/contracts/deno-json.ts
2417
+ /** Validate consumed publication metadata, not Deno's entire versioned schema. */
2418
+ const validateDenoMetadata = (config) => {
2419
+ const name = getByPath(config, ["name"]);
2420
+ if (name != null && typeof name !== "string") throw new ConfigError("deno.json: name must be a string or null.");
2421
+ const publish = getByPath(config, ["publish"]);
2422
+ if (publish == null || typeof publish === "boolean") return;
2423
+ if (!isPlainRecord(publish)) throw new ConfigError("deno.json: publish must be a boolean, object or null.");
2424
+ const include = getByPath(publish, ["include"]);
2425
+ 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.");
2426
+ };
2427
+ //#endregion
2428
+ //#region src/core/parse-config-file.ts
2429
+ const EMPTY_FILE = Object.freeze({});
2430
+ /** Create a lazy parser for one repository context; callers own its lifetime. */
2431
+ const createConfigParser = (codecFor, ctx) => {
2432
+ const cache = /* @__PURE__ */ new Map();
2433
+ return (file) => {
2434
+ if (file === void 0) return EMPTY_FILE;
2435
+ const cacheKey = `${file.kind}:${file.path}`;
2436
+ const cached = cache.get(cacheKey);
2437
+ if (cached !== void 0) return cached;
2438
+ const text = ctx.readText(file.path);
2439
+ if (text === void 0) {
2440
+ cache.set(cacheKey, EMPTY_FILE);
2441
+ return EMPTY_FILE;
2442
+ }
2443
+ const parsed = wrapCodecError(file.path, () => codecFor(file.kind).parse(text));
2444
+ cache.set(cacheKey, parsed);
2445
+ return parsed;
2446
+ };
2447
+ };
2448
+ /** Pair the context with a fresh lazy parser; construction performs no reads. */
2449
+ const createRepositoryEvaluation = (ctx, codecFor) => ({
2450
+ ctx,
2451
+ parseConfig: createConfigParser(codecFor, ctx)
2452
+ });
2453
+ //#endregion
2454
+ //#region src/core/discovery.ts
2455
+ /** One context per selected directory, shared by publication and installation passes. */
2456
+ const discover = (cwd, fs, excluded, projectType, dependencies) => {
2457
+ if (typeof fs.readDirectories !== "function") throw new ConfigError("FileSystem.readDirectories is required for package discovery; no native filesystem fallback is used.");
2458
+ const { paths, createRepoContext, codecFor } = dependencies;
2459
+ const pending = [asRelPath(".")];
2460
+ const directories = [];
2461
+ while (pending.length) {
2462
+ const directory = pending.pop();
2463
+ const absolute = paths.resolve(cwd, directory);
2464
+ let repository;
2465
+ const manifests = [];
2466
+ try {
2467
+ repository = createRepositoryEvaluation(createRepoContext(absolute, fs, projectType), codecFor);
2468
+ const { ctx, parseConfig } = repository;
2469
+ if (ctx.packageJson !== void 0) manifests.push("package.json");
2470
+ if (ctx.readText(CONFIG_FILES.denoJson.path) !== void 0) {
2471
+ validateDenoMetadata(parseConfig(CONFIG_FILES.denoJson));
2472
+ manifests.push("deno.json");
2473
+ } else if (ctx.exists(asRelPath("deno.jsonc"))) throw new ConfigError("deno.jsonc is not supported; use strict deno.json.");
2474
+ } catch (error) {
2475
+ if (error instanceof ConfigError && directory !== ".") throw new ConfigError(`${directory}/${error.message}`);
2476
+ throw error;
2477
+ }
2478
+ directories.push({
2479
+ directory,
2480
+ repository,
2481
+ manifests
2482
+ });
2483
+ const names = fs.readDirectories(absolute);
2484
+ if (!Array.isArray(names)) throw new ConfigError(`${directory}: FileSystem.readDirectories must return a dense array.`);
2485
+ const children = /* @__PURE__ */ new Set();
2486
+ for (let i = 0; i < names.length; i += 1) {
2487
+ if (!Object.hasOwn(names, i)) throw new ConfigError(`${directory}: FileSystem.readDirectories must return a dense array.`);
2488
+ const child = paths.child(directory, names[i]);
2489
+ if (names[i] === ".git" || names[i] === "node_modules" || excluded(child) || children.has(child)) continue;
2490
+ children.add(child);
2491
+ pending.push(child);
2492
+ }
2493
+ }
2494
+ return directories.sort((a, b) => a.directory < b.directory ? -1 : a.directory > b.directory ? 1 : 0);
2495
+ };
2496
+ //#endregion
2497
+ //#region src/core/manifest-checks.ts
2498
+ const manifestProjectType = (repository, file) => file === "deno.json" ? resolveDenoProjectType(repository.ctx, repository.parseConfig(CONFIG_FILES.denoJson)) : resolvePackageJsonProjectType(repository.ctx);
2499
+ const selectBinding = {
2500
+ "files-field": (file) => file === "deno.json" ? denoPublishBinding : packageJsonFilesBinding,
2501
+ "publish-access": (file, repository, targets) => {
2502
+ if (file !== "package.json") return void 0;
2503
+ return repository.ctx.packageJson?.publishConfig?.access === "private" && targets.length > 0 ? "pm" : publishAccessBinding;
2504
+ }
2505
+ };
2506
+ const checkManifest = (repository, file, targets, rules, overrides) => {
2507
+ const findings = [];
2508
+ const projectType = manifestProjectType(repository, file);
2509
+ const pms = targets.map((target) => target.pm);
2510
+ const pmVersions = Object.fromEntries(targets.map((target) => [target.pm, target.version]));
2511
+ for (const rule of rules) {
2512
+ if (rule.projectTypes && !rule.projectTypes.includes(projectType)) continue;
2513
+ if (rule.id === "unsupported-settings") findings.push(...runLint({
2514
+ repository,
2515
+ pms,
2516
+ pmVersions,
2517
+ ruleSet: [{
2518
+ ...rule,
2519
+ bindings: createUnsupportedSettings((path) => path === file).bindings
2520
+ }],
2521
+ severityOverrides: overrides
2522
+ }).findings);
2523
+ else if (Object.hasOwn(selectBinding, rule.id)) {
2524
+ const binding = selectBinding[rule.id](file, repository, targets);
2525
+ if (binding === "pm") findings.push(...runLint({
2526
+ repository,
2527
+ pms,
2528
+ pmVersions,
2529
+ ruleSet: [rule],
2530
+ severityOverrides: overrides
2531
+ }).findings);
2532
+ else if (binding) findings.push(...evaluateBinding(repository, rule, binding, overrides));
2533
+ }
2534
+ }
2535
+ return findings;
2536
+ };
2537
+ //#endregion
2538
+ //#region src/core/rebase-finding.ts
2539
+ /** Context-local rule output becomes cwd-relative once, without mutating rule-owned data. */
2540
+ const rebaseFinding = (directory, finding) => {
2541
+ if (directory === ".") return {
2542
+ ...finding,
2543
+ directory
2544
+ };
2545
+ const remediation = finding.remediation;
2546
+ const rebaseOperation = (operation) => ({
2547
+ ...operation,
2548
+ file: {
2549
+ ...operation.file,
2550
+ path: `${directory}/${operation.file.path}`
2551
+ }
2552
+ });
2553
+ return {
2554
+ ...finding,
2555
+ directory,
2556
+ ...finding.file === void 0 ? {} : { file: `${directory}/${finding.file}` },
2557
+ remediation: remediation?.kind === "automatic" ? {
2558
+ ...remediation,
2559
+ operations: [rebaseOperation(remediation.operations[0]), ...remediation.operations.slice(1).map(rebaseOperation)]
2560
+ } : remediation?.kind === "manual" ? {
2561
+ ...remediation,
2562
+ steps: [`Work in ${directory} for this finding.`, ...remediation.steps]
2563
+ } : remediation
2564
+ };
2565
+ };
2566
+ //#endregion
2567
+ //#region src/core/lint.ts
2568
+ const validateLintOptions = (options, paths) => {
2569
+ if (!options || !paths.isAbsolute(options.cwd)) throw new UsageError("cwd must be an absolute filesystem path.");
2570
+ 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.");
2880
2571
  if ("customRules" in options || "reporters" in options) throw new UsageError("Pass customRules and reporters inside the config option.");
2881
2572
  if (options.pm !== void 0 && !isPM(options.pm)) throw new UsageError(`Unknown package manager: ${String(options.pm)}`);
2882
2573
  if (options.projectType !== void 0 && !isProjectType(options.projectType)) throw new UsageError(`Unknown project type: ${String(options.projectType)}`);
2883
2574
  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.");
2884
- if (options.workspaces !== void 0 && typeof options.workspaces !== "boolean") throw new UsageError("workspaces must be a boolean.");
2575
+ };
2576
+ /** Validate inputs and prepare shared contexts, without running rules or child executable configs. */
2577
+ const prepareLint = (options, dependencies) => {
2578
+ validateLintOptions(options, dependencies.paths);
2885
2579
  const config = options.config === void 0 ? void 0 : parseConfig(options.config);
2886
- const ctx = createRepoContext(options.cwd, options.fs, options.projectType ?? config?.projectType);
2887
- const pms = resolvePMs(ctx, {
2888
- allowed: config?.pms,
2889
- pmOverride: options.pm
2890
- });
2891
- 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.");
2892
- const configured = applyConfig(rules, config);
2893
- const pmVersions = {
2894
- ...declaredPMVersion(ctx.packageJson?.packageManager),
2895
- ...config?.pmVersions,
2896
- ...options.pm && options.pmVersion ? { [options.pm]: options.pmVersion } : {}
2897
- };
2898
- const fs = options.fs ?? nodeFileSystem;
2580
+ const fs = options.fs === void 0 ? dependencies.fileSystem : options.fs;
2581
+ const excluded = dependencies.compileExclusions(parseExcludes(options.exclude !== void 0 ? options.exclude : config?.exclude ?? []));
2582
+ const installations = parseInstallationRoots(options.installationRoots !== void 0 ? options.installationRoots : config?.installationRoots ?? ["."]);
2583
+ const configured = applyConfig(dependencies.rules, config);
2584
+ const needsCustomTarget = configured.rules.some((rule) => scopeOf(rule.id) === "custom");
2585
+ const discovered = discover(options.cwd, fs, excluded, options.projectType ?? config?.projectType, dependencies);
2586
+ 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).`);
2899
2587
  return {
2900
- ctx,
2901
- pms,
2902
- pmVersions,
2903
- members: options.workspaces ? collectWorkspaceMembers(ctx, fs, pms, options.projectType ?? config?.projectType) : [],
2904
- ruleSet: configured.rules,
2905
- severityOverrides: configured.severityOverrides,
2906
- codecFor,
2588
+ evaluation: {
2589
+ directories: discovered.map((item) => {
2590
+ const { ctx } = item.repository;
2591
+ const entry = installations.find((root) => root.path === item.directory);
2592
+ let pms;
2593
+ let versions = declaredPMVersion(ctx.packageJson?.packageManager);
2594
+ if (item.directory === ".") {
2595
+ pms = resolvePMs(ctx, {
2596
+ allowed: config?.pms,
2597
+ pmOverride: options.pm,
2598
+ optional: !entry && !needsCustomTarget
2599
+ });
2600
+ versions = {
2601
+ ...versions,
2602
+ ...config?.pmVersions,
2603
+ ...options.pm && options.pmVersion ? { [options.pm]: options.pmVersion } : {}
2604
+ };
2605
+ } else if (entry) {
2606
+ try {
2607
+ pms = resolvePMs(ctx, { pmOverride: entry.pm });
2608
+ } catch (error) {
2609
+ if (error instanceof UsageError) throw new UsageError(`${item.directory}: ${error.message}`);
2610
+ throw error;
2611
+ }
2612
+ if (entry.pm && entry.pmVersion) versions = {
2613
+ ...versions,
2614
+ [entry.pm]: entry.pmVersion
2615
+ };
2616
+ } else {
2617
+ const declared = ctx.packageJson?.packageManager === void 0 ? void 0 : parsePackageManagerField(ctx.packageJson.packageManager);
2618
+ pms = PMS.filter((pm) => pm === declared || pm === "deno" && item.manifests.includes("deno.json"));
2619
+ }
2620
+ return {
2621
+ ...item,
2622
+ installation: entry !== void 0,
2623
+ targets: pms.map((pm) => ({
2624
+ pm,
2625
+ ...versions[pm] === void 0 ? {} : { version: versions[pm] }
2626
+ }))
2627
+ };
2628
+ }),
2629
+ ruleSet: configured.rules,
2630
+ severityOverrides: configured.severityOverrides
2631
+ },
2907
2632
  reporters: config?.reporters ?? []
2908
2633
  };
2909
2634
  };
2910
- /** Reuse the existing manifest checks; installation policy remains rooted at cwd. */
2911
- const runPreparedLint = (prepared) => {
2912
- const result = runLint(prepared);
2913
- const findings = [...result.findings];
2914
- const summary = { ...result.summary };
2915
- const memberRules = prepared.ruleSet.filter((rule) => [
2916
- "files-field",
2917
- "publish-access",
2918
- "unsupported-settings"
2919
- ].includes(rule.id));
2920
- for (const member of prepared.members) {
2921
- const child = runLint({
2922
- ...prepared,
2923
- ctx: member.ctx,
2924
- pms: [member.pm],
2925
- ruleSet: memberRules
2635
+ const runPreparedLint = (evaluation) => {
2636
+ const { directories, ruleSet, severityOverrides } = evaluation;
2637
+ const findings = [];
2638
+ const manifests = [];
2639
+ const installationRoots = [];
2640
+ const ruleOrder = new Map(ruleSet.map((rule, index) => [rule.id, index]));
2641
+ for (const item of directories) {
2642
+ const { directory, repository, targets, installation } = item;
2643
+ const local = [];
2644
+ for (const file of item.manifests) {
2645
+ const manifestTargets = targets.filter(({ pm }) => file === "deno.json" ? pm === "deno" : pm !== "deno");
2646
+ manifests.push({
2647
+ path: directory === "." ? file : `${directory}/${file}`,
2648
+ projectType: manifestProjectType(repository, file),
2649
+ targets: manifestTargets
2650
+ });
2651
+ local.push(...checkManifest(repository, file, manifestTargets, ruleSet, severityOverrides));
2652
+ }
2653
+ if (installation) installationRoots.push({
2654
+ directory,
2655
+ targets
2926
2656
  });
2927
- for (const finding of child.findings) findings.push({
2928
- ...finding,
2929
- file: `${member.directory}/${finding.file ?? "package.json"}`,
2930
- message: `${member.directory}: ${finding.message}`,
2931
- remediation: finding.remediation?.kind === "manual" ? {
2932
- kind: "manual",
2933
- steps: [`Work in ${member.directory} for this finding.`, ...finding.remediation.steps]
2934
- } : finding.remediation
2657
+ const scopedRules = ruleSet.flatMap((rule) => {
2658
+ const scope = scopeOf(rule.id);
2659
+ if (scope === "installation" && installation || scope === "custom" && directory === ".") return [rule];
2660
+ if (scope === "split" && installation) return [{
2661
+ ...rule,
2662
+ bindings: createUnsupportedSettings((file) => file !== "package.json" && file !== "deno.json").bindings
2663
+ }];
2664
+ return [];
2935
2665
  });
2936
- for (const level of [
2937
- "error",
2938
- "warn",
2939
- "info"
2940
- ]) summary[level] += child.summary[level];
2666
+ try {
2667
+ local.push(...runLint({
2668
+ repository,
2669
+ pms: targets.map(({ pm }) => pm),
2670
+ pmVersions: Object.fromEntries(targets.map(({ pm, version }) => [pm, version])),
2671
+ ruleSet: scopedRules,
2672
+ severityOverrides
2673
+ }).findings);
2674
+ } catch (error) {
2675
+ if (error instanceof ConfigError && directory !== ".") throw new ConfigError(`${directory}/${error.message}`);
2676
+ throw error;
2677
+ }
2678
+ local.sort((a, b) => (ruleOrder.get(a.ruleId) ?? 0) - (ruleOrder.get(b.ruleId) ?? 0));
2679
+ findings.push(...local.map((finding) => rebaseFinding(directory, finding)));
2680
+ }
2681
+ const summary = {
2682
+ error: 0,
2683
+ warn: 0,
2684
+ info: 0
2685
+ };
2686
+ for (const finding of findings) summary[finding.severity] += 1;
2687
+ return {
2688
+ findings,
2689
+ summary,
2690
+ inspection: {
2691
+ manifests,
2692
+ installationRoots
2693
+ }
2694
+ };
2695
+ };
2696
+ const lint$1 = (options, dependencies) => runPreparedLint(prepareLint(options, dependencies).evaluation);
2697
+ //#endregion
2698
+ //#region src/core/filter.ts
2699
+ const EXIT_SUCCESS = 0;
2700
+ const EXIT_FAILURE = 1;
2701
+ const meetsThreshold = (severity, threshold) => SEVERITY_RANK[severity] >= SEVERITY_RANK[threshold];
2702
+ /** Keep only findings at or above `threshold`, recomputing the summary. */
2703
+ const filterBySeverity = (result, threshold) => {
2704
+ const findings = [];
2705
+ const summary = {
2706
+ error: 0,
2707
+ info: 0,
2708
+ warn: 0
2709
+ };
2710
+ for (const finding of result.findings) if (meetsThreshold(finding.severity, threshold)) {
2711
+ findings.push(finding);
2712
+ summary[finding.severity] += 1;
2941
2713
  }
2942
2714
  return {
2715
+ ...result,
2943
2716
  findings,
2944
2717
  summary
2945
2718
  };
2946
2719
  };
2947
- const lint = (options) => runPreparedLint(prepareLint(options));
2720
+ /**
2721
+ * Exit code for a lint run. Non-zero when any finding meets `threshold`
2722
+ * (default: only `error` fails the run).
2723
+ */
2724
+ const exitCodeForLint = (result, threshold = "error") => {
2725
+ if (result.findings.some((finding) => meetsThreshold(finding.severity, threshold))) return EXIT_FAILURE;
2726
+ return EXIT_SUCCESS;
2727
+ };
2948
2728
  //#endregion
2949
- //#region src/application/commands/lint.ts
2729
+ //#region src/core/lint-command.ts
2950
2730
  /** Evaluate and report. Executable config loading belongs to the CLI adapter. */
2951
- const lintCommand = async (options, io) => {
2731
+ const lintCommand$1 = async (options, io, dependencies, reporters) => {
2952
2732
  if (!options) throw new UsageError("Lint options are required.");
2953
2733
  if (options.severity !== void 0 && !isSeverity(options.severity)) throw new UsageError(`Invalid severity: ${String(options.severity)}`);
2954
- const prepared = prepareLint(options);
2955
- const registry = createRegistry(prepared.reporters);
2956
- const selection = options.reporter ?? DEFAULT_REPORTER_NAME;
2734
+ const prepared = prepareLint(options, dependencies);
2735
+ const registry = reporters.createRegistry(prepared.reporters);
2736
+ const selection = options.reporter ?? reporters.defaultName;
2957
2737
  const reporter = typeof selection === "string" ? registry.get(selection) : selection;
2958
2738
  if (!isReporterShape(reporter)) throw new UsageError(`${typeof selection === "string" ? "Unknown" : "Invalid"} reporter: ${String(selection)} (available: ${[...registry.keys()].join(", ")})`);
2959
- const result = runPreparedLint(prepared);
2739
+ const result = runPreparedLint(prepared.evaluation);
2960
2740
  const exitCode = exitCodeForLint(result, options.severity ?? "error");
2961
- await reporter.format(filterBySeverity(result, options.severity ?? "info"), io);
2741
+ const writes = [];
2742
+ let outputFailed = false;
2743
+ let outputFailure;
2744
+ const recordFailure = (error) => {
2745
+ if (!outputFailed) {
2746
+ outputFailed = true;
2747
+ outputFailure = error;
2748
+ }
2749
+ };
2750
+ const track = (write) => (line) => {
2751
+ try {
2752
+ const written = write(line);
2753
+ writes.push(Promise.resolve(written).then(() => {}, recordFailure));
2754
+ return written;
2755
+ } catch (error) {
2756
+ recordFailure(error);
2757
+ throw error;
2758
+ }
2759
+ };
2760
+ try {
2761
+ await reporter.format(filterBySeverity(result, options.severity ?? "info"), {
2762
+ stdout: track((line) => io.stdout(line)),
2763
+ stderr: track((line) => io.stderr(line))
2764
+ }, { cwd: options.cwd });
2765
+ } finally {
2766
+ await Promise.all(writes);
2767
+ }
2768
+ if (outputFailed) throw outputFailure;
2962
2769
  return exitCode;
2963
2770
  };
2964
2771
  //#endregion
2772
+ //#region src/core/contracts/package-json.ts
2773
+ const PackageJsonSchema = vb.looseObject({
2774
+ files: vb.optional(vb.array(vb.string())),
2775
+ name: vb.optional(vb.string()),
2776
+ packageManager: vb.optional(vb.string()),
2777
+ private: vb.optional(vb.boolean()),
2778
+ publishConfig: vb.optional(vb.looseObject({
2779
+ access: vb.optional(vb.picklist([
2780
+ "public",
2781
+ "restricted",
2782
+ "private"
2783
+ ])),
2784
+ provenance: vb.optional(vb.boolean())
2785
+ })),
2786
+ trustedDependencies: vb.optional(vb.array(vb.string()))
2787
+ });
2788
+ const parsePackageJson = (value) => {
2789
+ if (!isPlainRecord(value)) throw new ConfigError("package.json: expected an object at the root.");
2790
+ const result = vb.safeParse(PackageJsonSchema, value);
2791
+ if (result.success) return result.output;
2792
+ const issue = result.issues[0];
2793
+ const field = vb.getDotPath(issue);
2794
+ throw new ConfigError(`package.json: ${field ? `${field}: ` : ""}${issue.message}`);
2795
+ };
2796
+ //#endregion
2797
+ //#region src/adapters/repo-context.ts
2798
+ const tryParseJson = (text) => {
2799
+ try {
2800
+ return JSON.parse(text);
2801
+ } catch (error) {
2802
+ let errMsg = String(error);
2803
+ if (error instanceof Error) errMsg = error.message;
2804
+ throw new ConfigError(`package.json: invalid JSON — ${errMsg}`);
2805
+ }
2806
+ };
2807
+ const readPackageJson = (raw) => {
2808
+ return parsePackageJson(tryParseJson(raw.trim()));
2809
+ };
2810
+ const createRepoContext = (root, fs = nodeFileSystem, projectType) => {
2811
+ if (fs === nodeFileSystem) assertDirectory(root);
2812
+ const packageFile = resolveIn(root, asRelPath("package.json"));
2813
+ const raw = fs.readText(packageFile);
2814
+ const texts = new Map([[packageFile, raw]]);
2815
+ const readText = (relPath) => {
2816
+ const file = resolveIn(root, relPath);
2817
+ if (!texts.has(file)) texts.set(file, fs.readText(file));
2818
+ return texts.get(file);
2819
+ };
2820
+ const exists = (relPath) => fs.exists(resolveIn(root, relPath));
2821
+ let packageJson = void 0;
2822
+ if (typeof raw !== "undefined") packageJson = readPackageJson(raw);
2823
+ return {
2824
+ exists,
2825
+ packageJson,
2826
+ projectType,
2827
+ readText,
2828
+ root
2829
+ };
2830
+ };
2831
+ //#endregion
2832
+ //#region src/adapters/codecs/ini.ts
2833
+ const INTEGER_PATTERN = /^-?\d+$/u;
2834
+ const coerceBoolean = (str) => {
2835
+ if (str === "true") return true;
2836
+ if (str === "false") return false;
2837
+ };
2838
+ const coerceInteger = (str) => {
2839
+ if (INTEGER_PATTERN.test(str)) return Number(str);
2840
+ };
2841
+ const coerce = (raw) => {
2842
+ if (typeof raw !== "string") return raw;
2843
+ return coerceBoolean(raw) ?? coerceInteger(raw) ?? raw;
2844
+ };
2845
+ //#endregion
2846
+ //#region src/adapters/codecs/store.ts
2847
+ const CODECS = {
2848
+ json: { parse(text) {
2849
+ const trimmed = text.trim();
2850
+ return toParsedConfig(JSON.parse(trimmed));
2851
+ } },
2852
+ npmrc: { parse(text) {
2853
+ const raw = toParsedConfig(ini.parse(text));
2854
+ const config = {};
2855
+ for (const [key, value] of Object.entries(raw)) if (Array.isArray(value)) config[key] = value.map((elem) => coerce(elem));
2856
+ else if (typeof value === "object" && value !== null) config[key] = toParsedConfig(value);
2857
+ else config[key] = coerce(value);
2858
+ return config;
2859
+ } },
2860
+ toml: { parse(text) {
2861
+ if (text.trim() === "") return {};
2862
+ return toParsedConfig(parse$1(text.startsWith("") ? text.slice(1) : text));
2863
+ } },
2864
+ yaml: { parse(text) {
2865
+ if (text.trim() === "") return {};
2866
+ const document = parseDocument(text);
2867
+ if (document.errors.length > 0) throw document.errors[0];
2868
+ if (document.contents === null) return {};
2869
+ return toParsedConfig(document.toJS({ onAnchor: (value, count) => {
2870
+ if (count > 1) JSON.stringify(value);
2871
+ } }));
2872
+ } }
2873
+ };
2874
+ /** Look up the codec for a parseable kind. Total — every CodecKind has one. */
2875
+ const codecFor = (kind) => CODECS[kind];
2876
+ //#endregion
2877
+ //#region src/adapters/reporters/github.ts
2878
+ const COMMAND = {
2879
+ error: "error",
2880
+ info: "notice",
2881
+ warn: "warning"
2882
+ };
2883
+ const escapeData = (raw) => raw.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A");
2884
+ const escapeProp = (raw) => escapeData(raw).replaceAll(":", "%3A").replaceAll(",", "%2C");
2885
+ /** Emit GitHub Actions workflow commands (annotations on PRs). */
2886
+ const githubReporter = {
2887
+ async format(result, io, context) {
2888
+ for (const finding of result.findings) {
2889
+ let file = "";
2890
+ if (finding.file) file = `file=${escapeProp(path.resolve(context.cwd, finding.file))},`;
2891
+ let body = `[${finding.pm ?? "package"}] ${finding.directory}: ${finding.message}`;
2892
+ if (finding.docs) body += ` (${finding.docs})`;
2893
+ await io.stdout(`::${COMMAND[finding.severity]} ${file}title=${escapeProp(finding.ruleId)}::${escapeData(body)}`);
2894
+ }
2895
+ },
2896
+ name: "github"
2897
+ };
2898
+ //#endregion
2899
+ //#region src/adapters/safe-text.ts
2900
+ /** Visible escaping for untrusted human-facing text; never rewrite API/native paths. */
2901
+ 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:");
2902
+ /** JSON syntax and decoded values stay identical; CI must not parse legacy commands in it. */
2903
+ const safeJsonText = (json) => json.replace(/[\u007f-\u009f\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/gu, (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`).replaceAll("##[", "\\u0023\\u0023[");
2904
+ //#endregion
2905
+ //#region src/adapters/reporters/json.ts
2906
+ /**
2907
+ * Versioned machine-readable output — the public contract consumed by
2908
+ * external fixers (see docs/json-output.md). Bump `schemaVersion` on any
2909
+ * breaking shape change and update that doc in the same commit.
2910
+ */
2911
+ const SCHEMA_VERSION = 3;
2912
+ const jsonReporter = {
2913
+ async format(result, io) {
2914
+ await io.stdout(safeJsonText(JSON.stringify({
2915
+ schemaVersion: SCHEMA_VERSION,
2916
+ siroVersion: version,
2917
+ findings: result.findings,
2918
+ summary: result.summary,
2919
+ inspection: result.inspection
2920
+ }, void 0, 2)));
2921
+ },
2922
+ name: "json"
2923
+ };
2924
+ //#endregion
2925
+ //#region src/adapters/reporters/pretty.ts
2926
+ const GLYPH = {
2927
+ error: "✖ error",
2928
+ info: "ℹ info",
2929
+ warn: "⚠ warn"
2930
+ };
2931
+ /**
2932
+ * Decide colour at call time, not at import time.
2933
+ *
2934
+ * picocolors snapshots `isColorSupported` when imported, so a later
2935
+ * `process.env.NO_COLOR = '1'` (or a different stdout) is ignored. The
2936
+ * reporter is invoked per `siro lint` run from many contexts (TTY, CI
2937
+ * with FORCE_COLOR, redirected output, …) — checking each call is the
2938
+ * only way NO_COLOR can truly win, as https://no-color.org/ requires.
2939
+ */
2940
+ const colorSupportedNow = () => {
2941
+ const { env } = process;
2942
+ if (typeof env.NO_COLOR !== "undefined" && env.NO_COLOR !== "") return false;
2943
+ if ("FORCE_COLOR" in env) return env.FORCE_COLOR !== "0" && env.FORCE_COLOR !== "";
2944
+ return pc.isColorSupported;
2945
+ };
2946
+ const renderFinding = (finding, ctx) => {
2947
+ const where = ctx.colors.dim(` (${safeText(finding.file ?? finding.directory)})`);
2948
+ ctx.io.stdout(`${ctx.tag[finding.severity](GLYPH[finding.severity])} [${finding.pm ?? "package"}] ${ctx.colors.bold(safeText(finding.ruleId))}${where}`);
2949
+ ctx.io.stdout(` ${safeText(finding.message)}`);
2950
+ for (const step of finding.remediation?.kind === "manual" ? finding.remediation.steps : []) ctx.io.stdout(` ↳ ${safeText(step)}`);
2951
+ if (finding.docs) ctx.io.stdout(ctx.colors.dim(` → ${safeText(finding.docs)}`));
2952
+ };
2953
+ const buildRenderCtx = (io) => {
2954
+ const colors = pc.createColors(colorSupportedNow());
2955
+ return {
2956
+ colors,
2957
+ io,
2958
+ tag: {
2959
+ error: colors.red,
2960
+ info: colors.cyan,
2961
+ warn: colors.yellow
2962
+ }
2963
+ };
2964
+ };
2965
+ const prettyReporter = {
2966
+ async format(result, io) {
2967
+ const lines = [];
2968
+ const collect = (line) => {
2969
+ lines.push(line);
2970
+ };
2971
+ const ctx = buildRenderCtx({
2972
+ stdout: collect,
2973
+ stderr: collect
2974
+ });
2975
+ collect(`Inspection: ${result.inspection.manifests.length} manifests; installation roots: ${result.inspection.installationRoots.map((root) => safeText(root.directory)).join(", ") || "none"}.`);
2976
+ collect("Unknown PM/version targets have no availability assessment; installation scope is explicit.");
2977
+ if (result.findings.length === 0) collect(ctx.colors.green("✔ No security best-practice issues found."));
2978
+ else {
2979
+ for (const finding of result.findings) renderFinding(finding, ctx);
2980
+ const { error, warn, info } = result.summary;
2981
+ collect("");
2982
+ collect(`Summary: ${error} error, ${warn} warn, ${info} info`);
2983
+ }
2984
+ await io.stdout(lines.join("\n"));
2985
+ },
2986
+ name: "pretty"
2987
+ };
2988
+ //#endregion
2989
+ //#region src/adapters/reporters/registry.ts
2990
+ const BUILTINS = [
2991
+ prettyReporter,
2992
+ jsonReporter,
2993
+ githubReporter
2994
+ ];
2995
+ const DEFAULT_REPORTER_NAME = prettyReporter.name;
2996
+ const JSON_REPORTER_NAME = jsonReporter.name;
2997
+ /** Names of every built-in reporter, in stable display order. */
2998
+ const BUILTIN_REPORTER_NAMES = BUILTINS.map((rep) => rep.name);
2999
+ /** Later registrations replace earlier reporters with the same name. */
3000
+ const createRegistry = (extras = []) => new Map([...BUILTINS, ...extras].map((reporter) => [reporter.name, reporter]));
3001
+ const dependencies = {
3002
+ rules: createBuiltinRules({
3003
+ now: () => Date.now(),
3004
+ parse: (value) => Date.parse(value)
3005
+ }),
3006
+ fileSystem: nodeFileSystem,
3007
+ paths: nodePaths,
3008
+ createRepoContext,
3009
+ codecFor,
3010
+ compileExclusions
3011
+ };
3012
+ /** Public Node API: callers can replace the filesystem without assembling the application. */
3013
+ const lint = (options) => lint$1(options, dependencies);
3014
+ const lintCommand = (options, io) => lintCommand$1(options, io, dependencies, {
3015
+ defaultName: DEFAULT_REPORTER_NAME,
3016
+ createRegistry
3017
+ });
3018
+ //#endregion
2965
3019
  //#region src/adapters/node-io.ts
2966
- const nodeIO = {
2967
- stderr: (line) => process.stderr.write(`${line}\n`),
2968
- stdout: (line) => process.stdout.write(`${line}\n`)
3020
+ const writer = (stream) => {
3021
+ let failure;
3022
+ const pending = /* @__PURE__ */ new Set();
3023
+ const fail = (cause) => {
3024
+ failure ??= new Error(`Output failed: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
3025
+ for (const reject of pending) reject(failure);
3026
+ pending.clear();
3027
+ return failure;
3028
+ };
3029
+ let observing = false;
3030
+ return (line) => {
3031
+ if (!observing) {
3032
+ observing = true;
3033
+ stream.on("error", fail);
3034
+ stream.on("close", () => fail(/* @__PURE__ */ new Error("Output stream closed.")));
3035
+ }
3036
+ return new Promise((resolve, reject) => {
3037
+ if (failure || stream.destroyed) {
3038
+ reject(failure ?? fail(/* @__PURE__ */ new Error("Output stream is destroyed.")));
3039
+ return;
3040
+ }
3041
+ pending.add(reject);
3042
+ try {
3043
+ stream.write(`${line}\n`, (error) => {
3044
+ pending.delete(reject);
3045
+ if (error) reject(fail(error));
3046
+ else resolve();
3047
+ });
3048
+ } catch (error) {
3049
+ fail(error);
3050
+ }
3051
+ });
3052
+ };
2969
3053
  };
3054
+ /** Node-only construction; keep streams and their errors outside the core. */
3055
+ const createNodeIO = (stdout, stderr) => ({
3056
+ stdout: writer(stdout),
3057
+ stderr: writer(stderr)
3058
+ });
3059
+ const nodeIO = createNodeIO(process.stdout, process.stderr);
2970
3060
  //#endregion
2971
- export { ConfigError as A, SUPPORTED_NODE_RANGE as C, isNodeError as D, nodeFileSystem as E, UsageError as M, asAbsPath as O, isSeverity as S, assertDirectory as T, PROJECT_TYPES as _, requireConfigKey as a, SEVERITIES as b, DEFAULT_REPORTER_NAME as c, jsonReporter as d, version as f, getByPath as g, defineRule as h, overrideBindings as i, SiroError as j, asRelPath as k, JSON_REPORTER_NAME as l, loadConfig as m, lintCommand as n, CONFIG_FILES as o, githubReporter as p, lint as r, BUILTIN_REPORTER_NAMES as s, nodeIO as t, prettyReporter as u, isProjectType as v, isSupportedNodeVersion as w, isPM as x, PMS as y };
3061
+ export { asRelPath as A, isSupportedNodeVersion as C, nodeFileSystem as D, assertDirectory as E, SiroError as M, UsageError as N, isNodeError as O, isSeverity as S, version as T, PROJECT_TYPES as _, DEFAULT_REPORTER_NAME as a, SEVERITIES as b, jsonReporter as c, overrideBindings as d, requireConfigKey as f, getByPath as g, defineRule as h, BUILTIN_REPORTER_NAMES as i, ConfigError as j, asAbsPath as k, safeText as l, loadConfig as m, lint as n, JSON_REPORTER_NAME as o, CONFIG_FILES as p, lintCommand as r, prettyReporter as s, nodeIO as t, githubReporter as u, isProjectType as v, SUPPORTED_NODE_RANGE as w, isPM as x, PMS as y };
2972
3062
 
2973
- //# sourceMappingURL=node-io-CaqobxYp.mjs.map
3063
+ //# sourceMappingURL=node-io-CAszdmko.mjs.map