@pho9ubenaa/siro 0.2.0 → 0.3.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.
@@ -20,11 +20,6 @@ const asRelPath = (path) => path;
20
20
  * place so individual commands never have to think about exit semantics.
21
21
  * The exit-code table lives in docs/configuration.md S"Exit codes" and
22
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
23
  */
29
24
  var SiroError = class extends Error {
30
25
  /** Recommended process exit code for this error. */
@@ -114,9 +109,7 @@ const isPM = (value) => PM_SET.has(value);
114
109
  const isSeverity = (value) => SEVERITY_SET.has(value);
115
110
  const PACKAGE_MANAGER_FIELD = /^(?<pmName>[a-z]+)(?:@.+)?$/u;
116
111
  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;
112
+ const name = PACKAGE_MANAGER_FIELD.exec(value.trim())?.groups?.pmName;
120
113
  if (typeof name !== "undefined" && isPM(name)) return name;
121
114
  };
122
115
 
@@ -160,7 +153,7 @@ const githubReporter = {
160
153
 
161
154
  //#endregion
162
155
  //#region src/version.ts
163
- const version = "0.2.0";
156
+ const version = "0.3.0";
164
157
 
165
158
  //#endregion
166
159
  //#region src/adapters/reporters/json.ts
@@ -246,6 +239,8 @@ const BUILTINS = [
246
239
  jsonReporter,
247
240
  githubReporter
248
241
  ];
242
+ const DEFAULT_REPORTER_NAME = prettyReporter.name;
243
+ const JSON_REPORTER_NAME = jsonReporter.name;
249
244
  /** Names of every built-in reporter, in stable display order. */
250
245
  const BUILTIN_REPORTER_NAMES = BUILTINS.map((rep) => rep.name);
251
246
  /** Build a registry from the builtins plus any extras (later wins on collision). */
@@ -261,6 +256,29 @@ const createRegistry = (extras = []) => {
261
256
  };
262
257
  };
263
258
 
259
+ //#endregion
260
+ //#region src/shared/node-version.ts
261
+ const SUPPORTED_NODE_RANGE = "^22.18.0 || ^23.6.0 || >=24.0.0";
262
+ const MAJOR_22 = 22;
263
+ const MINOR_22 = 18;
264
+ const MAJOR_23 = 23;
265
+ const MINOR_23 = 6;
266
+ const ALWAYS_SUPPORTED_SINCE = 24;
267
+ const isSupportedNodeVersion = (version) => {
268
+ const [majorRaw = "", minorRaw = "0"] = version.replace(/^v/u, "").split(".");
269
+ const major = Number(majorRaw);
270
+ const minor = Number(minorRaw);
271
+ if (major >= ALWAYS_SUPPORTED_SINCE) return true;
272
+ if (major === MAJOR_23) return minor >= MINOR_23;
273
+ if (major === MAJOR_22) return minor >= MINOR_22;
274
+ return false;
275
+ };
276
+
277
+ //#endregion
278
+ //#region src/domain/entities/project-type.ts
279
+ const PROJECT_TYPES = ["application", "package"];
280
+ const isProjectType = (value) => PROJECT_TYPES.some((projectType) => projectType === value);
281
+
264
282
  //#endregion
265
283
  //#region src/adapters/node-errors.ts
266
284
  /**
@@ -310,6 +328,12 @@ const exitCodeForLint = (result, threshold = "error") => {
310
328
 
311
329
  //#endregion
312
330
  //#region src/domain/entities/config-value.ts
331
+ const CODEC_KINDS = [
332
+ "json",
333
+ "npmrc",
334
+ "toml",
335
+ "yaml"
336
+ ];
313
337
  const isParsedConfigObject = (value) => {
314
338
  if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
315
339
  const proto = Object.getPrototypeOf(value);
@@ -327,6 +351,7 @@ const getByPath = (config, keyPath) => {
327
351
  let current = config;
328
352
  for (const key of keyPath) {
329
353
  if (!isParsedConfigObject(current)) return;
354
+ if (!Object.hasOwn(current, key)) return;
330
355
  current = current[key];
331
356
  }
332
357
  return current;
@@ -347,6 +372,29 @@ const toParsedConfig = (value) => {
347
372
  return {};
348
373
  };
349
374
 
375
+ //#endregion
376
+ //#region src/domain/entities/rule.ts
377
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
378
+ const isOptionalString = (value) => typeof value === "undefined" || typeof value === "string";
379
+ const CONFIG_FILE_KINDS = new Set([...CODEC_KINDS, "fileGlob"]);
380
+ const isConfigFileRefShape = (value) => {
381
+ if (!isRecord(value) || typeof value.kind !== "string" || typeof value.path !== "string") return false;
382
+ return CONFIG_FILE_KINDS.has(value.kind);
383
+ };
384
+ const isVersionNoteShape = (value) => typeof value === "undefined" || isRecord(value) && isOptionalString(value.configAvailableSince) && isOptionalString(value.defaultSafeSince) && isOptionalString(value.note);
385
+ const isRuleBindingShape = (value) => {
386
+ if (!isRecord(value)) return false;
387
+ return isConfigFileRefShape(value.file) && (value.fixKind === "auto" || value.fixKind === "advisory") && isOptionalString(value.docs) && (typeof value.severity === "undefined" || typeof value.severity === "string" && isSeverity(value.severity)) && isVersionNoteShape(value.versionNote) && typeof value.check === "function" && typeof value.fix === "function";
388
+ };
389
+ const isBindingsShape = (value) => isRecord(value) && Object.entries(value).every(([pm, binding]) => isPM(pm) && isRuleBindingShape(binding));
390
+ const isProjectTypeValue = (value) => typeof value === "string" && isProjectType(value);
391
+ const isProjectTypesShape = (value) => typeof value === "undefined" || Array.isArray(value) && Array.from(value).every((projectType) => isProjectTypeValue(projectType));
392
+ const isRuleShape = (value) => {
393
+ if (!isRecord(value)) return false;
394
+ return typeof value.id === "string" && typeof value.title === "string" && typeof value.description === "string" && typeof value.severity === "string" && isSeverity(value.severity) && isOptionalString(value.docs) && isProjectTypesShape(value.projectTypes) && isBindingsShape(value.bindings);
395
+ };
396
+ const defineRule = (rule) => rule;
397
+
350
398
  //#endregion
351
399
  //#region src/adapters/codecs/ini.ts
352
400
  const INTEGER_PATTERN = /^-?\d+$/u;
@@ -393,14 +441,18 @@ const jsonCodec = { parse(text) {
393
441
  //#region src/adapters/codecs/toml.ts
394
442
  const tomlCodec = { parse(text) {
395
443
  if (text.trim() === "") return {};
396
- return toParsedConfig(parse(text));
444
+ return toParsedConfig(parse(text.replace(/^\uFEFF/u, "")));
397
445
  } };
398
446
 
399
447
  //#endregion
400
448
  //#region src/adapters/codecs/yaml.ts
401
449
  const yamlCodec = { parse(text) {
402
450
  if (text.trim() === "") return {};
403
- return toParsedConfig(parseDocument(text).toJS());
451
+ const document = parseDocument(text);
452
+ if (document.errors.length > 0) throw document.errors[0];
453
+ return toParsedConfig(document.toJS({ onAnchor: (value) => {
454
+ JSON.stringify(value);
455
+ } }));
404
456
  } };
405
457
 
406
458
  //#endregion
@@ -461,14 +513,12 @@ const applyOverride = (rule, overrides, out) => {
461
513
  else out.push(rewriteBindingsForSeverityOverride(rule, override));
462
514
  };
463
515
  const mergeBase = (base, config) => {
464
- let customRules = void 0;
465
- if (config) ({customRules} = config);
516
+ const customRules = config?.customRules;
466
517
  return [...base, ...customRules ?? []];
467
518
  };
468
519
  const applyConfig = (base, config) => {
469
520
  const merged = mergeBase(base, config);
470
- let overrides = void 0;
471
- if (config) overrides = config.rules;
521
+ const overrides = config?.rules;
472
522
  if (typeof overrides === "undefined") return merged;
473
523
  const out = [];
474
524
  for (const rule of merged) applyOverride(rule, overrides, out);
@@ -534,28 +584,6 @@ const overrideBindings = (rule, overrides) => ({
534
584
  ...overrides
535
585
  }
536
586
  });
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
587
  const isDefaultOk = (spec) => {
560
588
  if (spec.accept) return spec.accept(spec.documentedDefault);
561
589
  return spec.documentedDefault === spec.value;
@@ -606,7 +634,7 @@ const checkKeyValue = (spec, config, message) => {
606
634
  const buildBinding = (spec, applies) => ({
607
635
  check(ctx, config) {
608
636
  if (typeof applies !== "undefined" && !applies(ctx)) return { state: "na" };
609
- return checkKeyValue(spec, config, renderVersionNoteMessage(spec.message, spec.versionNote));
637
+ return checkKeyValue(spec, config, spec.message);
610
638
  },
611
639
  docs: spec.docs,
612
640
  file: spec.file,
@@ -626,7 +654,8 @@ const buildBinding = (spec, applies) => ({
626
654
  return ops;
627
655
  },
628
656
  fixKind: "auto",
629
- severity: spec.severity
657
+ severity: spec.severity,
658
+ versionNote: spec.versionNote
630
659
  });
631
660
  /** Build a Rule from a per-PM table of {file, keyPath, value, message}. */
632
661
  const requireConfigKey = (options) => {
@@ -640,6 +669,7 @@ const requireConfigKey = (options) => {
640
669
  description: options.description,
641
670
  docs: options.docs,
642
671
  id: options.id,
672
+ projectTypes: options.projectTypes,
643
673
  severity: options.severity,
644
674
  title: options.title
645
675
  };
@@ -667,7 +697,7 @@ const advisoryCheck = requireConfigKey({
667
697
  //#endregion
668
698
  //#region src/domain/rules/approved-git-repos.ts
669
699
  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" });
700
+ const message = "Set `approvedGitRepositories: []` in .yarnrc.yml to block all git: protocol dependencies (or list approved repository globs).";
671
701
  const yarnBinding$2 = {
672
702
  check(_ctx, config) {
673
703
  const value = getByPath(config, ["approvedGitRepositories"]);
@@ -688,16 +718,17 @@ const yarnBinding$2 = {
688
718
  op: "note"
689
719
  }];
690
720
  },
691
- fixKind: "advisory"
721
+ fixKind: "advisory",
722
+ versionNote: { configAvailableSince: "yarn 4.14.0" }
692
723
  };
693
- const approvedGitRepos = {
724
+ const approvedGitRepos = defineRule({
694
725
  bindings: { yarn: yarnBinding$2 },
695
726
  description: "Restrict git: protocol dependencies to an explicit allowlist of approved repository URL patterns.",
696
727
  docs: "https://yarnpkg.com/configuration/yarnrc#approvedGitRepositories",
697
728
  id: "approved-git-repos",
698
729
  severity: "warn",
699
730
  title: "Approve git repository dependencies"
700
- };
731
+ });
701
732
 
702
733
  //#endregion
703
734
  //#region src/domain/rules/audit-suppression.ts
@@ -729,14 +760,14 @@ const yarnBinding$1 = {
729
760
  },
730
761
  fixKind: "advisory"
731
762
  };
732
- const auditSuppression = {
763
+ const auditSuppression = defineRule({
733
764
  bindings: { yarn: yarnBinding$1 },
734
765
  description: "Flag audit advisory suppressions that may silently hide future vulnerabilities via broad glob patterns.",
735
766
  docs: "https://yarnpkg.com/configuration/yarnrc#npmAuditIgnoreAdvisories",
736
767
  id: "audit-suppression",
737
768
  severity: "info",
738
769
  title: "Review audit suppression entries"
739
- };
770
+ });
740
771
 
741
772
  //#endregion
742
773
  //#region src/domain/rules/block-auto-install.ts
@@ -813,7 +844,10 @@ const builtRule$3 = requireConfigKey({
813
844
  keyPath: ["blockExoticSubdeps"],
814
845
  message: "Set `blockExoticSubdeps: true` in pnpm-workspace.yaml to block git/tarball URLs in transitive dependencies.",
815
846
  value: true,
816
- versionNote: { configAvailableSince: "pnpm 10.26.0" }
847
+ versionNote: {
848
+ configAvailableSince: "pnpm 10.26.0",
849
+ defaultSafeSince: "pnpm 10.26.0"
850
+ }
817
851
  }
818
852
  },
819
853
  description: "Refuse to install transitive dependencies sourced from git or tarball URLs, which bypass registry integrity checking.",
@@ -827,7 +861,6 @@ const blockExoticSubdeps = overrideBindings(builtRule$3, { npm: npmBinding });
827
861
  //#endregion
828
862
  //#region src/domain/rules/bun-security-scanner.ts
829
863
  const { bunfig: bunfig$4 } = CONFIG_FILES;
830
- const versionNote = { configAvailableSince: "bun 1.3.0" };
831
864
  const bunScannerBinding = {
832
865
  check(_ctx, config) {
833
866
  const scanner = getByPath(config, [
@@ -838,7 +871,7 @@ const bunScannerBinding = {
838
871
  if (typeof scanner === "string" && scanner.length > 0) return { state: "ok" };
839
872
  return {
840
873
  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),
874
+ message: "Configure `[install.security] scanner = \"...\"` in bunfig.toml (e.g. `@socketsecurity/bun-security-scanner`) to scan new packages on install.",
842
875
  state: "violation"
843
876
  };
844
877
  },
@@ -851,16 +884,17 @@ const bunScannerBinding = {
851
884
  op: "note"
852
885
  }];
853
886
  },
854
- fixKind: "advisory"
887
+ fixKind: "advisory",
888
+ versionNote: { configAvailableSince: "bun 1.3.0" }
855
889
  };
856
- const bunSecurityScanner = {
890
+ const bunSecurityScanner = defineRule({
857
891
  bindings: { bun: bunScannerBinding },
858
892
  description: "Bun supports a Security Scanner API that intercepts new packages at install time (e.g. Socket Firewall).",
859
893
  docs: "https://github.com/bodadotsh/npm-security-best-practices#preinstall-scanners",
860
894
  id: "bun-security-scanner",
861
895
  severity: "info",
862
896
  title: "Enable a bun install-time security scanner"
863
- };
897
+ });
864
898
 
865
899
  //#endregion
866
900
  //#region src/domain/rules/checksum-verification.ts
@@ -929,6 +963,7 @@ const LOCKFILE_DOCS = {
929
963
  pnpm: "https://pnpm.io/git#lockfiles",
930
964
  yarn: "https://yarnpkg.com/getting-started/qa#should-lockfiles-be-committed-to-the-repository"
931
965
  };
966
+ const LOCKFILE_VERSION_NOTES = { deno: { configAvailableSince: "deno 1.28.0" } };
932
967
  const lockfileBinding = (pm) => {
933
968
  const { lockfiles, reusesLockfiles } = PM_SIGNALS[pm];
934
969
  const [primary] = lockfiles;
@@ -954,10 +989,11 @@ const lockfileBinding = (pm) => {
954
989
  op: "ensureFileTracked"
955
990
  }];
956
991
  },
957
- fixKind: "advisory"
992
+ fixKind: "advisory",
993
+ versionNote: LOCKFILE_VERSION_NOTES[pm]
958
994
  };
959
995
  };
960
- const commitLockfile = {
996
+ const commitLockfile = defineRule({
961
997
  bindings: {
962
998
  aube: lockfileBinding("aube"),
963
999
  bun: lockfileBinding("bun"),
@@ -971,7 +1007,7 @@ const commitLockfile = {
971
1007
  id: "commit-lockfile",
972
1008
  severity: "error",
973
1009
  title: "Commit a lockfile"
974
- };
1010
+ });
975
1011
 
976
1012
  //#endregion
977
1013
  //#region src/domain/rules/dependency-overrides.ts
@@ -1001,7 +1037,7 @@ const makeBinding = (file, fileName, docs) => ({
1001
1037
  },
1002
1038
  fixKind: "advisory"
1003
1039
  });
1004
- const dependencyOverrides = {
1040
+ const dependencyOverrides = defineRule({
1005
1041
  bindings: {
1006
1042
  aube: makeBinding(aubeWorkspace$7, "aube-workspace.yaml", "https://aube.jdx.dev/settings.html"),
1007
1043
  pnpm: makeBinding(pnpmWorkspace$9, "pnpm-workspace.yaml", "https://pnpm.io/settings#overrides")
@@ -1011,17 +1047,11 @@ const dependencyOverrides = {
1011
1047
  id: "dependency-overrides",
1012
1048
  severity: "info",
1013
1049
  title: "Review dependency overrides"
1014
- };
1050
+ });
1015
1051
 
1016
1052
  //#endregion
1017
1053
  //#region src/domain/rules/disable-lifecycle-scripts.ts
1018
1054
  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
1055
  const pnpmBinding$4 = {
1026
1056
  check(_ctx, config) {
1027
1057
  const bypass = getByPath(config, ["dangerouslyAllowAllBuilds"]);
@@ -1029,7 +1059,7 @@ const pnpmBinding$4 = {
1029
1059
  actual: bypass,
1030
1060
  expected: false,
1031
1061
  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."),
1062
+ message: "`dangerouslyAllowAllBuilds: true` in pnpm-workspace.yaml bypasses strictDepBuilds — remove it (or set it to false) to restore lifecycle-script gating.",
1033
1063
  state: "violation"
1034
1064
  };
1035
1065
  const strict = getByPath(config, ["strictDepBuilds"]);
@@ -1037,18 +1067,18 @@ const pnpmBinding$4 = {
1037
1067
  if (typeof strict === "undefined") return {
1038
1068
  actual: strict,
1039
1069
  expected: true,
1040
- message: pnpmMsg("Set `strictDepBuilds: true` in pnpm-workspace.yaml to pin lifecycle-script gating across versions."),
1070
+ message: "Set `strictDepBuilds: true` in pnpm-workspace.yaml to pin lifecycle-script gating across versions.",
1041
1071
  severity: "info",
1042
1072
  state: "violation"
1043
1073
  };
1044
1074
  return {
1045
1075
  actual: strict,
1046
1076
  expected: true,
1047
- message: pnpmMsg("Set `strictDepBuilds: true` in pnpm-workspace.yaml to block silent skips of un-approved dep builds."),
1077
+ message: "Set `strictDepBuilds: true` in pnpm-workspace.yaml to block silent skips of un-approved dep builds.",
1048
1078
  state: "violation"
1049
1079
  };
1050
1080
  },
1051
- docs: pnpmStrictDepBuildsDocs,
1081
+ docs: "https://pnpm.io/settings#strictdepbuilds",
1052
1082
  file: pnpmWorkspace$8,
1053
1083
  fix() {
1054
1084
  return [{
@@ -1058,7 +1088,11 @@ const pnpmBinding$4 = {
1058
1088
  value: true
1059
1089
  }];
1060
1090
  },
1061
- fixKind: "auto"
1091
+ fixKind: "auto",
1092
+ versionNote: {
1093
+ configAvailableSince: "pnpm 10.3.0",
1094
+ defaultSafeSince: "pnpm 11.0.0"
1095
+ }
1062
1096
  };
1063
1097
  const aubeBinding = {
1064
1098
  check(_ctx, config) {
@@ -1095,15 +1129,13 @@ const aubeBinding = {
1095
1129
  },
1096
1130
  fixKind: "auto"
1097
1131
  };
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" });
1132
+ const bunMessage = "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).";
1099
1133
  const bunBinding = {
1100
1134
  check(ctx, config) {
1101
1135
  const ignoreScripts = getByPath(config, ["install", "ignoreScripts"]);
1102
1136
  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" };
1137
+ const trusted = ctx.packageJson?.trustedDependencies;
1138
+ if (typeof trusted !== "undefined" && trusted.length === 0) return { state: "ok" };
1107
1139
  return {
1108
1140
  actual: ignoreScripts,
1109
1141
  expected: true,
@@ -1122,7 +1154,8 @@ const bunBinding = {
1122
1154
  }];
1123
1155
  },
1124
1156
  fixKind: "auto",
1125
- severity: "info"
1157
+ severity: "info",
1158
+ versionNote: { configAvailableSince: "bun 1.2.0" }
1126
1159
  };
1127
1160
  const builtRule$2 = requireConfigKey({
1128
1161
  bindings: {
@@ -1165,15 +1198,14 @@ const yarnMessage = "Set `enableStrictSsl: true` in .yarnrc.yml to enforce SSL c
1165
1198
  const yarnBinding = {
1166
1199
  check(_ctx, config) {
1167
1200
  const strictSsl = getByPath(config, ["enableStrictSsl"]);
1168
- if (strictSsl === false) return {
1169
- actual: false,
1201
+ if (typeof strictSsl !== "undefined" && strictSsl !== true) return {
1202
+ actual: strictSsl,
1170
1203
  expected: true,
1171
1204
  message: yarnMessage,
1172
1205
  state: "violation"
1173
1206
  };
1174
- const EMPTY = 0;
1175
1207
  const whitelist = getByPath(config, ["unsafeHttpWhitelist"]);
1176
- if (Array.isArray(whitelist) && whitelist.length > EMPTY) return {
1208
+ if (Array.isArray(whitelist) && whitelist.length > 0) return {
1177
1209
  actual: whitelist,
1178
1210
  expected: "",
1179
1211
  manualSteps: ["Review and remove entries from `unsafeHttpWhitelist` in .yarnrc.yml. Each entry permits unencrypted HTTP to that hostname."],
@@ -1219,12 +1251,24 @@ const builtRule$1 = requireConfigKey({
1219
1251
  const enforceStrictSsl = overrideBindings(builtRule$1, { yarn: yarnBinding });
1220
1252
 
1221
1253
  //#endregion
1222
- //#region src/domain/rules/publishable.ts
1223
- const isPublishable = (ctx) => {
1254
+ //#region src/domain/services/project-type.ts
1255
+ const EMPTY$1 = 0;
1256
+ const isPublishableName = (value) => typeof value === "string" && value.trim().length > EMPTY$1;
1257
+ const resolveProjectType = (selected, inferredPackage) => {
1258
+ if (typeof selected !== "undefined") return selected;
1259
+ if (inferredPackage) return "package";
1260
+ return "application";
1261
+ };
1262
+ const resolvePackageJsonProjectType = (ctx) => {
1224
1263
  const pkg = ctx.packageJson;
1225
- if (typeof pkg === "undefined") return false;
1226
- return pkg.private !== true && typeof pkg.name === "string" && pkg.name.length > 0;
1264
+ const inferredPackage = pkg?.private !== true && isPublishableName(pkg?.name);
1265
+ return resolveProjectType(ctx.projectType, inferredPackage);
1227
1266
  };
1267
+ const resolveDenoProjectType = (ctx, denoConfig) => resolveProjectType(ctx.projectType, isPublishableName(getByPath(denoConfig, ["name"])));
1268
+
1269
+ //#endregion
1270
+ //#region src/domain/rules/publishable.ts
1271
+ const isPublishable = (ctx) => resolvePackageJsonProjectType(ctx) === "package";
1228
1272
 
1229
1273
  //#endregion
1230
1274
  //#region src/domain/rules/files-field.ts
@@ -1232,8 +1276,7 @@ const { packageJson: packageJson$1, denoJson: denoJson$3 } = CONFIG_FILES;
1232
1276
  const packageJsonFilesBinding = {
1233
1277
  check(ctx) {
1234
1278
  if (!isPublishable(ctx)) return { state: "na" };
1235
- let files = void 0;
1236
- if (ctx.packageJson) ({files} = ctx.packageJson);
1279
+ const files = ctx.packageJson?.files;
1237
1280
  if (Array.isArray(files) && files.length > 0) return { state: "ok" };
1238
1281
  return {
1239
1282
  actual: files,
@@ -1253,8 +1296,8 @@ const packageJsonFilesBinding = {
1253
1296
  fixKind: "advisory"
1254
1297
  };
1255
1298
  const denoPublishBinding = {
1256
- check(_ctx, config) {
1257
- if (typeof getByPath(config, ["name"]) !== "string") return { state: "na" };
1299
+ check(ctx, config) {
1300
+ if (resolveDenoProjectType(ctx, config) !== "package") return { state: "na" };
1258
1301
  const include = getByPath(config, ["publish", "include"]);
1259
1302
  if (Array.isArray(include) && include.length > 0) return { state: "ok" };
1260
1303
  return {
@@ -1274,7 +1317,7 @@ const denoPublishBinding = {
1274
1317
  },
1275
1318
  fixKind: "advisory"
1276
1319
  };
1277
- const filesField = {
1320
+ const filesField = defineRule({
1278
1321
  bindings: {
1279
1322
  aube: packageJsonFilesBinding,
1280
1323
  bun: packageJsonFilesBinding,
@@ -1286,9 +1329,10 @@ const filesField = {
1286
1329
  description: "An explicit `files` array in package.json restricts what gets published, preventing accidental inclusion of secrets or local files.",
1287
1330
  docs: "https://github.com/bodadotsh/npm-security-best-practices#11-review-published-files",
1288
1331
  id: "files-field",
1332
+ projectTypes: ["package"],
1289
1333
  severity: "info",
1290
1334
  title: "Declare a published files allow-list"
1291
- };
1335
+ });
1292
1336
 
1293
1337
  //#endregion
1294
1338
  //#region src/domain/rules/frozen-lockfile.ts
@@ -1394,14 +1438,14 @@ const pnpmBinding$3 = {
1394
1438
  },
1395
1439
  fixKind: "advisory"
1396
1440
  };
1397
- const frozenStore = {
1441
+ const frozenStore = defineRule({
1398
1442
  bindings: { pnpm: pnpmBinding$3 },
1399
1443
  description: "Recommend enabling frozenStore to prevent mutations to the content-addressable store, strengthening supply-chain integrity in CI and deploy environments.",
1400
1444
  docs: "https://pnpm.io/settings#frozenstore",
1401
1445
  id: "frozen-store",
1402
1446
  severity: "info",
1403
1447
  title: "Enable frozen store"
1404
- };
1448
+ });
1405
1449
 
1406
1450
  //#endregion
1407
1451
  //#region src/domain/rules/hardened-mode.ts
@@ -1438,12 +1482,22 @@ const RECOMMENDED_RELEASE_AGE_MINUTES = RECOMMENDED_RELEASE_AGE_DAYS * MINUTES_P
1438
1482
  const RECOMMENDED_RELEASE_AGE_SECONDS = RECOMMENDED_RELEASE_AGE_DAYS * SECONDS_PER_DAY;
1439
1483
  const DOCUMENTED_DEFAULT_MINUTES = MINUTES_PER_DAY;
1440
1484
  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;
1485
+ const isPositiveNumber = (value) => typeof value === "number" && value > 0;
1486
+ const DENO_DURATION = /^P(?=.*\d)(?:\d+(?:[.,]\d+)?Y)?(?:\d+(?:[.,]\d+)?M)?(?:\d+(?:[.,]\d+)?W)?(?:\d+(?:[.,]\d+)?D)?(?:T(?=\d)(?:\d+(?:[.,]\d+)?H)?(?:\d+(?:[.,]\d+)?M)?(?:\d+(?:[.,]\d+)?S)?)?$/u;
1487
+ const DENO_ZERO_DURATION = /^P(?=.*\d)(?:0+(?:[.,]0+)?[YMWD])*(?:T(?:0+(?:[.,]0+)?[HMS])*)?$/u;
1488
+ const DENO_DATE = /^\d{4}-\d{2}-\d{2}$/u;
1489
+ const DENO_RFC3339_TIMESTAMP = /^\d{4}-\d{2}-\d{2}[Tt](?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:[Zz]|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/u;
1490
+ const DENO_DATE_LENGTH = 10;
1443
1491
  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;
1492
+ if (typeof value === "number") return value > 0;
1493
+ if (typeof value === "string") {
1494
+ const isDuration = DENO_DURATION.test(value);
1495
+ const date = value.slice(0, DENO_DATE_LENGTH);
1496
+ const dateTimestamp = Date.parse(`${date}T00:00:00Z`);
1497
+ return isDuration && !DENO_ZERO_DURATION.test(value) || !isDuration && (DENO_DATE.test(value) || DENO_RFC3339_TIMESTAMP.test(value)) && !Number.isNaN(dateTimestamp) && new Date(dateTimestamp).toISOString().slice(0, date.length) === date && (DENO_DATE.test(value) || !Number.isNaN(Date.parse(value)));
1498
+ }
1499
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
1500
+ return !("age" in value) || isNonDisabledDenoDuration(value.age);
1447
1501
  };
1448
1502
  const minimumReleaseAge = requireConfigKey({
1449
1503
  bindings: {
@@ -1468,10 +1522,15 @@ const minimumReleaseAge = requireConfigKey({
1468
1522
  deno: {
1469
1523
  accept: isNonDisabledDenoDuration,
1470
1524
  docs: "https://docs.deno.com/runtime/reference/deno_json/",
1525
+ documentedDefault: DOCUMENTED_DEFAULT_MINUTES,
1471
1526
  file: denoJson$1,
1472
1527
  keyPath: ["minimumDependencyAge"],
1473
1528
  message: `Set minimumDependencyAge (e.g. "P3D" for a ${RECOMMENDED_RELEASE_AGE_DAYS}-day cooldown) in deno.json.`,
1474
- value: "P3D"
1529
+ value: "P3D",
1530
+ versionNote: {
1531
+ defaultSafeSince: "deno 2.9.0 (1440 minutes)",
1532
+ note: "object age optional since deno 2.9.4"
1533
+ }
1475
1534
  },
1476
1535
  npm: {
1477
1536
  accept: isPositiveNumber,
@@ -1541,14 +1600,14 @@ const pnpmBinding$2 = {
1541
1600
  },
1542
1601
  fixKind: "advisory"
1543
1602
  };
1544
- const namedRegistries = {
1603
+ const namedRegistries = defineRule({
1545
1604
  bindings: { pnpm: pnpmBinding$2 },
1546
1605
  description: "Flag named registry mappings that redirect package resolution to custom registries, which may enable dependency confusion attacks.",
1547
1606
  docs: "https://pnpm.io/settings#namedregistries",
1548
1607
  id: "named-registries",
1549
1608
  severity: "info",
1550
1609
  title: "Review named registry mappings"
1551
- };
1610
+ });
1552
1611
 
1553
1612
  //#endregion
1554
1613
  //#region src/domain/rules/paranoid-mode.ts
@@ -1593,14 +1652,14 @@ const pnpmBinding$1 = {
1593
1652
  },
1594
1653
  fixKind: "advisory"
1595
1654
  };
1596
- const patchedDependencies = {
1655
+ const patchedDependencies = defineRule({
1597
1656
  bindings: { pnpm: pnpmBinding$1 },
1598
1657
  description: "Flag patched dependencies whose source code is modified by local patch files, bypassing registry integrity verification.",
1599
1658
  docs: "https://pnpm.io/settings#patcheddependencies",
1600
1659
  id: "patched-dependencies",
1601
1660
  severity: "info",
1602
1661
  title: "Review patched dependencies"
1603
- };
1662
+ });
1604
1663
 
1605
1664
  //#endregion
1606
1665
  //#region src/domain/rules/pin-exact-versions.ts
@@ -1614,18 +1673,18 @@ const isConfigObject = (value) => !Array.isArray(value);
1614
1673
  */
1615
1674
  /** Wildcard segments (`1.x`, `1.*`, `1.2.X`) are ranges too. */
1616
1675
  const SPLIT_LIMIT_FIRST = 1;
1617
- const FIRST_ELEMENT = 0;
1618
1676
  const hasWildcardSegment = (spec) => {
1619
- return (spec.split("-", SPLIT_LIMIT_FIRST)[FIRST_ELEMENT] ?? spec).split(".").some((seg) => seg === "x" || seg === "X" || seg === "*");
1677
+ const [core = spec] = spec.split("-", SPLIT_LIMIT_FIRST);
1678
+ return core.split(".").some((seg) => seg === "x" || seg === "X" || seg === "*");
1620
1679
  };
1621
1680
  const RANGE_OPERATOR = /[\^~]|>=|<=|<|>|\|\||\s/u;
1622
- const isRangeSpec = (spec) => spec === "" || spec === "*" || RANGE_OPERATOR.test(spec) || hasWildcardSegment(spec);
1623
- const NO_AT_SIGN = 0;
1681
+ const INCOMPLETE_NUMERIC_VERSION = /^\d+(?:\.\d+)?(?:-|$)/u;
1682
+ const isRangeSpec = (spec) => spec === "" || spec === "*" || RANGE_OPERATOR.test(spec) || INCOMPLETE_NUMERIC_VERSION.test(spec) || hasWildcardSegment(spec);
1624
1683
  const AFTER_AT_SIGN = 1;
1625
1684
  const hasSemverRange = (specifier) => {
1626
1685
  if (specifier.startsWith("http://") || specifier.startsWith("https://")) return false;
1627
1686
  const at = specifier.lastIndexOf("@");
1628
- if (at <= NO_AT_SIGN) return false;
1687
+ if (at <= 0) return false;
1629
1688
  return isRangeSpec(specifier.slice(at + AFTER_AT_SIGN));
1630
1689
  };
1631
1690
  const collectRangedImports = (imports) => {
@@ -1634,9 +1693,8 @@ const collectRangedImports = (imports) => {
1634
1693
  return offenders;
1635
1694
  };
1636
1695
  const MAX_SAMPLE_COUNT = 3;
1637
- const EMPTY$2 = 0;
1638
1696
  const formatOffenders = (offenders) => {
1639
- const sample = offenders.slice(FIRST_ELEMENT, MAX_SAMPLE_COUNT).join(", ");
1697
+ const sample = offenders.slice(0, MAX_SAMPLE_COUNT).join(", ");
1640
1698
  let more = "";
1641
1699
  if (offenders.length > MAX_SAMPLE_COUNT) more = ` (and ${offenders.length - MAX_SAMPLE_COUNT} more)`;
1642
1700
  return {
@@ -1655,7 +1713,7 @@ const denoBinding = {
1655
1713
  const imports = extractImportsRecord(config);
1656
1714
  if (!imports) return OK;
1657
1715
  const offenders = collectRangedImports(imports);
1658
- if (offenders.length === EMPTY$2) return OK;
1716
+ if (offenders.length === 0) return OK;
1659
1717
  return formatOffenders(offenders);
1660
1718
  },
1661
1719
  docs: "https://docs.deno.com/runtime/reference/cli/add/",
@@ -1667,7 +1725,8 @@ const denoBinding = {
1667
1725
  op: "note"
1668
1726
  }];
1669
1727
  },
1670
- fixKind: "advisory"
1728
+ fixKind: "advisory",
1729
+ versionNote: { configAvailableSince: "deno 1.30.0" }
1671
1730
  };
1672
1731
  const baseRule = requireConfigKey({
1673
1732
  bindings: {
@@ -1751,6 +1810,7 @@ const provenance = requireConfigKey({
1751
1810
  description: "Provenance statements (via Sigstore) tie a release to its source and build, letting consumers verify it was not tampered with.",
1752
1811
  docs: "https://github.com/bodadotsh/npm-security-best-practices#10-generate-provenance-statements",
1753
1812
  id: "provenance",
1813
+ projectTypes: ["package"],
1754
1814
  severity: "warn",
1755
1815
  title: "Publish with provenance"
1756
1816
  });
@@ -1761,8 +1821,7 @@ const { packageJson } = CONFIG_FILES;
1761
1821
  const publishAccessBinding = {
1762
1822
  check(ctx) {
1763
1823
  if (!isPublishable(ctx)) return { state: "na" };
1764
- let access = void 0;
1765
- if (ctx.packageJson && ctx.packageJson.publishConfig) ({access} = ctx.packageJson.publishConfig);
1824
+ const access = ctx.packageJson?.publishConfig?.access;
1766
1825
  if (access === "public" || access === "restricted") return { state: "ok" };
1767
1826
  return {
1768
1827
  actual: access,
@@ -1781,7 +1840,7 @@ const publishAccessBinding = {
1781
1840
  },
1782
1841
  fixKind: "advisory"
1783
1842
  };
1784
- const publishAccess = {
1843
+ const publishAccess = defineRule({
1785
1844
  bindings: {
1786
1845
  aube: publishAccessBinding,
1787
1846
  bun: publishAccessBinding,
@@ -1792,9 +1851,10 @@ const publishAccess = {
1792
1851
  description: "Set `publishConfig.access` so a misconfigured scope or registry never accidentally publishes an internal package publicly.",
1793
1852
  docs: "https://github.com/bodadotsh/npm-security-best-practices#for-maintainers",
1794
1853
  id: "publish-access",
1854
+ projectTypes: ["package"],
1795
1855
  severity: "info",
1796
1856
  title: "Declare publish access explicitly"
1797
- };
1857
+ });
1798
1858
 
1799
1859
  //#endregion
1800
1860
  //#region src/domain/rules/store-server.ts
@@ -1820,14 +1880,14 @@ const pnpmBinding = {
1820
1880
  },
1821
1881
  fixKind: "advisory"
1822
1882
  };
1823
- const storeServer = {
1883
+ const storeServer = defineRule({
1824
1884
  bindings: { pnpm: pnpmBinding },
1825
1885
  description: "Flag use of an external store server process, which introduces a trust boundary where tampered packages could be served.",
1826
1886
  docs: "https://pnpm.io/settings#userunningStoreserver",
1827
1887
  id: "store-server",
1828
1888
  severity: "info",
1829
1889
  title: "Review store server usage"
1830
- };
1890
+ });
1831
1891
 
1832
1892
  //#endregion
1833
1893
  //#region src/domain/rules/strict-allow-scripts.ts
@@ -1913,48 +1973,39 @@ const trustPolicy = requireConfigKey({
1913
1973
 
1914
1974
  //#endregion
1915
1975
  //#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,
1976
+ const rules = [
1977
+ advisoryCheck,
1978
+ approvedGitRepos,
1979
+ auditSuppression,
1980
+ blockAutoInstall,
1981
+ blockExoticSubdeps,
1982
+ bunSecurityScanner,
1983
+ checksumVerification,
1984
+ commitLockfile,
1985
+ dependencyOverrides,
1986
+ disableLifecycleScripts,
1987
+ enforceStrictSsl,
1988
+ filesField,
1989
+ frozenLockfile,
1990
+ frozenStore,
1991
+ hardenedMode,
1992
+ minimumReleaseAge,
1993
+ namedRegistries,
1994
+ paranoidMode,
1995
+ patchedDependencies,
1996
+ pinExactVersions,
1937
1997
  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);
1998
+ publishAccess,
1999
+ storeServer,
2000
+ strictAllowScripts,
2001
+ strictReleaseAge,
2002
+ strictStoreIntegrity,
2003
+ trustPolicy
2004
+ ];
1946
2005
 
1947
2006
  //#endregion
1948
2007
  //#region src/domain/services/validate-rule-ids.ts
1949
2008
  /**
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
2009
  * Why both checks in one pass? `duplicates` must be detected against the
1959
2010
  * builtin set *before* `unknown` consults the merged (builtin ∪ custom)
1960
2011
  * set — otherwise a colliding customRule id silently widens the known
@@ -1989,7 +2040,6 @@ const validateRuleIds = (config, builtins, extraKnownIds = []) => {
1989
2040
 
1990
2041
  //#endregion
1991
2042
  //#region src/application/validate-config-rules.ts
1992
- const EMPTY$1 = 0;
1993
2043
  const SINGLE = 1;
1994
2044
  const throwUnknownIds = (unknown) => {
1995
2045
  let plural = "";
@@ -1999,7 +2049,7 @@ const throwUnknownIds = (unknown) => {
1999
2049
  const assertConfigRuleIdsKnown = (userConfig, programmaticRules) => {
2000
2050
  if (typeof userConfig === "undefined") return;
2001
2051
  const { unknown } = validateRuleIds(userConfig, rules, (programmaticRules ?? []).map((rule) => rule.id));
2002
- if (unknown.length > EMPTY$1) throwUnknownIds(unknown);
2052
+ if (unknown.length > 0) throwUnknownIds(unknown);
2003
2053
  };
2004
2054
 
2005
2055
  //#endregion
@@ -2014,7 +2064,7 @@ const assertConfigRuleIdsKnown = (userConfig, programmaticRules) => {
2014
2064
  * A malformed value reads as "absent" — EXCEPT `private`, which falls back to
2015
2065
  * `true`: a security tool must never flip a broken `private` into publishable.
2016
2066
  */
2017
- const [ABSENT] = [];
2067
+ const ABSENT = void 0;
2018
2068
  const optionalString = vb.fallback(vb.optional(vb.string()), ABSENT);
2019
2069
  const PackageJsonSchema = vb.looseObject({
2020
2070
  files: vb.fallback(vb.optional(vb.array(vb.string())), ABSENT),
@@ -2066,7 +2116,7 @@ const tryParseJson = (text) => {
2066
2116
  const parsePackageJson = (raw) => {
2067
2117
  return safeParsePackageJson(tryParseJson(raw.trim()));
2068
2118
  };
2069
- const createRepoContext = (root, fs = nodeFileSystem) => {
2119
+ const createRepoContext = (root, fs = nodeFileSystem, projectType) => {
2070
2120
  const readText = (relPath) => fs.readText(resolveIn(root, relPath));
2071
2121
  const exists = (relPath) => fs.exists(resolveIn(root, relPath));
2072
2122
  const raw = readText(asRelPath("package.json"));
@@ -2075,6 +2125,7 @@ const createRepoContext = (root, fs = nodeFileSystem) => {
2075
2125
  return {
2076
2126
  exists,
2077
2127
  packageJson,
2128
+ projectType,
2078
2129
  readText,
2079
2130
  root
2080
2131
  };
@@ -2089,8 +2140,9 @@ const CONFIG_NAMES = [
2089
2140
  "siro.config.js"
2090
2141
  ];
2091
2142
  const ConfigSchema = vb.strictObject({
2092
- customRules: vb.optional(vb.array(vb.custom(() => true))),
2143
+ customRules: vb.optional(vb.array(vb.custom(isRuleShape, "must be a structurally valid rule"))),
2093
2144
  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)"))),
2145
+ projectType: vb.optional(vb.picklist(PROJECT_TYPES)),
2094
2146
  reporters: vb.optional(vb.array(vb.custom(isReporterShape, "must be a { name, format } reporter"))),
2095
2147
  rules: vb.optional(vb.record(vb.string(), vb.union([vb.picklist(SEVERITIES), vb.literal("off")])))
2096
2148
  }, "unknown config key (check for a typo)");
@@ -2103,21 +2155,6 @@ const formatIssues = (issues) => issues.map((issue) => {
2103
2155
  if (keyPath) return `${keyPath}: ${issue.message}`;
2104
2156
  return issue.message;
2105
2157
  }).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
2158
  let loadCounter = 0;
2122
2159
  const INCREMENT = 1;
2123
2160
  const importConfig = (modulePath, name) => {
@@ -2146,10 +2183,9 @@ const validateSchema = (candidate, name) => {
2146
2183
  return result.output;
2147
2184
  };
2148
2185
  const rejectDuplicateCustomRuleIds = (config, name) => {
2149
- const EMPTY = 0;
2150
2186
  const SINGLE = 1;
2151
2187
  const { duplicates } = validateRuleIds(config, rules);
2152
- if (duplicates.length > EMPTY) {
2188
+ if (duplicates.length > 0) {
2153
2189
  let plural = "";
2154
2190
  if (duplicates.length > SINGLE) plural = "s";
2155
2191
  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).`);
@@ -2167,7 +2203,7 @@ const parseCandidate = (configPath, name) => importConfig(configPath, name).then
2167
2203
  const loadConfig = (cwd, fs = nodeFileSystem, nodeVersion = process.versions.node) => {
2168
2204
  const candidate = CONFIG_NAMES.find((name) => fs.exists(asAbsPath(path.join(cwd, name))));
2169
2205
  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.`));
2206
+ if (candidate.endsWith(".ts") && !isSupportedNodeVersion(nodeVersion)) return Promise.reject(new ConfigError(`${candidate} requires Node.js with native type stripping (${SUPPORTED_NODE_RANGE}); current is v${nodeVersion}. Rename the config to siro.config.mjs (plain JS) or upgrade Node.js.`));
2171
2207
  return parseCandidate(asAbsPath(path.join(cwd, candidate)), candidate);
2172
2208
  };
2173
2209
 
@@ -2198,10 +2234,9 @@ const throwCollisionError = (collisions) => {
2198
2234
  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
2235
  };
2200
2236
  const mergeProgrammaticRules = (builtins, programmatic, configCustom) => {
2201
- const EMPTY = 0;
2202
- if (typeof programmatic === "undefined" || programmatic.length === EMPTY) return builtins;
2237
+ if (typeof programmatic === "undefined" || programmatic.length === 0) return builtins;
2203
2238
  const collisions = detectCollisions(programmatic, new Set([...builtins.map((rule) => rule.id), ...(configCustom ?? []).map((rule) => rule.id)]));
2204
- if (collisions.size > EMPTY) throwCollisionError(collisions);
2239
+ if (collisions.size > 0) throwCollisionError(collisions);
2205
2240
  return [...builtins, ...programmatic];
2206
2241
  };
2207
2242
 
@@ -2235,19 +2270,8 @@ const buildDetectionSignals = () => {
2235
2270
  return out;
2236
2271
  };
2237
2272
  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
2273
  const addDeclaredPM = (ctx, found) => {
2249
- let declared = void 0;
2250
- if (ctx.packageJson) declared = ctx.packageJson.packageManager;
2274
+ const declared = ctx.packageJson?.packageManager;
2251
2275
  if (typeof declared === "string") {
2252
2276
  const pm = parsePackageManagerField(declared);
2253
2277
  if (typeof pm !== "undefined") found.add(pm);
@@ -2268,9 +2292,8 @@ const detectPMs = (ctx) => {
2268
2292
  const throwEmptyPMsError = (detected, opts, allowed) => {
2269
2293
  if (allowed) {
2270
2294
  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
2295
  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.`;
2296
+ if (detected.length > 0) msg = `Detected PMs (${detected.join(", ")}) do not match siro.config.ts pms (${allowed.join(", ")}). Adjust the config or remove the restriction.`;
2274
2297
  throw new UsageError(msg);
2275
2298
  }
2276
2299
  throw new UsageError(`No package manager detected. Pass --pm <${PMS.join("|")}> to be explicit.`);
@@ -2281,7 +2304,7 @@ const throwEmptyPMsError = (detected, opts, allowed) => {
2281
2304
  * needs. Throws `UsageError` for the two empty-set cases so the
2282
2305
  * caller never has to invent its own messaging.
2283
2306
  *
2284
- * Empty-set branches are usage errors, not silent npm fallback: see DECISIONS.md D08.
2307
+ * Empty-set branches are usage errors, not a silent npm fallback.
2285
2308
  */
2286
2309
  const computePMs = (ctx, opts) => {
2287
2310
  let detected = detectPMs(ctx);
@@ -2311,17 +2334,15 @@ const resolvePMs = (ctx, opts) => {
2311
2334
  * applyConfig protocol).
2312
2335
  */
2313
2336
  const resolveRuleSet = (userConfig, customRules) => {
2314
- let configCustomRules = void 0;
2315
- if (userConfig) configCustomRules = userConfig.customRules;
2337
+ const configCustomRules = userConfig?.customRules;
2316
2338
  const base = mergeProgrammaticRules(rules, customRules, configCustomRules);
2317
2339
  assertConfigRuleIdsKnown(userConfig, customRules);
2318
2340
  return applyConfig(base, userConfig);
2319
2341
  };
2320
2342
  const buildRunResult = (options, userConfig) => {
2321
2343
  const { cwd, fs, pm, customRules } = options;
2322
- const ctx = createRepoContext(cwd, fs);
2323
- let allowedPms = void 0;
2324
- if (userConfig) allowedPms = userConfig.pms;
2344
+ const ctx = createRepoContext(cwd, fs, options.projectType ?? userConfig?.projectType);
2345
+ const allowedPms = userConfig?.pms;
2325
2346
  return {
2326
2347
  ctx,
2327
2348
  pms: resolvePMs(ctx, {
@@ -2336,17 +2357,24 @@ const prepareRun = (options) => loadConfig(options.cwd, options.fs).then((userCo
2336
2357
 
2337
2358
  //#endregion
2338
2359
  //#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) {
2360
+ const appliesToProject = (rule, projectType) => typeof projectType === "undefined" || typeof rule.projectTypes === "undefined" || rule.projectTypes.some((candidate) => candidate === projectType);
2361
+ const resolveBindingProjectType = (ctx, pm, parseConfig) => {
2362
+ if (typeof ctx.projectType !== "undefined") return ctx.projectType;
2363
+ if (pm === "deno") {
2364
+ const { parsed } = parseConfig(ctx, CONFIG_FILES.denoJson);
2365
+ return resolveDenoProjectType(ctx, parsed);
2366
+ }
2367
+ return resolvePackageJsonProjectType(ctx);
2368
+ };
2369
+ const appliesToBinding = (rule, pm, opts) => {
2370
+ const { ctx, parseConfig } = opts;
2371
+ return typeof rule.projectTypes === "undefined" || appliesToProject(rule, resolveBindingProjectType(ctx, pm, parseConfig));
2372
+ };
2373
+ const evaluateRule = (rule, opts) => {
2374
+ const { ctx, pms, parseConfig, onViolation } = opts;
2375
+ for (const pm of pms) {
2348
2376
  const binding = rule.bindings[pm];
2349
- if (typeof binding !== "undefined") {
2377
+ if (typeof binding !== "undefined" && appliesToBinding(rule, pm, opts)) {
2350
2378
  const { parsed } = parseConfig(ctx, binding.file);
2351
2379
  const status = binding.check(ctx, parsed);
2352
2380
  if (status.state === "violation") onViolation({
@@ -2358,6 +2386,16 @@ const evaluateBindings = (opts) => {
2358
2386
  }
2359
2387
  }
2360
2388
  };
2389
+ /**
2390
+ * Iterate every applicable `(rule, pm)` binding, run its check, and hand
2391
+ * each violation to `onViolation`. Folding the loop into one place keeps
2392
+ * the rule-evaluation contract (parse once, run check, gate on violation)
2393
+ * in a single location so the call site (`runLint`) stays declarative.
2394
+ */
2395
+ const evaluateBindings = (opts) => {
2396
+ const { ruleSet } = opts;
2397
+ for (const rule of ruleSet) evaluateRule(rule, opts);
2398
+ };
2361
2399
 
2362
2400
  //#endregion
2363
2401
  //#region src/domain/services/parse-config-file.ts
@@ -2394,11 +2432,27 @@ const createConfigParser = (codecFor) => {
2394
2432
  */
2395
2433
  const decideSeverity = (status, binding, rule) => status.severity ?? binding.severity ?? rule.severity;
2396
2434
 
2435
+ //#endregion
2436
+ //#region src/domain/services/render-version-note.ts
2437
+ const buildVersionNoteParts = (versionNote) => {
2438
+ const parts = [];
2439
+ if (typeof versionNote.configAvailableSince !== "undefined") parts.push(`available since ${versionNote.configAvailableSince}`);
2440
+ if (typeof versionNote.defaultSafeSince !== "undefined") parts.push(`default safe since ${versionNote.defaultSafeSince}`);
2441
+ if (typeof versionNote.note !== "undefined") parts.push(versionNote.note);
2442
+ return parts;
2443
+ };
2444
+ const renderVersionNoteMessage = (message, versionNote) => {
2445
+ if (typeof versionNote === "undefined") return message;
2446
+ const parts = buildVersionNoteParts(versionNote);
2447
+ if (parts.length === 0) return message;
2448
+ return `${message} (${parts.join("; ")})`;
2449
+ };
2450
+
2397
2451
  //#endregion
2398
2452
  //#region src/application/run-lint.ts
2399
2453
  const EMPTY = 0;
2400
2454
  const resolveManualSteps = (raw) => {
2401
- if (typeof raw !== "undefined" && raw.length > EMPTY) return raw;
2455
+ if (typeof raw !== "undefined" && raw.length > 0) return raw;
2402
2456
  };
2403
2457
  const buildFinding = (visit, ctx) => {
2404
2458
  const { rule, pm, binding, status } = visit;
@@ -2414,9 +2468,9 @@ const buildFinding = (visit, ctx) => {
2414
2468
  expected: status.expected,
2415
2469
  file: filePath,
2416
2470
  fix,
2417
- fixable: binding.fixKind === "auto" && typeof manualSteps === "undefined",
2471
+ fixable: binding.fixKind === "auto" && typeof manualSteps === "undefined" && fix.length > EMPTY,
2418
2472
  manualSteps,
2419
- message: status.message,
2473
+ message: renderVersionNoteMessage(status.message, binding.versionNote),
2420
2474
  pm,
2421
2475
  ruleId: rule.id,
2422
2476
  severity
@@ -2451,14 +2505,27 @@ const runLint = (opts) => {
2451
2505
  //#endregion
2452
2506
  //#region src/application/commands/lint.ts
2453
2507
  const resolveReporter = (value, registry) => {
2454
- if (typeof value === "undefined") return registry.get("pretty");
2508
+ if (typeof value === "undefined") return registry.get(DEFAULT_REPORTER_NAME);
2455
2509
  if (typeof value === "string") return registry.get(value);
2456
2510
  if (!isReporterShape(value)) throw new UsageError("The 'reporter' option must be a name or a { name, format } object.");
2457
2511
  return value;
2458
2512
  };
2459
2513
  const validateEmbedderReporters = (reporters) => {
2514
+ if (typeof reporters !== "undefined" && !Array.isArray(reporters)) throw new UsageError("The 'reporters' option must be an array of reporter objects.");
2460
2515
  for (const rep of reporters ?? []) if (!isReporterShape(rep)) throw new UsageError("A reporter in 'reporters' needs a string 'name' and a 'format' function.");
2461
2516
  };
2517
+ const validateSelection = (value, isValid, errorMessage) => {
2518
+ if (typeof value !== "undefined" && (typeof value !== "string" || !isValid(value))) throw new UsageError(errorMessage);
2519
+ };
2520
+ const validateCustomRules = (customRules) => {
2521
+ if (typeof customRules !== "undefined" && (!Array.isArray(customRules) || !Array.from(customRules).every((rule) => isRuleShape(rule)))) throw new UsageError("The 'customRules' option must contain structurally valid rules.");
2522
+ };
2523
+ const validateLintOptions = (options) => {
2524
+ validateCustomRules(options.customRules);
2525
+ validateSelection(options.projectType, isProjectType, `Unknown project type: ${String(options.projectType)} (expected ${PROJECT_TYPES.join("|")})`);
2526
+ validateSelection(options.pm, isPM, `Unknown package manager: ${String(options.pm)} (expected one of: ${PMS.join(", ")})`);
2527
+ validateSelection(options.severity, isSeverity, `Invalid severity: ${String(options.severity)} (expected ${SEVERITIES.join("|")})`);
2528
+ };
2462
2529
  const resolveAndValidateReporter = (value, registry) => {
2463
2530
  const reporter = resolveReporter(value, registry);
2464
2531
  if (typeof reporter === "undefined") throw new UsageError(`Unknown reporter: ${String(value)} (available: ${registry.list().join(", ")})`);
@@ -2477,15 +2544,17 @@ const formatAndExit = (args) => {
2477
2544
  return exitCodeForLint(result, failThreshold);
2478
2545
  };
2479
2546
  /** `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 }) => {
2547
+ const lintCommand = async (options, io) => {
2548
+ validateLintOptions(options);
2549
+ const { userConfig, ctx, pms, ruleSet } = await prepareRun({
2550
+ customRules: options.customRules,
2551
+ cwd: options.cwd,
2552
+ fs: options.fs,
2553
+ pm: options.pm,
2554
+ projectType: options.projectType
2555
+ });
2486
2556
  validateEmbedderReporters(options.reporters);
2487
- let configReporters = [];
2488
- if (userConfig && userConfig.reporters) configReporters = userConfig.reporters;
2557
+ const configReporters = userConfig?.reporters ?? [];
2489
2558
  const registry = createRegistry([...options.reporters ?? [], ...configReporters]);
2490
2559
  return formatAndExit({
2491
2560
  ctx,
@@ -2495,7 +2564,7 @@ const lintCommand = (options, io) => prepareRun({
2495
2564
  reporter: resolveAndValidateReporter(options.reporter, registry),
2496
2565
  ruleSet
2497
2566
  });
2498
- });
2567
+ };
2499
2568
 
2500
2569
  //#endregion
2501
2570
  //#region src/adapters/node-io.ts
@@ -2505,5 +2574,5 @@ const nodeIO = {
2505
2574
  };
2506
2575
 
2507
2576
  //#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
2577
+ export { isSeverity as A, jsonReporter as C, PMS as D, isReporterShape as E, asAbsPath as F, asRelPath as I, UsageError as M, ConfigError as N, SEVERITIES as O, SiroError as P, prettyReporter as S, githubReporter as T, isSupportedNodeVersion as _, nodeFileSystem as a, JSON_REPORTER_NAME as b, requireConfigKey as c, exitCodeForLint as d, filterBySeverity as f, SUPPORTED_NODE_RANGE as g, isProjectType as h, detectPMs as i, wrapCodecError as j, isPM as k, CONFIG_FILES as l, PROJECT_TYPES as m, lintCommand as n, PM_SIGNALS as o, isNodeError as p, renderVersionNoteMessage as r, overrideBindings as s, nodeIO as t, getByPath as u, BUILTIN_REPORTER_NAMES as v, version as w, createRegistry as x, DEFAULT_REPORTER_NAME as y };
2578
+ //# sourceMappingURL=node-io-CUcMwlGH.mjs.map