swarmdo 1.37.0 → 1.45.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 +7 -5
- package/package.json +1 -1
- package/v3/@swarmdo/cli/dist/src/apply/apply.js +6 -1
- package/v3/@swarmdo/cli/dist/src/changelog/changelog.d.ts +21 -0
- package/v3/@swarmdo/cli/dist/src/changelog/changelog.js +43 -0
- package/v3/@swarmdo/cli/dist/src/commands/changelog.js +15 -4
- package/v3/@swarmdo/cli/dist/src/commands/config.js +6 -1
- package/v3/@swarmdo/cli/dist/src/commands/coupling.d.ts +17 -0
- package/v3/@swarmdo/cli/dist/src/commands/coupling.js +85 -0
- package/v3/@swarmdo/cli/dist/src/commands/hooks.js +55 -0
- package/v3/@swarmdo/cli/dist/src/commands/hotspots.js +2 -1
- package/v3/@swarmdo/cli/dist/src/commands/index.js +6 -3
- package/v3/@swarmdo/cli/dist/src/commands/redact.js +11 -0
- package/v3/@swarmdo/cli/dist/src/commands/release.js +27 -5
- package/v3/@swarmdo/cli/dist/src/commands/task.js +57 -2
- package/v3/@swarmdo/cli/dist/src/config-lint/agents-lint.d.ts +30 -0
- package/v3/@swarmdo/cli/dist/src/config-lint/agents-lint.js +87 -0
- package/v3/@swarmdo/cli/dist/src/config-lint/lint.d.ts +3 -0
- package/v3/@swarmdo/cli/dist/src/config-lint/lint.js +3 -0
- package/v3/@swarmdo/cli/dist/src/coupling/coupling.d.ts +56 -0
- package/v3/@swarmdo/cli/dist/src/coupling/coupling.js +86 -0
- package/v3/@swarmdo/cli/dist/src/hooks-recipe/command-guard.d.ts +46 -0
- package/v3/@swarmdo/cli/dist/src/hooks-recipe/command-guard.js +120 -0
- package/v3/@swarmdo/cli/dist/src/hooks-recipe/recipe.js +8 -0
- package/v3/@swarmdo/cli/dist/src/mcp-client.js +2 -0
- package/v3/@swarmdo/cli/dist/src/mcp-tools/coupling-tools.d.ts +14 -0
- package/v3/@swarmdo/cli/dist/src/mcp-tools/coupling-tools.js +56 -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/profiles.js +1 -0
- package/v3/@swarmdo/cli/dist/src/mcp-tools/task-deps.d.ts +35 -0
- package/v3/@swarmdo/cli/dist/src/mcp-tools/task-deps.js +87 -0
- package/v3/@swarmdo/cli/dist/src/redact/redact.js +22 -10
- package/v3/@swarmdo/cli/dist/src/redact/sarif.d.ts +28 -0
- package/v3/@swarmdo/cli/dist/src/redact/sarif.js +53 -0
- package/v3/@swarmdo/cli/dist/src/release/release.d.ts +19 -0
- package/v3/@swarmdo/cli/dist/src/release/release.js +28 -0
- package/v3/@swarmdo/cli/dist/src/usage/reflect.d.ts +9 -1
- package/v3/@swarmdo/cli/dist/src/usage/reflect.js +14 -4
- package/v3/@swarmdo/cli/dist/src/util/since.d.ts +13 -0
- package/v3/@swarmdo/cli/dist/src/util/since.js +21 -0
- package/v3/@swarmdo/cli/package.json +1 -1
|
@@ -759,6 +759,60 @@ const graphCommand = {
|
|
|
759
759
|
return { success: true };
|
|
760
760
|
}
|
|
761
761
|
};
|
|
762
|
+
// Doctor subcommand — classify blocked tasks live vs dead + a global deadlock
|
|
763
|
+
// verdict (task-deps.ts::taskDagHealth). Airflow's upstream_failed + DAG
|
|
764
|
+
// deadlock detection: surfaces a task that can NEVER run because a prerequisite
|
|
765
|
+
// failed/was cancelled/is missing, and a whole-graph stall a dispatch loop would
|
|
766
|
+
// otherwise spin on forever.
|
|
767
|
+
const doctorCommand = {
|
|
768
|
+
name: 'doctor',
|
|
769
|
+
aliases: ['health'],
|
|
770
|
+
description: 'Diagnose the task DAG: which blocked tasks can still run (live) vs are permanently stuck because a dependency failed/was cancelled/is missing (dead), and whether the whole graph is deadlocked',
|
|
771
|
+
options: [
|
|
772
|
+
{ name: 'json', description: 'Machine-readable output', type: 'boolean', default: false },
|
|
773
|
+
{ name: 'ci', description: 'Exit 1 if the DAG is deadlocked or any task is permanently (dead) blocked — gate a dispatch loop', type: 'boolean', default: false }
|
|
774
|
+
],
|
|
775
|
+
examples: [
|
|
776
|
+
{ command: 'swarmdo task doctor', description: 'Show live-blocked vs dead-blocked tasks and any deadlock' },
|
|
777
|
+
{ command: 'swarmdo task doctor --ci', description: 'Fail (exit 1) when the task graph is stuck' }
|
|
778
|
+
],
|
|
779
|
+
action: async (ctx) => {
|
|
780
|
+
const { loadTaskStore } = await import('../mcp-tools/task-tools.js');
|
|
781
|
+
const { taskDagHealth } = await import('../mcp-tools/task-deps.js');
|
|
782
|
+
const store = loadTaskStore(ctx.cwd);
|
|
783
|
+
const health = taskDagHealth(store);
|
|
784
|
+
const stuck = health.deadlocked || health.deadBlocked.length > 0;
|
|
785
|
+
const code = ctx.flags.ci === true && stuck ? 1 : 0;
|
|
786
|
+
if (ctx.flags.json === true) {
|
|
787
|
+
output.printJson({
|
|
788
|
+
deadlocked: health.deadlocked,
|
|
789
|
+
liveBlocked: health.liveBlocked.map(t => t.taskId),
|
|
790
|
+
deadBlocked: health.deadBlocked.map(d => ({ taskId: d.task.taskId, rootCause: d.rootCause })),
|
|
791
|
+
});
|
|
792
|
+
return { success: code === 0, exitCode: code, data: health };
|
|
793
|
+
}
|
|
794
|
+
output.writeln();
|
|
795
|
+
if (health.deadlocked) {
|
|
796
|
+
output.writeln(output.error('⚠ DEADLOCKED') + output.dim(' — pending work exists but nothing is ready and nothing is running'));
|
|
797
|
+
output.writeln();
|
|
798
|
+
}
|
|
799
|
+
output.writeln(output.bold(`Live-blocked (${health.liveBlocked.length})`) + output.dim(' — waiting on deps that can still complete'));
|
|
800
|
+
for (const t of health.liveBlocked) {
|
|
801
|
+
output.writeln(` ${output.dim('○')} ${t.taskId} ${t.description.slice(0, 60)}`);
|
|
802
|
+
}
|
|
803
|
+
output.writeln();
|
|
804
|
+
output.writeln(output.bold(`Dead-blocked (${health.deadBlocked.length})`) + output.dim(' — a prerequisite failed/was cancelled/is missing; will NEVER run without intervention'));
|
|
805
|
+
for (const d of health.deadBlocked) {
|
|
806
|
+
output.writeln(` ${output.error('✗')} ${d.task.taskId} ⇐ dead: ${d.rootCause.join(', ') || '(cycle)'}`);
|
|
807
|
+
}
|
|
808
|
+
if (!stuck) {
|
|
809
|
+
output.writeln();
|
|
810
|
+
output.printSuccess('task graph healthy — no permanent blocks or deadlock');
|
|
811
|
+
}
|
|
812
|
+
output.writeln();
|
|
813
|
+
return { success: code === 0, exitCode: code, data: health };
|
|
814
|
+
}
|
|
815
|
+
};
|
|
762
816
|
// parse-prd subcommand — decompose a PRD/spec doc into the task DAG (Task Master
|
|
763
817
|
// parity). The LLM decomposition lives in ../task/parse-prd.ts (injectable +
|
|
764
818
|
// unit-tested); this layer gates the billable call and creates the linked tasks.
|
|
@@ -890,7 +944,7 @@ const parsePrdCommand = {
|
|
|
890
944
|
export const taskCommand = {
|
|
891
945
|
name: 'task',
|
|
892
946
|
description: 'Task management commands',
|
|
893
|
-
subcommands: [createCommand, parsePrdCommand, listCommand, statusCommand, cancelCommand, assignCommand, retryCommand, dispatchCommand, readyCommand, graphCommand],
|
|
947
|
+
subcommands: [createCommand, parsePrdCommand, listCommand, statusCommand, cancelCommand, assignCommand, retryCommand, dispatchCommand, readyCommand, graphCommand, doctorCommand],
|
|
894
948
|
options: [],
|
|
895
949
|
examples: [
|
|
896
950
|
{ command: 'swarmdo task create -t implementation -d "Add user auth"', description: 'Create a task' },
|
|
@@ -917,7 +971,8 @@ export const taskCommand = {
|
|
|
917
971
|
`${output.highlight('assign')} - Assign task to agent(s)`,
|
|
918
972
|
`${output.highlight('retry')} - Retry a failed task`,
|
|
919
973
|
`${output.highlight('ready')} - List ready vs dependency-blocked tasks`,
|
|
920
|
-
`${output.highlight('graph')} - Show the dependency graph
|
|
974
|
+
`${output.highlight('graph')} - Show the dependency graph`,
|
|
975
|
+
`${output.highlight('doctor')} - Diagnose live vs dead blocks + deadlock (--ci gates)`
|
|
921
976
|
]);
|
|
922
977
|
output.writeln();
|
|
923
978
|
output.writeln('Run "swarmdo task <subcommand> --help" for subcommand help');
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agents-lint.ts — static validation of Claude Code subagent definitions
|
|
3
|
+
* (`.claude/agents/*.md`).
|
|
4
|
+
*
|
|
5
|
+
* Each subagent file is YAML frontmatter (`name` + `description` required;
|
|
6
|
+
* `tools`, `model` optional) plus a system-prompt body. Claude Code SILENTLY
|
|
7
|
+
* ignores a subagent whose frontmatter is malformed, whose `name` collides with
|
|
8
|
+
* another file, or whose `model` is invalid — so "why isn't my subagent
|
|
9
|
+
* loading?" is an undebuggable footgun, and users mass-copy these files from
|
|
10
|
+
* large community collections where typos ride along. This catches those
|
|
11
|
+
* before CC does.
|
|
12
|
+
*
|
|
13
|
+
* Pure: parsed text in, findings out — same `Finding` shape + report machinery
|
|
14
|
+
* as the rest of config-lint; the command layer only reads files.
|
|
15
|
+
*/
|
|
16
|
+
import type { Finding } from './lint.js';
|
|
17
|
+
/** Models a Claude Code subagent `model:` field may name. */
|
|
18
|
+
export declare const AGENT_MODELS: string[];
|
|
19
|
+
/** Lint ONE subagent file's raw text. Pure. */
|
|
20
|
+
export declare function lintAgentFile(file: string, raw: string): Finding[];
|
|
21
|
+
export interface AgentFile {
|
|
22
|
+
file: string;
|
|
23
|
+
raw: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Lint a set of subagent files: every per-file rule PLUS cross-file duplicate
|
|
27
|
+
* `name` detection (Claude Code loads only one of a colliding pair). Pure.
|
|
28
|
+
*/
|
|
29
|
+
export declare function lintAgents(files: AgentFile[]): Finding[];
|
|
30
|
+
//# sourceMappingURL=agents-lint.d.ts.map
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agents-lint.ts — static validation of Claude Code subagent definitions
|
|
3
|
+
* (`.claude/agents/*.md`).
|
|
4
|
+
*
|
|
5
|
+
* Each subagent file is YAML frontmatter (`name` + `description` required;
|
|
6
|
+
* `tools`, `model` optional) plus a system-prompt body. Claude Code SILENTLY
|
|
7
|
+
* ignores a subagent whose frontmatter is malformed, whose `name` collides with
|
|
8
|
+
* another file, or whose `model` is invalid — so "why isn't my subagent
|
|
9
|
+
* loading?" is an undebuggable footgun, and users mass-copy these files from
|
|
10
|
+
* large community collections where typos ride along. This catches those
|
|
11
|
+
* before CC does.
|
|
12
|
+
*
|
|
13
|
+
* Pure: parsed text in, findings out — same `Finding` shape + report machinery
|
|
14
|
+
* as the rest of config-lint; the command layer only reads files.
|
|
15
|
+
*/
|
|
16
|
+
import { parse as parseYaml } from 'yaml';
|
|
17
|
+
const f = (file, severity, rule, message) => ({ file, severity, rule, message });
|
|
18
|
+
/** Models a Claude Code subagent `model:` field may name. */
|
|
19
|
+
export const AGENT_MODELS = ['sonnet', 'opus', 'haiku', 'inherit'];
|
|
20
|
+
// Leading (optional BOM) `---` fence, YAML body (non-greedy) up to a closing
|
|
21
|
+
// `---` on its own line, then the optional system-prompt body. CRLF-tolerant.
|
|
22
|
+
const FRONTMATTER_RE = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n([\s\S]*))?$/;
|
|
23
|
+
function parseAgent(raw) {
|
|
24
|
+
const m = raw.replace(/^\uFEFF/, "").match(FRONTMATTER_RE);
|
|
25
|
+
if (!m)
|
|
26
|
+
return { meta: null, body: '', malformed: 'no YAML frontmatter block (a subagent file must start with `---` … `---`)' };
|
|
27
|
+
const body = m[2] ?? '';
|
|
28
|
+
let fm;
|
|
29
|
+
try {
|
|
30
|
+
fm = parseYaml(m[1]);
|
|
31
|
+
}
|
|
32
|
+
catch (e) {
|
|
33
|
+
return { meta: null, body, malformed: `frontmatter is not valid YAML: ${e.message}` };
|
|
34
|
+
}
|
|
35
|
+
if (fm === null || typeof fm !== 'object' || Array.isArray(fm)) {
|
|
36
|
+
return { meta: null, body, malformed: 'frontmatter must be a YAML mapping of key: value' };
|
|
37
|
+
}
|
|
38
|
+
return { meta: fm, body };
|
|
39
|
+
}
|
|
40
|
+
function lintParsed(file, p) {
|
|
41
|
+
if (p.malformed)
|
|
42
|
+
return [f(file, 'error', 'agent-malformed-frontmatter', p.malformed)];
|
|
43
|
+
const out = [];
|
|
44
|
+
const meta = p.meta;
|
|
45
|
+
if (typeof meta.name !== 'string' || meta.name.trim() === '') {
|
|
46
|
+
out.push(f(file, 'error', 'agent-missing-name', 'frontmatter is missing a non-empty `name`'));
|
|
47
|
+
}
|
|
48
|
+
if (typeof meta.description !== 'string' || meta.description.trim() === '') {
|
|
49
|
+
out.push(f(file, 'error', 'agent-missing-description', 'frontmatter is missing a non-empty `description` (Claude Code routes to a subagent by its description)'));
|
|
50
|
+
}
|
|
51
|
+
if (meta.model !== undefined && !AGENT_MODELS.includes(String(meta.model))) {
|
|
52
|
+
out.push(f(file, 'error', 'agent-bad-model', `model "${String(meta.model)}" is not one of: ${AGENT_MODELS.join(', ')}`));
|
|
53
|
+
}
|
|
54
|
+
if (p.body.trim() === '') {
|
|
55
|
+
out.push(f(file, 'warn', 'agent-empty-body', 'no system prompt below the frontmatter — the subagent has no instructions'));
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
/** Lint ONE subagent file's raw text. Pure. */
|
|
60
|
+
export function lintAgentFile(file, raw) {
|
|
61
|
+
return lintParsed(file, parseAgent(raw));
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Lint a set of subagent files: every per-file rule PLUS cross-file duplicate
|
|
65
|
+
* `name` detection (Claude Code loads only one of a colliding pair). Pure.
|
|
66
|
+
*/
|
|
67
|
+
export function lintAgents(files) {
|
|
68
|
+
const out = [];
|
|
69
|
+
const nameToFiles = new Map();
|
|
70
|
+
for (const { file, raw } of files) {
|
|
71
|
+
const p = parseAgent(raw);
|
|
72
|
+
out.push(...lintParsed(file, p));
|
|
73
|
+
const name = p.meta && typeof p.meta.name === 'string' ? p.meta.name.trim() : '';
|
|
74
|
+
if (name) {
|
|
75
|
+
const arr = nameToFiles.get(name) ?? [];
|
|
76
|
+
arr.push(file);
|
|
77
|
+
nameToFiles.set(name, arr);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
for (const [name, group] of nameToFiles) {
|
|
81
|
+
if (group.length > 1) {
|
|
82
|
+
out.push(f(group[0], 'error', 'agent-duplicate-name', `subagent name "${name}" is declared by ${group.length} files (${group.join(', ')}) — Claude Code loads only one; rename the rest`));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=agents-lint.js.map
|
|
@@ -17,6 +17,7 @@ export interface Finding {
|
|
|
17
17
|
}
|
|
18
18
|
export declare const TOPOLOGIES: string[];
|
|
19
19
|
export declare const MEMORY_BACKENDS: string[];
|
|
20
|
+
import { type AgentFile } from './agents-lint.js';
|
|
20
21
|
export declare const KNOWN_CONFIG_KEYS: string[];
|
|
21
22
|
export declare const HOOK_EVENTS: string[];
|
|
22
23
|
/** Parse a JSON file's raw text; a null raw means "file absent" (fine). */
|
|
@@ -49,6 +50,8 @@ export interface LintInput {
|
|
|
49
50
|
/** entries under `.claude/commands/sDo/` — used to identify flat pre-1.4 twins */
|
|
50
51
|
sdoCommands?: string[];
|
|
51
52
|
skills?: string[];
|
|
53
|
+
/** `.claude/agents/*.md` subagent definitions (raw text per file) */
|
|
54
|
+
agentFiles?: AgentFile[];
|
|
52
55
|
}
|
|
53
56
|
export interface LintReport {
|
|
54
57
|
findings: Finding[];
|
|
@@ -12,6 +12,7 @@ const f = (file, severity, rule, message) => ({ file, severity, rule, message })
|
|
|
12
12
|
export const TOPOLOGIES = ['hierarchical', 'mesh', 'hierarchical-mesh', 'ring', 'star', 'hybrid', 'adaptive'];
|
|
13
13
|
export const MEMORY_BACKENDS = ['agentdb', 'sqlite', 'hybrid', 'memory'];
|
|
14
14
|
import { parseOpenRouterConfig } from '../providers/openrouter-config.js';
|
|
15
|
+
import { lintAgents } from './agents-lint.js';
|
|
15
16
|
export const KNOWN_CONFIG_KEYS = ['topology', 'maxAgents', 'strategy', 'consensus', 'memory', 'memoryBackend', 'hnsw', 'neural', 'embeddings', 'providers', 'mcp', 'logging', 'daemon', 'hooks', 'version', 'openrouter', '$schema'];
|
|
16
17
|
// Current Claude Code hook events (source: code.claude.com/docs/en/hooks).
|
|
17
18
|
// Kept in sync with the runtime; a stale list here false-warns on valid hooks.
|
|
@@ -194,6 +195,8 @@ export function lintAll(input) {
|
|
|
194
195
|
findings.push(...jf, ...lintMcpConfig(input.mcpConfig.file, obj));
|
|
195
196
|
}
|
|
196
197
|
findings.push(...lintLegacyLayout(input.commandsRoot ?? [], input.skills ?? [], input.sdoCommands ?? []));
|
|
198
|
+
if (input.agentFiles?.length)
|
|
199
|
+
findings.push(...lintAgents(input.agentFiles));
|
|
197
200
|
return {
|
|
198
201
|
findings,
|
|
199
202
|
errors: findings.filter((x) => x.severity === 'error').length,
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* coupling.ts — temporal (co-change) coupling mined from git history.
|
|
3
|
+
*
|
|
4
|
+
* "Which files change together?" — the EMPIRICAL complement to `affected`'s
|
|
5
|
+
* STATIC import graph. Files that repeatedly land in the same commit are
|
|
6
|
+
* coupled in practice even when no import edge connects them (a JSON schema and
|
|
7
|
+
* its TS type, a serializer/deserializer split across modules, a feature flag
|
|
8
|
+
* and its consumers, a doc that must track an API). Surfacing that lets an agent
|
|
9
|
+
* or a maintainer catch the edit they'd otherwise forget.
|
|
10
|
+
*
|
|
11
|
+
* This is code-maat's `coupling`/`soc` analysis (Tornhill, *Your Code as a
|
|
12
|
+
* Crime Scene*), productized as "Change Coupling" in CodeScene. The scoring core
|
|
13
|
+
* is a pure fold over the SAME `git log --numstat` dump `hotspots` already
|
|
14
|
+
* parses, so it reuses that Commit type + parseGitLog and is unit-tested without
|
|
15
|
+
* a repo.
|
|
16
|
+
*/
|
|
17
|
+
import type { Commit } from '../hotspots/hotspots.js';
|
|
18
|
+
export interface CouplingPair {
|
|
19
|
+
/** the lexicographically smaller path of the pair */
|
|
20
|
+
a: string;
|
|
21
|
+
/** the lexicographically larger path of the pair */
|
|
22
|
+
b: string;
|
|
23
|
+
/** commits touching BOTH a and b */
|
|
24
|
+
shared: number;
|
|
25
|
+
/** commits touching a (within the file cap) */
|
|
26
|
+
aCommits: number;
|
|
27
|
+
/** commits touching b (within the file cap) */
|
|
28
|
+
bCommits: number;
|
|
29
|
+
/** shared / min(aCommits, bCommits) ∈ (0,1]; 1.0 = they never move apart */
|
|
30
|
+
degree: number;
|
|
31
|
+
}
|
|
32
|
+
export interface CouplingOptions {
|
|
33
|
+
/** drop pairs sharing fewer than this many commits (default 2) — support threshold */
|
|
34
|
+
minShared?: number;
|
|
35
|
+
/**
|
|
36
|
+
* skip commits touching more than this many files (default 30; 0 = no cap).
|
|
37
|
+
* A sweeping rename/format/license commit would otherwise couple everything
|
|
38
|
+
* it touches — the code-maat noise convention drops those wide commits.
|
|
39
|
+
*/
|
|
40
|
+
maxFiles?: number;
|
|
41
|
+
/** keep only the top N pairs after ranking (default: all) */
|
|
42
|
+
top?: number;
|
|
43
|
+
/** keep only pairs involving this exact path (a "what couples with X?" query) */
|
|
44
|
+
focus?: string;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Rank co-changing file pairs. Pure + deterministic.
|
|
48
|
+
*
|
|
49
|
+
* degree(A,B) = shared / min(commits(A), commits(B)) — the fraction of the
|
|
50
|
+
* rarer file's commits that also touched the other. Symmetric, in (0,1].
|
|
51
|
+
*/
|
|
52
|
+
export declare function computeCoupling(commits: Commit[], opts?: CouplingOptions): CouplingPair[];
|
|
53
|
+
export declare function couplingToCsv(pairs: CouplingPair[]): string;
|
|
54
|
+
/** Human-readable table. Pure. */
|
|
55
|
+
export declare function formatCoupling(pairs: CouplingPair[]): string;
|
|
56
|
+
//# sourceMappingURL=coupling.d.ts.map
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* coupling.ts — temporal (co-change) coupling mined from git history.
|
|
3
|
+
*
|
|
4
|
+
* "Which files change together?" — the EMPIRICAL complement to `affected`'s
|
|
5
|
+
* STATIC import graph. Files that repeatedly land in the same commit are
|
|
6
|
+
* coupled in practice even when no import edge connects them (a JSON schema and
|
|
7
|
+
* its TS type, a serializer/deserializer split across modules, a feature flag
|
|
8
|
+
* and its consumers, a doc that must track an API). Surfacing that lets an agent
|
|
9
|
+
* or a maintainer catch the edit they'd otherwise forget.
|
|
10
|
+
*
|
|
11
|
+
* This is code-maat's `coupling`/`soc` analysis (Tornhill, *Your Code as a
|
|
12
|
+
* Crime Scene*), productized as "Change Coupling" in CodeScene. The scoring core
|
|
13
|
+
* is a pure fold over the SAME `git log --numstat` dump `hotspots` already
|
|
14
|
+
* parses, so it reuses that Commit type + parseGitLog and is unit-tested without
|
|
15
|
+
* a repo.
|
|
16
|
+
*/
|
|
17
|
+
import { toCsv } from '../util/csv.js';
|
|
18
|
+
const US = '\x1f'; // pair-key separator (never appears in a file path)
|
|
19
|
+
/**
|
|
20
|
+
* Rank co-changing file pairs. Pure + deterministic.
|
|
21
|
+
*
|
|
22
|
+
* degree(A,B) = shared / min(commits(A), commits(B)) — the fraction of the
|
|
23
|
+
* rarer file's commits that also touched the other. Symmetric, in (0,1].
|
|
24
|
+
*/
|
|
25
|
+
export function computeCoupling(commits, opts = {}) {
|
|
26
|
+
const minShared = opts.minShared ?? 2;
|
|
27
|
+
const maxFiles = opts.maxFiles ?? 30;
|
|
28
|
+
const fileCommits = new Map(); // path → # commits touching it
|
|
29
|
+
const pairShared = new Map(); // "a\x1fb" → # commits touching both
|
|
30
|
+
for (const c of commits) {
|
|
31
|
+
const paths = [...new Set(c.files.map((f) => f.path))].filter(Boolean);
|
|
32
|
+
if (maxFiles > 0 && paths.length > maxFiles)
|
|
33
|
+
continue; // skip sweeping commits (both counts + pairs)
|
|
34
|
+
for (const p of paths)
|
|
35
|
+
fileCommits.set(p, (fileCommits.get(p) ?? 0) + 1);
|
|
36
|
+
const sorted = paths.slice().sort(); // canonical (a<b) pair keys
|
|
37
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
38
|
+
for (let j = i + 1; j < sorted.length; j++) {
|
|
39
|
+
const key = sorted[i] + US + sorted[j];
|
|
40
|
+
pairShared.set(key, (pairShared.get(key) ?? 0) + 1);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const pairs = [];
|
|
45
|
+
for (const [key, shared] of pairShared) {
|
|
46
|
+
if (shared < minShared)
|
|
47
|
+
continue;
|
|
48
|
+
const sep = key.indexOf(US);
|
|
49
|
+
const a = key.slice(0, sep);
|
|
50
|
+
const b = key.slice(sep + 1);
|
|
51
|
+
if (opts.focus && a !== opts.focus && b !== opts.focus)
|
|
52
|
+
continue;
|
|
53
|
+
const aCommits = fileCommits.get(a) ?? 0;
|
|
54
|
+
const bCommits = fileCommits.get(b) ?? 0;
|
|
55
|
+
const denom = Math.min(aCommits, bCommits) || 1;
|
|
56
|
+
const degree = Math.round((shared / denom) * 100) / 100;
|
|
57
|
+
pairs.push({ a, b, shared, aCommits, bCommits, degree });
|
|
58
|
+
}
|
|
59
|
+
// deterministic: strongest coupling first, then most-shared, then path order
|
|
60
|
+
pairs.sort((x, y) => y.degree - x.degree ||
|
|
61
|
+
y.shared - x.shared ||
|
|
62
|
+
(x.a < y.a ? -1 : x.a > y.a ? 1 : 0) ||
|
|
63
|
+
(x.b < y.b ? -1 : x.b > y.b ? 1 : 0));
|
|
64
|
+
return opts.top && opts.top > 0 ? pairs.slice(0, opts.top) : pairs;
|
|
65
|
+
}
|
|
66
|
+
export function couplingToCsv(pairs) {
|
|
67
|
+
const headers = ['fileA', 'fileB', 'degree', 'shared', 'commitsA', 'commitsB'];
|
|
68
|
+
const rows = pairs.map((p) => [p.a, p.b, p.degree, p.shared, p.aCommits, p.bCommits]);
|
|
69
|
+
return toCsv(headers, rows);
|
|
70
|
+
}
|
|
71
|
+
/** Human-readable table. Pure. */
|
|
72
|
+
export function formatCoupling(pairs) {
|
|
73
|
+
if (pairs.length === 0)
|
|
74
|
+
return 'no co-change coupling found (raise --since or lower --min-shared)';
|
|
75
|
+
const lines = [];
|
|
76
|
+
lines.push('rank degree shared commits(A/B) files');
|
|
77
|
+
pairs.forEach((p, i) => {
|
|
78
|
+
const rank = String(i + 1).padStart(4);
|
|
79
|
+
const degree = `${Math.round(p.degree * 100)}%`.padStart(6);
|
|
80
|
+
const shared = String(p.shared).padStart(6);
|
|
81
|
+
const ab = `${p.aCommits}/${p.bCommits}`.padStart(11);
|
|
82
|
+
lines.push(`${rank} ${degree} ${shared} ${ab} ${p.a} ↔ ${p.b}`);
|
|
83
|
+
});
|
|
84
|
+
return lines.join('\n');
|
|
85
|
+
}
|
|
86
|
+
//# sourceMappingURL=coupling.js.map
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* command-guard.ts — classify a bash command as safe or blockable, for a
|
|
3
|
+
* PreToolUse/Bash hook that emits Claude Code's `permissionDecision: "deny"`.
|
|
4
|
+
*
|
|
5
|
+
* swarmdo runs headless `claude -p --dangerously-skip-permissions` in its
|
|
6
|
+
* daemon / worker / repair flows; that flag skips the interactive approval
|
|
7
|
+
* prompt but NOT hooks, so a PreToolUse deny is the only remaining guardrail.
|
|
8
|
+
* The denylist is deliberately CONSERVATIVE — it blocks a small set of clearly
|
|
9
|
+
* destructive / irreversible / remote-exec commands and lets everything else
|
|
10
|
+
* through, so a routine `rm -rf ./build`, `rm -rf node_modules`, or a
|
|
11
|
+
* feature-branch force-push is never blocked. Pure + injection-free → fully
|
|
12
|
+
* unit-tested. This is defense-in-depth, not a sandbox: a determined command
|
|
13
|
+
* (indirection through a variable, an obscure tool) can still get through.
|
|
14
|
+
*/
|
|
15
|
+
export interface GuardVerdict {
|
|
16
|
+
block: boolean;
|
|
17
|
+
/** why it was blocked (present only when block === true) */
|
|
18
|
+
reason?: string;
|
|
19
|
+
/** the matched rule id (stable identifier, useful for tests/telemetry) */
|
|
20
|
+
rule?: string;
|
|
21
|
+
}
|
|
22
|
+
interface Rule {
|
|
23
|
+
id: string;
|
|
24
|
+
reason: string;
|
|
25
|
+
test: (c: string) => boolean;
|
|
26
|
+
}
|
|
27
|
+
/** The denylist. Order determines which reason is reported when several match. */
|
|
28
|
+
export declare const GUARD_RULES: Rule[];
|
|
29
|
+
/** Classify a bash command; blocks on the first matching danger rule, else allows. */
|
|
30
|
+
export declare function classifyCommand(command: string): GuardVerdict;
|
|
31
|
+
/**
|
|
32
|
+
* Extract the Bash command from a PreToolUse hook payload (the JSON Claude Code
|
|
33
|
+
* pipes on stdin: `{ "tool_name": "Bash", "tool_input": { "command": "…" } }`).
|
|
34
|
+
* Tolerant — returns '' when absent or unparseable, so the hook simply allows.
|
|
35
|
+
*/
|
|
36
|
+
export declare function extractBashCommand(payload: string): string;
|
|
37
|
+
/** The PreToolUse deny payload Claude Code honors to hard-block a tool call. */
|
|
38
|
+
export declare function denyOutput(reason: string): {
|
|
39
|
+
hookSpecificOutput: {
|
|
40
|
+
hookEventName: 'PreToolUse';
|
|
41
|
+
permissionDecision: 'deny';
|
|
42
|
+
permissionDecisionReason: string;
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
export {};
|
|
46
|
+
//# sourceMappingURL=command-guard.d.ts.map
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* command-guard.ts — classify a bash command as safe or blockable, for a
|
|
3
|
+
* PreToolUse/Bash hook that emits Claude Code's `permissionDecision: "deny"`.
|
|
4
|
+
*
|
|
5
|
+
* swarmdo runs headless `claude -p --dangerously-skip-permissions` in its
|
|
6
|
+
* daemon / worker / repair flows; that flag skips the interactive approval
|
|
7
|
+
* prompt but NOT hooks, so a PreToolUse deny is the only remaining guardrail.
|
|
8
|
+
* The denylist is deliberately CONSERVATIVE — it blocks a small set of clearly
|
|
9
|
+
* destructive / irreversible / remote-exec commands and lets everything else
|
|
10
|
+
* through, so a routine `rm -rf ./build`, `rm -rf node_modules`, or a
|
|
11
|
+
* feature-branch force-push is never blocked. Pure + injection-free → fully
|
|
12
|
+
* unit-tested. This is defense-in-depth, not a sandbox: a determined command
|
|
13
|
+
* (indirection through a variable, an obscure tool) can still get through.
|
|
14
|
+
*/
|
|
15
|
+
/** `rm` invoked with BOTH a recursive and a force flag (combined `-rf`/`-fr` or separate). */
|
|
16
|
+
function isRecursiveForceRm(c) {
|
|
17
|
+
if (!/\brm\b/.test(c))
|
|
18
|
+
return false;
|
|
19
|
+
const recursive = /(?:^|\s)-[a-z]*r[a-z]*(?=\s|$)/i.test(c) || /(?:^|\s)--recursive\b/.test(c);
|
|
20
|
+
const force = /(?:^|\s)-[a-z]*f[a-z]*(?=\s|$)/i.test(c) || /(?:^|\s)--force\b/.test(c);
|
|
21
|
+
return recursive && force;
|
|
22
|
+
}
|
|
23
|
+
/** Does the command target a root / home / top-level system path (the only rm targets we block)? */
|
|
24
|
+
function hasCriticalTarget(c) {
|
|
25
|
+
if (/--no-preserve-root\b/.test(c))
|
|
26
|
+
return true;
|
|
27
|
+
// a bare `/`, `~`, `$HOME`/`${HOME}` token, optionally with a trailing `/` or `/*` or `*`
|
|
28
|
+
if (/(?:^|\s)(?:\/|~|\$\{?HOME\}?)(?:\/\s|\/$|\/\*|\*|\s|$)/.test(c))
|
|
29
|
+
return true;
|
|
30
|
+
// a top-level system directory
|
|
31
|
+
if (/(?:^|\s)\/(?:etc|usr|bin|sbin|var|lib|lib64|boot|sys|proc|dev|root|opt)(?:\/|\s|\*|$)/.test(c))
|
|
32
|
+
return true;
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
/** The denylist. Order determines which reason is reported when several match. */
|
|
36
|
+
export const GUARD_RULES = [
|
|
37
|
+
{
|
|
38
|
+
id: 'rm-rf-critical',
|
|
39
|
+
reason: 'recursive force-delete of a root, home, or system path (rm -rf /, ~, or --no-preserve-root)',
|
|
40
|
+
test: (c) => isRecursiveForceRm(c) && hasCriticalTarget(c),
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: 'pipe-to-shell',
|
|
44
|
+
reason: 'piping remote content straight into a shell (curl/wget … | sh) — remote code execution',
|
|
45
|
+
test: (c) => /(?:\bcurl\b|\bwget\b)[^|]*\|\s*(?:sudo\s+)?(?:sh|bash|zsh|dash|ksh|fish)\b/.test(c),
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: 'force-push-protected',
|
|
49
|
+
reason: 'force-push to a protected branch (main/master) — use --force-with-lease',
|
|
50
|
+
test: (c) => /\bgit\s+push\b/.test(c) && /(?:--force(?!-with-lease)\b|(?:^|\s)-f\b)/.test(c) && /\b(?:main|master)\b/.test(c),
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: 'chmod-world-writable',
|
|
54
|
+
reason: 'world-writable permissions (chmod 777)',
|
|
55
|
+
test: (c) => /\bchmod\s+(?:-[a-z]+\s+)*(?:0?777|a\+rwx|ugo\+rwx)\b/i.test(c),
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: 'dd-to-device',
|
|
59
|
+
reason: 'dd writing directly to a block device (of=/dev/…) — data-destroying',
|
|
60
|
+
test: (c) => /\bdd\b[^|;&]*\bof=\/dev\/(?:sd|nvme|disk|hd|mmcblk|vd|xvd)/.test(c),
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: 'mkfs',
|
|
64
|
+
reason: 'formatting a filesystem (mkfs) — data-destroying',
|
|
65
|
+
test: (c) => /\bmkfs(?:\.\w+)?\b/.test(c),
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: 'redirect-to-device',
|
|
69
|
+
reason: 'redirecting output onto a raw block device (> /dev/sd…) — data-destroying',
|
|
70
|
+
test: (c) => /(?:^|\s)>{1,2}\s*\/dev\/(?:sd|nvme|disk|hd|mmcblk|vd|xvd)/.test(c),
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: 'fork-bomb',
|
|
74
|
+
reason: 'shell fork bomb',
|
|
75
|
+
test: (c) => /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/.test(c.replace(/\s+/g, ' ')),
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
/** Classify a bash command; blocks on the first matching danger rule, else allows. */
|
|
79
|
+
export function classifyCommand(command) {
|
|
80
|
+
const c = (command ?? '').trim();
|
|
81
|
+
if (!c)
|
|
82
|
+
return { block: false };
|
|
83
|
+
for (const r of GUARD_RULES) {
|
|
84
|
+
// a bad regex must never crash the hook (and thus never wrongly block)
|
|
85
|
+
try {
|
|
86
|
+
if (r.test(c))
|
|
87
|
+
return { block: true, reason: r.reason, rule: r.id };
|
|
88
|
+
}
|
|
89
|
+
catch { /* skip */ }
|
|
90
|
+
}
|
|
91
|
+
return { block: false };
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Extract the Bash command from a PreToolUse hook payload (the JSON Claude Code
|
|
95
|
+
* pipes on stdin: `{ "tool_name": "Bash", "tool_input": { "command": "…" } }`).
|
|
96
|
+
* Tolerant — returns '' when absent or unparseable, so the hook simply allows.
|
|
97
|
+
*/
|
|
98
|
+
export function extractBashCommand(payload) {
|
|
99
|
+
if (!payload || !payload.trim())
|
|
100
|
+
return '';
|
|
101
|
+
try {
|
|
102
|
+
const o = JSON.parse(payload);
|
|
103
|
+
const cmd = o?.tool_input?.command ?? o?.command;
|
|
104
|
+
return typeof cmd === 'string' ? cmd : '';
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return '';
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** The PreToolUse deny payload Claude Code honors to hard-block a tool call. */
|
|
111
|
+
export function denyOutput(reason) {
|
|
112
|
+
return {
|
|
113
|
+
hookSpecificOutput: {
|
|
114
|
+
hookEventName: 'PreToolUse',
|
|
115
|
+
permissionDecision: 'deny',
|
|
116
|
+
permissionDecisionReason: reason,
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
//# sourceMappingURL=command-guard.js.map
|
|
@@ -37,6 +37,14 @@ export const RECIPES = [
|
|
|
37
37
|
title: 'Surface new cross-session messages each prompt',
|
|
38
38
|
description: 'On every prompt, injects any unread messages other sessions sent to this one (multiplayer swarms) as context, then marks them read — so mail surfaces without polling (UserPromptSubmit hook). Powered by `swarmdo comms inbox --hook`. Opt-in.',
|
|
39
39
|
},
|
|
40
|
+
{
|
|
41
|
+
name: 'command-guard',
|
|
42
|
+
event: 'PreToolUse',
|
|
43
|
+
matcher: 'Bash',
|
|
44
|
+
command: 'swarmdo hooks guard-bash',
|
|
45
|
+
title: 'Block dangerous bash commands before they run',
|
|
46
|
+
description: 'Inspects every Bash tool call and hard-blocks a conservative denylist of destructive commands (rm -rf / , curl|sh, force-push to main, chmod 777, dd to a device, mkfs, fork bombs) via Claude Code’s PreToolUse `permissionDecision: "deny"`. Powered by `swarmdo hooks guard-bash`. Opt-in — the one guardrail that still fires under `--dangerously-skip-permissions`.',
|
|
47
|
+
},
|
|
40
48
|
];
|
|
41
49
|
export function findRecipe(name) {
|
|
42
50
|
const n = (name ?? '').trim().toLowerCase();
|
|
@@ -26,6 +26,7 @@ import { envTools } from './mcp-tools/env-tools.js';
|
|
|
26
26
|
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
|
+
import { couplingTools } from './mcp-tools/coupling-tools.js';
|
|
29
30
|
import { affectedTools } from './mcp-tools/affected-tools.js';
|
|
30
31
|
import { cyclesTools } from './mcp-tools/cycles-tools.js';
|
|
31
32
|
import { testreportTools } from './mcp-tools/testreport-tools.js';
|
|
@@ -119,6 +120,7 @@ const TOOL_GROUPS = {
|
|
|
119
120
|
license: () => licenseTools,
|
|
120
121
|
apply: () => applyTools,
|
|
121
122
|
hotspots: () => hotspotsTools,
|
|
123
|
+
coupling: () => couplingTools,
|
|
122
124
|
affected: () => affectedTools,
|
|
123
125
|
cycles: () => cyclesTools,
|
|
124
126
|
testreport: () => testreportTools,
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coupling MCP Tool
|
|
3
|
+
*
|
|
4
|
+
* Let an agent ask "which files change together?" and get ranked JSON — the
|
|
5
|
+
* EMPIRICAL complement to `affected`'s static import graph. Files that keep
|
|
6
|
+
* landing in the same commit are coupled in practice even when no import edge
|
|
7
|
+
* connects them (a JSON schema and its TS type, a serializer/deserializer split
|
|
8
|
+
* across modules, a doc that must track an API). Surfacing that lets an agent
|
|
9
|
+
* catch the co-edit it would otherwise forget. Shares the pure engine in
|
|
10
|
+
* ../coupling/coupling.ts with the CLI; captures git history via subprocess.
|
|
11
|
+
*/
|
|
12
|
+
import type { MCPTool } from './types.js';
|
|
13
|
+
export declare const couplingTools: MCPTool[];
|
|
14
|
+
//# sourceMappingURL=coupling-tools.d.ts.map
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coupling MCP Tool
|
|
3
|
+
*
|
|
4
|
+
* Let an agent ask "which files change together?" and get ranked JSON — the
|
|
5
|
+
* EMPIRICAL complement to `affected`'s static import graph. Files that keep
|
|
6
|
+
* landing in the same commit are coupled in practice even when no import edge
|
|
7
|
+
* connects them (a JSON schema and its TS type, a serializer/deserializer split
|
|
8
|
+
* across modules, a doc that must track an API). Surfacing that lets an agent
|
|
9
|
+
* catch the co-edit it would otherwise forget. Shares the pure engine in
|
|
10
|
+
* ../coupling/coupling.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 { computeCoupling } from '../coupling/coupling.js';
|
|
15
|
+
import { normalizeSince } from '../util/since.js';
|
|
16
|
+
const couplingTool = {
|
|
17
|
+
name: 'coupling',
|
|
18
|
+
description: 'Rank file pairs that change together in git history (temporal/co-change coupling) — the empirical complement to `affected`. Returns ranked JSON so an agent editing one file can see what else historically moves with it and avoid a forgotten co-edit. Pass `file` for a "what changes with X?" query. Runs in a git repository.',
|
|
19
|
+
category: 'coupling',
|
|
20
|
+
tags: ['git', 'co-change', 'coupling', 'impact', 'refactor'],
|
|
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 pairs (default 30; 0 = all)' },
|
|
27
|
+
minShared: { type: 'number', description: 'drop pairs sharing fewer than N commits (default 2)' },
|
|
28
|
+
maxFiles: { type: 'number', description: 'skip commits touching more than N files (default 30; 0 = no cap)' },
|
|
29
|
+
file: { type: 'string', description: 'show only pairs involving this exact path ("what changes with X?")' },
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
handler: async (params) => {
|
|
33
|
+
const cwd = typeof params.path === 'string' ? params.path : process.cwd();
|
|
34
|
+
const since = typeof params.since === 'string' ? params.since : '1 year ago';
|
|
35
|
+
const top = typeof params.top === 'number' ? params.top : 30;
|
|
36
|
+
const minShared = typeof params.minShared === 'number' ? params.minShared : 2;
|
|
37
|
+
const maxFiles = typeof params.maxFiles === 'number' ? params.maxFiles : 30;
|
|
38
|
+
const focus = typeof params.file === 'string' && params.file ? params.file : undefined;
|
|
39
|
+
// Capture the FULL history (no pathspec filter even when focused) — filtering
|
|
40
|
+
// the log to `focus` would strip the co-changed files out of each commit's
|
|
41
|
+
// numstat. We filter PAIRS in the engine (opts.focus) instead. Same
|
|
42
|
+
// `--numstat` dump `hotspots` parses.
|
|
43
|
+
const args = ['log', '--no-merges', '--numstat', `--since=${normalizeSince(since)}`, '--format=format:%x01%H%x1f%aN%x1f%aI'];
|
|
44
|
+
let raw;
|
|
45
|
+
try {
|
|
46
|
+
raw = execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 128 * 1024 * 1024 });
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return { error: true, message: 'git log failed — not a git repository?' };
|
|
50
|
+
}
|
|
51
|
+
const coupling = computeCoupling(parseGitLog(raw), { minShared, maxFiles, top: top > 0 ? top : undefined, focus });
|
|
52
|
+
return { since, minShared, count: coupling.length, coupling };
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
export const couplingTools = [couplingTool];
|
|
56
|
+
//# sourceMappingURL=coupling-tools.js.map
|