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
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![swarmdo](swarmdo/assets/brand/logo-full.svg)](https://swarmdo.com)
4
4
 
5
- [![npm version (swarmdo)](https://img.shields.io/badge/npx%20swarmdo-v1.19.0-cb3837?style=for-the-badge&logo=npm&logoColor=white)](https://github.com/SwarmDo/swarmdo/releases)
5
+ [![npm version (swarmdo)](https://img.shields.io/badge/npx%20swarmdo-v1.27.0-cb3837?style=for-the-badge&logo=npm&logoColor=white)](https://github.com/SwarmDo/swarmdo/releases)
6
6
  [![MIT License](https://img.shields.io/badge/License-MIT-yellow?style=for-the-badge)](https://github.com/SwarmDo/swarmdo/blob/main/LICENSE)
7
7
  [![Website](https://img.shields.io/badge/swarmdo.com-e2a33c?style=for-the-badge&logoColor=black)](https://swarmdo.com)
8
8
  [![Star on GitHub](https://img.shields.io/github/stars/SwarmDo/swarmdo?style=for-the-badge&logo=github&color=gold)](https://github.com/SwarmDo/swarmdo)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "swarmdo",
3
- "version": "1.19.0",
3
+ "version": "1.27.0",
4
4
  "description": "Swarmdo - Enterprise AI agent orchestration for Claude Code. Deploy 60+ specialized agents in coordinated swarms with self-learning, fault-tolerant consensus, vector memory, and MCP integration",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -15,6 +15,8 @@ export interface HunkLine {
15
15
  /** ' ' context, '-' removal, '+' addition */
16
16
  type: ' ' | '-' | '+';
17
17
  content: string;
18
+ /** true if a `` marker immediately followed this line */
19
+ noEol?: boolean;
18
20
  }
19
21
  export interface Hunk {
20
22
  oldStart: number;
@@ -54,7 +54,10 @@ export function parsePatch(text) {
54
54
  hunk.lines.push({ type: ' ', content: '' }); // blank context line written without the leading space
55
55
  }
56
56
  else if (line.startsWith('\\')) {
57
- // "" — ignore
57
+ // "" — attaches to the preceding hunk line,
58
+ // recording that that side of the diff has no trailing newline.
59
+ if (hunk.lines.length)
60
+ hunk.lines[hunk.lines.length - 1].noEol = true;
58
61
  }
59
62
  else {
60
63
  hunk = null; // end of hunk block
@@ -119,6 +122,9 @@ export function applyPatch(source, patch, opts = {}) {
119
122
  lines.pop(); // drop the empty element from a trailing \n
120
123
  let offset = 0;
121
124
  const results = [];
125
+ // If a hunk that reaches EOF carries an explicit no-newline marker, it — not
126
+ // the source — decides the output's trailing newline. undefined = no marker.
127
+ let eofNoEol;
122
128
  for (const hunk of patch.hunks) {
123
129
  const { oldBlock, newBlock } = hunkBlocks(hunk);
124
130
  const expected = hunk.oldStart - 1 + offset;
@@ -157,12 +163,33 @@ export function applyPatch(source, patch, opts = {}) {
157
163
  lines.splice(placed, oldBlock.length, ...newBlock);
158
164
  offset += newBlock.length - oldBlock.length;
159
165
  results.push({ hunk, applied: true, at: placed, fuzzUsed: usedFuzz });
166
+ // If this hunk's new content is now the tail of the file, its markers
167
+ // determine whether the file ends with a newline.
168
+ if (placed + newBlock.length === lines.length)
169
+ eofNoEol = newSideEndsNoEol(hunk);
160
170
  }
161
171
  let result = lines.join('\n');
162
- if (trailingNewline)
172
+ // The patch's explicit EOF marker wins; absent one, keep the source's state.
173
+ const endWithNewline = eofNoEol === undefined ? trailingNewline : !eofNoEol;
174
+ if (endWithNewline)
163
175
  result += '\n';
164
176
  return { ok: results.every((r) => r.applied), result, hunks: results };
165
177
  }
178
+ /**
179
+ * Whether the NEW side of `hunk` ends without a trailing newline — read from the
180
+ * last context/addition line's marker. Returns undefined if the hunk carries no
181
+ * `\ No newline` marker at all (so the source's state should be kept). Pure.
182
+ */
183
+ function newSideEndsNoEol(hunk) {
184
+ if (!hunk.lines.some((l) => l.noEol))
185
+ return undefined;
186
+ for (let i = hunk.lines.length - 1; i >= 0; i--) {
187
+ const l = hunk.lines[i];
188
+ if (l.type === ' ' || l.type === '+')
189
+ return !!l.noEol;
190
+ }
191
+ return undefined;
192
+ }
166
193
  function countLeadingContext(hunk) {
167
194
  let n = 0;
168
195
  for (const l of hunk.lines) {
@@ -24,18 +24,41 @@ export const SECTIONS = [
24
24
  { type: 'chore', label: 'Chores', hidden: true },
25
25
  ];
26
26
  const SUBJECT_RE = /^(\w+)(?:\(([^)]+)\))?(!)?:\s+(.+)$/;
27
+ // git's default revert subject: `Revert "<original subject>"` (git-revert(1)).
28
+ // The conventional `revert:` type is handled by SUBJECT_RE; this catches the
29
+ // bare git form, which otherwise falls into the hidden `other` bucket.
30
+ const REVERT_RE = /^Revert\s+"(.+)"$/;
31
+ // Conventional Commits: a breaking-change footer MUST be the uppercase token
32
+ // `BREAKING CHANGE` (or `BREAKING-CHANGE`) at the start of a footer line,
33
+ // followed by `: `. Anchoring to a line start avoids a false positive when the
34
+ // phrase merely appears in prose ("this is not a BREAKING CHANGE, just …").
35
+ const BREAKING_RE = /^BREAKING[ -]CHANGE:\s/m;
27
36
  /** Parse one conventional-commit subject (+ optional body for BREAKING CHANGE). */
28
37
  export function parseCommit(hash, subject, body = '') {
29
- const m = SUBJECT_RE.exec(subject.trim());
38
+ const trimmed = subject.trim();
39
+ const rev = REVERT_RE.exec(trimmed);
40
+ if (rev) {
41
+ // Recurse into the quoted original subject to lift its scope/description.
42
+ const inner = SUBJECT_RE.exec(rev[1].trim());
43
+ return {
44
+ hash,
45
+ type: 'revert',
46
+ scope: inner ? inner[2] ?? null : null,
47
+ breaking: BREAKING_RE.test(body),
48
+ subject: inner ? inner[4].trim() : rev[1].trim(),
49
+ raw: trimmed,
50
+ };
51
+ }
52
+ const m = SUBJECT_RE.exec(trimmed);
30
53
  if (!m) {
31
- return { hash, type: null, scope: null, breaking: /BREAKING[ -]CHANGE/.test(body), subject: subject.trim(), raw: subject.trim() };
54
+ return { hash, type: null, scope: null, breaking: BREAKING_RE.test(body), subject: subject.trim(), raw: subject.trim() };
32
55
  }
33
56
  const [, type, scope, bang, desc] = m;
34
57
  return {
35
58
  hash,
36
59
  type: type.toLowerCase(),
37
60
  scope: scope ?? null,
38
- breaking: bang === '!' || /BREAKING[ -]CHANGE/.test(body),
61
+ breaking: bang === '!' || BREAKING_RE.test(body),
39
62
  subject: desc.trim(),
40
63
  raw: subject.trim(),
41
64
  };
@@ -31,6 +31,13 @@ export interface ImportEdge {
31
31
  resolved: string | null;
32
32
  /** 1-based line of the import */
33
33
  line: number;
34
+ /**
35
+ * True for a TypeScript type-only import/export (`import type …`,
36
+ * `export type … from …`). These are erased at compile time (with
37
+ * isolatedModules/verbatimModuleSyntax) so they create NO runtime dependency
38
+ * — relevant to cycle detection, where a type-only "cycle" is benign.
39
+ */
40
+ isTypeOnly?: boolean;
34
41
  }
35
42
  export interface CodeIndex {
36
43
  /** Repo-relative file → symbols declared in it. */
@@ -46,19 +53,31 @@ export declare function extractImports(source: string, file: string): Array<{
46
53
  from: string;
47
54
  spec: string;
48
55
  line: number;
56
+ isTypeOnly: boolean;
49
57
  }>;
50
58
  /**
51
- * Resolve a spec to a repo-relative file in `fileSet`, or null. Only relative
52
- * specs (`.`/`..`) resolve; bare specifiers (packages, aliases, `node:`) are
53
- * external null. Tries the path as-is, common extensions, and `/index.*`,
54
- * and rewrites a `.js` spec to its `.ts` sibling (TS ESM convention).
59
+ * A tsconfig `paths` / workspace alias: a bare-specifier pattern mapped to a
60
+ * repo-relative target (baseUrl already applied), each optionally containing a
61
+ * single star wildcard e.g. pattern `@swarmdo/[star]` target
62
+ * `v3/@swarmdo/[star]/src`, where `[star]` captures the remaining spec.
55
63
  */
56
- export declare function resolveImport(fromFile: string, spec: string, fileSet: Set<string>): string | null;
57
- /** Build an index from already-read files. Pure. */
64
+ export interface ImportAlias {
65
+ pattern: string;
66
+ target: string;
67
+ }
68
+ /**
69
+ * Resolve a spec to a repo-relative file in `fileSet`, or null. Relative specs
70
+ * (`.`/`..`) resolve against the importing file's dir. Bare specifiers resolve
71
+ * only if they match a tsconfig-`paths`/workspace `alias` — otherwise they're
72
+ * external (packages, `node:`) → null. Tries common extensions and `/index.*`,
73
+ * and rewrites a `.js` spec to its `.ts` sibling (TS ESM convention). Pure.
74
+ */
75
+ export declare function resolveImport(fromFile: string, spec: string, fileSet: Set<string>, aliases?: ImportAlias[]): string | null;
76
+ /** Build an index from already-read files. `aliases` resolve bare-specifier internal imports. Pure. */
58
77
  export declare function buildIndex(files: Array<{
59
78
  file: string;
60
79
  source: string;
61
- }>): CodeIndex;
80
+ }>, aliases?: ImportAlias[]): CodeIndex;
62
81
  export interface QueryOptions {
63
82
  /** Substring/case-insensitive match instead of exact. */
64
83
  fuzzy?: boolean;
@@ -28,6 +28,43 @@ const MATCHERS = [
28
28
  { kind: 'enum', re: /^export\s+(?:const\s+)?enum\s+([A-Za-z_$][\w$]*)/ },
29
29
  { kind: 'const', re: /^export\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)/ },
30
30
  ];
31
+ /**
32
+ * Top-level (comma-separated) simple-identifier declarator names in a
33
+ * const/let/var declaration list, e.g. `a = 1, b = fn(1, 2), c = 3` → [a,b,c].
34
+ * Commas inside parens/brackets/braces/strings don't split. Destructuring
35
+ * patterns (a segment starting with `{`/`[`) yield no name — kept as-is. Pure.
36
+ */
37
+ function declaratorNames(decl) {
38
+ const names = [];
39
+ let depth = 0;
40
+ let start = 0;
41
+ let str = null;
42
+ const take = (seg) => {
43
+ const m = seg.trim().match(/^([A-Za-z_$][\w$]*)/);
44
+ if (m)
45
+ names.push(m[1]);
46
+ };
47
+ for (let i = 0; i < decl.length; i++) {
48
+ const ch = decl[i];
49
+ if (str) {
50
+ if (ch === str && decl[i - 1] !== '\\')
51
+ str = null;
52
+ continue;
53
+ }
54
+ if (ch === '"' || ch === "'" || ch === '`')
55
+ str = ch;
56
+ else if (ch === '(' || ch === '[' || ch === '{')
57
+ depth++;
58
+ else if (ch === ')' || ch === ']' || ch === '}')
59
+ depth--;
60
+ else if (ch === ',' && depth === 0) {
61
+ take(decl.slice(start, i));
62
+ start = i + 1;
63
+ }
64
+ }
65
+ take(decl.slice(start));
66
+ return names;
67
+ }
31
68
  /** Extract exported symbols from one source file. Pure. */
32
69
  export function extractSymbols(source, file) {
33
70
  const out = [];
@@ -37,9 +74,29 @@ export function extractSymbols(source, file) {
37
74
  const trimmed = raw.trimStart();
38
75
  if (!trimmed.startsWith('export'))
39
76
  continue;
77
+ // `export * as ns from '…'` (ES2020) creates a real named export `ns` — a
78
+ // namespace binding — unlike bare `export * from '…'` which adds no local
79
+ // name. Index it (kind 'const', the closest value-binding kind) before the
80
+ // blanket `export *` skip below.
81
+ const nsRe = /^export\s+\*\s+as\s+([A-Za-z_$][\w$]*)\s+from\b/;
82
+ const nsm = trimmed.match(nsRe);
83
+ if (nsm) {
84
+ out.push({ name: nsm[1], kind: 'const', file, line: i + 1, signature: trimmed.slice(0, SIG_MAX).replace(/\s+$/, '') });
85
+ continue;
86
+ }
40
87
  // Skip re-export / bare forms handled elsewhere: `export {` and `export *`.
41
88
  if (/^export\s*[*{]/.test(trimmed))
42
89
  continue;
90
+ // const/let/var can declare MULTIPLE comma-separated bindings — index each
91
+ // (`export const a = 1, b = 2` → a AND b). Exclude `const enum` (handled by
92
+ // the enum matcher) and leave destructuring exports unindexed as before.
93
+ const declM = trimmed.match(/^export\s+(?:const|let|var)\s+(?!enum\b)(.*)$/);
94
+ if (declM) {
95
+ const sig = trimmed.slice(0, SIG_MAX).replace(/\s+$/, '');
96
+ for (const name of declaratorNames(declM[1]))
97
+ out.push({ name, kind: 'const', file, line: i + 1, signature: sig });
98
+ continue;
99
+ }
43
100
  for (const { kind, re } of MATCHERS) {
44
101
  const m = trimmed.match(re);
45
102
  if (!m)
@@ -77,6 +134,9 @@ export function extractImports(source, file) {
77
134
  const line = lines[i];
78
135
  if (!/\b(?:from|import|require)\b/.test(line))
79
136
  continue;
137
+ // A whole-import type-only form: `import type …` / `export type …`. NOT
138
+ // inline `import { type X }` (mixed with value imports → still a runtime edge).
139
+ const isTypeOnly = /^\s*(?:import|export)\s+type\b/.test(line);
80
140
  const seen = new Set();
81
141
  for (const re of IMPORT_RES) {
82
142
  re.lastIndex = 0;
@@ -86,7 +146,7 @@ export function extractImports(source, file) {
86
146
  if (seen.has(spec))
87
147
  continue; // same spec caught by two patterns on one line
88
148
  seen.add(spec);
89
- out.push({ from: file, spec, line: i + 1 });
149
+ out.push({ from: file, spec, line: i + 1, isTypeOnly });
90
150
  }
91
151
  }
92
152
  }
@@ -106,16 +166,20 @@ function joinRelative(fromFile, spec) {
106
166
  return parts.join('/');
107
167
  }
108
168
  const RESOLVE_EXTS = ['', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts'];
109
- /**
110
- * Resolve a spec to a repo-relative file in `fileSet`, or null. Only relative
111
- * specs (`.`/`..`) resolve; bare specifiers (packages, aliases, `node:`) are
112
- * external → null. Tries the path as-is, common extensions, and `/index.*`,
113
- * and rewrites a `.js` spec to its `.ts` sibling (TS ESM convention).
114
- */
115
- export function resolveImport(fromFile, spec, fileSet) {
116
- if (!spec.startsWith('.'))
169
+ /** Substitute a spec through one alias (with `*` wildcard), or null if no match. Pure. */
170
+ function applyAlias(pattern, target, spec) {
171
+ const star = pattern.indexOf('*');
172
+ if (star < 0)
173
+ return spec === pattern ? target : null;
174
+ const prefix = pattern.slice(0, star);
175
+ const suffix = pattern.slice(star + 1);
176
+ if (spec.length < prefix.length + suffix.length || !spec.startsWith(prefix) || !spec.endsWith(suffix))
117
177
  return null;
118
- const base = joinRelative(fromFile, spec);
178
+ const mid = spec.slice(prefix.length, spec.length - suffix.length);
179
+ return target.includes('*') ? target.replace('*', mid) : target;
180
+ }
181
+ /** Resolve a bare repo-relative base path to a file in `fileSet` (exts + /index + .js→.ts). Pure. */
182
+ function resolveInFileSet(base, fileSet) {
119
183
  const bases = [base];
120
184
  const jsExt = base.match(/\.(js|jsx|mjs|cjs)$/);
121
185
  if (jsExt)
@@ -132,15 +196,35 @@ export function resolveImport(fromFile, spec, fileSet) {
132
196
  }
133
197
  return null;
134
198
  }
135
- /** Build an index from already-read files. Pure. */
136
- export function buildIndex(files) {
199
+ /**
200
+ * Resolve a spec to a repo-relative file in `fileSet`, or null. Relative specs
201
+ * (`.`/`..`) resolve against the importing file's dir. Bare specifiers resolve
202
+ * only if they match a tsconfig-`paths`/workspace `alias` — otherwise they're
203
+ * external (packages, `node:`) → null. Tries common extensions and `/index.*`,
204
+ * and rewrites a `.js` spec to its `.ts` sibling (TS ESM convention). Pure.
205
+ */
206
+ export function resolveImport(fromFile, spec, fileSet, aliases = []) {
207
+ if (spec.startsWith('.'))
208
+ return resolveInFileSet(joinRelative(fromFile, spec), fileSet);
209
+ for (const a of aliases) {
210
+ const cand = applyAlias(a.pattern, a.target, spec);
211
+ if (cand == null)
212
+ continue;
213
+ const r = resolveInFileSet(cand, fileSet);
214
+ if (r)
215
+ return r;
216
+ }
217
+ return null;
218
+ }
219
+ /** Build an index from already-read files. `aliases` resolve bare-specifier internal imports. Pure. */
220
+ export function buildIndex(files, aliases = []) {
137
221
  const symbols = [];
138
222
  const fileSet = new Set(files.map((f) => f.file));
139
223
  const imports = [];
140
224
  for (const { file, source } of files) {
141
225
  symbols.push(...extractSymbols(source, file));
142
226
  for (const imp of extractImports(source, file)) {
143
- imports.push({ ...imp, resolved: resolveImport(imp.from, imp.spec, fileSet) });
227
+ imports.push({ ...imp, resolved: resolveImport(imp.from, imp.spec, fileSet, aliases) });
144
228
  }
145
229
  }
146
230
  // Stable order: file, then line.
@@ -4,11 +4,18 @@
4
4
  * Shared by the `codegraph` CLI command and the codegraph MCP tools so the
5
5
  * scan/persist behaviour can't drift between them.
6
6
  */
7
- import { type CodeIndex } from './codegraph.js';
7
+ import { type CodeIndex, type ImportAlias } from './codegraph.js';
8
8
  export declare const INDEX_REL: string;
9
9
  /** Recursively collect source files under `root`, skipping vendor/build dirs and .d.ts. */
10
10
  export declare function walkSourceFiles(root: string): string[];
11
11
  export declare function indexPath(root: string): string;
12
+ /**
13
+ * Read `<root>/tsconfig.json` and derive repo-relative import aliases from
14
+ * `compilerOptions.paths` (+ `baseUrl`). Tolerant of JSONC comments/trailing
15
+ * commas; returns [] on any problem (so bare-specifier imports stay external,
16
+ * as before). Only the first target of each path pattern is used. Pure-ish (fs read).
17
+ */
18
+ export declare function parseTsconfigAliases(root: string): ImportAlias[];
12
19
  /** Scan a repo (or a subpath of it) and build the index. `root` is the repo
13
20
  * root used for relative paths; `scanRoot` (default = root) is where to walk. */
14
21
  export declare function scanRepo(root: string, scanRoot?: string): CodeIndex;
@@ -53,6 +53,83 @@ function safeRead(abs) {
53
53
  export function indexPath(root) {
54
54
  return path.join(root, INDEX_REL);
55
55
  }
56
+ /**
57
+ * Strip JSONC `//` and block comments + trailing commas, STRING-AWARE so a `/*`
58
+ * inside a path pattern (e.g. `"@app/*"`, ubiquitous in tsconfig paths) isn't
59
+ * mistaken for a comment start. Pure.
60
+ */
61
+ function stripJsonc(s) {
62
+ let out = '';
63
+ let inStr = false, esc = false;
64
+ for (let i = 0; i < s.length; i++) {
65
+ const ch = s[i];
66
+ if (inStr) {
67
+ out += ch;
68
+ if (esc)
69
+ esc = false;
70
+ else if (ch === '\\')
71
+ esc = true;
72
+ else if (ch === '"')
73
+ inStr = false;
74
+ continue;
75
+ }
76
+ if (ch === '"') {
77
+ inStr = true;
78
+ out += ch;
79
+ continue;
80
+ }
81
+ if (ch === '/' && s[i + 1] === '/') {
82
+ while (i < s.length && s[i] !== '\n')
83
+ i++;
84
+ out += '\n';
85
+ continue;
86
+ }
87
+ if (ch === '/' && s[i + 1] === '*') {
88
+ i += 2;
89
+ while (i < s.length && !(s[i] === '*' && s[i + 1] === '/'))
90
+ i++;
91
+ i++;
92
+ continue;
93
+ }
94
+ out += ch;
95
+ }
96
+ return out.replace(/,(\s*[}\]])/g, '$1');
97
+ }
98
+ /**
99
+ * Read `<root>/tsconfig.json` and derive repo-relative import aliases from
100
+ * `compilerOptions.paths` (+ `baseUrl`). Tolerant of JSONC comments/trailing
101
+ * commas; returns [] on any problem (so bare-specifier imports stay external,
102
+ * as before). Only the first target of each path pattern is used. Pure-ish (fs read).
103
+ */
104
+ export function parseTsconfigAliases(root) {
105
+ let raw;
106
+ try {
107
+ raw = fs.readFileSync(path.join(root, 'tsconfig.json'), 'utf8');
108
+ }
109
+ catch {
110
+ return [];
111
+ }
112
+ const json = stripJsonc(raw);
113
+ let cfg;
114
+ try {
115
+ cfg = JSON.parse(json);
116
+ }
117
+ catch {
118
+ return [];
119
+ }
120
+ const co = cfg.compilerOptions ?? {};
121
+ if (!co.paths || typeof co.paths !== 'object')
122
+ return [];
123
+ const baseUrl = typeof co.baseUrl === 'string' ? co.baseUrl : '.';
124
+ const aliases = [];
125
+ for (const [pattern, targets] of Object.entries(co.paths)) {
126
+ const first = Array.isArray(targets) ? targets[0] : undefined;
127
+ if (typeof first !== 'string')
128
+ continue;
129
+ aliases.push({ pattern, target: path.join(baseUrl, first).split(path.sep).join('/') });
130
+ }
131
+ return aliases;
132
+ }
56
133
  /** Scan a repo (or a subpath of it) and build the index. `root` is the repo
57
134
  * root used for relative paths; `scanRoot` (default = root) is where to walk. */
58
135
  export function scanRepo(root, scanRoot = root) {
@@ -60,7 +137,7 @@ export function scanRepo(root, scanRoot = root) {
60
137
  const read = files
61
138
  .map((abs) => ({ file: path.relative(root, abs), source: safeRead(abs) }))
62
139
  .filter((f) => f.source !== null);
63
- return buildIndex(read);
140
+ return buildIndex(read, parseTsconfigAliases(root));
64
141
  }
65
142
  /** Persist the index to .swarm/codegraph.json under `root`. */
66
143
  export function saveIndex(root, index) {
@@ -15,6 +15,7 @@ async function run(ctx) {
15
15
  const root = ctx.cwd || process.cwd();
16
16
  const asJson = ctx.flags.format === 'json';
17
17
  const ci = ctx.flags.ci === true;
18
+ const includeTypeOnly = ctx.flags['include-type-only'] === true;
18
19
  let index = loadIndex(root);
19
20
  if (!index) {
20
21
  index = scanRepo(root);
@@ -23,7 +24,7 @@ async function run(ctx) {
23
24
  }
24
25
  catch { /* read-only fs — index is in memory */ }
25
26
  }
26
- const res = findCycles(index);
27
+ const res = findCycles(index, { includeTypeOnly });
27
28
  const count = res.cycles.length + res.selfLoops.length;
28
29
  if (asJson) {
29
30
  process.stdout.write(JSON.stringify({ count, ...res }, null, 2) + '\n');
@@ -40,10 +41,12 @@ export const cyclesCommand = {
40
41
  description: 'Find circular import dependencies via the import graph (madge --circular style) — catch the TDZ/undefined-export bugs they cause',
41
42
  options: [
42
43
  { name: 'ci', description: 'exit 1 if any circular dependency exists (gate a build)', type: 'boolean' },
44
+ { name: 'include-type-only', description: 'also count TypeScript `import type` edges (excluded by default — they erase at compile time and cause no runtime cycle)', type: 'boolean' },
43
45
  ],
44
46
  examples: [
45
47
  { command: 'swarmdo cycles', description: 'List circular imports in the repo' },
46
48
  { command: 'swarmdo cycles --ci', description: 'Fail CI when a cycle is introduced' },
49
+ { command: 'swarmdo cycles --include-type-only', description: 'Strict view: count type-only imports too' },
47
50
  ],
48
51
  action: run,
49
52
  };
@@ -33,8 +33,11 @@ async function run(ctx) {
33
33
  }
34
34
  // Global --format (text|json|table); text and table both render the table.
35
35
  const asJson = ctx.flags.format === 'json';
36
- // Capture history: SOH-delimited header + numstat, no merges.
37
- const args = ['log', '--no-merges', '--numstat', `--since=${since}`, '--format=format:%x01%H%x1f%an%x1f%aI'];
36
+ // Capture history: SOH-delimited header + numstat, no merges. `%aN` (not
37
+ // `%an`) resolves author names through `.mailmap`, so name/email variants of
38
+ // the same person fold into one identity — otherwise the author-spread factor
39
+ // in the risk score is silently inflated. Identical to `%an` when no .mailmap.
40
+ const args = ['log', '--no-merges', '--numstat', `--since=${since}`, '--format=format:%x01%H%x1f%aN%x1f%aI'];
38
41
  if (pathArg)
39
42
  args.push('--', pathArg);
40
43
  let raw;
@@ -15,6 +15,10 @@ import * as os from 'node:os';
15
15
  import { execFileSync } from 'node:child_process';
16
16
  import { output } from '../output.js';
17
17
  import { planRelease, renderStep } from '../release/release.js';
18
+ /** Escape every regex metacharacter (incl. backslash) so a value is a literal in `new RegExp`. */
19
+ function escapeRegExp(s) {
20
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
21
+ }
18
22
  function repoRootFrom(cwd) {
19
23
  try {
20
24
  return execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
@@ -47,7 +51,7 @@ function syncDocs(root, files, current, next) {
47
51
  let after = before;
48
52
  // exact patterns used by every release since 1.4.x
49
53
  after = after.replaceAll(`**Swarmdo v${current}** (`, `**Swarmdo v${next}** (`);
50
- after = after.replace(new RegExp(`\\*\\*Swarmdo v${next.replace(/\./g, '\\.')}\\*\\* \\(\\d{4}-\\d{2}-\\d{2}\\)`), `**Swarmdo v${next}** (${today})`);
54
+ after = after.replace(new RegExp(`\\*\\*Swarmdo v${escapeRegExp(next)}\\*\\* \\(\\d{4}-\\d{2}-\\d{2}\\)`), `**Swarmdo v${next}** (${today})`);
51
55
  after = after.replaceAll(`swarmdo@${current}\` (umbrella), \`@swarmdo/cli@${current}\`, \`swarmdo-bridge@${current}\``, `swarmdo@${next}\` (umbrella), \`@swarmdo/cli@${next}\`, \`swarmdo-bridge@${next}\``);
52
56
  // README badge + website carry the last PUBLISHED version, which lags
53
57
  // the repo version (`current`) between releases — match any semver in
@@ -98,7 +98,8 @@ async function run(ctx) {
98
98
  else {
99
99
  output.writeln(formatSummary(summary, { top: top > 0 ? top : undefined }));
100
100
  }
101
- const code = ci && summary.failed > 0 ? 1 : 0;
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;
102
103
  return { success: code === 0, exitCode: code };
103
104
  }
104
105
  export const testreportCommand = {
@@ -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 {