@hegemonart/get-design-done 1.52.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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +49 -0
- package/README.md +2 -0
- package/agents/design-context-reviewer-gate.md +102 -0
- package/agents/design-context-reviewer.md +186 -0
- package/dist/claude-code/.claude/skills/discover/SKILL.md +7 -1
- package/dist/claude-code/.claude/skills/explore/SKILL.md +3 -1
- package/package.json +1 -1
- package/scripts/lib/explore-parallel-runner/index.ts +58 -0
- package/scripts/lib/explore-parallel-runner/types.ts +58 -0
- package/scripts/lib/manifest/skills.json +2 -2
- package/scripts/lib/mappers/compute-batches.mjs +625 -0
- package/scripts/lib/mappers/graph-adjacency.mjs +129 -0
- package/scripts/lib/mappers/incremental-discover.cjs +617 -0
- package/scripts/lib/mappers/incremental-discover.d.cts +133 -0
- package/scripts/lib/mappers/neighbor-map.mjs +0 -0
- package/sdk/cli/index.js +369 -2
- package/sdk/fingerprint/classify.cjs +406 -0
- package/sdk/fingerprint/index.ts +405 -0
- package/sdk/fingerprint/store.cjs +523 -0
- package/sdk/index.ts +1 -0
- package/skills/discover/SKILL.md +7 -1
- package/skills/explore/SKILL.md +3 -1
|
@@ -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
|
+
};
|