@heretek-ai/epistemic-swarm 0.7.21 → 0.7.22
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/plugin.json +1 -1
- package/README.md +1 -1
- package/bin/cli.js +3 -2
- package/extensions/pi/index.js +3 -2
- package/package.json +1 -1
- package/plugins/antigravity/plugin.json +1 -1
- package/plugins/factory/skills/factory/SKILL.md +1 -1
- package/plugins/opencode/config-io.js +598 -31
- package/plugins/opencode/index.js +56 -15
- package/plugins/opencode/tui.js +115 -11
- package/plugins/research-cache/skills/research_cache/hasher.py +33 -8
- package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
- package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
- package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
- package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
- package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
- package/runner/__pycache__/darkharvest_claims.cpython-311.pyc +0 -0
- package/runner/__pycache__/errors.cpython-311.pyc +0 -0
- package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
- package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
- package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
- package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
- package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
- package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
- package/runner/__pycache__/preflight.cpython-311.pyc +0 -0
- package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
- package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
- package/runner/__pycache__/retrieval_log.cpython-311.pyc +0 -0
- package/runner/__pycache__/schema_validate.cpython-311.pyc +0 -0
- package/runner/__pycache__/schemas.cpython-311.pyc +0 -0
- package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
- package/runner/mcp_server.py +249 -81
- package/runner/refinement.py +5 -0
- package/runner/schema_validate.py +63 -1
- package/runner/schemas.py +9 -4
- package/runner/tests/__pycache__/test_antigravity_plugin.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_backends.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claude_plugin.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_config_schema.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_contract_tooling.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_darkharvest_audit.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_dossier_contract.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_factory.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_frontier_concurrency.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_frontier_mirror_standalone.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_grounding.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_hardening_contract.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_manifest_concurrency.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_opencode_v2_registrars.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_phase08_waiver_reopens.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_search_anomaly.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_search_provider.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_search_provider_plugin.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_swarm_config.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_sweep_regressions.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
- package/runner/tests/fixtures/config_validation_cases.json +60 -0
- package/runner/tests/test_config_schema.py +119 -15
- package/runner/tests/test_hardening_contract.py +391 -0
- package/runner/tests/test_opencode_ux.py +232 -0
- package/runner/tests/test_opencode_v2_registrars.py +548 -16
- package/runner/tests/test_phase08_waiver_reopens.py +539 -0
- package/schemas/config.schema.json +4 -3
- package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
- package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
- package/scripts/__pycache__/check_docs.cpython-311.pyc +0 -0
- package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
- package/scripts/__pycache__/gen_schemas.cpython-311.pyc +0 -0
- package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
- package/skills/factory/SKILL.md +1 -1
- package/skills/grilling/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/grilling/__pycache__/socratic_tree.cpython-311.pyc +0 -0
- package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
- package/skills/research_cache/hasher.py +33 -8
- package/skills/swarm_config/SKILL.md +6 -1
- package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
- package/skills/swarm_config/configure.py +254 -17
|
@@ -31,7 +31,8 @@
|
|
|
31
31
|
* a foreign live lock is never removed, and a lock adopted away from us by
|
|
32
32
|
* a concurrent takeover is left intact.
|
|
33
33
|
* - Optional expected-hash guard (both `expected_hash` and `expectedHash`
|
|
34
|
-
* spellings; both present must agree
|
|
34
|
+
* spellings; both present must agree after strip+lowercase normalization
|
|
35
|
+
* or the call is rejected with
|
|
35
36
|
* CONFLICTING_EXPECTED_HASH). Full SHA-256 or a prefix of at least 8 hex
|
|
36
37
|
* chars; a mismatch raises CONFIG_STALE with a fresh snapshot and writes
|
|
37
38
|
* nothing. A malformed guard raises CONFIG_INVALID_EXPECTED_HASH.
|
|
@@ -42,12 +43,19 @@
|
|
|
42
43
|
|
|
43
44
|
import { createHash, randomBytes } from 'node:crypto';
|
|
44
45
|
import {
|
|
46
|
+
closeSync,
|
|
45
47
|
existsSync,
|
|
48
|
+
fstatSync,
|
|
49
|
+
lstatSync,
|
|
46
50
|
mkdirSync,
|
|
51
|
+
openSync,
|
|
47
52
|
readFileSync,
|
|
53
|
+
readSync,
|
|
48
54
|
renameSync,
|
|
55
|
+
statSync,
|
|
49
56
|
unlinkSync,
|
|
50
57
|
writeFileSync,
|
|
58
|
+
constants as fsConstants,
|
|
51
59
|
} from 'node:fs';
|
|
52
60
|
import path from 'node:path';
|
|
53
61
|
import { fileURLToPath } from 'node:url';
|
|
@@ -167,6 +175,45 @@ export class ConfigHashError extends ConfigError {
|
|
|
167
175
|
}
|
|
168
176
|
}
|
|
169
177
|
|
|
178
|
+
/**
|
|
179
|
+
* A caller-supplied null targets a schema-known non-nullable key (R2).
|
|
180
|
+
* Structural unification: `saveConfig` throws this (NULL_FOR_NON_NULLABLE_KEY)
|
|
181
|
+
* instead of the generic CONFIG_VALIDATION_FAILED, mirroring the Python
|
|
182
|
+
* `save_config`/`_handle_config` pre-check. Scoping: only schema-known paths
|
|
183
|
+
* are reported; nulls under unknown keys/roles stay preserved-verbatim.
|
|
184
|
+
*/
|
|
185
|
+
export class ConfigNullError extends ConfigError {
|
|
186
|
+
constructor(paths) {
|
|
187
|
+
const names = [...new Set((paths || []).map(String))].sort();
|
|
188
|
+
super(
|
|
189
|
+
'null is not accepted for non-nullable key(s): ' +
|
|
190
|
+
names.join(', ') +
|
|
191
|
+
'; omit the key to leave it unchanged, or pass a valid value',
|
|
192
|
+
{ errors: names }
|
|
193
|
+
);
|
|
194
|
+
this.name = 'ConfigNullError';
|
|
195
|
+
this.code = 'NULL_FOR_NON_NULLABLE_KEY';
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
get errors() {
|
|
199
|
+
return [...(this.details.errors || [])];
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The config file exists but is not a readable, bounded regular file
|
|
205
|
+
* (phase-08 R2, W17). `saveConfig` throws this fail-closed (nothing written)
|
|
206
|
+
* instead of hanging on a FIFO/special device or silently merging over an
|
|
207
|
+
* unreadable/oversize file — mirroring the Python `ConfigUnreadableError`.
|
|
208
|
+
*/
|
|
209
|
+
export class ConfigUnreadableError extends ConfigError {
|
|
210
|
+
constructor(message, details = {}) {
|
|
211
|
+
super(message, { written: false, ...(details || {}) });
|
|
212
|
+
this.name = 'ConfigUnreadableError';
|
|
213
|
+
this.code = 'CONFIG_UNREADABLE';
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
170
217
|
/** A live `.config.lock` is held by another writer; nothing was written. */
|
|
171
218
|
export class ConfigLockedError extends ConfigError {
|
|
172
219
|
constructor(details = {}) {
|
|
@@ -187,6 +234,13 @@ export class ConfigLockedError extends ConfigError {
|
|
|
187
234
|
|
|
188
235
|
let cachedSchema = null;
|
|
189
236
|
let schemaAttempted = false;
|
|
237
|
+
/**
|
|
238
|
+
* H7: number lexemes (`0.0` vs `0`) of the canonical schema artifact, keyed
|
|
239
|
+
* by literal schema path (`properties.divergence_threshold.maximum`). Lets
|
|
240
|
+
* bound/enum renderings match Python `{bound!r}` exactly; `null` when the
|
|
241
|
+
* artifact is unreadable.
|
|
242
|
+
*/
|
|
243
|
+
let cachedSchemaLexemes = null;
|
|
190
244
|
|
|
191
245
|
function schemaArtifactPath() {
|
|
192
246
|
return path.join(PKG_ROOT, 'schemas', 'config.schema.json');
|
|
@@ -202,8 +256,11 @@ export function loadConfigSchema() {
|
|
|
202
256
|
schemaAttempted = true;
|
|
203
257
|
try {
|
|
204
258
|
const raw = readFileSync(schemaArtifactPath(), 'utf-8');
|
|
205
|
-
const parsed =
|
|
206
|
-
if (parsed && typeof parsed === 'object')
|
|
259
|
+
const parsed = parseJsonWithNumberLexemes(raw);
|
|
260
|
+
if (parsed.value && typeof parsed.value === 'object') {
|
|
261
|
+
cachedSchema = parsed.value;
|
|
262
|
+
cachedSchemaLexemes = parsed.lexemes;
|
|
263
|
+
}
|
|
207
264
|
} catch {
|
|
208
265
|
try {
|
|
209
266
|
console.error(
|
|
@@ -214,6 +271,7 @@ export function loadConfigSchema() {
|
|
|
214
271
|
/* logging must never throw */
|
|
215
272
|
}
|
|
216
273
|
cachedSchema = null;
|
|
274
|
+
cachedSchemaLexemes = null;
|
|
217
275
|
}
|
|
218
276
|
return cachedSchema;
|
|
219
277
|
}
|
|
@@ -222,8 +280,150 @@ export function loadConfigSchema() {
|
|
|
222
280
|
export function _resetSchemaCache() {
|
|
223
281
|
cachedSchema = null;
|
|
224
282
|
schemaAttempted = false;
|
|
283
|
+
cachedSchemaLexemes = null;
|
|
225
284
|
}
|
|
226
285
|
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
// lexeme-preserving JSON parse (H7 verbatim-lexeme policy)
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Parse JSON text like `JSON.parse` but also record every number's raw lexeme.
|
|
292
|
+
*
|
|
293
|
+
* Returns `{ value, lexemes }` where `lexemes` maps a literal path to the
|
|
294
|
+
* source text of the number there: object keys join with `.` (`verify.min_fuzzy_confidence`),
|
|
295
|
+
* array indices append `[i]` (`license_whitelist[1]`), and the root is `''`
|
|
296
|
+
* (`[0]` for a root-level array element). Values are identical to
|
|
297
|
+
* `JSON.parse` (same doubles, same strings); only the lexeme table is extra.
|
|
298
|
+
* Throws `SyntaxError` on malformed JSON, mirroring `JSON.parse`.
|
|
299
|
+
*/
|
|
300
|
+
export function parseJsonWithNumberLexemes(text) {
|
|
301
|
+
const src = String(text);
|
|
302
|
+
const lexemes = new Map();
|
|
303
|
+
let pos = 0;
|
|
304
|
+
|
|
305
|
+
const fail = (msg) => {
|
|
306
|
+
throw new SyntaxError(`${msg} at position ${pos}`);
|
|
307
|
+
};
|
|
308
|
+
const skipWs = () => {
|
|
309
|
+
while (pos < src.length) {
|
|
310
|
+
const ch = src[pos];
|
|
311
|
+
if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') pos += 1;
|
|
312
|
+
else break;
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
const expect = (word) => {
|
|
316
|
+
if (src.startsWith(word, pos)) {
|
|
317
|
+
pos += word.length;
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
fail(`expected ${word}`);
|
|
321
|
+
};
|
|
322
|
+
const parseString = () => {
|
|
323
|
+
const start = pos;
|
|
324
|
+
pos += 1; // opening quote
|
|
325
|
+
while (pos < src.length) {
|
|
326
|
+
const ch = src[pos];
|
|
327
|
+
if (ch === '\\') {
|
|
328
|
+
pos += 2;
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (ch === '"') {
|
|
332
|
+
pos += 1;
|
|
333
|
+
return JSON.parse(src.slice(start, pos));
|
|
334
|
+
}
|
|
335
|
+
pos += 1;
|
|
336
|
+
}
|
|
337
|
+
fail('unterminated string');
|
|
338
|
+
};
|
|
339
|
+
const parseNumber = (at) => {
|
|
340
|
+
const rest = src.slice(pos);
|
|
341
|
+
const match = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/.exec(rest);
|
|
342
|
+
if (!match) fail('expected value');
|
|
343
|
+
pos += match[0].length;
|
|
344
|
+
lexemes.set(at, match[0]);
|
|
345
|
+
return Number(match[0]);
|
|
346
|
+
};
|
|
347
|
+
const parseArray = (at) => {
|
|
348
|
+
pos += 1; // [
|
|
349
|
+
const out = [];
|
|
350
|
+
skipWs();
|
|
351
|
+
if (src[pos] === ']') {
|
|
352
|
+
pos += 1;
|
|
353
|
+
return out;
|
|
354
|
+
}
|
|
355
|
+
let i = 0;
|
|
356
|
+
for (;;) {
|
|
357
|
+
out.push(parseValue(`${at}[${i}]`));
|
|
358
|
+
i += 1;
|
|
359
|
+
skipWs();
|
|
360
|
+
if (src[pos] === ',') {
|
|
361
|
+
pos += 1;
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
if (src[pos] === ']') {
|
|
365
|
+
pos += 1;
|
|
366
|
+
return out;
|
|
367
|
+
}
|
|
368
|
+
fail('expected , or ]');
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
const parseObject = (at) => {
|
|
372
|
+
pos += 1; // {
|
|
373
|
+
const out = {};
|
|
374
|
+
skipWs();
|
|
375
|
+
if (src[pos] === '}') {
|
|
376
|
+
pos += 1;
|
|
377
|
+
return out;
|
|
378
|
+
}
|
|
379
|
+
for (;;) {
|
|
380
|
+
skipWs();
|
|
381
|
+
if (src[pos] !== '"') fail('expected string key');
|
|
382
|
+
const key = parseString();
|
|
383
|
+
skipWs();
|
|
384
|
+
if (src[pos] !== ':') fail('expected colon');
|
|
385
|
+
pos += 1;
|
|
386
|
+
out[key] = parseValue(at ? `${at}.${key}` : String(key));
|
|
387
|
+
skipWs();
|
|
388
|
+
if (src[pos] === ',') {
|
|
389
|
+
pos += 1;
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
if (src[pos] === '}') {
|
|
393
|
+
pos += 1;
|
|
394
|
+
return out;
|
|
395
|
+
}
|
|
396
|
+
fail('expected , or }');
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
const parseValue = (at) => {
|
|
400
|
+
skipWs();
|
|
401
|
+
if (pos >= src.length) fail('unexpected end');
|
|
402
|
+
const ch = src[pos];
|
|
403
|
+
if (ch === '{') return parseObject(at);
|
|
404
|
+
if (ch === '[') return parseArray(at);
|
|
405
|
+
if (ch === '"') return parseString();
|
|
406
|
+
if (ch === 't') {
|
|
407
|
+
expect('true');
|
|
408
|
+
return true;
|
|
409
|
+
}
|
|
410
|
+
if (ch === 'f') {
|
|
411
|
+
expect('false');
|
|
412
|
+
return false;
|
|
413
|
+
}
|
|
414
|
+
if (ch === 'n') {
|
|
415
|
+
expect('null');
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
return parseNumber(at);
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
const value = parseValue('');
|
|
422
|
+
skipWs();
|
|
423
|
+
if (pos !== src.length) fail('unexpected trailing content');
|
|
424
|
+
return { value, lexemes };
|
|
425
|
+
};
|
|
426
|
+
|
|
227
427
|
// ---------------------------------------------------------------------------
|
|
228
428
|
// dependency-free validator subset (port of runner/schema_validate.py)
|
|
229
429
|
// ---------------------------------------------------------------------------
|
|
@@ -231,6 +431,21 @@ export function _resetSchemaCache() {
|
|
|
231
431
|
function typeMatches(value, expected) {
|
|
232
432
|
switch (expected) {
|
|
233
433
|
case 'integer':
|
|
434
|
+
// Object-path rule (lexeme-blind): JSON.parse erases the int/float
|
|
435
|
+
// distinction, so an integral float (`2.0`) passes here while Python
|
|
436
|
+
// rejects it (`got float`). See typeMatchesLex for the text-path rule
|
|
437
|
+
// (R3); distinguishing on the object path would change save-path
|
|
438
|
+
// verdicts on info JS cannot recover, so it stays as is by design.
|
|
439
|
+
// WAIVER W21 (F3, Phase 06 `06-parity` REWORK retry 2/3, manager
|
|
440
|
+
// tiebreak binding): object-path integer verdict lexeme-blindness is
|
|
441
|
+
// KEPT by design — `validateConfig({cache_ttl_days: 2.0})` (live JS
|
|
442
|
+
// number `2`) accepts while Python `validate({"cache_ttl_days": 2.0})`
|
|
443
|
+
// rejects (`expected integer|null, got float`); the text path
|
|
444
|
+
// (`validateConfigText('{"cache_ttl_days": 2.0}')`) is the exact
|
|
445
|
+
// surface and is corpus-pinned. Safety: Python load path stays
|
|
446
|
+
// lenient, next Python write re-validates (low harm). TRIGGER: flip
|
|
447
|
+
// deliberately only via a lexeme-aware save entrypoint threading TUI
|
|
448
|
+
// text to the validator (see .roadmap/06-parity/waivers.md W21).
|
|
234
449
|
return typeof value === 'number' && Number.isInteger(value);
|
|
235
450
|
case 'number':
|
|
236
451
|
// NaN passes the type check and is rejected by the finiteness check,
|
|
@@ -264,15 +479,77 @@ function typeName(value) {
|
|
|
264
479
|
return typeof value;
|
|
265
480
|
}
|
|
266
481
|
|
|
267
|
-
/**
|
|
268
|
-
|
|
482
|
+
/**
|
|
483
|
+
* R3: lexeme-aware integer typing for the TEXT path (`validateConfigText` /
|
|
484
|
+
* `validateAgainstSchema` with a data-lexeme table). `JSON.parse` erases the
|
|
485
|
+
* int/float distinction (`2.0` and `2` both become the double `2`), while
|
|
486
|
+
* Python's `json` keeps it — so `{"cache_ttl_days": 2.0}` is a float in
|
|
487
|
+
* Python (`expected integer|null, got float`) but looked like an int here.
|
|
488
|
+
* When the raw lexeme marks a float (`2.0`, `2e0`) but the value is
|
|
489
|
+
* integral, the value counts as a float: `integer` no longer matches and the
|
|
490
|
+
* type name renders `float`. With no lexeme (object path: `validateConfig` /
|
|
491
|
+
* `saveConfig` on live JS numbers) behavior is byte-identical to before —
|
|
492
|
+
* the int/float distinction is unrecoverable there (documented boundary;
|
|
493
|
+
* object-path verdicts unchanged by design).
|
|
494
|
+
*/
|
|
495
|
+
function isFloatLexeme(lexeme) {
|
|
496
|
+
return typeof lexeme === 'string' && /[.eE]/.test(lexeme);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function typeMatchesLex(value, expected, lexeme = null) {
|
|
500
|
+
if (expected === 'integer') {
|
|
501
|
+
if (typeof value !== 'number' || !Number.isInteger(value)) return false;
|
|
502
|
+
if (isFloatLexeme(lexeme)) return false;
|
|
503
|
+
return true;
|
|
504
|
+
}
|
|
505
|
+
return typeMatches(value, expected);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** R3: `float` wins when the lexeme marks an integral value as a float. */
|
|
509
|
+
function typeNameLex(value, lexeme = null) {
|
|
510
|
+
if (
|
|
511
|
+
typeof value === 'number' &&
|
|
512
|
+
Number.isInteger(value) &&
|
|
513
|
+
isFloatLexeme(lexeme)
|
|
514
|
+
) {
|
|
515
|
+
return 'float';
|
|
516
|
+
}
|
|
517
|
+
return typeName(value);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** Python repr() for scalars/collections (single quotes, True/None).
|
|
521
|
+
*
|
|
522
|
+
* H7 verbatim-lexeme policy (Python repr wins): JSON.parse erases the
|
|
523
|
+
* int/float distinction (`0.0` and `0` both become the double `0`), while
|
|
524
|
+
* Python's `json` keeps it (`{data!r}` renders `0.0` vs `0`). The optional
|
|
525
|
+
* `lexeme` is the raw JSON number text from `parseJsonWithNumberLexemes`;
|
|
526
|
+
* when it marks a float (`0.0`, `1.0`, `2e0`) but the value is integral,
|
|
527
|
+
* render Python-`repr` style (`0.0`, never `0`). Without a lexeme the
|
|
528
|
+
* fallback is plain `String(value)` — programmatic JS integral numbers
|
|
529
|
+
* correspond to Python ints, so default behavior is byte-identical.
|
|
530
|
+
*
|
|
531
|
+
* Residuals (documented, unavoidable without a lexeme): exponential lexemes
|
|
532
|
+
* render via `String()` (JS `1e-7` vs Python `1e-07`), and integers past
|
|
533
|
+
* 2^53 lose precision in both parsers differently (Python keeps the int).
|
|
534
|
+
* WAIVER W22 (F2, Phase 06 `06-parity` REWORK retry 2/3, manager tiebreak
|
|
535
|
+
* binding, second opinion agrees): the exponential float rendering class
|
|
536
|
+
* is WAIVED on BOTH paths — do NOT pad (whack-a-mole: `1e-5` -> JS
|
|
537
|
+
* `0.00001` vs Python `1e-05`, `1e16` -> JS `10000000000000000` vs Python
|
|
538
|
+
* `1e+16` differ by CPython shortest-repr SELECTION thresholds, not just
|
|
539
|
+
* zero-padding, so padding fixes one instance and breaks the next).
|
|
540
|
+
* Documented divergence pinned by
|
|
541
|
+
* `test_w22_exponential_rendering_waiver`. TRIGGER: revisit only via a
|
|
542
|
+
* port of CPython shortest-repr switching, and only if exponential config
|
|
543
|
+
* values occur (see .roadmap/06-parity/waivers.md W22).
|
|
544
|
+
*/
|
|
545
|
+
function pyRepr(value, lexeme = null) {
|
|
269
546
|
if (typeof value === 'string') {
|
|
270
547
|
return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
271
548
|
}
|
|
272
549
|
if (value === null || value === undefined) return 'None';
|
|
273
550
|
if (value === true) return 'True';
|
|
274
551
|
if (value === false) return 'False';
|
|
275
|
-
if (Array.isArray(value)) return `[${value.map(pyRepr).join(', ')}]`;
|
|
552
|
+
if (Array.isArray(value)) return `[${value.map((v) => pyRepr(v)).join(', ')}]`;
|
|
276
553
|
if (typeof value === 'object') {
|
|
277
554
|
const parts = Object.entries(value).map(
|
|
278
555
|
([k, v]) => `${pyRepr(k)}: ${pyRepr(v)}`
|
|
@@ -283,28 +560,135 @@ function pyRepr(value) {
|
|
|
283
560
|
if (Number.isNaN(value)) return 'nan';
|
|
284
561
|
if (value === Infinity) return 'inf';
|
|
285
562
|
if (value === -Infinity) return '-inf';
|
|
563
|
+
if (
|
|
564
|
+
typeof lexeme === 'string' &&
|
|
565
|
+
/[.eE]/.test(lexeme) &&
|
|
566
|
+
Number.isInteger(value)
|
|
567
|
+
) {
|
|
568
|
+
if (Object.is(value, -0)) return '-0.0';
|
|
569
|
+
const text = String(value);
|
|
570
|
+
if (text.includes('e') || text.includes('E') || text.includes('.')) {
|
|
571
|
+
return text;
|
|
572
|
+
}
|
|
573
|
+
return `${text}.0`;
|
|
574
|
+
}
|
|
286
575
|
}
|
|
287
576
|
return String(value);
|
|
288
577
|
}
|
|
289
578
|
|
|
290
|
-
function pyReprEnum(values) {
|
|
291
|
-
|
|
579
|
+
function pyReprEnum(values, lexemes = null) {
|
|
580
|
+
const list = values || [];
|
|
581
|
+
return `[${list.map((v, i) => pyRepr(v, lexemes ? lexemes[i] ?? null : null)).join(', ')}]`;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* R2: schema-type-aware DATA rendering for the OBJECT path (`validateConfig`
|
|
586
|
+
* on live JS numbers — the path `saveConfig` uses). Without a lexeme an
|
|
587
|
+
* integral JS number is ambiguous (Python `2` is an int, `2.0` a float), so
|
|
588
|
+
* the schema node decides: a float/`number`-context node renders `2.0`-style
|
|
589
|
+
* (matching Python `{data!r}` for float values, e.g. `divergence_threshold:
|
|
590
|
+
* value 2.0 is above maximum 1.0`), while an integer-context node renders
|
|
591
|
+
* `2`. With a lexeme the verbatim text wins (H7 policy, unchanged).
|
|
592
|
+
* Rendering only — comparisons still use the parsed doubles, so no verdict
|
|
593
|
+
* changes in this fix. Schema-literal minima/maxima/enum keep their
|
|
594
|
+
* `loadConfigSchema` lexemes (never routed here).
|
|
595
|
+
* WAIVER W20 (F1, Phase 06 `06-parity` REWORK retry 2/3, manager tiebreak
|
|
596
|
+
* binding): object-path int-in-number-field message ambiguity is
|
|
597
|
+
* INFORMATION-THEORETIC and KEPT in the R2 float direction — live JS `2`
|
|
598
|
+
* === `2.0` (one double), while Python `int(2)` vs `float(2.0)` render
|
|
599
|
+
* `2` vs `2.0`, so `validateConfig({divergence_threshold: 2})` renders
|
|
600
|
+
* `value 2.0 is above maximum 1.0` where Python-on-int-`2` renders
|
|
601
|
+
* `value 2 is above maximum 1.0` (verdicts agree: both reject). The text
|
|
602
|
+
* path (`validateConfigText('{"divergence_threshold": 2}')` -> `2`,
|
|
603
|
+
* `... 2.0}` -> `2.0`) is the exact surface. Documented behavior pinned
|
|
604
|
+
* by `test_w20_object_path_number_context_float_rendering_waiver` — flip
|
|
605
|
+
* deliberately only via a future lexeme-aware entrypoint (see
|
|
606
|
+
* .roadmap/06-parity/waivers.md W20).
|
|
607
|
+
*/
|
|
608
|
+
function isFloatContext(schemaNode) {
|
|
609
|
+
const t = schemaNode && schemaNode.type;
|
|
610
|
+
const types = Array.isArray(t) ? t : t === undefined ? [] : [t];
|
|
611
|
+
return types.includes('number') && !types.includes('integer');
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function pyReprData(value, lexeme, schemaNode) {
|
|
615
|
+
if (
|
|
616
|
+
typeof value === 'number' &&
|
|
617
|
+
Number.isInteger(value) &&
|
|
618
|
+
(lexeme === null || lexeme === undefined) &&
|
|
619
|
+
isFloatContext(schemaNode)
|
|
620
|
+
) {
|
|
621
|
+
if (Object.is(value, -0)) return '-0.0';
|
|
622
|
+
const text = String(value);
|
|
623
|
+
if (text.includes('.') || text.includes('e') || text.includes('E')) {
|
|
624
|
+
return text;
|
|
625
|
+
}
|
|
626
|
+
return `${text}.0`;
|
|
627
|
+
}
|
|
628
|
+
return pyRepr(value, lexeme);
|
|
292
629
|
}
|
|
293
630
|
|
|
294
631
|
/**
|
|
295
632
|
* Human-readable violations (dotted paths); [] == valid. Ports
|
|
296
633
|
* `runner/schema_validate.py:validate` keyword for keyword.
|
|
634
|
+
*
|
|
635
|
+
* H7: the optional 4th argument carries number lexemes for Python-`repr`
|
|
636
|
+
* rendering — `{ data, schema, schemaPath }` where `data`/`schema` are
|
|
637
|
+
* `parseJsonWithNumberLexemes` tables (either may be null) and `schemaPath`
|
|
638
|
+
* is the literal path of `schema` within its document root (`''` at the
|
|
639
|
+
* root; recursion extends it with `.properties.<key>` / `.items` /
|
|
640
|
+
* `.additionalProperties`). Number *comparisons* always use the parsed
|
|
641
|
+
* doubles, so verdicts are unchanged with or without lexemes (except the R3
|
|
642
|
+
* int-vs-float typing rule, which needs a data lexeme); only message
|
|
643
|
+
* strings gain the `0.0` style. Omitted (or null) keeps the historical
|
|
644
|
+
* rendering for schema bounds, while DATA numbers without a lexeme render
|
|
645
|
+
* schema-type-aware (R2: `number`-context `2` reads `2.0`, integer-context
|
|
646
|
+
* reads `2`).
|
|
297
647
|
*/
|
|
298
|
-
export function validateAgainstSchema(data, schema, currentPath = '') {
|
|
648
|
+
export function validateAgainstSchema(data, schema, currentPath = '', lex = null) {
|
|
299
649
|
if (!schema || typeof schema !== 'object') return [];
|
|
300
650
|
const problems = [];
|
|
301
651
|
const at = currentPath || '<root>';
|
|
652
|
+
const schemaPath =
|
|
653
|
+
lex && typeof lex.schemaPath === 'string' ? lex.schemaPath : '';
|
|
654
|
+
const childLex = (childSchemaPath) =>
|
|
655
|
+
lex ? { data: lex.data, schema: lex.schema, schemaPath: childSchemaPath } : null;
|
|
656
|
+
const propSchemaPath = (key) =>
|
|
657
|
+
schemaPath ? `${schemaPath}.properties.${key}` : `properties.${key}`;
|
|
658
|
+
const lookupDataLexeme = (dataPath) => {
|
|
659
|
+
if (!lex || !(lex.data instanceof Map)) return null;
|
|
660
|
+
if (lex.data.has(dataPath)) return lex.data.get(dataPath);
|
|
661
|
+
// Legacy display-prefix fallback: root-level arrays used to display as
|
|
662
|
+
// `<root>[i]` while the lexeme table keys them `[i]` (R4 now renders
|
|
663
|
+
// `[i]` directly, so this only fires for callers holding old paths).
|
|
664
|
+
if (dataPath.startsWith('<root>')) {
|
|
665
|
+
const stripped = dataPath.slice('<root>'.length);
|
|
666
|
+
if (lex.data.has(stripped)) return lex.data.get(stripped);
|
|
667
|
+
}
|
|
668
|
+
return null;
|
|
669
|
+
};
|
|
670
|
+
const lookupSchemaLexeme = (keyword) => {
|
|
671
|
+
if (!lex || !(lex.schema instanceof Map)) return null;
|
|
672
|
+
const key = schemaPath ? `${schemaPath}.${keyword}` : keyword;
|
|
673
|
+
return lex.schema.has(key) ? lex.schema.get(key) : null;
|
|
674
|
+
};
|
|
675
|
+
const lookupEnumLexemes = (count) => {
|
|
676
|
+
if (!lex || !(lex.schema instanceof Map)) return null;
|
|
677
|
+
const base = schemaPath ? `${schemaPath}.enum` : 'enum';
|
|
678
|
+
return Array.from({ length: count }, (_, i) => {
|
|
679
|
+
const key = `${base}[${i}]`;
|
|
680
|
+
return lex.schema.has(key) ? lex.schema.get(key) : null;
|
|
681
|
+
});
|
|
682
|
+
};
|
|
683
|
+
const dataLexeme = lookupDataLexeme(currentPath);
|
|
302
684
|
|
|
303
685
|
const expected = schema.type;
|
|
304
686
|
if (expected !== undefined) {
|
|
305
687
|
const types = Array.isArray(expected) ? expected : [expected];
|
|
306
|
-
|
|
307
|
-
|
|
688
|
+
// R3: lexeme-aware integer typing on the text path (object path passes
|
|
689
|
+
// lexeme null, so its verdicts are unchanged by design).
|
|
690
|
+
if (!types.some((t) => typeMatchesLex(data, t, dataLexeme))) {
|
|
691
|
+
return [`${at}: expected ${types.join('|')}, got ${typeNameLex(data, dataLexeme)}`];
|
|
308
692
|
}
|
|
309
693
|
}
|
|
310
694
|
|
|
@@ -318,46 +702,57 @@ export function validateAgainstSchema(data, schema, currentPath = '') {
|
|
|
318
702
|
for (const key of Object.keys(props)) {
|
|
319
703
|
if (Object.prototype.hasOwnProperty.call(data, key)) {
|
|
320
704
|
const child = currentPath ? `${currentPath}.${key}` : key;
|
|
321
|
-
problems.push(
|
|
705
|
+
problems.push(
|
|
706
|
+
...validateAgainstSchema(data[key], props[key], child, childLex(propSchemaPath(key)))
|
|
707
|
+
);
|
|
322
708
|
}
|
|
323
709
|
}
|
|
324
710
|
if (schema.additionalProperties && typeof schema.additionalProperties === 'object') {
|
|
325
711
|
const known = new Set(Object.keys(props));
|
|
712
|
+
const childSchemaPath = schemaPath
|
|
713
|
+
? `${schemaPath}.additionalProperties`
|
|
714
|
+
: 'additionalProperties';
|
|
326
715
|
for (const key of Object.keys(data)) {
|
|
327
716
|
if (!known.has(key)) {
|
|
328
717
|
const child = currentPath ? `${currentPath}.${key}` : key;
|
|
329
718
|
problems.push(
|
|
330
|
-
...validateAgainstSchema(data[key], schema.additionalProperties, child)
|
|
719
|
+
...validateAgainstSchema(data[key], schema.additionalProperties, child, childLex(childSchemaPath))
|
|
331
720
|
);
|
|
332
721
|
}
|
|
333
722
|
}
|
|
334
723
|
}
|
|
335
724
|
if (schema.enum !== undefined && !(schema.enum || []).includes(data)) {
|
|
336
|
-
problems.push(`${at}: value ${
|
|
725
|
+
problems.push(`${at}: value ${pyReprData(data, dataLexeme, schema)} not in enum ${pyReprEnum(schema.enum, lookupEnumLexemes((schema.enum || []).length))}`);
|
|
337
726
|
}
|
|
338
727
|
}
|
|
339
728
|
|
|
340
729
|
if (Array.isArray(data) && schema.items && typeof schema.items === 'object') {
|
|
730
|
+
const childSchemaPath = schemaPath ? `${schemaPath}.items` : 'items';
|
|
341
731
|
for (let i = 0; i < data.length; i += 1) {
|
|
342
|
-
|
|
732
|
+
// R4: Python renders root-level indices bare (`[1]`); building from the
|
|
733
|
+
// display prefix gave `<root>[1]`. Build from the DATA path ('' at the
|
|
734
|
+
// root), so top-level arrays agree byte-for-byte; nested paths are
|
|
735
|
+
// unchanged (`tags[0]`, `[0][1]`).
|
|
736
|
+
const child = currentPath ? `${at}[${i}]` : `[${i}]`;
|
|
737
|
+
problems.push(...validateAgainstSchema(data[i], schema.items, child, childLex(childSchemaPath)));
|
|
343
738
|
}
|
|
344
739
|
}
|
|
345
740
|
|
|
346
741
|
if (data === null || typeof data !== 'object') {
|
|
347
742
|
if (schema.enum !== undefined && !(schema.enum || []).includes(data)) {
|
|
348
|
-
problems.push(`${at}: value ${
|
|
743
|
+
problems.push(`${at}: value ${pyReprData(data, dataLexeme, schema)} not in enum ${pyReprEnum(schema.enum, lookupEnumLexemes((schema.enum || []).length))}`);
|
|
349
744
|
}
|
|
350
745
|
}
|
|
351
746
|
|
|
352
747
|
if (typeof data === 'number') {
|
|
353
748
|
if (!Number.isFinite(data)) {
|
|
354
|
-
problems.push(`${at}: value ${
|
|
749
|
+
problems.push(`${at}: value ${pyReprData(data, dataLexeme, schema)} is not finite`);
|
|
355
750
|
} else {
|
|
356
751
|
if (schema.minimum !== undefined && data < schema.minimum) {
|
|
357
|
-
problems.push(`${at}: value ${
|
|
752
|
+
problems.push(`${at}: value ${pyReprData(data, dataLexeme, schema)} is below minimum ${pyRepr(schema.minimum, lookupSchemaLexeme('minimum'))}`);
|
|
358
753
|
}
|
|
359
754
|
if (schema.maximum !== undefined && data > schema.maximum) {
|
|
360
|
-
problems.push(`${at}: value ${
|
|
755
|
+
problems.push(`${at}: value ${pyReprData(data, dataLexeme, schema)} is above maximum ${pyRepr(schema.maximum, lookupSchemaLexeme('maximum'))}`);
|
|
361
756
|
}
|
|
362
757
|
}
|
|
363
758
|
}
|
|
@@ -375,7 +770,7 @@ export function validateAgainstSchema(data, schema, currentPath = '') {
|
|
|
375
770
|
}
|
|
376
771
|
if (!matched) {
|
|
377
772
|
problems.push(
|
|
378
|
-
`${at}: value ${pyRepr(data)} does not match pattern ${pyRepr(schema.pattern)}`
|
|
773
|
+
`${at}: value ${pyRepr(data, dataLexeme)} does not match pattern ${pyRepr(schema.pattern)}`
|
|
379
774
|
);
|
|
380
775
|
}
|
|
381
776
|
}
|
|
@@ -389,7 +784,95 @@ export function validateConfig(cfg) {
|
|
|
389
784
|
try {
|
|
390
785
|
const schema = loadConfigSchema();
|
|
391
786
|
if (!schema) return [];
|
|
392
|
-
|
|
787
|
+
const lex =
|
|
788
|
+
cachedSchemaLexemes instanceof Map
|
|
789
|
+
? { data: null, schema: cachedSchemaLexemes, schemaPath: '' }
|
|
790
|
+
: null;
|
|
791
|
+
return validateAgainstSchema(cfg, schema, '', lex);
|
|
792
|
+
} catch {
|
|
793
|
+
return [];
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* H7: validate canonical-CONFIG JSON TEXT with verbatim lexemes.
|
|
799
|
+
*
|
|
800
|
+
* Parses `jsonText` with `parseJsonWithNumberLexemes` so float lexemes
|
|
801
|
+
* (`0.0`) survive erasure and messages match Python `{value!r}` exactly
|
|
802
|
+
* (verdicts are identical to `validateConfig` on the same text). Throws
|
|
803
|
+
* `SyntaxError` on malformed JSON, mirroring `JSON.parse`.
|
|
804
|
+
*/
|
|
805
|
+
export function validateConfigText(jsonText) {
|
|
806
|
+
const { value, lexemes } = parseJsonWithNumberLexemes(jsonText);
|
|
807
|
+
const schema = loadConfigSchema();
|
|
808
|
+
if (!schema) return [];
|
|
809
|
+
return validateAgainstSchema(value, schema, '', {
|
|
810
|
+
data: lexemes,
|
|
811
|
+
schema: cachedSchemaLexemes,
|
|
812
|
+
schemaPath: '',
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function schemaAllowsNull(subschema) {
|
|
817
|
+
if (!subschema || typeof subschema !== 'object') return false;
|
|
818
|
+
const t = subschema.type;
|
|
819
|
+
if (Array.isArray(t)) return t.includes('null');
|
|
820
|
+
return t === 'null';
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* Dotted paths in caller-supplied `caller` where null is banned (R2).
|
|
825
|
+
* Shared nested-null pre-check: `saveConfig` throws `ConfigNullError` for
|
|
826
|
+
* these paths so ALL writers emit `NULL_FOR_NON_NULLABLE_KEY` for
|
|
827
|
+
* schema-known paths. Only schema-known paths are reported; nulls under
|
|
828
|
+
* unknown top-level keys or unknown roles (e.g. `agents.gamma`) carry no
|
|
829
|
+
* nullability info and are preserved-verbatim. Returns [] when the schema
|
|
830
|
+
* is unreadable. Never throws.
|
|
831
|
+
*/
|
|
832
|
+
export function findCallerNullViolations(caller) {
|
|
833
|
+
try {
|
|
834
|
+
if (!caller || typeof caller !== 'object' || Array.isArray(caller)) return [];
|
|
835
|
+
const schema = loadConfigSchema();
|
|
836
|
+
if (!schema || !schema.properties) return [];
|
|
837
|
+
const props = schema.properties;
|
|
838
|
+
const found = [];
|
|
839
|
+
const walk = (value, subschema, at) => {
|
|
840
|
+
if (value === null || value === undefined) {
|
|
841
|
+
if (subschema && typeof subschema === 'object' && !schemaAllowsNull(subschema)) {
|
|
842
|
+
found.push(at);
|
|
843
|
+
}
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
if (value && typeof value === 'object' && !Array.isArray(value) && subschema && typeof subschema === 'object') {
|
|
847
|
+
const subProps = subschema.properties || {};
|
|
848
|
+
const additional = subschema.additionalProperties;
|
|
849
|
+
for (const [k, child] of Object.entries(value)) {
|
|
850
|
+
if (Object.prototype.hasOwnProperty.call(subProps, k)) {
|
|
851
|
+
walk(child, subProps[k], at ? `${at}.${k}` : String(k));
|
|
852
|
+
} else if (additional && typeof additional === 'object') {
|
|
853
|
+
walk(child, additional, at ? `${at}.${k}` : String(k));
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
if (Array.isArray(value) && subschema && typeof subschema === 'object') {
|
|
859
|
+
const items = subschema.items;
|
|
860
|
+
if (items && typeof items === 'object') {
|
|
861
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
862
|
+
walk(value[i], items, `${at}[${i}]`);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
};
|
|
867
|
+
for (const [key, value] of Object.entries(caller)) {
|
|
868
|
+
if (!Object.prototype.hasOwnProperty.call(props, key)) continue;
|
|
869
|
+
if (value === null || value === undefined) {
|
|
870
|
+
if (!schemaAllowsNull(props[key])) found.push(String(key));
|
|
871
|
+
} else if (value && typeof value === 'object') {
|
|
872
|
+
walk(value, props[key], String(key));
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
return [...new Set(found)].sort();
|
|
393
876
|
} catch {
|
|
394
877
|
return [];
|
|
395
878
|
}
|
|
@@ -540,13 +1023,61 @@ export function getLockPath(baseDir = DEFAULT_RESEARCH_DIR) {
|
|
|
540
1023
|
return path.join(String(baseDir || DEFAULT_RESEARCH_DIR), CONFIG_LOCK_NAME);
|
|
541
1024
|
}
|
|
542
1025
|
|
|
543
|
-
|
|
1026
|
+
/** Cap for any single `config.json` read (mirrors the Python `CONFIG_MAX_BYTES`). */
|
|
1027
|
+
export const CONFIG_MAX_BYTES = 1024 * 1024;
|
|
1028
|
+
|
|
1029
|
+
function readBytes(file, maxBytes = CONFIG_MAX_BYTES) {
|
|
1030
|
+
// Phase-08 R2 (W17): a FIFO named `config.json` hung every reader on a
|
|
1031
|
+
// plain `readFileSync`. Port of the `runner/preflight.py`
|
|
1032
|
+
// `_read_bounded_text` semantics: refuse non-regular paths (`stat` +
|
|
1033
|
+
// post-open `fstat`, closing the stat->open race), open `O_NONBLOCK`, and
|
|
1034
|
+
// cap the read — so a pathological workspace entry (FIFO, device, socket,
|
|
1035
|
+
// directory, runaway file) can never block the TUI/plugin. Returns null
|
|
1036
|
+
// when the path is absent, unreadable, non-regular, or over the cap.
|
|
1037
|
+
// Symlinks are FOLLOWED (behavior-preserving: the pre-fix reader followed
|
|
1038
|
+
// them too); a symlink to a non-regular target is still refused.
|
|
544
1039
|
try {
|
|
545
|
-
if (!
|
|
546
|
-
return readFileSync(file);
|
|
1040
|
+
if (!statSync(file).isFile()) return null;
|
|
547
1041
|
} catch {
|
|
548
1042
|
return null;
|
|
549
1043
|
}
|
|
1044
|
+
let fd = null;
|
|
1045
|
+
try {
|
|
1046
|
+
fd = openSync(file, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK);
|
|
1047
|
+
} catch {
|
|
1048
|
+
return null;
|
|
1049
|
+
}
|
|
1050
|
+
try {
|
|
1051
|
+
try {
|
|
1052
|
+
if (!fstatSync(fd).isFile()) return null;
|
|
1053
|
+
} catch {
|
|
1054
|
+
return null;
|
|
1055
|
+
}
|
|
1056
|
+
const chunks = [];
|
|
1057
|
+
let remaining = maxBytes + 1;
|
|
1058
|
+
const buf = Buffer.alloc(Math.min(remaining, 65536));
|
|
1059
|
+
for (;;) {
|
|
1060
|
+
let n;
|
|
1061
|
+
try {
|
|
1062
|
+
n = readSync(fd, buf, 0, Math.min(remaining, buf.length), null);
|
|
1063
|
+
} catch {
|
|
1064
|
+
return null;
|
|
1065
|
+
}
|
|
1066
|
+
if (!n) break;
|
|
1067
|
+
chunks.push(buf.subarray(0, n).toString('binary'));
|
|
1068
|
+
remaining -= n;
|
|
1069
|
+
if (remaining <= 0) break;
|
|
1070
|
+
}
|
|
1071
|
+
const data = Buffer.from(chunks.join(''), 'binary');
|
|
1072
|
+
if (data.length > maxBytes) return null;
|
|
1073
|
+
return data;
|
|
1074
|
+
} finally {
|
|
1075
|
+
try {
|
|
1076
|
+
if (fd !== null) closeSync(fd);
|
|
1077
|
+
} catch {
|
|
1078
|
+
/* close is best-effort */
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
550
1081
|
}
|
|
551
1082
|
|
|
552
1083
|
/** SHA-256 of the config file's exact bytes, or null when absent. */
|
|
@@ -873,15 +1404,23 @@ export function normalizeExpectedHash(expectedHash) {
|
|
|
873
1404
|
|
|
874
1405
|
/**
|
|
875
1406
|
* Resolve the guard from `expected_hash`/`expectedHash` spellings. Both
|
|
876
|
-
* present must agree
|
|
877
|
-
*
|
|
1407
|
+
* present must agree AFTER normalization (strip + lowercase, mirroring
|
|
1408
|
+
* normalizeExpectedHash — Phase-05 H2) or a CONFLICTING_EXPECTED_HASH
|
|
1409
|
+
* ConfigError is raised and nothing is written. Case/whitespace-only
|
|
1410
|
+
* differences are one guard; empty/whitespace-only spellings count as absent
|
|
1411
|
+
* on both sides (R3: "" never conflicts); the winner is validated by
|
|
1412
|
+
* normalizeExpectedHash below (malformed -> ConfigHashError).
|
|
878
1413
|
*/
|
|
879
1414
|
export function resolveExpectedHash(opts = {}) {
|
|
880
1415
|
const snake = opts ? opts.expected_hash : undefined;
|
|
881
1416
|
const camel = opts ? opts.expectedHash : undefined;
|
|
882
|
-
const hasSnake = snake !== null && snake !== undefined;
|
|
883
|
-
const hasCamel = camel !== null && camel !== undefined;
|
|
884
|
-
if (
|
|
1417
|
+
const hasSnake = snake !== null && snake !== undefined && String(snake).trim() !== '';
|
|
1418
|
+
const hasCamel = camel !== null && camel !== undefined && String(camel).trim() !== '';
|
|
1419
|
+
if (
|
|
1420
|
+
hasSnake &&
|
|
1421
|
+
hasCamel &&
|
|
1422
|
+
String(snake).trim().toLowerCase() !== String(camel).trim().toLowerCase()
|
|
1423
|
+
) {
|
|
885
1424
|
throw new ConfigError(
|
|
886
1425
|
'expected_hash and expectedHash were both provided with different ' +
|
|
887
1426
|
'values; provide one spelling (or two equal values).',
|
|
@@ -993,10 +1532,15 @@ let tempCounter = 0;
|
|
|
993
1532
|
* Atomically persist cfg into `<baseDir>/config.json`.
|
|
994
1533
|
*
|
|
995
1534
|
* - Merges over whatever is on disk (unknown keys survive); cfg wins.
|
|
1535
|
+
* - Caller-supplied null for a schema-known non-nullable key (any depth)
|
|
1536
|
+
* throws ConfigNullError (NULL_FOR_NON_NULLABLE_KEY) with dotted paths
|
|
1537
|
+
* before anything is written — the same code as the Python writer; nulls
|
|
1538
|
+
* under unknown keys/roles stay preserved-verbatim for generic validation.
|
|
996
1539
|
* - Validates the merged dict (disable only with validate:false when the
|
|
997
|
-
* exact dict was already validated); failures raise
|
|
1540
|
+
* exact dict was already validated); other failures raise
|
|
998
1541
|
* ConfigValidationError and write nothing.
|
|
999
|
-
* - Honors the expected-hash guard (both spellings
|
|
1542
|
+
* - Honors the expected-hash guard (both spellings; empty/whitespace-only
|
|
1543
|
+
* spellings count as absent); mismatches raise
|
|
1000
1544
|
* ConfigStaleError with a fresh snapshot (no absolute path, mirroring the
|
|
1001
1545
|
* Python R5 rule) and write nothing.
|
|
1002
1546
|
* - Publishes via a unique temp file + rename; `injectFailure:
|
|
@@ -1009,6 +1553,10 @@ export function saveConfig(cfg, baseDir = DEFAULT_RESEARCH_DIR, opts = {}) {
|
|
|
1009
1553
|
`<root>: expected object, got ${typeName(cfg)} (config must be a JSON object)`,
|
|
1010
1554
|
]);
|
|
1011
1555
|
}
|
|
1556
|
+
const nullPaths = findCallerNullViolations(cfg);
|
|
1557
|
+
if (nullPaths.length > 0) {
|
|
1558
|
+
throw new ConfigNullError(nullPaths);
|
|
1559
|
+
}
|
|
1012
1560
|
const expected = resolveExpectedHash(opts || {});
|
|
1013
1561
|
const dir = String(baseDir || DEFAULT_RESEARCH_DIR);
|
|
1014
1562
|
mkdirSync(dir, { recursive: true });
|
|
@@ -1017,6 +1565,25 @@ export function saveConfig(cfg, baseDir = DEFAULT_RESEARCH_DIR, opts = {}) {
|
|
|
1017
1565
|
let tmp = null;
|
|
1018
1566
|
try {
|
|
1019
1567
|
const existing = readBytes(cfgFile);
|
|
1568
|
+
let lexists = false;
|
|
1569
|
+
try {
|
|
1570
|
+
lstatSync(cfgFile);
|
|
1571
|
+
lexists = true;
|
|
1572
|
+
} catch {
|
|
1573
|
+
lexists = false;
|
|
1574
|
+
}
|
|
1575
|
+
if (existing === null && lexists) {
|
|
1576
|
+
// Phase-08 R2 (W17): fail closed (nothing written) rather than hang
|
|
1577
|
+
// on a FIFO/special device or silently merge over an
|
|
1578
|
+
// unreadable/oversize file — mirrors the Python save_config guard
|
|
1579
|
+
// (which also refuses non-file entries such as directories and
|
|
1580
|
+
// dangling symlinks instead of replacing them via os.replace).
|
|
1581
|
+
throw new ConfigUnreadableError(
|
|
1582
|
+
`${cfgFile} exists but is not a readable, bounded regular file ` +
|
|
1583
|
+
'(FIFO, device, directory, dangling symlink, or over the read cap); ' +
|
|
1584
|
+
'refusing to merge over it.'
|
|
1585
|
+
);
|
|
1586
|
+
}
|
|
1020
1587
|
const currentHash = existing
|
|
1021
1588
|
? createHash('sha256').update(existing).digest('hex')
|
|
1022
1589
|
: null;
|