swarmdo 1.19.0 → 1.27.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.
Files changed (32) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/v3/@swarmdo/cli/dist/src/apply/apply.d.ts +2 -0
  4. package/v3/@swarmdo/cli/dist/src/apply/apply.js +29 -2
  5. package/v3/@swarmdo/cli/dist/src/changelog/changelog.js +26 -3
  6. package/v3/@swarmdo/cli/dist/src/codegraph/codegraph.d.ts +26 -7
  7. package/v3/@swarmdo/cli/dist/src/codegraph/codegraph.js +97 -13
  8. package/v3/@swarmdo/cli/dist/src/codegraph/store.d.ts +8 -1
  9. package/v3/@swarmdo/cli/dist/src/codegraph/store.js +78 -1
  10. package/v3/@swarmdo/cli/dist/src/commands/cycles.js +4 -1
  11. package/v3/@swarmdo/cli/dist/src/commands/hotspots.js +5 -2
  12. package/v3/@swarmdo/cli/dist/src/commands/release.js +5 -1
  13. package/v3/@swarmdo/cli/dist/src/commands/testreport.js +2 -1
  14. package/v3/@swarmdo/cli/dist/src/cycles/cycles.d.ts +12 -2
  15. package/v3/@swarmdo/cli/dist/src/cycles/cycles.js +5 -3
  16. package/v3/@swarmdo/cli/dist/src/env/env.d.ts +5 -2
  17. package/v3/@swarmdo/cli/dist/src/env/env.js +24 -3
  18. package/v3/@swarmdo/cli/dist/src/hotspots/hotspots.d.ts +15 -0
  19. package/v3/@swarmdo/cli/dist/src/hotspots/hotspots.js +33 -1
  20. package/v3/@swarmdo/cli/dist/src/license/license.d.ts +31 -2
  21. package/v3/@swarmdo/cli/dist/src/license/license.js +133 -11
  22. package/v3/@swarmdo/cli/dist/src/pack/pack.d.ts +2 -3
  23. package/v3/@swarmdo/cli/dist/src/pack/pack.js +68 -12
  24. package/v3/@swarmdo/cli/dist/src/redact/redact.js +14 -2
  25. package/v3/@swarmdo/cli/dist/src/sbom/sbom.d.ts +16 -0
  26. package/v3/@swarmdo/cli/dist/src/sbom/sbom.js +28 -1
  27. package/v3/@swarmdo/cli/dist/src/testreport/testreport.d.ts +14 -0
  28. package/v3/@swarmdo/cli/dist/src/testreport/testreport.js +71 -7
  29. package/v3/@swarmdo/cli/dist/src/usage/claude-pricing.d.ts +6 -0
  30. package/v3/@swarmdo/cli/dist/src/usage/claude-pricing.js +12 -9
  31. package/v3/@swarmdo/cli/dist/src/usage/transcript-usage.js +4 -1
  32. package/v3/@swarmdo/cli/package.json +1 -1
@@ -28,6 +28,8 @@ const REF_PATTERNS = [
28
28
  /os\.environ\.get\(\s*['"]([^'"]+)['"]\s*\)/g,
29
29
  /os\.getenv\(\s*['"]([^'"]+)['"]\s*\)/g,
30
30
  ];
31
+ /** `const { A, B } = process.env` — the brace body (group 1) holds one or more refs. */
32
+ const DESTRUCTURE_RE = /\{\s*([^}]+)\}\s*=\s*process\.env\b(?![.[])/g;
31
33
  /** Non-secret prefixes on `import.meta.env` that are Vite builtins, not user vars. */
32
34
  const VITE_BUILTINS = new Set(['MODE', 'BASE_URL', 'PROD', 'DEV', 'SSR']);
33
35
  /** Extract env-var references from one source file. Pure. */
@@ -48,13 +50,32 @@ export function extractEnvRefs(source, file) {
48
50
  out.push({ key, file, line: i + 1 });
49
51
  }
50
52
  }
53
+ // Destructuring reads: `const { PORT, DB_URL } = process.env` (each property
54
+ // key is an env-var reference). The `(?![.[])` guard keeps it to the bare
55
+ // `process.env` object, not `= process.env.FOO`. Single-line, like the rest.
56
+ DESTRUCTURE_RE.lastIndex = 0;
57
+ let dm;
58
+ while ((dm = DESTRUCTURE_RE.exec(line)) !== null) {
59
+ for (const part of dm[1].split(',')) {
60
+ // The env-var name is the property key — left of a rename `:` or default `=`.
61
+ const key = part.trim().split(/[:=]/)[0].trim();
62
+ if (!/^[A-Za-z_$][\w$]*$/.test(key))
63
+ continue; // skip `...rest`, empties
64
+ if (VITE_BUILTINS.has(key))
65
+ continue;
66
+ out.push({ key, file, line: i + 1 });
67
+ }
68
+ }
51
69
  }
52
70
  return out;
53
71
  }
54
72
  /**
55
73
  * Parse a `.env` file into an ordered list of declared keys. Handles `KEY=val`,
56
- * `export KEY=val`, quoted values, `# comments`, and blank lines. Values are
57
- * ignored we only reconcile key presence. Pure.
74
+ * `export KEY=val`, quoted values, `# comments`, and blank lines. The key
75
+ * charset + `:` separator mirror the reference `dotenv` package's grammar
76
+ * (`[\w.-]+` with `=` or `: ` as separator), so dotted/hyphenated keys like
77
+ * `APP.NAME` aren't silently dropped. Values are ignored — we only reconcile
78
+ * key presence. Pure.
58
79
  */
59
80
  export function parseDotenv(text) {
60
81
  const keys = [];
@@ -63,7 +84,7 @@ export function parseDotenv(text) {
63
84
  const line = raw.trim();
64
85
  if (!line || line.startsWith('#'))
65
86
  continue;
66
- const m = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/);
87
+ const m = line.match(/^(?:export\s+)?([\w.-]+)(?:\s*=|:\s)/);
67
88
  if (!m)
68
89
  continue;
69
90
  if (seen.has(m[1]))
@@ -45,6 +45,21 @@ export interface HotspotOptions {
45
45
  /** keep only the top N after sorting (default: all) */
46
46
  top?: number;
47
47
  }
48
+ /**
49
+ * Resolve git's rename/copy numstat notation to the file's CURRENT (new) path.
50
+ *
51
+ * When rename/copy detection fires (git's default for merges, and often
52
+ * otherwise), `--numstat` doesn't emit a plain path. It emits either the
53
+ * compact braces form with a shared prefix/suffix around the changed run:
54
+ * `src/{old => new}/file.ts` · `{old => new}/file.ts` · `dir/{old => }/f`
55
+ * or, when there's no shared prefix/suffix, the full form:
56
+ * `old/path.ts => new/path.ts`
57
+ *
58
+ * We attribute churn to the file's current identity (the NEW path) so a renamed
59
+ * file's history folds into one entry instead of splitting across two phantom
60
+ * names. Plain paths pass through untouched. Pure — no git call.
61
+ */
62
+ export declare function resolveRenamePath(raw: string): string;
48
63
  /** Parse a control-char-delimited `git log --numstat` dump into commits. Pure. */
49
64
  export declare function parseGitLog(raw: string): Commit[];
50
65
  /** Aggregate commits into per-file hotspots, scored and ranked. Pure. */
@@ -17,6 +17,38 @@
17
17
  */
18
18
  const SOH = '\x01'; // start-of-commit marker (git --format=format:%x01...)
19
19
  const US = '\x1f'; // field separator (%x1f)
20
+ /**
21
+ * Resolve git's rename/copy numstat notation to the file's CURRENT (new) path.
22
+ *
23
+ * When rename/copy detection fires (git's default for merges, and often
24
+ * otherwise), `--numstat` doesn't emit a plain path. It emits either the
25
+ * compact braces form with a shared prefix/suffix around the changed run:
26
+ * `src/{old => new}/file.ts` · `{old => new}/file.ts` · `dir/{old => }/f`
27
+ * or, when there's no shared prefix/suffix, the full form:
28
+ * `old/path.ts => new/path.ts`
29
+ *
30
+ * We attribute churn to the file's current identity (the NEW path) so a renamed
31
+ * file's history folds into one entry instead of splitting across two phantom
32
+ * names. Plain paths pass through untouched. Pure — no git call.
33
+ */
34
+ export function resolveRenamePath(raw) {
35
+ const open = raw.indexOf('{');
36
+ if (open >= 0) {
37
+ const arrow = raw.indexOf(' => ', open);
38
+ const close = arrow >= 0 ? raw.indexOf('}', arrow) : -1;
39
+ if (arrow >= 0 && close >= 0) {
40
+ const prefix = raw.slice(0, open);
41
+ const newPart = raw.slice(arrow + 4, close);
42
+ const suffix = raw.slice(close + 1);
43
+ // Collapse the empty-side double slash (e.g. `dir/{old => }/f` → `dir/f`).
44
+ return (prefix + newPart + suffix).replace(/\/{2,}/g, '/');
45
+ }
46
+ }
47
+ const arrow = raw.indexOf(' => ');
48
+ if (arrow >= 0)
49
+ return raw.slice(arrow + 4);
50
+ return raw;
51
+ }
20
52
  /** Parse a control-char-delimited `git log --numstat` dump into commits. Pure. */
21
53
  export function parseGitLog(raw) {
22
54
  const commits = [];
@@ -40,7 +72,7 @@ export function parseGitLog(raw) {
40
72
  continue;
41
73
  const a = line.slice(0, tab1);
42
74
  const d = line.slice(tab1 + 1, tab2);
43
- const path = line.slice(tab2 + 1);
75
+ const path = resolveRenamePath(line.slice(tab2 + 1));
44
76
  if (!path)
45
77
  continue;
46
78
  cur.files.push({ path, added: a === '-' ? 0 : parseInt(a, 10) || 0, deleted: d === '-' ? 0 : parseInt(d, 10) || 0 });
@@ -38,7 +38,8 @@ export interface LicenseReport {
38
38
  /**
39
39
  * Normalize a package.json `license`/`licenses` field to an SPDX string.
40
40
  * Handles the modern string, the legacy `{type}` object, and the legacy
41
- * `licenses: [{type}]` array (→ `A OR B`). Missing 'UNKNOWN'.
41
+ * `licenses: [{type}]` array (→ `A OR B`). npm special values and a missing
42
+ * field → 'UNKNOWN'.
42
43
  */
43
44
  export declare function classifyLicense(pkg: {
44
45
  license?: unknown;
@@ -46,7 +47,35 @@ export declare function classifyLicense(pkg: {
46
47
  }): string;
47
48
  /** Split an SPDX expression into its atomic license ids (strips OR/AND/WITH/parens/+). */
48
49
  export declare function spdxComponents(expr: string): string[];
49
- /** Evaluate one dep against the policy; null if it passes. Pure. */
50
+ /**
51
+ * Parse an SPDX license expression into DISJUNCTIVE NORMAL FORM: a list of
52
+ * conjunctive terms, each a set of license ids. `A OR (B AND C)` →
53
+ * `[['A'], ['B','C']]`. Precedence per the SPDX spec (Annex D): `WITH` binds
54
+ * tighter than `AND`, `AND` tighter than `OR`; parentheses override.
55
+ *
56
+ * Each DNF term is a set of licenses that ALL apply simultaneously (the AND
57
+ * conjunction); the terms are the mutually-exclusive CHOICES (the OR). A policy
58
+ * is satisfied iff SOME term is acceptable — the semantics `evaluateDep` needs.
59
+ * `A WITH B` contributes the base license `A` (exceptions aren't standalone
60
+ * licenses in allow/deny lists). Trailing `+` is stripped. Pure.
61
+ */
62
+ export declare function parseSpdxDnf(expr: string): string[][];
63
+ /**
64
+ * Expand a license id to the set of ids it should match for policy comparison.
65
+ * A DEPRECATED bare GNU id is ambiguous (only? or-later?), so it matches either
66
+ * suffixed form; the suffixed forms stay exact, so `-only` and `-or-later` never
67
+ * match each other directly. Trailing `+` is treated as `-or-later`. Pure.
68
+ */
69
+ export declare function expandLicenseId(id: string): string[];
70
+ /**
71
+ * Evaluate one dep against the policy; null if it passes. Pure.
72
+ *
73
+ * SPDX-aware: a dep is acceptable iff SOME DNF term (a lawful realization of the
74
+ * license) violates nothing — every license in that term is un-denied and (under
75
+ * an allowlist) allowed. So `(MIT OR GPL-3.0)` with `deny:[GPL-3.0]` PASSES (take
76
+ * MIT), while `MIT AND GPL-3.0` under `allow:[MIT]` FAILS (GPL-3.0 also applies).
77
+ * Deprecated bare GNU ids (`GPL-2.0`) match either suffixed policy form.
78
+ */
50
79
  export declare function evaluateDep(dep: DepLicense, policy: LicensePolicy): Violation | null;
51
80
  /** Audit a dependency set against a policy. Pure. */
52
81
  export declare function auditLicenses(deps: DepLicense[], policy?: LicensePolicy): LicenseReport;
@@ -8,17 +8,32 @@
8
8
  * {name, version, license} plus a policy and returns violations. The fs walk
9
9
  * over node_modules lives in ../commands/license.ts.
10
10
  */
11
+ /**
12
+ * npm's documented non-SPDX license sentinels → 'UNKNOWN' (needs human review).
13
+ * `SEE LICENSE IN <file>` (custom license) and `UNLICENSED` (proprietary) are
14
+ * NOT SPDX ids; left as-is they'd be tokenized by parseSpdxDnf into a garbage
15
+ * atom (`"SEE"`), giving a misleading policy verdict. Pure.
16
+ */
17
+ function normalizeNpmLicense(s) {
18
+ const t = s.trim();
19
+ if (!t)
20
+ return 'UNKNOWN';
21
+ if (/^SEE LICENSE IN\b/i.test(t) || /^UNLICENSED$/i.test(t))
22
+ return 'UNKNOWN';
23
+ return t;
24
+ }
11
25
  /**
12
26
  * Normalize a package.json `license`/`licenses` field to an SPDX string.
13
27
  * Handles the modern string, the legacy `{type}` object, and the legacy
14
- * `licenses: [{type}]` array (→ `A OR B`). Missing 'UNKNOWN'.
28
+ * `licenses: [{type}]` array (→ `A OR B`). npm special values and a missing
29
+ * field → 'UNKNOWN'.
15
30
  */
16
31
  export function classifyLicense(pkg) {
17
32
  const l = pkg.license;
18
33
  if (typeof l === 'string' && l.trim())
19
- return l.trim();
34
+ return normalizeNpmLicense(l);
20
35
  if (l && typeof l === 'object' && typeof l.type === 'string') {
21
- return l.type.trim() || 'UNKNOWN';
36
+ return normalizeNpmLicense(l.type);
22
37
  }
23
38
  const arr = pkg.licenses;
24
39
  if (Array.isArray(arr) && arr.length) {
@@ -38,22 +53,129 @@ export function spdxComponents(expr) {
38
53
  .map((s) => s.trim().replace(/\+$/, ''))
39
54
  .filter(Boolean);
40
55
  }
41
- /** Evaluate one dep against the policy; null if it passes. Pure. */
56
+ /**
57
+ * Parse an SPDX license expression into DISJUNCTIVE NORMAL FORM: a list of
58
+ * conjunctive terms, each a set of license ids. `A OR (B AND C)` →
59
+ * `[['A'], ['B','C']]`. Precedence per the SPDX spec (Annex D): `WITH` binds
60
+ * tighter than `AND`, `AND` tighter than `OR`; parentheses override.
61
+ *
62
+ * Each DNF term is a set of licenses that ALL apply simultaneously (the AND
63
+ * conjunction); the terms are the mutually-exclusive CHOICES (the OR). A policy
64
+ * is satisfied iff SOME term is acceptable — the semantics `evaluateDep` needs.
65
+ * `A WITH B` contributes the base license `A` (exceptions aren't standalone
66
+ * licenses in allow/deny lists). Trailing `+` is stripped. Pure.
67
+ */
68
+ export function parseSpdxDnf(expr) {
69
+ const tokens = expr.replace(/([()])/g, ' $1 ').split(/\s+/).filter(Boolean);
70
+ let pos = 0;
71
+ const peek = () => tokens[pos];
72
+ const isOp = (t, op) => !!t && t.toUpperCase() === op;
73
+ // expr := term (OR term)* → concatenate the alternatives
74
+ const parseExpr = () => {
75
+ let terms = parseTerm();
76
+ while (isOp(peek(), 'OR')) {
77
+ pos++;
78
+ terms = terms.concat(parseTerm());
79
+ }
80
+ return terms;
81
+ };
82
+ // term := factor (AND factor)* → cartesian union of the conjuncts
83
+ const parseTerm = () => {
84
+ let left = parseFactor();
85
+ while (isOp(peek(), 'AND')) {
86
+ pos++;
87
+ const right = parseFactor();
88
+ const merged = [];
89
+ for (const a of left)
90
+ for (const b of right)
91
+ merged.push([...new Set([...a, ...b])]);
92
+ left = merged;
93
+ }
94
+ return left;
95
+ };
96
+ // factor := '(' expr ')' | license [WITH exception]
97
+ const parseFactor = () => {
98
+ if (peek() === '(') {
99
+ pos++;
100
+ const inner = parseExpr();
101
+ if (peek() === ')')
102
+ pos++;
103
+ return inner;
104
+ }
105
+ const lic = (tokens[pos++] ?? '').replace(/\+$/, '');
106
+ if (isOp(peek(), 'WITH')) {
107
+ pos++;
108
+ pos++;
109
+ } // consume `WITH <exception>` — base license only
110
+ return lic ? [[lic]] : [[]];
111
+ };
112
+ const dnf = parseExpr();
113
+ // Fall back to treating the whole string as one atomic license if parse yielded nothing.
114
+ return dnf.length && dnf.some((t) => t.length) ? dnf : [[expr.trim()]];
115
+ }
116
+ /**
117
+ * SPDX 3.0 deprecated the bare GNU-family ids in favor of explicit `-only` /
118
+ * `-or-later` variants, but npm packages still declare the bare form en masse.
119
+ */
120
+ const DEPRECATED_GNU_BARE = new Set([
121
+ 'GPL-1.0', 'GPL-2.0', 'GPL-3.0',
122
+ 'LGPL-2.0', 'LGPL-2.1', 'LGPL-3.0',
123
+ 'AGPL-1.0', 'AGPL-3.0',
124
+ 'GFDL-1.1', 'GFDL-1.2', 'GFDL-1.3',
125
+ ]);
126
+ /**
127
+ * Expand a license id to the set of ids it should match for policy comparison.
128
+ * A DEPRECATED bare GNU id is ambiguous (only? or-later?), so it matches either
129
+ * suffixed form; the suffixed forms stay exact, so `-only` and `-or-later` never
130
+ * match each other directly. Trailing `+` is treated as `-or-later`. Pure.
131
+ */
132
+ export function expandLicenseId(id) {
133
+ if (DEPRECATED_GNU_BARE.has(id))
134
+ return [id, `${id}-only`, `${id}-or-later`];
135
+ const plus = id.replace(/\+$/, '');
136
+ if (id.endsWith('+') && DEPRECATED_GNU_BARE.has(plus))
137
+ return [id, `${plus}-or-later`];
138
+ return [id];
139
+ }
140
+ /** Membership test aware of deprecated-bare-id aliases (both sides expanded). */
141
+ function inPolicySet(c, expandedPolicy) {
142
+ return expandLicenseId(c).some((e) => expandedPolicy.has(e));
143
+ }
144
+ function expandPolicy(ids) {
145
+ const out = new Set();
146
+ for (const id of ids ?? [])
147
+ for (const e of expandLicenseId(id))
148
+ out.add(e);
149
+ return out;
150
+ }
151
+ /**
152
+ * Evaluate one dep against the policy; null if it passes. Pure.
153
+ *
154
+ * SPDX-aware: a dep is acceptable iff SOME DNF term (a lawful realization of the
155
+ * license) violates nothing — every license in that term is un-denied and (under
156
+ * an allowlist) allowed. So `(MIT OR GPL-3.0)` with `deny:[GPL-3.0]` PASSES (take
157
+ * MIT), while `MIT AND GPL-3.0` under `allow:[MIT]` FAILS (GPL-3.0 also applies).
158
+ * Deprecated bare GNU ids (`GPL-2.0`) match either suffixed policy form.
159
+ */
42
160
  export function evaluateDep(dep, policy) {
43
- const allow = new Set(policy.allow ?? []);
44
- const deny = new Set(policy.deny ?? []);
161
+ const allow = expandPolicy(policy.allow);
162
+ const deny = expandPolicy(policy.deny);
45
163
  const isUnknown = dep.license === 'UNKNOWN' || !dep.license;
46
- const components = isUnknown ? [] : spdxComponents(dep.license);
47
- if (components.some((c) => deny.has(c))) {
48
- return { name: dep.name, version: dep.version, license: dep.license, reason: 'denied' };
49
- }
50
164
  if (isUnknown) {
51
165
  if (allow.size > 0 && !policy.allowUnknown) {
52
166
  return { name: dep.name, version: dep.version, license: 'UNKNOWN', reason: 'unknown' };
53
167
  }
54
168
  return null;
55
169
  }
56
- if (allow.size > 0 && !components.some((c) => allow.has(c))) {
170
+ const terms = parseSpdxDnf(dep.license);
171
+ // Terms that avoid every denied license (a realization the consumer may choose).
172
+ const undenied = terms.filter((t) => !t.some((c) => inPolicySet(c, deny)));
173
+ if (undenied.length === 0) {
174
+ // No lawful realization avoids a denied license → genuinely denied.
175
+ return { name: dep.name, version: dep.version, license: dep.license, reason: 'denied' };
176
+ }
177
+ if (allow.size > 0 && !undenied.some((t) => t.every((c) => inPolicySet(c, allow)))) {
178
+ // A denylist is satisfiable, but no un-denied realization is fully allowed.
57
179
  return { name: dep.name, version: dep.version, license: dep.license, reason: 'not-allowed' };
58
180
  }
59
181
  return null;
@@ -51,9 +51,8 @@ export declare function buildTree(paths: string[]): string;
51
51
  export declare function packFiles(input: PackFile[], opts?: PackOptions): PackResult;
52
52
  /**
53
53
  * Minimal .gitignore-style matcher. Handles the common cases — plain names,
54
- * `dir/`, `*.ext` globs, leading `/` anchors, and `!` negation — not the full
55
- * spec (no `**` depth semantics beyond substring, no nested ignore files).
56
- * Good enough to keep obvious noise out; full semantics is a follow-up.
54
+ * `dir/`, `*.ext` globs, `**` depth globs (gitignore(5) semantics), leading `/`
55
+ * anchors, and `!` negation. Not the full spec (no nested ignore files).
57
56
  */
58
57
  export declare function makeIgnoreMatcher(patterns: string[]): (relPath: string) => boolean;
59
58
  //# sourceMappingURL=pack.d.ts.map
@@ -80,7 +80,15 @@ function renderMarkdown(files, opts) {
80
80
  return parts.join('\n');
81
81
  }
82
82
  function escapeXml(s) {
83
- return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
83
+ // All five predefined XML entities — the quote escapes matter because this
84
+ // value is also interpolated into a double-quoted `path="…"` attribute, where
85
+ // an unescaped `"` in a file path would break out of the attribute.
86
+ return s
87
+ .replace(/&/g, '&amp;')
88
+ .replace(/</g, '&lt;')
89
+ .replace(/>/g, '&gt;')
90
+ .replace(/"/g, '&quot;')
91
+ .replace(/'/g, '&#39;');
84
92
  }
85
93
  function renderXml(files, opts) {
86
94
  const parts = ['<repository>'];
@@ -138,11 +146,42 @@ export function packFiles(input, opts = {}) {
138
146
  },
139
147
  };
140
148
  }
149
+ /**
150
+ * Convert a .gitignore glob body to a regex source (no anchors). Handles a
151
+ * double-star with correct gitignore(5) depth semantics: a double-star path
152
+ * segment matches zero or more directories (so `a/[star][star]/b` matches
153
+ * `a/b`, `a/x/b`, `a/x/y/b`); a leading one matches in any directory; a
154
+ * trailing one matches everything below. Single `*`/`?` stay within one path
155
+ * segment; other consecutive asterisks degrade to regular stars, per the spec.
156
+ * Pure.
157
+ */
158
+ function globToRegExp(body) {
159
+ const esc = (s) => s.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*').replace(/\?/g, '[^/]');
160
+ const parts = body.split('/');
161
+ let re = '';
162
+ for (let i = 0; i < parts.length; i++) {
163
+ const last = i === parts.length - 1;
164
+ if (parts[i] === '**') {
165
+ if (last) {
166
+ re += '.*';
167
+ } // trailing /** — everything below
168
+ else {
169
+ re += '(?:[^/]*/)*';
170
+ continue;
171
+ } // absorbs the following slash → zero-or-more dirs
172
+ }
173
+ else {
174
+ re += esc(parts[i]);
175
+ }
176
+ if (!last)
177
+ re += '/';
178
+ }
179
+ return re;
180
+ }
141
181
  /**
142
182
  * Minimal .gitignore-style matcher. Handles the common cases — plain names,
143
- * `dir/`, `*.ext` globs, leading `/` anchors, and `!` negation — not the full
144
- * spec (no `**` depth semantics beyond substring, no nested ignore files).
145
- * Good enough to keep obvious noise out; full semantics is a follow-up.
183
+ * `dir/`, `*.ext` globs, `**` depth globs (gitignore(5) semantics), leading `/`
184
+ * anchors, and `!` negation. Not the full spec (no nested ignore files).
146
185
  */
147
186
  export function makeIgnoreMatcher(patterns) {
148
187
  const rules = patterns
@@ -157,22 +196,39 @@ export function makeIgnoreMatcher(patterns) {
157
196
  const anchored = body.startsWith('/');
158
197
  if (anchored)
159
198
  body = body.slice(1);
160
- const re = new RegExp('^' +
161
- body
162
- .replace(/[.+^${}()|[\]\\]/g, '\\$&')
163
- .replace(/\*/g, '[^/]*')
164
- .replace(/\?/g, '[^/]') +
165
- (dirOnly ? '(/|$)' : '($|/)'));
199
+ const re = new RegExp('^' + globToRegExp(body) + (dirOnly ? '(/|$)' : '($|/)'));
166
200
  return { negate, anchored, re };
167
201
  });
168
- return (relPath) => {
202
+ // Ignored status of a single path (last-match-wins over the ordered rules).
203
+ const ruleVerdict = (path) => {
169
204
  let ignored = false;
170
205
  for (const r of rules) {
171
- const candidates = r.anchored ? [relPath] : [relPath, ...relPath.split('/').map((_, i, a) => a.slice(i).join('/'))];
206
+ const candidates = r.anchored ? [path] : [path, ...path.split('/').map((_, i, a) => a.slice(i).join('/'))];
172
207
  if (candidates.some((c) => r.re.test(c)))
173
208
  ignored = !r.negate;
174
209
  }
175
210
  return ignored;
176
211
  };
212
+ return (relPath) => {
213
+ // Walk ancestor dirs top-down: once a parent directory is excluded, the file
214
+ // stays excluded — a negation cannot re-include under an excluded parent
215
+ // (gitignore(5): "not possible to re-include a file if a parent directory of
216
+ // that file is excluded").
217
+ const parts = relPath.split('/').filter(Boolean);
218
+ let parentIgnored = false;
219
+ for (let i = 0; i < parts.length; i++) {
220
+ const isLast = i === parts.length - 1;
221
+ if (parentIgnored) {
222
+ if (isLast)
223
+ return true;
224
+ continue; // excluded ancestor carries down
225
+ }
226
+ const verdict = ruleVerdict(parts.slice(0, i + 1).join('/'));
227
+ if (isLast)
228
+ return verdict;
229
+ parentIgnored = verdict; // this directory prefix carries forward
230
+ }
231
+ return false;
232
+ };
177
233
  }
178
234
  //# sourceMappingURL=pack.js.map
@@ -26,6 +26,7 @@ export const RULES = [
26
26
  { id: 'anthropic-key', description: 'Anthropic API key', regex: /\bsk-ant-[0-9A-Za-z-]{16,}\b/g },
27
27
  { id: 'openai-key', description: 'OpenAI API key', regex: /\bsk-(?:proj-)?[0-9A-Za-z]{20,}\b/g },
28
28
  { id: 'google-api-key', description: 'Google API key', regex: /\bAIza[0-9A-Za-z_-]{35}\b/g },
29
+ { id: 'google-oauth-token', description: 'Google OAuth access token', regex: /\bya29\.[0-9A-Za-z_-]{20,}/g },
29
30
  { id: 'slack-token', description: 'Slack token', regex: /\bxox[baprs]-[0-9A-Za-z-]{10,}\b/g },
30
31
  { id: 'slack-webhook', description: 'Slack incoming webhook', regex: /https:\/\/hooks\.slack\.com\/services\/T[0-9A-Za-z_]+\/B[0-9A-Za-z_]+\/[0-9A-Za-z_]+/g },
31
32
  { id: 'stripe-key', description: 'Stripe secret/restricted key', regex: /\b(?:sk|rk)_live_[0-9A-Za-z]{24,}\b/g },
@@ -33,9 +34,20 @@ export const RULES = [
33
34
  { id: 'twilio-key', description: 'Twilio API key SID', regex: /\bSK[0-9a-fA-F]{32}\b/g },
34
35
  { id: 'npm-token', description: 'npm access token', regex: /\bnpm_[0-9A-Za-z]{36}\b/g },
35
36
  { id: 'jwt', description: 'JSON Web Token', regex: /\beyJ[0-9A-Za-z_-]{10,}\.eyJ[0-9A-Za-z_-]{10,}\.[0-9A-Za-z_-]{10,}\b/g },
37
+ // RFC 6750 Bearer credential. Last (most generic): specific token rules above
38
+ // claim their range first. 16+ b64token chars keeps prose ("Bearer document")
39
+ // from matching; only the token (group 1) is masked, not the `Bearer ` prefix.
40
+ { id: 'bearer-token', description: 'HTTP Bearer token (RFC 6750)', regex: /\bBearer\s+([A-Za-z0-9\-._~+/]{16,}=*)/g, group: 1 },
36
41
  ];
37
- /** Keyword-prefixed assignment, e.g. `api_key = "..."` — the value is group 1. */
38
- const ASSIGNMENT_RE = /(?:pass(?:word|wd)?|pwd|secret|token|api[_-]?key|apikey|access[_-]?key|auth[_-]?token|client[_-]?secret)["']?\s*[:=]\s*["']?([^\s"'`,;]{8,})/gi;
42
+ /**
43
+ * Keyword-prefixed assignment, e.g. `api_key = "..."` — the value is group 1.
44
+ * Keyword set mirrors gitleaks' reference `generic-api-key` rule (adds bare
45
+ * `key`, `credential`, `creds` so `PRIVATE_KEY=`/`ENCRYPTION_KEY=`/`DB_CREDENTIAL=`
46
+ * secrets reach the entropy check). The `[:=]` immediately after the keyword
47
+ * anchors it, so `keyboard=`/`monkey_val=` don't match; the entropy + length
48
+ * gates keep low-entropy values (paths, short flags) from being flagged.
49
+ */
50
+ const ASSIGNMENT_RE = /(?:pass(?:word|wd)?|pwd|secret|token|api[_-]?key|apikey|access[_-]?key|auth[_-]?token|client[_-]?secret|credential|creds|key)["']?\s*[:=]\s*["']?([^\s"'`,;]{8,})/gi;
39
51
  /** Shannon entropy in bits/char. Pure; used by the assignment fallback. */
40
52
  export function shannonEntropy(s) {
41
53
  if (!s)
@@ -21,6 +21,7 @@ export interface Component {
21
21
  content: string;
22
22
  };
23
23
  dev?: boolean;
24
+ optional?: boolean;
24
25
  }
25
26
  interface NpmLockEntry {
26
27
  version?: string;
@@ -53,6 +54,21 @@ export interface BomMeta {
53
54
  name: string;
54
55
  version: string;
55
56
  }
57
+ /**
58
+ * Is this npm `license` string an SPDX *expression* (compound), rather than a
59
+ * single license identifier? npm permits expressions like `(MIT OR Apache-2.0)`
60
+ * or `Apache-2.0 WITH LLVM-exception` for dual/multi-licensed packages. SPDX
61
+ * operators are uppercase and space-delimited, so a single ID (`MIT`,
62
+ * `BSD-3-Clause`, `CC-BY-SA-4.0`) never matches. Pure.
63
+ */
64
+ export declare function isSpdxExpression(license: string): boolean;
65
+ /**
66
+ * A CycloneDX `licenses[]` entry for a license string. CycloneDX requires
67
+ * `license.id` to be a single valid SPDX identifier, so an expression must use
68
+ * the sibling `expression` field instead — otherwise strict validators and
69
+ * scanners (Dependency-Track, Grype) reject or mis-parse the component. Pure.
70
+ */
71
+ export declare function cdxLicenseEntry(license: string): Record<string, unknown>;
56
72
  /** Build a CycloneDX 1.5 BOM object (no timestamp → deterministic). Pure. */
57
73
  export declare function buildCycloneDX(components: Component[], meta: BomMeta): Record<string, unknown>;
58
74
  /** Build a minimal SPDX 2.3 JSON document (no created timestamp → deterministic). Pure. */
@@ -77,11 +77,32 @@ export function componentsFromNpmLock(lock, opts = {}) {
77
77
  }
78
78
  if (entry.dev)
79
79
  comp.dev = true;
80
+ if (entry.optional)
81
+ comp.optional = true;
80
82
  out.push(comp);
81
83
  }
82
84
  out.sort((a, b) => (a.name === b.name ? a.version.localeCompare(b.version) : a.name.localeCompare(b.name)));
83
85
  return out;
84
86
  }
87
+ /**
88
+ * Is this npm `license` string an SPDX *expression* (compound), rather than a
89
+ * single license identifier? npm permits expressions like `(MIT OR Apache-2.0)`
90
+ * or `Apache-2.0 WITH LLVM-exception` for dual/multi-licensed packages. SPDX
91
+ * operators are uppercase and space-delimited, so a single ID (`MIT`,
92
+ * `BSD-3-Clause`, `CC-BY-SA-4.0`) never matches. Pure.
93
+ */
94
+ export function isSpdxExpression(license) {
95
+ return / (OR|AND|WITH) /.test(license) || license.includes('(');
96
+ }
97
+ /**
98
+ * A CycloneDX `licenses[]` entry for a license string. CycloneDX requires
99
+ * `license.id` to be a single valid SPDX identifier, so an expression must use
100
+ * the sibling `expression` field instead — otherwise strict validators and
101
+ * scanners (Dependency-Track, Grype) reject or mis-parse the component. Pure.
102
+ */
103
+ export function cdxLicenseEntry(license) {
104
+ return isSpdxExpression(license) ? { expression: license } : { license: { id: license } };
105
+ }
85
106
  /** Build a CycloneDX 1.5 BOM object (no timestamp → deterministic). Pure. */
86
107
  export function buildCycloneDX(components, meta) {
87
108
  return {
@@ -93,10 +114,16 @@ export function buildCycloneDX(components, meta) {
93
114
  },
94
115
  components: components.map((c) => {
95
116
  const comp = { type: 'library', name: c.name, version: c.version, purl: c.purl };
117
+ // CycloneDX 1.5 `scope`: dev-only deps are `excluded` (not shipped),
118
+ // optional deps are `optional`; a required runtime dep omits it (implicit
119
+ // `required`), keeping the common case clean. dev wins over optional.
120
+ const scope = c.dev ? 'excluded' : c.optional ? 'optional' : undefined;
121
+ if (scope)
122
+ comp.scope = scope;
96
123
  if (c.hash)
97
124
  comp.hashes = [{ alg: c.hash.alg, content: c.hash.content }];
98
125
  if (c.license)
99
- comp.licenses = [{ license: { id: c.license } }];
126
+ comp.licenses = [cdxLicenseEntry(c.license)];
100
127
  return comp;
101
128
  }),
102
129
  };
@@ -28,6 +28,20 @@ export interface TestSummary {
28
28
  total: number;
29
29
  durationMs: number;
30
30
  failures: TestFailure[];
31
+ /**
32
+ * TAP `# TODO` tests — "not expected to succeed" per the TAP spec, so a
33
+ * `not ok … # TODO` is a KNOWN-incomplete stub, counted here as a success,
34
+ * never a failure. Absent (0) for JUnit and TODO-free TAP.
35
+ */
36
+ todo?: number;
37
+ /**
38
+ * True if a TAP `Bail out!` line aborted the run. The counts are then
39
+ * INCOMPLETE — the suite stopped early, so a "0 failed" here does NOT mean
40
+ * the suite passed. Callers/CI must treat this as a failure.
41
+ */
42
+ bailedOut?: boolean;
43
+ /** Optional reason text following `Bail out!`. */
44
+ bailReason?: string;
31
45
  }
32
46
  /** Pull a `file:line` out of a stack-trace / assertion message. Pure. */
33
47
  export declare function extractFileLine(text: string): {