@hegemonart/get-design-done 1.52.0 → 1.54.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 (60) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +90 -0
  4. package/README.md +4 -0
  5. package/SKILL.md +2 -1
  6. package/agents/component-taxonomy-mapper.md +3 -0
  7. package/agents/design-context-reviewer-gate.md +102 -0
  8. package/agents/design-context-reviewer.md +186 -0
  9. package/agents/motion-mapper.md +1 -0
  10. package/agents/token-mapper.md +3 -0
  11. package/dist/claude-code/.claude/skills/discover/SKILL.md +7 -1
  12. package/dist/claude-code/.claude/skills/explore/SKILL.md +3 -1
  13. package/dist/claude-code/.claude/skills/new-addendum/SKILL.md +81 -0
  14. package/package.json +1 -1
  15. package/reference/frameworks/astro.md +43 -0
  16. package/reference/frameworks/nextjs.md +44 -0
  17. package/reference/frameworks/remix.md +44 -0
  18. package/reference/frameworks/storybook.md +44 -0
  19. package/reference/frameworks/sveltekit.md +43 -0
  20. package/reference/frameworks/vite-react.md +43 -0
  21. package/reference/interaction.md +1 -0
  22. package/reference/motion/framer-motion.md +45 -0
  23. package/reference/motion/gsap.md +45 -0
  24. package/reference/motion/motion-one.md +44 -0
  25. package/reference/motion/react-spring.md +44 -0
  26. package/reference/motion.md +1 -0
  27. package/reference/registry.json +163 -1
  28. package/reference/registry.schema.json +18 -1
  29. package/reference/skill-graph.md +2 -1
  30. package/reference/systems/chakra.md +44 -0
  31. package/reference/systems/css-modules.md +44 -0
  32. package/reference/systems/mui.md +44 -0
  33. package/reference/systems/radix-themes.md +43 -0
  34. package/reference/systems/shadcn.md +45 -0
  35. package/reference/systems/styled-components.md +44 -0
  36. package/reference/systems/tailwind.md +44 -0
  37. package/reference/systems/vanilla-extract.md +44 -0
  38. package/scripts/lib/detect/stack.cjs +455 -0
  39. package/scripts/lib/detect/stack.d.cts +44 -0
  40. package/scripts/lib/explore-parallel-runner/index.ts +196 -1
  41. package/scripts/lib/explore-parallel-runner/types.ts +85 -0
  42. package/scripts/lib/health-mirror/index.cjs +73 -1
  43. package/scripts/lib/manifest/skills.json +10 -2
  44. package/scripts/lib/mapper-spawn.cjs +257 -0
  45. package/scripts/lib/mapper-spawn.d.cts +60 -0
  46. package/scripts/lib/mappers/compute-batches.mjs +625 -0
  47. package/scripts/lib/mappers/graph-adjacency.mjs +129 -0
  48. package/scripts/lib/mappers/incremental-discover.cjs +617 -0
  49. package/scripts/lib/mappers/incremental-discover.d.cts +133 -0
  50. package/scripts/lib/mappers/neighbor-map.mjs +0 -0
  51. package/scripts/lib/new-addendum.cjs +204 -0
  52. package/sdk/cli/index.js +1504 -3
  53. package/sdk/fingerprint/classify.cjs +406 -0
  54. package/sdk/fingerprint/index.ts +405 -0
  55. package/sdk/fingerprint/store.cjs +523 -0
  56. package/sdk/index.ts +1 -0
  57. package/sdk/mcp/gdd-mcp/server.js +1047 -0
  58. package/skills/discover/SKILL.md +7 -1
  59. package/skills/explore/SKILL.md +3 -1
  60. package/skills/new-addendum/SKILL.md +81 -0
@@ -0,0 +1,617 @@
1
+ 'use strict';
2
+ /**
3
+ * scripts/lib/mappers/incremental-discover.cjs — Phase 53 (Semantic Mapper Engine), DISC-01 (executor E).
4
+ *
5
+ * The COMPOSITION layer that turns a DesignContext graph (Phase 52 shape) + a
6
+ * prior fingerprint snapshot into a concrete "what to re-map this cycle" plan.
7
+ * It glues together the three Round-1 subsystems behind ONE pure-ish entry so
8
+ * the explore-parallel-runner edit stays a thin call:
9
+ *
10
+ * A — scripts/lib/mappers/compute-batches.mjs (computeBatches, Louvain communities)
11
+ * scripts/lib/mappers/neighbor-map.mjs (buildNeighborMap, 1-hop sidecar)
12
+ * B — sdk/fingerprint/index.ts (fingerprint, compareFingerprints)
13
+ * C — sdk/fingerprint/classify.cjs (classify, the 4-action matrix)
14
+ *
15
+ * Flow (planIncremental):
16
+ * 1. computeBatches(graph) → community batches (always, so the dispatcher has
17
+ * a complete batch set regardless of the classifier decision).
18
+ * 2. Per fingerprintable node, derive the fingerprint INPUT projection from the
19
+ * node's graph context (its edges/children — NOT source text), hash it via
20
+ * fingerprint(), and compareFingerprints() against prevFingerprints[id].
21
+ * Nodes absent from prev compare as add (STRUCTURAL); prev nodes absent from
22
+ * the current graph compare as remove (STRUCTURAL).
23
+ * 3. Derive projectStats dir-shape from node-id provenance (the component:/token:/
24
+ * variant:/layer: prefix + a token subtype segment + the node.layer field) —
25
+ * never an FS re-walk (CONTEXT R2 / classify's contract).
26
+ * 4. classify(compareResults, projectStats) → action + affectedBatchHints.
27
+ * 5. Select batchesToMap by action:
28
+ * SKIP → [] (0 mappers)
29
+ * FULL_UPDATE → all batches (bootstrap / large change)
30
+ * PARTIAL/ARCHITECTURE→ only batches whose members intersect the hints
31
+ * `opts.forceFull` (the `--full` opt-out) overrides the decision to all batches.
32
+ * 6. Attach a neighborMap sidecar per SELECTED batch (buildNeighborMap).
33
+ *
34
+ * Determinism (CONTEXT D6): no Math.random / Date.now; every list is sorted
35
+ * before it is emitted; fingerprints are sha256 of canonicalized projections;
36
+ * compareResults are built in batch-then-member order then handed to classify
37
+ * which re-sorts the hints. Identical (graph, prevFingerprints) ⇒ identical plan
38
+ * on win32 / Linux / macOS.
39
+ *
40
+ * IMPORT STRATEGY: this is a `.cjs` module so a `.cjs` CLI / skill can require()
41
+ * it. classify.cjs is plain require(). The ESM .mjs (A) and the .ts engine (B)
42
+ * cannot be statically require()d from CJS, so they load via dynamic import()
43
+ * (the same mechanism concurrency-tuner.cjs uses for the .ts event-stream, and
44
+ * the phase-53-louvain suite uses for the .mjs batchers). The loaded modules are
45
+ * MEMOIZED so a multi-batch run pays the import once. planIncremental is async.
46
+ *
47
+ * Dep-free except the A/C/B sibling modules — no new npm dependency (CONTEXT D7).
48
+ *
49
+ * ---------------------------------------------------------------------------
50
+ * PUBLIC CONTRACT
51
+ * ---------------------------------------------------------------------------
52
+ * await planIncremental({ graph, prevFingerprints, opts }) → {
53
+ * action: 'SKIP'|'PARTIAL_UPDATE'|'ARCHITECTURE_UPDATE'|'FULL_UPDATE',
54
+ * batches: Batch[], // the full community batch set (A's shape)
55
+ * batchesToMap: Batch[], // the subset to dispatch this cycle
56
+ * neighborMaps: Record<batchId, NeighborMap>, // sidecar for SELECTED batches only
57
+ * fingerprints: Record<nodeId, {full,structural,type}>, // current per-node fps (to persist)
58
+ * compareResults: Array<{id,type,change}>, // the per-node change set fed to classify
59
+ * classification: <full classify() result>,// structuralCount, pct, dirChanged, hints, reason, thresholds
60
+ * method: 'louvain'|'count-fallback',
61
+ * modularity: number|null
62
+ * }
63
+ *
64
+ * Inputs:
65
+ * graph Phase-52 graph ({ nodes, edges }). Required; a missing /
66
+ * malformed graph yields an empty SKIP plan (no nodes ⇒
67
+ * classify's totalFiles===0 SKIP).
68
+ * prevFingerprints Record<nodeId, {full,structural}|string> from the store's
69
+ * readCurrent().fingerprints. Absent / empty ⇒ bootstrap
70
+ * (no prevDirShape) ⇒ classify returns FULL_UPDATE.
71
+ * opts { forceFull?: boolean, // the --full opt-out: map everything
72
+ * computeBatchesOpts?, // forwarded to computeBatches (resolution, maxCommunitySize, configCwd, …)
73
+ * neighborCap?: number, // buildNeighborMap cap (default 50)
74
+ * thresholds?, // forwarded into classify's projectStats
75
+ * hadPriorBaseline?: boolean } // force the bootstrap signal off when the
76
+ * // store had a snapshot but it was empty
77
+ */
78
+
79
+ const path = require('node:path');
80
+ const fs = require('node:fs');
81
+ const { pathToFileURL } = require('node:url');
82
+
83
+ // Resolve dependency dirs relative to the PACKAGE ROOT (the dir holding
84
+ // package.json), found by walking up from this file -- NOT via a fixed
85
+ // `__dirname`-relative jump. This is load-bearing for the esbuild SDK bundle:
86
+ // when this .cjs is inlined into sdk/cli/index.js, esbuild rewrites `__dirname`
87
+ // to the bundle's location (sdk/cli/), so `../../../sdk/fingerprint` would
88
+ // resolve one level ABOVE the package (the gdd-sdk --help crash on POSIX CI).
89
+ // The package root is the single ancestor of both the source tree
90
+ // (scripts/lib/mappers/) and the bundle (sdk/cli/), so a walk-up is correct in
91
+ // every mode: source, the in-repo test, and the packed/installed bundle.
92
+ function pkgRoot() {
93
+ let dir = __dirname;
94
+ for (let i = 0; i < 10; i += 1) {
95
+ if (fs.existsSync(path.join(dir, 'package.json'))) return dir;
96
+ const parent = path.dirname(dir);
97
+ if (parent === dir) break;
98
+ dir = parent;
99
+ }
100
+ return process.cwd();
101
+ }
102
+ const ROOT = pkgRoot();
103
+ const MAPPERS_DIR = path.join(ROOT, 'scripts', 'lib', 'mappers');
104
+ const SDK_FP_DIR = path.join(ROOT, 'sdk', 'fingerprint');
105
+
106
+ // classify.cjs is CJS, but require it LAZILY + memoized: merely loading this
107
+ // module (e.g. when the SDK CLI imports the explore runner just to print
108
+ // `--help`) must not pull classify in. It loads only when planIncremental
109
+ // actually classifies. Mirrors the lazy ESM/TS loaders below.
110
+ let _classify = null;
111
+ function classify(...args) {
112
+ if (!_classify) _classify = require(path.join(SDK_FP_DIR, 'classify.cjs')).classify;
113
+ return _classify(...args);
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Lazy + memoized ESM/TS module loading. CJS cannot statically require() an
118
+ // .mjs or a .ts; dynamic import() handles both (the .ts under
119
+ // --experimental-strip-types). Memoize so a multi-batch run imports once.
120
+ // ---------------------------------------------------------------------------
121
+
122
+ let _batchMod = null;
123
+ let _neighborMod = null;
124
+ let _fpMod = null;
125
+
126
+ async function loadBatchMod() {
127
+ if (!_batchMod) {
128
+ _batchMod = await import(pathToFileURL(path.join(MAPPERS_DIR, 'compute-batches.mjs')).href);
129
+ }
130
+ return _batchMod;
131
+ }
132
+ async function loadNeighborMod() {
133
+ if (!_neighborMod) {
134
+ _neighborMod = await import(pathToFileURL(path.join(MAPPERS_DIR, 'neighbor-map.mjs')).href);
135
+ }
136
+ return _neighborMod;
137
+ }
138
+ async function loadFingerprintMod() {
139
+ if (!_fpMod) {
140
+ _fpMod = await import(pathToFileURL(path.join(SDK_FP_DIR, 'index.ts')).href);
141
+ }
142
+ return _fpMod;
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Graph accessors (tolerant of a malformed graph — mirror the A/C modules).
147
+ // ---------------------------------------------------------------------------
148
+
149
+ function nodeList(graph) {
150
+ return Array.isArray(graph && graph.nodes) ? graph.nodes : [];
151
+ }
152
+ function edgeList(graph) {
153
+ return Array.isArray(graph && graph.edges) ? graph.edges : [];
154
+ }
155
+
156
+ const STRUCTURAL_EDGE_TYPES = new Set([
157
+ 'composes',
158
+ 'extends',
159
+ 'depends-on',
160
+ 'consumes-context',
161
+ 'provides-context',
162
+ ]);
163
+
164
+ /**
165
+ * The graph node `type` values we can fingerprint, mapped to the fingerprint
166
+ * engine's FingerprintType. Everything else (variant/layer/state/a11y-pattern/…)
167
+ * is NOT independently fingerprinted — those nodes ride along in their owning
168
+ * component's batch and are re-mapped when that component is.
169
+ */
170
+ const FINGERPRINTABLE = new Map([
171
+ ['component', 'component'],
172
+ ['token', 'token'],
173
+ ['motion-fragment', 'motion'],
174
+ ]);
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // Per-node fingerprint INPUT projection, harvested from graph context.
178
+ // The fingerprint engine (B) accepts rich per-type inputs; a Phase-52 graph
179
+ // node does not always carry props/members/used_tokens directly, so we harvest
180
+ // what the graph DOES encode (edges to tokens/variants, node fields) and leave
181
+ // the rest empty. This is graph-only (CONTEXT R2) — no source-file reads.
182
+ // ---------------------------------------------------------------------------
183
+
184
+ /**
185
+ * Build a once-per-graph index of the relationships we need to project a node:
186
+ * tokensOf[componentId] = Set<tokenId> (uses-token targets)
187
+ * variantsOf[componentId] = Set<variantName> (variant/state children via extends/structural)
188
+ *
189
+ * @param {object} graph
190
+ * @returns {{ byId: Map, tokensOf: Map, variantsOf: Map }}
191
+ */
192
+ function indexForProjection(graph) {
193
+ const byId = new Map();
194
+ for (const n of nodeList(graph)) {
195
+ if (n && typeof n.id === 'string') byId.set(n.id, n);
196
+ }
197
+
198
+ const tokensOf = new Map();
199
+ const variantsOf = new Map();
200
+ const ensure = (map, id) => {
201
+ let s = map.get(id);
202
+ if (!s) { s = new Set(); map.set(id, s); }
203
+ return s;
204
+ };
205
+ const typeOf = (id) => byId.get(id) && byId.get(id).type;
206
+ const nameOf = (id) => {
207
+ const node = byId.get(id);
208
+ return node && typeof node.name === 'string' ? node.name : id;
209
+ };
210
+
211
+ for (const e of edgeList(graph)) {
212
+ if (!e || typeof e.source !== 'string' || typeof e.target !== 'string') continue;
213
+ const sType = typeOf(e.source);
214
+ const tType = typeOf(e.target);
215
+
216
+ if (e.type === 'uses-token') {
217
+ if (sType === 'component' && tType === 'token') ensure(tokensOf, e.source).add(e.target);
218
+ else if (tType === 'component' && sType === 'token') ensure(tokensOf, e.target).add(e.source);
219
+ } else if (e.type === 'extends') {
220
+ // variant/state EXTENDS component (Phase-52 orientation: variant -> component).
221
+ if (tType === 'component' && (sType === 'variant' || sType === 'state')) {
222
+ ensure(variantsOf, e.target).add(nameOf(e.source));
223
+ } else if (sType === 'component' && (tType === 'variant' || tType === 'state')) {
224
+ ensure(variantsOf, e.source).add(nameOf(e.target));
225
+ }
226
+ } else if (STRUCTURAL_EDGE_TYPES.has(e.type)) {
227
+ if (sType === 'component' && tType === 'state') ensure(variantsOf, e.source).add(nameOf(e.target));
228
+ else if (tType === 'component' && sType === 'state') ensure(variantsOf, e.target).add(nameOf(e.source));
229
+ }
230
+ }
231
+
232
+ return { byId, tokensOf, variantsOf };
233
+ }
234
+
235
+ /** Coerce an arbitrary node-field value into a sorted, deduped string[]. */
236
+ function strArray(v) {
237
+ if (!Array.isArray(v)) return [];
238
+ const out = [];
239
+ const seen = new Set();
240
+ for (const x of v) {
241
+ const s = typeof x === 'string' ? x : (x && typeof x.name === 'string' ? x.name : null);
242
+ if (s != null && !seen.has(s)) { seen.add(s); out.push(s); }
243
+ }
244
+ out.sort();
245
+ return out;
246
+ }
247
+
248
+ /**
249
+ * Build the prop-shape array the component fingerprint expects. A Phase-52 node
250
+ * may carry `props` as either string[] (names only) or [{name,type,optional}].
251
+ * We normalize to the {name,type,optional} entry shape; bare strings get an
252
+ * empty type so a later type-annotation gain reads STRUCTURAL.
253
+ *
254
+ * @param {unknown} props
255
+ * @returns {Array<{name:string,type:string,optional?:boolean}>}
256
+ */
257
+ function propShape(props) {
258
+ if (!Array.isArray(props)) return [];
259
+ const out = [];
260
+ for (const p of props) {
261
+ if (typeof p === 'string') {
262
+ out.push({ name: p, type: '' });
263
+ } else if (p && typeof p === 'object' && typeof p.name === 'string') {
264
+ out.push({
265
+ name: p.name,
266
+ type: typeof p.type === 'string' ? p.type : '',
267
+ ...(typeof p.optional === 'boolean' ? { optional: p.optional } : {}),
268
+ });
269
+ }
270
+ }
271
+ return out;
272
+ }
273
+
274
+ /**
275
+ * Project ONE fingerprintable node into the input shape its fingerprint type
276
+ * expects, harvesting from graph context where the node itself is thin.
277
+ *
278
+ * @param {object} node the graph node
279
+ * @param {string} fpType 'component'|'token'|'motion'
280
+ * @param {{ byId, tokensOf, variantsOf }} idx
281
+ * @returns {object} the per-type fingerprint input
282
+ */
283
+ function projectNode(node, fpType, idx) {
284
+ if (fpType === 'component') {
285
+ const usedTokens = idx.tokensOf.has(node.id) ? [...idx.tokensOf.get(node.id)].sort() : [];
286
+ const variantsFromGraph = idx.variantsOf.has(node.id) ? [...idx.variantsOf.get(node.id)] : [];
287
+ const variantsFromNode = strArray(node.exported_variants || node.variants);
288
+ const exportedVariants = [...new Set([...variantsFromGraph, ...variantsFromNode])].sort();
289
+ return {
290
+ component_signature: {
291
+ name: typeof node.name === 'string' ? node.name : node.id,
292
+ members: strArray(node.members),
293
+ },
294
+ props_shape: propShape(node.props),
295
+ used_tokens: usedTokens,
296
+ exported_variants: exportedVariants,
297
+ };
298
+ }
299
+ if (fpType === 'token') {
300
+ return {
301
+ token_name: typeof node.name === 'string' ? node.name : node.id,
302
+ token_value:
303
+ node.value === undefined ? null
304
+ : (typeof node.value === 'object' ? JSON.stringify(node.value) : node.value),
305
+ token_type: typeof node.subtype === 'string' ? node.subtype : (typeof node.token_type === 'string' ? node.token_type : ''),
306
+ ...(typeof node.subtype === 'string' ? { subtype: node.subtype } : {}),
307
+ ...(typeof node.theme_scope === 'string' ? { theme_scope: node.theme_scope } : {}),
308
+ };
309
+ }
310
+ // motion
311
+ return {
312
+ animation_target: typeof node.name === 'string' ? node.name : node.id,
313
+ ...(Number.isFinite(node.duration_ms) ? { duration_ms: node.duration_ms } : {}),
314
+ ...(typeof node.easing === 'string' ? { easing: node.easing } : {}),
315
+ };
316
+ }
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // Directory-shape derivation from node-id provenance (NO FS walk).
320
+ // classify needs { totalFiles, prevDirShape, currDirShape } where a DirShape
321
+ // is { dirs, counts, layerHist }. Phase-52 node ids encode provenance via a
322
+ // `<type>:<...>` prefix (component:/token:/variant:/layer:) and tokens add a
323
+ // subtype segment (token:<subtype>:<name>); the Atomic layer is on node.layer.
324
+ // We treat the top-level id namespace + token subtype as the "dirs", count
325
+ // file-bearing nodes per namespace, and histogram the component layer field.
326
+ // ---------------------------------------------------------------------------
327
+
328
+ /** The id-prefix (namespace) of a node id, e.g. 'component' from 'component:button'. */
329
+ function idNamespace(id) {
330
+ if (typeof id !== 'string') return 'unknown';
331
+ const i = id.indexOf(':');
332
+ return i > 0 ? id.slice(0, i) : id;
333
+ }
334
+
335
+ /**
336
+ * A node counts as a "file-bearing" node for the dir-shape / totalFiles count
337
+ * when it is a first-class design entity (component, token, motion-fragment).
338
+ * Variants/layers/states are sub-entities that ride along; counting them would
339
+ * inflate `totalFiles` and dilute `pct`. This matches classify's "file-nodes"
340
+ * notion (the denominator of the structural fraction).
341
+ */
342
+ function isFileNode(node) {
343
+ return !!node && FINGERPRINTABLE.has(node.type);
344
+ }
345
+
346
+ /**
347
+ * Derive a DirShape from a graph's nodes. `dirs` = the sorted set of top-level
348
+ * namespaces present among file-nodes, with tokens broken out by subtype so a
349
+ * new token category surfaces as a "dir" add. `counts` = file-node count per
350
+ * dir. `layerHist` = histogram by node-id NAMESPACE (component/token/motion-fragment).
351
+ *
352
+ * NOTE on layerHist: the Atomic `layer` field (Atomic/Molecular/Organism) lives
353
+ * on the component node but is NOT carried in the stored per-node fingerprint,
354
+ * so a prior layerHist reconstructed from `prevFingerprints` (see
355
+ * `derivePrevDirShape`) could never see it. To keep the prev↔curr histograms
356
+ * COMPARABLE across a cycle (otherwise every re-run would falsely trip
357
+ * classify's `layerHistMajorShift` → majorRestructure → FULL), both functions
358
+ * histogram by the id namespace, the one provenance signal present on both sides.
359
+ *
360
+ * @param {object} graph
361
+ * @returns {{ dirs: string[], counts: Record<string,number>, layerHist: Record<string,number>, totalFiles: number }}
362
+ */
363
+ function deriveDirShape(graph) {
364
+ const counts = {};
365
+ const layerHist = {};
366
+ let totalFiles = 0;
367
+ for (const n of nodeList(graph)) {
368
+ if (!isFileNode(n)) continue;
369
+ totalFiles += 1;
370
+ const ns = idNamespace(n.id);
371
+ // Dir key: namespace, plus the token subtype as a sub-namespace so a new
372
+ // token category (e.g. token:shadow:*) registers as a dir change.
373
+ let dir = ns;
374
+ if (n.type === 'token' && typeof n.subtype === 'string' && n.subtype.length) {
375
+ dir = `token:${n.subtype}`;
376
+ }
377
+ counts[dir] = (counts[dir] || 0) + 1;
378
+ // Histogram by namespace (NOT the Atomic layer — see the NOTE above) so it
379
+ // is reconstructable from the store on the prior side.
380
+ layerHist[ns] = (layerHist[ns] || 0) + 1;
381
+ }
382
+ const dirs = Object.keys(counts).sort();
383
+ return { dirs, counts, layerHist, totalFiles };
384
+ }
385
+
386
+ /**
387
+ * Reconstruct a PRIOR DirShape from the prevFingerprints map. The store keeps
388
+ * per-node `{full,structural,type}` keyed by node id, so the id namespaces +
389
+ * the stored `type` give us the same dir/count/layer derivation without the
390
+ * prior graph. Components don't carry a layer in the stored fp, so the prior
391
+ * layerHist is approximate (namespace-level) — adequate for the relative-shift
392
+ * heuristic, and the bootstrap path doesn't rely on it at all.
393
+ *
394
+ * Returns null when prevFingerprints is empty (the bootstrap signal).
395
+ *
396
+ * @param {object} prevFingerprints Record<nodeId, {type?}|string>
397
+ * @returns {{ dirs:string[], counts:Record<string,number>, layerHist:Record<string,number> }|null}
398
+ */
399
+ function derivePrevDirShape(prevFingerprints) {
400
+ const fps = prevFingerprints && typeof prevFingerprints === 'object' ? prevFingerprints : {};
401
+ const ids = Object.keys(fps);
402
+ if (ids.length === 0) return null;
403
+ const counts = {};
404
+ const layerHist = {};
405
+ for (const id of ids) {
406
+ const ns = idNamespace(id);
407
+ // token:<subtype>:<name> ⇒ dir 'token:<subtype>'.
408
+ let dir = ns;
409
+ if (ns === 'token') {
410
+ const parts = id.split(':');
411
+ if (parts.length >= 3 && parts[1]) dir = `token:${parts[1]}`;
412
+ }
413
+ counts[dir] = (counts[dir] || 0) + 1;
414
+ // Histogram by namespace for EVERY file-node namespace, matching
415
+ // deriveDirShape so the prev↔curr layerHist comparison is apples-to-apples.
416
+ layerHist[ns] = (layerHist[ns] || 0) + 1;
417
+ }
418
+ return { dirs: Object.keys(counts).sort(), counts, layerHist };
419
+ }
420
+
421
+ // ---------------------------------------------------------------------------
422
+ // compareResults — per-node change set fed to classify.
423
+ // ---------------------------------------------------------------------------
424
+
425
+ /**
426
+ * Read a stored fingerprint value into the {full,structural} shape compare
427
+ * expects. The store may hold either the object or a bare hash string (treated
428
+ * as `full` only — a bare string has no structural projection, so a structural-
429
+ * only change against a bare-string baseline reads STRUCTURAL, which is the safe
430
+ * default).
431
+ *
432
+ * @param {unknown} v
433
+ * @returns {{full:string,structural:string}|null}
434
+ */
435
+ function asFingerprint(v) {
436
+ if (v == null) return null;
437
+ if (typeof v === 'string') return { full: v, structural: v };
438
+ if (typeof v === 'object' && typeof v.full === 'string') {
439
+ return { full: v.full, structural: typeof v.structural === 'string' ? v.structural : v.full };
440
+ }
441
+ return null;
442
+ }
443
+
444
+ /**
445
+ * Compute current fingerprints + the compareResults change set.
446
+ *
447
+ * @param {object} graph
448
+ * @param {object} prevFingerprints
449
+ * @param {Function} fingerprint from sdk/fingerprint
450
+ * @param {Function} compareFingerprints from sdk/fingerprint
451
+ * @returns {{ fingerprints: object, compareResults: Array }}
452
+ */
453
+ function buildCompareResults(graph, prevFingerprints, fingerprint, compareFingerprints) {
454
+ const idx = indexForProjection(graph);
455
+ const prev = prevFingerprints && typeof prevFingerprints === 'object' ? prevFingerprints : {};
456
+
457
+ const fingerprints = {};
458
+ const compareResults = [];
459
+
460
+ // Current nodes: add or compare.
461
+ const currentIds = new Set();
462
+ for (const node of nodeList(graph)) {
463
+ if (!node || typeof node.id !== 'string') continue;
464
+ const fpType = FINGERPRINTABLE.get(node.type);
465
+ if (!fpType) continue;
466
+ currentIds.add(node.id);
467
+
468
+ let fp;
469
+ try {
470
+ fp = fingerprint(projectNode(node, fpType, idx), fpType);
471
+ } catch {
472
+ // A malformed node never crashes the plan; treat it as structurally changed
473
+ // so its batch is conservatively re-mapped.
474
+ compareResults.push({ id: node.id, type: node.type, change: 'STRUCTURAL' });
475
+ continue;
476
+ }
477
+ fingerprints[node.id] = { full: fp.full, structural: fp.structural, type: node.type };
478
+
479
+ const before = asFingerprint(prev[node.id]);
480
+ const change = compareFingerprints(before, { full: fp.full, structural: fp.structural });
481
+ compareResults.push({ id: node.id, type: node.type, change });
482
+ }
483
+
484
+ // Removed nodes: present in prev, absent now ⇒ STRUCTURAL (compareFingerprints(prev, null)).
485
+ for (const id of Object.keys(prev)) {
486
+ if (currentIds.has(id)) continue;
487
+ const before = asFingerprint(prev[id]);
488
+ const change = compareFingerprints(before, null); // STRUCTURAL
489
+ const t = prev[id] && typeof prev[id] === 'object' && typeof prev[id].type === 'string'
490
+ ? prev[id].type : idNamespace(id);
491
+ compareResults.push({ id, type: t, change });
492
+ }
493
+
494
+ return { fingerprints, compareResults };
495
+ }
496
+
497
+ // ---------------------------------------------------------------------------
498
+ // Batch selection.
499
+ // ---------------------------------------------------------------------------
500
+
501
+ /**
502
+ * Select the batches to re-map for an action + hint set.
503
+ * SKIP → []
504
+ * FULL_UPDATE → all batches
505
+ * PARTIAL/ARCHITECTURE → batches whose members intersect the hint set
506
+ * forceFull (the --full opt-out) is applied by the caller BEFORE this by passing
507
+ * action='FULL_UPDATE'. Returns a NEW array (does not mutate `batches`).
508
+ *
509
+ * @param {Array} batches
510
+ * @param {string} action
511
+ * @param {string[]} affectedBatchHints STRUCTURAL-changed node ids
512
+ * @returns {Array}
513
+ */
514
+ function selectBatches(batches, action, affectedBatchHints) {
515
+ const all = Array.isArray(batches) ? batches : [];
516
+ if (action === 'SKIP') return [];
517
+ if (action === 'FULL_UPDATE') return all.slice();
518
+
519
+ // PARTIAL / ARCHITECTURE: intersect members with the hint set.
520
+ const hints = new Set(Array.isArray(affectedBatchHints) ? affectedBatchHints : []);
521
+ if (hints.size === 0) return [];
522
+ const out = [];
523
+ for (const b of all) {
524
+ const members = Array.isArray(b && b.members) ? b.members : [];
525
+ if (members.some((m) => hints.has(m))) out.push(b);
526
+ }
527
+ return out;
528
+ }
529
+
530
+ // ---------------------------------------------------------------------------
531
+ // Public entry.
532
+ // ---------------------------------------------------------------------------
533
+
534
+ /**
535
+ * Plan an incremental discover/explore cycle. See the file header for the full
536
+ * contract. Async (it dynamic-import()s the .mjs batchers + the .ts engine).
537
+ *
538
+ * @param {{ graph: object, prevFingerprints?: object, opts?: object }} args
539
+ * @returns {Promise<object>} the plan
540
+ */
541
+ async function planIncremental(args) {
542
+ const { graph, prevFingerprints, opts } = args && typeof args === 'object' ? args : {};
543
+ const o = opts && typeof opts === 'object' ? opts : {};
544
+ const prev = prevFingerprints && typeof prevFingerprints === 'object' ? prevFingerprints : {};
545
+
546
+ // 1. Batches — always compute the full set (A).
547
+ const { computeBatches } = await loadBatchMod();
548
+ const { batches, modularity, method } = computeBatches(graph, o.computeBatchesOpts);
549
+
550
+ // 2. Per-node fingerprints + compareResults (B).
551
+ const { fingerprint, compareFingerprints } = await loadFingerprintMod();
552
+ const { fingerprints, compareResults } = buildCompareResults(
553
+ graph,
554
+ prev,
555
+ fingerprint,
556
+ compareFingerprints,
557
+ );
558
+
559
+ // 3. projectStats dir-shape from node-id provenance (no FS walk).
560
+ const currDirShape = deriveDirShape(graph);
561
+ let prevDirShape = derivePrevDirShape(prev);
562
+ // A caller that KNOWS a prior baseline existed (the store had a snapshot) but
563
+ // whose prev map is empty can force the bootstrap signal off via
564
+ // hadPriorBaseline:false-vs-true — but the canonical signal is the prev map
565
+ // itself. We only honor an explicit `hadPriorBaseline:false` to FORCE bootstrap.
566
+ if (o.hadPriorBaseline === false) prevDirShape = null;
567
+
568
+ const projectStats = {
569
+ totalFiles: currDirShape.totalFiles,
570
+ prevDirShape,
571
+ currDirShape: { dirs: currDirShape.dirs, counts: currDirShape.counts, layerHist: currDirShape.layerHist },
572
+ ...(o.thresholds && typeof o.thresholds === 'object' ? { thresholds: o.thresholds } : {}),
573
+ };
574
+
575
+ // 4. Classify (C).
576
+ const classification = classify(compareResults, projectStats);
577
+
578
+ // 5. Select batches. The --full opt-out forces FULL regardless of the verdict.
579
+ const effectiveAction = o.forceFull ? 'FULL_UPDATE' : classification.action;
580
+ const batchesToMap = selectBatches(batches, effectiveAction, classification.affectedBatchHints);
581
+
582
+ // 6. neighborMap sidecar for SELECTED batches only.
583
+ const neighborMaps = {};
584
+ if (batchesToMap.length > 0) {
585
+ const { buildNeighborMap } = await loadNeighborMod();
586
+ const cap = Number.isInteger(o.neighborCap) && o.neighborCap >= 0 ? o.neighborCap : 50;
587
+ for (const b of batchesToMap) {
588
+ if (b && typeof b.id === 'string') {
589
+ neighborMaps[b.id] = buildNeighborMap(b, graph, { cap });
590
+ }
591
+ }
592
+ }
593
+
594
+ return {
595
+ action: effectiveAction,
596
+ batches,
597
+ batchesToMap,
598
+ neighborMaps,
599
+ fingerprints,
600
+ compareResults,
601
+ classification,
602
+ method,
603
+ modularity,
604
+ };
605
+ }
606
+
607
+ module.exports = {
608
+ planIncremental,
609
+ // exported for the wiring layer + tests (pure helpers, no side effects).
610
+ selectBatches,
611
+ deriveDirShape,
612
+ derivePrevDirShape,
613
+ buildCompareResults,
614
+ projectNode,
615
+ indexForProjection,
616
+ FINGERPRINTABLE,
617
+ };