swarmdo 1.18.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 (41) hide show
  1. package/.claude/helpers/statusline.cjs +1 -1
  2. package/README.md +2 -1
  3. package/package.json +1 -1
  4. package/v3/@swarmdo/cli/dist/src/apply/apply.d.ts +2 -0
  5. package/v3/@swarmdo/cli/dist/src/apply/apply.js +29 -2
  6. package/v3/@swarmdo/cli/dist/src/changelog/changelog.js +26 -3
  7. package/v3/@swarmdo/cli/dist/src/codegraph/codegraph.d.ts +26 -7
  8. package/v3/@swarmdo/cli/dist/src/codegraph/codegraph.js +97 -13
  9. package/v3/@swarmdo/cli/dist/src/codegraph/store.d.ts +8 -1
  10. package/v3/@swarmdo/cli/dist/src/codegraph/store.js +78 -1
  11. package/v3/@swarmdo/cli/dist/src/commands/cycles.js +4 -1
  12. package/v3/@swarmdo/cli/dist/src/commands/hotspots.js +5 -2
  13. package/v3/@swarmdo/cli/dist/src/commands/index.js +6 -3
  14. package/v3/@swarmdo/cli/dist/src/commands/release.js +5 -1
  15. package/v3/@swarmdo/cli/dist/src/commands/testreport.d.ts +17 -0
  16. package/v3/@swarmdo/cli/dist/src/commands/testreport.js +120 -0
  17. package/v3/@swarmdo/cli/dist/src/cycles/cycles.d.ts +12 -2
  18. package/v3/@swarmdo/cli/dist/src/cycles/cycles.js +5 -3
  19. package/v3/@swarmdo/cli/dist/src/env/env.d.ts +5 -2
  20. package/v3/@swarmdo/cli/dist/src/env/env.js +24 -3
  21. package/v3/@swarmdo/cli/dist/src/hotspots/hotspots.d.ts +15 -0
  22. package/v3/@swarmdo/cli/dist/src/hotspots/hotspots.js +33 -1
  23. package/v3/@swarmdo/cli/dist/src/license/license.d.ts +31 -2
  24. package/v3/@swarmdo/cli/dist/src/license/license.js +133 -11
  25. package/v3/@swarmdo/cli/dist/src/mcp-client.js +2 -0
  26. package/v3/@swarmdo/cli/dist/src/mcp-tools/index.d.ts +1 -0
  27. package/v3/@swarmdo/cli/dist/src/mcp-tools/index.js +1 -0
  28. package/v3/@swarmdo/cli/dist/src/mcp-tools/profiles.js +1 -0
  29. package/v3/@swarmdo/cli/dist/src/mcp-tools/testreport-tools.d.ts +11 -0
  30. package/v3/@swarmdo/cli/dist/src/mcp-tools/testreport-tools.js +33 -0
  31. package/v3/@swarmdo/cli/dist/src/pack/pack.d.ts +2 -3
  32. package/v3/@swarmdo/cli/dist/src/pack/pack.js +68 -12
  33. package/v3/@swarmdo/cli/dist/src/redact/redact.js +14 -2
  34. package/v3/@swarmdo/cli/dist/src/sbom/sbom.d.ts +16 -0
  35. package/v3/@swarmdo/cli/dist/src/sbom/sbom.js +28 -1
  36. package/v3/@swarmdo/cli/dist/src/testreport/testreport.d.ts +65 -0
  37. package/v3/@swarmdo/cli/dist/src/testreport/testreport.js +246 -0
  38. package/v3/@swarmdo/cli/dist/src/usage/claude-pricing.d.ts +6 -0
  39. package/v3/@swarmdo/cli/dist/src/usage/claude-pricing.js +12 -9
  40. package/v3/@swarmdo/cli/dist/src/usage/transcript-usage.js +4 -1
  41. package/v3/@swarmdo/cli/package.json +1 -1
@@ -0,0 +1,120 @@
1
+ /**
2
+ * `swarmdo testreport` — parse JUnit-XML / TAP results into a failure digest.
3
+ *
4
+ * swarmdo testreport junit.xml # human digest
5
+ * swarmdo testreport ./reports # scan a dir for *.xml/*.tap
6
+ * vitest --reporter=junit | swarmdo testreport # from stdin
7
+ * swarmdo testreport junit.xml --format json # machine output
8
+ * swarmdo testreport junit.xml --ci # exit 1 if any failed
9
+ *
10
+ * The front-half of the test→fix loop: turn raw results into exact failing
11
+ * test + file:line + message, then hand it to `repair`. Engine
12
+ * (../testreport/testreport.ts) is pure + tested; this reads files.
13
+ */
14
+ import * as fs from 'node:fs';
15
+ import * as path from 'node:path';
16
+ import { output } from '../output.js';
17
+ import { parseTestReport, detectFormat, mergeSummaries, formatSummary, } from '../testreport/testreport.js';
18
+ function readStdin() {
19
+ return new Promise((resolve) => {
20
+ const chunks = [];
21
+ process.stdin.on('data', (c) => chunks.push(Buffer.from(c)));
22
+ process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
23
+ process.stdin.on('error', () => resolve(Buffer.concat(chunks).toString('utf8')));
24
+ });
25
+ }
26
+ /** Expand path args into a list of result files (dirs → their *.xml/*.tap). */
27
+ function collectFiles(root, args) {
28
+ const files = [];
29
+ for (const a of args) {
30
+ const abs = path.resolve(root, a);
31
+ let st;
32
+ try {
33
+ st = fs.statSync(abs);
34
+ }
35
+ catch {
36
+ continue;
37
+ }
38
+ if (st.isDirectory()) {
39
+ for (const entry of fs.readdirSync(abs)) {
40
+ if (/\.(xml|tap)$/i.test(entry))
41
+ files.push(path.join(abs, entry));
42
+ }
43
+ }
44
+ else {
45
+ files.push(abs);
46
+ }
47
+ }
48
+ return files;
49
+ }
50
+ function numFlag(v, def) {
51
+ const n = typeof v === 'number' ? v : typeof v === 'string' ? parseInt(v, 10) : NaN;
52
+ return Number.isFinite(n) ? n : def;
53
+ }
54
+ async function run(ctx) {
55
+ const root = ctx.cwd || process.cwd();
56
+ const asJson = ctx.flags.format === 'json';
57
+ const ci = ctx.flags.ci === true;
58
+ const top = numFlag(ctx.flags.top, 0);
59
+ // NB: --type, not --format (global --format is choices-validated to text|json|table).
60
+ const typeOverride = typeof ctx.flags.type === 'string' ? ctx.flags.type : undefined;
61
+ if (typeOverride && typeOverride !== 'junit' && typeOverride !== 'tap') {
62
+ output.printError(`unknown --type '${typeOverride}' (use junit|tap)`);
63
+ return { success: false, exitCode: 1 };
64
+ }
65
+ let summary;
66
+ if (ctx.args.length === 0) {
67
+ if (process.stdin.isTTY) {
68
+ output.writeln(output.error('Usage: swarmdo testreport <file|dir> OR <runner> | swarmdo testreport'));
69
+ return { success: false, exitCode: 1 };
70
+ }
71
+ const content = await readStdin();
72
+ const fmt = typeOverride ?? detectFormat(content);
73
+ summary = parseTestReport(content, fmt);
74
+ }
75
+ else {
76
+ const files = collectFiles(root, ctx.args);
77
+ if (files.length === 0) {
78
+ output.printError('no test-result files found (looked for *.xml / *.tap)');
79
+ return { success: false, exitCode: 1 };
80
+ }
81
+ const summaries = [];
82
+ for (const f of files) {
83
+ let content;
84
+ try {
85
+ content = fs.readFileSync(f, 'utf8');
86
+ }
87
+ catch {
88
+ continue;
89
+ }
90
+ const fmt = typeOverride ?? detectFormat(content, f);
91
+ summaries.push(parseTestReport(content, fmt));
92
+ }
93
+ summary = mergeSummaries(summaries);
94
+ }
95
+ if (asJson) {
96
+ process.stdout.write(JSON.stringify(summary, null, 2) + '\n');
97
+ }
98
+ else {
99
+ output.writeln(formatSummary(summary, { top: top > 0 ? top : undefined }));
100
+ }
101
+ // A bail-out aborts the suite → results are incomplete → treat as failure in CI.
102
+ const code = ci && (summary.failed > 0 || summary.bailedOut) ? 1 : 0;
103
+ return { success: code === 0, exitCode: code };
104
+ }
105
+ export const testreportCommand = {
106
+ name: 'testreport',
107
+ description: 'Parse JUnit-XML / TAP test results into a compact failure digest (test name + file:line + message) — the front-half of the test→fix loop',
108
+ options: [
109
+ { name: 'type', description: 'force input format: junit|tap (default: auto-detect)', type: 'string' },
110
+ { name: 'top', description: 'show only the first N failures', type: 'string' },
111
+ { name: 'ci', description: 'exit 1 if any test failed', type: 'boolean' },
112
+ ],
113
+ examples: [
114
+ { command: 'swarmdo testreport junit.xml', description: 'Digest a JUnit report' },
115
+ { command: 'vitest --reporter=junit | swarmdo testreport --ci', description: 'Parse from stdin, fail on any failure' },
116
+ ],
117
+ action: run,
118
+ };
119
+ export default testreportCommand;
120
+ //# sourceMappingURL=testreport.js.map
@@ -12,8 +12,18 @@
12
12
  * The index load lives in ../commands/cycles.ts, so this is fixture-testable.
13
13
  */
14
14
  import type { CodeIndex } from '../codegraph/codegraph.js';
15
+ export interface CycleOptions {
16
+ /**
17
+ * Include TypeScript `import type`/`export type` edges. They erase at compile
18
+ * time so a type-only "cycle" causes none of the runtime bugs this detector
19
+ * exists to catch — excluded by DEFAULT to avoid false positives. Set true for
20
+ * a strict structural view. An edge counts as a runtime edge if ANY import
21
+ * between the two files is a value import (mixed imports keep the edge).
22
+ */
23
+ includeTypeOnly?: boolean;
24
+ }
15
25
  /** Build file → sorted list of internal (resolved) imports. Pure. */
16
- export declare function buildAdjacency(index: CodeIndex): Map<string, string[]>;
26
+ export declare function buildAdjacency(index: CodeIndex, opts?: CycleOptions): Map<string, string[]>;
17
27
  /**
18
28
  * Tarjan's SCC. Returns every strongly-connected component as a member list.
19
29
  * Iterative (no recursion) so deep graphs don't overflow the stack. Deterministic:
@@ -31,7 +41,7 @@ export interface CycleResult {
31
41
  * it has ≥2 files (mutual reachability) or a single file with a self-edge.
32
42
  * Deterministic: groups sorted by size desc then first member.
33
43
  */
34
- export declare function findCycles(index: CodeIndex): CycleResult;
44
+ export declare function findCycles(index: CodeIndex, opts?: CycleOptions): CycleResult;
35
45
  /** Human-readable summary. Pure. */
36
46
  export declare function formatCycles(res: CycleResult): string;
37
47
  //# sourceMappingURL=cycles.d.ts.map
@@ -12,7 +12,7 @@
12
12
  * The index load lives in ../commands/cycles.ts, so this is fixture-testable.
13
13
  */
14
14
  /** Build file → sorted list of internal (resolved) imports. Pure. */
15
- export function buildAdjacency(index) {
15
+ export function buildAdjacency(index, opts = {}) {
16
16
  const adj = new Map();
17
17
  const ensure = (f) => {
18
18
  let l = adj.get(f);
@@ -25,6 +25,8 @@ export function buildAdjacency(index) {
25
25
  for (const e of index.imports) {
26
26
  if (!e.resolved)
27
27
  continue; // external — not part of internal cycles
28
+ if (e.isTypeOnly && !opts.includeTypeOnly)
29
+ continue; // type-only → no runtime edge
28
30
  const l = ensure(e.from);
29
31
  if (!l.includes(e.resolved))
30
32
  l.push(e.resolved);
@@ -101,8 +103,8 @@ export function stronglyConnectedComponents(adj) {
101
103
  * it has ≥2 files (mutual reachability) or a single file with a self-edge.
102
104
  * Deterministic: groups sorted by size desc then first member.
103
105
  */
104
- export function findCycles(index) {
105
- const adj = buildAdjacency(index);
106
+ export function findCycles(index, opts = {}) {
107
+ const adj = buildAdjacency(index, opts);
106
108
  const selfLoops = [];
107
109
  for (const [from, tos] of adj)
108
110
  if (tos.includes(from))
@@ -29,8 +29,11 @@ export interface EnvReport {
29
29
  export declare function extractEnvRefs(source: string, file: string): EnvRef[];
30
30
  /**
31
31
  * Parse a `.env` file into an ordered list of declared keys. Handles `KEY=val`,
32
- * `export KEY=val`, quoted values, `# comments`, and blank lines. Values are
33
- * ignored we only reconcile key presence. Pure.
32
+ * `export KEY=val`, quoted values, `# comments`, and blank lines. The key
33
+ * charset + `:` separator mirror the reference `dotenv` package's grammar
34
+ * (`[\w.-]+` with `=` or `: ` as separator), so dotted/hyphenated keys like
35
+ * `APP.NAME` aren't silently dropped. Values are ignored — we only reconcile
36
+ * key presence. Pure.
34
37
  */
35
38
  export declare function parseDotenv(text: string): string[];
36
39
  export interface ReconcileInput {
@@ -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;
@@ -27,6 +27,7 @@ import { applyTools } from './mcp-tools/apply-tools.js';
27
27
  import { hotspotsTools } from './mcp-tools/hotspots-tools.js';
28
28
  import { affectedTools } from './mcp-tools/affected-tools.js';
29
29
  import { cyclesTools } from './mcp-tools/cycles-tools.js';
30
+ import { testreportTools } from './mcp-tools/testreport-tools.js';
30
31
  import { progressTools } from './mcp-tools/progress-tools.js';
31
32
  import { embeddingsTools } from './mcp-tools/embeddings-tools.js';
32
33
  import { claimsTools } from './mcp-tools/claims-tools.js';
@@ -118,6 +119,7 @@ const TOOL_GROUPS = {
118
119
  hotspots: () => hotspotsTools,
119
120
  affected: () => affectedTools,
120
121
  cycles: () => cyclesTools,
122
+ testreport: () => testreportTools,
121
123
  progress: () => progressTools,
122
124
  embeddings: () => embeddingsTools,
123
125
  claims: () => claimsTools,
@@ -23,6 +23,7 @@ export { applyTools } from './apply-tools.js';
23
23
  export { hotspotsTools } from './hotspots-tools.js';
24
24
  export { affectedTools } from './affected-tools.js';
25
25
  export { cyclesTools } from './cycles-tools.js';
26
+ export { testreportTools } from './testreport-tools.js';
26
27
  export { progressTools } from './progress-tools.js';
27
28
  export { transferTools } from './transfer-tools.js';
28
29
  export { securityTools } from './security-tools.js';
@@ -22,6 +22,7 @@ export { applyTools } from './apply-tools.js';
22
22
  export { hotspotsTools } from './hotspots-tools.js';
23
23
  export { affectedTools } from './affected-tools.js';
24
24
  export { cyclesTools } from './cycles-tools.js';
25
+ export { testreportTools } from './testreport-tools.js';
25
26
  export { progressTools } from './progress-tools.js';
26
27
  export { transferTools } from './transfer-tools.js';
27
28
  export { securityTools } from './security-tools.js';
@@ -29,6 +29,7 @@ const LEAN_GROUPS = [
29
29
  'hotspots', // hotspots — git-history change-risk ranking ("where's the debt?")
30
30
  'affected', // affected — impacted files/tests from a change via the import graph
31
31
  'cycles', // cycles — circular-import detection via the import graph
32
+ 'testreport', // testreport — JUnit/TAP → structured failure digest (pairs w/ repair)
32
33
  ];
33
34
  /** Balanced = lean + orchestration/session/system the average user reaches for. */
34
35
  const BALANCED_EXTRA = [
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Testreport MCP Tool
3
+ *
4
+ * Let an agent turn raw JUnit/TAP test output into a structured failure digest —
5
+ * exact failing test + file:line + message — instead of re-reading hundreds of
6
+ * log lines. The front-half of the test→fix loop: pair with `repair`. Shares the
7
+ * pure parser in ../testreport/testreport.ts with the CLI; string-in/JSON-out.
8
+ */
9
+ import type { MCPTool } from './types.js';
10
+ export declare const testreportTools: MCPTool[];
11
+ //# sourceMappingURL=testreport-tools.d.ts.map
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Testreport MCP Tool
3
+ *
4
+ * Let an agent turn raw JUnit/TAP test output into a structured failure digest —
5
+ * exact failing test + file:line + message — instead of re-reading hundreds of
6
+ * log lines. The front-half of the test→fix loop: pair with `repair`. Shares the
7
+ * pure parser in ../testreport/testreport.ts with the CLI; string-in/JSON-out.
8
+ */
9
+ import { parseTestReport, detectFormat } from '../testreport/testreport.js';
10
+ const testreportTool = {
11
+ name: 'testreport',
12
+ description: 'Parse JUnit-XML or TAP test output into a structured summary: {passed, failed, skipped, total, durationMs, failures:[{suite,name,file,line,message}]}. Call this on a results file/string to get the exact failing tests + file:line instead of scanning logs; feed the failures into `repair`. Deterministic; auto-detects the format.',
13
+ category: 'testreport',
14
+ tags: ['tests', 'junit', 'tap', 'ci', 'failures'],
15
+ inputSchema: {
16
+ type: 'object',
17
+ properties: {
18
+ content: { type: 'string', description: 'the raw JUnit XML or TAP text' },
19
+ format: { type: 'string', enum: ['junit', 'tap'], description: 'force the input format (default: auto-detect)' },
20
+ },
21
+ required: ['content'],
22
+ },
23
+ handler: async (params) => {
24
+ if (typeof params.content !== 'string')
25
+ return { error: true, message: 'content (string) is required' };
26
+ const fmt = params.format === 'junit' || params.format === 'tap'
27
+ ? params.format
28
+ : detectFormat(params.content);
29
+ return { format: fmt, ...parseTestReport(params.content, fmt) };
30
+ },
31
+ };
32
+ export const testreportTools = [testreportTool];
33
+ //# sourceMappingURL=testreport-tools.js.map
@@ -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