This is an automated email from the ASF dual-hosted git repository.
spmallette pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/tinkerpop.git
The following commit(s) were added to refs/heads/master by this push:
new 27a498c3ad tinker-review: add Louvain community detection
27a498c3ad is described below
commit 27a498c3adf491713375c3bd88c1d65143ecd4ec
Author: Stephen Mallette <[email protected]>
AuthorDate: Mon Jul 6 13:03:54 2026 -0400
tinker-review: add Louvain community detection
Adds thematic community detection (graphology Louvain) over the code
subgraph alongside the existing connected-component guard, weighted by
edge confidence with a well-connectedness split pass. Communities are
described by change mode and line churn from the diff, and the report
gets a colour-coded community diagram plus an enrichment-authored
assessment slot; raw membership moves to the appendix.
Assisted-by: Claude Code:claude-opus-4-8
---
.skills/tinker-review/SKILL.md | 13 +-
.skills/tinker-review/package-lock.json | 82 +++++
.skills/tinker-review/package.json | 6 +-
.skills/tinker-review/references/schema.md | 7 +
.../scripts/patterns/community-detection.js | 407 +++++++++++++++++++++
.skills/tinker-review/scripts/renderer/render.js | 97 ++++-
.skills/tinker-review/scripts/review.js | 40 ++
.../tinker-review/test/community-detection.test.js | 148 ++++++++
8 files changed, 795 insertions(+), 5 deletions(-)
diff --git a/.skills/tinker-review/SKILL.md b/.skills/tinker-review/SKILL.md
index d0479963ac..6eaf5f5e6c 100644
--- a/.skills/tinker-review/SKILL.md
+++ b/.skills/tinker-review/SKILL.md
@@ -185,7 +185,18 @@ produce a complete evidence-with-narrative JSON file.
Write it to
`/tmp/pr-review-<pr>/report.json`. The narrative fields you must provide:
- `summary` — HTML paragraph describing the PR
-- `clusters.assessment` — HTML prose about what the clusters mean
+- `clusters.assessment` — HTML prose about what the connected-component
clusters mean
+- `communityAssessment` — HTML, **light by default**: usually one or two
sentences on
+ whether the change is coherent/localized and what its dominant theme is.
Expand *only*
+ when the community **structure itself** shows something non-obvious that no
other section
+ captures — e.g. disconnected communities hinting at bundled unrelated
changes, a community
+ bridging subsystems that shouldn't be coupled, a changed file stranded in a
community about
+ an unrelated concern, or diffuse modularity on a supposedly focused change.
Absent such a
+ structural surprise, keep it short and instead use the communities as
*supporting evidence*
+ cited in other sections (Guided Walk, Findings, Change Coherence). Do NOT
restate the graph
+ literally (vertices/edges/labels/counts), do NOT re-narrate what Removal
References,
+ Coverage, or Blast Radius already report, and do NOT pad. The renderer draws
the diagram
+ and the appendix lists membership; you supply only the judgment.
- `guidedWalk` — array of `{ title, badge, badgeText, body }` objects
- `findings` — array of `{ title, snippet, body }` objects, ordered
most-severe-first (Interpret grades each blocking / high / low)
- `openQuestions` — array of `{ title, body, meta }` objects
diff --git a/.skills/tinker-review/package-lock.json
b/.skills/tinker-review/package-lock.json
index 9a6c7781d3..87b38cb952 100644
--- a/.skills/tinker-review/package-lock.json
+++ b/.skills/tinker-review/package-lock.json
@@ -8,6 +8,8 @@
"name": "@tinkerpop/graph-review",
"version": "0.1.0",
"dependencies": {
+ "graphology": "^0.26.0",
+ "graphology-communities-louvain": "^2.0.2",
"gremlin": "^3.8.1",
"tree-sitter-wasms": "^0.1.13",
"web-tree-sitter": "^0.24.7"
@@ -96,6 +98,62 @@
"node": ">=0.8.x"
}
},
+ "node_modules/graphology": {
+ "version": "0.26.0",
+ "resolved":
"https://registry.npmjs.org/graphology/-/graphology-0.26.0.tgz",
+ "integrity":
"sha512-8SSImzgUUYC89Z042s+0r/vMibY7GX/Emz4LDO5e7jYXhuoWfHISPFJYjpRLUSJGq6UQ6xlenvX1p/hJdfXuXg==",
+ "license": "MIT",
+ "dependencies": {
+ "events": "^3.3.0"
+ },
+ "peerDependencies": {
+ "graphology-types": ">=0.24.0"
+ }
+ },
+ "node_modules/graphology-communities-louvain": {
+ "version": "2.0.2",
+ "resolved":
"https://registry.npmjs.org/graphology-communities-louvain/-/graphology-communities-louvain-2.0.2.tgz",
+ "integrity":
"sha512-zt+2hHVPYxjEquyecxWXoUoIuN/UvYzsvI7boDdMNz0rRvpESQ7+e+Ejv6wK7AThycbZXuQ6DkG8NPMCq6XwoA==",
+ "license": "MIT",
+ "dependencies": {
+ "graphology-indices": "^0.17.0",
+ "graphology-utils": "^2.4.4",
+ "mnemonist": "^0.39.0",
+ "pandemonium": "^2.4.1"
+ },
+ "peerDependencies": {
+ "graphology-types": ">=0.19.0"
+ }
+ },
+ "node_modules/graphology-indices": {
+ "version": "0.17.0",
+ "resolved":
"https://registry.npmjs.org/graphology-indices/-/graphology-indices-0.17.0.tgz",
+ "integrity":
"sha512-A7RXuKQvdqSWOpn7ZVQo4S33O0vCfPBnUSf7FwE0zNCasqwZVUaCXePuWo5HBpWw68KJcwObZDHpFk6HKH6MYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "graphology-utils": "^2.4.2",
+ "mnemonist": "^0.39.0"
+ },
+ "peerDependencies": {
+ "graphology-types": ">=0.20.0"
+ }
+ },
+ "node_modules/graphology-types": {
+ "version": "0.24.8",
+ "resolved":
"https://registry.npmjs.org/graphology-types/-/graphology-types-0.24.8.tgz",
+ "integrity":
"sha512-hDRKYXa8TsoZHjgEaysSRyPdT6uB78Ci8WnjgbStlQysz7xR52PInxNsmnB7IBOM1BhikxkNyCVEFgmPKnpx3Q==",
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/graphology-utils": {
+ "version": "2.5.2",
+ "resolved":
"https://registry.npmjs.org/graphology-utils/-/graphology-utils-2.5.2.tgz",
+ "integrity":
"sha512-ckHg8MXrXJkOARk56ZaSCM1g1Wihe2d6iTmz1enGOz4W/l831MBCKSayeFQfowgF8wd+PQ4rlch/56Vs/VZLDQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "graphology-types": ">=0.23.0"
+ }
+ },
"node_modules/gremlin": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/gremlin/-/gremlin-3.8.1.tgz",
@@ -132,6 +190,30 @@
],
"license": "BSD-3-Clause"
},
+ "node_modules/mnemonist": {
+ "version": "0.39.8",
+ "resolved":
"https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.8.tgz",
+ "integrity":
"sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==",
+ "license": "MIT",
+ "dependencies": {
+ "obliterator": "^2.0.1"
+ }
+ },
+ "node_modules/obliterator": {
+ "version": "2.0.5",
+ "resolved":
"https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
+ "integrity":
"sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==",
+ "license": "MIT"
+ },
+ "node_modules/pandemonium": {
+ "version": "2.4.1",
+ "resolved":
"https://registry.npmjs.org/pandemonium/-/pandemonium-2.4.1.tgz",
+ "integrity":
"sha512-wRqjisUyiUfXowgm7MFH2rwJzKIr20rca5FsHXCMNm1W5YPP1hCtrZfgmQ62kP7OZ7Xt+cR858aB28lu5NX55g==",
+ "license": "MIT",
+ "dependencies": {
+ "mnemonist": "^0.39.2"
+ }
+ },
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
diff --git a/.skills/tinker-review/package.json
b/.skills/tinker-review/package.json
index a76f6ef8f8..3eece6b32d 100644
--- a/.skills/tinker-review/package.json
+++ b/.skills/tinker-review/package.json
@@ -12,8 +12,10 @@
"review": "node review.js"
},
"dependencies": {
+ "graphology": "^0.26.0",
+ "graphology-communities-louvain": "^2.0.2",
"gremlin": "^3.8.1",
- "web-tree-sitter": "^0.24.7",
- "tree-sitter-wasms": "^0.1.13"
+ "tree-sitter-wasms": "^0.1.13",
+ "web-tree-sitter": "^0.24.7"
}
}
diff --git a/.skills/tinker-review/references/schema.md
b/.skills/tinker-review/references/schema.md
index 31da9ec935..47df6e17a3 100644
--- a/.skills/tinker-review/references/schema.md
+++ b/.skills/tinker-review/references/schema.md
@@ -48,6 +48,13 @@ expansion (which pulls a changed type's ancestors and
descendants in as context)
so stubs mostly stand for third-party types. They lack `kind`/`filePath`;
filter
them with `.has("external", false)` or `.hasNot("external")`.
+*Analysis-written property* — `community: number` is stamped onto Function,
Type,
+File and Test vertices by community detection (`communityDetection`, Louvain
+modularity over the code subgraph). Vertices sharing a value are one
densely-tied
+theme; query a theme with `.has("community", n)`. Vertices with no in-subgraph
code
+edge are left unstamped. This is distinct from `connectedComponent()`, which
the
+`clusterAnalysis` guard runs on the `a` (OLAP) source without persisting a
property.
+
### TinkerPop domain
**Step** `{ name, canonical_name }`
diff --git a/.skills/tinker-review/scripts/patterns/community-detection.js
b/.skills/tinker-review/scripts/patterns/community-detection.js
new file mode 100644
index 0000000000..4596618c60
--- /dev/null
+++ b/.skills/tinker-review/scripts/patterns/community-detection.js
@@ -0,0 +1,407 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import Graph from "graphology";
+import louvain from "graphology-communities-louvain";
+
+/**
+ * Louvain modularity community detection over the PR's *code* subgraph.
+ *
+ * Complements `clusterAnalysis` (connected components), which stays as the
coarse
+ * "disjoint islands" guard. Connected components asks "is any changed file
totally
+ * unreachable from the rest?"; that signal saturates at one component in a
+ * well-connected codebase. Community detection asks the finer question — "how
many
+ * densely-tied themes does this change actually contain?" — and keeps giving
signal
+ * even when everything is technically reachable.
+ *
+ * We run over Function/Type/File/Test vertices tied by code edges only.
Discussion,
+ * Step, Doc and GrammarRule vertices are excluded on purpose: the PR vertex's
+ * `modifies` star and the `implements_step` hub would connect everything
through a
+ * single node and collapse the modularity structure we are trying to surface.
+ *
+ * Louvain can occasionally emit an internally-disconnected community; a
+ * well-connectedness post-pass splits any such community back into its
connected
+ * pieces (the one guarantee Leiden would give us natively, recovered cheaply
at
+ * PR scale).
+ *
+ * Communities are detected at Function/Type grain and rolled up to the files
they
+ * touch for reporting. Each vertex is stamped with a `community` property so a
+ * reviewer can query the partition directly (`g.V().has("community", n)`).
+ *
+ * @param {object} g - gremlin-js GraphTraversalSource (standard OLTP source)
+ * @param {object} params
+ * @param {number} [params.resolution] Louvain resolution (default: 1)
+ * @param {boolean} [params.writeBack] Stamp `community` onto vertices
(default: true)
+ * @param {number} [params.minCommunitySize] Report communities at or above
this size (default: 2)
+ * @param {object} [params.churn] Per-file `{ [path]: { added,
removed, deleted } }`
+ * (from the diff) so communities can be described by *how* they changed —
reduction
+ * vs. expansion — and the report can note that deleted code is absent from
the graph.
+ * @returns {Promise<CommunityResult>}
+ */
+
+/** Confidence → edge weight. Stronger evidence pulls harder. */
+const WEIGHT = { EXTRACTED: 3, INFERRED: 2, AMBIGUOUS: 1 };
+
+const NODE_LABELS = ["Function", "Type", "File", "Test"];
+const EDGE_LABELS = ["calls", "defines", "declares", "extends", "implements",
"overrides", "tests"];
+
+/**
+ * @typedef {Object} Community
+ * @property {number} id 0-based community index (largest first)
+ * @property {number} size member vertex count
+ * @property {string[]} files files this community touches (rolled
up), largest share first
+ * @property {string} dominantFile the file contributing the most members
+ * @property {Object} labelCounts member count per vertex label
(Function/Type/File/Test)
+ * @property {number} changedCount members with changed:true
+ * @property {string} dominantLabel most common vertex label
+ * @property {number} testShare fraction of members living in test files
— drives the role
+ * @property {?Object} churn `{ added, removed, mode }` for the
community's files, when churn is supplied
+ * @property {string} role human descriptor, e.g. "test
scaffolding, reduced (−240/+12), spanning 3 files"
+ *
+ * @typedef {Object} CommunityResult
+ * @property {number} communityCount communities with size >=
minCommunitySize
+ * @property {number} isolatedCount vertices with no in-subgraph code
edge
+ * @property {number} modularity Louvain modularity of the partition
(0..1)
+ * @property {number} splitCount communities split by the
well-connectedness pass
+ * @property {?Object} churn `{ added, removed, deletedFiles }`
across all changed files, when supplied
+ * @property {Community[]} communities communities >= minCommunitySize,
largest first
+ * @property {number} totalNodes vertices fed to Louvain
+ * @property {number} totalEdges undirected edges after
weight-merging
+ */
+export async function communityDetection(g, params = {}) {
+ const { nodes, edges } = await extractCodeSubgraph(g);
+ const result = detectCommunities(nodes, edges, params);
+
+ if (params.writeBack !== false && result.assignments) {
+ await writeBackCommunities(g, result.assignments);
+ }
+ delete result.assignments;
+ return result;
+}
+
+/**
+ * Pull the code subgraph out as flat node/edge lists via `elementMap`. Edge
+ * `elementMap` carries the incident vertices (IN/OUT) and the edge's
`confidence`
+ * in one step, so no `by()` projection is needed. A separate vertex query
captures
+ * nodes with no in-subgraph edge (meaningful singletons) that the edge query
alone
+ * would never surface.
+ */
+async function extractCodeSubgraph(g) {
+ const vRaw = await
g.V().hasLabel(...NODE_LABELS).hasNot("external").hasNot("parsed").elementMap().toList();
+ const eRaw = await g.E().hasLabel(...EDGE_LABELS).elementMap().toList();
+
+ const nodes = vRaw.map((m) => {
+ const o = mapToObj(m);
+ return { id: String(o.id), label: o.label, name: o.name, filePath:
o.filePath || o.path, changed: o.changed === true };
+ });
+
+ const edges = eRaw.map((m) => {
+ const o = mapToObj(m);
+ return { from: String(o.OUT.id), to: String(o.IN.id), conf: o.confidence };
+ });
+
+ return { nodes, edges };
+}
+
+/**
+ * Pure community detection over plain node/edge lists — no graph I/O, so it is
+ * unit-testable without a server.
+ *
+ * @param {Array<{id,label,name,filePath}>} nodes
+ * @param {Array<{from,to,conf}>} edges
+ * @param {object} params - see communityDetection; `params.churn` is an
optional
+ * per-file `{ [path]: { added, removed, deleted } }` map (all changed
files, not
+ * just clustered ones) that lets each community be described by *how* it
changed.
+ * @returns {CommunityResult & { assignments: Map<string, number> }}
+ */
+export function detectCommunities(nodes, edges, params = {}) {
+ const resolution = params.resolution || 1;
+ const minSize = params.minCommunitySize || 2;
+ const churn = params.churn || null;
+
+ const nodeById = new Map(nodes.map((n) => [n.id, n]));
+ const graph = new Graph({ type: "undirected", multi: false, allowSelfLoops:
false });
+ for (const n of nodes) graph.addNode(n.id);
+
+ for (const e of edges) {
+ if (e.from === e.to) continue;
+ if (!graph.hasNode(e.from) || !graph.hasNode(e.to)) continue; // endpoint
was filtered out
+ const w = WEIGHT[e.conf] || 1;
+ if (graph.hasEdge(e.from, e.to)) {
+ graph.updateEdgeAttribute(e.from, e.to, "weight", (x) => (x || 0) + w);
+ } else {
+ graph.addEdge(e.from, e.to, { weight: w });
+ }
+ }
+
+ let communities = {};
+ let modularity = 0;
+ if (graph.size > 0) {
+ const detailed = louvain.detailed(graph, { getEdgeWeight: "weight",
resolution });
+ communities = detailed.communities;
+ modularity = detailed.modularity;
+ } else {
+ // No edges: every node is its own community.
+ graph.forEachNode((n, _attr) => { communities[n] =
graph.nodes().indexOf(n); });
+ }
+
+ const { assignments, splitCount } = splitDisconnected(graph, communities);
+
+ // Group members by final community id.
+ const members = new Map();
+ for (const [id, comm] of assignments) {
+ if (!members.has(comm)) members.set(comm, []);
+ members.get(comm).push(id);
+ }
+
+ const built = [...members.entries()]
+ .map(([, ids]) => rollUp(ids, nodeById, churn))
+ .sort((a, b) => b.size - a.size);
+
+ const communityList = built.filter((c) => c.size >= minSize).map((c, i) =>
({ ...c, id: i }));
+ const isolatedCount = built.filter((c) => c.size < minSize).length;
+
+ return {
+ communityCount: communityList.length,
+ isolatedCount,
+ modularity: Number(modularity.toFixed(4)),
+ splitCount,
+ communities: communityList,
+ churn: globalChurn(churn),
+ interpretation: interpret(communityList, modularity, churn),
+ totalNodes: graph.order,
+ totalEdges: graph.size,
+ assignments,
+ };
+}
+
+/** Sum churn across all changed files, and count how many were deleted
outright. */
+function globalChurn(churn) {
+ if (!churn) return null;
+ let added = 0, removed = 0, deletedFiles = 0;
+ for (const c of Object.values(churn)) {
+ added += c.added || 0;
+ removed += c.removed || 0;
+ if (c.deleted) deletedFiles++;
+ }
+ return { added, removed, deletedFiles };
+}
+
+/**
+ * Turn the raw partition into a reading a human can act on, deterministically.
+ * The headline grades separation by modularity; the bullets call out what the
+ * dense structure is *made of* (e.g. all test scaffolding — a real signal for
a
+ * removal PR, where deleted production code leaves nothing to cluster) and how
+ * much of it the PR actually changed versus context pulled in for shape.
+ */
+function interpret(communities, modularity, churn) {
+ if (communities.length === 0) {
+ return { headline: "No thematic structure: the change has no
densely-connected group of two or more code vertices.", reading: [] };
+ }
+
+ const headline = modularity < 0.3
+ ? `Diffuse (modularity ${modularity.toFixed(2)}): the change reads as one
loosely-connected mass rather than separable themes.`
+ : modularity > 0.7
+ ? `Sharply themed (modularity ${modularity.toFixed(2)}):
${communities.length} clearly separated communities.`
+ : `Moderately themed (modularity ${modularity.toFixed(2)}):
${communities.length} distinguishable communities with some cross-talk.`;
+
+ const reading = [];
+ const testDom = communities.filter((c) => (c.testShare ?? 0) >= 0.8).length;
+ if (testDom === communities.length) {
+ reading.push("Every community is test infrastructure. The production code
this change touches has too few internal call edges to cluster — typical when
code is removed or thinned — so the densest remaining structure is the test
scaffolding around it. Read these as the test areas the change disturbs, not
new functional groupings.");
+ } else if (testDom > communities.length / 2) {
+ reading.push(`${testDom} of ${communities.length} communities are test
infrastructure, so the change's dense structure sits more in tests than in
production code.`);
+ }
+
+ const g = globalChurn(churn);
+ if (g && (g.added > 0 || g.removed > 0)) {
+ const net = g.added - g.removed;
+ const direction = net < 0 ? "net-deletion" : net > 0 ? "net-addition" :
"balanced";
+ reading.push(`This change is ${direction} overall
(−${g.removed}/+${g.added} lines). ${net < 0 ? "The communities describe
surviving structure being trimmed, not new structure being built." : "The
communities describe structure being grown or reworked."}`);
+ if (g.deletedFiles > 0) {
+ reading.push(`${g.deletedFiles} file(s) were deleted outright. Deleted
code has no vertices, so it is absent from every community — these themes show
only the surviving structure the removal reached into. See Removal References
for what was actually removed.`);
+ }
+ } else {
+ const total = communities.reduce((s, c) => s + c.size, 0);
+ const changed = communities.reduce((s, c) => s + c.changedCount, 0);
+ const share = total ? Math.round((changed / total) * 100) : 0;
+ reading.push(`${share}% of clustered vertices are actually changed by this
PR; the rest is surrounding context pulled in to give the change structural
shape.`);
+ }
+
+ return { headline, reading };
+}
+
+/**
+ * Describe what a community is: its kind (test scaffolding / call cluster /
…),
+ * how it changed, and its file span. When churn is known it drives the change
+ * clause (real line counts beat the changed/unchanged vertex-share heuristic);
+ * otherwise we fall back to that share.
+ */
+function describeRole(label, changedShare, fileCount, testShare, churnSummary)
{
+ const kind = testShare >= 0.6 ? "test scaffolding"
+ : label === "Type" ? "type hierarchy"
+ : label === "File" ? "file group"
+ : "call cluster";
+ const change = churnSummary
+ ? `${churnSummary.mode} (−${churnSummary.removed}/+${churnSummary.added})`
+ : changedShare >= 0.6 ? "mostly changed"
+ : changedShare <= 0.2 ? "mostly context"
+ : "mixed changed/context";
+ const span = fileCount > 1 ? `spanning ${fileCount} files` : "within one
file";
+ return `${kind}, ${change}, ${span}`;
+}
+
+/**
+ * Well-connectedness pass: within each Louvain community, find connected
pieces
+ * (using the code subgraph's own edges) and give each piece its own community
id.
+ * A community that is already internally connected passes through unchanged; a
+ * disconnected one is split. This recovers Leiden's connectedness guarantee.
+ */
+function splitDisconnected(graph, communities) {
+ const byComm = new Map();
+ for (const [node, c] of Object.entries(communities)) {
+ if (!byComm.has(c)) byComm.set(c, []);
+ byComm.get(c).push(node);
+ }
+
+ const assignments = new Map();
+ let nextId = 0;
+ let splitCount = 0;
+
+ for (const memberNodes of byComm.values()) {
+ const memberSet = new Set(memberNodes);
+ const seen = new Set();
+ let pieces = 0;
+ for (const start of memberNodes) {
+ if (seen.has(start)) continue;
+ pieces++;
+ const id = nextId++;
+ const queue = [start];
+ seen.add(start);
+ while (queue.length) {
+ const u = queue.pop();
+ assignments.set(u, id);
+ graph.forEachNeighbor(u, (nb) => {
+ if (memberSet.has(nb) && !seen.has(nb)) { seen.add(nb);
queue.push(nb); }
+ });
+ }
+ }
+ if (pieces > 1) splitCount++;
+ }
+
+ return { assignments, splitCount };
+}
+
+/** Roll a community's member vertices up to files, label mix, churn, and a
role descriptor. */
+function rollUp(ids, nodeById, churn) {
+ const fileCounts = new Map();
+ const labelCounts = {};
+ let changedCount = 0;
+ for (const id of ids) {
+ const n = nodeById.get(id);
+ if (!n) continue;
+ if (n.filePath) fileCounts.set(n.filePath, (fileCounts.get(n.filePath) ||
0) + 1);
+ labelCounts[n.label] = (labelCounts[n.label] || 0) + 1;
+ if (n.changed) changedCount++;
+ }
+ let testCount = 0;
+ for (const id of ids) {
+ const n = nodeById.get(id);
+ if (n && isTestFile(n.filePath)) testCount++;
+ }
+ const files = [...fileCounts.entries()].sort((a, b) => b[1] -
a[1]).map(([f]) => f);
+ const dominantLabel = Object.entries(labelCounts).sort((a, b) => b[1] -
a[1])[0]?.[0] || "Function";
+ const testShare = ids.length ? testCount / ids.length : 0;
+
+ // Roll churn up over the community's *distinct* files (a file counts once
even
+ // when several member functions live in it).
+ let churnSummary = null;
+ if (churn) {
+ let added = 0, removed = 0, withChurn = 0;
+ for (const f of files) {
+ if (churn[f]) { added += churn[f].added || 0; removed +=
churn[f].removed || 0; withChurn++; }
+ }
+ if (withChurn > 0) churnSummary = { added, removed, mode: churnMode(added,
removed) };
+ }
+
+ return {
+ size: ids.length,
+ files,
+ dominantFile: files[0] || null,
+ labelCounts,
+ changedCount,
+ dominantLabel,
+ testShare,
+ churn: churnSummary,
+ role: describeRole(dominantLabel, ids.length ? changedCount / ids.length :
0, files.length, testShare, churnSummary),
+ };
+}
+
+/** Classify a community's net churn: dominated by removals, additions, or
balanced. */
+function churnMode(added, removed) {
+ if (added === 0 && removed > 0) return "purely reduced";
+ if (removed > added * 2) return "reduced";
+ if (added > removed * 2) return "expanded";
+ return "reworked";
+}
+
+/**
+ * Is this a test file? Test methods extract as Function vertices, so the
vertex
+ * label alone under-counts test-ness; the file path is the reliable tell.
+ */
+function isTestFile(p) {
+ if (!p) return false;
+ if (/(^|\/)(test|tests)\//i.test(p)) return true;
+ const base = p.split("/").pop() || "";
+ return /(Test|Tests|IT|ITCase|TestCase)\.\w+$/.test(base) ||
/(^test_|_test)\.\w+$/.test(base) || /\.test\.\w+$/.test(base);
+}
+
+/**
+ * Stamp each vertex with its final `community` id. One traversal per community
+ * (`V(...ids).property(...)`) keeps this to a handful of round trips.
+ */
+async function writeBackCommunities(g, assignments) {
+ const byComm = new Map();
+ for (const [id, comm] of assignments) {
+ if (!byComm.has(comm)) byComm.set(comm, []);
+ byComm.get(comm).push(coerceId(id));
+ }
+ for (const [comm, ids] of byComm) {
+ await g.V(...ids).property("community", comm).iterate();
+ }
+}
+
+/** TinkerGraph ids are numeric; ids arrive as strings from our node keys. */
+function coerceId(id) {
+ const n = Number(id);
+ return Number.isNaN(n) ? id : n;
+}
+
+/**
+ * Convert a gremlin `elementMap` Map into a plain object, keying by the enum's
+ * element name so `t.id`/`t.label` become `id`/`label` and the edge adjacency
+ * `Direction.IN`/`Direction.OUT` become `IN`/`OUT`. Nested vertex maps (on
edges)
+ * are converted recursively. Avoids depending on driver enum singleton
identity.
+ */
+function mapToObj(m) {
+ if (!(m instanceof Map)) return m;
+ const o = {};
+ for (const [k, v] of m) o[String(k)] = v instanceof Map ? mapToObj(v) : v;
+ return o;
+}
diff --git a/.skills/tinker-review/scripts/renderer/render.js
b/.skills/tinker-review/scripts/renderer/render.js
index 6fdfce448e..58935effda 100644
--- a/.skills/tinker-review/scripts/renderer/render.js
+++ b/.skills/tinker-review/scripts/renderer/render.js
@@ -57,7 +57,7 @@ function notProvided(sectionId, title) {
}
export function render(evidence) {
- const { meta, graphStats, checks, discussions, summary, clusters,
+ const { meta, graphStats, checks, discussions, summary, clusters,
communityAssessment,
guidedWalk, functionalTest, findings, openQuestions, appendixFunctional }
= evidence;
const parts = [];
@@ -66,6 +66,7 @@ export function render(evidence) {
parts.push(summary ? renderSummary(summary) : notProvided("summary",
"Summary"));
parts.push(discussions ? renderContext(discussions) : notProvided("context",
"Discovered Context"));
parts.push(renderClusters(clusters, checks && checks.clusters,
evidence.architecture));
+ parts.push(renderCommunitySection(checks && checks.communities,
communityAssessment));
parts.push(guidedWalk && guidedWalk.length > 0 ?
renderGuidedWalk(guidedWalk) : notProvided("guided-walk", "Guided Walk"));
parts.push(functionalTest ? renderFunctionalTest(functionalTest) :
notProvided("functional-test", "Functional Test"));
parts.push(findings && findings.length > 0 ? renderFindings(findings) :
notProvided("findings", "Findings"));
@@ -95,6 +96,7 @@ function renderNav() {
<li><a href="#summary">Summary</a></li>
<li><a href="#context">Context</a></li>
<li><a href="#clusters">Clusters</a></li>
+ <li><a href="#communities">Communities</a></li>
<li><a href="#guided-walk">Guided Walk</a></li>
<li><a href="#functional-test">Functional Test</a></li>
<li><a href="#findings">Findings</a></li>
@@ -272,7 +274,7 @@ function renderClusters(clusters, clusterData,
architecture) {
return `<section id="clusters">
<h2>Change Coherence</h2>
- <p class="section-intro">Connected component analysis of changed files.
Files are connected when their functions call each other. A single cluster
means one logical change; multiple clusters suggest bundled unrelated
changes.</p>
+ <p class="section-intro"><strong>Connected components</strong> — the coarse
guard: it asks whether any changed file is wholly unreachable from the rest. A
strong signal when it fires, but usually one cluster in a well-connected
codebase. The finer, density-based view is <a href="#communities">Thematic
Communities</a> below.</p>
${svg}
<div class="card card-safe">
<h3>Assessment</h3>
@@ -281,6 +283,81 @@ function renderClusters(clusters, clusterData,
architecture) {
</section>`;
}
+function renderCommunitySection(communityData, assessment) {
+ if (!communityData || !communityData.interpretation) return
notProvided("communities", "Thematic Communities");
+
+ const interp = communityData.interpretation;
+ const svg = generateCommunitySvg(communityData);
+
+ // The main section is the analyst's meaning-making (enrichment). Until that
runs,
+ // fall back to the deterministic reading so the section is never empty.
+ const body = assessment
+ ? assessment
+ : `<p>${esc(interp.headline)}</p>${interp.reading.length
+ ? `<ul style="margin: 0.5rem 0 0 1.25rem;">${interp.reading.map((r) =>
`<li>${esc(r)}</li>`).join("")}</ul>`
+ : ""}
+ <p style="font-size: 0.82rem; color: var(--muted, #666); margin: 0.5rem 0
0;">Automated reading — run enrichment for an analyst's assessment of what
these communities mean for this PR.</p>`;
+
+ return `<section id="communities">
+ <h2>Thematic Communities <span class="badge">Louvain</span></h2>
+ <p class="section-intro">Louvain modularity over the code subgraph
(Discussion/Step hubs excluded) groups the change into densely-tied themes even
when everything is technically reachable. Box colour encodes change mode —
<span style="color:#c62828">red reduced</span>, <span
style="color:#2e7d32">green expanded</span>, <span style="color:#e65100">orange
reworked</span>, <span style="color:#3949ab">indigo unchanged context</span>.
Per-community membership is in the <a href="#appendix-st [...]
+ ${svg}
+ <div class="card ${assessment ? "card-safe" : ""}">
+ <h3>Assessment</h3>
+ ${body}
+ </div>
+</section>`;
+}
+
+function generateCommunitySvg(communityData) {
+ const communities = (communityData && communityData.communities) || [];
+ if (communities.length === 0) return "";
+
+ const modeColor = (c) => {
+ const m = c.churn && c.churn.mode;
+ if (m === "reduced" || m === "purely reduced") return { stroke: "#e53935",
fill: "#ffebee", label: "#c62828" };
+ if (m === "expanded") return { stroke: "#43a047", fill: "#e8f5e9", label:
"#2e7d32" };
+ if (m === "reworked") return { stroke: "#fb8c00", fill: "#fff3e0", label:
"#e65100" };
+ return { stroke: "#5c6bc0", fill: "#e8eaf6", label: "#3949ab" }; //
unchanged context / unknown
+ };
+
+ const shown = communities.slice(0, 9);
+ const cols = Math.min(shown.length, 3);
+ const boxW = 270, gapX = 20, gapY = 18;
+ const svgWidth = cols * boxW + (cols + 1) * gapX;
+
+ const heightOf = (c) => 44 + Math.min(c.files.length, 4) * 13 +
(c.files.length > 4 ? 13 : 0);
+ const positions = shown.map((c, i) => ({ c, col: i % cols, row: Math.floor(i
/ cols) }));
+ const numRows = Math.max(...positions.map((p) => p.row)) + 1;
+ const rowH = [];
+ for (let r = 0; r < numRows; r++) rowH[r] = Math.max(...positions.filter((p)
=> p.row === r).map((p) => heightOf(p.c)));
+ const rowY = [];
+ let acc = gapY;
+ for (let r = 0; r < numRows; r++) { rowY[r] = acc; acc += rowH[r] + gapY; }
+
+ let content = "";
+ for (const { c, col, row } of positions) {
+ const x = gapX + col * (boxW + gapX);
+ const y = rowY[row];
+ const h = rowH[row];
+ const cc = modeColor(c);
+ const kind = c.role.split(",")[0];
+ const churnBadge = c.churn ? `−${c.churn.removed}/+${c.churn.added}` :
"unchanged context";
+ content += `<rect x="${x}" y="${y}" width="${boxW}" height="${h}" rx="8"
fill="${cc.fill}" stroke="${cc.stroke}" stroke-width="1.5"/>`;
+ content += `<text x="${x + 12}" y="${y + 19}" font-size="11"
font-weight="600" fill="${cc.label}">Community ${c.id + 1} —
${esc(kind)}</text>`;
+ content += `<text x="${x + 12}" y="${y + 33}" font-size="9"
fill="#555">${c.size} vertices · ${esc(churnBadge)}</text>`;
+ const files = c.files.slice(0, 4).map((f) => f.split("/").pop());
+ for (let j = 0; j < files.length; j++) {
+ content += `<text x="${x + 12}" y="${y + 48 + j * 13}" font-size="8"
fill="#333">${esc(files[j])}</text>`;
+ }
+ if (c.files.length > 4) content += `<text x="${x + 12}" y="${y + 48 + 4 *
13}" font-size="8" fill="#666">+${c.files.length - 4} more files</text>`;
+ }
+
+ return `<svg class="cluster-svg" viewBox="0 0 ${svgWidth} ${acc}"
xmlns="http://www.w3.org/2000/svg">
+ ${content}
+</svg>`;
+}
+
function renderGuidedWalk(walk) {
const steps = walk.map((step, i) => {
const cls = step.badge === "attention" ? "card-attention" : step.badge ===
"safe" ? "card-safe" : "card-info";
@@ -408,6 +485,21 @@ function renderAppendixStructural(checks, graphStats) {
const hierarchyRows = hierarchy.slice(0, 10).map(t => {
return `<tr><td class="fn-name">${esc(t.name)}</td><td>${esc(t.kind ||
"")}</td><td>${esc((t.filePath || "").split("/").pop())}</td><td
class="num">${t.implementerCount}</td></tr>`;
}).join("\n ");
+ const communities = checks?.communities?.communities || [];
+ const communityRows = communities.map(c => {
+ const labels = Object.entries(c.labelCounts || {}).sort((a, b) => b[1] -
a[1]).map(([l, n]) => `${l}:${n}`).join(", ");
+ const files = c.files.map(f => `<code>${esc(f)}</code>`).join("<br>");
+ return `<tr><td>${c.id + 1}</td><td>${esc(c.role)}</td><td
class="num">${c.size}</td><td>${esc(labels)}</td><td>${files}</td></tr>`;
+ }).join("\n ");
+ const isolated = checks?.communities?.isolatedCount ?? 0;
+ const communitiesHtml = communities.length === 0 ? "" : `
+ <h3>Community Membership</h3>
+ <p class="section-intro">Louvain communities over the code subgraph, largest
first (modularity ${checks?.communities?.modularity}). The reading is in <a
href="#communities">Thematic Communities</a>; this is the raw membership. Role
carries the change mode and line churn (−removed/+added). ${isolated} vertices
have no in-subgraph edge and are omitted; every clustered vertex carries a
<code>community</code> property for direct querying.</p>
+ <table class="gap-table">
+ <thead><tr><th>#</th><th>Role</th><th>Vertices</th><th>Label
mix</th><th>Files</th></tr></thead>
+ <tbody>\n ${communityRows}\n </tbody>
+ </table>`;
+
const truncNote = truncated ? ` <strong>Neighborhood truncated — these
counts are a lower bound.</strong>` : "";
const hierarchyHtml = hierarchy.length === 0 ? "" : `
<h3>Type Hierarchy Impact</h3>
@@ -434,6 +526,7 @@ function renderAppendixStructural(checks, graphStats) {
<tbody>\n ${blastRows}\n </tbody>
</table>
${hierarchyHtml}
+${communitiesHtml}
${confidenceHtml}
<h3>Graph Statistics</h3>
<div class="stats-grid">
diff --git a/.skills/tinker-review/scripts/review.js
b/.skills/tinker-review/scripts/review.js
index 82ebc070ea..21df3054d3 100644
--- a/.skills/tinker-review/scripts/review.js
+++ b/.skills/tinker-review/scripts/review.js
@@ -33,6 +33,7 @@ import { coverageGaps } from "./patterns/coverage-gaps.js";
import { highCentrality } from "./patterns/centrality.js";
import { blastRadius } from "./patterns/blast-radius.js";
import { clusterAnalysis } from "./patterns/cluster-analysis.js";
+import { communityDetection } from "./patterns/community-detection.js";
import { architecture } from "./patterns/architecture.js";
import { confidenceAudit } from "./patterns/confidence-audit.js";
import { classifyExternals } from "./patterns/classify-externals.js";
@@ -86,6 +87,41 @@ async function getChangedFiles(repoPath, prBranch, remote =
"upstream", baseBran
return diffOutput.trim().split("\n").filter(Boolean);
}
+/**
+ * Per-file line churn and deletion status for the PR, so downstream analysis
can
+ * describe *how* code changed (reduced vs. expanded), not just that it
changed.
+ * Merges `--numstat` (line counts) with `--name-status` (A/M/D/R). Binary
files
+ * report 0/0. Returns `{ [path]: { added, removed, deleted } }`.
+ */
+export async function computeChurn(repoPath, prBranch, remote = "upstream",
baseBranch = "master") {
+ const { stdout: baseCommit } = await exec(
+ "git", ["merge-base", prBranch, `${remote}/${baseBranch}`], { cwd:
repoPath }
+ );
+ const range = `${baseCommit.trim()}...${prBranch}`;
+
+ const [numstat, nameStatus] = await Promise.all([
+ exec("git", ["diff", "--numstat", range], { cwd: repoPath }).then((r) =>
r.stdout).catch(() => ""),
+ exec("git", ["diff", "--name-status", range], { cwd: repoPath }).then((r)
=> r.stdout).catch(() => ""),
+ ]);
+
+ const churn = {};
+ for (const line of numstat.trim().split("\n").filter(Boolean)) {
+ const [added, removed, ...pathParts] = line.split("\t");
+ const path = pathParts.join("\t");
+ if (!path) continue;
+ churn[path] = { added: added === "-" ? 0 : Number(added), removed: removed
=== "-" ? 0 : Number(removed), deleted: false };
+ }
+ for (const line of nameStatus.trim().split("\n").filter(Boolean)) {
+ const [status, ...pathParts] = line.split("\t");
+ const path = pathParts.join("\t");
+ if (status && status[0] === "D" && path) {
+ churn[path] = churn[path] || { added: 0, removed: 0 };
+ churn[path].deleted = true;
+ }
+ }
+ return churn;
+}
+
function classifyDomains(changedFiles) {
const paths = changedFiles.join("\n").toLowerCase();
const domains = ["general"];
@@ -361,6 +397,8 @@ export async function phase1(session) {
const blastResult = await blastRadius(g, { depth: 3, changedOnly: true });
blastResult.neighborhood = extraction.hierarchyNeighborhood || null;
const clusterResult = await clusterAnalysis(a, { changedOnly: true });
+ const churn = await computeChurn(repoPath, prBranch, remote,
baseBranch).catch(() => null);
+ const communityResult = await communityDetection(g, { churn });
const confidenceResult = await confidenceAudit(g);
const orphansResult = await orphans(g, { vertexLabel: "Function",
expectedEdge: "tests", direction: "in", changedOnly: true });
log(` completeness: ${completenessResults.filter(r => r.missing.length >
0).length} gaps found`);
@@ -368,6 +406,7 @@ export async function phase1(session) {
log(` centrality: ${centralityResult.aboveThreshold} hotspots`);
log(` blast_radius: max ${blastResult.maxReachable} reachable,
${blastResult.types.length} changed types with hierarchy impact`);
log(` clusters: ${clusterResult.clusterCount} (${clusterResult.coherent ?
"coherent" : "fragmented"})`);
+ log(` communities: ${communityResult.communityCount} themes, modularity
${communityResult.modularity} (${communityResult.isolatedCount} isolated)`);
log(` confidence: ${confidenceResult.distribution.EXTRACTED} extracted /
${confidenceResult.distribution.INFERRED} inferred /
${confidenceResult.distribution.AMBIGUOUS} ambiguous`);
log(` orphans: ${orphansResult.totalOrphaned} functions with no test`);
@@ -393,6 +432,7 @@ export async function phase1(session) {
centrality: centralityResult,
blastRadius: blastResult,
clusters: clusterResult,
+ communities: communityResult,
confidence: confidenceResult,
externals: externalsResult,
removalRefs: removalRefsResult,
diff --git a/.skills/tinker-review/test/community-detection.test.js
b/.skills/tinker-review/test/community-detection.test.js
new file mode 100644
index 0000000000..ef147c21d4
--- /dev/null
+++ b/.skills/tinker-review/test/community-detection.test.js
@@ -0,0 +1,148 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// Pure Louvain community detection over plain node/edge lists — no server
needed.
+// The gremlin I/O wrapper (communityDetection) is exercised end-to-end
against a
+// live graph separately; here we pin the algorithm + roll-up + post-pass
behavior.
+
+import { test } from "node:test";
+import assert from "node:assert/strict";
+
+import { detectCommunities } from "../scripts/patterns/community-detection.js";
+
+const fn = (id, file) => ({ id, label: "Function", name: id, filePath: file });
+const edge = (from, to, conf = "INFERRED") => ({ from, to, conf });
+
+// Two triangles joined by a single weak bridge — the textbook two-community
graph.
+function barbell() {
+ const nodes = [
+ fn("a", "auth.java"), fn("b", "auth.java"), fn("c", "auth.java"),
+ fn("x", "io.java"), fn("y", "io.java"), fn("z", "io.java"),
+ ];
+ const edges = [
+ edge("a", "b", "EXTRACTED"), edge("b", "c", "EXTRACTED"), edge("a", "c",
"EXTRACTED"),
+ edge("x", "y", "EXTRACTED"), edge("y", "z", "EXTRACTED"), edge("x", "z",
"EXTRACTED"),
+ edge("c", "x", "AMBIGUOUS"), // weak bridge between the two dense groups
+ ];
+ return { nodes, edges };
+}
+
+test("splits a barbell into two communities and rolls them up to files", () =>
{
+ const { nodes, edges } = barbell();
+ const r = detectCommunities(nodes, edges, {});
+ assert.equal(r.communityCount, 2, "two dense triangles => two communities");
+ assert.ok(r.modularity > 0.3, `expected clear modular structure, got
${r.modularity}`);
+ const files = r.communities.map((c) => c.dominantFile).sort();
+ assert.deepEqual(files, ["auth.java", "io.java"], "each community rolls up
to its file");
+});
+
+test("a single dense triangle is one community", () => {
+ const nodes = [fn("a", "f.java"), fn("b", "f.java"), fn("c", "f.java")];
+ const edges = [edge("a", "b"), edge("b", "c"), edge("a", "c")];
+ const r = detectCommunities(nodes, edges, {});
+ assert.equal(r.communityCount, 1);
+ assert.equal(r.communities[0].size, 3);
+});
+
+test("vertices with no in-subgraph edge are counted as isolated, not
communities", () => {
+ const nodes = [
+ fn("a", "f.java"), fn("b", "f.java"), // connected pair
+ fn("orphan1", "x.java"), fn("orphan2", "y.java"), // no edges
+ ];
+ const edges = [edge("a", "b")];
+ const r = detectCommunities(nodes, edges, {});
+ assert.equal(r.communityCount, 1, "only the connected pair is a community");
+ assert.equal(r.isolatedCount, 2, "the two edgeless vertices are isolated");
+ assert.equal(r.communities[0].size, 2);
+});
+
+test("edges to a filtered-out vertex are dropped, not invented", () => {
+ const nodes = [fn("a", "f.java"), fn("b", "f.java")];
+ // 'ext' is not in the node set (e.g. an external stub excluded upstream)
+ const edges = [edge("a", "b"), edge("a", "ext"), edge("b", "ext")];
+ const r = detectCommunities(nodes, edges, {});
+ assert.equal(r.totalNodes, 2, "the missing endpoint never becomes a node");
+ assert.equal(r.totalEdges, 1, "only the fully-resolved edge survives");
+});
+
+test("the assignments map covers every node so write-back can stamp them all",
() => {
+ const { nodes, edges } = barbell();
+ const r = detectCommunities(nodes, edges, {});
+ assert.equal(r.assignments.size, nodes.length, "one community assignment per
vertex");
+});
+
+const testNode = (id, file, changed = false) => ({ id, label: "Test", name:
id, filePath: file, changed });
+
+test("an all-test partition reads as test scaffolding, not new functional
groupings", () => {
+ const nodes = [
+ testNode("t1", "AbstractIT.java"), testNode("t2", "AbstractIT.java"),
testNode("t3", "AbstractIT.java"),
+ testNode("u1", "AuditLogIT.java"), testNode("u2", "AuditLogIT.java"),
testNode("u3", "AuditLogIT.java"),
+ ];
+ const edges = [
+ edge("t1", "t2"), edge("t2", "t3"), edge("t1", "t3"),
+ edge("u1", "u2"), edge("u2", "u3"), edge("u1", "u3"),
+ edge("t3", "u1"),
+ ];
+ const r = detectCommunities(nodes, edges, {});
+ assert.ok(r.communities.every((c) => c.role.startsWith("test scaffolding")),
"each community typed as test scaffolding");
+ assert.match(r.interpretation.headline, /modularity/);
+ assert.ok(
+ r.interpretation.reading.some((line) => /test infrastructure/i.test(line)),
+ "reading calls out that dense structure is test infrastructure",
+ );
+});
+
+test("the reading reports the changed-vs-context share when no churn is
supplied", () => {
+ const nodes = [
+ fn("a", "f.java"), fn("b", "f.java"), fn("c", "f.java"), // changed:false
via fn()
+ ];
+ const edges = [edge("a", "b"), edge("b", "c"), edge("a", "c")];
+ const r = detectCommunities(nodes, edges, {});
+ assert.ok(
+ r.interpretation.reading.some((line) => /0% of clustered vertices are
actually changed/.test(line)),
+ "0% changed when no vertex is marked changed",
+ );
+ assert.equal(r.communities[0].changedCount, 0);
+});
+
+test("churn drives the role's change clause with real line counts", () => {
+ const nodes = [fn("a", "AbstractIT.java"), fn("b", "AbstractIT.java"),
fn("c", "AbstractIT.java")];
+ const edges = [edge("a", "b"), edge("b", "c"), edge("a", "c")];
+ const churn = { "AbstractIT.java": { added: 4, removed: 120, deleted: false
} };
+ const r = detectCommunities(nodes, edges, { churn });
+ assert.equal(r.communities[0].churn.mode, "reduced", "removals dominate =>
reduced");
+ assert.match(r.communities[0].role, /reduced \(−120\/\+4\)/, "role carries
the churn counts");
+});
+
+test("net-deletion PRs get a deletion caveat and a net-deletion reading", ()
=> {
+ const nodes = [fn("a", "AbstractIT.java"), fn("b", "AbstractIT.java")];
+ const edges = [edge("a", "b")];
+ const churn = {
+ "AbstractIT.java": { added: 2, removed: 60, deleted: false },
+ "KerberosAuthenticator.java": { added: 0, removed: 400, deleted: true },
+ "KerberosIT.java": { added: 0, removed: 210, deleted: true },
+ };
+ const r = detectCommunities(nodes, edges, { churn });
+ assert.equal(r.churn.deletedFiles, 2, "two files deleted outright");
+ assert.ok(r.interpretation.reading.some((l) => /net-deletion
overall/.test(l)), "reads as net-deletion");
+ assert.ok(
+ r.interpretation.reading.some((l) => /Deleted code has no
vertices/.test(l)),
+ "warns that deleted code is absent from the communities",
+ );
+});