@pho9ubenaa/siro 0.1.3 → 0.2.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.
@@ -0,0 +1,2509 @@
1
+ import pc from "picocolors";
2
+ import ini from "ini";
3
+ import { parse } from "smol-toml";
4
+ import { parseDocument } from "yaml";
5
+ import * as vb from "valibot";
6
+ import { accessSync, constants, readFileSync } from "node:fs";
7
+ import path from "node:path";
8
+ import { pathToFileURL } from "node:url";
9
+
10
+ //#region src/shared/paths.ts
11
+ /** Tag a string as absolute. The caller vouches for the shape. */
12
+ const asAbsPath = (path) => path;
13
+ /** Tag a string as repo-root-relative. The caller vouches for the shape. */
14
+ const asRelPath = (path) => path;
15
+
16
+ //#endregion
17
+ //#region src/shared/siro-error.ts
18
+ /**
19
+ * Structured errors used across siro. The CLI maps them to exit codes in one
20
+ * place so individual commands never have to think about exit semantics.
21
+ * The exit-code table lives in docs/configuration.md S"Exit codes" and
22
+ * src/cli/help.ts (HELP_LINT) -- not restated here.
23
+ *
24
+ * One mapping note that belongs at this altitude: filesystem errno errors
25
+ * are routed to 2 by SHAPE (numeric `errno`) in cli run(), so an
26
+ * errno-bearing throw from a user extension also lands on 2 rather than the
27
+ * 70 crash path -- same remedy (fix the environment) either way.
28
+ */
29
+ var SiroError = class extends Error {
30
+ /** Recommended process exit code for this error. */
31
+ exitCode;
32
+ constructor(message, exitCode) {
33
+ super(message);
34
+ this.name = "SiroError";
35
+ this.exitCode = exitCode;
36
+ }
37
+ };
38
+
39
+ //#endregion
40
+ //#region src/shared/config-error.ts
41
+ const CONFIG_EXIT_CODE = 2;
42
+ var ConfigError = class extends SiroError {
43
+ constructor(message) {
44
+ super(message, CONFIG_EXIT_CODE);
45
+ this.name = "ConfigError";
46
+ }
47
+ };
48
+
49
+ //#endregion
50
+ //#region src/shared/usage-error.ts
51
+ const USAGE_EXIT_CODE = 2;
52
+ var UsageError = class extends SiroError {
53
+ constructor(message) {
54
+ super(message, USAGE_EXIT_CODE);
55
+ this.name = "UsageError";
56
+ }
57
+ };
58
+
59
+ //#endregion
60
+ //#region src/shared/errors.ts
61
+ /**
62
+ * Run `fn` and wrap any non-`ConfigError` failure as a `ConfigError` prefixed
63
+ * with `filePath`. Codec `parse` calls hit raw libraries that
64
+ * throw bare `Error`s; without this wrap the CLI would surface those as
65
+ * exit-1 internal errors and break CI that branches on exit codes. A
66
+ * `ConfigError` that bubbles up from a nested call is re-thrown unchanged so
67
+ * the original `path: message` framing is preserved.
68
+ */
69
+ const wrapCodecError = (filePath, fn) => {
70
+ try {
71
+ return fn();
72
+ } catch (error) {
73
+ if (error instanceof ConfigError) throw error;
74
+ let msg = String(error);
75
+ if (error instanceof Error) msg = error.message;
76
+ throw new ConfigError(`${filePath}: ${msg}`);
77
+ }
78
+ };
79
+
80
+ //#endregion
81
+ //#region src/domain/entities/pms.ts
82
+ /**
83
+ * Single source of truth for package managers and severities.
84
+ * Types, runtime sets, and ordering are all derived from these tuples — add a
85
+ * package manager here and the rest of the codebase follows.
86
+ */
87
+ const PMS = [
88
+ "npm",
89
+ "pnpm",
90
+ "yarn",
91
+ "bun",
92
+ "deno",
93
+ "aube"
94
+ ];
95
+ const SEVERITIES = [
96
+ "error",
97
+ "warn",
98
+ "info"
99
+ ];
100
+ /**
101
+ * Numeric ordering of severities for threshold comparisons. Co-located with
102
+ * `Severity` so `filter`, `runLint`, and any future severity-aware code share
103
+ * one source of truth — drifting the order in one place silently changes the
104
+ * fail-on-threshold contract everywhere.
105
+ */
106
+ const SEVERITY_RANK = {
107
+ error: 3,
108
+ info: 1,
109
+ warn: 2
110
+ };
111
+ const PM_SET = new Set(PMS);
112
+ const SEVERITY_SET = new Set(SEVERITIES);
113
+ const isPM = (value) => PM_SET.has(value);
114
+ const isSeverity = (value) => SEVERITY_SET.has(value);
115
+ const PACKAGE_MANAGER_FIELD = /^(?<pmName>[a-z]+)(?:@.+)?$/u;
116
+ const parsePackageManagerField = (value) => {
117
+ const match = PACKAGE_MANAGER_FIELD.exec(value.trim());
118
+ let name = void 0;
119
+ if (match && match.groups) name = match.groups.pmName;
120
+ if (typeof name !== "undefined" && isPM(name)) return name;
121
+ };
122
+
123
+ //#endregion
124
+ //#region src/domain/ports/reporter.ts
125
+ /**
126
+ * Structural guard for an embedder-supplied reporter. The `Reporter` contract
127
+ * is compile-time only (`defineConfig` / typed options), so a hand-written JS
128
+ * config or a dynamically-built object can still pass a malformed value; this
129
+ * lets each boundary reject it with its own error type before `format` is
130
+ * called on something that isn't a function.
131
+ */
132
+ const isReporterShape = (value) => {
133
+ if (typeof value !== "object" || value === null) return false;
134
+ if (!("name" in value) || typeof value.name !== "string") return false;
135
+ return "format" in value && typeof value.format === "function";
136
+ };
137
+
138
+ //#endregion
139
+ //#region src/adapters/reporters/github.ts
140
+ const COMMAND = {
141
+ error: "error",
142
+ info: "notice",
143
+ warn: "warning"
144
+ };
145
+ const escapeProp = (raw) => raw.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A").replaceAll(":", "%3A").replaceAll(",", "%2C");
146
+ const escapeData = (raw) => raw.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A");
147
+ /** Emit GitHub Actions workflow commands (annotations on PRs). */
148
+ const githubReporter = {
149
+ format(result, io) {
150
+ for (const finding of result.findings) {
151
+ let file = "";
152
+ if (finding.file) file = `file=${escapeProp(finding.file)},`;
153
+ let body = `[${finding.pm}] ${finding.message}`;
154
+ if (finding.docs) body = `[${finding.pm}] ${finding.message} (${finding.docs})`;
155
+ io.stdout(`::${COMMAND[finding.severity]} ${file}title=${escapeProp(finding.ruleId)}::${escapeData(body)}`);
156
+ }
157
+ },
158
+ name: "github"
159
+ };
160
+
161
+ //#endregion
162
+ //#region src/version.ts
163
+ const version = "0.2.0";
164
+
165
+ //#endregion
166
+ //#region src/adapters/reporters/json.ts
167
+ /**
168
+ * Versioned machine-readable output — the public contract consumed by
169
+ * external fixers (see docs/json-output.md). Bump `schemaVersion` on any
170
+ * breaking shape change and update that doc in the same commit.
171
+ */
172
+ const SCHEMA_VERSION = 1;
173
+ const JSON_INDENT = 2;
174
+ const jsonReporter = {
175
+ format(result, io) {
176
+ io.stdout(JSON.stringify({
177
+ schemaVersion: 1,
178
+ siroVersion: version,
179
+ ...result
180
+ }, void 0, JSON_INDENT));
181
+ },
182
+ name: "json"
183
+ };
184
+
185
+ //#endregion
186
+ //#region src/adapters/reporters/pretty.ts
187
+ const GLYPH = {
188
+ error: "✖ error",
189
+ info: "ℹ info",
190
+ warn: "⚠ warn"
191
+ };
192
+ /**
193
+ * Decide colour at call time, not at import time.
194
+ *
195
+ * picocolors snapshots `isColorSupported` when imported, so a later
196
+ * `process.env.NO_COLOR = '1'` (or a different stdout) is ignored. The
197
+ * reporter is invoked per `siro lint` run from many contexts (TTY, CI
198
+ * with FORCE_COLOR, redirected output, …) — checking each call is the
199
+ * only way NO_COLOR can truly win, as https://no-color.org/ requires.
200
+ */
201
+ const colorSupportedNow = () => {
202
+ const { env } = process;
203
+ if (typeof env.NO_COLOR !== "undefined" && env.NO_COLOR !== "") return false;
204
+ if ("FORCE_COLOR" in env) return env.FORCE_COLOR !== "0" && env.FORCE_COLOR !== "";
205
+ return pc.isColorSupported;
206
+ };
207
+ const renderFinding = (finding, ctx) => {
208
+ let where = "";
209
+ if (finding.file) where = ctx.colors.dim(` (${finding.file})`);
210
+ ctx.io.stdout(`${ctx.tag[finding.severity](GLYPH[finding.severity])} [${finding.pm}] ${ctx.colors.bold(finding.ruleId)}${where}`);
211
+ ctx.io.stdout(` ${finding.message}`);
212
+ for (const step of finding.manualSteps ?? []) ctx.io.stdout(` ↳ ${step}`);
213
+ if (finding.docs) ctx.io.stdout(ctx.colors.dim(` → ${finding.docs}`));
214
+ };
215
+ const buildRenderCtx = (io) => {
216
+ const colors = pc.createColors(colorSupportedNow());
217
+ return {
218
+ colors,
219
+ io,
220
+ tag: {
221
+ error: colors.red,
222
+ info: colors.cyan,
223
+ warn: colors.yellow
224
+ }
225
+ };
226
+ };
227
+ const prettyReporter = {
228
+ format(result, io) {
229
+ const ctx = buildRenderCtx(io);
230
+ if (result.findings.length === 0) {
231
+ io.stdout(ctx.colors.green("✔ No security best-practice issues found."));
232
+ return;
233
+ }
234
+ for (const finding of result.findings) renderFinding(finding, ctx);
235
+ const { error, warn, info } = result.summary;
236
+ io.stdout("");
237
+ io.stdout(`Summary: ${error} error, ${warn} warn, ${info} info`);
238
+ },
239
+ name: "pretty"
240
+ };
241
+
242
+ //#endregion
243
+ //#region src/adapters/reporters/registry.ts
244
+ const BUILTINS = [
245
+ prettyReporter,
246
+ jsonReporter,
247
+ githubReporter
248
+ ];
249
+ /** Names of every built-in reporter, in stable display order. */
250
+ const BUILTIN_REPORTER_NAMES = BUILTINS.map((rep) => rep.name);
251
+ /** Build a registry from the builtins plus any extras (later wins on collision). */
252
+ const createRegistry = (extras = []) => {
253
+ const map = /* @__PURE__ */ new Map();
254
+ for (const rep of [...BUILTINS, ...extras]) {
255
+ if (!isReporterShape(rep)) throw new TypeError("createRegistry: each reporter needs a string 'name' and a 'format' function.");
256
+ map.set(rep.name, rep);
257
+ }
258
+ return {
259
+ get: (name) => map.get(name),
260
+ list: () => [...map.keys()]
261
+ };
262
+ };
263
+
264
+ //#endregion
265
+ //#region src/adapters/node-errors.ts
266
+ /**
267
+ * Node-runtime error helpers. Lives in the adapters layer so the shared
268
+ * module can stay runtime-agnostic — domain code transitively imports
269
+ * `shared/errors.ts`, and pulling Node's global type namespace through that
270
+ * path would couple every layer to `@types/node` for no reason beyond a
271
+ * single type guard.
272
+ */
273
+ /**
274
+ * Type guard for Node FS / system errors. `catch` exposes `unknown`, so every
275
+ * `errno`-aware branch needs to prove the shape before reading `code` —
276
+ * centralized here so callers don't sprinkle `as NodeJS.ErrnoException`.
277
+ */
278
+ const isNodeError = (value) => value instanceof Error && "code" in value && typeof value.code === "string";
279
+
280
+ //#endregion
281
+ //#region src/domain/services/filter.ts
282
+ const EXIT_SUCCESS = 0;
283
+ const EXIT_FAILURE = 1;
284
+ const meetsThreshold = (severity, threshold) => SEVERITY_RANK[severity] >= SEVERITY_RANK[threshold];
285
+ /** Keep only findings at or above `threshold`, recomputing the summary. */
286
+ const filterBySeverity = (result, threshold) => {
287
+ const findings = [];
288
+ const summary = {
289
+ error: 0,
290
+ info: 0,
291
+ warn: 0
292
+ };
293
+ for (const finding of result.findings) if (meetsThreshold(finding.severity, threshold)) {
294
+ findings.push(finding);
295
+ summary[finding.severity] += EXIT_FAILURE;
296
+ }
297
+ return {
298
+ findings,
299
+ summary
300
+ };
301
+ };
302
+ /**
303
+ * Exit code for a lint run. Non-zero when any finding meets `threshold`
304
+ * (default: only `error` fails the run).
305
+ */
306
+ const exitCodeForLint = (result, threshold = "error") => {
307
+ if (result.findings.some((finding) => meetsThreshold(finding.severity, threshold))) return EXIT_FAILURE;
308
+ return EXIT_SUCCESS;
309
+ };
310
+
311
+ //#endregion
312
+ //#region src/domain/entities/config-value.ts
313
+ const isParsedConfigObject = (value) => {
314
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
315
+ const proto = Object.getPrototypeOf(value);
316
+ return proto === null || proto === Object.prototype;
317
+ };
318
+ /**
319
+ * Look up a nested value by key path; `undefined` if any segment is missing.
320
+ *
321
+ * Part of the public surface (re-exported from `src/index.ts`): embedders
322
+ * writing custom rules receive the same `config: ParsedConfig` shape that
323
+ * built-in rules see, so they need the canonical traversal helper rather
324
+ * than re-implementing `null`-vs-missing semantics per rule.
325
+ */
326
+ const getByPath = (config, keyPath) => {
327
+ let current = config;
328
+ for (const key of keyPath) {
329
+ if (!isParsedConfigObject(current)) return;
330
+ current = current[key];
331
+ }
332
+ return current;
333
+ };
334
+ /**
335
+ * Single trust boundary between an external parser and siro's types — the
336
+ * `as ParsedConfig` cast lives here only, after a runtime shape check.
337
+ * Casting in each codec would scatter the boundary and let runtime checks
338
+ * drift out of sync. A deep sanitiser (recursing to keep only known scalars)
339
+ * was rejected because YAML legitimately yields `Date` and other host values
340
+ * that rules can inspect — silently dropping them would look like missing
341
+ * config keys. Throwing on non-objects was rejected so a root-level array or
342
+ * scalar behaves the same as an empty file: `{}`, letting `getByPath` return
343
+ * `undefined` instead of crashing every rule.
344
+ */
345
+ const toParsedConfig = (value) => {
346
+ if (isParsedConfigObject(value)) return value;
347
+ return {};
348
+ };
349
+
350
+ //#endregion
351
+ //#region src/adapters/codecs/ini.ts
352
+ const INTEGER_PATTERN = /^-?\d+$/u;
353
+ const coerceBoolean = (str) => {
354
+ if (str === "true") return true;
355
+ if (str === "false") return false;
356
+ };
357
+ const coerceInteger = (str) => {
358
+ if (INTEGER_PATTERN.test(str)) return Number(str);
359
+ };
360
+ const coerce = (raw) => {
361
+ if (typeof raw === "boolean" || typeof raw === "number") return raw;
362
+ let str = String(raw ?? "");
363
+ if (typeof raw === "string") str = raw;
364
+ return coerceBoolean(str) ?? coerceInteger(str) ?? str;
365
+ };
366
+ /**
367
+ * Coerces unquoted scalars on read so rule checks can match by typed
368
+ * value (`true === true`, `7 === 7`). Strings whose lexeme matches
369
+ * `true`/`false`/`-?\d+` lose their string-ness — this is by design for
370
+ * the npm schema, where rule values are booleans / integers / the empty
371
+ * string. Don't model a customRule around a string like `"0"` on this
372
+ * codec; pick yaml/json if you need lossless arbitrary-string reads.
373
+ */
374
+ const iniCodec = { parse(text) {
375
+ const raw = toParsedConfig(ini.parse(text));
376
+ const config = {};
377
+ for (const [key, value] of Object.entries(raw)) if (Array.isArray(value)) config[key] = value.map((elem) => coerce(elem));
378
+ else if (typeof value === "object" && value !== null) config[key] = toParsedConfig(value);
379
+ else config[key] = coerce(value);
380
+ return config;
381
+ } };
382
+
383
+ //#endregion
384
+ //#region src/adapters/codecs/json.ts
385
+ /** Codec for strict JSON (deno.json, package.json). Comments are not supported. */
386
+ const jsonCodec = { parse(text) {
387
+ const trimmed = text.trim();
388
+ if (trimmed === "") return {};
389
+ return toParsedConfig(JSON.parse(trimmed));
390
+ } };
391
+
392
+ //#endregion
393
+ //#region src/adapters/codecs/toml.ts
394
+ const tomlCodec = { parse(text) {
395
+ if (text.trim() === "") return {};
396
+ return toParsedConfig(parse(text));
397
+ } };
398
+
399
+ //#endregion
400
+ //#region src/adapters/codecs/yaml.ts
401
+ const yamlCodec = { parse(text) {
402
+ if (text.trim() === "") return {};
403
+ return toParsedConfig(parseDocument(text).toJS());
404
+ } };
405
+
406
+ //#endregion
407
+ //#region src/adapters/codecs/store.ts
408
+ const CODECS = {
409
+ json: jsonCodec,
410
+ npmrc: iniCodec,
411
+ toml: tomlCodec,
412
+ yaml: yamlCodec
413
+ };
414
+ /** Look up the codec for a parseable kind. Total — every CodecKind has one. */
415
+ const codecFor = (kind) => CODECS[kind];
416
+
417
+ //#endregion
418
+ //#region src/domain/services/apply-config.ts
419
+ const stripDynamicSeverity = (check) => (ctx, config) => {
420
+ const status = check(ctx, config);
421
+ if (status.state === "violation" && typeof status.severity !== "undefined") return {
422
+ actual: status.actual,
423
+ expected: status.expected,
424
+ manualSteps: status.manualSteps,
425
+ message: status.message,
426
+ state: "violation"
427
+ };
428
+ return status;
429
+ };
430
+ /**
431
+ * Rewrite all bindings of a rule to strip dynamic and per-binding severity,
432
+ * so the user's config-level severity override is the sole authority.
433
+ */
434
+ const rewriteBindingsForSeverityOverride = (rule, override) => {
435
+ const bindings = {};
436
+ for (const pm of PMS) {
437
+ const binding = rule.bindings[pm];
438
+ if (typeof binding !== "undefined") bindings[pm] = {
439
+ ...binding,
440
+ check: stripDynamicSeverity(binding.check),
441
+ severity: void 0
442
+ };
443
+ }
444
+ return {
445
+ ...rule,
446
+ bindings,
447
+ severity: override
448
+ };
449
+ };
450
+ /**
451
+ * Merge a user `SiroConfig` into the base ruleset, returning a fresh array
452
+ * (the inputs are not mutated). `base` is the already-resolved rule list —
453
+ * typically builtins plus any programmatic `customRules` — and `config` is
454
+ * the loaded `SiroConfig` whose `customRules` extend `base` and whose
455
+ * `rules` map applies severity overrides (or `'off'` to drop).
456
+ */
457
+ const applyOverride = (rule, overrides, out) => {
458
+ const override = overrides[rule.id];
459
+ if (override === "off") return;
460
+ if (typeof override === "undefined") out.push(rule);
461
+ else out.push(rewriteBindingsForSeverityOverride(rule, override));
462
+ };
463
+ const mergeBase = (base, config) => {
464
+ let customRules = void 0;
465
+ if (config) ({customRules} = config);
466
+ return [...base, ...customRules ?? []];
467
+ };
468
+ const applyConfig = (base, config) => {
469
+ const merged = mergeBase(base, config);
470
+ let overrides = void 0;
471
+ if (config) overrides = config.rules;
472
+ if (typeof overrides === "undefined") return merged;
473
+ const out = [];
474
+ for (const rule of merged) applyOverride(rule, overrides, out);
475
+ return out;
476
+ };
477
+
478
+ //#endregion
479
+ //#region src/domain/entities/config-files.ts
480
+ /**
481
+ * Canonical {@link ConfigFileRef}s for every package-manager config file siro
482
+ * knows how to read. Centralizing them keeps each rule module from hardcoding
483
+ * `kind` / `path` pairs, so renaming a file (or fixing a typo in its path) is a
484
+ * single-line change. This is also the one place `RelPath` is minted for a
485
+ * config ref — every downstream binding inherits the brand, so no rule needs
486
+ * an ad-hoc `asRelPath(ref.path)` cast at the FS boundary.
487
+ *
488
+ * Add a new entry here when introducing support for a new package manager.
489
+ */
490
+ const CONFIG_FILES = {
491
+ aubeWorkspace: {
492
+ kind: "yaml",
493
+ path: asRelPath("aube-workspace.yaml")
494
+ },
495
+ bunfig: {
496
+ kind: "toml",
497
+ path: asRelPath("bunfig.toml")
498
+ },
499
+ denoJson: {
500
+ kind: "json",
501
+ path: asRelPath("deno.json")
502
+ },
503
+ npmrc: {
504
+ kind: "npmrc",
505
+ path: asRelPath(".npmrc")
506
+ },
507
+ packageJson: {
508
+ kind: "json",
509
+ path: asRelPath("package.json")
510
+ },
511
+ pnpmWorkspace: {
512
+ kind: "yaml",
513
+ path: asRelPath("pnpm-workspace.yaml")
514
+ },
515
+ yarnrc: {
516
+ kind: "yaml",
517
+ path: asRelPath(".yarnrc.yml")
518
+ }
519
+ };
520
+
521
+ //#endregion
522
+ //#region src/domain/rules/builders/require-config-key.ts
523
+ /**
524
+ * Replace one or more bindings on an existing rule, returning a fresh Rule
525
+ * object. Used by rules whose shape outgrows {@link requireConfigKey} for a
526
+ * subset of PMs — they build the simple slots via the builder, then splice
527
+ * the hand-written bindings in via this helper rather than rebuilding from
528
+ * scratch or mutating the source rule.
529
+ */
530
+ const overrideBindings = (rule, overrides) => ({
531
+ ...rule,
532
+ bindings: {
533
+ ...rule.bindings,
534
+ ...overrides
535
+ }
536
+ });
537
+ /**
538
+ * Compose `message` with the structured suffix derived from {@link VersionNote}.
539
+ * Exported for the two hand-written bindings that can't be expressed via
540
+ * {@link requireConfigKey}: the pnpm slot of `disable-lifecycle-scripts`
541
+ * (two-key gate+bypass) and `bun-security-scanner` (advisory shape).
542
+ * New rules should grow `requireConfigKey` rather than reach for this
543
+ * helper — every additional caller raises the cost of evolving the suffix
544
+ * format.
545
+ */
546
+ const buildVersionNoteParts = (versionNote) => {
547
+ const parts = [];
548
+ if (typeof versionNote.configAvailableSince !== "undefined") parts.push(`available since ${versionNote.configAvailableSince}`);
549
+ if (typeof versionNote.defaultSafeSince !== "undefined") parts.push(`default safe since ${versionNote.defaultSafeSince}`);
550
+ if (typeof versionNote.note !== "undefined") parts.push(versionNote.note);
551
+ return parts;
552
+ };
553
+ const renderVersionNoteMessage = (message, versionNote) => {
554
+ if (typeof versionNote === "undefined") return message;
555
+ const parts = buildVersionNoteParts(versionNote);
556
+ if (parts.length === 0) return message;
557
+ return `${message} (${parts.join("; ")})`;
558
+ };
559
+ const isDefaultOk = (spec) => {
560
+ if (spec.accept) return spec.accept(spec.documentedDefault);
561
+ return spec.documentedDefault === spec.value;
562
+ };
563
+ const checkDocumentedDefault = (spec, actual, message) => {
564
+ if (typeof actual !== "undefined" || typeof spec.documentedDefault === "undefined") return;
565
+ if (!isDefaultOk(spec)) return;
566
+ const sev = spec.defaultSatisfiedSeverity ?? "info";
567
+ if (sev === "off") return { state: "ok" };
568
+ return {
569
+ actual,
570
+ expected: spec.value,
571
+ message,
572
+ severity: sev,
573
+ state: "violation"
574
+ };
575
+ };
576
+ const checkExtraFixes = (spec, config, message) => {
577
+ for (const extra of spec.extraFix ?? []) {
578
+ const extraActual = getByPath(config, extra.keyPath);
579
+ if (extraActual !== extra.value) return {
580
+ actual: extraActual,
581
+ expected: extra.value,
582
+ message,
583
+ state: "violation"
584
+ };
585
+ }
586
+ };
587
+ const checkPrimary = (spec, actual, message) => {
588
+ let primaryOk = actual === spec.value;
589
+ if (spec.accept) primaryOk = spec.accept(actual);
590
+ if (!primaryOk) return {
591
+ actual,
592
+ expected: spec.value,
593
+ message,
594
+ state: "violation"
595
+ };
596
+ };
597
+ const checkKeyValue = (spec, config, message) => {
598
+ const actual = getByPath(config, spec.keyPath);
599
+ const advisory = checkDocumentedDefault(spec, actual, message);
600
+ if (typeof advisory === "undefined") {
601
+ const primary = checkPrimary(spec, actual, message);
602
+ if (typeof primary !== "undefined") return primary;
603
+ }
604
+ return checkExtraFixes(spec, config, message) ?? advisory ?? { state: "ok" };
605
+ };
606
+ const buildBinding = (spec, applies) => ({
607
+ check(ctx, config) {
608
+ if (typeof applies !== "undefined" && !applies(ctx)) return { state: "na" };
609
+ return checkKeyValue(spec, config, renderVersionNoteMessage(spec.message, spec.versionNote));
610
+ },
611
+ docs: spec.docs,
612
+ file: spec.file,
613
+ fix() {
614
+ const ops = [{
615
+ file: spec.file,
616
+ keyPath: spec.keyPath,
617
+ op: "setKey",
618
+ value: spec.value
619
+ }];
620
+ for (const extra of spec.extraFix ?? []) ops.push({
621
+ file: spec.file,
622
+ keyPath: extra.keyPath,
623
+ op: "setKey",
624
+ value: extra.value
625
+ });
626
+ return ops;
627
+ },
628
+ fixKind: "auto",
629
+ severity: spec.severity
630
+ });
631
+ /** Build a Rule from a per-PM table of {file, keyPath, value, message}. */
632
+ const requireConfigKey = (options) => {
633
+ const bindings = {};
634
+ for (const pm of PMS) {
635
+ const spec = options.bindings[pm];
636
+ if (typeof spec !== "undefined") bindings[pm] = buildBinding(spec, options.applies);
637
+ }
638
+ return {
639
+ bindings,
640
+ description: options.description,
641
+ docs: options.docs,
642
+ id: options.id,
643
+ severity: options.severity,
644
+ title: options.title
645
+ };
646
+ };
647
+
648
+ //#endregion
649
+ //#region src/domain/rules/advisory-check.ts
650
+ const { aubeWorkspace: aubeWorkspace$9 } = CONFIG_FILES;
651
+ const advisoryCheck = requireConfigKey({
652
+ bindings: { aube: {
653
+ accept: (value) => value === "on" || value === "required",
654
+ docs: "https://aube.jdx.dev/security.html",
655
+ file: aubeWorkspace$9,
656
+ keyPath: ["advisoryCheck"],
657
+ message: "Set `advisoryCheck: on` (or `required`) in aube-workspace.yaml to check dependencies against known-malicious advisories.",
658
+ value: "on"
659
+ } },
660
+ description: "Query the OSV database for known-malicious packages during dependency resolution.",
661
+ docs: "https://aube.jdx.dev/security.html",
662
+ id: "advisory-check",
663
+ severity: "warn",
664
+ title: "Enable malicious-package advisory checks"
665
+ });
666
+
667
+ //#endregion
668
+ //#region src/domain/rules/approved-git-repos.ts
669
+ const { yarnrc: yarnrc$9 } = CONFIG_FILES;
670
+ const message = renderVersionNoteMessage("Set `approvedGitRepositories: []` in .yarnrc.yml to block all git: protocol dependencies (or list approved repository globs).", { configAvailableSince: "yarn 4.14.0" });
671
+ const yarnBinding$2 = {
672
+ check(_ctx, config) {
673
+ const value = getByPath(config, ["approvedGitRepositories"]);
674
+ if (Array.isArray(value)) return { state: "ok" };
675
+ return {
676
+ actual: value,
677
+ expected: "[]",
678
+ message,
679
+ state: "violation"
680
+ };
681
+ },
682
+ docs: "https://yarnpkg.com/configuration/yarnrc#approvedGitRepositories",
683
+ file: yarnrc$9,
684
+ fix() {
685
+ return [{
686
+ file: yarnrc$9,
687
+ message: "Add `approvedGitRepositories: []` to .yarnrc.yml to block all git deps, or list specific approved repository URL globs.",
688
+ op: "note"
689
+ }];
690
+ },
691
+ fixKind: "advisory"
692
+ };
693
+ const approvedGitRepos = {
694
+ bindings: { yarn: yarnBinding$2 },
695
+ description: "Restrict git: protocol dependencies to an explicit allowlist of approved repository URL patterns.",
696
+ docs: "https://yarnpkg.com/configuration/yarnrc#approvedGitRepositories",
697
+ id: "approved-git-repos",
698
+ severity: "warn",
699
+ title: "Approve git repository dependencies"
700
+ };
701
+
702
+ //#endregion
703
+ //#region src/domain/rules/audit-suppression.ts
704
+ const { yarnrc: yarnrc$8 } = CONFIG_FILES;
705
+ const isNonEmptyArray = (value) => Array.isArray(value) && value.length > 0;
706
+ const SUPPRESSION_KEYS = ["npmAuditIgnoreAdvisories", "npmAuditExcludePackages"];
707
+ const checkSuppression = (config) => {
708
+ const present = SUPPRESSION_KEYS.filter((key) => isNonEmptyArray(getByPath(config, [key])));
709
+ const [first] = present;
710
+ if (typeof first === "undefined") return { state: "ok" };
711
+ return {
712
+ actual: getByPath(config, [first]),
713
+ message: `Review audit suppression in .yarnrc.yml (${present.join(", ")}). Broad glob patterns can silently hide future vulnerabilities.`,
714
+ state: "violation"
715
+ };
716
+ };
717
+ const yarnBinding$1 = {
718
+ check(_ctx, config) {
719
+ return checkSuppression(config);
720
+ },
721
+ docs: "https://yarnpkg.com/configuration/yarnrc#npmAuditIgnoreAdvisories",
722
+ file: yarnrc$8,
723
+ fix() {
724
+ return [{
725
+ file: yarnrc$8,
726
+ message: "Review entries in `npmAuditIgnoreAdvisories` and `npmAuditExcludePackages` — remove stale suppressions and overly broad glob patterns.",
727
+ op: "note"
728
+ }];
729
+ },
730
+ fixKind: "advisory"
731
+ };
732
+ const auditSuppression = {
733
+ bindings: { yarn: yarnBinding$1 },
734
+ description: "Flag audit advisory suppressions that may silently hide future vulnerabilities via broad glob patterns.",
735
+ docs: "https://yarnpkg.com/configuration/yarnrc#npmAuditIgnoreAdvisories",
736
+ id: "audit-suppression",
737
+ severity: "info",
738
+ title: "Review audit suppression entries"
739
+ };
740
+
741
+ //#endregion
742
+ //#region src/domain/rules/block-auto-install.ts
743
+ const { bunfig: bunfig$5 } = CONFIG_FILES;
744
+ const blockAutoInstall = requireConfigKey({
745
+ bindings: { bun: {
746
+ docs: "https://bun.sh/docs/runtime/bunfig#install-auto",
747
+ file: bunfig$5,
748
+ keyPath: ["install", "auto"],
749
+ message: "Set `auto = \"disable\"` under [install] in bunfig.toml to prevent auto-install from bypassing security guards.",
750
+ value: "disable"
751
+ } },
752
+ description: "Disable auto-install so dependencies are only installed through an explicit install step where security guards apply.",
753
+ docs: "https://bun.sh/docs/runtime/bunfig#install-auto",
754
+ id: "block-auto-install",
755
+ severity: "warn",
756
+ title: "Block automatic package installation"
757
+ });
758
+
759
+ //#endregion
760
+ //#region src/domain/rules/block-exotic-subdeps.ts
761
+ const { aubeWorkspace: aubeWorkspace$8, npmrc: npmrc$6, pnpmWorkspace: pnpmWorkspace$10 } = CONFIG_FILES;
762
+ const isRootOrNone = (value) => value === "root" || value === "none";
763
+ const npmMessage = "Set `allow-git=root` and `allow-remote=root` in .npmrc to block git/tarball URLs in transitive dependencies.";
764
+ const npmBinding = {
765
+ check(_ctx, config) {
766
+ const git = getByPath(config, ["allow-git"]);
767
+ if (!isRootOrNone(git)) return {
768
+ actual: git,
769
+ expected: "root",
770
+ message: npmMessage,
771
+ state: "violation"
772
+ };
773
+ const remote = getByPath(config, ["allow-remote"]);
774
+ if (!isRootOrNone(remote)) return {
775
+ actual: remote,
776
+ expected: "root",
777
+ message: npmMessage,
778
+ state: "violation"
779
+ };
780
+ return { state: "ok" };
781
+ },
782
+ docs: "https://docs.npmjs.com/cli/v11/using-npm/config#allow-git",
783
+ file: npmrc$6,
784
+ fix() {
785
+ return [{
786
+ file: npmrc$6,
787
+ keyPath: ["allow-git"],
788
+ op: "setKey",
789
+ value: "root"
790
+ }, {
791
+ file: npmrc$6,
792
+ keyPath: ["allow-remote"],
793
+ op: "setKey",
794
+ value: "root"
795
+ }];
796
+ },
797
+ fixKind: "auto"
798
+ };
799
+ const builtRule$3 = requireConfigKey({
800
+ bindings: {
801
+ aube: {
802
+ docs: "https://aube.jdx.dev/security.html",
803
+ documentedDefault: true,
804
+ file: aubeWorkspace$8,
805
+ keyPath: ["blockExoticSubdeps"],
806
+ message: "Set `blockExoticSubdeps: true` in aube-workspace.yaml to block git/tarball URLs in transitive dependencies.",
807
+ value: true
808
+ },
809
+ pnpm: {
810
+ docs: "https://pnpm.io/settings#blockexoticsubdeps",
811
+ documentedDefault: true,
812
+ file: pnpmWorkspace$10,
813
+ keyPath: ["blockExoticSubdeps"],
814
+ message: "Set `blockExoticSubdeps: true` in pnpm-workspace.yaml to block git/tarball URLs in transitive dependencies.",
815
+ value: true,
816
+ versionNote: { configAvailableSince: "pnpm 10.26.0" }
817
+ }
818
+ },
819
+ description: "Refuse to install transitive dependencies sourced from git or tarball URLs, which bypass registry integrity checking.",
820
+ docs: "https://pnpm.io/settings#blockexoticsubdeps",
821
+ id: "block-exotic-subdeps",
822
+ severity: "warn",
823
+ title: "Block exotic subdependencies"
824
+ });
825
+ const blockExoticSubdeps = overrideBindings(builtRule$3, { npm: npmBinding });
826
+
827
+ //#endregion
828
+ //#region src/domain/rules/bun-security-scanner.ts
829
+ const { bunfig: bunfig$4 } = CONFIG_FILES;
830
+ const versionNote = { configAvailableSince: "bun 1.3.0" };
831
+ const bunScannerBinding = {
832
+ check(_ctx, config) {
833
+ const scanner = getByPath(config, [
834
+ "install",
835
+ "security",
836
+ "scanner"
837
+ ]);
838
+ if (typeof scanner === "string" && scanner.length > 0) return { state: "ok" };
839
+ return {
840
+ actual: scanner,
841
+ message: renderVersionNoteMessage("Configure `[install.security] scanner = \"...\"` in bunfig.toml (e.g. `@socketsecurity/bun-security-scanner`) to scan new packages on install.", versionNote),
842
+ state: "violation"
843
+ };
844
+ },
845
+ docs: "https://bun.com/docs/pm/security-scanner-api",
846
+ file: bunfig$4,
847
+ fix() {
848
+ return [{
849
+ file: bunfig$4,
850
+ message: "Add `[install.security] scanner = \"@socketsecurity/bun-security-scanner\"` (or another bun-compatible scanner) to bunfig.toml.",
851
+ op: "note"
852
+ }];
853
+ },
854
+ fixKind: "advisory"
855
+ };
856
+ const bunSecurityScanner = {
857
+ bindings: { bun: bunScannerBinding },
858
+ description: "Bun supports a Security Scanner API that intercepts new packages at install time (e.g. Socket Firewall).",
859
+ docs: "https://github.com/bodadotsh/npm-security-best-practices#preinstall-scanners",
860
+ id: "bun-security-scanner",
861
+ severity: "info",
862
+ title: "Enable a bun install-time security scanner"
863
+ };
864
+
865
+ //#endregion
866
+ //#region src/domain/rules/checksum-verification.ts
867
+ const { yarnrc: yarnrc$7 } = CONFIG_FILES;
868
+ const checksumVerification = requireConfigKey({
869
+ bindings: { yarn: {
870
+ docs: "https://yarnpkg.com/configuration/yarnrc#checksumBehavior",
871
+ documentedDefault: "throw",
872
+ file: yarnrc$7,
873
+ keyPath: ["checksumBehavior"],
874
+ message: "Set `checksumBehavior: throw` in .yarnrc.yml to reject packages whose checksum does not match the lockfile.",
875
+ value: "throw"
876
+ } },
877
+ description: "Throw on checksum mismatches so tampered or corrupted packages are never silently installed.",
878
+ docs: "https://yarnpkg.com/configuration/yarnrc#checksumBehavior",
879
+ id: "checksum-verification",
880
+ severity: "warn",
881
+ title: "Verify package checksums"
882
+ });
883
+
884
+ //#endregion
885
+ //#region src/domain/entities/signals.ts
886
+ const PM_SIGNALS = {
887
+ aube: {
888
+ configs: [CONFIG_FILES.aubeWorkspace.path],
889
+ lockfiles: ["aube-lock.yaml"],
890
+ reusesLockfiles: [
891
+ "pnpm-lock.yaml",
892
+ "package-lock.json",
893
+ "npm-shrinkwrap.json",
894
+ "yarn.lock",
895
+ "bun.lock",
896
+ "bun.lockb",
897
+ "deno.lock"
898
+ ]
899
+ },
900
+ bun: {
901
+ configs: [CONFIG_FILES.bunfig.path],
902
+ lockfiles: ["bun.lock", "bun.lockb"]
903
+ },
904
+ deno: {
905
+ configs: [CONFIG_FILES.denoJson.path],
906
+ lockfiles: ["deno.lock"]
907
+ },
908
+ npm: {
909
+ configs: [],
910
+ lockfiles: ["package-lock.json", "npm-shrinkwrap.json"]
911
+ },
912
+ pnpm: {
913
+ configs: [CONFIG_FILES.pnpmWorkspace.path],
914
+ lockfiles: ["pnpm-lock.yaml"]
915
+ },
916
+ yarn: {
917
+ configs: [CONFIG_FILES.yarnrc.path],
918
+ lockfiles: ["yarn.lock"]
919
+ }
920
+ };
921
+
922
+ //#endregion
923
+ //#region src/domain/rules/commit-lockfile.ts
924
+ const LOCKFILE_DOCS = {
925
+ aube: "https://aube.en.dev/package-manager/lockfiles",
926
+ bun: "https://bun.com/docs/install/lockfile",
927
+ deno: "https://docs.deno.com/runtime/fundamentals/modules/#integrity-checking-and-lock-files",
928
+ npm: "https://docs.npmjs.com/cli/v11/configuring-npm/package-lock-json",
929
+ pnpm: "https://pnpm.io/git#lockfiles",
930
+ yarn: "https://yarnpkg.com/getting-started/qa#should-lockfiles-be-committed-to-the-repository"
931
+ };
932
+ const lockfileBinding = (pm) => {
933
+ const { lockfiles, reusesLockfiles } = PM_SIGNALS[pm];
934
+ const [primary] = lockfiles;
935
+ const primaryRef = {
936
+ kind: "fileGlob",
937
+ path: asRelPath(primary)
938
+ };
939
+ const accepted = [...lockfiles, ...reusesLockfiles ?? []];
940
+ return {
941
+ check(ctx) {
942
+ if (accepted.some((lf) => ctx.exists(asRelPath(lf)))) return { state: "ok" };
943
+ return {
944
+ message: `No lockfile found. Generate and commit ${primary}.`,
945
+ state: "violation"
946
+ };
947
+ },
948
+ docs: LOCKFILE_DOCS[pm],
949
+ file: primaryRef,
950
+ fix() {
951
+ return [{
952
+ file: primaryRef,
953
+ message: `Install dependencies to generate ${primary}, then commit it.`,
954
+ op: "ensureFileTracked"
955
+ }];
956
+ },
957
+ fixKind: "advisory"
958
+ };
959
+ };
960
+ const commitLockfile = {
961
+ bindings: {
962
+ aube: lockfileBinding("aube"),
963
+ bun: lockfileBinding("bun"),
964
+ deno: lockfileBinding("deno"),
965
+ npm: lockfileBinding("npm"),
966
+ pnpm: lockfileBinding("pnpm"),
967
+ yarn: lockfileBinding("yarn")
968
+ },
969
+ description: "Lockfiles pin the full dependency tree and integrity hashes, enabling reproducible, verifiable installs (e.g. `npm ci`).",
970
+ docs: "https://github.com/bodadotsh/npm-security-best-practices#2-include-lockfiles",
971
+ id: "commit-lockfile",
972
+ severity: "error",
973
+ title: "Commit a lockfile"
974
+ };
975
+
976
+ //#endregion
977
+ //#region src/domain/rules/dependency-overrides.ts
978
+ const { aubeWorkspace: aubeWorkspace$7, pnpmWorkspace: pnpmWorkspace$9 } = CONFIG_FILES;
979
+ const isNonEmptyObject$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length > 0;
980
+ const checkOverrides = (config, file) => {
981
+ const value = getByPath(config, ["overrides"]);
982
+ if (!isNonEmptyObject$2(value)) return { state: "ok" };
983
+ return {
984
+ actual: value,
985
+ message: `Review \`overrides\` in ${file}. Overrides can replace transitive dependencies with arbitrary versions or forks.`,
986
+ state: "violation"
987
+ };
988
+ };
989
+ const makeBinding = (file, fileName, docs) => ({
990
+ check(_ctx, config) {
991
+ return checkOverrides(config, fileName);
992
+ },
993
+ docs,
994
+ file,
995
+ fix() {
996
+ return [{
997
+ file,
998
+ message: "Review each entry in `overrides` — verify pinned versions address a known CVE and are not redirecting to untrusted packages.",
999
+ op: "note"
1000
+ }];
1001
+ },
1002
+ fixKind: "advisory"
1003
+ });
1004
+ const dependencyOverrides = {
1005
+ bindings: {
1006
+ aube: makeBinding(aubeWorkspace$7, "aube-workspace.yaml", "https://aube.jdx.dev/settings.html"),
1007
+ pnpm: makeBinding(pnpmWorkspace$9, "pnpm-workspace.yaml", "https://pnpm.io/settings#overrides")
1008
+ },
1009
+ description: "Flag dependency overrides that can replace transitive packages with arbitrary versions or forks — a supply-chain injection vector.",
1010
+ docs: "https://pnpm.io/settings#overrides",
1011
+ id: "dependency-overrides",
1012
+ severity: "info",
1013
+ title: "Review dependency overrides"
1014
+ };
1015
+
1016
+ //#endregion
1017
+ //#region src/domain/rules/disable-lifecycle-scripts.ts
1018
+ const { npmrc: npmrc$5, pnpmWorkspace: pnpmWorkspace$8, yarnrc: yarnrc$6, aubeWorkspace: aubeWorkspace$6, bunfig: bunfig$3 } = CONFIG_FILES;
1019
+ const pnpmStrictDepBuildsDocs = "https://pnpm.io/settings#strictdepbuilds";
1020
+ const pnpmVersionNote = {
1021
+ configAvailableSince: "pnpm 10.3.0",
1022
+ defaultSafeSince: "pnpm 11.0.0"
1023
+ };
1024
+ const pnpmMsg = (body) => renderVersionNoteMessage(body, pnpmVersionNote);
1025
+ const pnpmBinding$4 = {
1026
+ check(_ctx, config) {
1027
+ const bypass = getByPath(config, ["dangerouslyAllowAllBuilds"]);
1028
+ if (bypass === true) return {
1029
+ actual: bypass,
1030
+ expected: false,
1031
+ manualSteps: ["Remove `dangerouslyAllowAllBuilds: true` from pnpm-workspace.yaml (or set it to `false`). Setting `strictDepBuilds: true` alone has no effect while the bypass remains."],
1032
+ message: pnpmMsg("`dangerouslyAllowAllBuilds: true` in pnpm-workspace.yaml bypasses strictDepBuilds — remove it (or set it to false) to restore lifecycle-script gating."),
1033
+ state: "violation"
1034
+ };
1035
+ const strict = getByPath(config, ["strictDepBuilds"]);
1036
+ if (strict === true) return { state: "ok" };
1037
+ if (typeof strict === "undefined") return {
1038
+ actual: strict,
1039
+ expected: true,
1040
+ message: pnpmMsg("Set `strictDepBuilds: true` in pnpm-workspace.yaml to pin lifecycle-script gating across versions."),
1041
+ severity: "info",
1042
+ state: "violation"
1043
+ };
1044
+ return {
1045
+ actual: strict,
1046
+ expected: true,
1047
+ message: pnpmMsg("Set `strictDepBuilds: true` in pnpm-workspace.yaml to block silent skips of un-approved dep builds."),
1048
+ state: "violation"
1049
+ };
1050
+ },
1051
+ docs: pnpmStrictDepBuildsDocs,
1052
+ file: pnpmWorkspace$8,
1053
+ fix() {
1054
+ return [{
1055
+ file: pnpmWorkspace$8,
1056
+ keyPath: ["strictDepBuilds"],
1057
+ op: "setKey",
1058
+ value: true
1059
+ }];
1060
+ },
1061
+ fixKind: "auto"
1062
+ };
1063
+ const aubeBinding = {
1064
+ check(_ctx, config) {
1065
+ const jail = getByPath(config, ["jailBuilds"]);
1066
+ if (jail !== true) return {
1067
+ actual: jail,
1068
+ expected: true,
1069
+ message: "Set `jailBuilds: true` in aube-workspace.yaml to sandbox build scripts.",
1070
+ state: "violation"
1071
+ };
1072
+ const strict = getByPath(config, ["strictDepBuilds"]);
1073
+ if (strict !== true) return {
1074
+ actual: strict,
1075
+ expected: true,
1076
+ message: "Set `strictDepBuilds: true` in aube-workspace.yaml to require explicit allow/deny for lifecycle scripts.",
1077
+ state: "violation"
1078
+ };
1079
+ return { state: "ok" };
1080
+ },
1081
+ docs: "https://aube.jdx.dev/security.html",
1082
+ file: aubeWorkspace$6,
1083
+ fix() {
1084
+ return [{
1085
+ file: aubeWorkspace$6,
1086
+ keyPath: ["jailBuilds"],
1087
+ op: "setKey",
1088
+ value: true
1089
+ }, {
1090
+ file: aubeWorkspace$6,
1091
+ keyPath: ["strictDepBuilds"],
1092
+ op: "setKey",
1093
+ value: true
1094
+ }];
1095
+ },
1096
+ fixKind: "auto"
1097
+ };
1098
+ const bunMessage = renderVersionNoteMessage("Set `ignoreScripts = true` under [install] in bunfig.toml — or set `\"trustedDependencies\": []` in package.json — to opt out of the curated allow-list (postinstall is already blocked for untrusted packages by default).", { configAvailableSince: "bun 1.2.0" });
1099
+ const bunBinding = {
1100
+ check(ctx, config) {
1101
+ const ignoreScripts = getByPath(config, ["install", "ignoreScripts"]);
1102
+ if (ignoreScripts === true) return { state: "ok" };
1103
+ const EMPTY = 0;
1104
+ let trusted = void 0;
1105
+ if (ctx.packageJson) trusted = ctx.packageJson.trustedDependencies;
1106
+ if (typeof trusted !== "undefined" && trusted.length === EMPTY) return { state: "ok" };
1107
+ return {
1108
+ actual: ignoreScripts,
1109
+ expected: true,
1110
+ message: bunMessage,
1111
+ state: "violation"
1112
+ };
1113
+ },
1114
+ docs: "https://bun.com/docs/pm/lifecycle",
1115
+ file: bunfig$3,
1116
+ fix() {
1117
+ return [{
1118
+ file: bunfig$3,
1119
+ keyPath: ["install", "ignoreScripts"],
1120
+ op: "setKey",
1121
+ value: true
1122
+ }];
1123
+ },
1124
+ fixKind: "auto",
1125
+ severity: "info"
1126
+ };
1127
+ const builtRule$2 = requireConfigKey({
1128
+ bindings: {
1129
+ npm: {
1130
+ docs: "https://docs.npmjs.com/cli/v11/using-npm/config#ignore-scripts",
1131
+ file: npmrc$5,
1132
+ keyPath: ["ignore-scripts"],
1133
+ message: "Set `ignore-scripts=true` in .npmrc to block dependency lifecycle scripts.",
1134
+ value: true
1135
+ },
1136
+ yarn: {
1137
+ docs: "https://yarnpkg.com/configuration/yarnrc#enableScripts",
1138
+ documentedDefault: false,
1139
+ file: yarnrc$6,
1140
+ keyPath: ["enableScripts"],
1141
+ message: "Set `enableScripts: false` in .yarnrc.yml to pin the policy across versions.",
1142
+ value: false,
1143
+ versionNote: {
1144
+ configAvailableSince: "yarn 2.0.0",
1145
+ defaultSafeSince: "yarn 4.14.0"
1146
+ }
1147
+ }
1148
+ },
1149
+ description: "Malicious postinstall scripts are a primary supply-chain attack vector. Prevent automatic execution of dependency lifecycle scripts.",
1150
+ docs: "https://github.com/bodadotsh/npm-security-best-practices#3-disable-lifecycle-scripts",
1151
+ id: "disable-lifecycle-scripts",
1152
+ severity: "error",
1153
+ title: "Disable dependency lifecycle scripts"
1154
+ });
1155
+ const disableLifecycleScripts = overrideBindings(builtRule$2, {
1156
+ aube: aubeBinding,
1157
+ bun: bunBinding,
1158
+ pnpm: pnpmBinding$4
1159
+ });
1160
+
1161
+ //#endregion
1162
+ //#region src/domain/rules/enforce-strict-ssl.ts
1163
+ const { npmrc: npmrc$4, yarnrc: yarnrc$5 } = CONFIG_FILES;
1164
+ const yarnMessage = "Set `enableStrictSsl: true` in .yarnrc.yml to enforce SSL certificate validation for registry connections.";
1165
+ const yarnBinding = {
1166
+ check(_ctx, config) {
1167
+ const strictSsl = getByPath(config, ["enableStrictSsl"]);
1168
+ if (strictSsl === false) return {
1169
+ actual: false,
1170
+ expected: true,
1171
+ message: yarnMessage,
1172
+ state: "violation"
1173
+ };
1174
+ const EMPTY = 0;
1175
+ const whitelist = getByPath(config, ["unsafeHttpWhitelist"]);
1176
+ if (Array.isArray(whitelist) && whitelist.length > EMPTY) return {
1177
+ actual: whitelist,
1178
+ expected: "",
1179
+ manualSteps: ["Review and remove entries from `unsafeHttpWhitelist` in .yarnrc.yml. Each entry permits unencrypted HTTP to that hostname."],
1180
+ message: "`unsafeHttpWhitelist` in .yarnrc.yml allows unencrypted HTTP connections — remove entries or clear the list to enforce HTTPS-only registry access.",
1181
+ state: "violation"
1182
+ };
1183
+ if (typeof strictSsl === "undefined") return {
1184
+ actual: strictSsl,
1185
+ expected: true,
1186
+ message: yarnMessage,
1187
+ severity: "info",
1188
+ state: "violation"
1189
+ };
1190
+ return { state: "ok" };
1191
+ },
1192
+ docs: "https://yarnpkg.com/configuration/yarnrc#enableStrictSsl",
1193
+ file: yarnrc$5,
1194
+ fix() {
1195
+ return [{
1196
+ file: yarnrc$5,
1197
+ keyPath: ["enableStrictSsl"],
1198
+ op: "setKey",
1199
+ value: true
1200
+ }];
1201
+ },
1202
+ fixKind: "auto"
1203
+ };
1204
+ const builtRule$1 = requireConfigKey({
1205
+ bindings: { npm: {
1206
+ docs: "https://docs.npmjs.com/cli/v11/using-npm/config#strict-ssl",
1207
+ documentedDefault: true,
1208
+ file: npmrc$4,
1209
+ keyPath: ["strict-ssl"],
1210
+ message: "Set `strict-ssl=true` in .npmrc to enforce SSL certificate validation.",
1211
+ value: true
1212
+ } },
1213
+ description: "Require SSL certificate validation so registry traffic cannot be intercepted or tampered with.",
1214
+ docs: "https://docs.npmjs.com/cli/v11/using-npm/config#strict-ssl",
1215
+ id: "enforce-strict-ssl",
1216
+ severity: "warn",
1217
+ title: "Enforce SSL for registry connections"
1218
+ });
1219
+ const enforceStrictSsl = overrideBindings(builtRule$1, { yarn: yarnBinding });
1220
+
1221
+ //#endregion
1222
+ //#region src/domain/rules/publishable.ts
1223
+ const isPublishable = (ctx) => {
1224
+ const pkg = ctx.packageJson;
1225
+ if (typeof pkg === "undefined") return false;
1226
+ return pkg.private !== true && typeof pkg.name === "string" && pkg.name.length > 0;
1227
+ };
1228
+
1229
+ //#endregion
1230
+ //#region src/domain/rules/files-field.ts
1231
+ const { packageJson: packageJson$1, denoJson: denoJson$3 } = CONFIG_FILES;
1232
+ const packageJsonFilesBinding = {
1233
+ check(ctx) {
1234
+ if (!isPublishable(ctx)) return { state: "na" };
1235
+ let files = void 0;
1236
+ if (ctx.packageJson) ({files} = ctx.packageJson);
1237
+ if (Array.isArray(files) && files.length > 0) return { state: "ok" };
1238
+ return {
1239
+ actual: files,
1240
+ message: "Add a `files` allow-list to package.json to limit what gets published.",
1241
+ state: "violation"
1242
+ };
1243
+ },
1244
+ docs: "https://docs.npmjs.com/cli/v11/configuring-npm/package-json#files",
1245
+ file: packageJson$1,
1246
+ fix() {
1247
+ return [{
1248
+ file: packageJson$1,
1249
+ message: "Add a `files` array to package.json (e.g. [\"dist\"]) and verify with `npm pack --dry-run`.",
1250
+ op: "note"
1251
+ }];
1252
+ },
1253
+ fixKind: "advisory"
1254
+ };
1255
+ const denoPublishBinding = {
1256
+ check(_ctx, config) {
1257
+ if (typeof getByPath(config, ["name"]) !== "string") return { state: "na" };
1258
+ const include = getByPath(config, ["publish", "include"]);
1259
+ if (Array.isArray(include) && include.length > 0) return { state: "ok" };
1260
+ return {
1261
+ actual: include,
1262
+ message: "Add `publish.include` to deno.json to limit what gets published.",
1263
+ state: "violation"
1264
+ };
1265
+ },
1266
+ docs: "https://docs.deno.com/runtime/reference/cli/publish/#how-publishing-works",
1267
+ file: denoJson$3,
1268
+ fix() {
1269
+ return [{
1270
+ file: denoJson$3,
1271
+ message: "Add `publish.include` (and/or `publish.exclude`) to deno.json.",
1272
+ op: "note"
1273
+ }];
1274
+ },
1275
+ fixKind: "advisory"
1276
+ };
1277
+ const filesField = {
1278
+ bindings: {
1279
+ aube: packageJsonFilesBinding,
1280
+ bun: packageJsonFilesBinding,
1281
+ deno: denoPublishBinding,
1282
+ npm: packageJsonFilesBinding,
1283
+ pnpm: packageJsonFilesBinding,
1284
+ yarn: packageJsonFilesBinding
1285
+ },
1286
+ description: "An explicit `files` array in package.json restricts what gets published, preventing accidental inclusion of secrets or local files.",
1287
+ docs: "https://github.com/bodadotsh/npm-security-best-practices#11-review-published-files",
1288
+ id: "files-field",
1289
+ severity: "info",
1290
+ title: "Declare a published files allow-list"
1291
+ };
1292
+
1293
+ //#endregion
1294
+ //#region src/domain/rules/frozen-lockfile.ts
1295
+ const { pnpmWorkspace: pnpmWorkspace$7, yarnrc: yarnrc$4, bunfig: bunfig$2, denoJson: denoJson$2, aubeWorkspace: aubeWorkspace$5 } = CONFIG_FILES;
1296
+ const denoLockMessage = "Set `lock.frozen: true` in deno.json for reproducible, verified installs.";
1297
+ const denoBinding$1 = {
1298
+ check(_ctx, config) {
1299
+ if (getByPath(config, ["lock", "frozen"]) === true) return { state: "ok" };
1300
+ const lock = getByPath(config, ["lock"]);
1301
+ const lockIsScalar = typeof lock !== "undefined" && (typeof lock !== "object" || lock === null || Array.isArray(lock));
1302
+ const base = {
1303
+ actual: getByPath(config, ["lock", "frozen"]),
1304
+ expected: true,
1305
+ message: denoLockMessage,
1306
+ state: "violation"
1307
+ };
1308
+ if (lockIsScalar) return {
1309
+ ...base,
1310
+ manualSteps: ["deno.json `lock` is set to a non-object value; writing `lock.frozen: true` under it would overwrite that value. Set it yourself, or make `lock` an object first."]
1311
+ };
1312
+ return base;
1313
+ },
1314
+ docs: "https://docs.deno.com/runtime/fundamentals/configuration/#lock",
1315
+ file: denoJson$2,
1316
+ fix() {
1317
+ return [{
1318
+ file: denoJson$2,
1319
+ keyPath: ["lock", "frozen"],
1320
+ op: "setKey",
1321
+ value: true
1322
+ }];
1323
+ },
1324
+ fixKind: "auto"
1325
+ };
1326
+ const builtRule = requireConfigKey({
1327
+ bindings: {
1328
+ aube: {
1329
+ docs: "https://aube.en.dev/settings/",
1330
+ documentedDefault: true,
1331
+ file: aubeWorkspace$5,
1332
+ keyPath: ["preferFrozenLockfile"],
1333
+ message: "aube defaults preferFrozenLockfile to true — set it explicitly in aube-workspace.yaml to pin the policy across versions.",
1334
+ value: true
1335
+ },
1336
+ bun: {
1337
+ docs: "https://bun.com/docs/runtime/bunfig#install-frozenlockfile",
1338
+ file: bunfig$2,
1339
+ keyPath: ["install", "frozenLockfile"],
1340
+ message: "Set `frozenLockfile = true` under [install] in bunfig.toml so installs fail when the lockfile would change.",
1341
+ value: true,
1342
+ versionNote: { configAvailableSince: "bun 0.6.10" }
1343
+ },
1344
+ pnpm: {
1345
+ docs: "https://pnpm.io/settings#frozenlockfile",
1346
+ documentedDefault: true,
1347
+ file: pnpmWorkspace$7,
1348
+ keyPath: ["frozenLockfile"],
1349
+ message: "pnpm defaults frozenLockfile to true in CI — set it explicitly in pnpm-workspace.yaml to pin the policy outside CI too.",
1350
+ value: true
1351
+ },
1352
+ yarn: {
1353
+ docs: "https://yarnpkg.com/configuration/yarnrc#enableImmutableInstalls",
1354
+ documentedDefault: true,
1355
+ file: yarnrc$4,
1356
+ keyPath: ["enableImmutableInstalls"],
1357
+ message: "Set `enableImmutableInstalls: true` in .yarnrc.yml to pin the policy outside CI too.",
1358
+ value: true,
1359
+ versionNote: {
1360
+ configAvailableSince: "yarn 2.0.0",
1361
+ defaultSafeSince: "yarn 3.0.0 in CI"
1362
+ }
1363
+ }
1364
+ },
1365
+ description: "Refuse to mutate the lockfile on install so unexpected dependency changes fail loudly.",
1366
+ docs: "https://github.com/bodadotsh/npm-security-best-practices#2-include-lockfiles",
1367
+ id: "frozen-lockfile",
1368
+ severity: "warn",
1369
+ title: "Freeze the lockfile"
1370
+ });
1371
+ const frozenLockfile = overrideBindings(builtRule, { deno: denoBinding$1 });
1372
+
1373
+ //#endregion
1374
+ //#region src/domain/rules/frozen-store.ts
1375
+ const { pnpmWorkspace: pnpmWorkspace$6 } = CONFIG_FILES;
1376
+ const pnpmBinding$3 = {
1377
+ check(_ctx, config) {
1378
+ const value = getByPath(config, ["frozenStore"]);
1379
+ if (value === true) return { state: "ok" };
1380
+ return {
1381
+ actual: value,
1382
+ message: "Consider enabling `frozenStore` in pnpm-workspace.yaml. Without it, the content-addressable store can be mutated after initial population, weakening integrity guarantees.",
1383
+ state: "violation"
1384
+ };
1385
+ },
1386
+ docs: "https://pnpm.io/settings#frozenstore",
1387
+ file: pnpmWorkspace$6,
1388
+ fix() {
1389
+ return [{
1390
+ file: pnpmWorkspace$6,
1391
+ message: "Set `frozenStore: true` to prevent writes to the store after initial population — especially useful in CI and deployment pipelines.",
1392
+ op: "note"
1393
+ }];
1394
+ },
1395
+ fixKind: "advisory"
1396
+ };
1397
+ const frozenStore = {
1398
+ bindings: { pnpm: pnpmBinding$3 },
1399
+ description: "Recommend enabling frozenStore to prevent mutations to the content-addressable store, strengthening supply-chain integrity in CI and deploy environments.",
1400
+ docs: "https://pnpm.io/settings#frozenstore",
1401
+ id: "frozen-store",
1402
+ severity: "info",
1403
+ title: "Enable frozen store"
1404
+ };
1405
+
1406
+ //#endregion
1407
+ //#region src/domain/rules/hardened-mode.ts
1408
+ const { yarnrc: yarnrc$3 } = CONFIG_FILES;
1409
+ const hardenedMode = requireConfigKey({
1410
+ bindings: { yarn: {
1411
+ docs: "https://yarnpkg.com/configuration/yarnrc#enableHardenedMode",
1412
+ documentedDefault: true,
1413
+ file: yarnrc$3,
1414
+ keyPath: ["enableHardenedMode"],
1415
+ 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.",
1416
+ value: true,
1417
+ versionNote: {
1418
+ configAvailableSince: "yarn 4.0.0",
1419
+ defaultSafeSince: "yarn 4.0.0 (conditional: auto-enabled for PRs on public repositories)"
1420
+ }
1421
+ } },
1422
+ description: "Yarn 4's enableHardenedMode performs end-to-end checksum, lockfile, and version verification at install time.",
1423
+ docs: "https://yarnpkg.com/configuration/yarnrc#enableHardenedMode",
1424
+ id: "hardened-mode",
1425
+ severity: "warn",
1426
+ title: "Enable hardened mode (yarn)"
1427
+ });
1428
+
1429
+ //#endregion
1430
+ //#region src/domain/rules/minimum-release-age.ts
1431
+ const HOURS_PER_DAY = 24;
1432
+ const MINUTES_PER_HOUR = 60;
1433
+ const SECONDS_PER_MINUTE = 60;
1434
+ const MINUTES_PER_DAY = HOURS_PER_DAY * MINUTES_PER_HOUR;
1435
+ const SECONDS_PER_DAY = MINUTES_PER_DAY * SECONDS_PER_MINUTE;
1436
+ const RECOMMENDED_RELEASE_AGE_DAYS = 3;
1437
+ const RECOMMENDED_RELEASE_AGE_MINUTES = RECOMMENDED_RELEASE_AGE_DAYS * MINUTES_PER_DAY;
1438
+ const RECOMMENDED_RELEASE_AGE_SECONDS = RECOMMENDED_RELEASE_AGE_DAYS * SECONDS_PER_DAY;
1439
+ const DOCUMENTED_DEFAULT_MINUTES = MINUTES_PER_DAY;
1440
+ const { npmrc: npmrc$3, pnpmWorkspace: pnpmWorkspace$5, yarnrc: yarnrc$2, bunfig: bunfig$1, denoJson: denoJson$1, aubeWorkspace: aubeWorkspace$4 } = CONFIG_FILES;
1441
+ const ZERO = 0;
1442
+ const isPositiveNumber = (value) => typeof value === "number" && value > ZERO;
1443
+ const isNonDisabledDenoDuration = (value) => {
1444
+ if (typeof value === "number") return value > ZERO;
1445
+ if (typeof value === "string") return value !== "" && value !== "0";
1446
+ return typeof value === "object" && value !== null;
1447
+ };
1448
+ const minimumReleaseAge = requireConfigKey({
1449
+ bindings: {
1450
+ aube: {
1451
+ accept: isPositiveNumber,
1452
+ docs: "https://aube.en.dev/settings/",
1453
+ documentedDefault: DOCUMENTED_DEFAULT_MINUTES,
1454
+ file: aubeWorkspace$4,
1455
+ keyPath: ["minimumReleaseAge"],
1456
+ message: `Set minimumReleaseAge (~${RECOMMENDED_RELEASE_AGE_MINUTES} minutes for a 3-day cooldown) in aube-workspace.yaml.`,
1457
+ value: RECOMMENDED_RELEASE_AGE_MINUTES
1458
+ },
1459
+ bun: {
1460
+ accept: isPositiveNumber,
1461
+ docs: "https://bun.com/docs/runtime/bunfig#install-minimumreleaseage",
1462
+ file: bunfig$1,
1463
+ keyPath: ["install", "minimumReleaseAge"],
1464
+ message: `Set install.minimumReleaseAge (bun) to ~${RECOMMENDED_RELEASE_AGE_SECONDS} seconds to quarantine brand-new releases.`,
1465
+ value: RECOMMENDED_RELEASE_AGE_SECONDS,
1466
+ versionNote: { configAvailableSince: "bun 1.3.0" }
1467
+ },
1468
+ deno: {
1469
+ accept: isNonDisabledDenoDuration,
1470
+ docs: "https://docs.deno.com/runtime/reference/deno_json/",
1471
+ file: denoJson$1,
1472
+ keyPath: ["minimumDependencyAge"],
1473
+ message: `Set minimumDependencyAge (e.g. "P3D" for a ${RECOMMENDED_RELEASE_AGE_DAYS}-day cooldown) in deno.json.`,
1474
+ value: "P3D"
1475
+ },
1476
+ npm: {
1477
+ accept: isPositiveNumber,
1478
+ docs: "https://docs.npmjs.com/cli/v11/using-npm/config#min-release-age",
1479
+ file: npmrc$3,
1480
+ keyPath: ["min-release-age"],
1481
+ message: `Set min-release-age to ~${RECOMMENDED_RELEASE_AGE_DAYS} days to quarantine brand-new releases.`,
1482
+ value: RECOMMENDED_RELEASE_AGE_DAYS,
1483
+ versionNote: { configAvailableSince: "npm 11.10.0" }
1484
+ },
1485
+ pnpm: {
1486
+ accept: isPositiveNumber,
1487
+ docs: "https://pnpm.io/settings#minimumreleaseage",
1488
+ documentedDefault: DOCUMENTED_DEFAULT_MINUTES,
1489
+ file: pnpmWorkspace$5,
1490
+ keyPath: ["minimumReleaseAge"],
1491
+ message: `Set minimumReleaseAge (~${RECOMMENDED_RELEASE_AGE_MINUTES} minutes for a 3-day cooldown) in pnpm-workspace.yaml.`,
1492
+ value: RECOMMENDED_RELEASE_AGE_MINUTES,
1493
+ versionNote: {
1494
+ configAvailableSince: "pnpm 10.16.0",
1495
+ defaultSafeSince: "pnpm 11.0.0 (1440 minutes)"
1496
+ }
1497
+ },
1498
+ yarn: {
1499
+ accept: isPositiveNumber,
1500
+ docs: "https://yarnpkg.com/configuration/yarnrc#npmMinimalAgeGate",
1501
+ documentedDefault: DOCUMENTED_DEFAULT_MINUTES,
1502
+ file: yarnrc$2,
1503
+ keyPath: ["npmMinimalAgeGate"],
1504
+ message: `Set npmMinimalAgeGate (~${RECOMMENDED_RELEASE_AGE_MINUTES} minutes for a 3-day cooldown) in .yarnrc.yml.`,
1505
+ value: RECOMMENDED_RELEASE_AGE_MINUTES,
1506
+ versionNote: {
1507
+ configAvailableSince: "yarn 4.10.0",
1508
+ defaultSafeSince: "yarn 4.15.0 (1440 minutes)"
1509
+ }
1510
+ }
1511
+ },
1512
+ description: "Refuse to install releases newer than a cooldown window so freshly published (possibly compromised) versions are skipped.",
1513
+ docs: "https://github.com/bodadotsh/npm-security-best-practices#set-minimal-release-age",
1514
+ id: "minimum-release-age",
1515
+ severity: "warn",
1516
+ title: "Set a minimum release age"
1517
+ });
1518
+
1519
+ //#endregion
1520
+ //#region src/domain/rules/named-registries.ts
1521
+ const { pnpmWorkspace: pnpmWorkspace$4 } = CONFIG_FILES;
1522
+ const isNonEmptyObject$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length > 0;
1523
+ const pnpmBinding$2 = {
1524
+ check(_ctx, config) {
1525
+ const value = getByPath(config, ["namedRegistries"]);
1526
+ if (!isNonEmptyObject$1(value)) return { state: "ok" };
1527
+ return {
1528
+ actual: value,
1529
+ message: "Review `namedRegistries` in pnpm-workspace.yaml. Custom registry mappings can redirect package resolution away from the public registry, enabling dependency confusion attacks.",
1530
+ state: "violation"
1531
+ };
1532
+ },
1533
+ docs: "https://pnpm.io/settings#namedregistries",
1534
+ file: pnpmWorkspace$4,
1535
+ fix() {
1536
+ return [{
1537
+ file: pnpmWorkspace$4,
1538
+ message: "Verify each named registry points to a trusted source and that scoped packages cannot be hijacked via a public registry of the same name.",
1539
+ op: "note"
1540
+ }];
1541
+ },
1542
+ fixKind: "advisory"
1543
+ };
1544
+ const namedRegistries = {
1545
+ bindings: { pnpm: pnpmBinding$2 },
1546
+ description: "Flag named registry mappings that redirect package resolution to custom registries, which may enable dependency confusion attacks.",
1547
+ docs: "https://pnpm.io/settings#namedregistries",
1548
+ id: "named-registries",
1549
+ severity: "info",
1550
+ title: "Review named registry mappings"
1551
+ };
1552
+
1553
+ //#endregion
1554
+ //#region src/domain/rules/paranoid-mode.ts
1555
+ const { aubeWorkspace: aubeWorkspace$3 } = CONFIG_FILES;
1556
+ const paranoidMode = requireConfigKey({
1557
+ bindings: { aube: {
1558
+ docs: "https://aube.jdx.dev/security.html",
1559
+ file: aubeWorkspace$3,
1560
+ keyPath: ["paranoid"],
1561
+ message: "Set `paranoid: true` in aube-workspace.yaml to enable the strict-security bundle (trustPolicy, jailBuilds, minimumReleaseAgeStrict, strictStoreIntegrity, strictDepBuilds, advisoryCheck).",
1562
+ value: true
1563
+ } },
1564
+ description: "Activate the strict-security setting bundle that forces trustPolicy, jailBuilds, minimumReleaseAgeStrict, strictStoreIntegrity, strictDepBuilds, and advisoryCheck on in one switch.",
1565
+ docs: "https://aube.jdx.dev/security.html",
1566
+ id: "paranoid-mode",
1567
+ severity: "info",
1568
+ title: "Enable paranoid security mode"
1569
+ });
1570
+
1571
+ //#endregion
1572
+ //#region src/domain/rules/patched-dependencies.ts
1573
+ const { pnpmWorkspace: pnpmWorkspace$3 } = CONFIG_FILES;
1574
+ const isNonEmptyObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length > 0;
1575
+ const pnpmBinding$1 = {
1576
+ check(_ctx, config) {
1577
+ const value = getByPath(config, ["patchedDependencies"]);
1578
+ if (!isNonEmptyObject(value)) return { state: "ok" };
1579
+ return {
1580
+ actual: value,
1581
+ message: "Review `patchedDependencies` in pnpm-workspace.yaml. Patches modify dependency source code directly and bypass registry integrity checks.",
1582
+ state: "violation"
1583
+ };
1584
+ },
1585
+ docs: "https://pnpm.io/settings#patcheddependencies",
1586
+ file: pnpmWorkspace$3,
1587
+ fix() {
1588
+ return [{
1589
+ file: pnpmWorkspace$3,
1590
+ message: "Review each entry in `patchedDependencies` — verify patches address a known issue and have not been tampered with.",
1591
+ op: "note"
1592
+ }];
1593
+ },
1594
+ fixKind: "advisory"
1595
+ };
1596
+ const patchedDependencies = {
1597
+ bindings: { pnpm: pnpmBinding$1 },
1598
+ description: "Flag patched dependencies whose source code is modified by local patch files, bypassing registry integrity verification.",
1599
+ docs: "https://pnpm.io/settings#patcheddependencies",
1600
+ id: "patched-dependencies",
1601
+ severity: "info",
1602
+ title: "Review patched dependencies"
1603
+ };
1604
+
1605
+ //#endregion
1606
+ //#region src/domain/rules/pin-exact-versions.ts
1607
+ const { npmrc: npmrc$2, pnpmWorkspace: pnpmWorkspace$2, yarnrc: yarnrc$1, bunfig, denoJson } = CONFIG_FILES;
1608
+ const isConfigObject = (value) => !Array.isArray(value);
1609
+ /**
1610
+ * Specifiers without any `@version` part fall into "no range" by design: this
1611
+ * is a deliberate, best-effort static-check stance. Treating "latest"-style
1612
+ * imports as violations would need a different mental model (and ideally
1613
+ * resolution against the lockfile), so we leave them alone here.
1614
+ */
1615
+ /** Wildcard segments (`1.x`, `1.*`, `1.2.X`) are ranges too. */
1616
+ const SPLIT_LIMIT_FIRST = 1;
1617
+ const FIRST_ELEMENT = 0;
1618
+ const hasWildcardSegment = (spec) => {
1619
+ return (spec.split("-", SPLIT_LIMIT_FIRST)[FIRST_ELEMENT] ?? spec).split(".").some((seg) => seg === "x" || seg === "X" || seg === "*");
1620
+ };
1621
+ const RANGE_OPERATOR = /[\^~]|>=|<=|<|>|\|\||\s/u;
1622
+ const isRangeSpec = (spec) => spec === "" || spec === "*" || RANGE_OPERATOR.test(spec) || hasWildcardSegment(spec);
1623
+ const NO_AT_SIGN = 0;
1624
+ const AFTER_AT_SIGN = 1;
1625
+ const hasSemverRange = (specifier) => {
1626
+ if (specifier.startsWith("http://") || specifier.startsWith("https://")) return false;
1627
+ const at = specifier.lastIndexOf("@");
1628
+ if (at <= NO_AT_SIGN) return false;
1629
+ return isRangeSpec(specifier.slice(at + AFTER_AT_SIGN));
1630
+ };
1631
+ const collectRangedImports = (imports) => {
1632
+ const offenders = [];
1633
+ for (const [name, value] of Object.entries(imports)) if (typeof value === "string" && hasSemverRange(value)) offenders.push(`${name}=${value}`);
1634
+ return offenders;
1635
+ };
1636
+ const MAX_SAMPLE_COUNT = 3;
1637
+ const EMPTY$2 = 0;
1638
+ const formatOffenders = (offenders) => {
1639
+ const sample = offenders.slice(FIRST_ELEMENT, MAX_SAMPLE_COUNT).join(", ");
1640
+ let more = "";
1641
+ if (offenders.length > MAX_SAMPLE_COUNT) more = ` (and ${offenders.length - MAX_SAMPLE_COUNT} more)`;
1642
+ return {
1643
+ message: `${offenders.length} deno imports use semver ranges: ${sample}${more}. Use \`deno add --save-exact\` or pin manually.`,
1644
+ state: "violation"
1645
+ };
1646
+ };
1647
+ const OK = { state: "ok" };
1648
+ const extractImportsRecord = (config) => {
1649
+ const imports = getByPath(config, ["imports"]);
1650
+ if (typeof imports !== "object" || imports === null || !isConfigObject(imports)) return;
1651
+ return imports;
1652
+ };
1653
+ const denoBinding = {
1654
+ check(_ctx, config) {
1655
+ const imports = extractImportsRecord(config);
1656
+ if (!imports) return OK;
1657
+ const offenders = collectRangedImports(imports);
1658
+ if (offenders.length === EMPTY$2) return OK;
1659
+ return formatOffenders(offenders);
1660
+ },
1661
+ docs: "https://docs.deno.com/runtime/reference/cli/add/",
1662
+ file: denoJson,
1663
+ fix() {
1664
+ return [{
1665
+ file: denoJson,
1666
+ message: "Run `deno add --save-exact <pkg>` for each ranged import in deno.json, or rewrite the `imports` entries to use exact versions.",
1667
+ op: "note"
1668
+ }];
1669
+ },
1670
+ fixKind: "advisory"
1671
+ };
1672
+ const baseRule = requireConfigKey({
1673
+ bindings: {
1674
+ bun: {
1675
+ docs: "https://bun.com/docs/runtime/bunfig#install-exact",
1676
+ file: bunfig,
1677
+ keyPath: ["install", "exact"],
1678
+ message: "Set `exact = true` under [install] in bunfig.toml to pin exact versions.",
1679
+ value: true,
1680
+ versionNote: { configAvailableSince: "bun 0.6.10" }
1681
+ },
1682
+ npm: {
1683
+ docs: "https://docs.npmjs.com/cli/v11/using-npm/config#save-exact",
1684
+ extraFix: [{
1685
+ keyPath: ["save-prefix"],
1686
+ value: ""
1687
+ }],
1688
+ file: npmrc$2,
1689
+ keyPath: ["save-exact"],
1690
+ message: "Set `save-exact=true` (and `save-prefix=`) in .npmrc to pin exact versions.",
1691
+ value: true
1692
+ },
1693
+ pnpm: {
1694
+ docs: "https://pnpm.io/settings#saveprefix",
1695
+ file: pnpmWorkspace$2,
1696
+ keyPath: ["savePrefix"],
1697
+ message: "Set `savePrefix: ''` in pnpm-workspace.yaml to pin exact versions.",
1698
+ value: ""
1699
+ },
1700
+ yarn: {
1701
+ docs: "https://yarnpkg.com/configuration/yarnrc#defaultSemverRangePrefix",
1702
+ file: yarnrc$1,
1703
+ keyPath: ["defaultSemverRangePrefix"],
1704
+ message: "Set `defaultSemverRangePrefix: ''` in .yarnrc.yml to pin exact versions.",
1705
+ value: "",
1706
+ versionNote: { configAvailableSince: "yarn 2.0.0" }
1707
+ }
1708
+ },
1709
+ description: "Semver ranges (^, ~) auto-adopt new releases, including compromised ones. Save exact versions by default.",
1710
+ docs: "https://github.com/bodadotsh/npm-security-best-practices#1-pin-dependency-versions",
1711
+ id: "pin-exact-versions",
1712
+ severity: "error",
1713
+ title: "Pin exact dependency versions"
1714
+ });
1715
+ const pinExactVersions = overrideBindings(baseRule, { deno: denoBinding });
1716
+
1717
+ //#endregion
1718
+ //#region src/domain/rules/provenance.ts
1719
+ const { npmrc: npmrc$1, yarnrc } = CONFIG_FILES;
1720
+ const npmrcProvenance = {
1721
+ file: npmrc$1,
1722
+ keyPath: ["provenance"],
1723
+ message: "Set `provenance=true` (and publish from CI) to attest releases.",
1724
+ value: true
1725
+ };
1726
+ const provenance = requireConfigKey({
1727
+ applies: isPublishable,
1728
+ bindings: {
1729
+ bun: {
1730
+ ...npmrcProvenance,
1731
+ docs: "https://github.com/oven-sh/bun/issues/15601",
1732
+ message: "Set `provenance=true` in .npmrc and publish via `bunx npm publish` from CI — `bun publish` does not emit provenance attestations natively (tracking: oven-sh/bun#15601)."
1733
+ },
1734
+ npm: {
1735
+ ...npmrcProvenance,
1736
+ docs: "https://docs.npmjs.com/cli/v11/using-npm/config#provenance",
1737
+ versionNote: { configAvailableSince: "npm 9.5.0" }
1738
+ },
1739
+ pnpm: {
1740
+ ...npmrcProvenance,
1741
+ docs: "https://pnpm.io/cli/publish"
1742
+ },
1743
+ yarn: {
1744
+ docs: "https://yarnpkg.com/configuration/yarnrc#npmPublishProvenance",
1745
+ file: yarnrc,
1746
+ keyPath: ["npmPublishProvenance"],
1747
+ message: "Set `npmPublishProvenance: true` (and publish from CI) to attest releases.",
1748
+ value: true
1749
+ }
1750
+ },
1751
+ description: "Provenance statements (via Sigstore) tie a release to its source and build, letting consumers verify it was not tampered with.",
1752
+ docs: "https://github.com/bodadotsh/npm-security-best-practices#10-generate-provenance-statements",
1753
+ id: "provenance",
1754
+ severity: "warn",
1755
+ title: "Publish with provenance"
1756
+ });
1757
+
1758
+ //#endregion
1759
+ //#region src/domain/rules/publish-access.ts
1760
+ const { packageJson } = CONFIG_FILES;
1761
+ const publishAccessBinding = {
1762
+ check(ctx) {
1763
+ if (!isPublishable(ctx)) return { state: "na" };
1764
+ let access = void 0;
1765
+ if (ctx.packageJson && ctx.packageJson.publishConfig) ({access} = ctx.packageJson.publishConfig);
1766
+ if (access === "public" || access === "restricted") return { state: "ok" };
1767
+ return {
1768
+ actual: access,
1769
+ message: "Set `publishConfig.access` in package.json to `public` or `restricted` to declare publish scope explicitly.",
1770
+ state: "violation"
1771
+ };
1772
+ },
1773
+ docs: "https://docs.npmjs.com/cli/v11/configuring-npm/package-json#publishconfig",
1774
+ file: packageJson,
1775
+ fix() {
1776
+ return [{
1777
+ file: packageJson,
1778
+ message: "Add `\"publishConfig\": { \"access\": \"public\" }` (or `\"restricted\"`) to package.json so publishes never default unexpectedly.",
1779
+ op: "note"
1780
+ }];
1781
+ },
1782
+ fixKind: "advisory"
1783
+ };
1784
+ const publishAccess = {
1785
+ bindings: {
1786
+ aube: publishAccessBinding,
1787
+ bun: publishAccessBinding,
1788
+ npm: publishAccessBinding,
1789
+ pnpm: publishAccessBinding,
1790
+ yarn: publishAccessBinding
1791
+ },
1792
+ description: "Set `publishConfig.access` so a misconfigured scope or registry never accidentally publishes an internal package publicly.",
1793
+ docs: "https://github.com/bodadotsh/npm-security-best-practices#for-maintainers",
1794
+ id: "publish-access",
1795
+ severity: "info",
1796
+ title: "Declare publish access explicitly"
1797
+ };
1798
+
1799
+ //#endregion
1800
+ //#region src/domain/rules/store-server.ts
1801
+ const { pnpmWorkspace: pnpmWorkspace$1 } = CONFIG_FILES;
1802
+ const pnpmBinding = {
1803
+ check(_ctx, config) {
1804
+ const value = getByPath(config, ["useRunningStoreServer"]);
1805
+ if (value !== true) return { state: "ok" };
1806
+ return {
1807
+ actual: value,
1808
+ message: "Review `useRunningStoreServer` in pnpm-workspace.yaml. Delegating store operations to an external process introduces a trust boundary — the server process can serve tampered packages.",
1809
+ state: "violation"
1810
+ };
1811
+ },
1812
+ docs: "https://pnpm.io/settings#userunningStoreserver",
1813
+ file: pnpmWorkspace$1,
1814
+ fix() {
1815
+ return [{
1816
+ file: pnpmWorkspace$1,
1817
+ message: "Verify the store server is running in a trusted environment and that its communication channel is not exposed to untrusted networks.",
1818
+ op: "note"
1819
+ }];
1820
+ },
1821
+ fixKind: "advisory"
1822
+ };
1823
+ const storeServer = {
1824
+ bindings: { pnpm: pnpmBinding },
1825
+ description: "Flag use of an external store server process, which introduces a trust boundary where tampered packages could be served.",
1826
+ docs: "https://pnpm.io/settings#userunningStoreserver",
1827
+ id: "store-server",
1828
+ severity: "info",
1829
+ title: "Review store server usage"
1830
+ };
1831
+
1832
+ //#endregion
1833
+ //#region src/domain/rules/strict-allow-scripts.ts
1834
+ const { npmrc } = CONFIG_FILES;
1835
+ const strictAllowScripts = requireConfigKey({
1836
+ bindings: { npm: {
1837
+ docs: "https://docs.npmjs.com/cli/v11/using-npm/config#strict-allow-scripts",
1838
+ file: npmrc,
1839
+ keyPath: ["strict-allow-scripts"],
1840
+ message: "Set `strict-allow-scripts=true` in .npmrc to block packages with unapproved install scripts.",
1841
+ value: true
1842
+ } },
1843
+ description: "Turn install-script policy warnings into hard errors so unapproved lifecycle scripts block installation.",
1844
+ docs: "https://docs.npmjs.com/cli/v11/using-npm/config#strict-allow-scripts",
1845
+ id: "strict-allow-scripts",
1846
+ severity: "warn",
1847
+ title: "Require explicit script allow-listing"
1848
+ });
1849
+
1850
+ //#endregion
1851
+ //#region src/domain/rules/strict-release-age.ts
1852
+ const { aubeWorkspace: aubeWorkspace$2 } = CONFIG_FILES;
1853
+ const strictReleaseAge = requireConfigKey({
1854
+ bindings: { aube: {
1855
+ docs: "https://aube.jdx.dev/settings/",
1856
+ file: aubeWorkspace$2,
1857
+ keyPath: ["minimumReleaseAgeStrict"],
1858
+ message: "Set `minimumReleaseAgeStrict: true` in aube-workspace.yaml to make the release age gate a hard block.",
1859
+ value: true
1860
+ } },
1861
+ description: "Make the minimum release age gate a hard failure instead of falling back to the next-oldest satisfying version.",
1862
+ docs: "https://aube.jdx.dev/security.html",
1863
+ id: "strict-release-age",
1864
+ severity: "info",
1865
+ title: "Enforce strict release age gate"
1866
+ });
1867
+
1868
+ //#endregion
1869
+ //#region src/domain/rules/strict-store-integrity.ts
1870
+ const { aubeWorkspace: aubeWorkspace$1 } = CONFIG_FILES;
1871
+ const strictStoreIntegrity = requireConfigKey({
1872
+ bindings: { aube: {
1873
+ docs: "https://aube.jdx.dev/settings/",
1874
+ file: aubeWorkspace$1,
1875
+ keyPath: ["strictStoreIntegrity"],
1876
+ message: "Set `strictStoreIntegrity: true` in aube-workspace.yaml to refuse tarballs that lack integrity metadata.",
1877
+ value: true
1878
+ } },
1879
+ description: "Refuse to import tarballs from the registry when the packument lacks a dist.integrity field, preventing silent integrity bypass.",
1880
+ docs: "https://aube.jdx.dev/security.html",
1881
+ id: "strict-store-integrity",
1882
+ severity: "warn",
1883
+ title: "Require tarball integrity metadata"
1884
+ });
1885
+
1886
+ //#endregion
1887
+ //#region src/domain/rules/trust-policy.ts
1888
+ const { aubeWorkspace, pnpmWorkspace } = CONFIG_FILES;
1889
+ const trustPolicy = requireConfigKey({
1890
+ bindings: {
1891
+ aube: {
1892
+ docs: "https://aube.jdx.dev/security.html",
1893
+ file: aubeWorkspace,
1894
+ keyPath: ["trustPolicy"],
1895
+ message: "Set `trustPolicy: no-downgrade` in aube-workspace.yaml to block packages whose trust level has decreased.",
1896
+ value: "no-downgrade"
1897
+ },
1898
+ pnpm: {
1899
+ docs: "https://pnpm.io/settings#trustpolicy",
1900
+ file: pnpmWorkspace,
1901
+ keyPath: ["trustPolicy"],
1902
+ message: "Set `trustPolicy: no-downgrade` in pnpm-workspace.yaml to block packages whose trust level has decreased.",
1903
+ value: "no-downgrade",
1904
+ versionNote: { configAvailableSince: "pnpm 10.21.0" }
1905
+ }
1906
+ },
1907
+ description: "Fail installation when a package trust level has decreased compared to previous releases, catching publisher credential downgrades.",
1908
+ docs: "https://pnpm.io/settings#trustpolicy",
1909
+ id: "trust-policy",
1910
+ severity: "warn",
1911
+ title: "Enforce trust policy on dependency updates"
1912
+ });
1913
+
1914
+ //#endregion
1915
+ //#region src/domain/builtin-rules.ts
1916
+ const RULE_REGISTRY = {
1917
+ "advisory-check": advisoryCheck,
1918
+ "approved-git-repos": approvedGitRepos,
1919
+ "audit-suppression": auditSuppression,
1920
+ "block-auto-install": blockAutoInstall,
1921
+ "block-exotic-subdeps": blockExoticSubdeps,
1922
+ "bun-security-scanner": bunSecurityScanner,
1923
+ "checksum-verification": checksumVerification,
1924
+ "commit-lockfile": commitLockfile,
1925
+ "dependency-overrides": dependencyOverrides,
1926
+ "disable-lifecycle-scripts": disableLifecycleScripts,
1927
+ "enforce-strict-ssl": enforceStrictSsl,
1928
+ "files-field": filesField,
1929
+ "frozen-lockfile": frozenLockfile,
1930
+ "frozen-store": frozenStore,
1931
+ "hardened-mode": hardenedMode,
1932
+ "minimum-release-age": minimumReleaseAge,
1933
+ "named-registries": namedRegistries,
1934
+ "paranoid-mode": paranoidMode,
1935
+ "patched-dependencies": patchedDependencies,
1936
+ "pin-exact-versions": pinExactVersions,
1937
+ provenance,
1938
+ "publish-access": publishAccess,
1939
+ "store-server": storeServer,
1940
+ "strict-allow-scripts": strictAllowScripts,
1941
+ "strict-release-age": strictReleaseAge,
1942
+ "strict-store-integrity": strictStoreIntegrity,
1943
+ "trust-policy": trustPolicy
1944
+ };
1945
+ const rules = Object.values(RULE_REGISTRY);
1946
+
1947
+ //#endregion
1948
+ //#region src/domain/services/validate-rule-ids.ts
1949
+ /**
1950
+ * Cross-check a loaded SiroConfig against the registered builtin rules.
1951
+ *
1952
+ * Pure: no I/O, no globals, takes builtins as an argument so it stays
1953
+ * testable and so the domain layer doesn't reach for `builtin-rules.ts`
1954
+ * via a hidden static dependency. Callers (the adapters-layer loadConfig
1955
+ * and the application-layer assertConfigRuleIdsKnown) pass the builtin
1956
+ * list explicitly.
1957
+ *
1958
+ * Why both checks in one pass? `duplicates` must be detected against the
1959
+ * builtin set *before* `unknown` consults the merged (builtin ∪ custom)
1960
+ * set — otherwise a colliding customRule id silently widens the known
1961
+ * set and a typo in `rules` aimed at that id is no longer flagged.
1962
+ */
1963
+ const detectDuplicates = (customRules, builtinIds) => {
1964
+ const duplicateSet = /* @__PURE__ */ new Set();
1965
+ const seen = /* @__PURE__ */ new Set();
1966
+ for (const rule of customRules) {
1967
+ if (builtinIds.has(rule.id) || seen.has(rule.id)) duplicateSet.add(rule.id);
1968
+ seen.add(rule.id);
1969
+ }
1970
+ return [...duplicateSet];
1971
+ };
1972
+ const detectUnknown = (rules, known) => {
1973
+ const unknown = [];
1974
+ for (const id of Object.keys(rules)) if (!known.has(id)) unknown.push(id);
1975
+ return unknown;
1976
+ };
1977
+ const validateRuleIds = (config, builtins, extraKnownIds = []) => {
1978
+ const builtinIds = new Set(builtins.map((rule) => rule.id));
1979
+ const customRules = config.customRules ?? [];
1980
+ const duplicates = detectDuplicates(customRules, builtinIds);
1981
+ const known = new Set(builtinIds);
1982
+ for (const id of extraKnownIds) known.add(id);
1983
+ for (const rule of customRules) known.add(rule.id);
1984
+ return {
1985
+ duplicates,
1986
+ unknown: detectUnknown(config.rules ?? {}, known)
1987
+ };
1988
+ };
1989
+
1990
+ //#endregion
1991
+ //#region src/application/validate-config-rules.ts
1992
+ const EMPTY$1 = 0;
1993
+ const SINGLE = 1;
1994
+ const throwUnknownIds = (unknown) => {
1995
+ let plural = "";
1996
+ if (unknown.length > SINGLE) plural = "s";
1997
+ throw new ConfigError(`siro.config: unknown rule id${plural} in rules: ${unknown.map((id) => `'${id}'`).join(", ")}`);
1998
+ };
1999
+ const assertConfigRuleIdsKnown = (userConfig, programmaticRules) => {
2000
+ if (typeof userConfig === "undefined") return;
2001
+ const { unknown } = validateRuleIds(userConfig, rules, (programmaticRules ?? []).map((rule) => rule.id));
2002
+ if (unknown.length > EMPTY$1) throwUnknownIds(unknown);
2003
+ };
2004
+
2005
+ //#endregion
2006
+ //#region src/domain/schemas/package-json.ts
2007
+ /**
2008
+ * Subset of `package.json` that siro actually reads. We keep the schema
2009
+ * intentionally narrow — every field rules consume is here, everything else
2010
+ * passes through unvalidated under `[key: string]: unknown` semantics.
2011
+ *
2012
+ * Every field is wrapped in `v.fallback` so one type-mismatched field can't
2013
+ * blank out the entire typed view (and silently N/A every publish rule).
2014
+ * A malformed value reads as "absent" — EXCEPT `private`, which falls back to
2015
+ * `true`: a security tool must never flip a broken `private` into publishable.
2016
+ */
2017
+ const [ABSENT] = [];
2018
+ const optionalString = vb.fallback(vb.optional(vb.string()), ABSENT);
2019
+ const PackageJsonSchema = vb.looseObject({
2020
+ files: vb.fallback(vb.optional(vb.array(vb.string())), ABSENT),
2021
+ name: optionalString,
2022
+ packageManager: optionalString,
2023
+ private: vb.optional(vb.fallback(vb.boolean(), true)),
2024
+ publishConfig: vb.fallback(vb.optional(vb.looseObject({ access: vb.fallback(vb.optional(vb.union([vb.literal("public"), vb.literal("restricted")])), ABSENT) })), ABSENT),
2025
+ trustedDependencies: vb.fallback(vb.optional(vb.array(vb.string())), ABSENT)
2026
+ });
2027
+ const safeParsePackageJson = (value) => {
2028
+ const result = vb.safeParse(PackageJsonSchema, value);
2029
+ if (result.success) return result.output;
2030
+ };
2031
+
2032
+ //#endregion
2033
+ //#region src/adapters/node-file-system.ts
2034
+ const nodeFileSystem = {
2035
+ exists(filePath) {
2036
+ try {
2037
+ accessSync(filePath, constants.F_OK);
2038
+ return true;
2039
+ } catch (error) {
2040
+ if (isNodeError(error) && error.code === "ENOENT") return false;
2041
+ throw error;
2042
+ }
2043
+ },
2044
+ readText(filePath) {
2045
+ try {
2046
+ return readFileSync(filePath, "utf8");
2047
+ } catch (error) {
2048
+ if (isNodeError(error) && error.code === "ENOENT") return;
2049
+ throw error;
2050
+ }
2051
+ }
2052
+ };
2053
+ const resolveIn = (root, relPath) => asAbsPath(path.join(root, relPath));
2054
+
2055
+ //#endregion
2056
+ //#region src/adapters/repo-context.ts
2057
+ const tryParseJson = (text) => {
2058
+ try {
2059
+ return JSON.parse(text);
2060
+ } catch (error) {
2061
+ let errMsg = String(error);
2062
+ if (error instanceof Error) errMsg = error.message;
2063
+ throw new ConfigError(`package.json: invalid JSON — ${errMsg}`);
2064
+ }
2065
+ };
2066
+ const parsePackageJson = (raw) => {
2067
+ return safeParsePackageJson(tryParseJson(raw.trim()));
2068
+ };
2069
+ const createRepoContext = (root, fs = nodeFileSystem) => {
2070
+ const readText = (relPath) => fs.readText(resolveIn(root, relPath));
2071
+ const exists = (relPath) => fs.exists(resolveIn(root, relPath));
2072
+ const raw = readText(asRelPath("package.json"));
2073
+ let packageJson = void 0;
2074
+ if (typeof raw !== "undefined") packageJson = parsePackageJson(raw);
2075
+ return {
2076
+ exists,
2077
+ packageJson,
2078
+ readText,
2079
+ root
2080
+ };
2081
+ };
2082
+
2083
+ //#endregion
2084
+ //#region src/adapters/config-loader.ts
2085
+ const MIN_PMS_LENGTH = 1;
2086
+ const CONFIG_NAMES = [
2087
+ "siro.config.ts",
2088
+ "siro.config.mjs",
2089
+ "siro.config.js"
2090
+ ];
2091
+ const ConfigSchema = vb.strictObject({
2092
+ customRules: vb.optional(vb.array(vb.custom(() => true))),
2093
+ pms: vb.optional(vb.pipe(vb.array(vb.picklist(PMS)), vb.minLength(MIN_PMS_LENGTH, "must not be empty (omit the key to auto-detect, or list at least one PM)"))),
2094
+ reporters: vb.optional(vb.array(vb.custom(isReporterShape, "must be a { name, format } reporter"))),
2095
+ rules: vb.optional(vb.record(vb.string(), vb.union([vb.picklist(SEVERITIES), vb.literal("off")])))
2096
+ }, "unknown config key (check for a typo)");
2097
+ const describeError = (err) => {
2098
+ if (err instanceof Error) return err.message;
2099
+ return String(err);
2100
+ };
2101
+ const formatIssues = (issues) => issues.map((issue) => {
2102
+ const keyPath = (issue.path ?? []).map((seg) => seg.key).filter((key) => typeof key === "string" || typeof key === "number").join(".");
2103
+ if (keyPath) return `${keyPath}: ${issue.message}`;
2104
+ return issue.message;
2105
+ }).join("; ");
2106
+ const TS_STRIPPING_MAJOR_22 = 22;
2107
+ const TS_STRIPPING_MINOR_22 = 18;
2108
+ const TS_STRIPPING_MAJOR_23 = 23;
2109
+ const TS_STRIPPING_MINOR_23 = 6;
2110
+ const TS_STRIPPING_ALWAYS_SINCE = 24;
2111
+ /** Whether a Node version imports .ts via native type stripping (unflagged since 22.18.0 / 23.6.0). */
2112
+ const hasNativeTypeStripping = (version) => {
2113
+ const [majorRaw = "", minorRaw = "0"] = version.replace(/^v/u, "").split(".");
2114
+ const major = Number(majorRaw);
2115
+ const minor = Number(minorRaw);
2116
+ if (major >= TS_STRIPPING_ALWAYS_SINCE) return true;
2117
+ if (major === TS_STRIPPING_MAJOR_23) return minor >= TS_STRIPPING_MINOR_23;
2118
+ if (major === TS_STRIPPING_MAJOR_22) return minor >= TS_STRIPPING_MINOR_22;
2119
+ return false;
2120
+ };
2121
+ let loadCounter = 0;
2122
+ const INCREMENT = 1;
2123
+ const importConfig = (modulePath, name) => {
2124
+ const url = pathToFileURL(modulePath);
2125
+ loadCounter += INCREMENT;
2126
+ url.searchParams.set("siro-load", String(loadCounter));
2127
+ return import(url.href).catch((error) => {
2128
+ throw new ConfigError(`Failed to load ${name}: ${describeError(error)}`);
2129
+ });
2130
+ };
2131
+ const extractDefault = (mod) => {
2132
+ if (mod !== null && typeof mod === "object" && "default" in mod) return mod.default;
2133
+ return mod;
2134
+ };
2135
+ const validateCandidateShape = (candidate, name) => {
2136
+ if (typeof candidate === "undefined" || candidate === null || typeof candidate !== "object" || Array.isArray(candidate)) {
2137
+ let got = typeof candidate;
2138
+ if (Array.isArray(candidate)) got = "an array";
2139
+ throw new ConfigError(`${name} must export a config object (got ${got}).`);
2140
+ }
2141
+ return candidate;
2142
+ };
2143
+ const validateSchema = (candidate, name) => {
2144
+ const result = vb.safeParse(ConfigSchema, candidate);
2145
+ if (!result.success) throw new ConfigError(`${name}: ${formatIssues(result.issues)}`);
2146
+ return result.output;
2147
+ };
2148
+ const rejectDuplicateCustomRuleIds = (config, name) => {
2149
+ const EMPTY = 0;
2150
+ const SINGLE = 1;
2151
+ const { duplicates } = validateRuleIds(config, rules);
2152
+ if (duplicates.length > EMPTY) {
2153
+ let plural = "";
2154
+ if (duplicates.length > SINGLE) plural = "s";
2155
+ throw new ConfigError(`${name}: duplicate customRule id${plural}: ${duplicates.map((id) => `'${id}'`).join(", ")}. Pick a unique id (or use 'rules' to override an existing rule's severity).`);
2156
+ }
2157
+ };
2158
+ const parseCandidate = (configPath, name) => importConfig(configPath, name).then((mod) => {
2159
+ const config = validateSchema(validateCandidateShape(extractDefault(mod), name), name);
2160
+ rejectDuplicateCustomRuleIds(config, name);
2161
+ return config;
2162
+ });
2163
+ /**
2164
+ * Locate and load `siro.config.{ts,mjs,js}` under `cwd`. Returns undefined when
2165
+ * no config exists. Throws ConfigError on malformed exports.
2166
+ */
2167
+ const loadConfig = (cwd, fs = nodeFileSystem, nodeVersion = process.versions.node) => {
2168
+ const candidate = CONFIG_NAMES.find((name) => fs.exists(asAbsPath(path.join(cwd, name))));
2169
+ if (typeof candidate === "undefined") return Promise.resolve(void 0);
2170
+ if (candidate.endsWith(".ts") && !hasNativeTypeStripping(nodeVersion)) return Promise.reject(new ConfigError(`${candidate} requires Node.js with native type stripping (^22.18.0 || ^23.6.0 || >=24); current is v${nodeVersion}. Rename the config to siro.config.mjs (plain JS) or upgrade Node.js.`));
2171
+ return parseCandidate(asAbsPath(path.join(cwd, candidate)), candidate);
2172
+ };
2173
+
2174
+ //#endregion
2175
+ //#region src/domain/services/merge-programmatic-rules.ts
2176
+ /**
2177
+ * Compose builtin rules with embedder-supplied programmatic rules. Reject
2178
+ * any programmatic rule whose id collides with a builtin, a config-supplied
2179
+ * customRule, or another programmatic rule in the same call — embedders need
2180
+ * to hear about collisions explicitly, the same way loadConfig surfaces
2181
+ * intra-config duplicates (otherwise two rules with the same id race through
2182
+ * applyConfig and double-count findings).
2183
+ */
2184
+ const detectCollisions = (programmatic, taken) => {
2185
+ const seen = /* @__PURE__ */ new Set();
2186
+ const collisions = /* @__PURE__ */ new Set();
2187
+ for (const rule of programmatic) {
2188
+ if (taken.has(rule.id) || seen.has(rule.id)) collisions.add(rule.id);
2189
+ seen.add(rule.id);
2190
+ }
2191
+ return collisions;
2192
+ };
2193
+ const SINGLE_COLLISION = 1;
2194
+ const throwCollisionError = (collisions) => {
2195
+ const ids = [...collisions];
2196
+ let noun = "rule id collides";
2197
+ if (ids.length > SINGLE_COLLISION) noun = "rule ids collide";
2198
+ throw new ConfigError(`customRules: ${noun} with builtin, config, or another programmatic rule: ${ids.map((id) => `'${id}'`).join(", ")}. Pick a unique id (or use 'rules' to override an existing rule's severity).`);
2199
+ };
2200
+ const mergeProgrammaticRules = (builtins, programmatic, configCustom) => {
2201
+ const EMPTY = 0;
2202
+ if (typeof programmatic === "undefined" || programmatic.length === EMPTY) return builtins;
2203
+ const collisions = detectCollisions(programmatic, new Set([...builtins.map((rule) => rule.id), ...(configCustom ?? []).map((rule) => rule.id)]));
2204
+ if (collisions.size > EMPTY) throwCollisionError(collisions);
2205
+ return [...builtins, ...programmatic];
2206
+ };
2207
+
2208
+ //#endregion
2209
+ //#region src/domain/services/detect-pms.ts
2210
+ /**
2211
+ * Per-PM list of filenames whose presence identifies that PM: its own
2212
+ * `lockfiles` plus `configs`. `reusesLockfiles` is deliberately excluded —
2213
+ * those are other PMs' shapes (see PMSignals), so they must not count as
2214
+ * detection evidence. With reused lockfiles split out, every detection signal
2215
+ * is owned by exactly one PM; the IIFE asserts that invariant at module load
2216
+ * so a future signal collision fails loudly here rather than silently
2217
+ * mis-attributing a file.
2218
+ */
2219
+ const registerSignals = (signals, pm, owner) => {
2220
+ for (const file of signals) {
2221
+ const prior = owner.get(file);
2222
+ if (typeof prior !== "undefined") throw new TypeError(`detection signal '${file}' is claimed by both '${prior}' and '${pm}'`);
2223
+ owner.set(file, pm);
2224
+ }
2225
+ };
2226
+ const buildDetectionSignals = () => {
2227
+ const out = /* @__PURE__ */ new Map();
2228
+ const owner = /* @__PURE__ */ new Map();
2229
+ for (const pm of PMS) {
2230
+ const { lockfiles, configs } = PM_SIGNALS[pm];
2231
+ const signals = [...lockfiles, ...configs];
2232
+ registerSignals(signals, pm, owner);
2233
+ out.set(pm, signals);
2234
+ }
2235
+ return out;
2236
+ };
2237
+ const DETECTION_SIGNALS = buildDetectionSignals();
2238
+ /**
2239
+ * Detect the package managers in use, strongest signal first:
2240
+ * 1) `packageManager` in package.json,
2241
+ * 2) lockfiles + configs (see DETECTION_SIGNALS for the per-PM allow-list
2242
+ * that skips filenames shared between PMs).
2243
+ * Returns every PM with evidence, in canonical `PMS` order. `ctx` carries
2244
+ * the repo signals — `packageJson` (already parsed) and `exists(relPath)`
2245
+ * for filename probing — so embedders can swap in a virtual FS without
2246
+ * touching the real disk.
2247
+ */
2248
+ const addDeclaredPM = (ctx, found) => {
2249
+ let declared = void 0;
2250
+ if (ctx.packageJson) declared = ctx.packageJson.packageManager;
2251
+ if (typeof declared === "string") {
2252
+ const pm = parsePackageManagerField(declared);
2253
+ if (typeof pm !== "undefined") found.add(pm);
2254
+ }
2255
+ };
2256
+ const detectPMs = (ctx) => {
2257
+ const found = /* @__PURE__ */ new Set();
2258
+ addDeclaredPM(ctx, found);
2259
+ for (const pm of PMS) {
2260
+ const signals = DETECTION_SIGNALS.get(pm);
2261
+ if (signals && signals.some((file) => ctx.exists(asRelPath(file)))) found.add(pm);
2262
+ }
2263
+ return PMS.filter((pm) => found.has(pm));
2264
+ };
2265
+
2266
+ //#endregion
2267
+ //#region src/domain/services/resolve-pms.ts
2268
+ const throwEmptyPMsError = (detected, opts, allowed) => {
2269
+ if (allowed) {
2270
+ if (opts.pmOverride) throw new UsageError(`--pm ${opts.pmOverride} is not in siro.config.ts pms (${allowed.join(", ")}). Adjust the config or the flag.`);
2271
+ const EMPTY = 0;
2272
+ let msg = `No package manager detected, and siro.config.ts restricts pms to ${allowed.join(", ")}. Adjust the config or remove the restriction.`;
2273
+ if (detected.length > EMPTY) msg = `Detected PMs (${detected.join(", ")}) do not match siro.config.ts pms (${allowed.join(", ")}). Adjust the config or remove the restriction.`;
2274
+ throw new UsageError(msg);
2275
+ }
2276
+ throw new UsageError(`No package manager detected. Pass --pm <${PMS.join("|")}> to be explicit.`);
2277
+ };
2278
+ /**
2279
+ * Pick the set of PMs to operate on for a single CLI command, applying the
2280
+ * same three-stage policy (`override → detect → restrict`) that `lint`
2281
+ * needs. Throws `UsageError` for the two empty-set cases so the
2282
+ * caller never has to invent its own messaging.
2283
+ *
2284
+ * Empty-set branches are usage errors, not silent npm fallback: see DECISIONS.md D08.
2285
+ */
2286
+ const computePMs = (ctx, opts) => {
2287
+ let detected = detectPMs(ctx);
2288
+ if (opts.pmOverride) detected = [opts.pmOverride];
2289
+ const { allowed } = opts;
2290
+ let pms = detected;
2291
+ if (allowed) {
2292
+ const allowedSet = new Set(allowed);
2293
+ pms = detected.filter((pm) => allowedSet.has(pm));
2294
+ }
2295
+ return {
2296
+ detected,
2297
+ pms
2298
+ };
2299
+ };
2300
+ const resolvePMs = (ctx, opts) => {
2301
+ const { detected, pms } = computePMs(ctx, opts);
2302
+ if (pms.length > 0) return pms;
2303
+ throw throwEmptyPMsError(detected, opts, opts.allowed);
2304
+ };
2305
+
2306
+ //#endregion
2307
+ //#region src/application/prepare-context.ts
2308
+ /**
2309
+ * Shared composition-root preamble for `lint` (kept separate so a future
2310
+ * command cannot drift from the load → context → resolve → merge → validate →
2311
+ * applyConfig protocol).
2312
+ */
2313
+ const resolveRuleSet = (userConfig, customRules) => {
2314
+ let configCustomRules = void 0;
2315
+ if (userConfig) configCustomRules = userConfig.customRules;
2316
+ const base = mergeProgrammaticRules(rules, customRules, configCustomRules);
2317
+ assertConfigRuleIdsKnown(userConfig, customRules);
2318
+ return applyConfig(base, userConfig);
2319
+ };
2320
+ const buildRunResult = (options, userConfig) => {
2321
+ const { cwd, fs, pm, customRules } = options;
2322
+ const ctx = createRepoContext(cwd, fs);
2323
+ let allowedPms = void 0;
2324
+ if (userConfig) allowedPms = userConfig.pms;
2325
+ return {
2326
+ ctx,
2327
+ pms: resolvePMs(ctx, {
2328
+ allowed: allowedPms,
2329
+ pmOverride: pm
2330
+ }),
2331
+ ruleSet: resolveRuleSet(userConfig, customRules),
2332
+ userConfig
2333
+ };
2334
+ };
2335
+ const prepareRun = (options) => loadConfig(options.cwd, options.fs).then((userConfig) => buildRunResult(options, userConfig));
2336
+
2337
+ //#endregion
2338
+ //#region src/domain/services/evaluate-bindings.ts
2339
+ /**
2340
+ * Iterate every applicable `(rule, pm)` binding, run its check, and hand
2341
+ * each violation to `onViolation`. Folding the loop into one place keeps
2342
+ * the rule-evaluation contract (parse once, run check, gate on violation)
2343
+ * in a single location so the call site (`runLint`) stays declarative.
2344
+ */
2345
+ const evaluateBindings = (opts) => {
2346
+ const { ctx, pms, ruleSet, parseConfig, onViolation } = opts;
2347
+ for (const rule of ruleSet) for (const pm of pms) {
2348
+ const binding = rule.bindings[pm];
2349
+ if (typeof binding !== "undefined") {
2350
+ const { parsed } = parseConfig(ctx, binding.file);
2351
+ const status = binding.check(ctx, parsed);
2352
+ if (status.state === "violation") onViolation({
2353
+ binding,
2354
+ pm,
2355
+ rule,
2356
+ status
2357
+ });
2358
+ }
2359
+ }
2360
+ };
2361
+
2362
+ //#endregion
2363
+ //#region src/domain/services/parse-config-file.ts
2364
+ const EMPTY_FILE = Object.freeze({ parsed: Object.freeze({}) });
2365
+ const parseAndCache = (ctx, file, deps) => {
2366
+ const text = ctx.readText(file.path) ?? "";
2367
+ const entry = { parsed: wrapCodecError(file.path, () => deps.codecFor(file.kind).parse(text)) };
2368
+ deps.cache.set(deps.cacheKey, entry);
2369
+ return entry;
2370
+ };
2371
+ /** Create a fresh, command-scoped parser. Always call once per command. */
2372
+ const createConfigParser = (codecFor) => {
2373
+ const cache = /* @__PURE__ */ new Map();
2374
+ return (ctx, file) => {
2375
+ if (file.kind === "fileGlob") return EMPTY_FILE;
2376
+ const cacheKey = `${file.kind}:${file.path}`;
2377
+ const hit = cache.get(cacheKey);
2378
+ if (typeof hit !== "undefined") return hit;
2379
+ return parseAndCache(ctx, file, {
2380
+ cache,
2381
+ cacheKey,
2382
+ codecFor
2383
+ });
2384
+ };
2385
+ };
2386
+
2387
+ //#endregion
2388
+ //#region src/domain/services/decide-severity.ts
2389
+ /**
2390
+ * A user `rules` override is NOT consulted here: `applyConfig` rewrites
2391
+ * `rule.severity` to the user's choice AND strips `binding.severity` +
2392
+ * dynamic `status.severity` before this function ever runs, so a single
2393
+ * three-source resolution suffices at the call site.
2394
+ */
2395
+ const decideSeverity = (status, binding, rule) => status.severity ?? binding.severity ?? rule.severity;
2396
+
2397
+ //#endregion
2398
+ //#region src/application/run-lint.ts
2399
+ const EMPTY = 0;
2400
+ const resolveManualSteps = (raw) => {
2401
+ if (typeof raw !== "undefined" && raw.length > EMPTY) return raw;
2402
+ };
2403
+ const buildFinding = (visit, ctx) => {
2404
+ const { rule, pm, binding, status } = visit;
2405
+ const severity = decideSeverity(status, binding, rule);
2406
+ const manualSteps = resolveManualSteps(status.manualSteps);
2407
+ let filePath = void 0;
2408
+ if (binding.file.kind !== "fileGlob") filePath = binding.file.path;
2409
+ let fix = [];
2410
+ if (typeof manualSteps === "undefined") fix = binding.fix(ctx);
2411
+ return {
2412
+ actual: status.actual,
2413
+ docs: binding.docs ?? rule.docs,
2414
+ expected: status.expected,
2415
+ file: filePath,
2416
+ fix,
2417
+ fixable: binding.fixKind === "auto" && typeof manualSteps === "undefined",
2418
+ manualSteps,
2419
+ message: status.message,
2420
+ pm,
2421
+ ruleId: rule.id,
2422
+ severity
2423
+ };
2424
+ };
2425
+ /** Evaluate every rule binding for the given PMs and collect violations. */
2426
+ const runLint = (opts) => {
2427
+ const { ctx, pms, ruleSet, codecFor } = opts;
2428
+ const findings = [];
2429
+ const summary = {
2430
+ error: 0,
2431
+ info: 0,
2432
+ warn: 0
2433
+ };
2434
+ evaluateBindings({
2435
+ ctx,
2436
+ onViolation: (visit) => {
2437
+ const finding = buildFinding(visit, ctx);
2438
+ findings.push(finding);
2439
+ summary[finding.severity] += 1;
2440
+ },
2441
+ parseConfig: createConfigParser(codecFor),
2442
+ pms,
2443
+ ruleSet
2444
+ });
2445
+ return {
2446
+ findings,
2447
+ summary
2448
+ };
2449
+ };
2450
+
2451
+ //#endregion
2452
+ //#region src/application/commands/lint.ts
2453
+ const resolveReporter = (value, registry) => {
2454
+ if (typeof value === "undefined") return registry.get("pretty");
2455
+ if (typeof value === "string") return registry.get(value);
2456
+ if (!isReporterShape(value)) throw new UsageError("The 'reporter' option must be a name or a { name, format } object.");
2457
+ return value;
2458
+ };
2459
+ const validateEmbedderReporters = (reporters) => {
2460
+ for (const rep of reporters ?? []) if (!isReporterShape(rep)) throw new UsageError("A reporter in 'reporters' needs a string 'name' and a 'format' function.");
2461
+ };
2462
+ const resolveAndValidateReporter = (value, registry) => {
2463
+ const reporter = resolveReporter(value, registry);
2464
+ if (typeof reporter === "undefined") throw new UsageError(`Unknown reporter: ${String(value)} (available: ${registry.list().join(", ")})`);
2465
+ return reporter;
2466
+ };
2467
+ const formatAndExit = (args) => {
2468
+ const result = runLint({
2469
+ codecFor,
2470
+ ctx: args.ctx,
2471
+ pms: args.pms,
2472
+ ruleSet: args.ruleSet
2473
+ });
2474
+ const displayThreshold = args.options.severity ?? "info";
2475
+ const failThreshold = args.options.severity ?? "error";
2476
+ args.reporter.format(filterBySeverity(result, displayThreshold), args.io);
2477
+ return exitCodeForLint(result, failThreshold);
2478
+ };
2479
+ /** `siro lint`: detect PMs, evaluate rules, report findings. */
2480
+ const lintCommand = (options, io) => prepareRun({
2481
+ customRules: options.customRules,
2482
+ cwd: options.cwd,
2483
+ fs: options.fs,
2484
+ pm: options.pm
2485
+ }).then(({ userConfig, ctx, pms, ruleSet }) => {
2486
+ validateEmbedderReporters(options.reporters);
2487
+ let configReporters = [];
2488
+ if (userConfig && userConfig.reporters) configReporters = userConfig.reporters;
2489
+ const registry = createRegistry([...options.reporters ?? [], ...configReporters]);
2490
+ return formatAndExit({
2491
+ ctx,
2492
+ io,
2493
+ options,
2494
+ pms,
2495
+ reporter: resolveAndValidateReporter(options.reporter, registry),
2496
+ ruleSet
2497
+ });
2498
+ });
2499
+
2500
+ //#endregion
2501
+ //#region src/adapters/node-io.ts
2502
+ const nodeIO = {
2503
+ stderr: (line) => process.stderr.write(`${line}\n`),
2504
+ stdout: (line) => process.stdout.write(`${line}\n`)
2505
+ };
2506
+
2507
+ //#endregion
2508
+ export { asRelPath as A, isPM as C, ConfigError as D, UsageError as E, SiroError as O, SEVERITIES as S, wrapCodecError as T, jsonReporter as _, PM_SIGNALS as a, isReporterShape as b, requireConfigKey as c, exitCodeForLint as d, filterBySeverity as f, prettyReporter as g, createRegistry as h, nodeFileSystem as i, asAbsPath as k, CONFIG_FILES as l, BUILTIN_REPORTER_NAMES as m, lintCommand as n, overrideBindings as o, isNodeError as p, detectPMs as r, renderVersionNoteMessage as s, nodeIO as t, getByPath as u, version as v, isSeverity as w, PMS as x, githubReporter as y };
2509
+ //# sourceMappingURL=node-io-Cb8nixgE.mjs.map