@dependably/npm-check 1.9.0 → 1.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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';
@@ -683,6 +683,31 @@ const validPnpmFieldRule = {
683
683
  * Opt-in: with no `hosts` configured the rule is a no-op, so projects that
684
684
  * genuinely install from a private registry are unaffected.
685
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
+
686
711
  const resolvedRegistryPinRule = {
687
712
  id: 'resolved-registry-pin',
688
713
  description: 'Resolved URLs must point only at the registry hosts this project pins to',
@@ -691,31 +716,16 @@ const resolvedRegistryPinRule = {
691
716
  // so there is nothing to pin.
692
717
  flavors: ['npm'],
693
718
  check({ lockfile, options }) {
694
- const { hosts = [] } = options;
695
719
  const findings = [];
696
720
  // Unconfigured == off. Pinning is a per-project decision, not a default.
697
- if (!Array.isArray(hosts) || hosts.length === 0) return findings;
698
- if (!lockfile.packages) return findings;
699
-
700
- const pinned = hosts
701
- .filter((h) => typeof h === 'string' && h.trim())
702
- .map((h) => h.trim().toLowerCase());
703
- if (pinned.length === 0) return findings;
704
-
705
- forEachPackageEntry(lockfile, ({ key, entry, name, isRoot, isWorkspaceSource, isLink, isGitDep, isFileDep }) => {
706
- // Git/file/link/workspace entries resolve outside the registry by
707
- // definition — no-git-deps / secure-resolved own those.
708
- if (isRoot || isWorkspaceSource || isLink || isGitDep || isFileDep) return;
709
- const resolved = entry && entry.resolved;
710
- if (!resolved || !/^https?:/i.test(resolved)) return;
711
- let hostname;
712
- try {
713
- hostname = new URL(resolved).hostname.toLowerCase();
714
- } catch {
715
- // Unparseable URL — secure-resolved flags it; not this rule's job.
716
- return;
717
- }
718
- if (pinned.includes(hostname)) return;
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;
719
729
  findings.push({
720
730
  packagePath: key,
721
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`
@@ -725,6 +735,118 @@ const resolvedRegistryPinRule = {
725
735
  }
726
736
  };
727
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
+
728
850
  export const rules = [
729
851
  lockfileVersionRule,
730
852
  validStructureRule,
@@ -742,7 +864,8 @@ export const rules = [
742
864
  noFundRule,
743
865
  validNpmrcRule,
744
866
  validPnpmWorkspaceRule,
745
- validPnpmFieldRule
867
+ validPnpmFieldRule,
868
+ minReleaseAgeRule
746
869
  ];
747
870
 
748
871
  /**
@@ -870,30 +993,45 @@ export function runAudit(target, config = {}) {
870
993
  * @param {object} options - { format: 'stylish' | 'json' }
871
994
  * @returns {string}
872
995
  */
873
- export function formatAuditReport(report, options = {}) {
874
- const { format = 'stylish', showSuppressed = false } = options;
875
- 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
+ }
876
1011
 
877
- if (format === 'json') {
878
- return JSON.stringify({
879
- filePath: report.filePath,
880
- pass: report.pass,
881
- summary: report.summary,
882
- findings: report.findings,
883
- suppressed,
884
- exceptionsMeta: {
885
- unused: (report.exceptionsMeta && report.exceptionsMeta.unused || []).map((e) => e._raw),
886
- expired: (report.exceptionsMeta && report.exceptionsMeta.expired || []).map((e) => e._raw)
887
- }
888
- }, null, 2);
889
- }
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
+ }
890
1021
 
891
- if (format !== 'stylish') {
892
- throw new AuditError(`Unknown report format: ${format}`, 'UNKNOWN_FORMAT');
893
- }
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
+ }
894
1031
 
895
- const lines = [];
896
- lines.push(report.filePath);
1032
+ // Human-readable (ESLint-like) half of formatAuditReport.
1033
+ function formatAuditStylish(report, suppressed, showSuppressed) {
1034
+ const lines = [report.filePath];
897
1035
 
898
1036
  if (report.findings.length === 0) {
899
1037
  lines.push(suppressed.length > 0 ? ` no problems found (${suppressed.length} suppressed by .dependably)` : ' no problems found');
@@ -901,22 +1039,24 @@ export function formatAuditReport(report, options = {}) {
901
1039
  return lines.join('\n');
902
1040
  }
903
1041
 
904
- const ruleWidth = Math.max(...report.findings.map((f) => f.ruleId.length));
905
- for (const finding of report.findings) {
906
- const sev = finding.severity === 'error' ? 'error' : 'warn ';
907
- const loc = finding.packagePath ? `${finding.packagePath} ` : '';
908
- lines.push(` ${sev} ${finding.ruleId.padEnd(ruleWidth)} ${loc}${finding.message}`);
909
- }
910
-
911
- const { errors, warnings, total } = report.summary;
912
- const problemWord = total === 1 ? 'problem' : 'problems';
913
- const suffix = suppressed.length > 0 ? ` — ${suppressed.length} suppressed by .dependably` : '';
1042
+ lines.push(...findingLines(report.findings));
914
1043
  lines.push('');
915
- lines.push(`${total} ${problemWord} (${errors} error${errors === 1 ? '' : 's'}, ${warnings} warning${warnings === 1 ? '' : 's'})${suffix}`);
1044
+ lines.push(totalsLine(report.summary, suppressed.length));
916
1045
  appendSuppressed(lines, suppressed, showSuppressed);
917
1046
  return lines.join('\n');
918
1047
  }
919
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
+
920
1060
  // Optionally list the suppressed findings (with their exception reason) below the
921
1061
  // summary, so `--show-suppressed` keeps the audit trail visible.
922
1062
  function appendSuppressed(lines, suppressed, showSuppressed) {
package/src/exceptions.js CHANGED
@@ -110,89 +110,106 @@ function splitPackageSelector(pkg) {
110
110
  * @param {string} [opts.configPath]
111
111
  * @returns {Array<{rule, reason, expires, source, selectors, _raw}>}
112
112
  */
113
- export function parseExceptions(raw, opts = {}) {
114
- const { source = 'own', applicableSelectors = SELECTORS, knownRules = null, configPath = null } = opts;
115
- const entries = ensureArray(raw, { configPath });
116
- 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
+ }
117
129
 
118
- entries.forEach((entry, index) => {
119
- const at = { configPath, source, index };
120
- if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
121
- throw new ExceptionConfigError(`exception #${index} must be an object`, 'INVALID_EXCEPTIONS', at);
122
- }
123
- if (typeof entry.rule !== 'string' || entry.rule.trim() === '') {
124
- throw new ExceptionConfigError(`exception #${index} is missing "rule"`, 'EXCEPTION_MISSING_RULE', at);
125
- }
126
- 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() === '') {
127
144
  throw new ExceptionConfigError(
128
- `exception for rule "${entry.rule}" is missing a non-empty "reason"`,
129
- 'EXCEPTION_MISSING_REASON',
145
+ `exception selector "${sel}" for rule "${entry.rule}" must be a non-empty string`,
146
+ 'EXCEPTION_BAD_SELECTOR',
130
147
  at
131
148
  );
132
149
  }
133
-
134
- const present = SELECTORS.filter((s) => entry[s] !== undefined);
135
- if (present.length === 0) {
150
+ if (source === 'own' && !applicableSelectors.includes(sel)) {
136
151
  throw new ExceptionConfigError(
137
- `exception for rule "${entry.rule}" needs at least one selector (${SELECTORS.join(', ')})`,
138
- 'EXCEPTION_NO_SELECTOR',
152
+ `exception selector "${sel}" is not applicable to this tool (applicable: ${applicableSelectors.join(', ')})`,
153
+ 'EXCEPTION_BAD_SELECTOR',
139
154
  at
140
155
  );
141
156
  }
142
- for (const sel of present) {
143
- if (typeof entry[sel] !== 'string' || entry[sel].trim() === '') {
144
- throw new ExceptionConfigError(
145
- `exception selector "${sel}" for rule "${entry.rule}" must be a non-empty string`,
146
- 'EXCEPTION_BAD_SELECTOR',
147
- at
148
- );
149
- }
150
- // A selector this tool's findings never carry is an error in the tool's
151
- // OWN section but is tolerated in `common` (it simply never matches).
152
- if (source === 'own' && !applicableSelectors.includes(sel)) {
153
- throw new ExceptionConfigError(
154
- `exception selector "${sel}" is not applicable to this tool (applicable: ${applicableSelectors.join(', ')})`,
155
- 'EXCEPTION_BAD_SELECTOR',
156
- at
157
- );
158
- }
159
- }
157
+ }
158
+ }
160
159
 
161
- if (entry.expires !== undefined) {
162
- if (typeof entry.expires !== 'string' || !EXPIRES_RE.test(entry.expires) || Number.isNaN(Date.parse(entry.expires))) {
163
- throw new ExceptionConfigError(
164
- `exception "expires" for rule "${entry.rule}" must be a valid YYYY-MM-DD date`,
165
- 'EXCEPTION_BAD_EXPIRES',
166
- at
167
- );
168
- }
169
- }
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
+ }
170
171
 
171
- if (source === 'own' && knownRules && !knownRules.includes(entry.rule)) {
172
- throw new ExceptionConfigError(
173
- `Unknown rule "${entry.rule}" in exception (known rules: ${knownRules.join(', ')})`,
174
- 'UNKNOWN_RULE',
175
- at
176
- );
177
- }
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 });
178
196
 
179
- const selectors = { rule: entry.rule };
180
- if (entry.package !== undefined) selectors.package = splitPackageSelector(entry.package);
181
- if (entry.path !== undefined) selectors.path = entry.path;
182
- if (entry.symbol !== undefined) selectors.symbol = entry.symbol;
183
- 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);
184
203
 
185
- out.push({
204
+ return {
186
205
  rule: entry.rule,
187
206
  reason: entry.reason,
188
207
  expires: entry.expires || null,
189
208
  source,
190
- selectors,
209
+ selectors: buildExceptionSelectors(entry),
191
210
  _raw: entry
192
- });
211
+ };
193
212
  });
194
-
195
- return out;
196
213
  }
197
214
 
198
215
  // --- matching ---
@@ -236,8 +253,7 @@ export function matchException(exception, finding) {
236
253
  if (s.package !== undefined && !matchPackage(s.package, finding)) return false;
237
254
  if (s.path !== undefined && !matchGlob(s.path, finding.path)) return false;
238
255
  if (s.symbol !== undefined && !matchSymbol(s.symbol, finding.symbol)) return false;
239
- if (s.id !== undefined && s.id !== finding.id) return false;
240
- return true;
256
+ return !(s.id !== undefined && s.id !== finding.id);
241
257
  }
242
258
 
243
259
  /**