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",
+  );
+});


Reply via email to