@dependably/npm-check 1.7.0 → 1.7.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/README.md +1 -5
- package/bin/cli.js +52 -16
- package/package.json +1 -1
- package/src/audit-config.js +215 -62
- package/src/audit.js +73 -8
- package/src/exceptions.js +281 -0
- package/src/progress-reporter.js +33 -1
- package/src/report.js +261 -48
package/src/audit.js
CHANGED
|
@@ -12,6 +12,7 @@ import { walkOverrides } from './overrides.js';
|
|
|
12
12
|
import { findOrphanedPackages } from './pruner.js';
|
|
13
13
|
import { findUnusedDependencies } from './usage-scanner.js';
|
|
14
14
|
import { mergeConfig } from './audit-config.js';
|
|
15
|
+
import { matchException, isExpired } from './exceptions.js';
|
|
15
16
|
|
|
16
17
|
export class AuditError extends Error {
|
|
17
18
|
constructor(message, code, context = {}) {
|
|
@@ -723,6 +724,43 @@ function summarizeByRule(findings) {
|
|
|
723
724
|
return byRule;
|
|
724
725
|
}
|
|
725
726
|
|
|
727
|
+
// The package name an npm-check finding is about, from its lockfile path (the
|
|
728
|
+
// segment after the last `node_modules/`). Used to match `package` exceptions.
|
|
729
|
+
function deriveFindingPackage(packagePath) {
|
|
730
|
+
if (!packagePath) return undefined;
|
|
731
|
+
const marker = 'node_modules/';
|
|
732
|
+
const idx = packagePath.lastIndexOf(marker);
|
|
733
|
+
const tail = idx >= 0 ? packagePath.slice(idx + marker.length) : packagePath;
|
|
734
|
+
return tail || undefined;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// Partition audit findings by the resolved `.dependably` exceptions. Kept
|
|
738
|
+
// findings are returned untouched (same object refs); suppressed ones are copies
|
|
739
|
+
// stamped with `suppressed`/`suppressedBy`. Expired entries never suppress.
|
|
740
|
+
function applyAuditExceptions(findings, exceptions) {
|
|
741
|
+
if (!Array.isArray(exceptions) || exceptions.length === 0) {
|
|
742
|
+
return { kept: findings, suppressed: [], unused: [], expired: [] };
|
|
743
|
+
}
|
|
744
|
+
const live = [];
|
|
745
|
+
const expired = [];
|
|
746
|
+
for (const ex of exceptions) (isExpired(ex) ? expired : live).push(ex);
|
|
747
|
+
|
|
748
|
+
const used = new Set();
|
|
749
|
+
const kept = [];
|
|
750
|
+
const suppressed = [];
|
|
751
|
+
for (const finding of findings) {
|
|
752
|
+
const probe = { ruleId: finding.ruleId, package: deriveFindingPackage(finding.packagePath) };
|
|
753
|
+
const hit = live.find((ex) => matchException(ex, probe));
|
|
754
|
+
if (hit) {
|
|
755
|
+
used.add(hit);
|
|
756
|
+
suppressed.push({ ...finding, suppressed: true, suppressedBy: hit.reason });
|
|
757
|
+
} else {
|
|
758
|
+
kept.push(finding);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
return { kept, suppressed, unused: live.filter((ex) => !used.has(ex)), expired };
|
|
762
|
+
}
|
|
763
|
+
|
|
726
764
|
export function runAudit(target, config = {}) {
|
|
727
765
|
const { lockfile, packageJson = null, filePath = 'package-lock.json' } = target;
|
|
728
766
|
if (!lockfile || typeof lockfile !== 'object') {
|
|
@@ -732,7 +770,7 @@ export function runAudit(target, config = {}) {
|
|
|
732
770
|
const resolved = resolveAuditConfig(config);
|
|
733
771
|
const flavor = detectLockfileFlavor(lockfile);
|
|
734
772
|
|
|
735
|
-
const
|
|
773
|
+
const raw = [];
|
|
736
774
|
for (const rule of rules) {
|
|
737
775
|
// Flavor gating: a rule only runs against the lockfile flavors it supports
|
|
738
776
|
// (npm-shape rules no-op on pnpm-lock.yaml; pnpm rules no-op on npm).
|
|
@@ -742,9 +780,13 @@ export function runAudit(target, config = {}) {
|
|
|
742
780
|
if (!ruleConfig || ruleConfig.severity === 'off') continue;
|
|
743
781
|
|
|
744
782
|
const context = { lockfile, packageJson, options: ruleConfig.options || {}, filePath, flavor };
|
|
745
|
-
|
|
783
|
+
raw.push(...collectRuleFindings(rule, ruleConfig, context));
|
|
746
784
|
}
|
|
747
785
|
|
|
786
|
+
// Suppress findings named by `.dependably` exceptions: they no longer gate but
|
|
787
|
+
// are reported separately (spec §6). Kept findings drive errors/warnings/pass.
|
|
788
|
+
const { kept: findings, suppressed, unused, expired } = applyAuditExceptions(raw, config.exceptions);
|
|
789
|
+
|
|
748
790
|
const errors = findings.filter((f) => f.severity === 'error').length;
|
|
749
791
|
const warnings = findings.filter((f) => f.severity === 'warn').length;
|
|
750
792
|
const byRule = summarizeByRule(findings);
|
|
@@ -754,9 +796,11 @@ export function runAudit(target, config = {}) {
|
|
|
754
796
|
|
|
755
797
|
return {
|
|
756
798
|
findings,
|
|
757
|
-
|
|
799
|
+
suppressed,
|
|
800
|
+
summary: { errors, warnings, total: findings.length, byRule, suppressed: suppressed.length },
|
|
758
801
|
pass,
|
|
759
|
-
filePath
|
|
802
|
+
filePath,
|
|
803
|
+
exceptionsMeta: { unused, expired }
|
|
760
804
|
};
|
|
761
805
|
}
|
|
762
806
|
|
|
@@ -767,14 +811,20 @@ export function runAudit(target, config = {}) {
|
|
|
767
811
|
* @returns {string}
|
|
768
812
|
*/
|
|
769
813
|
export function formatAuditReport(report, options = {}) {
|
|
770
|
-
const { format = 'stylish' } = options;
|
|
814
|
+
const { format = 'stylish', showSuppressed = false } = options;
|
|
815
|
+
const suppressed = report.suppressed || [];
|
|
771
816
|
|
|
772
817
|
if (format === 'json') {
|
|
773
818
|
return JSON.stringify({
|
|
774
819
|
filePath: report.filePath,
|
|
775
820
|
pass: report.pass,
|
|
776
821
|
summary: report.summary,
|
|
777
|
-
findings: report.findings
|
|
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
|
+
}
|
|
778
828
|
}, null, 2);
|
|
779
829
|
}
|
|
780
830
|
|
|
@@ -786,7 +836,8 @@ export function formatAuditReport(report, options = {}) {
|
|
|
786
836
|
lines.push(report.filePath);
|
|
787
837
|
|
|
788
838
|
if (report.findings.length === 0) {
|
|
789
|
-
lines.push(' no problems found');
|
|
839
|
+
lines.push(suppressed.length > 0 ? ` no problems found (${suppressed.length} suppressed by .dependably)` : ' no problems found');
|
|
840
|
+
appendSuppressed(lines, suppressed, showSuppressed);
|
|
790
841
|
return lines.join('\n');
|
|
791
842
|
}
|
|
792
843
|
|
|
@@ -799,7 +850,21 @@ export function formatAuditReport(report, options = {}) {
|
|
|
799
850
|
|
|
800
851
|
const { errors, warnings, total } = report.summary;
|
|
801
852
|
const problemWord = total === 1 ? 'problem' : 'problems';
|
|
853
|
+
const suffix = suppressed.length > 0 ? ` — ${suppressed.length} suppressed by .dependably` : '';
|
|
802
854
|
lines.push('');
|
|
803
|
-
lines.push(`${total} ${problemWord} (${errors} error${errors === 1 ? '' : 's'}, ${warnings} warning${warnings === 1 ? '' : 's'})`);
|
|
855
|
+
lines.push(`${total} ${problemWord} (${errors} error${errors === 1 ? '' : 's'}, ${warnings} warning${warnings === 1 ? '' : 's'})${suffix}`);
|
|
856
|
+
appendSuppressed(lines, suppressed, showSuppressed);
|
|
804
857
|
return lines.join('\n');
|
|
805
858
|
}
|
|
859
|
+
|
|
860
|
+
// Optionally list the suppressed findings (with their exception reason) below the
|
|
861
|
+
// summary, so `--show-suppressed` keeps the audit trail visible.
|
|
862
|
+
function appendSuppressed(lines, suppressed, showSuppressed) {
|
|
863
|
+
if (!showSuppressed || suppressed.length === 0) return;
|
|
864
|
+
lines.push('');
|
|
865
|
+
lines.push('suppressed by .dependably:');
|
|
866
|
+
for (const f of suppressed) {
|
|
867
|
+
const loc = f.packagePath ? `${f.packagePath} ` : '';
|
|
868
|
+
lines.push(` ${f.ruleId} ${loc}${f.message} (${f.suppressedBy})`);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// src/exceptions.js
|
|
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/.
|
|
7
|
+
//
|
|
8
|
+
// An exception suppresses SPECIFIC findings so a run does not fail on them,
|
|
9
|
+
// without excluding whole files (`exclude`) or disabling a rule globally
|
|
10
|
+
// (`rules: {id: "off"}`). Each entry is:
|
|
11
|
+
//
|
|
12
|
+
// { rule, package?, path?, symbol?, id?, reason, expires? }
|
|
13
|
+
//
|
|
14
|
+
// `rule` + `reason` are mandatory; at least one selector is required; all
|
|
15
|
+
// selectors present on an entry must match a finding (AND within an entry,
|
|
16
|
+
// OR across entries). Suppressed findings are still counted and reported.
|
|
17
|
+
|
|
18
|
+
export class ExceptionConfigError extends Error {
|
|
19
|
+
constructor(message, code, context = {}) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = 'ExceptionConfigError';
|
|
22
|
+
this.code = code;
|
|
23
|
+
this.context = context;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// The four finding selectors, in a stable order for messages.
|
|
28
|
+
export const SELECTORS = ['package', 'path', 'symbol', 'id'];
|
|
29
|
+
|
|
30
|
+
const EXPIRES_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
31
|
+
|
|
32
|
+
// --- glob (portable subset: ** any depth, * within a segment, ? one char) ---
|
|
33
|
+
|
|
34
|
+
function globToRegExp(glob) {
|
|
35
|
+
let re = '';
|
|
36
|
+
let i = 0;
|
|
37
|
+
while (i < glob.length) {
|
|
38
|
+
const c = glob[i];
|
|
39
|
+
if (c === '*' && glob[i + 1] === '*') {
|
|
40
|
+
if (glob[i + 2] === '/') {
|
|
41
|
+
// `**/` — zero or more leading path segments.
|
|
42
|
+
re += '(?:.*/)?';
|
|
43
|
+
i += 3;
|
|
44
|
+
} else if (re.endsWith('/')) {
|
|
45
|
+
// `foo/**` at the end — match `foo` and `foo/anything`.
|
|
46
|
+
re = `${re.slice(0, -1)}(?:/.*)?`;
|
|
47
|
+
i += 2;
|
|
48
|
+
} else {
|
|
49
|
+
// bare `**` — any number of characters including separators.
|
|
50
|
+
re += '.*';
|
|
51
|
+
i += 2;
|
|
52
|
+
}
|
|
53
|
+
} else if (c === '*') {
|
|
54
|
+
// `*` — anything except a path separator.
|
|
55
|
+
re += '[^/]*';
|
|
56
|
+
i++;
|
|
57
|
+
} else if (c === '?') {
|
|
58
|
+
re += '[^/]';
|
|
59
|
+
i++;
|
|
60
|
+
} else {
|
|
61
|
+
re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
62
|
+
i++;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return new RegExp(`^${re}$`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Match a POSIX-style path against a portable glob (`**`, `*`, `?`). */
|
|
69
|
+
export function matchGlob(glob, value) {
|
|
70
|
+
if (typeof value !== 'string') return false;
|
|
71
|
+
return globToRegExp(glob).test(value.replace(/\\/g, '/'));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// --- parsing / validation ---
|
|
75
|
+
|
|
76
|
+
function ensureArray(raw, context) {
|
|
77
|
+
if (raw === undefined) return [];
|
|
78
|
+
if (!Array.isArray(raw)) {
|
|
79
|
+
throw new ExceptionConfigError(
|
|
80
|
+
'exceptions must be an array',
|
|
81
|
+
'INVALID_EXCEPTIONS',
|
|
82
|
+
context
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
return raw;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Split a `package` selector into { name, version } (version optional, from an
|
|
89
|
+
// `@<version>` suffix). Scoped names keep their leading `@`.
|
|
90
|
+
function splitPackageSelector(pkg) {
|
|
91
|
+
const at = pkg.lastIndexOf('@');
|
|
92
|
+
if (at > 0) {
|
|
93
|
+
return { name: pkg.slice(0, at).toLowerCase(), version: pkg.slice(at + 1) };
|
|
94
|
+
}
|
|
95
|
+
return { name: pkg.toLowerCase(), version: null };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Parse and validate a raw `exceptions` array into normalized entries.
|
|
100
|
+
*
|
|
101
|
+
* @param {*} raw - the raw `exceptions` value from a config section
|
|
102
|
+
* @param {object} opts
|
|
103
|
+
* @param {'own'|'common'} [opts.source] - `own` enforces selector applicability
|
|
104
|
+
* (§6.7); `common` tolerates selectors this tool never emits.
|
|
105
|
+
* @param {string[]} [opts.applicableSelectors] - selectors this tool's findings
|
|
106
|
+
* can carry (e.g. npm-check: ['package','id']). Required for `own`.
|
|
107
|
+
* @param {string[]} [opts.knownRules] - if given, an unknown `rule` in an `own`
|
|
108
|
+
* entry throws UNKNOWN_RULE (spec §8); in `common` it is tolerated.
|
|
109
|
+
* @param {string} [opts.configPath]
|
|
110
|
+
* @returns {Array<{rule, reason, expires, source, selectors, _raw}>}
|
|
111
|
+
*/
|
|
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 = [];
|
|
116
|
+
|
|
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() === '') {
|
|
126
|
+
throw new ExceptionConfigError(
|
|
127
|
+
`exception for rule "${entry.rule}" is missing a non-empty "reason"`,
|
|
128
|
+
'EXCEPTION_MISSING_REASON',
|
|
129
|
+
at
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const present = SELECTORS.filter((s) => entry[s] !== undefined);
|
|
134
|
+
if (present.length === 0) {
|
|
135
|
+
throw new ExceptionConfigError(
|
|
136
|
+
`exception for rule "${entry.rule}" needs at least one selector (${SELECTORS.join(', ')})`,
|
|
137
|
+
'EXCEPTION_NO_SELECTOR',
|
|
138
|
+
at
|
|
139
|
+
);
|
|
140
|
+
}
|
|
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
|
+
}
|
|
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
|
+
}
|
|
169
|
+
|
|
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
|
+
}
|
|
177
|
+
|
|
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;
|
|
183
|
+
|
|
184
|
+
out.push({
|
|
185
|
+
rule: entry.rule,
|
|
186
|
+
reason: entry.reason,
|
|
187
|
+
expires: entry.expires || null,
|
|
188
|
+
source,
|
|
189
|
+
selectors,
|
|
190
|
+
_raw: entry
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// --- matching ---
|
|
198
|
+
|
|
199
|
+
function toDate(today) {
|
|
200
|
+
if (today instanceof Date) return today;
|
|
201
|
+
if (typeof today === 'string') return new Date(`${today}T00:00:00Z`);
|
|
202
|
+
return new Date();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** True when an exception's `expires` date is strictly before `today`. */
|
|
206
|
+
export function isExpired(exception, today = new Date()) {
|
|
207
|
+
if (!exception.expires) return false;
|
|
208
|
+
const exp = new Date(`${exception.expires}T00:00:00Z`);
|
|
209
|
+
return toDate(today).getTime() > exp.getTime();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function matchPackage(sel, finding) {
|
|
213
|
+
const name = (finding.package || '').toLowerCase();
|
|
214
|
+
if (name !== sel.name) return false;
|
|
215
|
+
if (sel.version === null) return true;
|
|
216
|
+
return finding.version !== undefined && finding.version !== null && String(finding.version) === sel.version;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function matchSymbol(selSymbol, findingSymbol) {
|
|
220
|
+
if (typeof findingSymbol !== 'string') return false;
|
|
221
|
+
// `Type` matches `Type` and any `Type.Member`; `Type.Member` matches exactly.
|
|
222
|
+
return findingSymbol === selSymbol || findingSymbol.startsWith(`${selSymbol}.`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* True when every selector on `exception` matches `finding` (AND). Expiry is
|
|
227
|
+
* NOT consulted here — callers skip expired entries via isExpired().
|
|
228
|
+
*
|
|
229
|
+
* A finding is `{ rule|ruleId, package?, version?, path?, symbol?, id? }`.
|
|
230
|
+
*/
|
|
231
|
+
export function matchException(exception, finding) {
|
|
232
|
+
const s = exception.selectors;
|
|
233
|
+
const findingRule = finding.rule !== undefined ? finding.rule : finding.ruleId;
|
|
234
|
+
if (s.rule !== findingRule) return false;
|
|
235
|
+
if (s.package !== undefined && !matchPackage(s.package, finding)) return false;
|
|
236
|
+
if (s.path !== undefined && !matchGlob(s.path, finding.path)) return false;
|
|
237
|
+
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;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Partition findings by the exceptions, returning suppression bookkeeping.
|
|
244
|
+
*
|
|
245
|
+
* @param {Array} findings - normalized findings (see matchException)
|
|
246
|
+
* @param {Array} exceptions - parsed exceptions (from parseExceptions)
|
|
247
|
+
* @param {object} [opts]
|
|
248
|
+
* @param {Date|string} [opts.today] - clock for expiry (tests pass a fixed date)
|
|
249
|
+
* @returns {{
|
|
250
|
+
* kept: Array, // findings that still gate
|
|
251
|
+
* suppressed: Array, // findings matched by a live exception (each carries `suppressed:true` + `suppressedBy`)
|
|
252
|
+
* unused: Array, // exceptions that matched no finding
|
|
253
|
+
* expired: Array // exceptions past their `expires` date (never suppress)
|
|
254
|
+
* }}
|
|
255
|
+
*/
|
|
256
|
+
export function applyExceptions(findings, exceptions, opts = {}) {
|
|
257
|
+
const today = opts.today;
|
|
258
|
+
const live = [];
|
|
259
|
+
const expired = [];
|
|
260
|
+
for (const ex of exceptions) {
|
|
261
|
+
if (isExpired(ex, today)) expired.push(ex);
|
|
262
|
+
else live.push(ex);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const used = new Set();
|
|
266
|
+
const kept = [];
|
|
267
|
+
const suppressed = [];
|
|
268
|
+
|
|
269
|
+
for (const finding of findings) {
|
|
270
|
+
const hit = live.find((ex) => matchException(ex, finding));
|
|
271
|
+
if (hit) {
|
|
272
|
+
used.add(hit);
|
|
273
|
+
suppressed.push({ ...finding, suppressed: true, suppressedBy: hit.reason });
|
|
274
|
+
} else {
|
|
275
|
+
kept.push(finding);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const unused = live.filter((ex) => !used.has(ex));
|
|
280
|
+
return { kept, suppressed, unused, expired };
|
|
281
|
+
}
|
package/src/progress-reporter.js
CHANGED
|
@@ -237,9 +237,41 @@ export function createProgressBar(progress, width = 40) {
|
|
|
237
237
|
return `[${bar}] ${percentage}%`;
|
|
238
238
|
}
|
|
239
239
|
|
|
240
|
+
/**
|
|
241
|
+
* Decide what (if anything) a CLI progress reporter should write to the
|
|
242
|
+
* terminal for this update, given whether stdout is attached to a real TTY.
|
|
243
|
+
*
|
|
244
|
+
* On a TTY, redrawing an animated `\r` bar in place is fine — the terminal
|
|
245
|
+
* overwrites the previous frame. When stdout is NOT a TTY (piped, redirected,
|
|
246
|
+
* `tee`'d, CI logs) there is nowhere for `\r` to redraw: every frame lands as
|
|
247
|
+
* its own line and floods the log (a real run emitted ~35KB of redraw frames
|
|
248
|
+
* this way). So the non-TTY case degrades to periodic one-line milestones
|
|
249
|
+
* (0/25/50/75/100%) instead of an animated bar — callers should still write
|
|
250
|
+
* this to stderr so stdout stays report-only either way.
|
|
251
|
+
*
|
|
252
|
+
* @param {ProgressInfo} progress
|
|
253
|
+
* @param {object} state - Caller-owned, mutated in place across calls:
|
|
254
|
+
* `{ lastPercentage, lastMilestone }` (both start at -1/null).
|
|
255
|
+
* @param {boolean} isTTY - Whether the target stream is a real TTY.
|
|
256
|
+
* @returns {string|null} The line to write, or null to write nothing this update.
|
|
257
|
+
*/
|
|
258
|
+
export function formatCliProgressUpdate(progress, state, isTTY) {
|
|
259
|
+
if (!isTTY) {
|
|
260
|
+
const milestone = Math.floor(progress.percentage / 25) * 25;
|
|
261
|
+
if (milestone === state.lastMilestone) return null;
|
|
262
|
+
state.lastMilestone = milestone;
|
|
263
|
+
return `${progress.stage}: ${progress.percentage}%\n`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (state.lastPercentage === progress.percentage) return null;
|
|
267
|
+
state.lastPercentage = progress.percentage;
|
|
268
|
+
return `\r${createProgressBar(progress)} ${progress.stage}`;
|
|
269
|
+
}
|
|
270
|
+
|
|
240
271
|
export default {
|
|
241
272
|
ProgressReporter,
|
|
242
273
|
createProgressReporter,
|
|
243
274
|
formatProgress,
|
|
244
|
-
createProgressBar
|
|
275
|
+
createProgressBar,
|
|
276
|
+
formatCliProgressUpdate
|
|
245
277
|
};
|