@dependably/npm-check 1.8.0 → 1.10.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.
package/src/audit.js CHANGED
@@ -4,8 +4,8 @@ import path from 'path';
4
4
  import { forEachPackageEntry, detectLockfileFlavor } from './format-library.js';
5
5
  import { validatePackageLock } from './validator.js';
6
6
  import { validatePackageJson } from './package-json-validator.js';
7
- import { validateNpmrc, NPMRC_SECURITY_CODES } from './npmrc-validator.js';
8
- import { validatePnpmWorkspace } from './pnpm-workspace-validator.js';
7
+ import { validateNpmrc, parseNpmrc, NPMRC_SECURITY_CODES } from './npmrc-validator.js';
8
+ import { validatePnpmWorkspace, parsePnpmWorkspace } from './pnpm-workspace-validator.js';
9
9
  import { isPlaceholder } from './integrity.js';
10
10
  import { classifyRange } from './pinner.js';
11
11
  import { walkOverrides } from './overrides.js';
@@ -666,6 +666,187 @@ const validPnpmFieldRule = {
666
666
  }
667
667
  };
668
668
 
669
+ /**
670
+ * Lockfile portability: every `resolved` URL must point at a host this project
671
+ * pins to.
672
+ *
673
+ * This is deliberately NOT the same question as `secure-resolved` /
674
+ * `no-remote-deps`, which both consult `allowedRegistryHosts` to ask "is this
675
+ * host a legitimate, trusted registry?". Trust and portability are orthogonal:
676
+ * an org's own private mirror is entirely trusted, yet a lockfile resolving
677
+ * from it cannot be installed by anyone outside that network (a public CI
678
+ * runner, an external contributor, a GitHub build). A shared
679
+ * `allowedRegistryHosts` also unions across config levels, so it can only ever
680
+ * grow more permissive — correct for a trust allowlist, but useless as a pin,
681
+ * which must be able to narrow.
682
+ *
683
+ * Opt-in: with no `hosts` configured the rule is a no-op, so projects that
684
+ * genuinely install from a private registry are unaffected.
685
+ */
686
+ // Trim/lowercase the configured pin list, dropping non-string and blank entries.
687
+ // An empty result means the rule is unconfigured, i.e. off.
688
+ function normalizePinnedHosts(hosts) {
689
+ if (!Array.isArray(hosts)) return [];
690
+ return hosts.filter((h) => typeof h === 'string' && h.trim()).map((h) => h.trim().toLowerCase());
691
+ }
692
+
693
+ // Git/file/link/workspace entries resolve outside the registry by definition —
694
+ // no-git-deps / secure-resolved own those.
695
+ function resolvesOutsideRegistry({ isRoot, isWorkspaceSource, isLink, isGitDep, isFileDep }) {
696
+ return Boolean(isRoot || isWorkspaceSource || isLink || isGitDep || isFileDep);
697
+ }
698
+
699
+ // Hostname of an entry's http(s) `resolved` URL, or null when it has none or the
700
+ // URL is unparseable (secure-resolved flags that; not this rule's job).
701
+ function resolvedHostname(entry) {
702
+ const resolved = entry && entry.resolved;
703
+ if (!resolved || !/^https?:/i.test(resolved)) return null;
704
+ try {
705
+ return new URL(resolved).hostname.toLowerCase();
706
+ } catch {
707
+ return null;
708
+ }
709
+ }
710
+
711
+ const resolvedRegistryPinRule = {
712
+ id: 'resolved-registry-pin',
713
+ description: 'Resolved URLs must point only at the registry hosts this project pins to',
714
+ defaultSeverity: 'error',
715
+ // pnpm lockfiles carry no `resolved` URLs (the registry is implied by config),
716
+ // so there is nothing to pin.
717
+ flavors: ['npm'],
718
+ check({ lockfile, options }) {
719
+ const findings = [];
720
+ // Unconfigured == off. Pinning is a per-project decision, not a default.
721
+ const pinned = normalizePinnedHosts(options.hosts);
722
+ if (pinned.length === 0 || !lockfile.packages) return findings;
723
+
724
+ forEachPackageEntry(lockfile, (packageEntry) => {
725
+ if (resolvesOutsideRegistry(packageEntry)) return;
726
+ const hostname = resolvedHostname(packageEntry.entry);
727
+ if (hostname === null || pinned.includes(hostname)) return;
728
+ const { key, name } = packageEntry;
729
+ findings.push({
730
+ packagePath: key,
731
+ message: `${name || key} resolves from "${hostname}", which is not a pinned registry host (${pinned.join(', ')}) — the lockfile will not install where that host is unreachable`
732
+ });
733
+ });
734
+ return findings;
735
+ }
736
+ };
737
+
738
+ // Supply-chain "cooldown": refuse to install versions published less than N ago.
739
+ // A compromised maintainer account's malicious release is typically detected and
740
+ // unpublished within hours, so a cooldown means you simply never resolve it.
741
+ //
742
+ // The two package managers disagree on BOTH key name and unit, which is the whole
743
+ // reason this normalizes to days before comparing:
744
+ // npm >= 11.10 : `.npmrc` min-release-age (DAYS)
745
+ // pnpm >= 10.16 : `pnpm-workspace.yaml` minimumReleaseAge (MINUTES)
746
+ // pnpm reads only auth/registry settings from .npmrc, so its cooldown is never
747
+ // there; npm has no equivalent yaml, so its cooldown is never in the workspace file.
748
+ const MINUTES_PER_DAY = 1440;
749
+
750
+ // Configured cooldown in DAYS for an npm project, or null when unset/unparseable.
751
+ function npmCooldownDays(npmrcPath) {
752
+ let content;
753
+ try {
754
+ content = fs.readFileSync(npmrcPath, 'utf8');
755
+ } catch {
756
+ return null; // no committed .npmrc → no committed policy
757
+ }
758
+ // parseNpmrc yields an entry list, keys lowercased; last assignment wins (ini).
759
+ const entry = parseNpmrc(content).filter((e) => e.key === 'min-release-age').pop();
760
+ if (!entry) return null;
761
+ const days = Number(entry.value);
762
+ return Number.isFinite(days) ? days : null;
763
+ }
764
+
765
+ // Configured cooldown in DAYS for a pnpm project, or null when unset/unparseable.
766
+ function pnpmCooldownDays(workspacePath) {
767
+ let content;
768
+ try {
769
+ content = fs.readFileSync(workspacePath, 'utf8');
770
+ } catch {
771
+ return null;
772
+ }
773
+ let doc;
774
+ try {
775
+ doc = parsePnpmWorkspace(content);
776
+ } catch {
777
+ return null; // malformed YAML — valid-pnpm-workspace owns that finding
778
+ }
779
+ const raw = doc && doc.minimumReleaseAge;
780
+ if (raw === undefined) return null;
781
+ const minutes = Number(raw);
782
+ return Number.isFinite(minutes) ? minutes / MINUTES_PER_DAY : null;
783
+ }
784
+
785
+ // A blanket exclusion silently voids the policy, so it is worth its own finding.
786
+ function blanketExclusions(workspacePath) {
787
+ let doc;
788
+ try {
789
+ doc = parsePnpmWorkspace(fs.readFileSync(workspacePath, 'utf8'));
790
+ } catch {
791
+ return [];
792
+ }
793
+ const list = doc && doc.minimumReleaseAgeExclude;
794
+ if (!Array.isArray(list)) return [];
795
+ return list.filter((p) => typeof p === 'string' && (p === '*' || p === '**'));
796
+ }
797
+
798
+ const minReleaseAgeRule = {
799
+ id: 'min-release-age',
800
+ description: 'A minimum release-age cooldown must be configured, so a freshly published (possibly compromised) version is never installed',
801
+ defaultSeverity: 'warn',
802
+ flavors: ['npm', 'pnpm'],
803
+ check({ filePath, options, flavor }) {
804
+ const { minDays = 3 } = options;
805
+ const dir = path.dirname(path.resolve(filePath));
806
+ const isPnpm = flavor === 'pnpm';
807
+ const configFile = isPnpm ? 'pnpm-workspace.yaml' : '.npmrc';
808
+ // `npmrcPath` lets a caller point at a .npmrc outside the lockfile's dir
809
+ // (same option valid-npmrc takes); it is meaningless on the pnpm path.
810
+ const npmrcPath = options.npmrcPath ? path.resolve(options.npmrcPath) : path.join(dir, '.npmrc');
811
+ const configPath = isPnpm ? path.join(dir, 'pnpm-workspace.yaml') : npmrcPath;
812
+
813
+ const configured = isPnpm ? pnpmCooldownDays(configPath) : npmCooldownDays(configPath);
814
+ const setting = isPnpm ? 'minimumReleaseAge' : 'min-release-age';
815
+ const findings = [];
816
+
817
+ if (configured === null) {
818
+ const unit = isPnpm ? `${minDays * MINUTES_PER_DAY} (minutes)` : `${minDays} (days)`;
819
+ findings.push({
820
+ packagePath: configFile,
821
+ message: `no release-age cooldown configured — set "${setting}" to at least ${unit} in ${configFile} so a version published moments ago is never installed`
822
+ });
823
+ } else if (configured < minDays) {
824
+ findings.push({
825
+ packagePath: configFile,
826
+ message: `release-age cooldown is ${formatDays(configured)}, below the required minimum of ${formatDays(minDays)} ("${setting}" in ${configFile})`
827
+ });
828
+ }
829
+
830
+ if (isPnpm) {
831
+ for (const pattern of blanketExclusions(configPath)) {
832
+ findings.push({
833
+ packagePath: configFile,
834
+ message: `"minimumReleaseAgeExclude" contains the blanket pattern "${pattern}", which exempts every package and voids the cooldown`
835
+ });
836
+ }
837
+ }
838
+ return findings;
839
+ }
840
+ };
841
+
842
+ // Render a day count the way a reader configures it: whole days, or minutes when
843
+ // the value is under a day (which is how a pnpm `minimumReleaseAge` will land here).
844
+ function formatDays(days) {
845
+ if (days >= 1) return `${Number.isInteger(days) ? days : days.toFixed(2)} day${days === 1 ? '' : 's'}`;
846
+ const minutes = Math.round(days * MINUTES_PER_DAY);
847
+ return `${minutes} minute${minutes === 1 ? '' : 's'}`;
848
+ }
849
+
669
850
  export const rules = [
670
851
  lockfileVersionRule,
671
852
  validStructureRule,
@@ -675,6 +856,7 @@ export const rules = [
675
856
  installScriptsRule,
676
857
  noGitDepsRule,
677
858
  noRemoteDepsRule,
859
+ resolvedRegistryPinRule,
678
860
  pinnedVersionsRule,
679
861
  lockfileSyncRule,
680
862
  noOrphanPackagesRule,
@@ -682,7 +864,8 @@ export const rules = [
682
864
  noFundRule,
683
865
  validNpmrcRule,
684
866
  validPnpmWorkspaceRule,
685
- validPnpmFieldRule
867
+ validPnpmFieldRule,
868
+ minReleaseAgeRule
686
869
  ];
687
870
 
688
871
  /**
@@ -810,30 +993,45 @@ export function runAudit(target, config = {}) {
810
993
  * @param {object} options - { format: 'stylish' | 'json' }
811
994
  * @returns {string}
812
995
  */
813
- export function formatAuditReport(report, options = {}) {
814
- const { format = 'stylish', showSuppressed = false } = options;
815
- const suppressed = report.suppressed || [];
996
+ // Machine-readable half of formatAuditReport.
997
+ function formatAuditJson(report, suppressed) {
998
+ const meta = report.exceptionsMeta || {};
999
+ return JSON.stringify({
1000
+ filePath: report.filePath,
1001
+ pass: report.pass,
1002
+ summary: report.summary,
1003
+ findings: report.findings,
1004
+ suppressed,
1005
+ exceptionsMeta: {
1006
+ unused: (meta.unused || []).map((e) => e._raw),
1007
+ expired: (meta.expired || []).map((e) => e._raw)
1008
+ }
1009
+ }, null, 2);
1010
+ }
816
1011
 
817
- if (format === 'json') {
818
- return JSON.stringify({
819
- filePath: report.filePath,
820
- pass: report.pass,
821
- summary: report.summary,
822
- findings: report.findings,
823
- suppressed,
824
- exceptionsMeta: {
825
- unused: (report.exceptionsMeta && report.exceptionsMeta.unused || []).map((e) => e._raw),
826
- expired: (report.exceptionsMeta && report.exceptionsMeta.expired || []).map((e) => e._raw)
827
- }
828
- }, null, 2);
829
- }
1012
+ // One aligned `severity rule path message` line per finding.
1013
+ function findingLines(findings) {
1014
+ const ruleWidth = Math.max(...findings.map((f) => f.ruleId.length));
1015
+ return findings.map((finding) => {
1016
+ const sev = finding.severity === 'error' ? 'error' : 'warn ';
1017
+ const loc = finding.packagePath ? `${finding.packagePath} ` : '';
1018
+ return ` ${sev} ${finding.ruleId.padEnd(ruleWidth)} ${loc}${finding.message}`;
1019
+ });
1020
+ }
830
1021
 
831
- if (format !== 'stylish') {
832
- throw new AuditError(`Unknown report format: ${format}`, 'UNKNOWN_FORMAT');
833
- }
1022
+ // The closing `N problems (E errors, W warnings)` tally.
1023
+ function totalsLine(summary, suppressedCount) {
1024
+ const { errors, warnings, total } = summary;
1025
+ const problemWord = total === 1 ? 'problem' : 'problems';
1026
+ const errorWord = errors === 1 ? 'error' : 'errors';
1027
+ const warningWord = warnings === 1 ? 'warning' : 'warnings';
1028
+ const suffix = suppressedCount > 0 ? ` — ${suppressedCount} suppressed by .dependably` : '';
1029
+ return `${total} ${problemWord} (${errors} ${errorWord}, ${warnings} ${warningWord})${suffix}`;
1030
+ }
834
1031
 
835
- const lines = [];
836
- lines.push(report.filePath);
1032
+ // Human-readable (ESLint-like) half of formatAuditReport.
1033
+ function formatAuditStylish(report, suppressed, showSuppressed) {
1034
+ const lines = [report.filePath];
837
1035
 
838
1036
  if (report.findings.length === 0) {
839
1037
  lines.push(suppressed.length > 0 ? ` no problems found (${suppressed.length} suppressed by .dependably)` : ' no problems found');
@@ -841,22 +1039,24 @@ export function formatAuditReport(report, options = {}) {
841
1039
  return lines.join('\n');
842
1040
  }
843
1041
 
844
- const ruleWidth = Math.max(...report.findings.map((f) => f.ruleId.length));
845
- for (const finding of report.findings) {
846
- const sev = finding.severity === 'error' ? 'error' : 'warn ';
847
- const loc = finding.packagePath ? `${finding.packagePath} ` : '';
848
- lines.push(` ${sev} ${finding.ruleId.padEnd(ruleWidth)} ${loc}${finding.message}`);
849
- }
850
-
851
- const { errors, warnings, total } = report.summary;
852
- const problemWord = total === 1 ? 'problem' : 'problems';
853
- const suffix = suppressed.length > 0 ? ` — ${suppressed.length} suppressed by .dependably` : '';
1042
+ lines.push(...findingLines(report.findings));
854
1043
  lines.push('');
855
- lines.push(`${total} ${problemWord} (${errors} error${errors === 1 ? '' : 's'}, ${warnings} warning${warnings === 1 ? '' : 's'})${suffix}`);
1044
+ lines.push(totalsLine(report.summary, suppressed.length));
856
1045
  appendSuppressed(lines, suppressed, showSuppressed);
857
1046
  return lines.join('\n');
858
1047
  }
859
1048
 
1049
+ export function formatAuditReport(report, options = {}) {
1050
+ const { format = 'stylish', showSuppressed = false } = options;
1051
+ const suppressed = report.suppressed || [];
1052
+
1053
+ if (format === 'json') return formatAuditJson(report, suppressed);
1054
+ if (format !== 'stylish') {
1055
+ throw new AuditError(`Unknown report format: ${format}`, 'UNKNOWN_FORMAT');
1056
+ }
1057
+ return formatAuditStylish(report, suppressed, showSuppressed);
1058
+ }
1059
+
860
1060
  // Optionally list the suppressed findings (with their exception reason) below the
861
1061
  // summary, so `--show-suppressed` keeps the audit trail visible.
862
1062
  function appendSuppressed(lines, suppressed, showSuppressed) {
package/src/exceptions.js CHANGED
@@ -1,9 +1,10 @@
1
1
  // src/exceptions.js
2
2
  //
3
- // Reference implementation of the `.dependably` exception grammar
4
- // (docs/dependably-config-spec.md §6). This is the module the C# and Python
5
- // ports mirror; keep it behavior-compatible with the conformance fixtures under
6
- // conformance/dependably/.
3
+ // Reference implementation of the `.dependably` exception grammar, specified in
4
+ // §6 of the config spec at
5
+ // https://gitlab.northwardlabs.ca/moonlitlabs/dependably-spec. This is the module
6
+ // the C# and Python ports mirror; keep it behavior-compatible with the vendored
7
+ // conformance fixtures under conformance/dependably/.
7
8
  //
8
9
  // An exception suppresses SPECIFIC findings so a run does not fail on them,
9
10
  // without excluding whole files (`exclude`) or disabling a rule globally
@@ -109,89 +110,106 @@ function splitPackageSelector(pkg) {
109
110
  * @param {string} [opts.configPath]
110
111
  * @returns {Array<{rule, reason, expires, source, selectors, _raw}>}
111
112
  */
112
- export function parseExceptions(raw, opts = {}) {
113
- const { source = 'own', applicableSelectors = SELECTORS, knownRules = null, configPath = null } = opts;
114
- const entries = ensureArray(raw, { configPath });
115
- const out = [];
113
+ // An exception entry must be an object carrying non-empty `rule` and `reason`.
114
+ function validateExceptionShape(entry, index, at) {
115
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
116
+ throw new ExceptionConfigError(`exception #${index} must be an object`, 'INVALID_EXCEPTIONS', at);
117
+ }
118
+ if (typeof entry.rule !== 'string' || entry.rule.trim() === '') {
119
+ throw new ExceptionConfigError(`exception #${index} is missing "rule"`, 'EXCEPTION_MISSING_RULE', at);
120
+ }
121
+ if (typeof entry.reason !== 'string' || entry.reason.trim() === '') {
122
+ throw new ExceptionConfigError(
123
+ `exception for rule "${entry.rule}" is missing a non-empty "reason"`,
124
+ 'EXCEPTION_MISSING_REASON',
125
+ at
126
+ );
127
+ }
128
+ }
116
129
 
117
- entries.forEach((entry, index) => {
118
- const at = { configPath, source, index };
119
- if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
120
- throw new ExceptionConfigError(`exception #${index} must be an object`, 'INVALID_EXCEPTIONS', at);
121
- }
122
- if (typeof entry.rule !== 'string' || entry.rule.trim() === '') {
123
- throw new ExceptionConfigError(`exception #${index} is missing "rule"`, 'EXCEPTION_MISSING_RULE', at);
124
- }
125
- if (typeof entry.reason !== 'string' || entry.reason.trim() === '') {
130
+ // At least one selector, each a non-empty string. A selector this tool's findings
131
+ // never carry is an error in the tool's OWN section but is tolerated in `common`
132
+ // (it simply never matches).
133
+ function validateExceptionSelectors(entry, source, applicableSelectors, at) {
134
+ const present = SELECTORS.filter((s) => entry[s] !== undefined);
135
+ if (present.length === 0) {
136
+ throw new ExceptionConfigError(
137
+ `exception for rule "${entry.rule}" needs at least one selector (${SELECTORS.join(', ')})`,
138
+ 'EXCEPTION_NO_SELECTOR',
139
+ at
140
+ );
141
+ }
142
+ for (const sel of present) {
143
+ if (typeof entry[sel] !== 'string' || entry[sel].trim() === '') {
126
144
  throw new ExceptionConfigError(
127
- `exception for rule "${entry.rule}" is missing a non-empty "reason"`,
128
- 'EXCEPTION_MISSING_REASON',
145
+ `exception selector "${sel}" for rule "${entry.rule}" must be a non-empty string`,
146
+ 'EXCEPTION_BAD_SELECTOR',
129
147
  at
130
148
  );
131
149
  }
132
-
133
- const present = SELECTORS.filter((s) => entry[s] !== undefined);
134
- if (present.length === 0) {
150
+ if (source === 'own' && !applicableSelectors.includes(sel)) {
135
151
  throw new ExceptionConfigError(
136
- `exception for rule "${entry.rule}" needs at least one selector (${SELECTORS.join(', ')})`,
137
- 'EXCEPTION_NO_SELECTOR',
152
+ `exception selector "${sel}" is not applicable to this tool (applicable: ${applicableSelectors.join(', ')})`,
153
+ 'EXCEPTION_BAD_SELECTOR',
138
154
  at
139
155
  );
140
156
  }
141
- for (const sel of present) {
142
- if (typeof entry[sel] !== 'string' || entry[sel].trim() === '') {
143
- throw new ExceptionConfigError(
144
- `exception selector "${sel}" for rule "${entry.rule}" must be a non-empty string`,
145
- 'EXCEPTION_BAD_SELECTOR',
146
- at
147
- );
148
- }
149
- // A selector this tool's findings never carry is an error in the tool's
150
- // OWN section but is tolerated in `common` (it simply never matches).
151
- if (source === 'own' && !applicableSelectors.includes(sel)) {
152
- throw new ExceptionConfigError(
153
- `exception selector "${sel}" is not applicable to this tool (applicable: ${applicableSelectors.join(', ')})`,
154
- 'EXCEPTION_BAD_SELECTOR',
155
- at
156
- );
157
- }
158
- }
157
+ }
158
+ }
159
159
 
160
- if (entry.expires !== undefined) {
161
- if (typeof entry.expires !== 'string' || !EXPIRES_RE.test(entry.expires) || Number.isNaN(Date.parse(entry.expires))) {
162
- throw new ExceptionConfigError(
163
- `exception "expires" for rule "${entry.rule}" must be a valid YYYY-MM-DD date`,
164
- 'EXCEPTION_BAD_EXPIRES',
165
- at
166
- );
167
- }
168
- }
160
+ // `expires`, when present, must be a valid YYYY-MM-DD date.
161
+ function validateExceptionExpires(entry, at) {
162
+ if (entry.expires === undefined) return;
163
+ if (typeof entry.expires !== 'string' || !EXPIRES_RE.test(entry.expires) || Number.isNaN(Date.parse(entry.expires))) {
164
+ throw new ExceptionConfigError(
165
+ `exception "expires" for rule "${entry.rule}" must be a valid YYYY-MM-DD date`,
166
+ 'EXCEPTION_BAD_EXPIRES',
167
+ at
168
+ );
169
+ }
170
+ }
169
171
 
170
- if (source === 'own' && knownRules && !knownRules.includes(entry.rule)) {
171
- throw new ExceptionConfigError(
172
- `Unknown rule "${entry.rule}" in exception (known rules: ${knownRules.join(', ')})`,
173
- 'UNKNOWN_RULE',
174
- at
175
- );
176
- }
172
+ // An unknown rule id is an error in the tool's own section (spec §8); in `common`
173
+ // it belongs to a sibling tool and is tolerated.
174
+ function validateExceptionRule(entry, source, knownRules, at) {
175
+ if (source === 'own' && knownRules && !knownRules.includes(entry.rule)) {
176
+ throw new ExceptionConfigError(
177
+ `Unknown rule "${entry.rule}" in exception (known rules: ${knownRules.join(', ')})`,
178
+ 'UNKNOWN_RULE',
179
+ at
180
+ );
181
+ }
182
+ }
183
+
184
+ function buildExceptionSelectors(entry) {
185
+ const selectors = { rule: entry.rule };
186
+ if (entry.package !== undefined) selectors.package = splitPackageSelector(entry.package);
187
+ if (entry.path !== undefined) selectors.path = entry.path;
188
+ if (entry.symbol !== undefined) selectors.symbol = entry.symbol;
189
+ if (entry.id !== undefined) selectors.id = entry.id;
190
+ return selectors;
191
+ }
192
+
193
+ export function parseExceptions(raw, opts = {}) {
194
+ const { source = 'own', applicableSelectors = SELECTORS, knownRules = null, configPath = null } = opts;
195
+ const entries = ensureArray(raw, { configPath });
177
196
 
178
- const selectors = { rule: entry.rule };
179
- if (entry.package !== undefined) selectors.package = splitPackageSelector(entry.package);
180
- if (entry.path !== undefined) selectors.path = entry.path;
181
- if (entry.symbol !== undefined) selectors.symbol = entry.symbol;
182
- if (entry.id !== undefined) selectors.id = entry.id;
197
+ return entries.map((entry, index) => {
198
+ const at = { configPath, source, index };
199
+ validateExceptionShape(entry, index, at);
200
+ validateExceptionSelectors(entry, source, applicableSelectors, at);
201
+ validateExceptionExpires(entry, at);
202
+ validateExceptionRule(entry, source, knownRules, at);
183
203
 
184
- out.push({
204
+ return {
185
205
  rule: entry.rule,
186
206
  reason: entry.reason,
187
207
  expires: entry.expires || null,
188
208
  source,
189
- selectors,
209
+ selectors: buildExceptionSelectors(entry),
190
210
  _raw: entry
191
- });
211
+ };
192
212
  });
193
-
194
- return out;
195
213
  }
196
214
 
197
215
  // --- matching ---
@@ -235,8 +253,7 @@ export function matchException(exception, finding) {
235
253
  if (s.package !== undefined && !matchPackage(s.package, finding)) return false;
236
254
  if (s.path !== undefined && !matchGlob(s.path, finding.path)) return false;
237
255
  if (s.symbol !== undefined && !matchSymbol(s.symbol, finding.symbol)) return false;
238
- if (s.id !== undefined && s.id !== finding.id) return false;
239
- return true;
256
+ return !(s.id !== undefined && s.id !== finding.id);
240
257
  }
241
258
 
242
259
  /**