@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.
@@ -0,0 +1,406 @@
1
+ 'use strict';
2
+ /**
3
+ * sdk/fingerprint/classify.cjs — Phase 53 (Semantic Mapper Engine), FP-02.
4
+ *
5
+ * The incremental-update CLASSIFIER. Given a pre-computed array of per-node
6
+ * fingerprint-compare results (see `compareFingerprints` in
7
+ * `sdk/fingerprint/index.ts`, which yields NONE | COSMETIC | STRUCTURAL) and a
8
+ * project-shape snapshot, decide HOW MUCH of the design context must be re-mapped
9
+ * on this cycle:
10
+ *
11
+ * SKIP — nothing structural changed; reuse the prior context.
12
+ * PARTIAL_UPDATE — a few nodes changed; re-map only the affected batches.
13
+ * ARCHITECTURE_UPDATE — a modest, dir-reshaping change; re-map + re-batch.
14
+ * FULL_UPDATE — a large or restructuring change (or a bootstrap with no
15
+ * prior baseline); re-map everything.
16
+ *
17
+ * This module is the one `.cjs` exception the ROADMAP names so that a `.cjs` CLI
18
+ * or skill can `require()` it directly. It is DEP-FREE and PURE: it consumes a
19
+ * `compareResults` array (NOT fingerprint internals) and never calls the
20
+ * fingerprint engine, so it is fully independent of `sdk/fingerprint/index.ts`.
21
+ * It performs NO filesystem access — directory shape is derived from node-id
22
+ * provenance carried in `projectStats`, never from an FS re-walk.
23
+ *
24
+ * Determinism is a hard contract (D6): no `Math.random`, no `Date.now`, sorted
25
+ * accumulation of `affectedBatchHints`. Identical inputs → identical output on
26
+ * win32 / Linux / macOS.
27
+ *
28
+ * ---------------------------------------------------------------------------
29
+ * INPUT / OUTPUT CONTRACT (for executor E's wiring at the discover/explore boundary)
30
+ * ---------------------------------------------------------------------------
31
+ *
32
+ * classify(compareResults, projectStats) → result
33
+ *
34
+ * compareResults: Array<{ id: string, type?: string, change: 'NONE'|'COSMETIC'|'STRUCTURAL' }>
35
+ * One entry per fingerprinted node that exists in BOTH the prior and the
36
+ * current context, plus add/remove which `compareFingerprints` already maps
37
+ * to STRUCTURAL. `change` is the authoritative signal; `type` (component |
38
+ * token | motion | ...) is carried through for downstream batching but does
39
+ * not affect the action decision. An EMPTY array means "no prior baseline
40
+ * signal" (bootstrap / first run) → FULL_UPDATE (documented below).
41
+ *
42
+ * projectStats: {
43
+ * totalFiles: number, // count of file-nodes in the CURRENT context
44
+ * prevDirShape?: DirShape|null, // null/absent ⇒ bootstrap (no prior baseline)
45
+ * currDirShape?: DirShape|null,
46
+ * thresholds?: Partial<Thresholds> // inline override (lower precedence than config)
47
+ * }
48
+ *
49
+ * DirShape = {
50
+ * dirs: string[], // top-level design dirs (derived from node-id provenance)
51
+ * counts: Record<string, number>, // file-node count per top-level dir
52
+ * layerHist: Record<string, number> // Atomic-layer histogram (atom/molecule/organism/…)
53
+ * }
54
+ *
55
+ * result: {
56
+ * action: 'SKIP'|'PARTIAL_UPDATE'|'ARCHITECTURE_UPDATE'|'FULL_UPDATE',
57
+ * structuralCount: number, // # of STRUCTURAL entries
58
+ * pct: number, // structuralCount / totalFiles (0 when totalFiles===0)
59
+ * dirChanged: boolean, // any top-level dir added/removed/renamed, or per-dir count delta
60
+ * majorRestructure: boolean, // see DECISION MATRIX below
61
+ * affectedBatchHints: string[], // sorted ids of STRUCTURAL-changed inputs
62
+ * reason: string, // human-readable trigger (advisory; stable text)
63
+ * thresholds: Thresholds // the effective thresholds actually used
64
+ * }
65
+ *
66
+ * ---------------------------------------------------------------------------
67
+ * DECISION MATRIX (top-down, FIRST match wins)
68
+ * ---------------------------------------------------------------------------
69
+ * (0) totalFiles === 0 → SKIP (guard: no divide-by-zero, nothing to map)
70
+ * (B) prevDirShape == null (no prior baseline) → FULL_UPDATE (bootstrap; empty compareResults lands here)
71
+ * (1) structuralCount === 0 → SKIP (cosmetic-only / no-op vs a KNOWN baseline)
72
+ * (2) structuralCount > fullFileCount
73
+ * || pct > fullPct
74
+ * || majorRestructure → FULL_UPDATE
75
+ * (3) 0 < pct <= archPctMax && dirChanged → ARCHITECTURE_UPDATE
76
+ * (4) else → PARTIAL_UPDATE
77
+ *
78
+ * BOOTSTRAP rule (no prior baseline): when `prevDirShape` is null/absent (a
79
+ * first run has no baseline to diff against) and there ARE files to map, we
80
+ * cannot trust `pct` or `dirChanged` to be meaningful, so we classify
81
+ * FULL_UPDATE. An EMPTY `compareResults` is the canonical bootstrap signal and
82
+ * likewise yields FULL_UPDATE (the caller treats the whole context as new). The
83
+ * bootstrap check (B) therefore sits ABOVE the structuralCount===0 SKIP (1) —
84
+ * otherwise an empty compare array would be misread as "nothing changed". Only
85
+ * the totalFiles===0 guard (0) wins over bootstrap: a project with zero
86
+ * file-nodes has nothing to map even on its first run.
87
+ *
88
+ * majorRestructure is TRUE when ANY of:
89
+ * (a) a top-level design dir was added, removed, or renamed
90
+ * (set difference of prevDirShape.dirs vs currDirShape.dirs is non-empty);
91
+ * (b) the Atomic-layer histogram shifts > 30% (relative) in ANY bucket;
92
+ * (c) > 25% of the file-nodes changed their owning top-level dir, approximated
93
+ * deterministically by the net per-dir count churn over total files.
94
+ */
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Thresholds — defaults overridable from `.design/config.json#incremental`
98
+ // (config is read by the CALLER and threaded in via projectStats.thresholds,
99
+ // or merged here when a caller hands us the raw config object). We keep the
100
+ // merge logic here so every consumer agrees on precedence + clamping.
101
+ // ---------------------------------------------------------------------------
102
+
103
+ const DEFAULT_THRESHOLDS = Object.freeze({
104
+ fullFileCount: 30, // > this many STRUCTURAL file changes ⇒ FULL_UPDATE
105
+ fullPct: 0.5, // structural fraction > this ⇒ FULL_UPDATE
106
+ archPctMax: 0.3, // structural fraction at-or-below this (with dirChanged) ⇒ ARCHITECTURE
107
+ });
108
+
109
+ /** Relative-shift fraction for an Atomic-layer bucket that counts as "major". */
110
+ const LAYER_SHIFT_MAJOR = 0.3;
111
+ /** Fraction of file-nodes that must change owning dir to count as "major". */
112
+ const DIR_REOWN_MAJOR = 0.25;
113
+
114
+ /**
115
+ * Coerce + clamp a thresholds object. Unknown keys are ignored; non-finite or
116
+ * out-of-range values fall back to the default for that key. `fullFileCount` is
117
+ * a non-negative integer; the two pcts are clamped to [0, 1].
118
+ *
119
+ * @param {Partial<typeof DEFAULT_THRESHOLDS>|null|undefined} raw
120
+ * @returns {typeof DEFAULT_THRESHOLDS}
121
+ */
122
+ function normalizeThresholds(raw) {
123
+ const t = { ...DEFAULT_THRESHOLDS };
124
+ if (!raw || typeof raw !== 'object') return t;
125
+
126
+ if (Number.isFinite(raw.fullFileCount) && raw.fullFileCount >= 0) {
127
+ t.fullFileCount = Math.floor(raw.fullFileCount);
128
+ }
129
+ if (Number.isFinite(raw.fullPct) && raw.fullPct >= 0) {
130
+ t.fullPct = Math.min(1, raw.fullPct);
131
+ }
132
+ if (Number.isFinite(raw.archPctMax) && raw.archPctMax >= 0) {
133
+ t.archPctMax = Math.min(1, raw.archPctMax);
134
+ }
135
+ return t;
136
+ }
137
+
138
+ /**
139
+ * Resolve the effective thresholds. Precedence (highest first):
140
+ * 1. `projectStats.thresholds` — explicit inline override
141
+ * 2. `projectStats.config.incremental` — a raw `.design/config.json` object,
142
+ * if a caller hands the whole config through instead of pre-extracting it
143
+ * 3. DEFAULT_THRESHOLDS
144
+ *
145
+ * @param {object} projectStats
146
+ * @returns {typeof DEFAULT_THRESHOLDS}
147
+ */
148
+ function resolveThresholds(projectStats) {
149
+ const ps = projectStats && typeof projectStats === 'object' ? projectStats : {};
150
+ if (ps.thresholds && typeof ps.thresholds === 'object') {
151
+ return normalizeThresholds(ps.thresholds);
152
+ }
153
+ if (ps.config && typeof ps.config === 'object' && ps.config.incremental) {
154
+ return normalizeThresholds(ps.config.incremental);
155
+ }
156
+ return { ...DEFAULT_THRESHOLDS };
157
+ }
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // Dir-shape diffing — all derived from node-id provenance carried in
161
+ // projectStats; NO filesystem walk. A "DirShape" is { dirs, counts, layerHist }.
162
+ // ---------------------------------------------------------------------------
163
+
164
+ /** Safe array of strings from an arbitrary value. */
165
+ function strList(v) {
166
+ return Array.isArray(v) ? v.filter((s) => typeof s === 'string') : [];
167
+ }
168
+
169
+ /** Safe Record<string, number> from an arbitrary value. */
170
+ function numMap(v) {
171
+ const out = {};
172
+ if (v && typeof v === 'object' && !Array.isArray(v)) {
173
+ for (const k of Object.keys(v)) {
174
+ if (Number.isFinite(v[k])) out[k] = v[k];
175
+ }
176
+ }
177
+ return out;
178
+ }
179
+
180
+ /**
181
+ * True when the set of top-level dirs differs between prev and curr (any add,
182
+ * remove, or rename surfaces as a set difference in at least one direction).
183
+ *
184
+ * @param {string[]} prevDirs
185
+ * @param {string[]} currDirs
186
+ * @returns {boolean}
187
+ */
188
+ function topLevelDirsChanged(prevDirs, currDirs) {
189
+ const a = new Set(prevDirs);
190
+ const b = new Set(currDirs);
191
+ if (a.size !== b.size) return true;
192
+ for (const d of a) if (!b.has(d)) return true;
193
+ return false;
194
+ }
195
+
196
+ /**
197
+ * True when ANY per-dir file-count delta is non-zero. This catches files moving
198
+ * between existing dirs (which leaves `dirs` identical but shifts `counts`).
199
+ *
200
+ * @param {Record<string, number>} prevCounts
201
+ * @param {Record<string, number>} currCounts
202
+ * @returns {boolean}
203
+ */
204
+ function perDirCountsChanged(prevCounts, currCounts) {
205
+ const keys = new Set([...Object.keys(prevCounts), ...Object.keys(currCounts)]);
206
+ for (const k of keys) {
207
+ if ((prevCounts[k] || 0) !== (currCounts[k] || 0)) return true;
208
+ }
209
+ return false;
210
+ }
211
+
212
+ /**
213
+ * True when the Atomic-layer histogram shifts more than LAYER_SHIFT_MAJOR
214
+ * (relative) in ANY bucket. Relative shift for a bucket is
215
+ * |curr - prev| / max(prev, 1) so a bucket going 0→N is a full (>=100%) shift,
216
+ * and we also treat any bucket appearing/disappearing as a major shift.
217
+ *
218
+ * @param {Record<string, number>} prevHist
219
+ * @param {Record<string, number>} currHist
220
+ * @returns {boolean}
221
+ */
222
+ function layerHistMajorShift(prevHist, currHist) {
223
+ const keys = new Set([...Object.keys(prevHist), ...Object.keys(currHist)]);
224
+ for (const k of keys) {
225
+ const p = prevHist[k] || 0;
226
+ const c = currHist[k] || 0;
227
+ if (p === 0 && c === 0) continue;
228
+ const rel = Math.abs(c - p) / Math.max(p, 1);
229
+ if (rel > LAYER_SHIFT_MAJOR) return true;
230
+ }
231
+ return false;
232
+ }
233
+
234
+ /**
235
+ * Deterministic approximation of "> DIR_REOWN_MAJOR of file-nodes changed their
236
+ * owning top-level dir". We do not have per-node before/after dir membership in
237
+ * `compareResults`, so we approximate the re-owning churn from the per-dir count
238
+ * deltas: half the summed absolute count delta is the minimum number of files
239
+ * that must have moved across the dir boundary (each move decrements one dir and
240
+ * increments another, so the summed |delta| double-counts a pure move). We
241
+ * divide by totalFiles to get a fraction. This is monotone in churn and
242
+ * deterministic.
243
+ *
244
+ * @param {Record<string, number>} prevCounts
245
+ * @param {Record<string, number>} currCounts
246
+ * @param {number} totalFiles
247
+ * @returns {boolean}
248
+ */
249
+ function dirReownMajor(prevCounts, currCounts, totalFiles) {
250
+ if (!(totalFiles > 0)) return false;
251
+ const keys = new Set([...Object.keys(prevCounts), ...Object.keys(currCounts)]);
252
+ let absDelta = 0;
253
+ for (const k of keys) {
254
+ absDelta += Math.abs((currCounts[k] || 0) - (prevCounts[k] || 0));
255
+ }
256
+ const movedApprox = absDelta / 2;
257
+ return movedApprox / totalFiles > DIR_REOWN_MAJOR;
258
+ }
259
+
260
+ /**
261
+ * Compute { dirChanged, majorRestructure } from the two dir shapes. When there
262
+ * is no prior shape (bootstrap), there is nothing to diff: dirChanged=false and
263
+ * majorRestructure=false (the bootstrap → FULL decision is made separately, on
264
+ * the absence of a baseline, not on a fabricated "everything changed").
265
+ *
266
+ * @param {object|null} prevDirShape
267
+ * @param {object|null} currDirShape
268
+ * @param {number} totalFiles
269
+ * @returns {{ dirChanged: boolean, majorRestructure: boolean }}
270
+ */
271
+ function diffDirShapes(prevDirShape, currDirShape, totalFiles) {
272
+ if (!prevDirShape || typeof prevDirShape !== 'object') {
273
+ return { dirChanged: false, majorRestructure: false };
274
+ }
275
+ const prevDirs = strList(prevDirShape.dirs);
276
+ const currDirs = strList(currDirShape && currDirShape.dirs);
277
+ const prevCounts = numMap(prevDirShape.counts);
278
+ const currCounts = numMap(currDirShape && currDirShape.counts);
279
+ const prevHist = numMap(prevDirShape.layerHist);
280
+ const currHist = numMap(currDirShape && currDirShape.layerHist);
281
+
282
+ const dirsChanged = topLevelDirsChanged(prevDirs, currDirs);
283
+ const countsChanged = perDirCountsChanged(prevCounts, currCounts);
284
+ const dirChanged = dirsChanged || countsChanged;
285
+
286
+ const majorRestructure =
287
+ dirsChanged ||
288
+ layerHistMajorShift(prevHist, currHist) ||
289
+ dirReownMajor(prevCounts, currCounts, totalFiles);
290
+
291
+ return { dirChanged, majorRestructure };
292
+ }
293
+
294
+ // ---------------------------------------------------------------------------
295
+ // classify — the 4-action top-down matrix.
296
+ // ---------------------------------------------------------------------------
297
+
298
+ /**
299
+ * Classify a cycle's change set into an update action. See the module header
300
+ * for the full input/output contract and decision matrix.
301
+ *
302
+ * @param {Array<{id: string, type?: string, change: string}>} compareResults
303
+ * @param {object} projectStats
304
+ * @returns {{
305
+ * action: string, structuralCount: number, pct: number,
306
+ * dirChanged: boolean, majorRestructure: boolean,
307
+ * affectedBatchHints: string[], reason: string,
308
+ * thresholds: typeof DEFAULT_THRESHOLDS
309
+ * }}
310
+ */
311
+ function classify(compareResults, projectStats) {
312
+ const results = Array.isArray(compareResults) ? compareResults : [];
313
+ const ps = projectStats && typeof projectStats === 'object' ? projectStats : {};
314
+ const thresholds = resolveThresholds(ps);
315
+
316
+ const totalFiles = Number.isFinite(ps.totalFiles) && ps.totalFiles >= 0 ? ps.totalFiles : 0;
317
+ const hasPriorBaseline = ps.prevDirShape != null && typeof ps.prevDirShape === 'object';
318
+
319
+ // Gather STRUCTURAL ids deterministically (sorted, deduped). Only STRUCTURAL
320
+ // entries become batch hints; NONE/COSMETIC do not trigger a re-map.
321
+ const structuralIds = [];
322
+ const seen = new Set();
323
+ for (const r of results) {
324
+ if (!r || typeof r !== 'object') continue;
325
+ if (r.change === 'STRUCTURAL' && typeof r.id === 'string' && r.id.length) {
326
+ if (!seen.has(r.id)) {
327
+ seen.add(r.id);
328
+ structuralIds.push(r.id);
329
+ }
330
+ }
331
+ }
332
+ structuralIds.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
333
+ const structuralCount = structuralIds.length;
334
+
335
+ const pct = totalFiles > 0 ? structuralCount / totalFiles : 0;
336
+
337
+ const { dirChanged, majorRestructure } = diffDirShapes(
338
+ ps.prevDirShape || null,
339
+ ps.currDirShape || null,
340
+ totalFiles,
341
+ );
342
+
343
+ const base = {
344
+ structuralCount,
345
+ pct,
346
+ dirChanged,
347
+ majorRestructure,
348
+ affectedBatchHints: structuralIds,
349
+ thresholds,
350
+ };
351
+
352
+ // (0) Empty project guard — nothing to map, no divide-by-zero. This wins over
353
+ // everything: a project with zero file-nodes has nothing to re-map even on a
354
+ // first run, so a bootstrap with no files still SKIPs.
355
+ if (totalFiles === 0) {
356
+ return { ...base, action: 'SKIP', reason: 'no-files' };
357
+ }
358
+
359
+ // Bootstrap: there is no prior baseline to diff against (first run / no
360
+ // fingerprint store). We cannot trust `pct` or `dirChanged` to be meaningful,
361
+ // so the whole current context is treated as new ⇒ FULL_UPDATE. This is
362
+ // ALSO the canonical empty-`compareResults` path: a caller with files but no
363
+ // prior fingerprints passes `[]` + no `prevDirShape`, and we map everything.
364
+ // The bootstrap decision is made on the ABSENCE OF A BASELINE, not on the
365
+ // structural count — so it must be checked BEFORE the structuralCount===0 SKIP
366
+ // (an empty compare array would otherwise be misread as "nothing changed").
367
+ if (!hasPriorBaseline) {
368
+ return { ...base, action: 'FULL_UPDATE', reason: 'bootstrap-no-baseline' };
369
+ }
370
+
371
+ // (1) No structural change against a KNOWN baseline — skip the cycle
372
+ // (cosmetic-only edits or a genuine no-op).
373
+ if (structuralCount === 0) {
374
+ return { ...base, action: 'SKIP', reason: 'no-structural-change' };
375
+ }
376
+
377
+ // (2) Large or restructuring change ⇒ FULL.
378
+ if (structuralCount > thresholds.fullFileCount) {
379
+ return { ...base, action: 'FULL_UPDATE', reason: 'structural-count-over-threshold' };
380
+ }
381
+ if (pct > thresholds.fullPct) {
382
+ return { ...base, action: 'FULL_UPDATE', reason: 'structural-pct-over-threshold' };
383
+ }
384
+ if (majorRestructure) {
385
+ return { ...base, action: 'FULL_UPDATE', reason: 'major-restructure' };
386
+ }
387
+
388
+ // (3) Modest, dir-reshaping change ⇒ ARCHITECTURE.
389
+ if (pct > 0 && pct <= thresholds.archPctMax && dirChanged) {
390
+ return { ...base, action: 'ARCHITECTURE_UPDATE', reason: 'dir-reshape-modest' };
391
+ }
392
+
393
+ // (4) Default — a handful of in-place changes ⇒ PARTIAL.
394
+ return { ...base, action: 'PARTIAL_UPDATE', reason: 'partial-in-place' };
395
+ }
396
+
397
+ module.exports = {
398
+ classify,
399
+ // exported for callers that want to pre-validate or display the effective
400
+ // thresholds, and for the test suite.
401
+ normalizeThresholds,
402
+ resolveThresholds,
403
+ DEFAULT_THRESHOLDS,
404
+ LAYER_SHIFT_MAJOR,
405
+ DIR_REOWN_MAJOR,
406
+ };