swarmdo 1.45.0 → 1.50.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.
- package/README.md +4 -2
- package/package.json +1 -1
- package/v3/@swarmdo/cli/dist/src/commands/config.js +32 -1
- package/v3/@swarmdo/cli/dist/src/commands/hidden-coupling.d.ts +19 -0
- package/v3/@swarmdo/cli/dist/src/commands/hidden-coupling.js +92 -0
- package/v3/@swarmdo/cli/dist/src/commands/index.js +9 -3
- package/v3/@swarmdo/cli/dist/src/commands/ownership.d.ts +18 -0
- package/v3/@swarmdo/cli/dist/src/commands/ownership.js +88 -0
- package/v3/@swarmdo/cli/dist/src/commands/pack.js +4 -4
- package/v3/@swarmdo/cli/dist/src/config-lint/commands-lint.d.ts +31 -0
- package/v3/@swarmdo/cli/dist/src/config-lint/commands-lint.js +127 -0
- package/v3/@swarmdo/cli/dist/src/config-lint/lint.d.ts +5 -0
- package/v3/@swarmdo/cli/dist/src/config-lint/lint.js +4 -0
- package/v3/@swarmdo/cli/dist/src/coupling/hidden.d.ts +45 -0
- package/v3/@swarmdo/cli/dist/src/coupling/hidden.js +66 -0
- package/v3/@swarmdo/cli/dist/src/mcp-client.js +2 -0
- package/v3/@swarmdo/cli/dist/src/mcp-tools/index.d.ts +1 -0
- package/v3/@swarmdo/cli/dist/src/mcp-tools/index.js +1 -0
- package/v3/@swarmdo/cli/dist/src/mcp-tools/ownership-tools.d.ts +14 -0
- package/v3/@swarmdo/cli/dist/src/mcp-tools/ownership-tools.js +51 -0
- package/v3/@swarmdo/cli/dist/src/mcp-tools/profiles.js +1 -0
- package/v3/@swarmdo/cli/dist/src/ownership/ownership.d.ts +61 -0
- package/v3/@swarmdo/cli/dist/src/ownership/ownership.js +151 -0
- package/v3/@swarmdo/cli/dist/src/pack/pack.d.ts +1 -1
- package/v3/@swarmdo/cli/dist/src/pack/pack.js +12 -4
- package/v3/@swarmdo/cli/dist/src/redact/redact.js +28 -9
- package/v3/@swarmdo/cli/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://swarmdo.com)
|
|
4
4
|
|
|
5
|
-
[](https://img.shields.io/badge/npx%20swarmdo-v1.50.0-cb3837?style=for-the-badge&logo=npm&logoColor=white)](https://github.com/SwarmDo/swarmdo/releases)
|
|
6
6
|
[](https://github.com/SwarmDo/swarmdo/blob/main/LICENSE)
|
|
7
7
|
[](https://swarmdo.com)
|
|
8
8
|
[](https://github.com/SwarmDo/swarmdo)
|
|
@@ -290,12 +290,14 @@ The recent release train added a full day-to-day operations layer around the swa
|
|
|
290
290
|
| `swarmdo affected` | **Run only the tests your change touches** — from a git diff, walk `codegraph`'s import graph to list every file (and test file) a change could break (reverse-dependency closure, nx/turbo/`jest --findRelatedTests` style). `--base main`, `--tests` (pipeable list), `--format json`. Also an MCP tool (`affected`). Deterministic |
|
|
291
291
|
| `swarmdo cycles` | **Find circular import dependencies** — an SCC scan over `codegraph`'s import graph surfaces the mutually-importing file groups (and self-imports) that cause temporal-dead-zone and `undefined`-export bugs, `madge --circular` style. `--ci` exits 1 on any cycle to gate a build; `--format json`. Also an MCP tool (`cycles`). Deterministic |
|
|
292
292
|
| `swarmdo coupling` | **Files that change together** — the empirical complement to `affected`'s static import graph: mine git history for file pairs that keep landing in the same commit (a schema and its type, a serializer split across modules) so you catch the co-edit you'd otherwise forget. `--file <path>` answers "what changes with X?", `--since`, `--min-shared`, `--csv`. Modeled on code-maat / CodeScene. Also an MCP tool (`coupling`). Deterministic |
|
|
293
|
+
| `swarmdo ownership` | **Who owns each file, and what breaks if they leave** — a per-file knowledge map + bus factor from git history: the dominant author, ownership concentration, and the fewest authors whose churn clears 50% (a lone owner is flagged ⚠ key-person), plus a repo-wide truck factor. `--since`, `--min-churn`, `--top`, `--csv`, `--format json`. code-maat main-dev / CodeScene "Knowledge Map"; also an MCP tool (`ownership`). Deterministic |
|
|
294
|
+
| `swarmdo hidden-coupling` | **Co-change with no import edge** — join the two graphs swarmdo already owns (temporal `coupling` from git + `codegraph`'s import graph) and emit the set difference: file pairs that keep changing together yet nothing in the code links them ("logical minus structural coupling" — a config and its consumers, a schema and its mirror type). The co-edit `affected` can't see. `--since`, `--min-shared`, `--csv`, `--format json`. Grounded in Gall et al. (ICSM 1998). Deterministic |
|
|
293
295
|
| `swarmdo testreport` | **JUnit/TAP → failure digest** — turn raw test-result files into the exact failing test names + `file:line` + assertion messages, instead of scanning hundreds of log lines. The front-half of the test→fix loop: feed the failures straight into `repair`. Reads a file, a directory, or stdin; `--ci` exits 1 on any failure, `--format json`. Also an MCP tool (`testreport`). Deterministic |
|
|
294
296
|
| `swarmdo integrations` (alias `integrate`) | **Use swarmdo from Codex CLI, GitHub Copilot CLI, and pi** — one command wires AGENTS.md + each CLI's MCP config (idempotent, dry-run first, never touches your Claude Code setup) |
|
|
295
297
|
| OpenRouter model pool | **Let swarms pick from any models you configure** — declare tier-mapped OpenRouter models in `swarmdo.config.json`; the router Thompson-samples among them per task and the execution layer dispatches the winner |
|
|
296
298
|
| `swarmdo changelog` (alias `notes`) | **Release notes from conventional commits** — `--out NOTES.md` feeds `gh release create --notes-file`; `--contributors` appends a credited contributor roll |
|
|
297
299
|
| `swarmdo mcp doctor` | **MCP config diagnosis** — missing binaries, bad URLs, malformed entries across `.mcp.json` + `~/.claude.json` |
|
|
298
|
-
| `swarmdo config lint` | **Static config validation** — the pure shape layer for `swarmdo.config.json`, the `.claude/settings*.json` hooks block, `.mcp.json`,
|
|
300
|
+
| `swarmdo config lint` | **Static config validation** — the pure shape layer for `swarmdo.config.json`, the `.claude/settings*.json` hooks block, `.mcp.json`, **`.claude/agents/*.md` subagents** (missing `name`/`description`, a `name` duplicated across files — CC silently loads only one — a bad `model`, malformed frontmatter), and **custom slash commands + skills** (malformed YAML that makes CC load empty metadata, a bad `effort`, an inline `` !`cmd` `` bash-injection that's inert or not covered by `allowed-tools`). `--strict` gates CI |
|
|
299
301
|
| `swarmdo permissions` (alias `perms`) | **Audit your Claude Code permission rules** — static analysis of `permissions.allow`/`deny`/`ask` in `.claude/settings*.json`: flags allow↔deny conflicts (dead rules), over-broad `Bash(*)` grants, shadowed/redundant rules, duplicates, and malformed entries. `--strict` gates CI. Read-only; the static-safety sibling of `config lint` / `mcp doctor` |
|
|
300
302
|
| `swarmdo comms` (alias `mailbox`) | **Cross-session agent mailbox** — one Claude Code session messages another by name (`send -t <session>`, `-t all` broadcasts, `inbox`, `read`, `watch`); sessions on the same repo share `.swarmdo/comms/`. `inbox --hook` surfaces new mail as prompt context without polling. Also MCP tools (`comms_send`/`comms_inbox`) |
|
|
301
303
|
| `swarmdo hooks memory-inject` | **Prompt-time semantic memory injection** — embeds each prompt, vector-searches your stored memories, and injects the most relevant under a token budget (recall at the moment of need); wire it with `hooks recipe memory-inject` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "swarmdo",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.50.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",
|
|
@@ -400,7 +400,7 @@ const importCommand = {
|
|
|
400
400
|
const lintCommand = {
|
|
401
401
|
name: 'lint',
|
|
402
402
|
aliases: ['validate'],
|
|
403
|
-
description: 'Statically validate swarmdo.config.json, .claude/settings*.json hooks, .mcp.json, .claude/agents/*.md subagents, and the post-1.4 sDo layout',
|
|
403
|
+
description: 'Statically validate swarmdo.config.json, .claude/settings*.json hooks, .mcp.json, .claude/agents/*.md subagents, .claude/commands + skills slash-command frontmatter/body, and the post-1.4 sDo layout',
|
|
404
404
|
options: [
|
|
405
405
|
{ name: 'json', type: 'boolean', description: 'machine-readable findings', default: false },
|
|
406
406
|
{ name: 'strict', type: 'boolean', description: 'exit 1 on warnings too (default: errors only)', default: false },
|
|
@@ -437,6 +437,35 @@ const lintCommand = {
|
|
|
437
437
|
.filter((n) => n.endsWith('.md'))
|
|
438
438
|
.map((n) => read(`.claude/agents/${n}`))
|
|
439
439
|
.filter((x) => x.raw !== null);
|
|
440
|
+
// Custom slash commands nest (e.g. `.claude/commands/sDo/*.md`), so walk the
|
|
441
|
+
// tree; skills live one dir deep as `.claude/skills/<name>/SKILL.md`.
|
|
442
|
+
const walkMd = (rel) => {
|
|
443
|
+
const acc = [];
|
|
444
|
+
let entries;
|
|
445
|
+
try {
|
|
446
|
+
entries = fs.readdirSync(path.join(cwd, rel), { withFileTypes: true });
|
|
447
|
+
}
|
|
448
|
+
catch {
|
|
449
|
+
return acc;
|
|
450
|
+
}
|
|
451
|
+
for (const e of entries) {
|
|
452
|
+
if (e.name.startsWith('.'))
|
|
453
|
+
continue;
|
|
454
|
+
const childRel = `${rel}/${e.name}`;
|
|
455
|
+
if (e.isDirectory())
|
|
456
|
+
acc.push(...walkMd(childRel));
|
|
457
|
+
else if (e.isFile() && e.name.endsWith('.md')) {
|
|
458
|
+
const r = read(childRel);
|
|
459
|
+
if (r.raw !== null)
|
|
460
|
+
acc.push({ file: childRel, raw: r.raw });
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return acc;
|
|
464
|
+
};
|
|
465
|
+
const commandFiles = walkMd('.claude/commands');
|
|
466
|
+
const skillFiles = list('.claude/skills')
|
|
467
|
+
.map((dir) => read(`.claude/skills/${dir}/SKILL.md`))
|
|
468
|
+
.filter((x) => x.raw !== null);
|
|
440
469
|
const report = lintAll({
|
|
441
470
|
swarmdoConfig: read(configRel),
|
|
442
471
|
settingsFiles: [read('.claude/settings.json'), read('.claude/settings.local.json')],
|
|
@@ -445,6 +474,8 @@ const lintCommand = {
|
|
|
445
474
|
sdoCommands: list('.claude/commands/sDo'),
|
|
446
475
|
skills: list('.claude/skills'),
|
|
447
476
|
agentFiles,
|
|
477
|
+
commandFiles,
|
|
478
|
+
skillFiles,
|
|
448
479
|
});
|
|
449
480
|
const failed = report.errors > 0 || (ctx.flags.strict === true && report.warnings > 0);
|
|
450
481
|
if (ctx.flags.json === true) {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `swarmdo hidden-coupling` — files that CHANGE TOGETHER but have no import edge.
|
|
3
|
+
*
|
|
4
|
+
* swarmdo hidden-coupling # co-change pairs the code doesn't explain
|
|
5
|
+
* swarmdo hidden-coupling --since 6mo --min-shared 3
|
|
6
|
+
* swarmdo hidden-coupling --format json | --csv
|
|
7
|
+
*
|
|
8
|
+
* "Logical coupling minus structural coupling": pairs with a high co-change
|
|
9
|
+
* `degree` (from `coupling`) yet NO import edge connecting them (from
|
|
10
|
+
* `codegraph`). A dependency real enough to move two files in lockstep but
|
|
11
|
+
* invisible in the code — the co-edit an agent following imports would miss, or
|
|
12
|
+
* a missing abstraction a reviewer should question. Composes the two capture
|
|
13
|
+
* paths verbatim: git-log + `computeCoupling`, and the codegraph index load.
|
|
14
|
+
* Engine (../coupling/hidden.ts) is pure + tested.
|
|
15
|
+
*/
|
|
16
|
+
import type { Command } from '../types.js';
|
|
17
|
+
export declare const hiddenCouplingCommand: Command;
|
|
18
|
+
export default hiddenCouplingCommand;
|
|
19
|
+
//# sourceMappingURL=hidden-coupling.d.ts.map
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `swarmdo hidden-coupling` — files that CHANGE TOGETHER but have no import edge.
|
|
3
|
+
*
|
|
4
|
+
* swarmdo hidden-coupling # co-change pairs the code doesn't explain
|
|
5
|
+
* swarmdo hidden-coupling --since 6mo --min-shared 3
|
|
6
|
+
* swarmdo hidden-coupling --format json | --csv
|
|
7
|
+
*
|
|
8
|
+
* "Logical coupling minus structural coupling": pairs with a high co-change
|
|
9
|
+
* `degree` (from `coupling`) yet NO import edge connecting them (from
|
|
10
|
+
* `codegraph`). A dependency real enough to move two files in lockstep but
|
|
11
|
+
* invisible in the code — the co-edit an agent following imports would miss, or
|
|
12
|
+
* a missing abstraction a reviewer should question. Composes the two capture
|
|
13
|
+
* paths verbatim: git-log + `computeCoupling`, and the codegraph index load.
|
|
14
|
+
* Engine (../coupling/hidden.ts) is pure + tested.
|
|
15
|
+
*/
|
|
16
|
+
import { execFileSync } from 'node:child_process';
|
|
17
|
+
import { output } from '../output.js';
|
|
18
|
+
import { parseGitLog } from '../hotspots/hotspots.js';
|
|
19
|
+
import { computeCoupling } from '../coupling/coupling.js';
|
|
20
|
+
import { computeHiddenCoupling, formatHiddenCoupling, hiddenCouplingToCsv } from '../coupling/hidden.js';
|
|
21
|
+
import { loadIndex, scanRepo, saveIndex } from '../codegraph/store.js';
|
|
22
|
+
import { normalizeSince } from '../util/since.js';
|
|
23
|
+
/** Read a numeric flag that the parser may deliver as a number OR a string. */
|
|
24
|
+
function numFlag(v, def) {
|
|
25
|
+
const n = typeof v === 'number' ? v : typeof v === 'string' ? parseInt(v, 10) : NaN;
|
|
26
|
+
return Number.isFinite(n) ? n : def;
|
|
27
|
+
}
|
|
28
|
+
async function run(ctx) {
|
|
29
|
+
const root = ctx.cwd || process.cwd();
|
|
30
|
+
const since = typeof ctx.flags.since === 'string' ? ctx.flags.since : '1 year ago';
|
|
31
|
+
const top = numFlag(ctx.flags.top, 30);
|
|
32
|
+
const minShared = numFlag(ctx.flags['min-shared'], 2);
|
|
33
|
+
const maxFiles = numFlag(ctx.flags['max-files'], 30);
|
|
34
|
+
const asJson = ctx.flags.format === 'json';
|
|
35
|
+
const asCsv = ctx.flags.csv === true;
|
|
36
|
+
// Capture 1: the full co-change history (same `--numstat` dump `coupling` uses;
|
|
37
|
+
// NO pathspec filter — that would strip co-changed files from each commit).
|
|
38
|
+
const args = ['log', '--no-merges', '--numstat', `--since=${normalizeSince(since)}`, '--format=format:%x01%H%x1f%aN%x1f%aI'];
|
|
39
|
+
let raw;
|
|
40
|
+
try {
|
|
41
|
+
raw = execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 128 * 1024 * 1024 });
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
output.printError('git log failed — is this a git repository?');
|
|
45
|
+
return { success: false, exitCode: 1 };
|
|
46
|
+
}
|
|
47
|
+
// Rank ALL coupling pairs (no top yet — we cap the HIDDEN set after filtering).
|
|
48
|
+
const pairs = computeCoupling(parseGitLog(raw), { minShared, maxFiles });
|
|
49
|
+
// Capture 2: the static import graph (prefer a saved index; build one if absent).
|
|
50
|
+
let index = loadIndex(root);
|
|
51
|
+
if (!index) {
|
|
52
|
+
index = scanRepo(root);
|
|
53
|
+
try {
|
|
54
|
+
saveIndex(root, index);
|
|
55
|
+
}
|
|
56
|
+
catch { /* read-only fs — in-memory is fine */ }
|
|
57
|
+
}
|
|
58
|
+
const hidden = computeHiddenCoupling(pairs, index.imports, { top: top > 0 ? top : undefined });
|
|
59
|
+
if (asCsv) {
|
|
60
|
+
process.stdout.write(hiddenCouplingToCsv(hidden) + '\n');
|
|
61
|
+
}
|
|
62
|
+
else if (asJson) {
|
|
63
|
+
process.stdout.write(JSON.stringify({ generated: new Date().toISOString(), since, minShared, coupled: pairs.length, count: hidden.length, hidden }, null, 2) + '\n');
|
|
64
|
+
}
|
|
65
|
+
else if (hidden.length === 0) {
|
|
66
|
+
output.writeln(output.dim(`no hidden coupling found — every co-change pair has an import edge (or none met --min-shared ${minShared} / --since ${since})`));
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
output.writeln(output.bold(`Hidden coupling (co-change with NO import edge, since ${since}, min-shared ${minShared})`));
|
|
70
|
+
output.writeln(formatHiddenCoupling(hidden));
|
|
71
|
+
output.writeln(output.dim(`${hidden.length} hidden of ${pairs.length} coupled pair(s) — these move together but nothing in the code links them`));
|
|
72
|
+
}
|
|
73
|
+
return { success: true, exitCode: 0 };
|
|
74
|
+
}
|
|
75
|
+
export const hiddenCouplingCommand = {
|
|
76
|
+
name: 'hidden-coupling',
|
|
77
|
+
description: 'Rank file pairs that change together in git history but have NO import edge (logical minus structural coupling) — the co-edit `affected` can\'t see',
|
|
78
|
+
options: [
|
|
79
|
+
{ name: 'since', description: 'history window, e.g. 90d or "3 months ago" (default 1 year)', type: 'string' },
|
|
80
|
+
{ name: 'min-shared', description: 'drop pairs sharing fewer than N commits (default 2)', type: 'string' },
|
|
81
|
+
{ name: 'max-files', description: 'skip commits touching more than N files (default 30; 0 = no cap)', type: 'string' },
|
|
82
|
+
{ name: 'top', description: 'keep only the top N hidden pairs (default 30; 0 = all)', type: 'string' },
|
|
83
|
+
{ name: 'csv', description: 'export the ranking as CSV (for spreadsheets)', type: 'boolean' },
|
|
84
|
+
],
|
|
85
|
+
examples: [
|
|
86
|
+
{ command: 'swarmdo hidden-coupling --since 6mo --min-shared 3', description: 'Strong co-change pairs with no code link, last 6 months' },
|
|
87
|
+
{ command: 'swarmdo hidden-coupling --format json', description: 'Machine-readable hidden-coupling report' },
|
|
88
|
+
],
|
|
89
|
+
action: run,
|
|
90
|
+
};
|
|
91
|
+
export default hiddenCouplingCommand;
|
|
92
|
+
//# sourceMappingURL=hidden-coupling.js.map
|
|
@@ -54,6 +54,12 @@ const commandLoaders = {
|
|
|
54
54
|
// Temporal (co-change) coupling from git history (code-maat demand) — file
|
|
55
55
|
// pairs that change together; the empirical complement to `affected`.
|
|
56
56
|
coupling: () => import('./coupling.js'),
|
|
57
|
+
// Per-file knowledge map + bus factor from git history (code-maat main-dev
|
|
58
|
+
// demand) — who owns each file, and what breaks if they leave.
|
|
59
|
+
ownership: () => import('./ownership.js'),
|
|
60
|
+
// Hidden coupling — co-change pairs with NO import edge (logical minus
|
|
61
|
+
// structural coupling); joins `coupling`'s git capture with codegraph's graph.
|
|
62
|
+
'hidden-coupling': () => import('./hidden-coupling.js'),
|
|
57
63
|
// JUnit/TAP test-result parser (dorny/test-reporter demand) — raw results →
|
|
58
64
|
// failing test + file:line + message; the front-half of the test→fix loop.
|
|
59
65
|
testreport: () => import('./testreport.js'),
|
|
@@ -296,7 +302,7 @@ export async function getCommandsByCategory() {
|
|
|
296
302
|
// three slots from 'statusline' onward (completionsCmd received the
|
|
297
303
|
// statusline module, analyzeCmd received completions, …), scrambling the
|
|
298
304
|
// categorized help. Every load now has a named slot.
|
|
299
|
-
const [daemonCmd, doctorCmd, embeddingsCmd, neuralCmd, performanceCmd, securityCmd, swarmvectorCmd, hiveMindCmd, configCmd, statuslineCmd, compressCmd, efficiencyCmd, completionsCmd, migrateCmd, workflowCmd, analyzeCmd, routeCmd, progressCmd, providersCmd, pluginsCmd, deploymentCmd, claimsCmd, issuesCmd, updateCmd, processCmd, guidanceCmd, applianceCmd, cleanupCmd, autopilotCmd, demoCmd, usageCmd, repairCmd, hudCmd, compactCmd, codegraphCmd, redactCmd, packCmd, envCmd, licenseCmd, sbomCmd, applyCmd, hotspotsCmd, affectedCmd, cyclesCmd, couplingCmd, testreportCmd, compactSnapshotCmd,] = await Promise.all([
|
|
305
|
+
const [daemonCmd, doctorCmd, embeddingsCmd, neuralCmd, performanceCmd, securityCmd, swarmvectorCmd, hiveMindCmd, configCmd, statuslineCmd, compressCmd, efficiencyCmd, completionsCmd, migrateCmd, workflowCmd, analyzeCmd, routeCmd, progressCmd, providersCmd, pluginsCmd, deploymentCmd, claimsCmd, issuesCmd, updateCmd, processCmd, guidanceCmd, applianceCmd, cleanupCmd, autopilotCmd, demoCmd, usageCmd, repairCmd, hudCmd, compactCmd, codegraphCmd, redactCmd, packCmd, envCmd, licenseCmd, sbomCmd, applyCmd, hotspotsCmd, affectedCmd, cyclesCmd, couplingCmd, ownershipCmd, hiddenCouplingCmd, testreportCmd, compactSnapshotCmd,] = await Promise.all([
|
|
300
306
|
loadCommand('daemon'), loadCommand('doctor'), loadCommand('embeddings'), loadCommand('neural'),
|
|
301
307
|
loadCommand('performance'), loadCommand('security'), loadCommand('swarmvector'), loadCommand('hive-mind'),
|
|
302
308
|
loadCommand('config'), loadCommand('statusline'), loadCommand('compress'), loadCommand('efficiency'),
|
|
@@ -304,7 +310,7 @@ export async function getCommandsByCategory() {
|
|
|
304
310
|
loadCommand('analyze'), loadCommand('route'), loadCommand('progress'), loadCommand('providers'),
|
|
305
311
|
loadCommand('plugins'), loadCommand('deployment'), loadCommand('claims'), loadCommand('issues'),
|
|
306
312
|
loadCommand('update'), loadCommand('process'), loadCommand('guidance'), loadCommand('appliance'),
|
|
307
|
-
loadCommand('cleanup'), loadCommand('autopilot'), loadCommand('demo'), loadCommand('usage'), loadCommand('repair'), loadCommand('hud'), loadCommand('compact'), loadCommand('codegraph'), loadCommand('redact'), loadCommand('pack'), loadCommand('env'), loadCommand('license'), loadCommand('sbom'), loadCommand('apply'), loadCommand('hotspots'), loadCommand('affected'), loadCommand('cycles'), loadCommand('coupling'), loadCommand('testreport'), loadCommand('compact-snapshot'),
|
|
313
|
+
loadCommand('cleanup'), loadCommand('autopilot'), loadCommand('demo'), loadCommand('usage'), loadCommand('repair'), loadCommand('hud'), loadCommand('compact'), loadCommand('codegraph'), loadCommand('redact'), loadCommand('pack'), loadCommand('env'), loadCommand('license'), loadCommand('sbom'), loadCommand('apply'), loadCommand('hotspots'), loadCommand('affected'), loadCommand('cycles'), loadCommand('coupling'), loadCommand('ownership'), loadCommand('hidden-coupling'), loadCommand('testreport'), loadCommand('compact-snapshot'),
|
|
308
314
|
]);
|
|
309
315
|
return {
|
|
310
316
|
primary: [
|
|
@@ -320,7 +326,7 @@ export async function getCommandsByCategory() {
|
|
|
320
326
|
utility: [
|
|
321
327
|
configCmd, doctorCmd, daemonCmd, completionsCmd,
|
|
322
328
|
migrateCmd, workflowCmd, demoCmd,
|
|
323
|
-
statuslineCmd, compressCmd, compactCmd, redactCmd, packCmd, envCmd, licenseCmd, sbomCmd, applyCmd, hotspotsCmd, affectedCmd, cyclesCmd, couplingCmd, testreportCmd, compactSnapshotCmd, efficiencyCmd,
|
|
329
|
+
statuslineCmd, compressCmd, compactCmd, redactCmd, packCmd, envCmd, licenseCmd, sbomCmd, applyCmd, hotspotsCmd, affectedCmd, cyclesCmd, couplingCmd, ownershipCmd, hiddenCouplingCmd, testreportCmd, compactSnapshotCmd, efficiencyCmd,
|
|
324
330
|
].filter(Boolean),
|
|
325
331
|
analysis: [
|
|
326
332
|
analyzeCmd, routeCmd, progressCmd, usageCmd, hudCmd, codegraphCmd,
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `swarmdo ownership` — per-file knowledge map + BUS FACTOR mined from git history.
|
|
3
|
+
*
|
|
4
|
+
* swarmdo ownership # knowledge map, most fragile files first
|
|
5
|
+
* swarmdo ownership src --since 90d # scope to a path + window
|
|
6
|
+
* swarmdo ownership --top 10 --format json
|
|
7
|
+
* swarmdo ownership --csv > ownership.csv
|
|
8
|
+
*
|
|
9
|
+
* "Who owns each file, and what breaks if they leave?" — the code-maat
|
|
10
|
+
* main-dev / bus-factor analysis. Files where one author owns nearly all the
|
|
11
|
+
* churn (bus factor 1) are key-person risks. Pairs with `hotspots` (change-risk)
|
|
12
|
+
* and `coupling` (co-change). Engine (../ownership/ownership.ts) is pure +
|
|
13
|
+
* tested; this captures the git log (the same `--numstat` dump those use).
|
|
14
|
+
*/
|
|
15
|
+
import type { Command } from '../types.js';
|
|
16
|
+
export declare const ownershipCommand: Command;
|
|
17
|
+
export default ownershipCommand;
|
|
18
|
+
//# sourceMappingURL=ownership.d.ts.map
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `swarmdo ownership` — per-file knowledge map + BUS FACTOR mined from git history.
|
|
3
|
+
*
|
|
4
|
+
* swarmdo ownership # knowledge map, most fragile files first
|
|
5
|
+
* swarmdo ownership src --since 90d # scope to a path + window
|
|
6
|
+
* swarmdo ownership --top 10 --format json
|
|
7
|
+
* swarmdo ownership --csv > ownership.csv
|
|
8
|
+
*
|
|
9
|
+
* "Who owns each file, and what breaks if they leave?" — the code-maat
|
|
10
|
+
* main-dev / bus-factor analysis. Files where one author owns nearly all the
|
|
11
|
+
* churn (bus factor 1) are key-person risks. Pairs with `hotspots` (change-risk)
|
|
12
|
+
* and `coupling` (co-change). Engine (../ownership/ownership.ts) is pure +
|
|
13
|
+
* tested; this captures the git log (the same `--numstat` dump those use).
|
|
14
|
+
*/
|
|
15
|
+
import { execFileSync } from 'node:child_process';
|
|
16
|
+
import { output } from '../output.js';
|
|
17
|
+
import { parseGitLog } from '../hotspots/hotspots.js';
|
|
18
|
+
import { computeOwnership, repoBusFactor, formatOwnership, ownershipToCsv } from '../ownership/ownership.js';
|
|
19
|
+
import { normalizeSince } from '../util/since.js';
|
|
20
|
+
/** Read a numeric flag that the parser may deliver as a number OR a string. */
|
|
21
|
+
function numFlag(v, def) {
|
|
22
|
+
const n = typeof v === 'number' ? v : typeof v === 'string' ? parseInt(v, 10) : NaN;
|
|
23
|
+
return Number.isFinite(n) ? n : def;
|
|
24
|
+
}
|
|
25
|
+
async function run(ctx) {
|
|
26
|
+
const root = ctx.cwd || process.cwd();
|
|
27
|
+
const pathArg = ctx.args[0];
|
|
28
|
+
const since = typeof ctx.flags.since === 'string' ? ctx.flags.since : '1 year ago';
|
|
29
|
+
const top = numFlag(ctx.flags.top, 40);
|
|
30
|
+
const minChurn = numFlag(ctx.flags['min-churn'], 1);
|
|
31
|
+
const asJson = ctx.flags.format === 'json';
|
|
32
|
+
const asCsv = ctx.flags.csv === true;
|
|
33
|
+
// Same SOH-delimited numstat capture `hotspots` uses. `%aN` folds author
|
|
34
|
+
// name/email variants through `.mailmap` so one person is one owner. A
|
|
35
|
+
// positional pathspec scopes BOTH the file rows and the truck factor to that
|
|
36
|
+
// subtree (unlike `coupling`, per-file ownership survives a pathspec fine).
|
|
37
|
+
const args = ['log', '--no-merges', '--numstat', `--since=${normalizeSince(since)}`, '--format=format:%x01%H%x1f%aN%x1f%aI'];
|
|
38
|
+
if (pathArg)
|
|
39
|
+
args.push('--', pathArg);
|
|
40
|
+
let raw;
|
|
41
|
+
try {
|
|
42
|
+
raw = execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 128 * 1024 * 1024 });
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
output.printError('git log failed — is this a git repository?');
|
|
46
|
+
return { success: false, exitCode: 1 };
|
|
47
|
+
}
|
|
48
|
+
const commits = parseGitLog(raw);
|
|
49
|
+
const files = computeOwnership(commits, { minChurn, top: top > 0 ? top : undefined });
|
|
50
|
+
const repo = repoBusFactor(commits);
|
|
51
|
+
if (asCsv) {
|
|
52
|
+
process.stdout.write(ownershipToCsv(files) + '\n');
|
|
53
|
+
}
|
|
54
|
+
else if (asJson) {
|
|
55
|
+
process.stdout.write(JSON.stringify({ generated: new Date().toISOString(), since, minChurn, repoBusFactor: repo, count: files.length, ownership: files }, null, 2) + '\n');
|
|
56
|
+
}
|
|
57
|
+
else if (files.length === 0) {
|
|
58
|
+
output.writeln(output.dim('no ownership data found — no matching git history in the window'));
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
output.writeln(output.bold(`File ownership & bus factor (since ${since})`));
|
|
62
|
+
if (repo.factor > 0) {
|
|
63
|
+
const who = repo.authors.length <= 3 ? repo.authors.join(', ') : `${repo.authors.slice(0, 3).join(', ')} +${repo.authors.length - 3}`;
|
|
64
|
+
const flag = repo.factor === 1 ? ' ⚠ single point of knowledge' : '';
|
|
65
|
+
output.writeln(output.dim(`Repo truck factor: ${repo.factor} (${who} own >50% of churn)${flag}`));
|
|
66
|
+
}
|
|
67
|
+
output.writeln(formatOwnership(files));
|
|
68
|
+
}
|
|
69
|
+
return { success: true, exitCode: 0 };
|
|
70
|
+
}
|
|
71
|
+
export const ownershipCommand = {
|
|
72
|
+
name: 'ownership',
|
|
73
|
+
description: 'Map per-file authorship concentration + bus factor from git history — find the key-person risks (code-maat main-dev / knowledge map)',
|
|
74
|
+
options: [
|
|
75
|
+
{ name: 'since', description: 'history window, e.g. 90d or "3 months ago" (default 1 year)', type: 'string' },
|
|
76
|
+
{ name: 'top', description: 'keep only the top N files (default 40; 0 = all)', type: 'string' },
|
|
77
|
+
{ name: 'min-churn', description: 'drop files with total churn below N (default 1)', type: 'string' },
|
|
78
|
+
{ name: 'csv', description: 'export the knowledge map as CSV (for spreadsheets)', type: 'boolean' },
|
|
79
|
+
],
|
|
80
|
+
examples: [
|
|
81
|
+
{ command: 'swarmdo ownership src --since 6mo', description: 'Knowledge map under src/ in the last 6 months' },
|
|
82
|
+
{ command: 'swarmdo ownership --top 10 --format json', description: 'Ten most fragile files as JSON, with repo truck factor' },
|
|
83
|
+
{ command: 'swarmdo ownership --csv > ownership.csv', description: 'Export the ownership map to CSV' },
|
|
84
|
+
],
|
|
85
|
+
action: run,
|
|
86
|
+
};
|
|
87
|
+
export default ownershipCommand;
|
|
88
|
+
//# sourceMappingURL=ownership.js.map
|
|
@@ -78,13 +78,13 @@ function walk(o) {
|
|
|
78
78
|
if (e.isDirectory()) {
|
|
79
79
|
if (SKIP_DIRS.has(e.name))
|
|
80
80
|
continue;
|
|
81
|
-
if (o.gitignore?.(rel
|
|
82
|
-
continue;
|
|
81
|
+
if (o.gitignore?.(rel, true))
|
|
82
|
+
continue; // isDir=true → dir-only patterns (build/) prune the dir + its contents
|
|
83
83
|
stack.push(full);
|
|
84
84
|
}
|
|
85
85
|
else if (e.isFile()) {
|
|
86
|
-
if (o.gitignore?.(rel))
|
|
87
|
-
continue;
|
|
86
|
+
if (o.gitignore?.(rel, false))
|
|
87
|
+
continue; // isDir=false → a dir-only pattern (build/) must NOT drop a file named `build`
|
|
88
88
|
if (o.exclude?.(rel))
|
|
89
89
|
continue;
|
|
90
90
|
if (o.include && !o.include(rel))
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* commands-lint.ts — static validation of Claude Code custom slash commands
|
|
3
|
+
* (the `.claude/commands` tree) and skills (`.claude/skills/<name>/SKILL.md`).
|
|
4
|
+
*
|
|
5
|
+
* Slash commands / skills are the most-copied Claude Code power-user artifact,
|
|
6
|
+
* and CC swallows their footguns silently: malformed YAML frontmatter makes CC
|
|
7
|
+
* load the body with EMPTY metadata (so `/cmd` "works" typed but Claude can
|
|
8
|
+
* never auto-match it), and two body rules bite in ways manual review misses —
|
|
9
|
+
* an inline `` !`cmd` `` that isn't at a line start / after whitespace is left as
|
|
10
|
+
* literal text and NEVER runs, and one whose command no `allowed-tools` Bash
|
|
11
|
+
* rule covers prompts/blocks instead of running pre-approved. This catches those
|
|
12
|
+
* before CC does. Sibling of agents-lint; same `Finding` shape.
|
|
13
|
+
*
|
|
14
|
+
* Pure: parsed text in, findings out. Reuses the permission-rule matcher
|
|
15
|
+
* (parseRule/covers) so allowed-tools coverage matches Claude Code's own glob
|
|
16
|
+
* semantics. Only truly-bounded fields are enum-checked (NOT `model`, which
|
|
17
|
+
* accepts aliases and full IDs) to stay false-positive-safe.
|
|
18
|
+
*/
|
|
19
|
+
import type { Finding } from './lint.js';
|
|
20
|
+
/** Reasoning-effort levels a command/skill `effort:` field may name. */
|
|
21
|
+
export declare const EFFORT_LEVELS: string[];
|
|
22
|
+
/** Lint ONE command/skill file's raw text. `kind` toggles the skill-only rule
|
|
23
|
+
* that frontmatter (with name + description) is REQUIRED. Pure. */
|
|
24
|
+
export declare function lintCommandFile(file: string, raw: string, kind?: 'command' | 'skill'): Finding[];
|
|
25
|
+
export interface CommandFile {
|
|
26
|
+
file: string;
|
|
27
|
+
raw: string;
|
|
28
|
+
}
|
|
29
|
+
/** Lint a set of command files and skill files. Pure. */
|
|
30
|
+
export declare function lintCommandFiles(commands: CommandFile[], skills?: CommandFile[]): Finding[];
|
|
31
|
+
//# sourceMappingURL=commands-lint.d.ts.map
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* commands-lint.ts — static validation of Claude Code custom slash commands
|
|
3
|
+
* (the `.claude/commands` tree) and skills (`.claude/skills/<name>/SKILL.md`).
|
|
4
|
+
*
|
|
5
|
+
* Slash commands / skills are the most-copied Claude Code power-user artifact,
|
|
6
|
+
* and CC swallows their footguns silently: malformed YAML frontmatter makes CC
|
|
7
|
+
* load the body with EMPTY metadata (so `/cmd` "works" typed but Claude can
|
|
8
|
+
* never auto-match it), and two body rules bite in ways manual review misses —
|
|
9
|
+
* an inline `` !`cmd` `` that isn't at a line start / after whitespace is left as
|
|
10
|
+
* literal text and NEVER runs, and one whose command no `allowed-tools` Bash
|
|
11
|
+
* rule covers prompts/blocks instead of running pre-approved. This catches those
|
|
12
|
+
* before CC does. Sibling of agents-lint; same `Finding` shape.
|
|
13
|
+
*
|
|
14
|
+
* Pure: parsed text in, findings out. Reuses the permission-rule matcher
|
|
15
|
+
* (parseRule/covers) so allowed-tools coverage matches Claude Code's own glob
|
|
16
|
+
* semantics. Only truly-bounded fields are enum-checked (NOT `model`, which
|
|
17
|
+
* accepts aliases and full IDs) to stay false-positive-safe.
|
|
18
|
+
*/
|
|
19
|
+
import { parse as parseYaml } from 'yaml';
|
|
20
|
+
import { parseRule, covers } from '../permissions/audit.js';
|
|
21
|
+
const f = (file, severity, rule, message) => ({ file, severity, rule, message });
|
|
22
|
+
/** Reasoning-effort levels a command/skill `effort:` field may name. */
|
|
23
|
+
export const EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'];
|
|
24
|
+
const FRONTMATTER_RE = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n([\s\S]*))?$/;
|
|
25
|
+
function parseFile(raw) {
|
|
26
|
+
const text = raw.replace(/^\uFEFF/, '');
|
|
27
|
+
const m = text.match(FRONTMATTER_RE);
|
|
28
|
+
if (!m) {
|
|
29
|
+
// An opening `---` with no closing fence is malformed; anything else is a
|
|
30
|
+
// plain-markdown command (no frontmatter — perfectly valid).
|
|
31
|
+
if (/^---[ \t]*\r?\n/.test(text))
|
|
32
|
+
return { meta: null, body: text, malformed: 'has an opening `---` but no closing `---` fence' };
|
|
33
|
+
return { meta: null, body: text };
|
|
34
|
+
}
|
|
35
|
+
const body = m[2] ?? '';
|
|
36
|
+
let fm;
|
|
37
|
+
try {
|
|
38
|
+
fm = parseYaml(m[1]);
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
return { meta: null, body, malformed: `frontmatter is not valid YAML: ${e.message}` };
|
|
42
|
+
}
|
|
43
|
+
if (fm === null || typeof fm !== 'object' || Array.isArray(fm)) {
|
|
44
|
+
return { meta: null, body, malformed: 'frontmatter must be a YAML mapping of key: value' };
|
|
45
|
+
}
|
|
46
|
+
return { meta: fm, body };
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Inline bash-injection placeholders `` !`cmd` `` in the body. Claude Code only
|
|
50
|
+
* RUNS one whose `!` is at a line start or immediately after whitespace; if `!`
|
|
51
|
+
* follows another character (e.g. `` KEY=!`cmd` ``) it is left as literal text.
|
|
52
|
+
*/
|
|
53
|
+
function bashInjections(body) {
|
|
54
|
+
const out = [];
|
|
55
|
+
const re = /!`([^`\n]+)`/g;
|
|
56
|
+
let m;
|
|
57
|
+
while ((m = re.exec(body)) !== null) {
|
|
58
|
+
const at = m.index;
|
|
59
|
+
const active = at === 0 || /\s/.test(body[at - 1]);
|
|
60
|
+
out.push({ cmd: m[1].trim(), active });
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
/** Parse the `allowed-tools` frontmatter (string list or YAML array) into rules;
|
|
65
|
+
* null = the key is absent (no pre-approval declared, so coverage isn't checked). */
|
|
66
|
+
function allowedToolRules(meta) {
|
|
67
|
+
const at = meta['allowed-tools'] ?? meta['allowedTools'];
|
|
68
|
+
if (at === undefined)
|
|
69
|
+
return null;
|
|
70
|
+
const parts = Array.isArray(at) ? at.map(String) : typeof at === 'string' ? at.split(',') : [];
|
|
71
|
+
const rules = [];
|
|
72
|
+
for (const p of parts) {
|
|
73
|
+
const r = parseRule(p.trim());
|
|
74
|
+
if (r.tool !== undefined)
|
|
75
|
+
rules.push(r);
|
|
76
|
+
}
|
|
77
|
+
return rules;
|
|
78
|
+
}
|
|
79
|
+
/** Lint ONE command/skill file's raw text. `kind` toggles the skill-only rule
|
|
80
|
+
* that frontmatter (with name + description) is REQUIRED. Pure. */
|
|
81
|
+
export function lintCommandFile(file, raw, kind = 'command') {
|
|
82
|
+
const p = parseFile(raw);
|
|
83
|
+
const out = [];
|
|
84
|
+
if (p.malformed) {
|
|
85
|
+
out.push(f(file, 'error', 'command-malformed-frontmatter', p.malformed));
|
|
86
|
+
}
|
|
87
|
+
else if (kind === 'skill' && !p.meta) {
|
|
88
|
+
out.push(f(file, 'error', 'skill-missing-frontmatter', 'a SKILL.md must start with a `---` … `---` frontmatter block declaring `name` and `description`'));
|
|
89
|
+
}
|
|
90
|
+
const meta = p.meta;
|
|
91
|
+
if (meta) {
|
|
92
|
+
if (kind === 'skill' && (typeof meta.name !== 'string' || meta.name.trim() === '')) {
|
|
93
|
+
out.push(f(file, 'error', 'skill-missing-name', 'frontmatter is missing a non-empty `name`'));
|
|
94
|
+
}
|
|
95
|
+
if (typeof meta.description !== 'string' || meta.description.trim() === '') {
|
|
96
|
+
const how = kind === 'skill' ? 'decide when to invoke the skill' : 'list the command and model-invoke it';
|
|
97
|
+
out.push(f(file, kind === 'skill' ? 'error' : 'warn', 'command-missing-description', `frontmatter has no non-empty \`description\` (Claude Code uses it to ${how})`));
|
|
98
|
+
}
|
|
99
|
+
if (meta.effort !== undefined && !EFFORT_LEVELS.includes(String(meta.effort))) {
|
|
100
|
+
out.push(f(file, 'error', 'command-bad-effort', `effort "${String(meta.effort)}" is not one of: ${EFFORT_LEVELS.join(', ')}`));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// Body cross-checks (both kinds). Coverage is only checked when allowed-tools
|
|
104
|
+
// is declared — otherwise the author never opted into pre-approval.
|
|
105
|
+
const rules = meta ? allowedToolRules(meta) : null;
|
|
106
|
+
for (const inj of bashInjections(p.body)) {
|
|
107
|
+
const tok = '!`' + inj.cmd + '`';
|
|
108
|
+
if (!inj.active) {
|
|
109
|
+
out.push(f(file, 'warn', 'command-inert-bash-injection', `inline bash ${tok} follows a non-whitespace character — Claude Code leaves it as literal text and never runs it (put \`!\` at a line start or after whitespace)`));
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (rules && rules.length > 0 && !rules.some((r) => covers(r, { tool: 'Bash', specifier: inj.cmd }))) {
|
|
113
|
+
out.push(f(file, 'warn', 'command-uncovered-bash-injection', `inline bash ${tok} is not covered by any \`allowed-tools\` Bash rule — it will prompt/block instead of running pre-approved`));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
/** Lint a set of command files and skill files. Pure. */
|
|
119
|
+
export function lintCommandFiles(commands, skills = []) {
|
|
120
|
+
const out = [];
|
|
121
|
+
for (const c of commands)
|
|
122
|
+
out.push(...lintCommandFile(c.file, c.raw, 'command'));
|
|
123
|
+
for (const s of skills)
|
|
124
|
+
out.push(...lintCommandFile(s.file, s.raw, 'skill'));
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
//# sourceMappingURL=commands-lint.js.map
|
|
@@ -18,6 +18,7 @@ export interface Finding {
|
|
|
18
18
|
export declare const TOPOLOGIES: string[];
|
|
19
19
|
export declare const MEMORY_BACKENDS: string[];
|
|
20
20
|
import { type AgentFile } from './agents-lint.js';
|
|
21
|
+
import { type CommandFile } from './commands-lint.js';
|
|
21
22
|
export declare const KNOWN_CONFIG_KEYS: string[];
|
|
22
23
|
export declare const HOOK_EVENTS: string[];
|
|
23
24
|
/** Parse a JSON file's raw text; a null raw means "file absent" (fine). */
|
|
@@ -52,6 +53,10 @@ export interface LintInput {
|
|
|
52
53
|
skills?: string[];
|
|
53
54
|
/** `.claude/agents/*.md` subagent definitions (raw text per file) */
|
|
54
55
|
agentFiles?: AgentFile[];
|
|
56
|
+
/** custom slash commands under the `.claude/commands` tree (raw text per file) */
|
|
57
|
+
commandFiles?: CommandFile[];
|
|
58
|
+
/** skill definitions at `.claude/skills/<name>/SKILL.md` (raw text per file) */
|
|
59
|
+
skillFiles?: CommandFile[];
|
|
55
60
|
}
|
|
56
61
|
export interface LintReport {
|
|
57
62
|
findings: Finding[];
|
|
@@ -13,6 +13,7 @@ export const TOPOLOGIES = ['hierarchical', 'mesh', 'hierarchical-mesh', 'ring',
|
|
|
13
13
|
export const MEMORY_BACKENDS = ['agentdb', 'sqlite', 'hybrid', 'memory'];
|
|
14
14
|
import { parseOpenRouterConfig } from '../providers/openrouter-config.js';
|
|
15
15
|
import { lintAgents } from './agents-lint.js';
|
|
16
|
+
import { lintCommandFiles } from './commands-lint.js';
|
|
16
17
|
export const KNOWN_CONFIG_KEYS = ['topology', 'maxAgents', 'strategy', 'consensus', 'memory', 'memoryBackend', 'hnsw', 'neural', 'embeddings', 'providers', 'mcp', 'logging', 'daemon', 'hooks', 'version', 'openrouter', '$schema'];
|
|
17
18
|
// Current Claude Code hook events (source: code.claude.com/docs/en/hooks).
|
|
18
19
|
// Kept in sync with the runtime; a stale list here false-warns on valid hooks.
|
|
@@ -197,6 +198,9 @@ export function lintAll(input) {
|
|
|
197
198
|
findings.push(...lintLegacyLayout(input.commandsRoot ?? [], input.skills ?? [], input.sdoCommands ?? []));
|
|
198
199
|
if (input.agentFiles?.length)
|
|
199
200
|
findings.push(...lintAgents(input.agentFiles));
|
|
201
|
+
if (input.commandFiles?.length || input.skillFiles?.length) {
|
|
202
|
+
findings.push(...lintCommandFiles(input.commandFiles ?? [], input.skillFiles ?? []));
|
|
203
|
+
}
|
|
200
204
|
return {
|
|
201
205
|
findings,
|
|
202
206
|
errors: findings.filter((x) => x.severity === 'error').length,
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hidden.ts — HIDDEN coupling: files that change together but have no import
|
|
3
|
+
* edge to explain it. "Logical coupling minus structural coupling."
|
|
4
|
+
*
|
|
5
|
+
* `coupling` mines temporal (co-change) coupling from git; `codegraph` builds
|
|
6
|
+
* the static import graph. Join them and emit the SET DIFFERENCE: pairs with a
|
|
7
|
+
* high co-change degree yet NO import edge connecting them in either direction.
|
|
8
|
+
* That's a dependency real enough to move two files in lockstep but invisible in
|
|
9
|
+
* the code — a JSON schema and the type that mirrors it, a serializer/parser
|
|
10
|
+
* split across modules, a config file and its consumers, a doc that must track
|
|
11
|
+
* an API. An agent editing A won't reach B by following imports, so it forgets
|
|
12
|
+
* the co-edit; a reviewer sees a missing abstraction / architectural smell.
|
|
13
|
+
*
|
|
14
|
+
* This is the divergence between evolutionary coupling (Gall et al., "Detection
|
|
15
|
+
* of Logical Coupling," ICSM 1998) and structural coupling — CodeScene flags
|
|
16
|
+
* change-coupling that crosses architectural boundaries as "surprising." swarmdo
|
|
17
|
+
* is uniquely positioned to compute it because it already owns BOTH captures.
|
|
18
|
+
*
|
|
19
|
+
* Pure + deterministic: a set-difference over two already-built inputs (the
|
|
20
|
+
* ranked CouplingPair[] from computeCoupling + the ImportEdge[] from the index).
|
|
21
|
+
* No git, no fs — fully fixture-testable.
|
|
22
|
+
*/
|
|
23
|
+
import type { CouplingPair } from './coupling.js';
|
|
24
|
+
import type { ImportEdge } from '../codegraph/codegraph.js';
|
|
25
|
+
export interface AnnotatedPair extends CouplingPair {
|
|
26
|
+
/** true iff an import edge connects a↔b in EITHER direction (a structural link
|
|
27
|
+
* that explains the co-change); the returned pairs are all `false`. */
|
|
28
|
+
importLinked: boolean;
|
|
29
|
+
}
|
|
30
|
+
export interface HiddenCouplingOptions {
|
|
31
|
+
/** keep only the top N hidden pairs after ranking (default: all) */
|
|
32
|
+
top?: number;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Return the co-change pairs that NO import edge explains, ranked as they came
|
|
36
|
+
* from `computeCoupling` (degree desc, shared desc, path order). A type-only
|
|
37
|
+
* import still counts as a structural link — it's a real code reference that
|
|
38
|
+
* explains the co-change — so those pairs are excluded too. Pure.
|
|
39
|
+
*/
|
|
40
|
+
export declare function computeHiddenCoupling(pairs: CouplingPair[], imports: ImportEdge[], opts?: HiddenCouplingOptions): AnnotatedPair[];
|
|
41
|
+
/** Export the hidden-coupling ranking as CSV for review. Pure. */
|
|
42
|
+
export declare function hiddenCouplingToCsv(pairs: AnnotatedPair[]): string;
|
|
43
|
+
/** Human-readable table. Pure. */
|
|
44
|
+
export declare function formatHiddenCoupling(pairs: AnnotatedPair[]): string;
|
|
45
|
+
//# sourceMappingURL=hidden.d.ts.map
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hidden.ts — HIDDEN coupling: files that change together but have no import
|
|
3
|
+
* edge to explain it. "Logical coupling minus structural coupling."
|
|
4
|
+
*
|
|
5
|
+
* `coupling` mines temporal (co-change) coupling from git; `codegraph` builds
|
|
6
|
+
* the static import graph. Join them and emit the SET DIFFERENCE: pairs with a
|
|
7
|
+
* high co-change degree yet NO import edge connecting them in either direction.
|
|
8
|
+
* That's a dependency real enough to move two files in lockstep but invisible in
|
|
9
|
+
* the code — a JSON schema and the type that mirrors it, a serializer/parser
|
|
10
|
+
* split across modules, a config file and its consumers, a doc that must track
|
|
11
|
+
* an API. An agent editing A won't reach B by following imports, so it forgets
|
|
12
|
+
* the co-edit; a reviewer sees a missing abstraction / architectural smell.
|
|
13
|
+
*
|
|
14
|
+
* This is the divergence between evolutionary coupling (Gall et al., "Detection
|
|
15
|
+
* of Logical Coupling," ICSM 1998) and structural coupling — CodeScene flags
|
|
16
|
+
* change-coupling that crosses architectural boundaries as "surprising." swarmdo
|
|
17
|
+
* is uniquely positioned to compute it because it already owns BOTH captures.
|
|
18
|
+
*
|
|
19
|
+
* Pure + deterministic: a set-difference over two already-built inputs (the
|
|
20
|
+
* ranked CouplingPair[] from computeCoupling + the ImportEdge[] from the index).
|
|
21
|
+
* No git, no fs — fully fixture-testable.
|
|
22
|
+
*/
|
|
23
|
+
import { toCsv } from '../util/csv.js';
|
|
24
|
+
const US = '\x1f'; // pair-key separator (never appears in a file path)
|
|
25
|
+
/** Canonical undirected pair key so direction doesn't matter. */
|
|
26
|
+
const pairKey = (a, b) => (a < b ? a + US + b : b + US + a);
|
|
27
|
+
/**
|
|
28
|
+
* Return the co-change pairs that NO import edge explains, ranked as they came
|
|
29
|
+
* from `computeCoupling` (degree desc, shared desc, path order). A type-only
|
|
30
|
+
* import still counts as a structural link — it's a real code reference that
|
|
31
|
+
* explains the co-change — so those pairs are excluded too. Pure.
|
|
32
|
+
*/
|
|
33
|
+
export function computeHiddenCoupling(pairs, imports, opts = {}) {
|
|
34
|
+
// Every file pair connected by a resolved import edge, either direction.
|
|
35
|
+
const linked = new Set();
|
|
36
|
+
for (const e of imports) {
|
|
37
|
+
if (e.resolved)
|
|
38
|
+
linked.add(pairKey(e.from, e.resolved));
|
|
39
|
+
}
|
|
40
|
+
const hidden = pairs
|
|
41
|
+
.map((p) => ({ ...p, importLinked: linked.has(pairKey(p.a, p.b)) }))
|
|
42
|
+
.filter((p) => !p.importLinked);
|
|
43
|
+
return opts.top && opts.top > 0 ? hidden.slice(0, opts.top) : hidden;
|
|
44
|
+
}
|
|
45
|
+
/** Export the hidden-coupling ranking as CSV for review. Pure. */
|
|
46
|
+
export function hiddenCouplingToCsv(pairs) {
|
|
47
|
+
const headers = ['fileA', 'fileB', 'degree', 'shared', 'commitsA', 'commitsB'];
|
|
48
|
+
const rows = pairs.map((p) => [p.a, p.b, p.degree, p.shared, p.aCommits, p.bCommits]);
|
|
49
|
+
return toCsv(headers, rows);
|
|
50
|
+
}
|
|
51
|
+
/** Human-readable table. Pure. */
|
|
52
|
+
export function formatHiddenCoupling(pairs) {
|
|
53
|
+
if (pairs.length === 0)
|
|
54
|
+
return 'no hidden coupling found (every co-change pair has an import edge, or none met the threshold)';
|
|
55
|
+
const lines = [];
|
|
56
|
+
lines.push('rank degree shared commits(A/B) files (co-change, no import edge)');
|
|
57
|
+
pairs.forEach((p, i) => {
|
|
58
|
+
const rank = String(i + 1).padStart(4);
|
|
59
|
+
const degree = `${Math.round(p.degree * 100)}%`.padStart(6);
|
|
60
|
+
const shared = String(p.shared).padStart(6);
|
|
61
|
+
const ab = `${p.aCommits}/${p.bCommits}`.padStart(11);
|
|
62
|
+
lines.push(`${rank} ${degree} ${shared} ${ab} ${p.a} ⇢ ${p.b}`);
|
|
63
|
+
});
|
|
64
|
+
return lines.join('\n');
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=hidden.js.map
|
|
@@ -27,6 +27,7 @@ import { licenseTools } from './mcp-tools/license-tools.js';
|
|
|
27
27
|
import { applyTools } from './mcp-tools/apply-tools.js';
|
|
28
28
|
import { hotspotsTools } from './mcp-tools/hotspots-tools.js';
|
|
29
29
|
import { couplingTools } from './mcp-tools/coupling-tools.js';
|
|
30
|
+
import { ownershipTools } from './mcp-tools/ownership-tools.js';
|
|
30
31
|
import { affectedTools } from './mcp-tools/affected-tools.js';
|
|
31
32
|
import { cyclesTools } from './mcp-tools/cycles-tools.js';
|
|
32
33
|
import { testreportTools } from './mcp-tools/testreport-tools.js';
|
|
@@ -121,6 +122,7 @@ const TOOL_GROUPS = {
|
|
|
121
122
|
apply: () => applyTools,
|
|
122
123
|
hotspots: () => hotspotsTools,
|
|
123
124
|
coupling: () => couplingTools,
|
|
125
|
+
ownership: () => ownershipTools,
|
|
124
126
|
affected: () => affectedTools,
|
|
125
127
|
cycles: () => cyclesTools,
|
|
126
128
|
testreport: () => testreportTools,
|
|
@@ -23,6 +23,7 @@ export { licenseTools } from './license-tools.js';
|
|
|
23
23
|
export { applyTools } from './apply-tools.js';
|
|
24
24
|
export { hotspotsTools } from './hotspots-tools.js';
|
|
25
25
|
export { couplingTools } from './coupling-tools.js';
|
|
26
|
+
export { ownershipTools } from './ownership-tools.js';
|
|
26
27
|
export { affectedTools } from './affected-tools.js';
|
|
27
28
|
export { cyclesTools } from './cycles-tools.js';
|
|
28
29
|
export { testreportTools } from './testreport-tools.js';
|
|
@@ -22,6 +22,7 @@ export { licenseTools } from './license-tools.js';
|
|
|
22
22
|
export { applyTools } from './apply-tools.js';
|
|
23
23
|
export { hotspotsTools } from './hotspots-tools.js';
|
|
24
24
|
export { couplingTools } from './coupling-tools.js';
|
|
25
|
+
export { ownershipTools } from './ownership-tools.js';
|
|
25
26
|
export { affectedTools } from './affected-tools.js';
|
|
26
27
|
export { cyclesTools } from './cycles-tools.js';
|
|
27
28
|
export { testreportTools } from './testreport-tools.js';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ownership MCP Tool
|
|
3
|
+
*
|
|
4
|
+
* Let an agent ask "who owns this file, and is it a bus-factor-1 key-person
|
|
5
|
+
* file?" and get ranked JSON — the risk/reviewer dimension it is otherwise
|
|
6
|
+
* blind to. An agent about to refactor a fragile single-owner file gets the
|
|
7
|
+
* signal that the change needs extra care, and "who should review this" gets a
|
|
8
|
+
* data-backed answer (the file's main-dev). Completes the git-mining trio's
|
|
9
|
+
* agent-callable set (hotspots + coupling + ownership). Shares the pure engine
|
|
10
|
+
* in ../ownership/ownership.ts with the CLI; captures git history via subprocess.
|
|
11
|
+
*/
|
|
12
|
+
import type { MCPTool } from './types.js';
|
|
13
|
+
export declare const ownershipTools: MCPTool[];
|
|
14
|
+
//# sourceMappingURL=ownership-tools.d.ts.map
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ownership MCP Tool
|
|
3
|
+
*
|
|
4
|
+
* Let an agent ask "who owns this file, and is it a bus-factor-1 key-person
|
|
5
|
+
* file?" and get ranked JSON — the risk/reviewer dimension it is otherwise
|
|
6
|
+
* blind to. An agent about to refactor a fragile single-owner file gets the
|
|
7
|
+
* signal that the change needs extra care, and "who should review this" gets a
|
|
8
|
+
* data-backed answer (the file's main-dev). Completes the git-mining trio's
|
|
9
|
+
* agent-callable set (hotspots + coupling + ownership). Shares the pure engine
|
|
10
|
+
* in ../ownership/ownership.ts with the CLI; captures git history via subprocess.
|
|
11
|
+
*/
|
|
12
|
+
import { execFileSync } from 'node:child_process';
|
|
13
|
+
import { parseGitLog } from '../hotspots/hotspots.js';
|
|
14
|
+
import { computeOwnership, repoBusFactor } from '../ownership/ownership.js';
|
|
15
|
+
import { normalizeSince } from '../util/since.js';
|
|
16
|
+
const ownershipTool = {
|
|
17
|
+
name: 'ownership',
|
|
18
|
+
description: 'Map per-file authorship concentration + bus factor from git history — "who owns each file, and what breaks if they leave?" (code-maat main-dev / knowledge map). Returns ranked JSON, most-fragile-first, so an agent editing a file knows if it is a single-owner (bus factor 1) key-person risk that needs extra care, and who its main-dev reviewer is. Includes a repo-level truck factor. Runs in a git repository.',
|
|
19
|
+
category: 'ownership',
|
|
20
|
+
tags: ['git', 'ownership', 'bus-factor', 'knowledge-map', 'risk', 'review'],
|
|
21
|
+
inputSchema: {
|
|
22
|
+
type: 'object',
|
|
23
|
+
properties: {
|
|
24
|
+
path: { type: 'string', description: 'repo path to run in (default cwd)' },
|
|
25
|
+
since: { type: 'string', description: 'history window, e.g. 90d or "3 months ago" (default 1 year)' },
|
|
26
|
+
top: { type: 'number', description: 'keep only the top N files (default 40; 0 = all)' },
|
|
27
|
+
minChurn: { type: 'number', description: 'drop files with total churn below N (default 1)' },
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
handler: async (params) => {
|
|
31
|
+
const cwd = typeof params.path === 'string' ? params.path : process.cwd();
|
|
32
|
+
const since = typeof params.since === 'string' ? params.since : '1 year ago';
|
|
33
|
+
const top = typeof params.top === 'number' ? params.top : 40;
|
|
34
|
+
const minChurn = typeof params.minChurn === 'number' ? params.minChurn : 1;
|
|
35
|
+
// Same `--numstat` dump `hotspots`/`coupling` parse; `%aN` folds author
|
|
36
|
+
// name/email variants through .mailmap so one person is one owner.
|
|
37
|
+
const args = ['log', '--no-merges', '--numstat', `--since=${normalizeSince(since)}`, '--format=format:%x01%H%x1f%aN%x1f%aI'];
|
|
38
|
+
let raw;
|
|
39
|
+
try {
|
|
40
|
+
raw = execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 128 * 1024 * 1024 });
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return { error: true, message: 'git log failed — not a git repository?' };
|
|
44
|
+
}
|
|
45
|
+
const commits = parseGitLog(raw);
|
|
46
|
+
const ownership = computeOwnership(commits, { minChurn, top: top > 0 ? top : undefined });
|
|
47
|
+
return { since, minChurn, repoBusFactor: repoBusFactor(commits), count: ownership.length, ownership };
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
export const ownershipTools = [ownershipTool];
|
|
51
|
+
//# sourceMappingURL=ownership-tools.js.map
|
|
@@ -28,6 +28,7 @@ const LEAN_GROUPS = [
|
|
|
28
28
|
'apply', // apply_patch — fuzzy unified-diff applier for agent edits
|
|
29
29
|
'hotspots', // hotspots — git-history change-risk ranking ("where's the debt?")
|
|
30
30
|
'coupling', // coupling — files that change together (temporal co-change), complements affected
|
|
31
|
+
'ownership', // ownership — per-file bus factor / knowledge map (who owns it, key-person risk)
|
|
31
32
|
'affected', // affected — impacted files/tests from a change via the import graph
|
|
32
33
|
'cycles', // cycles — circular-import detection via the import graph
|
|
33
34
|
'testreport', // testreport — JUnit/TAP → structured failure digest (pairs w/ repair)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ownership.ts — per-file authorship concentration + BUS FACTOR from git history.
|
|
3
|
+
*
|
|
4
|
+
* "Who owns this file, and what breaks if they leave?" answered from data. For
|
|
5
|
+
* every file we compute the dominant author (main-dev), how concentrated the
|
|
6
|
+
* churn is in that one person (ownership share), how many hands have touched it,
|
|
7
|
+
* and its bus factor — the smallest set of top authors whose combined churn
|
|
8
|
+
* clears half the file. A bus factor of 1 is a key-person risk: one departure
|
|
9
|
+
* orphans the file. A repo-level "truck factor" answers the same question for
|
|
10
|
+
* the whole codebase.
|
|
11
|
+
*
|
|
12
|
+
* This is the code-maat `main-dev` / `entity-ownership` analysis (Tornhill,
|
|
13
|
+
* *Your Code as a Crime Scene*), surfaced as CodeScene's "Knowledge Map" and
|
|
14
|
+
* "Bus Factor". The scoring core is a pure fold over the SAME `git log --numstat`
|
|
15
|
+
* dump `hotspots`/`coupling` already parse — it reuses that Commit type +
|
|
16
|
+
* parseGitLog and is unit-tested without a repo. `%aN` (mailmap-folded author)
|
|
17
|
+
* means name/email variants of one person count as one owner.
|
|
18
|
+
*/
|
|
19
|
+
import type { Commit } from '../hotspots/hotspots.js';
|
|
20
|
+
export interface FileOwnership {
|
|
21
|
+
path: string;
|
|
22
|
+
/** dominant author: most churn, tie → most commits, tie → name ascending */
|
|
23
|
+
owner: string;
|
|
24
|
+
/** owner churn / total file churn ∈ (0,1]; 1.0 = a single hand */
|
|
25
|
+
ownership: number;
|
|
26
|
+
/** distinct authors who touched the file */
|
|
27
|
+
authors: number;
|
|
28
|
+
/** total added+deleted across all commits */
|
|
29
|
+
churn: number;
|
|
30
|
+
/** commits touching the file */
|
|
31
|
+
commits: number;
|
|
32
|
+
/** smallest # of top-churn authors whose cumulative churn EXCEEDS 50% */
|
|
33
|
+
busFactor: number;
|
|
34
|
+
/** busFactor === 1 — a single point of knowledge (one departure orphans it) */
|
|
35
|
+
keyPersonRisk: boolean;
|
|
36
|
+
}
|
|
37
|
+
export interface RepoBusFactor {
|
|
38
|
+
/** the covering set: fewest top authors whose cumulative churn exceeds 50% */
|
|
39
|
+
authors: string[];
|
|
40
|
+
/** authors.length — the repo "truck factor" */
|
|
41
|
+
factor: number;
|
|
42
|
+
}
|
|
43
|
+
export interface OwnershipOptions {
|
|
44
|
+
/** drop files with total churn below this (default 1 → skips pure-binary edits) */
|
|
45
|
+
minChurn?: number;
|
|
46
|
+
/** keep only the top N files after ranking (default: all) */
|
|
47
|
+
top?: number;
|
|
48
|
+
}
|
|
49
|
+
/** Aggregate commits into per-file ownership, scored and ranked. Pure. */
|
|
50
|
+
export declare function computeOwnership(commits: Commit[], opts?: OwnershipOptions): FileOwnership[];
|
|
51
|
+
/**
|
|
52
|
+
* Repo-level truck factor: the fewest top authors whose combined churn across
|
|
53
|
+
* the WHOLE history exceeds half the total. If that set is one person, a single
|
|
54
|
+
* departure takes the majority of the codebase's institutional knowledge. Pure.
|
|
55
|
+
*/
|
|
56
|
+
export declare function repoBusFactor(commits: Commit[]): RepoBusFactor;
|
|
57
|
+
/** Export the knowledge map as CSV for spreadsheets / staffing review. Pure. */
|
|
58
|
+
export declare function ownershipToCsv(files: FileOwnership[]): string;
|
|
59
|
+
/** Human-readable knowledge map. Pure. */
|
|
60
|
+
export declare function formatOwnership(files: FileOwnership[]): string;
|
|
61
|
+
//# sourceMappingURL=ownership.d.ts.map
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ownership.ts — per-file authorship concentration + BUS FACTOR from git history.
|
|
3
|
+
*
|
|
4
|
+
* "Who owns this file, and what breaks if they leave?" answered from data. For
|
|
5
|
+
* every file we compute the dominant author (main-dev), how concentrated the
|
|
6
|
+
* churn is in that one person (ownership share), how many hands have touched it,
|
|
7
|
+
* and its bus factor — the smallest set of top authors whose combined churn
|
|
8
|
+
* clears half the file. A bus factor of 1 is a key-person risk: one departure
|
|
9
|
+
* orphans the file. A repo-level "truck factor" answers the same question for
|
|
10
|
+
* the whole codebase.
|
|
11
|
+
*
|
|
12
|
+
* This is the code-maat `main-dev` / `entity-ownership` analysis (Tornhill,
|
|
13
|
+
* *Your Code as a Crime Scene*), surfaced as CodeScene's "Knowledge Map" and
|
|
14
|
+
* "Bus Factor". The scoring core is a pure fold over the SAME `git log --numstat`
|
|
15
|
+
* dump `hotspots`/`coupling` already parse — it reuses that Commit type +
|
|
16
|
+
* parseGitLog and is unit-tested without a repo. `%aN` (mailmap-folded author)
|
|
17
|
+
* means name/email variants of one person count as one owner.
|
|
18
|
+
*/
|
|
19
|
+
import { toCsv } from '../util/csv.js';
|
|
20
|
+
/**
|
|
21
|
+
* Bus factor of a churn distribution: the smallest number of top-churn authors
|
|
22
|
+
* whose cumulative churn STRICTLY exceeds half the total. An author sitting at
|
|
23
|
+
* exactly 50% is not enough on their own (you'd need one more), which is what
|
|
24
|
+
* makes an even two-way split a bus factor of 2. Pure.
|
|
25
|
+
*/
|
|
26
|
+
function busFactorOf(authors, total) {
|
|
27
|
+
const byChurn = authors.slice().sort((x, y) => y.churn - x.churn || (x.name < y.name ? -1 : x.name > y.name ? 1 : 0));
|
|
28
|
+
const threshold = total / 2;
|
|
29
|
+
let cum = 0;
|
|
30
|
+
let k = 0;
|
|
31
|
+
for (const a of byChurn) {
|
|
32
|
+
cum += a.churn;
|
|
33
|
+
k++;
|
|
34
|
+
if (cum > threshold)
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
return Math.max(1, k);
|
|
38
|
+
}
|
|
39
|
+
/** Aggregate commits into per-file ownership, scored and ranked. Pure. */
|
|
40
|
+
export function computeOwnership(commits, opts = {}) {
|
|
41
|
+
const minChurn = opts.minChurn ?? 1;
|
|
42
|
+
const acc = new Map();
|
|
43
|
+
for (const c of commits) {
|
|
44
|
+
// Fold this commit's numstat lines into per-path churn first, so a path that
|
|
45
|
+
// appears twice in one commit (rename collisions) counts as ONE commit.
|
|
46
|
+
const perPath = new Map();
|
|
47
|
+
for (const f of c.files) {
|
|
48
|
+
if (!f.path)
|
|
49
|
+
continue;
|
|
50
|
+
perPath.set(f.path, (perPath.get(f.path) ?? 0) + f.added + f.deleted);
|
|
51
|
+
}
|
|
52
|
+
for (const [path, churn] of perPath) {
|
|
53
|
+
let fa = acc.get(path);
|
|
54
|
+
if (!fa) {
|
|
55
|
+
fa = { churn: 0, commits: 0, byAuthor: new Map() };
|
|
56
|
+
acc.set(path, fa);
|
|
57
|
+
}
|
|
58
|
+
fa.churn += churn;
|
|
59
|
+
fa.commits += 1;
|
|
60
|
+
let a = fa.byAuthor.get(c.author);
|
|
61
|
+
if (!a) {
|
|
62
|
+
a = { name: c.author, churn: 0, commits: 0 };
|
|
63
|
+
fa.byAuthor.set(c.author, a);
|
|
64
|
+
}
|
|
65
|
+
a.churn += churn;
|
|
66
|
+
a.commits += 1;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const out = [];
|
|
70
|
+
for (const [path, fa] of acc) {
|
|
71
|
+
if (fa.churn < minChurn)
|
|
72
|
+
continue;
|
|
73
|
+
const authors = [...fa.byAuthor.values()];
|
|
74
|
+
// owner: most churn, tie → most commits (sustained involvement), tie → name asc
|
|
75
|
+
const owner = authors
|
|
76
|
+
.slice()
|
|
77
|
+
.sort((x, y) => y.churn - x.churn || y.commits - x.commits || (x.name < y.name ? -1 : x.name > y.name ? 1 : 0))[0];
|
|
78
|
+
const ownership = fa.churn > 0 ? Math.round((owner.churn / fa.churn) * 100) / 100 : 0;
|
|
79
|
+
const busFactor = busFactorOf(authors, fa.churn);
|
|
80
|
+
out.push({
|
|
81
|
+
path,
|
|
82
|
+
owner: owner.name,
|
|
83
|
+
ownership,
|
|
84
|
+
authors: authors.length,
|
|
85
|
+
churn: fa.churn,
|
|
86
|
+
commits: fa.commits,
|
|
87
|
+
busFactor,
|
|
88
|
+
keyPersonRisk: busFactor === 1,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
// Deterministic ranking: most fragile first — lowest bus factor, then most
|
|
92
|
+
// concentrated (ownership desc), then most churn, then path asc for stable ties.
|
|
93
|
+
out.sort((x, y) => x.busFactor - y.busFactor ||
|
|
94
|
+
y.ownership - x.ownership ||
|
|
95
|
+
y.churn - x.churn ||
|
|
96
|
+
(x.path < y.path ? -1 : x.path > y.path ? 1 : 0));
|
|
97
|
+
return opts.top && opts.top > 0 ? out.slice(0, opts.top) : out;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Repo-level truck factor: the fewest top authors whose combined churn across
|
|
101
|
+
* the WHOLE history exceeds half the total. If that set is one person, a single
|
|
102
|
+
* departure takes the majority of the codebase's institutional knowledge. Pure.
|
|
103
|
+
*/
|
|
104
|
+
export function repoBusFactor(commits) {
|
|
105
|
+
const byAuthor = new Map();
|
|
106
|
+
let total = 0;
|
|
107
|
+
for (const c of commits) {
|
|
108
|
+
for (const f of c.files) {
|
|
109
|
+
const ch = f.added + f.deleted;
|
|
110
|
+
byAuthor.set(c.author, (byAuthor.get(c.author) ?? 0) + ch);
|
|
111
|
+
total += ch;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const sorted = [...byAuthor.entries()].sort((x, y) => y[1] - x[1] || (x[0] < y[0] ? -1 : x[0] > y[0] ? 1 : 0));
|
|
115
|
+
const threshold = total / 2;
|
|
116
|
+
const authors = [];
|
|
117
|
+
let cum = 0;
|
|
118
|
+
for (const [name, ch] of sorted) {
|
|
119
|
+
authors.push(name);
|
|
120
|
+
cum += ch;
|
|
121
|
+
if (cum > threshold)
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
return { authors, factor: authors.length };
|
|
125
|
+
}
|
|
126
|
+
/** Export the knowledge map as CSV for spreadsheets / staffing review. Pure. */
|
|
127
|
+
export function ownershipToCsv(files) {
|
|
128
|
+
const headers = ['path', 'owner', 'ownership', 'busFactor', 'authors', 'churn', 'commits', 'keyPersonRisk'];
|
|
129
|
+
const rows = files.map((f) => [f.path, f.owner, f.ownership, f.busFactor, f.authors, f.churn, f.commits, f.keyPersonRisk]);
|
|
130
|
+
return toCsv(headers, rows);
|
|
131
|
+
}
|
|
132
|
+
/** Human-readable knowledge map. Pure. */
|
|
133
|
+
export function formatOwnership(files) {
|
|
134
|
+
if (files.length === 0)
|
|
135
|
+
return 'no ownership data found (no matching history)';
|
|
136
|
+
const lines = [];
|
|
137
|
+
lines.push('rank bus own% authors churn commits owner file');
|
|
138
|
+
files.forEach((f, i) => {
|
|
139
|
+
const rank = String(i + 1).padStart(4);
|
|
140
|
+
const bus = String(f.busFactor).padStart(3);
|
|
141
|
+
const own = `${Math.round(f.ownership * 100)}%`.padStart(5);
|
|
142
|
+
const authors = String(f.authors).padStart(7);
|
|
143
|
+
const churn = String(f.churn).padStart(6);
|
|
144
|
+
const commits = String(f.commits).padStart(7);
|
|
145
|
+
const owner = (f.owner.length > 20 ? f.owner.slice(0, 19) + '…' : f.owner).padEnd(20);
|
|
146
|
+
const risk = f.keyPersonRisk ? ' ⚠ key-person' : '';
|
|
147
|
+
lines.push(`${rank} ${bus} ${own} ${authors} ${churn} ${commits} ${owner} ${f.path}${risk}`);
|
|
148
|
+
});
|
|
149
|
+
return lines.join('\n');
|
|
150
|
+
}
|
|
151
|
+
//# sourceMappingURL=ownership.js.map
|
|
@@ -54,5 +54,5 @@ export declare function packFiles(input: PackFile[], opts?: PackOptions): PackRe
|
|
|
54
54
|
* `dir/`, `*.ext` globs, `**` depth globs (gitignore(5) semantics), leading `/`
|
|
55
55
|
* anchors, and `!` negation. Not the full spec (no nested ignore files).
|
|
56
56
|
*/
|
|
57
|
-
export declare function makeIgnoreMatcher(patterns: string[]): (relPath: string) => boolean;
|
|
57
|
+
export declare function makeIgnoreMatcher(patterns: string[]): (relPath: string, isDir?: boolean) => boolean;
|
|
58
58
|
//# sourceMappingURL=pack.d.ts.map
|
|
@@ -201,19 +201,24 @@ export function makeIgnoreMatcher(patterns) {
|
|
|
201
201
|
// Only a slash-free pattern (`dir`, `*.ext`) matches at any depth.
|
|
202
202
|
const anchored = leadingSlash || body.includes('/');
|
|
203
203
|
const re = new RegExp('^' + globToRegExp(body) + (dirOnly ? '(/|$)' : '($|/)'));
|
|
204
|
-
return { negate, anchored, re };
|
|
204
|
+
return { negate, anchored, dirOnly, re };
|
|
205
205
|
});
|
|
206
206
|
// Ignored status of a single path (last-match-wins over the ordered rules).
|
|
207
|
-
|
|
207
|
+
// `isDirComponent` is whether the entry named by the path's LAST segment is a
|
|
208
|
+
// directory: a trailing-slash (dir-only) pattern matches directories ONLY per
|
|
209
|
+
// gitignore(5), so `build/` must NOT fire on a regular file named `build`.
|
|
210
|
+
const ruleVerdict = (path, isDirComponent) => {
|
|
208
211
|
let ignored = false;
|
|
209
212
|
for (const r of rules) {
|
|
213
|
+
if (r.dirOnly && !isDirComponent)
|
|
214
|
+
continue; // dir-only pattern can't match a file
|
|
210
215
|
const candidates = r.anchored ? [path] : [path, ...path.split('/').map((_, i, a) => a.slice(i).join('/'))];
|
|
211
216
|
if (candidates.some((c) => r.re.test(c)))
|
|
212
217
|
ignored = !r.negate;
|
|
213
218
|
}
|
|
214
219
|
return ignored;
|
|
215
220
|
};
|
|
216
|
-
return (relPath) => {
|
|
221
|
+
return (relPath, isDir = false) => {
|
|
217
222
|
// Walk ancestor dirs top-down: once a parent directory is excluded, the file
|
|
218
223
|
// stays excluded — a negation cannot re-include under an excluded parent
|
|
219
224
|
// (gitignore(5): "not possible to re-include a file if a parent directory of
|
|
@@ -227,7 +232,10 @@ export function makeIgnoreMatcher(patterns) {
|
|
|
227
232
|
return true;
|
|
228
233
|
continue; // excluded ancestor carries down
|
|
229
234
|
}
|
|
230
|
-
|
|
235
|
+
// Ancestor prefixes are directories by construction; only the final entry's
|
|
236
|
+
// type comes from the caller (a filesystem walker knows dir vs file).
|
|
237
|
+
const componentIsDir = isLast ? isDir : true;
|
|
238
|
+
const verdict = ruleVerdict(parts.slice(0, i + 1).join('/'), componentIsDir);
|
|
231
239
|
if (isLast)
|
|
232
240
|
return verdict;
|
|
233
241
|
parentIgnored = verdict; // this directory prefix carries forward
|
|
@@ -53,9 +53,11 @@ export const RULES = [
|
|
|
53
53
|
* values (paths, short flags) out. The value class also stops at the URL/query
|
|
54
54
|
* delimiters `&`, `?`, `#` (absent from base64/base64url/hex token alphabets):
|
|
55
55
|
* otherwise a query-string secret like `client_secret=…&api_key=…` over-captures
|
|
56
|
-
* past the `&` into the next secret
|
|
57
|
-
*
|
|
58
|
-
* the
|
|
56
|
+
* past the `&` into the next secret. It CANNOT stop at `/ : . = + ~`, though —
|
|
57
|
+
* base64/base64url/connection-string secrets contain those — so a recognized
|
|
58
|
+
* token glued on by one of them still over-captures; the entropy pass in
|
|
59
|
+
* `collectHits` handles that by CLIPPING the span at the claimed range rather
|
|
60
|
+
* than dropping the finding (which would leak the leading secret, #100).
|
|
59
61
|
*/
|
|
60
62
|
const ASSIGNMENT_RE = /(?<![A-Za-z])(?:pass(?:word|phrase|wd)?|pwd|secret|token|api[_-]?key|apikey|access[_-]?key|auth[_-]?token|client[_-]?secret|credential|creds|key)["']?\s*[:=]\s*["']?([^\s"'`,;&?#]{8,})/gi;
|
|
61
63
|
/** Shannon entropy in bits/char. Pure; used by the assignment fallback. */
|
|
@@ -121,15 +123,32 @@ function collectHits(text, opts) {
|
|
|
121
123
|
while ((m = ASSIGNMENT_RE.exec(text)) !== null) {
|
|
122
124
|
const value = m[1];
|
|
123
125
|
const start = m.index + m[0].indexOf(value);
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
126
|
+
let end = start + value.length;
|
|
127
|
+
let match = value;
|
|
128
|
+
// The value class must include `/ : . = + ~` for base64/base64url/conn-string
|
|
129
|
+
// secrets, so a recognized RULES token (GitHub PAT, AWS key, …) glued on by
|
|
130
|
+
// one of those separators gets over-captured into this span. When that
|
|
131
|
+
// happens, DON'T drop the finding on overlap — the leading high-entropy
|
|
132
|
+
// secret would leak in cleartext (only the trailing token got masked, #100).
|
|
133
|
+
// Instead clip the span to end at the earliest claimed range within it and
|
|
134
|
+
// re-gate the leading fragment; the trailing token keeps its RULES redaction.
|
|
135
|
+
const overlapping = claimed.filter(([s, e]) => start < e && end > s);
|
|
136
|
+
if (overlapping.length > 0) {
|
|
137
|
+
// value's own start already sits inside a claimed secret → fully covered
|
|
138
|
+
if (overlapping.some(([s, e]) => s <= start && start < e))
|
|
139
|
+
continue;
|
|
140
|
+
const clipTo = Math.min(...overlapping.map(([s]) => s));
|
|
141
|
+
match = text.slice(start, clipTo).replace(/[^A-Za-z0-9]+$/, ''); // drop the trailing separator
|
|
142
|
+
end = start + match.length;
|
|
143
|
+
if (match.length < 8)
|
|
144
|
+
continue; // clipped leading fragment too short to be a secret
|
|
145
|
+
}
|
|
146
|
+
if (isAllowlisted(match, opts.allowlist))
|
|
128
147
|
continue;
|
|
129
|
-
if (shannonEntropy(
|
|
148
|
+
if (shannonEntropy(match) < threshold)
|
|
130
149
|
continue;
|
|
131
150
|
claimed.push([start, end]);
|
|
132
|
-
hits.push({ ruleId: 'high-entropy-assignment', description: 'High-entropy secret assignment', index: start, match
|
|
151
|
+
hits.push({ ruleId: 'high-entropy-assignment', description: 'High-entropy secret assignment', index: start, match });
|
|
133
152
|
}
|
|
134
153
|
}
|
|
135
154
|
return hits.sort((a, b) => a.index - b.index);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swarmdo/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.50.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Swarmdo CLI - Enterprise AI agent orchestration with 60+ specialized agents, swarm coordination, MCP server, self-learning hooks, and vector memory for Claude Code",
|
|
6
6
|
"main": "dist/src/index.js",
|