@hegemonart/get-design-done 1.51.0 → 1.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +96 -0
  4. package/README.md +4 -0
  5. package/SKILL.md +2 -0
  6. package/agents/a11y-mapper.md +30 -1
  7. package/agents/component-taxonomy-mapper.md +30 -1
  8. package/agents/design-context-reviewer-gate.md +102 -0
  9. package/agents/design-context-reviewer.md +186 -0
  10. package/agents/design-debt-crawler.md +60 -60
  11. package/agents/design-research-synthesizer.md +27 -1
  12. package/agents/motion-mapper.md +35 -13
  13. package/agents/token-mapper.md +30 -1
  14. package/agents/visual-hierarchy-mapper.md +30 -1
  15. package/dist/claude-code/.claude/skills/context/SKILL.md +137 -0
  16. package/dist/claude-code/.claude/skills/discover/SKILL.md +7 -1
  17. package/dist/claude-code/.claude/skills/explore/SKILL.md +3 -1
  18. package/dist/claude-code/.claude/skills/migrate-context/SKILL.md +123 -0
  19. package/dist/claude-code/.claude/skills/progress/SKILL.md +4 -0
  20. package/package.json +3 -2
  21. package/reference/design-context-schema.md +159 -0
  22. package/reference/design-context-tag-vocab.md +82 -0
  23. package/reference/registry.json +14 -0
  24. package/reference/schemas/design-context.schema.json +130 -0
  25. package/reference/schemas/mcp-gdd-tools.schema.json +34 -1
  26. package/reference/skill-graph.md +3 -1
  27. package/scripts/lib/design-context/extract-a11y.mjs +188 -0
  28. package/scripts/lib/design-context/extract-components.mjs +243 -0
  29. package/scripts/lib/design-context/extract-motion.mjs +248 -0
  30. package/scripts/lib/design-context/extract-tokens.mjs +234 -0
  31. package/scripts/lib/design-context/extract-visual-hierarchy.mjs +178 -0
  32. package/scripts/lib/design-context/integration-map.mjs +251 -0
  33. package/scripts/lib/design-context/merge-fragments.mjs +227 -0
  34. package/scripts/lib/design-context-query.cjs +0 -0
  35. package/scripts/lib/explore-parallel-runner/index.ts +58 -0
  36. package/scripts/lib/explore-parallel-runner/types.ts +58 -0
  37. package/scripts/lib/manifest/skills.json +18 -2
  38. package/scripts/lib/mappers/compute-batches.mjs +625 -0
  39. package/scripts/lib/mappers/graph-adjacency.mjs +129 -0
  40. package/scripts/lib/mappers/incremental-discover.cjs +617 -0
  41. package/scripts/lib/mappers/incremental-discover.d.cts +133 -0
  42. package/scripts/lib/mappers/neighbor-map.mjs +0 -0
  43. package/scripts/lib/mcp-tools-lint/index.cjs +3 -1
  44. package/sdk/cli/index.js +369 -2
  45. package/sdk/fingerprint/classify.cjs +406 -0
  46. package/sdk/fingerprint/index.ts +405 -0
  47. package/sdk/fingerprint/store.cjs +523 -0
  48. package/sdk/index.ts +1 -0
  49. package/sdk/mcp/gdd-mcp/schemas/gdd_context_query.schema.json +60 -0
  50. package/sdk/mcp/gdd-mcp/server.js +474 -158
  51. package/sdk/mcp/gdd-mcp/server.ts +9 -5
  52. package/sdk/mcp/gdd-mcp/tools/gdd_context_query.ts +35 -0
  53. package/sdk/mcp/gdd-mcp/tools/index.ts +18 -13
  54. package/skills/context/SKILL.md +137 -0
  55. package/skills/discover/SKILL.md +7 -1
  56. package/skills/explore/SKILL.md +3 -1
  57. package/skills/migrate-context/SKILL.md +123 -0
  58. package/skills/progress/SKILL.md +4 -0
@@ -0,0 +1,625 @@
1
+ // scripts/lib/mappers/compute-batches.mjs — Phase 53 (Semantic Mapper Engine), executor A.
2
+ //
3
+ // Deterministic, dependency-free community batching over a DesignContext graph
4
+ // (Phase 52 shape). Groups graph nodes into mapper-sized batches so the explore
5
+ // runner can dispatch each community to a mapper instance (the batching extends
6
+ // explore-parallel-runner per CONTEXT R1; THIS file owns only the pure engine).
7
+ //
8
+ // Algorithm: a self-contained two-phase Louvain modularity maximization (NO
9
+ // graphology, NO new dependency — CONTEXT R5/D7). The graph it optimizes is an
10
+ // UNDIRECTED WEIGHTED graph over file-bearing nodes:
11
+ // - component nodes are the primary anchors;
12
+ // - a non-component node (variant/layer/token/...) that is OWNED by a component
13
+ // (variant --extends--> component; token used by exactly one component; layer
14
+ // attached to one component) FOLDS into that component — it is not its own
15
+ // Louvain node, and it inherits the owner's community in the flat result;
16
+ // - structural edges (composes/extends/depends-on/consumes-context/
17
+ // provides-context) between two anchors contribute their edge weight;
18
+ // - uses-token contributes an INVERSE-FREQUENCY-DAMPED token-cohesion weight:
19
+ // each token shared by a pair of components adds 1/log(1+deg(token)) to that
20
+ // component-pair's edge weight, so a globally-used token does not collapse
21
+ // every component into one blob;
22
+ // - non-component nodes that connect only to other non-component nodes (no
23
+ // component owner) remain standalone anchors and cluster among themselves
24
+ // into "semantic groups" (token/motion/a11y batches, mergeable:false).
25
+ //
26
+ // Determinism (hard contract, CONTEXT D6): nodes are iterated in a FIXED
27
+ // LEXICOGRAPHIC order (the only "seed" — NO Math.random); ties on a modularity
28
+ // move break to the smallest community id then the smallest node id; aggregation
29
+ // and unfolding sort before emitting. Identical batches on win32 + Linux + macOS.
30
+ //
31
+ // Safety nets:
32
+ // - try/catch around the whole optimization → count-fallback (alphabetical
33
+ // fallbackBatchSize-file batches) on ANY throw or fewer than 2 anchors;
34
+ // - MAX_COMMUNITY_SIZE overflow → alphabetical sub-split (labels c7, c7-2, …);
35
+ // - small-batch merger pools singleton code communities into <=miscCap "misc"
36
+ // batches; non-code semantic groups are emitted with mergeable:false so the
37
+ // merger never folds them.
38
+ //
39
+ // Public API:
40
+ // computeBatches(graph, opts?) -> {
41
+ // batches: Array<{ id, members:string[], mergeable:boolean,
42
+ // kind:'code'|'token'|'motion'|'a11y'|'misc',
43
+ // source:'louvain'|'fallback'|'subsplit'|'merge' }>,
44
+ // modularity: number|null,
45
+ // method: 'louvain'|'count-fallback'
46
+ // }
47
+ // Opts: resolution=1.0, maxCommunitySize=35, miscCap=25, fallbackBatchSize=12,
48
+ // configCwd (override .design/config.json discovery root).
49
+
50
+ import fs from 'node:fs';
51
+ import path from 'node:path';
52
+ import { buildAdjacency, degreeIndex } from './graph-adjacency.mjs';
53
+
54
+ const STRUCTURAL_EDGE_TYPES = new Set([
55
+ 'composes',
56
+ 'extends',
57
+ 'depends-on',
58
+ 'consumes-context',
59
+ 'provides-context',
60
+ ]);
61
+
62
+ // Node types that, when they DOMINATE a community, mark it a non-code semantic
63
+ // group (emitted mergeable:false). Mapped to the batch `kind`.
64
+ const NONCODE_KIND = new Map([
65
+ ['token', 'token'],
66
+ ['motion-fragment', 'motion'],
67
+ ['a11y-pattern', 'a11y'],
68
+ ]);
69
+
70
+ const DEFAULTS = {
71
+ resolution: 1.0,
72
+ maxCommunitySize: 35,
73
+ miscCap: 25,
74
+ fallbackBatchSize: 12,
75
+ };
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Config (mirrors blast-radius.cjs#loadConfig precedence; never throws).
79
+ // ---------------------------------------------------------------------------
80
+
81
+ function numberOr(...candidates) {
82
+ for (const v of candidates) {
83
+ if (typeof v === 'number' && Number.isFinite(v) && v >= 0) return v;
84
+ }
85
+ return undefined;
86
+ }
87
+
88
+ /**
89
+ * Resolve options. Precedence per dimension:
90
+ * explicit opts > .design/config.json#louvain.{...} > DEFAULTS.
91
+ * resolution/maxCommunitySize live under `louvain`; miscCap/fallbackBatchSize
92
+ * default unless an explicit opt overrides. Absent/garbage config never throws.
93
+ */
94
+ function resolveOpts(opts) {
95
+ const o = opts || {};
96
+ let cfg = {};
97
+ try {
98
+ const root = o.configCwd || process.cwd();
99
+ cfg = JSON.parse(fs.readFileSync(path.join(root, '.design', 'config.json'), 'utf8'));
100
+ } catch { cfg = {}; }
101
+ const lv = (cfg && typeof cfg === 'object' && cfg.louvain) || {};
102
+ return {
103
+ resolution: numberOr(o.resolution, lv.resolution, DEFAULTS.resolution),
104
+ maxCommunitySize: numberOr(o.maxCommunitySize, lv.maxCommunitySize, DEFAULTS.maxCommunitySize),
105
+ miscCap: numberOr(o.miscCap, lv.miscCap, DEFAULTS.miscCap),
106
+ fallbackBatchSize: numberOr(o.fallbackBatchSize, lv.fallbackBatchSize, DEFAULTS.fallbackBatchSize),
107
+ };
108
+ }
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Graph shaping: anchors, fold map, and the weighted undirected batch graph.
112
+ // ---------------------------------------------------------------------------
113
+
114
+ function nodeList(graph) {
115
+ return Array.isArray(graph && graph.nodes) ? graph.nodes : [];
116
+ }
117
+ function edgeList(graph) {
118
+ return Array.isArray(graph && graph.edges) ? graph.edges : [];
119
+ }
120
+
121
+ /**
122
+ * Partition graph nodes into anchors (run Louvain over these) and a fold map
123
+ * (folded node id -> owning anchor id). A non-component node folds into a
124
+ * component when it is OWNED by exactly one component:
125
+ * - variant/state/layer/etc. with a structural edge to a single component;
126
+ * - token used by exactly one component (uses-token).
127
+ * Everything else (every component; non-component nodes with zero or many
128
+ * component owners) is an anchor. Components are always anchors. Tokens with >1
129
+ * component owner stay anchors but contribute cohesion weight (below) and are
130
+ * pulled toward code communities via that weight.
131
+ *
132
+ * Deterministic: when a node has multiple candidate single owners across edge
133
+ * kinds we never reach here (multi-owner => anchor); single-owner is unambiguous.
134
+ */
135
+ function shapeAnchors(graph) {
136
+ const nodes = nodeList(graph);
137
+ const byId = new Map();
138
+ for (const n of nodes) if (n && typeof n.id === 'string') byId.set(n.id, n);
139
+
140
+ const isComponent = (id) => byId.get(id)?.type === 'component';
141
+
142
+ // Collect, per non-component node, the set of component ids it relates to via
143
+ // a structural or uses-token edge (direction-agnostic — ownership is undirected).
144
+ const ownersOf = new Map(); // nonCompId -> Set<componentId>
145
+ const noteOwner = (nonComp, comp) => {
146
+ if (!byId.has(nonComp) || !byId.has(comp)) return;
147
+ if (isComponent(nonComp)) return;
148
+ let s = ownersOf.get(nonComp);
149
+ if (!s) { s = new Set(); ownersOf.set(nonComp, s); }
150
+ s.add(comp);
151
+ };
152
+
153
+ for (const e of edgeList(graph)) {
154
+ if (!e || typeof e.source !== 'string' || typeof e.target !== 'string') continue;
155
+ const isOwning = STRUCTURAL_EDGE_TYPES.has(e.type) || e.type === 'uses-token';
156
+ if (!isOwning) continue;
157
+ const sComp = isComponent(e.source);
158
+ const tComp = isComponent(e.target);
159
+ // exactly one endpoint is a component, the other is the candidate folded node.
160
+ if (sComp && !tComp) noteOwner(e.target, e.source);
161
+ else if (tComp && !sComp) noteOwner(e.source, e.target);
162
+ }
163
+
164
+ const fold = new Map(); // foldedId -> ownerComponentId
165
+ for (const [nonComp, owners] of ownersOf) {
166
+ if (owners.size === 1) fold.set(nonComp, [...owners][0]);
167
+ }
168
+
169
+ // Anchors = all components + every non-component node NOT folded.
170
+ const anchors = new Set();
171
+ for (const n of nodes) {
172
+ if (!n || typeof n.id !== 'string') continue;
173
+ if (n.type === 'component') { anchors.add(n.id); continue; }
174
+ if (!fold.has(n.id)) anchors.add(n.id);
175
+ }
176
+ // A fold target must itself be an anchor; if an owner somehow isn't a node,
177
+ // demote the folded node to an anchor (defensive, keeps the partition total).
178
+ for (const [folded, owner] of [...fold]) {
179
+ if (!anchors.has(owner)) { fold.delete(folded); anchors.add(folded); }
180
+ }
181
+
182
+ return { byId, anchors, fold, isComponent };
183
+ }
184
+
185
+ /**
186
+ * Build the undirected weighted batch graph over anchors. Returns:
187
+ * { ids: string[] (sorted), weight: Map<id, Map<id, number>>, m: number }
188
+ * where `weight` is symmetric (both directions populated) and `m` is the total
189
+ * undirected edge weight (sum of one side). Cohesion: each token shared by a
190
+ * pair of component anchors adds 1/log(1+deg(token)) to that pair.
191
+ */
192
+ function buildBatchGraph(graph, shape) {
193
+ const { byId, anchors, fold, isComponent } = shape;
194
+ const deg = degreeIndex(graph);
195
+
196
+ // Symmetric weighted adjacency among anchors only.
197
+ const w = new Map();
198
+ for (const id of anchors) w.set(id, new Map());
199
+ const bump = (a, b, val) => {
200
+ if (a === b || !w.has(a) || !w.has(b)) return;
201
+ const ma = w.get(a); ma.set(b, (ma.get(b) || 0) + val);
202
+ const mb = w.get(b); mb.set(a, (mb.get(a) || 0) + val);
203
+ };
204
+ // Resolve an endpoint to its anchor (folded node -> its owner component).
205
+ const anchorOf = (id) => (anchors.has(id) ? id : fold.get(id));
206
+
207
+ // 1) Structural edges between resolved anchors contribute their weight.
208
+ for (const e of edgeList(graph)) {
209
+ if (!e || typeof e.source !== 'string' || typeof e.target !== 'string') continue;
210
+ if (!STRUCTURAL_EDGE_TYPES.has(e.type)) continue;
211
+ const a = anchorOf(e.source);
212
+ const b = anchorOf(e.target);
213
+ if (a === undefined || b === undefined) continue;
214
+ const val = typeof e.weight === 'number' && Number.isFinite(e.weight) && e.weight >= 0 ? e.weight : 1;
215
+ bump(a, b, val);
216
+ }
217
+
218
+ // 2) uses-token cohesion. For each token, find the set of component anchors
219
+ // that use it, then add a damped weight to every component pair sharing it.
220
+ // damp = 1 / log(1 + deg(token)) (deg via degreeIndex; guard log<=0 -> 1).
221
+ const tokenUsers = new Map(); // tokenId -> Set<componentAnchorId>
222
+ for (const e of edgeList(graph)) {
223
+ if (!e || e.type !== 'uses-token') continue;
224
+ // uses-token is component(source) -> token(target) in Phase 52, but tolerate
225
+ // either orientation: the token endpoint is the non-component one.
226
+ let tokenId; let compId;
227
+ if (byId.get(e.target)?.type === 'token') { tokenId = e.target; compId = e.source; }
228
+ else if (byId.get(e.source)?.type === 'token') { tokenId = e.source; compId = e.target; }
229
+ else continue;
230
+ const compAnchor = anchorOf(compId);
231
+ if (compAnchor === undefined || !isComponent(compAnchor)) continue;
232
+ let s = tokenUsers.get(tokenId);
233
+ if (!s) { s = new Set(); tokenUsers.set(tokenId, s); }
234
+ s.add(compAnchor);
235
+ }
236
+ for (const [tokenId, usersSet] of tokenUsers) {
237
+ if (usersSet.size < 2) continue; // a token used by <2 components ties nothing
238
+ const dlog = Math.log(1 + (deg.get(tokenId) || 0));
239
+ const damp = dlog > 0 ? 1 / dlog : 1;
240
+ const users = [...usersSet].sort(); // deterministic pair order
241
+ for (let i = 0; i < users.length; i++) {
242
+ for (let j = i + 1; j < users.length; j++) {
243
+ bump(users[i], users[j], damp);
244
+ }
245
+ }
246
+ }
247
+
248
+ // Total undirected edge weight m = half the sum of all directed entries.
249
+ let two = 0;
250
+ for (const m of w.values()) for (const v of m.values()) two += v;
251
+ const m = two / 2;
252
+
253
+ const ids = [...anchors].sort();
254
+ return { ids, weight: w, m };
255
+ }
256
+
257
+ // ---------------------------------------------------------------------------
258
+ // Two-phase Louvain.
259
+ // ---------------------------------------------------------------------------
260
+
261
+ /**
262
+ * One level of local modularity optimization over a weighted graph given as
263
+ * nodes: string[] (already sorted lexicographically — the determinism seed),
264
+ * adj: Map<id, Map<id, number>> (symmetric),
265
+ * selfLoop: Map<id, number> (intra-node weight from aggregation; 0 at level 0),
266
+ * m: total undirected edge weight,
267
+ * gamma: resolution.
268
+ * Returns Map<id, communityId> (community id is a representative node id string).
269
+ *
270
+ * ΔQ(i->c) = k_{i,in}/m − gamma * (Σ_tot[c] * k_i) / (2 m^2)
271
+ * Move each node to the neighbor community of max positive gain; tie-break to the
272
+ * smallest community id then smallest node id (handled by fixed iteration + the
273
+ * comparison below). Stops when a pass improves total gain < 1e-7 or 20 passes.
274
+ */
275
+ function louvainLevel(nodes, adj, selfLoop, m, gamma) {
276
+ const community = new Map(); // node -> communityId
277
+ const k = new Map(); // node -> weighted degree (incident, incl self*2)
278
+ const sigmaTot = new Map(); // communityId -> Σ degree of members
279
+
280
+ for (const n of nodes) {
281
+ community.set(n, n);
282
+ const self = selfLoop.get(n) || 0;
283
+ let deg = self * 2; // a self-loop contributes twice to weighted degree
284
+ for (const v of (adj.get(n) || new Map()).values()) deg += v;
285
+ k.set(n, deg);
286
+ sigmaTot.set(n, deg);
287
+ }
288
+
289
+ if (m <= 0) return community; // no edges: every node its own community
290
+
291
+ const twoM2 = 2 * m * m;
292
+
293
+ for (let pass = 0; pass < 20; pass++) {
294
+ let passGain = 0;
295
+ for (const i of nodes) { // FIXED lexicographic order — the determinism seed
296
+ const ci = community.get(i);
297
+ const ki = k.get(i);
298
+
299
+ // Sum of weights from i into each candidate community (excluding self-loop).
300
+ const wTo = new Map();
301
+ for (const [nb, wgt] of adj.get(i) || new Map()) {
302
+ if (nb === i) continue;
303
+ const cnb = community.get(nb);
304
+ wTo.set(cnb, (wTo.get(cnb) || 0) + wgt);
305
+ }
306
+
307
+ // Remove i from its current community before evaluating moves.
308
+ sigmaTot.set(ci, sigmaTot.get(ci) - ki);
309
+
310
+ // Gain of placing i into community c: k_{i,in}/m − gamma*(Σ_tot[c]*k_i)/(2m²).
311
+ // The shared 1/m and damped term make the absolute baseline (staying in ci,
312
+ // now emptied of i) gain wTo(ci)/m − gamma*sigmaTot[ci]*ki/2m². We pick the
313
+ // max; ties break to smallest community id then smallest node id.
314
+ let bestC = ci;
315
+ let bestGain = (wTo.get(ci) || 0) / m - (gamma * sigmaTot.get(ci) * ki) / twoM2;
316
+ // Ensure the "stay" option is always considered with ci as the tie anchor.
317
+ for (const [c, kin] of [...wTo].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))) {
318
+ const gain = kin / m - (gamma * sigmaTot.get(c) * ki) / twoM2;
319
+ if (gain > bestGain + 1e-12 || (Math.abs(gain - bestGain) <= 1e-12 && c < bestC)) {
320
+ bestGain = gain; bestC = c;
321
+ }
322
+ }
323
+
324
+ // Place i into bestC.
325
+ sigmaTot.set(bestC, sigmaTot.get(bestC) + ki);
326
+ if (bestC !== ci) {
327
+ community.set(i, bestC);
328
+ const stayGain = (wTo.get(ci) || 0) / m - (gamma * (sigmaTot.get(ci)) * ki) / twoM2;
329
+ passGain += Math.max(0, bestGain - stayGain);
330
+ }
331
+ }
332
+ if (passGain < 1e-7) break;
333
+ }
334
+
335
+ return community;
336
+ }
337
+
338
+ /** Compute global modularity Q of a partition over the weighted graph. */
339
+ function modularityOf(nodes, adj, selfLoop, m, community) {
340
+ if (m <= 0) return null;
341
+ // Q = Σ_c [ Σ_in[c]/(2m) − (Σ_tot[c]/(2m))² ]
342
+ const sigmaIn = new Map(); // community -> internal weight (2*intra incl self-loops counted once*2)
343
+ const sigmaTot = new Map();
344
+ const deg = new Map();
345
+ for (const n of nodes) {
346
+ const self = selfLoop.get(n) || 0;
347
+ let d = self * 2;
348
+ for (const v of (adj.get(n) || new Map()).values()) d += v;
349
+ deg.set(n, d);
350
+ const c = community.get(n);
351
+ sigmaTot.set(c, (sigmaTot.get(c) || 0) + d);
352
+ // self-loop is fully internal: contributes 2*self to Σ_in.
353
+ sigmaIn.set(c, (sigmaIn.get(c) || 0) + 2 * self);
354
+ }
355
+ for (const n of nodes) {
356
+ const c = community.get(n);
357
+ for (const [nb, wgt] of adj.get(n) || new Map()) {
358
+ if (nb === n) continue;
359
+ if (community.get(nb) === c) sigmaIn.set(c, (sigmaIn.get(c) || 0) + wgt);
360
+ }
361
+ }
362
+ let q = 0;
363
+ const twoM = 2 * m;
364
+ for (const c of new Set(community.values())) {
365
+ const sin = sigmaIn.get(c) || 0;
366
+ const stot = sigmaTot.get(c) || 0;
367
+ q += sin / twoM - (stot / twoM) * (stot / twoM);
368
+ }
369
+ return q;
370
+ }
371
+
372
+ /**
373
+ * Full two-phase Louvain. Returns { labels: Map<anchorId, communityLabel>,
374
+ * modularity:number|null }. Iterates: local optimize -> aggregate communities
375
+ * into super-nodes (summing inter/intra weights) -> repeat until the partition
376
+ * stops changing (or no gain). Community labels are the lexicographically
377
+ * smallest original anchor id in each community (stable, OS-independent).
378
+ */
379
+ function runLouvain(batchGraph, gamma) {
380
+ const { ids, weight, m } = batchGraph;
381
+ if (m <= 0 || ids.length < 2) {
382
+ // Degenerate: each anchor alone. Caller's <2 check handles the throw path;
383
+ // here (no edges) we still return singletons labeled by themselves.
384
+ const labels = new Map(ids.map((id) => [id, id]));
385
+ return { labels, modularity: m <= 0 ? null : 0 };
386
+ }
387
+
388
+ // membership: original anchor id -> current super-node id (starts as itself).
389
+ let nodes = ids.slice();
390
+ let adj = weight;
391
+ let selfLoop = new Map(nodes.map((n) => [n, 0]));
392
+ // map from current super-node id -> set of original anchor ids it contains.
393
+ let contains = new Map(nodes.map((n) => [n, new Set([n])]));
394
+
395
+ for (let iter = 0; iter < 50; iter++) {
396
+ const community = louvainLevel(nodes, adj, selfLoop, m, gamma);
397
+
398
+ // Did anything merge? If every node is its own community, we're stable.
399
+ const distinct = new Set(community.values());
400
+ if (distinct.size === nodes.length) break;
401
+
402
+ // Aggregate: new super-node id = lexicographically smallest member id.
403
+ const repOf = new Map(); // oldCommunityId -> representative (smallest member)
404
+ for (const n of nodes) {
405
+ const c = community.get(n);
406
+ const cur = repOf.get(c);
407
+ if (cur === undefined || n < cur) repOf.set(c, n);
408
+ }
409
+ const newContains = new Map();
410
+ for (const n of nodes) {
411
+ const rep = repOf.get(community.get(n));
412
+ let set = newContains.get(rep);
413
+ if (!set) { set = new Set(); newContains.set(rep, set); }
414
+ for (const orig of contains.get(n)) set.add(orig);
415
+ }
416
+ const newNodes = [...newContains.keys()].sort();
417
+ const newAdj = new Map(newNodes.map((n) => [n, new Map()]));
418
+ const newSelf = new Map(newNodes.map((n) => [n, 0]));
419
+ for (const n of nodes) {
420
+ const rn = repOf.get(community.get(n));
421
+ // self-loop carries forward.
422
+ newSelf.set(rn, (newSelf.get(rn) || 0) + (selfLoop.get(n) || 0));
423
+ for (const [nb, wgt] of adj.get(n) || new Map()) {
424
+ const rnb = repOf.get(community.get(nb));
425
+ if (rn === rnb) {
426
+ if (nb === n) continue; // self handled above
427
+ // intra-community edge becomes part of the super-node self-loop (each
428
+ // undirected edge appears twice across n/nb; add half here).
429
+ newSelf.set(rn, (newSelf.get(rn) || 0) + wgt / 2);
430
+ } else {
431
+ const ma = newAdj.get(rn);
432
+ ma.set(rnb, (ma.get(rnb) || 0) + wgt);
433
+ }
434
+ }
435
+ }
436
+
437
+ nodes = newNodes;
438
+ adj = newAdj;
439
+ selfLoop = newSelf;
440
+ contains = newContains;
441
+ }
442
+
443
+ // Unfold: every original anchor id -> the super-node id (representative) it
444
+ // ended in. `contains` maps current super-node -> original ids.
445
+ const labels = new Map();
446
+ for (const [rep, origSet] of contains) {
447
+ for (const orig of origSet) labels.set(orig, rep);
448
+ }
449
+
450
+ // Final modularity is measured on the ORIGINAL graph with the final partition.
451
+ const origCommunity = new Map(ids.map((id) => [id, labels.get(id)]));
452
+ const modularity = modularityOf(ids, weight, new Map(ids.map((id) => [id, 0])), m, origCommunity);
453
+
454
+ return { labels, modularity };
455
+ }
456
+
457
+ // ---------------------------------------------------------------------------
458
+ // Community -> batches (kind classification, sub-split, merge).
459
+ // ---------------------------------------------------------------------------
460
+
461
+ /**
462
+ * Expand each Louvain community into full membership (anchors + their folded
463
+ * children), then classify, sub-split oversize, and merge small code singletons.
464
+ * Returns the final batches array.
465
+ */
466
+ function communitiesToBatches(labels, shape, opts) {
467
+ const { byId, fold } = shape;
468
+ const { maxCommunitySize, miscCap } = opts;
469
+
470
+ // community label -> member node ids (anchors + folded children).
471
+ const members = new Map();
472
+ const push = (label, id) => {
473
+ let arr = members.get(label);
474
+ if (!arr) { arr = []; members.set(label, arr); }
475
+ arr.push(id);
476
+ };
477
+ for (const [anchor, label] of labels) push(label, anchor);
478
+ for (const [folded, owner] of fold) {
479
+ const label = labels.get(owner);
480
+ if (label !== undefined) push(label, folded);
481
+ }
482
+
483
+ // Classify a community by node-type majority. Non-code (token/motion/a11y
484
+ // dominant) -> that kind + mergeable:false. Code (component-bearing) -> 'code'.
485
+ const classify = (ids) => {
486
+ const counts = new Map();
487
+ for (const id of ids) {
488
+ const t = byId.get(id)?.type || 'unknown';
489
+ counts.set(t, (counts.get(t) || 0) + 1);
490
+ }
491
+ const componentCount = counts.get('component') || 0;
492
+ if (componentCount > 0) return { kind: 'code', mergeable: true };
493
+ // No components: pick the dominant non-code type (deterministic tie -> the
494
+ // NONCODE_KIND order, then alpha).
495
+ let bestType = null; let bestN = -1;
496
+ for (const [t, n] of [...counts].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))) {
497
+ if (n > bestN) { bestN = n; bestType = t; }
498
+ }
499
+ const kind = NONCODE_KIND.get(bestType) || 'misc';
500
+ // token/motion/a11y semantic groups are non-mergeable; pure 'misc' (e.g. a
501
+ // lone variant/layer/screen cluster) stays mergeable so the merger can pool it.
502
+ const mergeable = !NONCODE_KIND.has(bestType);
503
+ return { kind, mergeable };
504
+ };
505
+
506
+ // Stable community order: by smallest member id.
507
+ const orderedLabels = [...members.keys()].sort();
508
+ let counter = 0;
509
+ const nextId = () => `batch-${String(++counter).padStart(2, '0')}`;
510
+
511
+ const out = [];
512
+ for (const label of orderedLabels) {
513
+ const ids = members.get(label).slice().sort();
514
+ const { kind, mergeable } = classify(ids);
515
+
516
+ if (ids.length <= maxCommunitySize) {
517
+ out.push({ id: nextId(), members: ids, mergeable, kind, source: 'louvain' });
518
+ continue;
519
+ }
520
+ // Oversize -> alphabetical sub-split into <=maxCommunitySize chunks.
521
+ const baseId = nextId();
522
+ for (let i = 0, part = 0; i < ids.length; i += maxCommunitySize, part++) {
523
+ const chunk = ids.slice(i, i + maxCommunitySize);
524
+ const id = part === 0 ? baseId : `${baseId}-${part + 1}`;
525
+ out.push({ id, members: chunk, mergeable, kind, source: 'subsplit' });
526
+ }
527
+ }
528
+
529
+ return mergeSmall(out, miscCap, nextId);
530
+ }
531
+
532
+ /**
533
+ * Pool small MERGEABLE code/misc batches (here: singletons and tiny leftovers)
534
+ * into <=miscCap "misc" batches. Non-mergeable semantic groups pass through
535
+ * untouched. Deterministic: merge candidates are sorted by their first member id
536
+ * and packed greedily into misc bins of size <=miscCap.
537
+ */
538
+ function mergeSmall(batches, miscCap, nextId) {
539
+ const SMALL = 2; // a "small" mergeable batch is a singleton (1 member).
540
+ const keep = [];
541
+ const poolable = [];
542
+ for (const b of batches) {
543
+ if (b.mergeable && b.members.length < SMALL) poolable.push(b);
544
+ else keep.push(b);
545
+ }
546
+ if (poolable.length < 2) return batches; // nothing worth pooling
547
+
548
+ poolable.sort((a, b) => (a.members[0] < b.members[0] ? -1 : a.members[0] > b.members[0] ? 1 : 0));
549
+ const merged = [];
550
+ let bin = [];
551
+ const flush = () => {
552
+ if (!bin.length) return;
553
+ // `nextId` continues the counter past the last assigned batch id so merge
554
+ // bins get fresh, non-colliding ids; existing ids (incl. subsplit -N
555
+ // suffixes like batch-07-2) are PRESERVED — never renumbered.
556
+ merged.push({ id: nextId(), members: bin.slice().sort(), mergeable: true, kind: 'misc', source: 'merge' });
557
+ bin = [];
558
+ };
559
+ for (const b of poolable) {
560
+ if (bin.length + b.members.length > miscCap) flush();
561
+ bin.push(...b.members);
562
+ }
563
+ flush();
564
+
565
+ // Stable output order by first member id; ids are left untouched so the
566
+ // documented label contract (subsplit base + -N suffix) survives merging.
567
+ const all = keep.concat(merged);
568
+ all.sort((a, b) => (a.members[0] < b.members[0] ? -1 : a.members[0] > b.members[0] ? 1 : 0));
569
+ return all;
570
+ }
571
+
572
+ // ---------------------------------------------------------------------------
573
+ // count-fallback (alphabetical fixed-size batches).
574
+ // ---------------------------------------------------------------------------
575
+
576
+ /** Alphabetical fallback over ALL node ids in fixed-size chunks. */
577
+ function countFallback(graph, size) {
578
+ const ids = nodeList(graph)
579
+ .filter((n) => n && typeof n.id === 'string')
580
+ .map((n) => n.id)
581
+ .sort();
582
+ const batches = [];
583
+ let n = 0;
584
+ for (let i = 0; i < ids.length; i += size) {
585
+ batches.push({
586
+ id: `batch-${String(++n).padStart(2, '0')}`,
587
+ members: ids.slice(i, i + size),
588
+ mergeable: true,
589
+ kind: 'misc',
590
+ source: 'fallback',
591
+ });
592
+ }
593
+ return { batches, modularity: null, method: 'count-fallback' };
594
+ }
595
+
596
+ // ---------------------------------------------------------------------------
597
+ // Public entry.
598
+ // ---------------------------------------------------------------------------
599
+
600
+ /**
601
+ * Group a DesignContext graph into mapper batches via deterministic two-phase
602
+ * Louvain, with safety nets. See file header for the full contract.
603
+ *
604
+ * @param {object} graph Phase-52 graph ({ nodes, edges }).
605
+ * @param {object} [opts] resolution, maxCommunitySize, miscCap, fallbackBatchSize, configCwd.
606
+ * @returns {{batches:Array, modularity:number|null, method:'louvain'|'count-fallback'}}
607
+ */
608
+ export function computeBatches(graph, opts) {
609
+ const resolved = resolveOpts(opts);
610
+ try {
611
+ const shape = shapeAnchors(graph);
612
+ // <2 anchors => count-fallback (the contract's small-input guard).
613
+ if (shape.anchors.size < 2) return countFallback(graph, resolved.fallbackBatchSize);
614
+
615
+ const batchGraph = buildBatchGraph(graph, shape);
616
+ const { labels, modularity } = runLouvain(batchGraph, resolved.resolution);
617
+ const batches = communitiesToBatches(labels, shape, resolved);
618
+ return { batches, modularity, method: 'louvain' };
619
+ } catch {
620
+ // ANY throw -> count-fallback (never crash the dispatcher).
621
+ return countFallback(graph, resolved.fallbackBatchSize);
622
+ }
623
+ }
624
+
625
+ export default { computeBatches };