futura-scion 0.2.6 → 0.2.7
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/README.md +34 -0
- package/config/scion.config.yaml +5 -1
- package/package.json +1 -1
- package/src/config.js +2 -1
- package/src/ladder.js +44 -1
- package/src/mind/architecture.js +177 -8
- package/src/mind/generator.js +8 -1
- package/src/mind/replay.js +10 -1
package/README.md
CHANGED
|
@@ -592,6 +592,40 @@ with the violation evidence attached — the human chooses refactor, move,
|
|
|
592
592
|
or amend the declaration, via the same `scion review` loop as every other
|
|
593
593
|
escalation.
|
|
594
594
|
|
|
595
|
+
#### Hub rules — shape, not just edges (`architecture.hubs`)
|
|
596
|
+
|
|
597
|
+
Edges police direction; HUBS police concentration, checked against the
|
|
598
|
+
schematic's measured fan-in/fan-out (`scion map` design data — curator
|
|
599
|
+
models derive the same numbers from edges, so no map build is required):
|
|
600
|
+
|
|
601
|
+
```yaml
|
|
602
|
+
architecture:
|
|
603
|
+
hubs:
|
|
604
|
+
max_fan_in: 100 # no file imported by more than N (change magnets)
|
|
605
|
+
max_fan_out: 20 # no file importing more than N (God modules)
|
|
606
|
+
max_layer_fan_in: 300 # no layer funneling more inbound edges than N
|
|
607
|
+
ignore: [vendor, gen] # path prefixes exempt from hub accounting
|
|
608
|
+
```
|
|
609
|
+
|
|
610
|
+
Hub violations are analyzer-shaped **warnings** (`arch-hub-fan-in`,
|
|
611
|
+
`arch-hub-fan-out`, `arch-hub-layer-fan-in`) carrying the measured number,
|
|
612
|
+
the declared ceiling, and the file — a design smell escalates to the human
|
|
613
|
+
arch lane, never machine-patched. All thresholds are opt-in (empty = not
|
|
614
|
+
checked); malformed or typo'd keys fail loud. Live on FS's own source at
|
|
615
|
+
diagnostic thresholds: `kernel/trail.js` flagged at 173 inbound edges
|
|
616
|
+
(change magnet), `daemon.js` at 22 imports (God module).
|
|
617
|
+
|
|
618
|
+
#### Map-first THINK (`schematic.enabled`)
|
|
619
|
+
|
|
620
|
+
Every ladder decision attaches the schematic digest — layers, entries,
|
|
621
|
+
hubs, exported surface — as context for all downstream rungs (generator
|
|
622
|
+
prompts, analyzer reasoning, research queries). Served from a warm cache
|
|
623
|
+
(`schematic.digest_ttl_ms`, default 30000) so latency-bounded scans are
|
|
624
|
+
never starved: one refresh per TTL window, amortized across every worker
|
|
625
|
+
and daemon scan. `taskId` and `mapContext` are ambient keys — stripped
|
|
626
|
+
before signature hashing, so identical problems replay identically across
|
|
627
|
+
task ids and map refreshes.
|
|
628
|
+
|
|
595
629
|
### Command over tools — the research rung (`scion tools`)
|
|
596
630
|
|
|
597
631
|
FS's rungs answer from what it knows. The RESEARCH rung gives it hands:
|
package/config/scion.config.yaml
CHANGED
|
@@ -11,8 +11,12 @@ ladder:
|
|
|
11
11
|
min_insight_confidence: 0.55 # reasoner rung admission floor
|
|
12
12
|
|
|
13
13
|
llm:
|
|
14
|
-
daily_tokens:
|
|
14
|
+
daily_tokens: 500
|
|
15
15
|
|
|
16
|
+
apiKey: null
|
|
17
|
+
model: "qwen2.5-coder:7b"
|
|
18
|
+
baseUrl: "http://localhost:11434/v1"
|
|
19
|
+
provider: "ollama"
|
|
16
20
|
bounds:
|
|
17
21
|
max_turns: 25
|
|
18
22
|
timeout_ms: 300000
|
package/package.json
CHANGED
package/src/config.js
CHANGED
|
@@ -27,7 +27,8 @@ export const DEFAULTS = Object.freeze({
|
|
|
27
27
|
forge: { min_support: 3, min_precision: 0.7, max_trigger_terms: 3, max_seeds_per_run: 5 },
|
|
28
28
|
queue: { max_attempts: 3 },
|
|
29
29
|
analyzer: { enabled: true },
|
|
30
|
-
|
|
30
|
+
schematic: { enabled: true, digest_ttl_ms: 30000 }, // map-first THINK: inject the codebase digest into every ladder decide (warm-cache TTL)
|
|
31
|
+
architecture: { rules: [], hubs: { max_fan_in: null, max_fan_out: null, max_layer_fan_in: null, ignore: [] } }, // declared layer rules (mind/architecture.js); hubs thresholds are the schematic's measured shape (null = not checked)
|
|
31
32
|
daemon: { auto_fix: false, max_fixes_per_scan: 10 },
|
|
32
33
|
muscle: { path_deny: [], path_allow: [] }, // empty deny = shipped defaults
|
|
33
34
|
api: { token: null, port: 5107, host: '127.0.0.1' },
|
package/src/ladder.js
CHANGED
|
@@ -27,7 +27,12 @@ import * as replay from './mind/replay.js';
|
|
|
27
27
|
import * as seeds from './mind/seeds.js';
|
|
28
28
|
import * as brainReasoner from './brain/reasoner.js';
|
|
29
29
|
import * as memory from './brain/memory.js';
|
|
30
|
-
|
|
30
|
+
|
|
31
|
+
// The warm map: one digest, refreshed at most once per TTL window (see the
|
|
32
|
+
// MAP-FIRST THINK block in decide()). Module-level so a hot daemon's scans
|
|
33
|
+
// and every worker's THINK share the same amortized cost.
|
|
34
|
+
let warmMap = null;
|
|
35
|
+
const MAP_CONTEXT_TTL_MS = 30_000;import * as trail from './kernel/trail.js';
|
|
31
36
|
import { forge } from './mind/forge.js';
|
|
32
37
|
import { load as loadConfig } from './config.js';
|
|
33
38
|
import { analyzeSource, readTargetSource } from './mind/analyzer-rung.js';
|
|
@@ -154,6 +159,44 @@ export async function decide(problem, opts = {}) {
|
|
|
154
159
|
const memoryScope = opts.memoryScope ?? cfg.memory.scope ?? 'auto';
|
|
155
160
|
const minInsightConfidence = opts.minInsightConfidence ?? cfg.ladder.min_insight_confidence;
|
|
156
161
|
|
|
162
|
+
// MAP-FIRST THINK (the schematics contract): every task starts from the
|
|
163
|
+
// codebase map, not from re-reading source. The digest is the one-screen
|
|
164
|
+
// briefing — layers, entries, hubs, surface — attached to the input as
|
|
165
|
+
// `mapContext` for every downstream rung (analyzer reasoning, generator
|
|
166
|
+
// prompts, research queries).
|
|
167
|
+
// COST CONTRACT: mapFor() re-parses only changed files, but even its
|
|
168
|
+
// hash-only pass reads+hashes every file — per-decide that is real money
|
|
169
|
+
// on large trees and it starves latency-bounded scans. THINK context
|
|
170
|
+
// tolerates seconds of staleness, so the digest is served from a WARM
|
|
171
|
+
// cache (default 30s TTL, config schematic.digest_ttl_ms): at most one
|
|
172
|
+
// refresh per TTL window. Anything that needs fresh truth (the analyzer
|
|
173
|
+
// rung, the Gate) reads the real source as always — context never
|
|
174
|
+
// misleads: a map built for a different root, or disabled in config,
|
|
175
|
+
// is skipped silently.
|
|
176
|
+
if (cfg.schematic?.enabled !== false && !input.mapContext) {
|
|
177
|
+
try {
|
|
178
|
+
const ttl = Number(cfg.schematic?.digest_ttl_ms ?? MAP_CONTEXT_TTL_MS);
|
|
179
|
+
const stale = !warmMap || warmMap.root !== project || (Date.now() - warmMap.at > ttl);
|
|
180
|
+
if (stale) {
|
|
181
|
+
const sch = await import('./mind/schematic.js');
|
|
182
|
+
const prev = sch.loadModel();
|
|
183
|
+
const mapRoot = prev?.root ?? project;
|
|
184
|
+
const { model } = sch.mapFor(mapRoot);
|
|
185
|
+
warmMap = model && model.totalSymbols > 0
|
|
186
|
+
? { root: model.root, at: Date.now(), digest: sch.digest(model) }
|
|
187
|
+
: null;
|
|
188
|
+
}
|
|
189
|
+
if (warmMap?.digest) {
|
|
190
|
+
input.mapContext = warmMap.digest;
|
|
191
|
+
trail.journal('ladder.map-context', {
|
|
192
|
+
root: warmMap.root, cachedMs: Date.now() - warmMap.at,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
} catch (err) {
|
|
196
|
+
trail.journal('ladder.map-context-skipped', { error: String(err?.message || err).slice(0, 120) });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
157
200
|
// Rung 1 — replay (TEXT-ONLY problems): the zero-generation fast path.
|
|
158
201
|
// For problems carrying a concrete file the analyzer (below) runs FIRST:
|
|
159
202
|
// a replay verdict for a file task is an OUTCOME RECORD ("this was fixed
|
package/src/mind/architecture.js
CHANGED
|
@@ -127,26 +127,183 @@ export function normalizeRules(archCfg) {
|
|
|
127
127
|
});
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Validate the `architecture.hubs` config section — hub-based layer rules
|
|
132
|
+
* checked against the SCHEMATIC's measured fan-in/fan-out (mind/schematic
|
|
133
|
+
* design data). Where `rules` police EDGES (who may import whom), hubs
|
|
134
|
+
* police SHAPE (how concentrated the dependency graph has become):
|
|
135
|
+
*
|
|
136
|
+
* architecture:
|
|
137
|
+
* hubs:
|
|
138
|
+
* max_fan_in: 60 # no file imported by more than 60 (change magnet)
|
|
139
|
+
* max_fan_out: 20 # no file importing more than 20 (God module)
|
|
140
|
+
* max_layer_fan_in: 300 # no LAYER gathering more inbound edges than this
|
|
141
|
+
* ignore: [vendor] # path prefixes excluded from hub accounting
|
|
142
|
+
*
|
|
143
|
+
* Every threshold is opt-in (absent = not checked); numbers must be > 0.
|
|
144
|
+
* Fail loud on malformed config — a typo must not silently disable it.
|
|
145
|
+
* @param {object} archCfg — config.architecture (may be undefined)
|
|
146
|
+
* @returns {object|null} normalized hubs or null when unconfigured
|
|
147
|
+
*/
|
|
148
|
+
export function normalizeHubs(archCfg) {
|
|
149
|
+
const hubs = archCfg?.hubs;
|
|
150
|
+
if (hubs === undefined || hubs === null) return null;
|
|
151
|
+
if (typeof hubs !== 'object' || Array.isArray(hubs)) {
|
|
152
|
+
throw new Error('architecture: hubs must be a mapping { max_fan_in?, max_fan_out?, max_layer_fan_in?, ignore? }');
|
|
153
|
+
}
|
|
154
|
+
// A typo'd threshold key (max_fan_inn) must not silently disable the
|
|
155
|
+
// check — unknown keys fail loud, same doctrine as merge()'s allowlist.
|
|
156
|
+
const KNOWN = new Set(['max_fan_in', 'max_fan_out', 'max_layer_fan_in', 'ignore']);
|
|
157
|
+
for (const k of Object.keys(hubs)) {
|
|
158
|
+
if (!KNOWN.has(k)) {
|
|
159
|
+
throw new Error(`architecture: unknown hubs field "${k}" — allowed: ${[...KNOWN].join(', ')}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const out = {};
|
|
163
|
+
for (const key of ['max_fan_in', 'max_fan_out', 'max_layer_fan_in']) {
|
|
164
|
+
if (hubs[key] === undefined || hubs[key] === null) continue; // null default = not checked
|
|
165
|
+
const n = Number(hubs[key]);
|
|
166
|
+
if (!Number.isFinite(n) || n <= 0 || !Number.isInteger(n)) {
|
|
167
|
+
throw new Error(`architecture: hubs.${key} must be a positive integer`);
|
|
168
|
+
}
|
|
169
|
+
out[key] = n;
|
|
170
|
+
}
|
|
171
|
+
if (hubs.ignore !== undefined && hubs.ignore !== null) {
|
|
172
|
+
if (!Array.isArray(hubs.ignore) || !hubs.ignore.every(s => typeof s === 'string' && s.trim() !== '')) {
|
|
173
|
+
throw new Error('architecture: hubs.ignore must be an array of non-empty path prefixes');
|
|
174
|
+
}
|
|
175
|
+
out.ignore = hubs.ignore.map(normPrefix);
|
|
176
|
+
}
|
|
177
|
+
if (Object.keys(out).length === 0) {
|
|
178
|
+
return null; // {} (the default) — no hub thresholds declared, nothing to check
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Hub checks: compare the model's MEASURED concentration (schematic design
|
|
185
|
+
* data) against the DECLARED thresholds. A violation is a design smell,
|
|
186
|
+
* not a broken edge — severity 'warning' (design decisions escalate to
|
|
187
|
+
* humans via the arch task lane; the Gate never machine-patches shape).
|
|
188
|
+
* @param {object} model — schematic model (design.fan_in/fan_out/layers)
|
|
189
|
+
* @param {object} hubs — normalized hubs config
|
|
190
|
+
* @returns {{ violations: Array, stats: object }}
|
|
191
|
+
*/
|
|
192
|
+
function checkHubs(model, hubs) {
|
|
193
|
+
const ignore = hubs.ignore ?? [];
|
|
194
|
+
const ignored = (rel) => ignore.some(pfx => under(rel, pfx));
|
|
195
|
+
const violations = [];
|
|
196
|
+
// Fan counts: prefer the schematic's persisted design data when present,
|
|
197
|
+
// otherwise derive from the model's edges (curator models carry no design
|
|
198
|
+
// field — every internal edge increments importer fan-out and imported
|
|
199
|
+
// fan-in, identical arithmetic to the schematic's own derivation).
|
|
200
|
+
const deriveFromEdges = () => {
|
|
201
|
+
const fi = new Map(); const fo = new Map();
|
|
202
|
+
for (const e of model.edges ?? []) {
|
|
203
|
+
if (e.external) continue;
|
|
204
|
+
fo.set(e.from, (fo.get(e.from) ?? 0) + 1);
|
|
205
|
+
fi.set(e.to, (fi.get(e.to) ?? 0) + 1);
|
|
206
|
+
}
|
|
207
|
+
return { fi, fo };
|
|
208
|
+
};
|
|
209
|
+
let fi; let fo;
|
|
210
|
+
if (model.design?.fan_in && model.design?.fan_out) {
|
|
211
|
+
fi = new Map(Object.entries(model.design.fan_in));
|
|
212
|
+
fo = new Map(Object.entries(model.design.fan_out));
|
|
213
|
+
} else {
|
|
214
|
+
({ fi, fo } = deriveFromEdges());
|
|
215
|
+
}
|
|
216
|
+
const relFan = (m) => Object.fromEntries(
|
|
217
|
+
[...m.entries()].map(([p, n]) => [relTo(model.root, p), n])
|
|
218
|
+
);
|
|
219
|
+
const fanIn = relFan(fi);
|
|
220
|
+
const fanOut = relFan(fo);
|
|
221
|
+
|
|
222
|
+
if (hubs.max_fan_in !== undefined) {
|
|
223
|
+
for (const [file, n] of Object.entries(fanIn)) {
|
|
224
|
+
if (ignored(file)) continue;
|
|
225
|
+
if (n > hubs.max_fan_in) {
|
|
226
|
+
violations.push(hubFinding(file, 'arch-hub-fan-in',
|
|
227
|
+
`hub: ${file} is imported by ${n} files (declared max_fan_in ${hubs.max_fan_in}) — a change magnet: every edit risks ${n} consumers`,
|
|
228
|
+
{ fan_in: n, declared_max: hubs.max_fan_in }));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (hubs.max_fan_out !== undefined) {
|
|
233
|
+
for (const [file, n] of Object.entries(fanOut)) {
|
|
234
|
+
if (ignored(file)) continue;
|
|
235
|
+
if (n > hubs.max_fan_out) {
|
|
236
|
+
violations.push(hubFinding(file, 'arch-hub-fan-out',
|
|
237
|
+
`hub: ${file} imports ${n} modules (declared max_fan_out ${hubs.max_fan_out}) — a God module: split it along its consumers`,
|
|
238
|
+
{ fan_out: n, declared_max: hubs.max_fan_out }));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (hubs.max_layer_fan_in !== undefined) {
|
|
243
|
+
// Layer data comes from the schematic; without it, derive per-top-dir
|
|
244
|
+
// inbound edge counts from the edges (same top-of-path keying).
|
|
245
|
+
let layers = model.design?.layers;
|
|
246
|
+
if (!layers) {
|
|
247
|
+
layers = {};
|
|
248
|
+
for (const e of model.edges ?? []) {
|
|
249
|
+
if (e.external) continue;
|
|
250
|
+
const top = relTo(model.root, e.to).split('/')[0] ?? '.';
|
|
251
|
+
layers[top] = layers[top] ?? { files: 0, fan_in: 0, fan_out: 0 };
|
|
252
|
+
layers[top].fan_in += 1;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
for (const [layer, d] of Object.entries(layers)) {
|
|
256
|
+
if (d.fan_in > hubs.max_layer_fan_in) {
|
|
257
|
+
violations.push(hubFinding(`${layer}/`, 'arch-hub-layer-fan-in',
|
|
258
|
+
`hub: layer ${layer} gathers ${d.fan_in} inbound edges across ${d.files} files (declared max_layer_fan_in ${hubs.max_layer_fan_in}) — the architecture is funneling through one layer`,
|
|
259
|
+
{ fan_in: d.fan_in, files: d.files, declared_max: hubs.max_layer_fan_in }));
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
const stats = {
|
|
264
|
+
max_fan_in_observed: Math.max(0, ...Object.values(fanIn)),
|
|
265
|
+
max_fan_out_observed: Math.max(0, ...Object.values(fanOut)),
|
|
266
|
+
layers: Object.keys(model.design?.layers ?? {}).length,
|
|
267
|
+
};
|
|
268
|
+
return { violations, stats };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Analyzer-shaped hub finding (design smell → warning severity). */
|
|
272
|
+
function hubFinding(file, rule, description, detail) {
|
|
273
|
+
return {
|
|
274
|
+
file,
|
|
275
|
+
line: 1,
|
|
276
|
+
rule,
|
|
277
|
+
severity: 'warning',
|
|
278
|
+
description,
|
|
279
|
+
text: '',
|
|
280
|
+
...detail,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
130
284
|
/**
|
|
131
285
|
* Check the model's internal edges against the declared rules.
|
|
132
286
|
* External (package) imports are out of scope — layers govern YOUR code.
|
|
287
|
+
* When `architecture.hubs` is configured, the schematic's measured
|
|
288
|
+
* fan-in/fan-out is additionally checked against the declared thresholds.
|
|
133
289
|
*
|
|
134
|
-
* @param {object} model — buildModel()/loadModel() output (files + edges)
|
|
135
|
-
* @param {object} [opts] — { rules?: normalized rules (defaults from config), now? }
|
|
136
|
-
* @returns {{ rules: number, checked: number, violations: analyzer-shaped findings[] }}
|
|
290
|
+
* @param {object} model — buildModel()/loadModel() output (files + edges + design)
|
|
291
|
+
* @param {object} [opts] — { rules?: normalized rules, hubs?: normalized hubs (defaults from config), now? }
|
|
292
|
+
* @returns {{ rules: number, checked: number, violations: analyzer-shaped findings[], hubs?: { stats } }}
|
|
137
293
|
*/
|
|
138
294
|
export function checkArchitecture(model, opts = {}) {
|
|
139
295
|
const rules = opts.rules ?? normalizeRules(load().architecture);
|
|
140
|
-
|
|
296
|
+
const hubs = opts.hubs !== undefined ? opts.hubs : normalizeHubs(load().architecture);
|
|
297
|
+
let checked = 0;
|
|
298
|
+
const violations = [];
|
|
299
|
+
if (rules.length === 0 && !hubs) {
|
|
141
300
|
return { rules: 0, checked: model.edges.filter(e => !e.external).length, violations: [] };
|
|
142
301
|
}
|
|
143
302
|
// A line number requires source: look the import up in the fragment.
|
|
144
|
-
let checked = 0;
|
|
145
|
-
const violations = [];
|
|
146
303
|
for (const edge of model.edges) {
|
|
147
304
|
if (edge.external) continue;
|
|
148
305
|
checked++;
|
|
149
|
-
if (!rules.some(r => matchesRule(edge, r, model.root))) continue;
|
|
306
|
+
if (rules.length === 0 || !rules.some(r => matchesRule(edge, r, model.root))) continue;
|
|
150
307
|
// Locate the exact import line for an analyzer-shaped finding
|
|
151
308
|
// (deterministic first matching import; unreadable source degrades
|
|
152
309
|
// to line 1 — the violation stands regardless).
|
|
@@ -175,7 +332,19 @@ export function checkArchitecture(model, opts = {}) {
|
|
|
175
332
|
import_target: edge.to,
|
|
176
333
|
});
|
|
177
334
|
}
|
|
178
|
-
|
|
335
|
+
// HUB checks (schematic shape rules) ride along — independent of edges.
|
|
336
|
+
let hubStats;
|
|
337
|
+
if (hubs) {
|
|
338
|
+
const { violations: hubV, stats } = checkHubs(model, hubs);
|
|
339
|
+
violations.push(...hubV);
|
|
340
|
+
hubStats = stats;
|
|
341
|
+
}
|
|
342
|
+
return {
|
|
343
|
+
rules: rules.length,
|
|
344
|
+
checked,
|
|
345
|
+
violations,
|
|
346
|
+
...(hubs ? { hubs: { checked: true, stats: hubStats } } : {}),
|
|
347
|
+
};
|
|
179
348
|
}
|
|
180
349
|
|
|
181
350
|
/**
|
package/src/mind/generator.js
CHANGED
|
@@ -94,7 +94,14 @@ export async function generateCandidate(problem, opts = {}) {
|
|
|
94
94
|
const structured = context && typeof context === 'object' && Array.isArray(context.nodes)
|
|
95
95
|
? buildCallTreeContext(context, problem)
|
|
96
96
|
: null;
|
|
97
|
-
|
|
97
|
+
// Map-first THINK: the codebase digest rides along (mapContext) — the
|
|
98
|
+
// generator sees the architecture briefing without anyone re-reading
|
|
99
|
+
// source to build it.
|
|
100
|
+
raw = await generator({
|
|
101
|
+
...problem,
|
|
102
|
+
...(context ? { context } : {}),
|
|
103
|
+
...(structured ? { prompt: structured } : {}),
|
|
104
|
+
});
|
|
98
105
|
} catch (err) {
|
|
99
106
|
trail.journal('generate.error', { error: String(err?.message || err).slice(0, 200) });
|
|
100
107
|
return null;
|
package/src/mind/replay.js
CHANGED
|
@@ -21,9 +21,18 @@ import { normalize as nluNormalize } from './nlu.js';
|
|
|
21
21
|
|
|
22
22
|
export const MIN_REPLAY_CONFIDENCE = 0.5;
|
|
23
23
|
|
|
24
|
+
// AMBIENT keys — context attached to a problem by the harness (routing and
|
|
25
|
+
// map-first THINK metadata) that must NEVER enter a signature: identical
|
|
26
|
+
// problems must replay identically regardless of which task id carries them
|
|
27
|
+
// or how fresh the codebase map was when they arrived.
|
|
28
|
+
const AMBIENT_KEYS = new Set(['taskId', 'mapContext']);
|
|
29
|
+
|
|
24
30
|
/** Deterministic signature for a repeatable input. */
|
|
25
31
|
export function signatureOf(input) {
|
|
26
|
-
|
|
32
|
+
const clean = input && typeof input === 'object' && !Array.isArray(input)
|
|
33
|
+
? Object.fromEntries(Object.entries(input).filter(([k]) => !AMBIENT_KEYS.has(k)))
|
|
34
|
+
: input;
|
|
35
|
+
return createHash('sha1').update(JSON.stringify(clean)).digest('hex').slice(0, 24);
|
|
27
36
|
}
|
|
28
37
|
|
|
29
38
|
/**
|