@davidorex/pi-context 0.30.0 → 0.31.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/CHANGELOG.md +69 -0
- package/README.md +21 -6
- package/dist/block-api.d.ts +13 -0
- package/dist/block-api.d.ts.map +1 -1
- package/dist/block-api.js +1 -1
- package/dist/block-api.js.map +1 -1
- package/dist/content-hash.d.ts +13 -0
- package/dist/content-hash.d.ts.map +1 -1
- package/dist/content-hash.js +16 -0
- package/dist/content-hash.js.map +1 -1
- package/dist/context-dir.d.ts +12 -0
- package/dist/context-dir.d.ts.map +1 -1
- package/dist/context-dir.js +14 -0
- package/dist/context-dir.js.map +1 -1
- package/dist/context.d.ts +60 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +44 -0
- package/dist/context.js.map +1 -1
- package/dist/index.d.ts +534 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1879 -20
- package/dist/index.js.map +1 -1
- package/dist/migration-registry-loader.d.ts +16 -0
- package/dist/migration-registry-loader.d.ts.map +1 -1
- package/dist/migration-registry-loader.js +33 -0
- package/dist/migration-registry-loader.js.map +1 -1
- package/dist/ops-registry.d.ts +16 -0
- package/dist/ops-registry.d.ts.map +1 -1
- package/dist/ops-registry.js +270 -8
- package/dist/ops-registry.js.map +1 -1
- package/dist/pending-blocked-store.d.ts +83 -0
- package/dist/pending-blocked-store.d.ts.map +1 -0
- package/dist/pending-blocked-store.js +93 -0
- package/dist/pending-blocked-store.js.map +1 -0
- package/dist/schema-merge.d.ts +26 -0
- package/dist/schema-merge.d.ts.map +1 -0
- package/dist/schema-merge.js +176 -0
- package/dist/schema-merge.js.map +1 -0
- package/package.json +2 -1
- package/samples/conception.json +50 -0
- package/samples/schemas/framework-gaps.schema.json +1 -1
- package/samples/schemas/issues.schema.json +2 -2
- package/samples/schemas/layer-plans.schema.json +2 -2
- package/samples/schemas/research.schema.json +1 -1
- package/samples/schemas/work-orders.schema.json +2 -2
- package/schemas/config.schema.json +25 -1
- package/schemas/pending-blocked.schema.json +190 -0
- package/skill-narrative.md +7 -5
- package/skills/pi-context/SKILL.md +90 -7
- package/skills/pi-context/references/bundled-resources.md +2 -1
package/dist/index.js
CHANGED
|
@@ -6,15 +6,24 @@ import { execSync } from "node:child_process";
|
|
|
6
6
|
import fs from "node:fs";
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
|
-
import { readBlock } from "./block-api.js";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
9
|
+
import { forEachBlockArray, readBlock, readBlockForDir, writeBlockForDir } from "./block-api.js";
|
|
10
|
+
import { computeContentHash, computeFileContentHash } from "./content-hash.js";
|
|
11
|
+
import { adoptConception, installedBlockDestPath, installedSchemaDestPath, loadConfig, loadContext, mergeCatalogRegistries, reconcileActiveSubstrateRegistration, writeConfig, writeSkeletonConfig, } from "./context.js";
|
|
12
|
+
import { BootstrapNotFoundError, flipBootstrapPointer, migrationsPathForDir, resolveContextDir, SCHEMAS_DIR, schemasDir, tryResolveContextDir, writeBootstrapPointer, } from "./context-dir.js";
|
|
12
13
|
import { contextState, findAppendableBlocks, validateContext } from "./context-sdk.js";
|
|
13
14
|
import { cleanGitEnv } from "./git-env.js";
|
|
14
15
|
import { buildCurationSuggestions, loadLensView, renderLensView } from "./lens-view.js";
|
|
16
|
+
import { buildFreshRegistryWithChain, getProjectMigrationRegistryForDir, invalidateMigrationRegistryForDir, } from "./migration-registry-loader.js";
|
|
17
|
+
import { appendMigrationDeclForDir, loadMigrationsFileForDir } from "./migrations-store.js";
|
|
18
|
+
import { getObject, putObject } from "./object-store.js";
|
|
15
19
|
import { registerAll } from "./ops-registry.js";
|
|
16
20
|
import { buildOrientationBlock, skillsDir } from "./orientation.js";
|
|
21
|
+
import { loadPendingBlockedForDir, reconcilePendingBlockedForDir, } from "./pending-blocked-store.js";
|
|
17
22
|
import { listRoadmaps, loadRoadmap, renderRoadmap, validateRoadmaps } from "./roadmap-plan.js";
|
|
23
|
+
import { mergeSchema } from "./schema-merge.js";
|
|
24
|
+
import { runMigrations } from "./schema-migrations.js";
|
|
25
|
+
import { ValidationError, validate, validateBlockWithMigrationForDir } from "./schema-validator.js";
|
|
26
|
+
import { writeSchemaCheckedForDir } from "./schema-write.js";
|
|
18
27
|
import { checkForUpdates } from "./update-check.js";
|
|
19
28
|
// ── Command handlers ────────────────────────────────────────────────────────
|
|
20
29
|
/**
|
|
@@ -262,6 +271,744 @@ export function initProject(cwd, contextDir) {
|
|
|
262
271
|
(skeleton.written ? created : skipped).push(configRel);
|
|
263
272
|
return { created, skipped };
|
|
264
273
|
}
|
|
274
|
+
/**
|
|
275
|
+
* Resolve the failing block item's `id` from an AJV `instancePath` (TASK-048).
|
|
276
|
+
* The AJV pointer for a block-item error is `/<arrayKey>/<index>/<field>…`; this
|
|
277
|
+
* matches the leading `/<arrayKey>/<index>` segment, resolves
|
|
278
|
+
* `blockData[arrayKey][index]`, and returns its `id` when that is a string.
|
|
279
|
+
* Returns undefined when the path is envelope-level (no `/<arrayKey>/<index>`
|
|
280
|
+
* prefix), the indexed item is absent, or its `id` is not a string.
|
|
281
|
+
*/
|
|
282
|
+
function itemIdForPath(blockData, instancePath) {
|
|
283
|
+
const m = /^\/([^/]+)\/(\d+)/.exec(instancePath);
|
|
284
|
+
if (!m)
|
|
285
|
+
return undefined;
|
|
286
|
+
const [, arrayKey, indexStr] = m;
|
|
287
|
+
if (!blockData || typeof blockData !== "object")
|
|
288
|
+
return undefined;
|
|
289
|
+
const arr = blockData[arrayKey];
|
|
290
|
+
if (!Array.isArray(arr))
|
|
291
|
+
return undefined;
|
|
292
|
+
const item = arr[Number(indexStr)];
|
|
293
|
+
if (!item || typeof item !== "object")
|
|
294
|
+
return undefined;
|
|
295
|
+
const id = item.id;
|
|
296
|
+
return typeof id === "string" ? id : undefined;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Map AJV `ErrorObject[]` to the minimal {@link BlockValidationFailure} shape,
|
|
300
|
+
* resolving each error's failing item `id` from `blockData` via {@link
|
|
301
|
+
* itemIdForPath}. `message` is the AJV message (`""` when absent).
|
|
302
|
+
*/
|
|
303
|
+
function mapValidationFailures(errors, blockData) {
|
|
304
|
+
return errors.map((e) => ({
|
|
305
|
+
itemId: itemIdForPath(blockData, e.instancePath),
|
|
306
|
+
instancePath: e.instancePath,
|
|
307
|
+
keyword: e.keyword,
|
|
308
|
+
message: e.message ?? "",
|
|
309
|
+
}));
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* TASK-052 / FGAP-081: locate the 0-based source-text line index of the field a
|
|
313
|
+
* `validation-failed` failure's `instancePath` points at, within the pretty-printed
|
|
314
|
+
* block-file lines. The AJV pointer is `/<arrayKey>/<index>/<field>…`; this finds the
|
|
315
|
+
* `"<arrayKey>":` line, then walks forward counting STRUCTURAL `{`/`[`/`}`/`]` (a
|
|
316
|
+
* per-line lexer skips JSON string literals — quote-to-quote with backslash escapes —
|
|
317
|
+
* so braces/brackets inside string VALUES are never miscounted) to reach the index-th
|
|
318
|
+
* item object, then finds the `"<field>":` line within it. Returns null when the path
|
|
319
|
+
* is envelope-level (no `/<arrayKey>/<index>` prefix) or the line cannot be located —
|
|
320
|
+
* the caller then places a header marker block at the TOP of the file. Line-granular
|
|
321
|
+
* only (no column); the marker write is always a full-line sentinel above/below.
|
|
322
|
+
*/
|
|
323
|
+
function locateFailureLine(lines, instancePath) {
|
|
324
|
+
const m = /^\/([^/]+)\/(\d+)(?:\/(.+))?$/.exec(instancePath);
|
|
325
|
+
if (!m)
|
|
326
|
+
return null;
|
|
327
|
+
const [, arrayKey, indexStr, fieldTail] = m;
|
|
328
|
+
const index = Number(indexStr);
|
|
329
|
+
const arrayKeyRe = new RegExp(`^\\s*"${arrayKey.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"\\s*:`);
|
|
330
|
+
let arrLine = -1;
|
|
331
|
+
for (let i = 0; i < lines.length; i++) {
|
|
332
|
+
if (arrayKeyRe.test(lines[i])) {
|
|
333
|
+
arrLine = i;
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
if (arrLine === -1)
|
|
338
|
+
return null;
|
|
339
|
+
let depth = 0;
|
|
340
|
+
let inArray = false;
|
|
341
|
+
let itemCount = -1;
|
|
342
|
+
let itemStart = -1;
|
|
343
|
+
for (let i = arrLine; i < lines.length; i++) {
|
|
344
|
+
const line = lines[i];
|
|
345
|
+
// Per-line lexer: count structural brackets only OUTSIDE string literals so a
|
|
346
|
+
// brace/bracket inside a string VALUE never shifts the depth (the string-aware
|
|
347
|
+
// fix — a naive char scan would mis-place the sentinel).
|
|
348
|
+
let inString = false;
|
|
349
|
+
let escaped = false;
|
|
350
|
+
for (let c = 0; c < line.length; c++) {
|
|
351
|
+
const ch = line[c];
|
|
352
|
+
if (inString) {
|
|
353
|
+
if (escaped) {
|
|
354
|
+
escaped = false;
|
|
355
|
+
}
|
|
356
|
+
else if (ch === "\\") {
|
|
357
|
+
escaped = true;
|
|
358
|
+
}
|
|
359
|
+
else if (ch === '"') {
|
|
360
|
+
inString = false;
|
|
361
|
+
}
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
if (ch === '"') {
|
|
365
|
+
inString = true;
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
if (ch === "[") {
|
|
369
|
+
if (!inArray)
|
|
370
|
+
inArray = true;
|
|
371
|
+
}
|
|
372
|
+
else if (ch === "]") {
|
|
373
|
+
if (inArray && depth === 0)
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
else if (ch === "{") {
|
|
377
|
+
if (inArray && depth === 0) {
|
|
378
|
+
itemCount++;
|
|
379
|
+
if (itemCount === index)
|
|
380
|
+
itemStart = i;
|
|
381
|
+
}
|
|
382
|
+
depth++;
|
|
383
|
+
}
|
|
384
|
+
else if (ch === "}") {
|
|
385
|
+
depth--;
|
|
386
|
+
if (inArray && depth === 0 && itemCount === index) {
|
|
387
|
+
if (!fieldTail)
|
|
388
|
+
return itemStart >= 0 ? itemStart : null;
|
|
389
|
+
const fieldKey = fieldTail.split("/")[0];
|
|
390
|
+
const fieldRe = new RegExp(`^\\s*"${fieldKey.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"\\s*:`);
|
|
391
|
+
for (let j = itemStart; j <= i; j++) {
|
|
392
|
+
if (fieldRe.test(lines[j]))
|
|
393
|
+
return j;
|
|
394
|
+
}
|
|
395
|
+
return itemStart >= 0 ? itemStart : null;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* The git-style conflict sentinel contract — ONE source of truth shared by the
|
|
404
|
+
* marker WRITER (`composeMarkerText` builds its open/close lines from these
|
|
405
|
+
* tokens) and the marker DETECTORS (every blocked-marker scan derives its regex
|
|
406
|
+
* from `MARKER_LINE_RE`). The seven-character `<<<<<<<` / `>>>>>>>` literals are
|
|
407
|
+
* intentionally confined to these two token constants so the writer and the
|
|
408
|
+
* detectors can never drift to different sentinels. `MARKER_LINE_RE` matches a
|
|
409
|
+
* line that STARTS with either token (no `m` flag — single-line tests);
|
|
410
|
+
* `MARKER_LINE_RE_MULTILINE` is its multiline twin (same source, `m` flag) for
|
|
411
|
+
* whole-file scans where the marker may be on any line.
|
|
412
|
+
*/
|
|
413
|
+
const MARKER_OPEN = "<<<<<<<";
|
|
414
|
+
const MARKER_CLOSE = ">>>>>>>";
|
|
415
|
+
const MARKER_LINE_RE = new RegExp(`^(${MARKER_OPEN}|${MARKER_CLOSE})`);
|
|
416
|
+
const MARKER_LINE_RE_MULTILINE = new RegExp(MARKER_LINE_RE.source, "m");
|
|
417
|
+
/**
|
|
418
|
+
* TASK-052 / FGAP-081: compose the marker-bearing block-file text for a
|
|
419
|
+
* validation-blocked resync. Given the raw pretty-printed block bytes and the
|
|
420
|
+
* per-item failures, inserts FULL-LINE git-style conflict sentinels around the
|
|
421
|
+
* offending line(s):
|
|
422
|
+
*
|
|
423
|
+
* `<<<<<<< BLOCKED <name> <from> -> <to> <instancePath> [<keyword>]: <message>`
|
|
424
|
+
* ...the offending line...
|
|
425
|
+
* `>>>>>>> target: <name>@<to>`
|
|
426
|
+
*
|
|
427
|
+
* The message reuses `describeBlockedFailure`'s keyword phrasing so the in-file text
|
|
428
|
+
* and the CLI `renderBlocked` report read identically. A failure whose line cannot be
|
|
429
|
+
* located (envelope-level path, or the field line is not found) gets a marker block at
|
|
430
|
+
* the TOP of the file. Sentinels are inserted bottom-up by descending line index so an
|
|
431
|
+
* earlier insertion does not shift a later one. The result is NOT valid JSON (by
|
|
432
|
+
* design — the markers make the block fail any JSON read until stripped by
|
|
433
|
+
* `resolveBlocked`).
|
|
434
|
+
*/
|
|
435
|
+
function composeMarkerText(rawBytes, name, from, to, failures) {
|
|
436
|
+
const fromTok = from ?? "?";
|
|
437
|
+
const toTok = to ?? "?";
|
|
438
|
+
const lines = rawBytes.split("\n");
|
|
439
|
+
const topMarkers = [];
|
|
440
|
+
const byLine = new Map();
|
|
441
|
+
for (const f of failures) {
|
|
442
|
+
const idx = locateFailureLine(lines, f.instancePath);
|
|
443
|
+
if (idx === null) {
|
|
444
|
+
topMarkers.push(f);
|
|
445
|
+
}
|
|
446
|
+
else {
|
|
447
|
+
const list = byLine.get(idx) ?? [];
|
|
448
|
+
list.push(f);
|
|
449
|
+
byLine.set(idx, list);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
const openFor = (f) => {
|
|
453
|
+
const kw = f.keyword && f.keyword !== "error" ? ` [${f.keyword}]` : "";
|
|
454
|
+
const ip = f.instancePath ? ` ${f.instancePath}` : "";
|
|
455
|
+
return `${MARKER_OPEN} BLOCKED ${name} ${fromTok} -> ${toTok}${ip}${kw}: ${describeBlockedFailure(f)}`;
|
|
456
|
+
};
|
|
457
|
+
const closeLine = `${MARKER_CLOSE} target: ${name}@${toTok}`;
|
|
458
|
+
const targetIdxs = [...byLine.keys()].sort((a, b) => b - a);
|
|
459
|
+
for (const idx of targetIdxs) {
|
|
460
|
+
const lineFailures = byLine.get(idx) ?? [];
|
|
461
|
+
const opens = lineFailures.map(openFor);
|
|
462
|
+
lines.splice(idx + 1, 0, closeLine);
|
|
463
|
+
lines.splice(idx, 0, ...opens);
|
|
464
|
+
}
|
|
465
|
+
if (topMarkers.length > 0) {
|
|
466
|
+
const header = [...topMarkers.map(openFor), closeLine];
|
|
467
|
+
lines.unshift(...header);
|
|
468
|
+
}
|
|
469
|
+
return lines.join("\n");
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Resolve the package samples catalog once: the absolute `samplesRoot` plus a
|
|
473
|
+
* `byId` map from each block_kind's `canonical_id` to its declared
|
|
474
|
+
* `schema_path` / `data_path` (relative to `samplesRoot`). Shared read helper
|
|
475
|
+
* extracted from `installContext` so the installer and the read-only
|
|
476
|
+
* `checkStatus` drift detector resolve the catalog identically (no divergence).
|
|
477
|
+
*
|
|
478
|
+
* lazy fileURLToPath idiom (FGAP-088): import.meta.dirname is undefined under
|
|
479
|
+
* tsx's CJS-interop dist-load; import.meta.url is not. Reads the conception once
|
|
480
|
+
* for the canonical_id→paths map so callers resolve sources by the same
|
|
481
|
+
* block_kind declarations the accept-all conception ships (DEC-0037/0038).
|
|
482
|
+
*/
|
|
483
|
+
function resolveCatalog() {
|
|
484
|
+
const samplesRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "samples");
|
|
485
|
+
const conception = JSON.parse(fs.readFileSync(path.join(samplesRoot, "conception.json"), "utf-8"));
|
|
486
|
+
const byId = new Map();
|
|
487
|
+
for (const bk of conception.block_kinds ?? []) {
|
|
488
|
+
byId.set(bk.canonical_id, { schema_path: bk.schema_path, data_path: bk.data_path });
|
|
489
|
+
}
|
|
490
|
+
return { samplesRoot, byId };
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Read a JSON file's own declared `version` field (the schema/block envelope
|
|
494
|
+
* `version`). Returns undefined when the file is absent, unreadable, not valid
|
|
495
|
+
* JSON, or carries no string `version`. Used by resyncSchema to compare the
|
|
496
|
+
* catalog vs installed schema versions without crashing on a corrupt file.
|
|
497
|
+
*/
|
|
498
|
+
function readDeclaredVersion(file) {
|
|
499
|
+
try {
|
|
500
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
501
|
+
return typeof parsed.version === "string" ? parsed.version : undefined;
|
|
502
|
+
}
|
|
503
|
+
catch {
|
|
504
|
+
return undefined;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Read the verbatim bundled catalog `*.schema.json` body for a named
|
|
509
|
+
* block_kind. Resolves the catalog via the shared `resolveCatalog()` (same
|
|
510
|
+
* `samplesRoot` + `canonical_id`→`schema_path` map the installer and the drift
|
|
511
|
+
* detector use), then reads the catalog schema file's raw bytes. The returned
|
|
512
|
+
* `text` is the unparsed source (raw JSON Schema — properties/definitions/$id),
|
|
513
|
+
* NOT the `read-samples-catalog` projection, so an operator can diff it locally
|
|
514
|
+
* against the installed `<substrate>/schemas/<name>.schema.json` without
|
|
515
|
+
* touching node_modules (STORY-010 / FGAP-079, TASK-050).
|
|
516
|
+
*
|
|
517
|
+
* Read-only and substrate-independent: it touches only the package's bundled
|
|
518
|
+
* `samplesRoot` and takes no cwd, so no installed schema, block, or config is
|
|
519
|
+
* reachable from this path. Throws on an unknown kind, matching the unknown-kind
|
|
520
|
+
* throw idiom in validateBlockItemsAgainstCatalog / resolveConflict.
|
|
521
|
+
*/
|
|
522
|
+
export function readCatalogSchemaText(kindName) {
|
|
523
|
+
const { samplesRoot, byId } = resolveCatalog();
|
|
524
|
+
const kind = byId.get(kindName);
|
|
525
|
+
if (!kind) {
|
|
526
|
+
throw new Error(`no catalog block_kind named '${kindName}'`);
|
|
527
|
+
}
|
|
528
|
+
const schemaPath = path.join(samplesRoot, kind.schema_path);
|
|
529
|
+
const text = fs.readFileSync(schemaPath, "utf-8");
|
|
530
|
+
return { kind: kindName, schemaPath, text };
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Walk the shipped catalog migration chain for `schemaName` from `fromVersion`
|
|
534
|
+
* to `toVersion`, returning the ordered MigrationDecl list (one per hop) when a
|
|
535
|
+
* complete chain exists, or `null` when no chain reaches `toVersion`.
|
|
536
|
+
*
|
|
537
|
+
* Chain semantics mirror the registry's one-outgoing-edge-per-(schemaName,
|
|
538
|
+
* fromVersion) discipline: at each step we look for the single decl whose
|
|
539
|
+
* (schemaName, fromVersion) matches the current cursor and advance the cursor to
|
|
540
|
+
* its `toVersion`. A cycle guard bounds the walk to the number of available
|
|
541
|
+
* decls so a malformed catalog cannot loop forever. The catalog migrations file
|
|
542
|
+
* is read from `samplesRoot/migrations.json` (the SAME catalog the schema source
|
|
543
|
+
* is copied from), so the migration declarations and the schema versions cannot
|
|
544
|
+
* drift apart.
|
|
545
|
+
*/
|
|
546
|
+
function findCatalogMigrationChain(samplesRoot, schemaName, fromVersion, toVersion) {
|
|
547
|
+
const catalogMigrationsPath = path.join(samplesRoot, "migrations.json");
|
|
548
|
+
let catalogDecls;
|
|
549
|
+
try {
|
|
550
|
+
const parsed = JSON.parse(fs.readFileSync(catalogMigrationsPath, "utf-8"));
|
|
551
|
+
catalogDecls = Array.isArray(parsed.migrations) ? parsed.migrations : [];
|
|
552
|
+
}
|
|
553
|
+
catch {
|
|
554
|
+
return null;
|
|
555
|
+
}
|
|
556
|
+
const chain = [];
|
|
557
|
+
let cursor = fromVersion;
|
|
558
|
+
const maxHops = catalogDecls.length;
|
|
559
|
+
for (let i = 0; i <= maxHops; i++) {
|
|
560
|
+
if (cursor === toVersion)
|
|
561
|
+
return chain;
|
|
562
|
+
const next = catalogDecls.find((d) => d.schemaName === schemaName && d.fromVersion === cursor);
|
|
563
|
+
if (!next)
|
|
564
|
+
return null;
|
|
565
|
+
chain.push(next);
|
|
566
|
+
cursor = next.toVersion;
|
|
567
|
+
}
|
|
568
|
+
// Exhausted maxHops without reaching toVersion → cyclic / non-terminating.
|
|
569
|
+
return null;
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* Read-only computation of the migration declarations a version-bump re-sync of
|
|
573
|
+
* `name` WOULD register into the substrate's migrations.json, WITHOUT writing
|
|
574
|
+
* anything (FGAP-050 — surfaced migration reporting). Mirrors the filter
|
|
575
|
+
* `resyncSchema`'s registration loop applies: read the installed vs catalog
|
|
576
|
+
* `version`, walk the shipped catalog chain, and subtract the decls whose
|
|
577
|
+
* `(schemaName, fromVersion)` pair is already present on disk. Returns the
|
|
578
|
+
* absent decls as the `{ schema, from, to }` report shape (empty array when the
|
|
579
|
+
* versions match, either version is unreadable, no chain reaches the catalog
|
|
580
|
+
* version, or every chain decl is already registered). Reads only — never
|
|
581
|
+
* appends, never overwrites the schema. Used by `updateContext`'s dryRun
|
|
582
|
+
* catalog-ahead arm to report the would-register set; the live path lets
|
|
583
|
+
* `resyncSchema` itself report the decls it actually appended.
|
|
584
|
+
*/
|
|
585
|
+
function computeWouldRegisterMigrations(destRoot, samplesRoot, sourceFile, destFile, name) {
|
|
586
|
+
const catalogVersion = readDeclaredVersion(sourceFile);
|
|
587
|
+
const installedVersion = readDeclaredVersion(destFile);
|
|
588
|
+
if (installedVersion === undefined || catalogVersion === undefined || installedVersion === catalogVersion) {
|
|
589
|
+
return [];
|
|
590
|
+
}
|
|
591
|
+
const chain = findCatalogMigrationChain(samplesRoot, name, installedVersion, catalogVersion);
|
|
592
|
+
if (chain === null)
|
|
593
|
+
return [];
|
|
594
|
+
const existing = loadMigrationsFileForDir(destRoot);
|
|
595
|
+
const present = new Set((existing?.migrations ?? []).map((m) => `${m.schemaName} ${m.fromVersion}`));
|
|
596
|
+
const out = [];
|
|
597
|
+
for (const decl of chain) {
|
|
598
|
+
const key = `${decl.schemaName} ${decl.fromVersion}`;
|
|
599
|
+
if (present.has(key))
|
|
600
|
+
continue;
|
|
601
|
+
out.push({ schema: decl.schemaName, from: decl.fromVersion, to: decl.toVersion });
|
|
602
|
+
present.add(key);
|
|
603
|
+
}
|
|
604
|
+
return out;
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* Pure-read simulation of `resyncSchema`'s outcome for ONE catalog-ahead schema
|
|
608
|
+
* (FGAP-066 / TASK-046 faithful dryRun). Predicts which of `resyncSchema`'s
|
|
609
|
+
* three terminal statuses (`resynced` / `migrated` / `blocked`) a live re-sync
|
|
610
|
+
* WOULD produce, by mirroring `resyncSchema`'s five decision arms 1:1 over an
|
|
611
|
+
* IN-MEMORY forward-migration + re-validation — WITHOUT writing any file and
|
|
612
|
+
* WITHOUT touching the project's cached migration registry. The aim is a dryRun
|
|
613
|
+
* plan whose per-schema bucket matches what `--update` (no dryRun) would land,
|
|
614
|
+
* so the preview no longer lists every catalog-ahead schema as `resynced`
|
|
615
|
+
* regardless of the true outcome.
|
|
616
|
+
*
|
|
617
|
+
* Arm mapping to `resyncSchema` (lines noted are that helper's, not this one's):
|
|
618
|
+
* 1. Same/either-undefined version → `resynced`, `wouldRegister: []`
|
|
619
|
+
* (mirrors the same-version verbatim-overwrite arm).
|
|
620
|
+
* 2. No catalog chain reaching the catalog version → `blocked`,
|
|
621
|
+
* `wouldRegister: []` (mirrors the no-chain refuse arm).
|
|
622
|
+
* 3. `wouldRegister` = the chain decls not already present in migrations.json
|
|
623
|
+
* (reuses `computeWouldRegisterMigrations`' dedup, shared by call).
|
|
624
|
+
* 4. Block file absent / zero items → `migrated`, `wouldRegister` (mirrors the
|
|
625
|
+
* no-items register-the-chain + advance arm). An UNREADABLE block file is
|
|
626
|
+
* treated as POPULATED (the same safety default `resyncSchema` applies),
|
|
627
|
+
* routing it through the validate path which throws → `blocked`.
|
|
628
|
+
* 5. Populated block → build a FRESH in-memory registry (the substrate's
|
|
629
|
+
* existing decls + the catalog chain's absent edges via `migrationFnFor`,
|
|
630
|
+
* deduped on (schemaName, fromVersion) since `register` throws on
|
|
631
|
+
* duplicates), then mirror `validateBlockWithMigrationForDir`'s keying in
|
|
632
|
+
* memory: when the block carries a string `schema_version` differing from
|
|
633
|
+
* the catalog version, `runMigrations(registry, name, blockVersion,
|
|
634
|
+
* catalogVersion, blockData)`; absent ⇒ validate as-is. Then
|
|
635
|
+
* `validate(catalogSchema, migrated, name)`. Pass → `migrated`; any throw
|
|
636
|
+
* (no path, migration throw, validation failure) → `blocked` with
|
|
637
|
+
* `wouldRegister: []` and a `detail` (TASK-048): `reason:"validation-failed"`,
|
|
638
|
+
* the version pair, and the per-item failures mapped from
|
|
639
|
+
* `ValidationError.errors` (a single synthetic failure for a non-AJV throw).
|
|
640
|
+
*
|
|
641
|
+
* `detail` carries the blocked diagnostic the dryRun arm pushes into
|
|
642
|
+
* `UpdateResult.blockedDetail`; the `blocked` arms still report `wouldRegister:
|
|
643
|
+
* []`, mirroring `resyncSchema`'s post-rollback truth that a refused re-sync
|
|
644
|
+
* leaves nothing registered. (Supersedes the prior unconsumed `errors` field.)
|
|
645
|
+
*/
|
|
646
|
+
function simulateResyncOutcome(destRoot, samplesRoot, sourceFile, destFile, name) {
|
|
647
|
+
const catalogVersion = readDeclaredVersion(sourceFile);
|
|
648
|
+
const installedVersion = readDeclaredVersion(destFile);
|
|
649
|
+
// Arm 1 — same version (or either unreadable / non-versioned): no transition
|
|
650
|
+
// to migrate across, the live path overwrites verbatim → resynced, no decls.
|
|
651
|
+
if (installedVersion === catalogVersion || catalogVersion === undefined || installedVersion === undefined) {
|
|
652
|
+
return { outcome: "resynced", wouldRegister: [] };
|
|
653
|
+
}
|
|
654
|
+
// Arm 2 — version bump with NO shipped chain reaching the catalog version: the
|
|
655
|
+
// live path refuses, leaving everything unchanged → blocked, no decls. The
|
|
656
|
+
// blocked detail records the no-chain reason + the version pair (TASK-048).
|
|
657
|
+
const chain = findCatalogMigrationChain(samplesRoot, name, installedVersion, catalogVersion);
|
|
658
|
+
if (chain === null) {
|
|
659
|
+
return {
|
|
660
|
+
outcome: "blocked",
|
|
661
|
+
wouldRegister: [],
|
|
662
|
+
detail: { reason: "no-migration-chain", from: installedVersion, to: catalogVersion },
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
// Arm 3 — the decls a live resync WOULD register (chain minus already-present),
|
|
666
|
+
// reusing the same dedup the live path applies.
|
|
667
|
+
const wouldRegister = computeWouldRegisterMigrations(destRoot, samplesRoot, sourceFile, destFile, name);
|
|
668
|
+
// Arm 4 — load the block exactly as the live path does (same dest path; missing
|
|
669
|
+
// file ⇒ no items; unreadable ⇒ treat populated AND route to the validate path).
|
|
670
|
+
const blockFile = installedBlockDestPath(destRoot, name);
|
|
671
|
+
let blockData;
|
|
672
|
+
let hasItems = false;
|
|
673
|
+
if (fs.existsSync(blockFile)) {
|
|
674
|
+
try {
|
|
675
|
+
blockData = JSON.parse(fs.readFileSync(blockFile, "utf-8"));
|
|
676
|
+
forEachBlockArray(blockData, (_arrayKey, arr) => {
|
|
677
|
+
if (arr.length > 0)
|
|
678
|
+
hasItems = true;
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
catch {
|
|
682
|
+
// Unreadable block — POPULATED safety default; the validate attempt below
|
|
683
|
+
// will throw on the undefined blockData, predicting the live blocked path.
|
|
684
|
+
hasItems = true;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
if (!hasItems) {
|
|
688
|
+
// No items to migrate — the live path overwrites + registers the chain and
|
|
689
|
+
// reports migrated. Mirror that bucket; the decls would register.
|
|
690
|
+
return { outcome: "migrated", wouldRegister };
|
|
691
|
+
}
|
|
692
|
+
// Arm 5 — populated block: simulate the forward-migrate + re-validate IN MEMORY
|
|
693
|
+
// against a FRESH registry (never the project's cached registry, which a dryRun
|
|
694
|
+
// must not warm) and the catalog schema object read off disk.
|
|
695
|
+
try {
|
|
696
|
+
// Build the registry the live path would resolve against: the substrate's
|
|
697
|
+
// existing decls plus the catalog chain's absent edges, deduped on
|
|
698
|
+
// (schemaName, fromVersion). FRESH, never the project's cached registry.
|
|
699
|
+
const registry = buildFreshRegistryWithChain(destRoot, chain);
|
|
700
|
+
const catalogSchema = JSON.parse(fs.readFileSync(sourceFile, "utf-8"));
|
|
701
|
+
// Mirror validateBlockWithMigrationForDir's keying IN MEMORY: fromVersion =
|
|
702
|
+
// the block's declared schema_version (when a string), toVersion = the
|
|
703
|
+
// catalog schema's version. Absent / non-string envelope ⇒ validate as-is.
|
|
704
|
+
const blockVersion = blockData && typeof blockData === "object" && "schema_version" in blockData
|
|
705
|
+
? blockData.schema_version
|
|
706
|
+
: undefined;
|
|
707
|
+
let toValidate = blockData;
|
|
708
|
+
if (typeof blockVersion === "string" && blockVersion !== catalogVersion) {
|
|
709
|
+
toValidate = runMigrations(registry, name, blockVersion, catalogVersion, blockData);
|
|
710
|
+
}
|
|
711
|
+
validate(catalogSchema, toValidate, name);
|
|
712
|
+
return { outcome: "migrated", wouldRegister };
|
|
713
|
+
}
|
|
714
|
+
catch (err) {
|
|
715
|
+
// Migrated items would NOT validate against the catalog schema (or the
|
|
716
|
+
// migration walk threw) → the live path refuses → blocked. Post-rollback
|
|
717
|
+
// truth: nothing registers, so report no decls. The blocked detail records
|
|
718
|
+
// the validation-failed reason + the version pair + (when the throw is an AJV
|
|
719
|
+
// ValidationError) the per-item failures mapped against the loaded block data;
|
|
720
|
+
// a non-AJV throw becomes a single synthetic failure (TASK-048).
|
|
721
|
+
const failures = err instanceof ValidationError
|
|
722
|
+
? mapValidationFailures(err.errors, blockData)
|
|
723
|
+
: [{ instancePath: "", keyword: "error", message: String(err) }];
|
|
724
|
+
return {
|
|
725
|
+
outcome: "blocked",
|
|
726
|
+
wouldRegister: [],
|
|
727
|
+
detail: { reason: "validation-failed", from: installedVersion, to: catalogVersion, failures },
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* Validate ONE installed block's items against the CATALOG schema version,
|
|
733
|
+
* read-only (TASK-048 — FGAP-077). The standalone diagnostic underneath the
|
|
734
|
+
* `validate-block-items` op: it answers "would these items pass the catalog
|
|
735
|
+
* schema (after the shipped forward-migration, when the block lags the catalog
|
|
736
|
+
* version)?" WITHOUT writing anything — no schema overwrite, no block re-write, no
|
|
737
|
+
* migration registration.
|
|
738
|
+
*
|
|
739
|
+
* Resolution mirrors the catalog-ahead resync path so the diagnostic predicts the
|
|
740
|
+
* same pass/fail `resyncSchema` would reach:
|
|
741
|
+
* - resolve the block_kind via `resolveCatalog().byId` (an unknown block throws
|
|
742
|
+
* a field-named Error); read the catalog schema body off `samplesRoot`.
|
|
743
|
+
* - load the installed block via `installedBlockDestPath` (a missing block file
|
|
744
|
+
* throws field-named).
|
|
745
|
+
* - when the block's declared envelope `schema_version` is a string differing
|
|
746
|
+
* from the catalog `version` AND a shipped chain reaches the catalog version,
|
|
747
|
+
* forward-migrate the block IN MEMORY through a FRESH registry seeded from the
|
|
748
|
+
* substrate's existing decls + the chain's absent edges (deduped on
|
|
749
|
+
* (schemaName, fromVersion)); otherwise validate as-is. No registry warming.
|
|
750
|
+
* - `validate(catalogSchema, data, blockName)` in try/catch → pass:
|
|
751
|
+
* `{valid:true, failures:[]}`; ValidationError → `{valid:false, failures}`
|
|
752
|
+
* mapped against the (migrated) data; any other throw → a single synthetic
|
|
753
|
+
* `{instancePath:"", keyword:"error", message:String(err)}` failure.
|
|
754
|
+
*
|
|
755
|
+
* Returns `{ block, from?, to?, valid, failures }`: `from`/`to` are the block's
|
|
756
|
+
* declared version and the catalog version (each undefined when unreadable).
|
|
757
|
+
* NEVER writes.
|
|
758
|
+
*/
|
|
759
|
+
export function validateBlockItemsAgainstCatalog(cwd, blockName) {
|
|
760
|
+
const destRoot = tryResolveContextDir(cwd);
|
|
761
|
+
if (destRoot === null) {
|
|
762
|
+
throw new Error("No .pi-context.json bootstrap pointer found. Run /context init <substrate-dir> first to bootstrap the substrate.");
|
|
763
|
+
}
|
|
764
|
+
const { samplesRoot, byId } = resolveCatalog();
|
|
765
|
+
const kind = byId.get(blockName);
|
|
766
|
+
if (!kind) {
|
|
767
|
+
throw new Error(`block: '${blockName}' is not a known catalog block_kind (no canonical_id matches)`);
|
|
768
|
+
}
|
|
769
|
+
const sourceFile = path.join(samplesRoot, kind.schema_path);
|
|
770
|
+
const blockFile = installedBlockDestPath(destRoot, blockName);
|
|
771
|
+
if (!fs.existsSync(blockFile)) {
|
|
772
|
+
throw new Error(`block: installed block file not found for '${blockName}' at ${blockFile}`);
|
|
773
|
+
}
|
|
774
|
+
const catalogVersion = readDeclaredVersion(sourceFile);
|
|
775
|
+
let blockData;
|
|
776
|
+
try {
|
|
777
|
+
blockData = JSON.parse(fs.readFileSync(blockFile, "utf-8"));
|
|
778
|
+
}
|
|
779
|
+
catch (err) {
|
|
780
|
+
return {
|
|
781
|
+
block: blockName,
|
|
782
|
+
to: catalogVersion,
|
|
783
|
+
valid: false,
|
|
784
|
+
failures: [{ instancePath: "", keyword: "error", message: String(err) }],
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
const blockVersion = blockData && typeof blockData === "object" && "schema_version" in blockData
|
|
788
|
+
? blockData.schema_version
|
|
789
|
+
: undefined;
|
|
790
|
+
const fromVersion = typeof blockVersion === "string" ? blockVersion : undefined;
|
|
791
|
+
try {
|
|
792
|
+
const catalogSchema = JSON.parse(fs.readFileSync(sourceFile, "utf-8"));
|
|
793
|
+
let toValidate = blockData;
|
|
794
|
+
if (typeof blockVersion === "string" && catalogVersion !== undefined && blockVersion !== catalogVersion) {
|
|
795
|
+
const chain = findCatalogMigrationChain(samplesRoot, blockName, blockVersion, catalogVersion);
|
|
796
|
+
if (chain !== null) {
|
|
797
|
+
// FRESH registry seeded from the substrate's existing decls + the chain's
|
|
798
|
+
// absent edges, deduped on (schemaName, fromVersion) — never warm the
|
|
799
|
+
// project's cached registry from a read-only diagnostic.
|
|
800
|
+
const registry = buildFreshRegistryWithChain(destRoot, chain);
|
|
801
|
+
toValidate = runMigrations(registry, blockName, blockVersion, catalogVersion, blockData);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
validate(catalogSchema, toValidate, blockName);
|
|
805
|
+
return { block: blockName, from: fromVersion, to: catalogVersion, valid: true, failures: [] };
|
|
806
|
+
}
|
|
807
|
+
catch (err) {
|
|
808
|
+
const failures = err instanceof ValidationError
|
|
809
|
+
? mapValidationFailures(err.errors, blockData)
|
|
810
|
+
: [{ instancePath: "", keyword: "error", message: String(err) }];
|
|
811
|
+
return { block: blockName, from: fromVersion, to: catalogVersion, valid: false, failures };
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* Migration-aware re-sync of ONE installed schema under `/context install
|
|
816
|
+
* --update` (FGAP-029 safe re-sync, slice S4). Replaces the blind
|
|
817
|
+
* `fs.copyFileSync` the schema loop used to perform with a forward-migrate-or-
|
|
818
|
+
* refuse decision so a catalog schema version bump never strands the block's
|
|
819
|
+
* already-filed items under a schema they no longer satisfy.
|
|
820
|
+
*
|
|
821
|
+
* Precondition: the dest schema file EXISTS (the caller routes fresh installs
|
|
822
|
+
* straight to copyFileSync). Returns one of:
|
|
823
|
+
* - `"resynced"`: same installed/catalog `version` (description-only drift, or
|
|
824
|
+
* non-versioned schemas) → safe verbatim overwrite; OR a version bump whose
|
|
825
|
+
* block file is absent / holds zero items → no items to migrate, overwrite +
|
|
826
|
+
* register the chain.
|
|
827
|
+
* - `"migrated"`: a version bump with a populated block whose items
|
|
828
|
+
* forward-migrated through the shipped chain AND re-validated against the new
|
|
829
|
+
* schema → block re-written via the migration path.
|
|
830
|
+
* - `"blocked"`: a version bump with NO shipped chain reaching the catalog
|
|
831
|
+
* version, OR migrated items that would FAIL the new schema. The schema file,
|
|
832
|
+
* the block file, AND migrations.json are all left BYTE-UNCHANGED.
|
|
833
|
+
*
|
|
834
|
+
* Byte-unchanged guarantee for `"blocked"`: this helper captures the original
|
|
835
|
+
* schema bytes BEFORE any overwrite, and the original migrations.json bytes
|
|
836
|
+
* BEFORE the first decl append. For the version-bump path it must validate the
|
|
837
|
+
* migrated items against the NEW schema (which validateBlockWithMigrationFor-
|
|
838
|
+
* Dir reads from disk), so it registers the shipped chain into migrations.json
|
|
839
|
+
* and overwrites the schema first, then on ANY failure (no chain, append
|
|
840
|
+
* failure, validation throw) RESTORES the captured original schema bytes
|
|
841
|
+
* verbatim, RESTORES migrations.json to its captured pre-call bytes (or removes
|
|
842
|
+
* it when it did not exist pre-call, since the append loop may have created it),
|
|
843
|
+
* and invalidates the registry cache it may have warmed. The block file is only
|
|
844
|
+
* ever touched via writeBlockForDir on the SUCCESS path, so a `"blocked"`
|
|
845
|
+
* outcome never writes the block. The net effect for `"blocked"` is the schema
|
|
846
|
+
* file, the block file, and migrations.json all identical to their pre-call
|
|
847
|
+
* bytes (migrations.json absent if it was absent pre-call).
|
|
848
|
+
*/
|
|
849
|
+
function resyncSchema(destRoot, samplesRoot, sourceFile, destFile, name) {
|
|
850
|
+
const catalogVersion = readDeclaredVersion(sourceFile);
|
|
851
|
+
const installedVersion = readDeclaredVersion(destFile);
|
|
852
|
+
// TASK-051 / FGAP-080: build the pending-blocked record for a refused resync.
|
|
853
|
+
// Pin the TARGET catalog schema body into the object store (computeContentHash
|
|
854
|
+
// + putObject — idempotent on the content hash) so resolve-blocked can later
|
|
855
|
+
// re-validate the corrected block against the SAME pinned target this run
|
|
856
|
+
// blocked on, and carry the chain reaching it (empty for a no-chain refusal).
|
|
857
|
+
// resyncSchema RETURNS this entry; updateContext owns the sidecar write so the
|
|
858
|
+
// helper's contract stays narrow (it does not touch pending-blocked.json).
|
|
859
|
+
const buildPendingEntry = (reason, chain, failures) => {
|
|
860
|
+
const targetBody = JSON.parse(fs.readFileSync(sourceFile, "utf-8"));
|
|
861
|
+
const targetHash = computeContentHash(targetBody);
|
|
862
|
+
putObject(destRoot, targetHash, targetBody);
|
|
863
|
+
const entry = {
|
|
864
|
+
name,
|
|
865
|
+
reason,
|
|
866
|
+
target_hash: targetHash,
|
|
867
|
+
chain,
|
|
868
|
+
blocked_at: new Date().toISOString(),
|
|
869
|
+
};
|
|
870
|
+
if (installedVersion !== undefined)
|
|
871
|
+
entry.from = installedVersion;
|
|
872
|
+
if (catalogVersion !== undefined)
|
|
873
|
+
entry.to = catalogVersion;
|
|
874
|
+
if (failures)
|
|
875
|
+
entry.failures = failures;
|
|
876
|
+
return entry;
|
|
877
|
+
};
|
|
878
|
+
// (A) Same version (or either version unreadable / non-versioned): there is no
|
|
879
|
+
// version transition to migrate across, so the drift is description-only —
|
|
880
|
+
// safe to overwrite the schema verbatim. Items are unaffected by a same-
|
|
881
|
+
// version schema body change (the version is the migration contract). No
|
|
882
|
+
// migration is registered on this arm, so it reports an empty decl list.
|
|
883
|
+
if (installedVersion === catalogVersion || catalogVersion === undefined || installedVersion === undefined) {
|
|
884
|
+
fs.copyFileSync(sourceFile, destFile);
|
|
885
|
+
return { status: "resynced", registeredMigrations: [] };
|
|
886
|
+
}
|
|
887
|
+
// (B) Version bump — migrate-or-refuse. installedVersion ≠ catalogVersion,
|
|
888
|
+
// both defined. The chain is sought in the catalog's OWN migrations.json so
|
|
889
|
+
// the declarations and the schema versions stay coherent.
|
|
890
|
+
const chain = findCatalogMigrationChain(samplesRoot, name, installedVersion, catalogVersion);
|
|
891
|
+
if (chain === null) {
|
|
892
|
+
// No shipped chain reaches the catalog version → refuse, leave unchanged.
|
|
893
|
+
// Nothing was registered, so the report is empty. The blocked detail records
|
|
894
|
+
// the no-chain reason + the version pair (TASK-048).
|
|
895
|
+
return {
|
|
896
|
+
status: "blocked",
|
|
897
|
+
registeredMigrations: [],
|
|
898
|
+
blockedDetail: { reason: "no-migration-chain", from: installedVersion, to: catalogVersion },
|
|
899
|
+
pendingEntry: buildPendingEntry("no-migration-chain", []),
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
// Capture migrations.json raw bytes BEFORE any decl append so the refuse path
|
|
903
|
+
// can restore it byte-for-byte — appendMigrationDeclForDir below mutates it, and
|
|
904
|
+
// a later validation throw must leave migrations.json byte-unchanged too (not
|
|
905
|
+
// just the schema file). Mirrors the originalSchemaBytes capture below.
|
|
906
|
+
const migrationsPath = migrationsPathForDir(destRoot);
|
|
907
|
+
const originalMigrationsBytes = fs.existsSync(migrationsPath) ? fs.readFileSync(migrationsPath) : null;
|
|
908
|
+
// Register each shipped decl into the substrate's migrations.json (idempotent:
|
|
909
|
+
// skip a decl whose (schemaName, fromVersion) is already present — append
|
|
910
|
+
// throws on collision). Registration is required BEFORE the validate+migrate
|
|
911
|
+
// call so the loaded registry carries the forward edge.
|
|
912
|
+
const existing = loadMigrationsFileForDir(destRoot);
|
|
913
|
+
const present = new Set((existing?.migrations ?? []).map((m) => `${m.schemaName}${m.fromVersion}`));
|
|
914
|
+
// Accumulate the decls THIS call actually appends (the not-already-present
|
|
915
|
+
// subset), reported as the { schema, from, to } shape for the caller to
|
|
916
|
+
// surface (FGAP-050). On the blocked rollback path below this list is
|
|
917
|
+
// discarded and [] is returned (post-rollback truth: nothing stuck).
|
|
918
|
+
const registeredMigrations = [];
|
|
919
|
+
for (const decl of chain) {
|
|
920
|
+
const key = `${decl.schemaName}${decl.fromVersion}`;
|
|
921
|
+
if (present.has(key))
|
|
922
|
+
continue;
|
|
923
|
+
appendMigrationDeclForDir(destRoot, decl);
|
|
924
|
+
present.add(key);
|
|
925
|
+
registeredMigrations.push({ schema: decl.schemaName, from: decl.fromVersion, to: decl.toVersion });
|
|
926
|
+
}
|
|
927
|
+
// Determine whether the block file carries items to migrate. Absent / zero-
|
|
928
|
+
// item blocks need no migration: register-the-chain + overwrite the schema and
|
|
929
|
+
// report `migrated` (the version model advanced even though no items moved).
|
|
930
|
+
const blockFile = installedBlockDestPath(destRoot, name);
|
|
931
|
+
let blockData;
|
|
932
|
+
let hasItems = false;
|
|
933
|
+
if (fs.existsSync(blockFile)) {
|
|
934
|
+
try {
|
|
935
|
+
blockData = JSON.parse(fs.readFileSync(blockFile, "utf-8"));
|
|
936
|
+
forEachBlockArray(blockData, (_arrayKey, arr) => {
|
|
937
|
+
if (arr.length > 0)
|
|
938
|
+
hasItems = true;
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
catch {
|
|
942
|
+
// Unreadable block — treat as POPULATED (safety default) and route it
|
|
943
|
+
// through the validate path, which will throw and trigger rollback.
|
|
944
|
+
hasItems = true;
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
// Capture the original schema bytes for an airtight rollback, then overwrite
|
|
948
|
+
// the schema so validateBlockWithMigrationForDir (which reads the schema from
|
|
949
|
+
// disk) validates the migrated items against the NEW schema.
|
|
950
|
+
const originalSchemaBytes = fs.readFileSync(destFile);
|
|
951
|
+
fs.copyFileSync(sourceFile, destFile);
|
|
952
|
+
if (!hasItems) {
|
|
953
|
+
// No items to migrate — schema overwritten, chain registered. Done.
|
|
954
|
+
return { status: "migrated", registeredMigrations };
|
|
955
|
+
}
|
|
956
|
+
try {
|
|
957
|
+
const registry = getProjectMigrationRegistryForDir(destRoot);
|
|
958
|
+
const migrated = validateBlockWithMigrationForDir(destRoot, name, blockData, registry);
|
|
959
|
+
// Persist the forward-migrated block (identity ⇒ byte-equal items). When the
|
|
960
|
+
// block carries a `schema_version` envelope field, advance it to the catalog
|
|
961
|
+
// version so the on-disk block declares the version it now conforms to —
|
|
962
|
+
// runMigrations applies the item transforms but does NOT stamp the envelope
|
|
963
|
+
// version, so we advance it here before the write. writeBlockForDir re-routes
|
|
964
|
+
// through the migration path; with the envelope now at catalogVersion it
|
|
965
|
+
// validates straight (no re-migration) against the just-installed schema.
|
|
966
|
+
if (migrated &&
|
|
967
|
+
typeof migrated === "object" &&
|
|
968
|
+
!Array.isArray(migrated) &&
|
|
969
|
+
typeof migrated.schema_version === "string") {
|
|
970
|
+
migrated.schema_version = catalogVersion;
|
|
971
|
+
}
|
|
972
|
+
// Persist the migrated block via the full whole-block write so the migrated
|
|
973
|
+
// items are identity-stamped (mint-or-preserve oid, recompute content_hash).
|
|
974
|
+
// Identity stamping is mandatory for every write.
|
|
975
|
+
writeBlockForDir(destRoot, name, migrated);
|
|
976
|
+
return { status: "migrated", registeredMigrations };
|
|
977
|
+
}
|
|
978
|
+
catch (err) {
|
|
979
|
+
// Migrated items would NOT validate against the new schema (or migration
|
|
980
|
+
// threw). Refuse: restore the original schema bytes verbatim so the schema
|
|
981
|
+
// file is byte-unchanged, and invalidate the registry cache warmed above so
|
|
982
|
+
// a subsequent read rebuilds against the on-disk (restored) state. The block
|
|
983
|
+
// file was never written on this path, so it is already byte-unchanged.
|
|
984
|
+
fs.writeFileSync(destFile, originalSchemaBytes);
|
|
985
|
+
// Restore migrations.json to its pre-call bytes: if it existed, write the
|
|
986
|
+
// captured bytes back; if it did NOT exist pre-call, the append loop created
|
|
987
|
+
// it — remove it so the refuse path leaves no trace.
|
|
988
|
+
if (originalMigrationsBytes === null) {
|
|
989
|
+
if (fs.existsSync(migrationsPath))
|
|
990
|
+
fs.unlinkSync(migrationsPath);
|
|
991
|
+
}
|
|
992
|
+
else {
|
|
993
|
+
fs.writeFileSync(migrationsPath, originalMigrationsBytes);
|
|
994
|
+
}
|
|
995
|
+
invalidateMigrationRegistryForDir(destRoot);
|
|
996
|
+
// Rollback reverted migrations.json to its pre-call bytes, so report no
|
|
997
|
+
// registered migrations (post-rollback truth — nothing is stuck on disk). The
|
|
998
|
+
// blocked detail records the validation-failed reason + the version pair + the
|
|
999
|
+
// per-item failures (a single synthetic failure for a non-AJV throw) so the
|
|
1000
|
+
// caller surfaces WHY the resync refused (TASK-048).
|
|
1001
|
+
const failures = err instanceof ValidationError
|
|
1002
|
+
? mapValidationFailures(err.errors, blockData)
|
|
1003
|
+
: [{ instancePath: "", keyword: "error", message: String(err) }];
|
|
1004
|
+
return {
|
|
1005
|
+
status: "blocked",
|
|
1006
|
+
registeredMigrations: [],
|
|
1007
|
+
blockedDetail: { reason: "validation-failed", from: installedVersion, to: catalogVersion, failures },
|
|
1008
|
+
pendingEntry: buildPendingEntry("validation-failed", chain, failures),
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
265
1012
|
/**
|
|
266
1013
|
* /context install opt-in mechanism (DEC-0011). Reads config.installed_schemas
|
|
267
1014
|
* and config.installed_blocks, copies declared assets from the package
|
|
@@ -274,7 +1021,16 @@ export function initProject(cwd, contextDir) {
|
|
|
274
1021
|
* - Empty install lists are not an error — the result is a clean no-op.
|
|
275
1022
|
*/
|
|
276
1023
|
export function installContext(cwd, options = {}) {
|
|
277
|
-
const result = {
|
|
1024
|
+
const result = {
|
|
1025
|
+
installed: [],
|
|
1026
|
+
updated: [],
|
|
1027
|
+
skipped: [],
|
|
1028
|
+
notFound: [],
|
|
1029
|
+
preserved: [],
|
|
1030
|
+
resynced: [],
|
|
1031
|
+
migrated: [],
|
|
1032
|
+
blocked: [],
|
|
1033
|
+
};
|
|
278
1034
|
const overwrite = options.overwrite === true;
|
|
279
1035
|
const destRoot = tryResolveContextDir(cwd);
|
|
280
1036
|
if (destRoot === null) {
|
|
@@ -296,16 +1052,10 @@ export function installContext(cwd, options = {}) {
|
|
|
296
1052
|
const schemasRoot = path.join(destRoot, SCHEMAS_DIR);
|
|
297
1053
|
if (!fs.existsSync(schemasRoot))
|
|
298
1054
|
fs.mkdirSync(schemasRoot, { recursive: true });
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
|
|
303
|
-
const samplesRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "samples");
|
|
304
|
-
const conception = JSON.parse(fs.readFileSync(path.join(samplesRoot, "conception.json"), "utf-8"));
|
|
305
|
-
const byId = new Map();
|
|
306
|
-
for (const bk of conception.block_kinds ?? []) {
|
|
307
|
-
byId.set(bk.canonical_id, { schema_path: bk.schema_path, data_path: bk.data_path });
|
|
308
|
-
}
|
|
1055
|
+
// Catalog resolution (samplesRoot + canonical_id→paths map) is shared with
|
|
1056
|
+
// the read-only checkStatus drift detector via resolveCatalog so installer
|
|
1057
|
+
// and detector cannot drift in how they resolve sources.
|
|
1058
|
+
const { samplesRoot, byId } = resolveCatalog();
|
|
309
1059
|
for (const name of config.installed_schemas ?? []) {
|
|
310
1060
|
const relDest = `${SCHEMAS_DIR}/${name}.schema.json`;
|
|
311
1061
|
const kind = byId.get(name);
|
|
@@ -326,8 +1076,33 @@ export function installContext(cwd, options = {}) {
|
|
|
326
1076
|
result.skipped.push(relDest);
|
|
327
1077
|
continue;
|
|
328
1078
|
}
|
|
329
|
-
|
|
330
|
-
|
|
1079
|
+
if (!destExists) {
|
|
1080
|
+
// Fresh install — no installed copy yet, so there are no items to
|
|
1081
|
+
// migrate. Copy the catalog schema verbatim (unchanged behaviour).
|
|
1082
|
+
fs.copyFileSync(sourceFile, destFile);
|
|
1083
|
+
result.installed.push(relDest);
|
|
1084
|
+
continue;
|
|
1085
|
+
}
|
|
1086
|
+
// destExists && overwrite — migration-aware schema re-sync (FGAP-029 S4).
|
|
1087
|
+
// resyncSchema decides between same-version overwrite, version-bump
|
|
1088
|
+
// forward-migration, and refuse-and-leave-unchanged; it never strands the
|
|
1089
|
+
// block's items under a schema they fail.
|
|
1090
|
+
// resyncSchema now returns { status, registeredMigrations }; installContext
|
|
1091
|
+
// reports only the status bucket (the migration-decl reporting surface is on
|
|
1092
|
+
// /context update — FGAP-050 — so the appended decls are intentionally
|
|
1093
|
+
// ignored here).
|
|
1094
|
+
const { status: outcome } = resyncSchema(destRoot, samplesRoot, sourceFile, destFile, name);
|
|
1095
|
+
switch (outcome) {
|
|
1096
|
+
case "resynced":
|
|
1097
|
+
result.resynced.push(relDest);
|
|
1098
|
+
break;
|
|
1099
|
+
case "migrated":
|
|
1100
|
+
result.migrated.push(relDest);
|
|
1101
|
+
break;
|
|
1102
|
+
case "blocked":
|
|
1103
|
+
result.blocked.push(relDest);
|
|
1104
|
+
break;
|
|
1105
|
+
}
|
|
331
1106
|
}
|
|
332
1107
|
for (const name of config.installed_blocks ?? []) {
|
|
333
1108
|
const relDest = `${name}.json`;
|
|
@@ -343,15 +1118,1077 @@ export function installContext(cwd, options = {}) {
|
|
|
343
1118
|
continue;
|
|
344
1119
|
}
|
|
345
1120
|
const destExists = fs.existsSync(destFile);
|
|
346
|
-
if (destExists
|
|
347
|
-
|
|
1121
|
+
if (destExists) {
|
|
1122
|
+
// Block-data preservation (FGAP-029 safe re-sync): never copy a catalog
|
|
1123
|
+
// starter over a block that already holds items, even under --update.
|
|
1124
|
+
// Catalog block starters are empty ({"tasks": []}); copying one over a
|
|
1125
|
+
// populated block would delete the filed items. Read the existing block
|
|
1126
|
+
// and treat ANY top-level (or nested) array with length > 0 as populated.
|
|
1127
|
+
// Safety default: if the block can't be read/confirmed-empty (throw, or
|
|
1128
|
+
// migration-validation failure), treat it as POPULATED — never overwrite
|
|
1129
|
+
// something we could not read.
|
|
1130
|
+
let populated = true;
|
|
1131
|
+
try {
|
|
1132
|
+
const existing = readBlockForDir(destRoot, name);
|
|
1133
|
+
let hasItems = false;
|
|
1134
|
+
forEachBlockArray(existing, (_arrayKey, arr) => {
|
|
1135
|
+
if (arr.length > 0)
|
|
1136
|
+
hasItems = true;
|
|
1137
|
+
});
|
|
1138
|
+
populated = hasItems;
|
|
1139
|
+
}
|
|
1140
|
+
catch {
|
|
1141
|
+
populated = true;
|
|
1142
|
+
}
|
|
1143
|
+
if (populated) {
|
|
1144
|
+
result.preserved.push(relDest);
|
|
1145
|
+
continue;
|
|
1146
|
+
}
|
|
1147
|
+
if (!overwrite) {
|
|
1148
|
+
result.skipped.push(relDest);
|
|
1149
|
+
continue;
|
|
1150
|
+
}
|
|
1151
|
+
// FGAP-051 idempotent skip: the on-disk empty block already equals the
|
|
1152
|
+
// catalog starter (JCS-canonical content equality, key-order/whitespace
|
|
1153
|
+
// insensitive) — rewriting it would be a no-op churn (mtime bump, identical
|
|
1154
|
+
// bytes), so skip it and report `skipped` rather than `updated`. Reaches
|
|
1155
|
+
// here only for an itemless block under overwrite; a starter whose content
|
|
1156
|
+
// differs (e.g. an extra top-level field) still falls through to the copy.
|
|
1157
|
+
if (computeFileContentHash(destFile) === computeFileContentHash(sourceFile)) {
|
|
1158
|
+
result.skipped.push(relDest);
|
|
1159
|
+
continue;
|
|
1160
|
+
}
|
|
1161
|
+
fs.copyFileSync(sourceFile, destFile);
|
|
1162
|
+
result.updated.push(relDest);
|
|
348
1163
|
continue;
|
|
349
1164
|
}
|
|
350
1165
|
fs.copyFileSync(sourceFile, destFile);
|
|
351
|
-
|
|
1166
|
+
result.installed.push(relDest);
|
|
352
1167
|
}
|
|
1168
|
+
// ── Install baseline of the installed SCHEMAS (FGAP-029 safe re-sync) ──────
|
|
1169
|
+
// Record where the installed schema model came from + a per-schema content
|
|
1170
|
+
// fingerprint, so a later slice can detect installed-vs-catalog drift. BLOCKS
|
|
1171
|
+
// are user data and are deliberately NOT baselined (only the re-syncable model
|
|
1172
|
+
// — schemas — is fingerprinted). The fingerprint is taken from the INSTALLED
|
|
1173
|
+
// dest file (via the SAME `installedSchemaDestPath` derivation the copy loop
|
|
1174
|
+
// uses), not the catalog source, so the baseline reflects what is actually on
|
|
1175
|
+
// disk. `version` is the installed schema file's own declared `version` field.
|
|
1176
|
+
const assets = {};
|
|
1177
|
+
for (const name of config.installed_schemas ?? []) {
|
|
1178
|
+
const destSchemaFile = installedSchemaDestPath(destRoot, name);
|
|
1179
|
+
if (!fs.existsSync(destSchemaFile))
|
|
1180
|
+
continue;
|
|
1181
|
+
// Safety default (mirror of the block-preservation try/catch above): a
|
|
1182
|
+
// declared schema file present-but-corrupt (not valid JSON, unreadable, or
|
|
1183
|
+
// not hashable) must NOT crash installContext. Skip baselining it — it is
|
|
1184
|
+
// simply omitted from `installed_from.assets`; drift tracking resumes once
|
|
1185
|
+
// the file is valid. Install proceeds for all other declared schemas.
|
|
1186
|
+
try {
|
|
1187
|
+
const schemaJson = JSON.parse(fs.readFileSync(destSchemaFile, "utf-8"));
|
|
1188
|
+
const content_hash = computeFileContentHash(destSchemaFile);
|
|
1189
|
+
// Base-stamp (TASK-035 / FEAT-006 T2): persist the as-installed schema body
|
|
1190
|
+
// into the content-addressed object store keyed by its install-baseline
|
|
1191
|
+
// content_hash, so the merge base is retrievable later (TASK-036 precondition).
|
|
1192
|
+
// putObject is idempotent (content-addressed) — re-installing unchanged content
|
|
1193
|
+
// re-stamps identical bytes harmlessly. Reuses the already-parsed schemaJson and
|
|
1194
|
+
// already-computed content_hash; no re-read or re-hash.
|
|
1195
|
+
putObject(destRoot, content_hash, schemaJson);
|
|
1196
|
+
assets[name] = {
|
|
1197
|
+
content_hash,
|
|
1198
|
+
version: typeof schemaJson.version === "string" ? schemaJson.version : "",
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
catch { }
|
|
1202
|
+
}
|
|
1203
|
+
// `catalog` is the pi-context package "name@version", resolved from the SAME
|
|
1204
|
+
// package root `samplesRoot` is derived from (one dir up from this module).
|
|
1205
|
+
const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
1206
|
+
const pkgJson = JSON.parse(fs.readFileSync(path.join(pkgRoot, "package.json"), "utf-8"));
|
|
1207
|
+
const catalog = `${pkgJson.name ?? ""}@${pkgJson.version ?? ""}`;
|
|
1208
|
+
// `catalog_version` is the conception's own schema_version (samplesRoot resolved above).
|
|
1209
|
+
const conceptionVersion = JSON.parse(fs.readFileSync(path.join(samplesRoot, "conception.json"), "utf-8"));
|
|
1210
|
+
const catalog_version = conceptionVersion.schema_version ?? "";
|
|
1211
|
+
// Idempotency: when the EXISTING baseline has deep-equal catalog + catalog_version
|
|
1212
|
+
// + assets, PRESERVE it verbatim (including its `at`) so a re-run on an unchanged
|
|
1213
|
+
// substrate produces a byte-identical config.json. Only refresh `at` when the
|
|
1214
|
+
// baseline content differs.
|
|
1215
|
+
const existingFrom = config.installed_from;
|
|
1216
|
+
const sameBaseline = existingFrom !== undefined &&
|
|
1217
|
+
existingFrom.catalog === catalog &&
|
|
1218
|
+
existingFrom.catalog_version === catalog_version &&
|
|
1219
|
+
JSON.stringify(existingFrom.assets) === JSON.stringify(assets);
|
|
1220
|
+
const installed_from = sameBaseline
|
|
1221
|
+
? existingFrom
|
|
1222
|
+
: { catalog, catalog_version, at: new Date().toISOString(), assets };
|
|
1223
|
+
writeConfig(cwd, { ...config, installed_from });
|
|
353
1224
|
return result;
|
|
354
1225
|
}
|
|
1226
|
+
/**
|
|
1227
|
+
* PURE-READ drift detector for `/context check-status` (FGAP-029 safe
|
|
1228
|
+
* re-sync, slice S3). Compares, per installed schema, the S2 install baseline
|
|
1229
|
+
* against the catalog's current schema file and the currently-installed schema
|
|
1230
|
+
* file, classifies the drift, and RETURNS the report. Writes NOTHING anywhere —
|
|
1231
|
+
* no config write, no file copy, no mkdir; only reads.
|
|
1232
|
+
*
|
|
1233
|
+
* For each `config.installed_schemas` entry:
|
|
1234
|
+
* - baseline = config.installed_from?.assets?.[name]?.content_hash
|
|
1235
|
+
* - catalog-now = computeFileContentHash(samplesRoot/<kind.schema_path>)
|
|
1236
|
+
* (state `missing-catalog` when the source file is absent
|
|
1237
|
+
* or unhashable)
|
|
1238
|
+
* - installed-now = computeFileContentHash(installedSchemaDestPath(destRoot,name))
|
|
1239
|
+
* (state `missing-installed` when the dest file is absent
|
|
1240
|
+
* or unhashable)
|
|
1241
|
+
*
|
|
1242
|
+
* Each file-hash read is wrapped in try/catch so a corrupt file degrades to a
|
|
1243
|
+
* `missing-*` / diverged classification rather than throwing — mirroring S2's
|
|
1244
|
+
* safety default. A schema whose name has no catalog block_kind is reported
|
|
1245
|
+
* `missing-catalog`.
|
|
1246
|
+
*/
|
|
1247
|
+
export function checkStatus(cwd) {
|
|
1248
|
+
const emptySummary = () => ({
|
|
1249
|
+
"in-sync": 0,
|
|
1250
|
+
"catalog-ahead": 0,
|
|
1251
|
+
"locally-modified": 0,
|
|
1252
|
+
"both-diverged": 0,
|
|
1253
|
+
"no-baseline": 0,
|
|
1254
|
+
"missing-catalog": 0,
|
|
1255
|
+
"missing-installed": 0,
|
|
1256
|
+
total: 0,
|
|
1257
|
+
});
|
|
1258
|
+
const perAsset = [];
|
|
1259
|
+
const destRoot = tryResolveContextDir(cwd);
|
|
1260
|
+
if (destRoot === null) {
|
|
1261
|
+
return { perAsset, summary: emptySummary() };
|
|
1262
|
+
}
|
|
1263
|
+
const config = loadConfig(cwd);
|
|
1264
|
+
if (!config) {
|
|
1265
|
+
return { perAsset, summary: emptySummary() };
|
|
1266
|
+
}
|
|
1267
|
+
const { samplesRoot, byId } = resolveCatalog();
|
|
1268
|
+
for (const name of config.installed_schemas ?? []) {
|
|
1269
|
+
const baselineAsset = config.installed_from?.assets?.[name];
|
|
1270
|
+
const baseline = baselineAsset?.content_hash;
|
|
1271
|
+
const kind = byId.get(name);
|
|
1272
|
+
// Catalog-now hash (undefined when the source is absent/unhashable).
|
|
1273
|
+
let catalogHash;
|
|
1274
|
+
let catalogVersion;
|
|
1275
|
+
if (kind) {
|
|
1276
|
+
const sourceFile = path.join(samplesRoot, kind.schema_path);
|
|
1277
|
+
try {
|
|
1278
|
+
catalogHash = computeFileContentHash(sourceFile);
|
|
1279
|
+
const parsed = JSON.parse(fs.readFileSync(sourceFile, "utf-8"));
|
|
1280
|
+
catalogVersion = typeof parsed.version === "string" ? parsed.version : undefined;
|
|
1281
|
+
}
|
|
1282
|
+
catch {
|
|
1283
|
+
catalogHash = undefined;
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
// Installed-now hash (undefined when the dest is absent/unhashable).
|
|
1287
|
+
let installedHash;
|
|
1288
|
+
try {
|
|
1289
|
+
installedHash = computeFileContentHash(installedSchemaDestPath(destRoot, name));
|
|
1290
|
+
}
|
|
1291
|
+
catch {
|
|
1292
|
+
installedHash = undefined;
|
|
1293
|
+
}
|
|
1294
|
+
const installed_modified = baseline !== undefined && installedHash !== undefined && installedHash !== baseline;
|
|
1295
|
+
let state;
|
|
1296
|
+
if (installedHash === undefined) {
|
|
1297
|
+
state = "missing-installed";
|
|
1298
|
+
}
|
|
1299
|
+
else if (catalogHash === undefined) {
|
|
1300
|
+
state = "missing-catalog";
|
|
1301
|
+
}
|
|
1302
|
+
else if (baseline === undefined) {
|
|
1303
|
+
state = "no-baseline";
|
|
1304
|
+
}
|
|
1305
|
+
else {
|
|
1306
|
+
const catalogDrift = catalogHash !== baseline;
|
|
1307
|
+
const installedDrift = installedHash !== baseline;
|
|
1308
|
+
if (!catalogDrift && !installedDrift) {
|
|
1309
|
+
state = "in-sync";
|
|
1310
|
+
}
|
|
1311
|
+
else if (catalogDrift && !installedDrift) {
|
|
1312
|
+
state = "catalog-ahead";
|
|
1313
|
+
}
|
|
1314
|
+
else if (!catalogDrift && installedDrift) {
|
|
1315
|
+
state = "locally-modified";
|
|
1316
|
+
}
|
|
1317
|
+
else {
|
|
1318
|
+
state = "both-diverged";
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
// FGAP-078 / STORY-007: surface, per asset, whether the catalog has moved
|
|
1322
|
+
// past the install baseline and by what version gap. Computed AFTER the
|
|
1323
|
+
// classification arm above (the arm is unchanged). `behind` is true exactly
|
|
1324
|
+
// for the catalog-moved states; the version delta carries the baseline →
|
|
1325
|
+
// catalog version pair and a `basis` that records whether the catalog drift
|
|
1326
|
+
// was a declared version bump or a content-only change (catalog-ahead is a
|
|
1327
|
+
// hash comparison, so the version string can be unchanged while the body moved).
|
|
1328
|
+
const behind = state === "catalog-ahead" || state === "both-diverged";
|
|
1329
|
+
const baselineVersion = baselineAsset?.version;
|
|
1330
|
+
const versionDelta = behind
|
|
1331
|
+
? {
|
|
1332
|
+
from: baselineVersion,
|
|
1333
|
+
to: catalogVersion,
|
|
1334
|
+
basis: baselineVersion !== undefined && catalogVersion !== undefined && baselineVersion !== catalogVersion
|
|
1335
|
+
? "version-bump"
|
|
1336
|
+
: "content-only",
|
|
1337
|
+
}
|
|
1338
|
+
: undefined;
|
|
1339
|
+
perAsset.push({
|
|
1340
|
+
name,
|
|
1341
|
+
state,
|
|
1342
|
+
baseline_version: baselineVersion,
|
|
1343
|
+
catalog_version: catalogVersion,
|
|
1344
|
+
installed_modified,
|
|
1345
|
+
...(behind ? { behind: true, version_delta: versionDelta } : {}),
|
|
1346
|
+
});
|
|
1347
|
+
}
|
|
1348
|
+
const summary = emptySummary();
|
|
1349
|
+
for (const a of perAsset) {
|
|
1350
|
+
summary[a.state] += 1;
|
|
1351
|
+
summary.total += 1;
|
|
1352
|
+
}
|
|
1353
|
+
return { perAsset, summary };
|
|
1354
|
+
}
|
|
1355
|
+
/**
|
|
1356
|
+
* Render a `CheckStatusReport` (from the read-only `checkStatus` detector) as a
|
|
1357
|
+
* scannable per-state grouping for `/context check-status`. Groups the
|
|
1358
|
+
* affected schema names under each non-empty state, then a total line. Mirrors
|
|
1359
|
+
* the install-handler `lines.push` style.
|
|
1360
|
+
*/
|
|
1361
|
+
export function renderCheckStatus(report) {
|
|
1362
|
+
const lines = [];
|
|
1363
|
+
lines.push("Schema drift — installed vs catalog (read-only; no writes):");
|
|
1364
|
+
const order = [
|
|
1365
|
+
"in-sync",
|
|
1366
|
+
"catalog-ahead",
|
|
1367
|
+
"locally-modified",
|
|
1368
|
+
"both-diverged",
|
|
1369
|
+
"no-baseline",
|
|
1370
|
+
"missing-catalog",
|
|
1371
|
+
"missing-installed",
|
|
1372
|
+
];
|
|
1373
|
+
for (const state of order) {
|
|
1374
|
+
const assets = report.perAsset.filter((a) => a.state === state);
|
|
1375
|
+
if (assets.length === 0)
|
|
1376
|
+
continue;
|
|
1377
|
+
// For a behind asset (catalog-ahead / both-diverged) annotate the name with
|
|
1378
|
+
// the version gap: `name (1.0.0 -> 1.0.1)` for a declared bump, or
|
|
1379
|
+
// `name (1.0.1, content changed)` / `name (content changed)` (versions
|
|
1380
|
+
// undefined) for a content-only drift — so the version pair is scannable
|
|
1381
|
+
// inline (FGAP-078 / STORY-007).
|
|
1382
|
+
const labels = assets.map((a) => {
|
|
1383
|
+
if (!a.behind || !a.version_delta)
|
|
1384
|
+
return a.name;
|
|
1385
|
+
const { from, to, basis } = a.version_delta;
|
|
1386
|
+
if (basis === "version-bump")
|
|
1387
|
+
return `${a.name} (${from} -> ${to})`;
|
|
1388
|
+
const v = to ?? from;
|
|
1389
|
+
return v !== undefined ? `${a.name} (${v}, content changed)` : `${a.name} (content changed)`;
|
|
1390
|
+
});
|
|
1391
|
+
lines.push(` ${state} (${assets.length}): ${labels.join(", ")}`);
|
|
1392
|
+
}
|
|
1393
|
+
if (report.perAsset.length === 0) {
|
|
1394
|
+
lines.push(" (no installed schemas declared — nothing to compare)");
|
|
1395
|
+
}
|
|
1396
|
+
lines.push(`Total: ${report.summary.total} schema(s).`);
|
|
1397
|
+
return lines.join("\n");
|
|
1398
|
+
}
|
|
1399
|
+
/**
|
|
1400
|
+
* `/context update` engine (FEAT-006 T1 — TASK-034 / DEC-0017). Brings the
|
|
1401
|
+
* installed substrate MODEL (schemas) current with the packaged catalog by
|
|
1402
|
+
* consulting the read-only `checkStatus` drift detector per installed schema and
|
|
1403
|
+
* routing each by its drift `state`:
|
|
1404
|
+
*
|
|
1405
|
+
* - `in-sync` → no-op (recorded under `inSync`).
|
|
1406
|
+
* - `catalog-ahead` → re-sync via the EXISTING `resyncSchema` (the SAME
|
|
1407
|
+
* call shape `/context install --update`'s schema loop
|
|
1408
|
+
* uses for that asset: `resyncSchema(destRoot,
|
|
1409
|
+
* samplesRoot, sourceFile, destFile, name)` with
|
|
1410
|
+
* `sourceFile = samplesRoot/<kind.schema_path>` and
|
|
1411
|
+
* `destFile = installedSchemaDestPath(destRoot,
|
|
1412
|
+
* name)`). Its `resynced`/`migrated`/`blocked` outcome
|
|
1413
|
+
* routes into the matching array.
|
|
1414
|
+
* - `locally-modified` /
|
|
1415
|
+
* `both-diverged` → REFUSE-AND-REPORT: do NOT call `resyncSchema`, do NOT
|
|
1416
|
+
* overwrite; record under `refused`. The first increment
|
|
1417
|
+
* (DEC-0017) never clobbers a locally-edited schema; the
|
|
1418
|
+
* three-way merge is deferred (TASK-036).
|
|
1419
|
+
* [TASK-036 — FEAT-006 T3, now implemented]: the merge is no
|
|
1420
|
+
* longer deferred. BASE is reconstructed from the baseline's
|
|
1421
|
+
* content-addressed body (`getObject(destRoot,
|
|
1422
|
+
* installed_from.assets[name].content_hash)`) and key/path-
|
|
1423
|
+
* merged with OURS (installed file) + THEIRS (catalog file)
|
|
1424
|
+
* via `mergeSchema`. Conflict-free → write via
|
|
1425
|
+
* `writeSchemaCheckedForDir` (meta-validated; `dryRun`
|
|
1426
|
+
* validates without writing), record under `merged`; any
|
|
1427
|
+
* conflict → record `{name, conflicts}` under `conflicts`,
|
|
1428
|
+
* write NOTHING; no retrievable base body / parse / merge /
|
|
1429
|
+
* validation throw → fall back to `refused`. An auto-merged
|
|
1430
|
+
* body is base-refreshed post-loop like a resync.
|
|
1431
|
+
* - `no-baseline` /
|
|
1432
|
+
* `missing-catalog` /
|
|
1433
|
+
* `missing-installed` → record under `reported` (with the state) — undecidable
|
|
1434
|
+
* or absent, not acted on.
|
|
1435
|
+
*
|
|
1436
|
+
* When `dryRun` is true NO writes occur: `checkStatus` is consulted and the action
|
|
1437
|
+
* plan is computed, but `resyncSchema` is NOT invoked. For each `catalog-ahead`
|
|
1438
|
+
* schema the dryRun arm calls `simulateResyncOutcome`, which mirrors
|
|
1439
|
+
* `resyncSchema`'s decision arms 1:1 over an IN-MEMORY forward-migration +
|
|
1440
|
+
* re-validation (FGAP-066 / TASK-046) and predicts the precise outcome bucket —
|
|
1441
|
+
* `resynced` / `migrated` / `blocked` — the live path would land, so the schema is
|
|
1442
|
+
* pushed onto `result[outcome]` rather than unconditionally onto `resynced`. The
|
|
1443
|
+
* would-register migration decls it returns (the same FGAP-050 read-only set:
|
|
1444
|
+
* catalog chain minus the decls already on disk; empty on a blocked prediction)
|
|
1445
|
+
* are surfaced onto `migrationsRegistered` without writing. The live path mutates
|
|
1446
|
+
* only via `resyncSchema` (the catalog-ahead branch) and surfaces the decls it
|
|
1447
|
+
* appended onto `migrationsRegistered`; `installContext` and its install handler
|
|
1448
|
+
* are NOT touched.
|
|
1449
|
+
* Resolves the catalog / dest paths through the SAME `resolveCatalog` +
|
|
1450
|
+
* `installedSchemaDestPath` helpers the installer + detector use.
|
|
1451
|
+
*/
|
|
1452
|
+
export function updateContext(cwd, { dryRun = false } = {}) {
|
|
1453
|
+
const result = {
|
|
1454
|
+
dryRun,
|
|
1455
|
+
resynced: [],
|
|
1456
|
+
migrated: [],
|
|
1457
|
+
blocked: [],
|
|
1458
|
+
blockedDetail: [],
|
|
1459
|
+
refused: [],
|
|
1460
|
+
merged: [],
|
|
1461
|
+
conflicts: [],
|
|
1462
|
+
reported: [],
|
|
1463
|
+
inSync: [],
|
|
1464
|
+
registryAdditions: { relation_types: [], invariants: [], block_kinds: [], lenses: [] },
|
|
1465
|
+
migrationsRegistered: [],
|
|
1466
|
+
};
|
|
1467
|
+
const destRoot = tryResolveContextDir(cwd);
|
|
1468
|
+
if (destRoot === null) {
|
|
1469
|
+
result.error =
|
|
1470
|
+
"No .pi-context.json bootstrap pointer found. Run /context init <substrate-dir> first to bootstrap the substrate.";
|
|
1471
|
+
return result;
|
|
1472
|
+
}
|
|
1473
|
+
const config = loadConfig(cwd);
|
|
1474
|
+
if (!config) {
|
|
1475
|
+
result.error = "No config.json found in substrate dir — run /context init <substrate-dir> first.";
|
|
1476
|
+
return result;
|
|
1477
|
+
}
|
|
1478
|
+
// Drift classification is the read-only checkStatus detector (shares
|
|
1479
|
+
// resolveCatalog + installedSchemaDestPath with the installer, so update and
|
|
1480
|
+
// install cannot diverge on how they resolve sources / dests). The routing
|
|
1481
|
+
// below acts ONLY on the classified state; it never re-derives drift.
|
|
1482
|
+
const report = checkStatus(cwd);
|
|
1483
|
+
// Catalog resolution for the catalog-ahead resync branch — the SAME helper the
|
|
1484
|
+
// installer + detector use, so the sourceFile derivation matches checkStatus's.
|
|
1485
|
+
const { samplesRoot, byId } = resolveCatalog();
|
|
1486
|
+
// TASK-051 / FGAP-080: the live run's pending-blocked records. Each blocked
|
|
1487
|
+
// catalog-ahead resync returns a pinned `pendingEntry`; after the schema loop
|
|
1488
|
+
// the LIVE path reconciles pending-blocked.json to exactly this set (an empty
|
|
1489
|
+
// set removes the sidecar — no stale empty file). The dryRun path collects
|
|
1490
|
+
// nothing and the post-loop reconcile is !dryRun-guarded, so a preview never
|
|
1491
|
+
// touches the sidecar (nor the object store, since resyncSchema is never called
|
|
1492
|
+
// under dryRun).
|
|
1493
|
+
const pendingBlockedEntries = [];
|
|
1494
|
+
for (const asset of report.perAsset) {
|
|
1495
|
+
const { name, state } = asset;
|
|
1496
|
+
switch (state) {
|
|
1497
|
+
case "in-sync":
|
|
1498
|
+
result.inSync.push(name);
|
|
1499
|
+
break;
|
|
1500
|
+
case "catalog-ahead": {
|
|
1501
|
+
if (dryRun) {
|
|
1502
|
+
// Preview only — never call resyncSchema (it writes).
|
|
1503
|
+
// FGAP-066 / TASK-046: predict the PRECISE per-schema outcome by running
|
|
1504
|
+
// resyncSchema’s decision arms 1:1 over an in-memory forward-migration +
|
|
1505
|
+
// re-validation (simulateResyncOutcome), so the plan buckets the schema as
|
|
1506
|
+
// the resynced / migrated / blocked it WOULD land — not as resynced
|
|
1507
|
+
// unconditionally. The would-register decls it returns are the same FGAP-050
|
|
1508
|
+
// set (empty on a blocked prediction — post-rollback truth that a refused
|
|
1509
|
+
// resync registers nothing). Wrapped in the merge-arm per-asset error
|
|
1510
|
+
// tolerance: on a thrown helper failure, fall back to the prior behavior
|
|
1511
|
+
// (resynced + no decls) so the plan never crashes.
|
|
1512
|
+
try {
|
|
1513
|
+
const kind = byId.get(name);
|
|
1514
|
+
if (kind) {
|
|
1515
|
+
const sourceFile = path.join(samplesRoot, kind.schema_path);
|
|
1516
|
+
const destFile = installedSchemaDestPath(destRoot, name);
|
|
1517
|
+
const { outcome, wouldRegister, detail } = simulateResyncOutcome(destRoot, samplesRoot, sourceFile, destFile, name);
|
|
1518
|
+
result[outcome].push(name);
|
|
1519
|
+
for (const m of wouldRegister)
|
|
1520
|
+
result.migrationsRegistered.push(m);
|
|
1521
|
+
// TASK-048: a predicted-blocked schema carries its diagnostic detail
|
|
1522
|
+
// (reason + version pair + per-item failures) into blockedDetail so the
|
|
1523
|
+
// dryRun plan surfaces WHY it would refuse, matching the live run.
|
|
1524
|
+
if (outcome === "blocked" && detail) {
|
|
1525
|
+
result.blockedDetail.push({ name, ...detail });
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
else {
|
|
1529
|
+
result.resynced.push(name);
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
catch {
|
|
1533
|
+
result.resynced.push(name);
|
|
1534
|
+
}
|
|
1535
|
+
break;
|
|
1536
|
+
}
|
|
1537
|
+
const kind = byId.get(name);
|
|
1538
|
+
if (!kind) {
|
|
1539
|
+
// A catalog-ahead classification implies a catalog block_kind existed
|
|
1540
|
+
// when checkStatus ran; defend against a races/edge by reporting rather
|
|
1541
|
+
// than throwing (mirrors checkStatus's missing-catalog default).
|
|
1542
|
+
result.reported.push({ name, state: "missing-catalog" });
|
|
1543
|
+
break;
|
|
1544
|
+
}
|
|
1545
|
+
const sourceFile = path.join(samplesRoot, kind.schema_path);
|
|
1546
|
+
const destFile = installedSchemaDestPath(destRoot, name);
|
|
1547
|
+
const { status: outcome, registeredMigrations, blockedDetail, pendingEntry, } = resyncSchema(destRoot, samplesRoot, sourceFile, destFile, name);
|
|
1548
|
+
// FGAP-050: surface the decls the live resync actually appended.
|
|
1549
|
+
for (const m of registeredMigrations)
|
|
1550
|
+
result.migrationsRegistered.push(m);
|
|
1551
|
+
switch (outcome) {
|
|
1552
|
+
case "resynced":
|
|
1553
|
+
result.resynced.push(name);
|
|
1554
|
+
break;
|
|
1555
|
+
case "migrated":
|
|
1556
|
+
result.migrated.push(name);
|
|
1557
|
+
break;
|
|
1558
|
+
case "blocked":
|
|
1559
|
+
result.blocked.push(name);
|
|
1560
|
+
// TASK-048: carry the live refusal diagnostic (reason + version pair +
|
|
1561
|
+
// per-item failures) into blockedDetail alongside the bare name.
|
|
1562
|
+
if (blockedDetail)
|
|
1563
|
+
result.blockedDetail.push({ name, ...blockedDetail });
|
|
1564
|
+
// TASK-051 / FGAP-080: collect the pinned pending-blocked record so the
|
|
1565
|
+
// post-loop reconcile persists it (resolve-blocked consumes it later).
|
|
1566
|
+
if (pendingEntry)
|
|
1567
|
+
pendingBlockedEntries.push(pendingEntry);
|
|
1568
|
+
break;
|
|
1569
|
+
}
|
|
1570
|
+
break;
|
|
1571
|
+
}
|
|
1572
|
+
case "locally-modified":
|
|
1573
|
+
case "both-diverged": {
|
|
1574
|
+
// 3-way merge (TASK-036 — FEAT-006 T3): a locally-edited schema is no
|
|
1575
|
+
// longer blindly refused. Reconstruct BASE from the recorded install
|
|
1576
|
+
// baseline's content-addressed body, take OURS = the installed file and
|
|
1577
|
+
// THEIRS = the catalog file, and key/path-merge. Conflict-free → write
|
|
1578
|
+
// (or, under dryRun, validate-only); any conflict → record + do NOT write.
|
|
1579
|
+
const kind = byId.get(name);
|
|
1580
|
+
if (!kind) {
|
|
1581
|
+
// Mirrors the catalog-ahead arm's missing-catalog guard: a drift
|
|
1582
|
+
// classification implies a catalog block_kind existed at check time;
|
|
1583
|
+
// defend a race by reporting rather than throwing.
|
|
1584
|
+
result.reported.push({ name, state: "missing-catalog" });
|
|
1585
|
+
break;
|
|
1586
|
+
}
|
|
1587
|
+
const sourceFile = path.join(samplesRoot, kind.schema_path);
|
|
1588
|
+
const destFile = installedSchemaDestPath(destRoot, name);
|
|
1589
|
+
const baseHash = config.installed_from?.assets?.[name]?.content_hash;
|
|
1590
|
+
const base = baseHash ? getObject(destRoot, baseHash) : null;
|
|
1591
|
+
if (!base) {
|
|
1592
|
+
// No retrievable stamped base body ⇒ no safe 3-way merge possible;
|
|
1593
|
+
// fall back to refuse-and-report (DEC-0017) so the drift signal stays.
|
|
1594
|
+
result.refused.push(name);
|
|
1595
|
+
break;
|
|
1596
|
+
}
|
|
1597
|
+
try {
|
|
1598
|
+
const ours = JSON.parse(fs.readFileSync(destFile, "utf-8"));
|
|
1599
|
+
const theirs = JSON.parse(fs.readFileSync(sourceFile, "utf-8"));
|
|
1600
|
+
const { merged, conflicts } = mergeSchema(base, ours, theirs);
|
|
1601
|
+
if (conflicts.length === 0) {
|
|
1602
|
+
// writeSchemaCheckedForDir meta-validates + guards nested-id arrays;
|
|
1603
|
+
// dryRun validates without writing. The refresh loop (below) is
|
|
1604
|
+
// !dryRun-guarded, so a dryRun merge stamps/refreshes nothing.
|
|
1605
|
+
writeSchemaCheckedForDir(destRoot, name, merged, "replace", undefined, { dryRun });
|
|
1606
|
+
result.merged.push(name);
|
|
1607
|
+
// Stamp the merge BASE := the CATALOG body (theirs), not the merged
|
|
1608
|
+
// on-disk body (FGAP-070). A 3-way merge that KEEPS a local divergence
|
|
1609
|
+
// (a reconciled conflict OR a disjoint auto-merge: installed === merged
|
|
1610
|
+
// === R, R ≠ catalog) must persist as `locally-modified` on the next
|
|
1611
|
+
// check-status — so the next update re-derives base === theirs (catalog)
|
|
1612
|
+
// → ours === R via the `base === theirs → ours` rule (schema-merge.ts),
|
|
1613
|
+
// keeping R durable at a stable fixed point. Were the baseline left at
|
|
1614
|
+
// the merged on-disk body, the next check would read `catalog-ahead` and
|
|
1615
|
+
// RESYNC the schema to the catalog, clobbering R. Mirrors resolveConflict's
|
|
1616
|
+
// base-advance. dryRun-guarded so a dry-run merge stamps nothing.
|
|
1617
|
+
if (!dryRun)
|
|
1618
|
+
stampBaselineFromBody(cwd, name, theirs, readDeclaredVersion(sourceFile) ?? "");
|
|
1619
|
+
}
|
|
1620
|
+
else {
|
|
1621
|
+
result.conflicts.push({ name, conflicts });
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
catch {
|
|
1625
|
+
// A parse/merge/meta-validation throw must not crash the per-asset
|
|
1626
|
+
// loop; fall back to refuse-and-report so the schema keeps its drift
|
|
1627
|
+
// signal and is surfaced for manual reconciliation.
|
|
1628
|
+
result.refused.push(name);
|
|
1629
|
+
}
|
|
1630
|
+
break;
|
|
1631
|
+
}
|
|
1632
|
+
default:
|
|
1633
|
+
// no-baseline / missing-catalog / missing-installed — undecidable or
|
|
1634
|
+
// absent; report with the state, take no action.
|
|
1635
|
+
result.reported.push({ name, state });
|
|
1636
|
+
break;
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
// TASK-051 / FGAP-080: reconcile pending-blocked.json to THIS run's blocked set
|
|
1640
|
+
// (live only — the dryRun arm collected nothing and writes nothing). When the
|
|
1641
|
+
// run produced blocked entries, persist exactly them; when it produced none,
|
|
1642
|
+
// REMOVE any prior sidecar so a now-unblocked model leaves no stale record. The
|
|
1643
|
+
// blocked contract (schema/block/migrations.json byte-unchanged) is untouched —
|
|
1644
|
+
// this sidecar + the pinned object are additive, outside that contract.
|
|
1645
|
+
// TASK-052 / FGAP-081: git-style in-file failure markers are the DEFAULT behavior
|
|
1646
|
+
// of a live validation-blocked schema (no flag, no mode). For every blocked entry
|
|
1647
|
+
// whose reason is validation-failed with non-empty failures, inscribe full-line
|
|
1648
|
+
// conflict sentinels INTO the block file at the offending items/fields so the
|
|
1649
|
+
// operator sees the problem inline (STORY-013). (1) Pin the PRE-MARKER block-file
|
|
1650
|
+
// bytes (wrapped so the object faithfully identifies the raw text) into the object
|
|
1651
|
+
// store and set `premarker_hash` on the entry BEFORE the sidecar write, so the
|
|
1652
|
+
// record carries the byte-exact restore point. (2) Compose the marker-bearing text
|
|
1653
|
+
// and raw-write it back via tmp+rename — NOT writeBlockForDir (the marker file is
|
|
1654
|
+
// not valid JSON; routing it through the block writer would throw). The SCHEMA and
|
|
1655
|
+
// migrations.json stay byte-unchanged (the blocked contract for those is intact);
|
|
1656
|
+
// only the block file changes, by design. A re-run over a block whose file ALREADY
|
|
1657
|
+
// carries sentinels RETAINS the prior entry's premarker_hash and does NOT re-mark
|
|
1658
|
+
// (CHANGE 4) — the marker file is composed once, from the genuine pre-marker bytes.
|
|
1659
|
+
// dryRun writes nothing (this whole block is !dryRun-guarded); a no-chain entry is
|
|
1660
|
+
// never marked (validation-failed only).
|
|
1661
|
+
if (!dryRun) {
|
|
1662
|
+
const priorPending = loadPendingBlockedForDir(destRoot);
|
|
1663
|
+
for (const entry of pendingBlockedEntries) {
|
|
1664
|
+
if (entry.reason !== "validation-failed" || !entry.failures || entry.failures.length === 0)
|
|
1665
|
+
continue;
|
|
1666
|
+
const blockFile = installedBlockDestPath(destRoot, entry.name);
|
|
1667
|
+
if (!fs.existsSync(blockFile))
|
|
1668
|
+
continue;
|
|
1669
|
+
const rawBytes = fs.readFileSync(blockFile, "utf-8");
|
|
1670
|
+
if (MARKER_LINE_RE_MULTILINE.test(rawBytes)) {
|
|
1671
|
+
// Already marked (a re-run): the freshly-built candidate `entry` is DEGRADED
|
|
1672
|
+
// — its failures were re-derived from the marker-bearing (non-JSON) block file,
|
|
1673
|
+
// which parses to a synthetic envelope-level failure ([{instancePath:"",
|
|
1674
|
+
// keyword:"type",message:"must be object"}]) rather than the genuine per-item
|
|
1675
|
+
// failures. RETAIN the prior pending entry WHOLE (failures, chain, from/to,
|
|
1676
|
+
// reason, blocked_at, premarker_hash) and discard the degraded candidate; the
|
|
1677
|
+
// marker file is left untouched (do not re-mark, do not re-pin). When no prior
|
|
1678
|
+
// entry exists (sentinels present but no sidecar — e.g. a hand-marked file or a
|
|
1679
|
+
// removed sidecar), there is nothing genuine to retain; keep the candidate as-is.
|
|
1680
|
+
const prior = priorPending?.entries.find((e) => e.name === entry.name);
|
|
1681
|
+
if (prior) {
|
|
1682
|
+
const idx = pendingBlockedEntries.indexOf(entry);
|
|
1683
|
+
if (idx >= 0)
|
|
1684
|
+
pendingBlockedEntries[idx] = prior;
|
|
1685
|
+
// Re-derive this run's blockedDetail for the schema from the retained entry
|
|
1686
|
+
// (the per-loop result.blockedDetail push carried the same degraded failures
|
|
1687
|
+
// the candidate did). Carry premarker_hash so renderBlocked can truthfully
|
|
1688
|
+
// assert that markers were written for this entry.
|
|
1689
|
+
const detail = result.blockedDetail.find((d) => d.name === prior.name);
|
|
1690
|
+
if (detail) {
|
|
1691
|
+
detail.reason = prior.reason;
|
|
1692
|
+
detail.from = prior.from;
|
|
1693
|
+
detail.to = prior.to;
|
|
1694
|
+
detail.failures = prior.failures;
|
|
1695
|
+
detail.premarker_hash = prior.premarker_hash;
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
continue;
|
|
1699
|
+
}
|
|
1700
|
+
const wrapper = { kind: "raw-block-bytes", block: entry.name, bytes: rawBytes };
|
|
1701
|
+
const premarkerHash = computeContentHash(wrapper);
|
|
1702
|
+
putObject(destRoot, premarkerHash, wrapper);
|
|
1703
|
+
entry.premarker_hash = premarkerHash;
|
|
1704
|
+
// Mirror the pin onto this run's blockedDetail so renderBlocked can truthfully
|
|
1705
|
+
// assert that markers were written for this entry (only marker-bearing entries
|
|
1706
|
+
// carry premarker_hash; dryRun and no-chain entries never reach this arm).
|
|
1707
|
+
const detail = result.blockedDetail.find((d) => d.name === entry.name);
|
|
1708
|
+
if (detail)
|
|
1709
|
+
detail.premarker_hash = premarkerHash;
|
|
1710
|
+
const markerText = composeMarkerText(rawBytes, entry.name, entry.from, entry.to, entry.failures);
|
|
1711
|
+
const tmpPath = `${blockFile}.markers-${process.pid}.tmp`;
|
|
1712
|
+
fs.writeFileSync(tmpPath, markerText);
|
|
1713
|
+
fs.renameSync(tmpPath, blockFile);
|
|
1714
|
+
}
|
|
1715
|
+
reconcilePendingBlockedForDir(destRoot, pendingBlockedEntries);
|
|
1716
|
+
}
|
|
1717
|
+
// Baseline refresh for the schemas this run actually brought current. A resync
|
|
1718
|
+
// overwrites the installed schema file with the catalog source but does NOT, by
|
|
1719
|
+
// itself, refresh the recorded install baseline (config.installed_from.assets) —
|
|
1720
|
+
// so without this step a just-resynced schema would still read as drifted
|
|
1721
|
+
// (installed === catalog ≠ stale-baseline → both-diverged) on the next
|
|
1722
|
+
// check-status. Mirror installContext's post-loop baseline write, but SURGICALLY:
|
|
1723
|
+
// refresh ONLY the resynced/migrated assets (their on-disk body IS the catalog
|
|
1724
|
+
// post-resync, so base === catalog either way). A merged schema is EXCLUDED here
|
|
1725
|
+
// (FGAP-070): it already stamped its baseline := the CATALOG body in the merge
|
|
1726
|
+
// arm, so re-fingerprinting its merged on-disk body would overwrite that with
|
|
1727
|
+
// the merged body and resync away a kept-local divergence on the next update. A
|
|
1728
|
+
// `refused` (locally-modified) schema is likewise not in `brought_current`, so it
|
|
1729
|
+
// keeps its drift signal. dryRun performs no writes, so it never refreshes.
|
|
1730
|
+
const brought_current = [...result.resynced, ...result.migrated];
|
|
1731
|
+
if (!dryRun && brought_current.length > 0) {
|
|
1732
|
+
// Refresh the install baseline + base-stamp the body for each schema this run
|
|
1733
|
+
// actually brought current, via the shared `refreshBaselineForSchema` helper
|
|
1734
|
+
// (TASK-037 — FEAT-006 T4 DRY-out of the prior inline body). The helper owns
|
|
1735
|
+
// its own per-name config load + content-addressed stamp + config write, and
|
|
1736
|
+
// is internally guarded against an absent / corrupt schema file (returns
|
|
1737
|
+
// false, leaving the stale baseline entry untouched) — so a present-but-
|
|
1738
|
+
// corrupt schema is skipped rather than crashing the update, mirroring the
|
|
1739
|
+
// prior inline safety default. A `refused` (locally-modified) schema is NOT
|
|
1740
|
+
// in `brought_current`, so it keeps its drift signal (re-fingerprinting it
|
|
1741
|
+
// would falsely mark it in-sync). dryRun is excluded by the outer guard, so a
|
|
1742
|
+
// dry-run never refreshes.
|
|
1743
|
+
for (const name of brought_current) {
|
|
1744
|
+
refreshBaselineForSchema(cwd, name);
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
// Config-registry propagation (TASK-038 — FEAT-006 T5). Bring catalog-new
|
|
1748
|
+
// keyed-array config-registry entries (relation_types / invariants /
|
|
1749
|
+
// block_kinds / lenses) that are ABSENT from the substrate config current with
|
|
1750
|
+
// the packaged catalog, ADDITIVELY: a user-authored entry (absent from the
|
|
1751
|
+
// catalog) and an existing entry whose body diverges from the catalog are both
|
|
1752
|
+
// preserved untouched (mergeCatalogRegistries never replaces a present id).
|
|
1753
|
+
// Re-load config FRESH — the baseline-refresh loop above wrote config.json, so
|
|
1754
|
+
// the `config` captured at function entry is stale (would drop those baseline
|
|
1755
|
+
// updates on write-back). Under dryRun: compute + record additions, write
|
|
1756
|
+
// nothing. A read/parse failure is swallowed so the schema-update result
|
|
1757
|
+
// (already computed) is not lost — registryAdditions simply stays empty.
|
|
1758
|
+
try {
|
|
1759
|
+
const catalog = JSON.parse(fs.readFileSync(path.join(samplesRoot, "conception.json"), "utf-8"));
|
|
1760
|
+
const fresh = loadConfig(cwd);
|
|
1761
|
+
if (fresh) {
|
|
1762
|
+
const { merged, additions } = mergeCatalogRegistries(fresh, catalog);
|
|
1763
|
+
result.registryAdditions = additions;
|
|
1764
|
+
if (!dryRun &&
|
|
1765
|
+
(additions.relation_types.length ||
|
|
1766
|
+
additions.invariants.length ||
|
|
1767
|
+
additions.block_kinds.length ||
|
|
1768
|
+
additions.lenses.length)) {
|
|
1769
|
+
writeConfig(cwd, merged);
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
catch {
|
|
1774
|
+
// Catalog read / parse / config-load failure: leave registryAdditions empty
|
|
1775
|
+
// (its initialized value) and return the schema-update result unchanged.
|
|
1776
|
+
}
|
|
1777
|
+
return result;
|
|
1778
|
+
}
|
|
1779
|
+
/**
|
|
1780
|
+
* Stamp an in-memory schema `body` as the install baseline
|
|
1781
|
+
* (`config.installed_from.assets[name]`) for one schema (TASK-037 — FEAT-006 T4
|
|
1782
|
+
* / FGAP-069). The shared stamp mechanics extracted from
|
|
1783
|
+
* `refreshBaselineForSchema`: compute the content_hash of `body`, store it into
|
|
1784
|
+
* the content-addressed object store (`putObject`) under that hash, set
|
|
1785
|
+
* `config.installed_from.assets[name] = { content_hash, version }` (refreshing
|
|
1786
|
+
* `at`), and write the config. Self-contained + idempotent: it owns its config
|
|
1787
|
+
* load + write. Returns the stamped `content_hash`, or `null` (no write) when
|
|
1788
|
+
* the substrate dir is unresolvable or the config carries no `installed_from`.
|
|
1789
|
+
*
|
|
1790
|
+
* Two callers stamp via this: `refreshBaselineForSchema` (re-baselines the
|
|
1791
|
+
* ON-DISK body — `update`'s post-loop refresh) and `resolveConflict` (advances
|
|
1792
|
+
* the baseline to the CATALOG body so the next `update` re-derives a resolved
|
|
1793
|
+
* schema as `locally-modified`, not a recurring conflict).
|
|
1794
|
+
*/
|
|
1795
|
+
export function stampBaselineFromBody(cwd, name, body, version) {
|
|
1796
|
+
const destRoot = tryResolveContextDir(cwd);
|
|
1797
|
+
if (destRoot === null)
|
|
1798
|
+
return null;
|
|
1799
|
+
const config = loadConfig(cwd);
|
|
1800
|
+
if (!config?.installed_from)
|
|
1801
|
+
return null;
|
|
1802
|
+
const hash = computeContentHash(body);
|
|
1803
|
+
putObject(destRoot, hash, body);
|
|
1804
|
+
const installed_from = {
|
|
1805
|
+
...config.installed_from,
|
|
1806
|
+
at: new Date().toISOString(),
|
|
1807
|
+
assets: {
|
|
1808
|
+
...config.installed_from.assets,
|
|
1809
|
+
[name]: { content_hash: hash, version },
|
|
1810
|
+
},
|
|
1811
|
+
};
|
|
1812
|
+
writeConfig(cwd, { ...config, installed_from });
|
|
1813
|
+
return hash;
|
|
1814
|
+
}
|
|
1815
|
+
/**
|
|
1816
|
+
* Re-stamp the install baseline (`config.installed_from.assets[name]`) for one
|
|
1817
|
+
* schema from its CURRENT on-disk body (TASK-037 — FEAT-006 T4). Self-contained
|
|
1818
|
+
* + idempotent. Used by `updateContext`'s post-loop refresh to re-baseline each
|
|
1819
|
+
* brought-current schema (resynced / migrated / auto-merged) so a follow-up
|
|
1820
|
+
* `/context check-status` reports it `in-sync`:
|
|
1821
|
+
*
|
|
1822
|
+
* - returns `false` (no write) when the installed schema file is absent, OR
|
|
1823
|
+
* its freshly-computed `content_hash` already equals the recorded baseline
|
|
1824
|
+
* hash (a true no-op — nothing was reconciled / written).
|
|
1825
|
+
* - otherwise delegates the stamp to `stampBaselineFromBody` (object-store
|
|
1826
|
+
* put + `assets[name]` set + config write from the on-disk body + its
|
|
1827
|
+
* declared version) and returns `true`.
|
|
1828
|
+
*
|
|
1829
|
+
* This is a pure idempotent re-stamp action (re-stamps the on-disk body as the
|
|
1830
|
+
* new baseline; false when the file is absent or its hash already equals the
|
|
1831
|
+
* baseline). Mirrors `updateContext`'s post-loop refresh body for ONE name.
|
|
1832
|
+
*/
|
|
1833
|
+
export function refreshBaselineForSchema(cwd, name) {
|
|
1834
|
+
const destRoot = tryResolveContextDir(cwd);
|
|
1835
|
+
if (destRoot === null)
|
|
1836
|
+
return false;
|
|
1837
|
+
const config = loadConfig(cwd);
|
|
1838
|
+
if (!config?.installed_from)
|
|
1839
|
+
return false;
|
|
1840
|
+
const destFile = installedSchemaDestPath(destRoot, name);
|
|
1841
|
+
if (!fs.existsSync(destFile))
|
|
1842
|
+
return false;
|
|
1843
|
+
const newHash = computeFileContentHash(destFile);
|
|
1844
|
+
if (newHash === config.installed_from.assets?.[name]?.content_hash)
|
|
1845
|
+
return false;
|
|
1846
|
+
const body = JSON.parse(fs.readFileSync(destFile, "utf-8"));
|
|
1847
|
+
const stamped = stampBaselineFromBody(cwd, name, body, typeof body.version === "string" ? body.version : "");
|
|
1848
|
+
return stamped !== null;
|
|
1849
|
+
}
|
|
1850
|
+
/**
|
|
1851
|
+
* Reconciliation-commit op (FGAP-069) — completes the caller-as-reconciler model
|
|
1852
|
+
* end-to-end. After `update` surfaces a both-diverged schema CONFLICT, the
|
|
1853
|
+
* calling agent reconciles the conflicting paths into a resolved body R and runs
|
|
1854
|
+
* this op. It does two things atomically per call:
|
|
1855
|
+
*
|
|
1856
|
+
* 1. WRITES R, when a `schema` is supplied: parse-if-string (mirroring the
|
|
1857
|
+
* write-schema op's tolerant JSON-string handling) then
|
|
1858
|
+
* `writeSchemaCheckedForDir(destRoot, name, R, "replace", ctx)` (AJV
|
|
1859
|
+
* meta-validate + nested-id guard + atomic write). When `schema` is omitted
|
|
1860
|
+
* the current on-disk body is treated as already reconciled — no write.
|
|
1861
|
+
* 2. ADVANCES the merge base to the CATALOG body (theirs): it reads the
|
|
1862
|
+
* catalog source schema, stamps it as the install baseline via
|
|
1863
|
+
* `stampBaselineFromBody`. This is the fix the bare write-schema lacks —
|
|
1864
|
+
* with the baseline advanced to the catalog, the next `update`'s 3-way
|
|
1865
|
+
* check resolves the schema as `locally-modified` (base === catalog ≠ R),
|
|
1866
|
+
* and `mergeSchema(base=catalog, ours=R, theirs=catalog)` takes R via the
|
|
1867
|
+
* `base === theirs → ours` rule → auto-merge, zero conflicts, R preserved.
|
|
1868
|
+
* Without this advance, the baseline stays at the original pre-conflict
|
|
1869
|
+
* body and `update` re-derives the SAME both-diverged conflict forever.
|
|
1870
|
+
*
|
|
1871
|
+
* Throws a clear error when the substrate dir is unresolvable, the config /
|
|
1872
|
+
* catalog kind for `name` is missing, or the catalog source schema is absent —
|
|
1873
|
+
* the base cannot be advanced without a catalog body to advance it to.
|
|
1874
|
+
*
|
|
1875
|
+
* Returns `{ schemaName, wroteSchema, baseAdvancedTo }`: `wroteSchema` is true
|
|
1876
|
+
* iff a `schema` was supplied and written; `baseAdvancedTo` is the content_hash
|
|
1877
|
+
* of the catalog body now stamped as the baseline.
|
|
1878
|
+
*/
|
|
1879
|
+
export function resolveConflict(cwd, name, schema, ctx) {
|
|
1880
|
+
const destRoot = tryResolveContextDir(cwd);
|
|
1881
|
+
if (destRoot === null) {
|
|
1882
|
+
throw new Error(`resolve-conflict: no active substrate resolved for '${cwd}'`);
|
|
1883
|
+
}
|
|
1884
|
+
const { samplesRoot, byId } = resolveCatalog();
|
|
1885
|
+
const kind = byId.get(name);
|
|
1886
|
+
if (!kind) {
|
|
1887
|
+
throw new Error(`resolve-conflict: no catalog block_kind named '${name}' — cannot advance the merge base`);
|
|
1888
|
+
}
|
|
1889
|
+
const sourceFile = path.join(samplesRoot, kind.schema_path);
|
|
1890
|
+
if (!fs.existsSync(sourceFile)) {
|
|
1891
|
+
throw new Error(`resolve-conflict: catalog schema source missing at ${sourceFile} for '${name}'`);
|
|
1892
|
+
}
|
|
1893
|
+
// 1. Write the reconciled body R when supplied. Type.Unknown() params may
|
|
1894
|
+
// arrive as JSON strings (mirror the write-schema op handler): parse if
|
|
1895
|
+
// possible, otherwise keep raw (meta-validation rejects a non-object body).
|
|
1896
|
+
let wroteSchema = false;
|
|
1897
|
+
if (schema !== undefined) {
|
|
1898
|
+
let body = schema;
|
|
1899
|
+
if (typeof body === "string") {
|
|
1900
|
+
try {
|
|
1901
|
+
body = JSON.parse(body);
|
|
1902
|
+
}
|
|
1903
|
+
catch {
|
|
1904
|
+
/* keep raw string — meta-validation will reject a non-object */
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
writeSchemaCheckedForDir(destRoot, name, body, "replace", ctx);
|
|
1908
|
+
wroteSchema = true;
|
|
1909
|
+
}
|
|
1910
|
+
// 2. Advance the merge base to the CATALOG body (theirs). Read + parse the
|
|
1911
|
+
// catalog source, stamp it as the new install baseline so the next update's
|
|
1912
|
+
// 3-way check sees base === catalog and takes R via base === theirs → ours.
|
|
1913
|
+
const catalogBody = JSON.parse(fs.readFileSync(sourceFile, "utf-8"));
|
|
1914
|
+
const version = readDeclaredVersion(sourceFile) ?? "";
|
|
1915
|
+
const catalogHash = stampBaselineFromBody(cwd, name, catalogBody, version);
|
|
1916
|
+
if (catalogHash === null) {
|
|
1917
|
+
throw new Error(`resolve-conflict: could not advance the merge base for '${name}' (no install baseline in config)`);
|
|
1918
|
+
}
|
|
1919
|
+
return { schemaName: name, wroteSchema, baseAdvancedTo: catalogHash };
|
|
1920
|
+
}
|
|
1921
|
+
/**
|
|
1922
|
+
* Blocked-resolution commit op (TASK-051 — FGAP-080) — the resolution half of
|
|
1923
|
+
* the blocked-resync loop `update` opens. After `update` REFUSES a catalog-ahead
|
|
1924
|
+
* resync (blocked) it persists a pending-blocked record pinning the TARGET
|
|
1925
|
+
* catalog schema body (in the object store) + the migration chain reaching it.
|
|
1926
|
+
* The calling agent then fixes the block's failing items (or widens the local
|
|
1927
|
+
* schema) and runs THIS op to commit the resolution against the SAME pinned
|
|
1928
|
+
* target the run blocked on — so a subsequent `update` converges (in-sync)
|
|
1929
|
+
* instead of re-blocking.
|
|
1930
|
+
*
|
|
1931
|
+
* Flow:
|
|
1932
|
+
* 1. Load the pending-blocked record; an absent entry for `name` throws a
|
|
1933
|
+
* field-named error (run `update` first to produce one).
|
|
1934
|
+
* 2. Retrieve the pinned target schema body by its `target_hash` from the
|
|
1935
|
+
* object store; a missing object throws (the pin is the resolution contract).
|
|
1936
|
+
* 3. Re-validate the CURRENT block against the PINNED target body: load the
|
|
1937
|
+
* installed block, forward-migrate its items IN MEMORY through the entry's
|
|
1938
|
+
* chain when the block's declared `schema_version` differs from the target
|
|
1939
|
+
* `to` version (a FRESH registry seeded existing-decls-first + the chain,
|
|
1940
|
+
* mirroring validateBlockItemsAgainstCatalog), then `validate`.
|
|
1941
|
+
* 4. FAIL → return `{ resolved: false, failures }` and WRITE NOTHING — the
|
|
1942
|
+
* pending record stays intact so the caller can correct + retry.
|
|
1943
|
+
* 5. PASS → in order: register the chain decls not already on disk (collecting
|
|
1944
|
+
* the registered set), write the target schema (replace), advance the
|
|
1945
|
+
* migrated block's `schema_version` envelope to `to` + persist it (skipping
|
|
1946
|
+
* the block write when it had no items — schema still written, base still
|
|
1947
|
+
* advanced, mirroring the live no-items handling), advance the merge base to
|
|
1948
|
+
* the target body, and clear the entry from pending-blocked.json (removing
|
|
1949
|
+
* the file when it becomes empty). Return `{ resolved: true,
|
|
1950
|
+
* registeredMigrations, baseAdvancedTo }`.
|
|
1951
|
+
*
|
|
1952
|
+
* Throws (no write) when the substrate dir is unresolvable, no pending entry
|
|
1953
|
+
* names `name`, or the pinned target object is missing.
|
|
1954
|
+
*/
|
|
1955
|
+
export function resolveBlocked(cwd, name, ctx) {
|
|
1956
|
+
const destRoot = tryResolveContextDir(cwd);
|
|
1957
|
+
if (destRoot === null) {
|
|
1958
|
+
throw new Error(`resolve-blocked: no active substrate resolved for '${cwd}'`);
|
|
1959
|
+
}
|
|
1960
|
+
const pending = loadPendingBlockedForDir(destRoot);
|
|
1961
|
+
const entry = pending?.entries.find((e) => e.name === name);
|
|
1962
|
+
if (!entry) {
|
|
1963
|
+
throw new Error(`schemaName: no pending-blocked entry for '${name}' — run update first`);
|
|
1964
|
+
}
|
|
1965
|
+
const targetBody = getObject(destRoot, entry.target_hash);
|
|
1966
|
+
if (targetBody === null) {
|
|
1967
|
+
throw new Error(`schemaName: pinned target schema object ${entry.target_hash} missing for '${name}' — cannot re-validate the block`);
|
|
1968
|
+
}
|
|
1969
|
+
// Load the installed block (the validateBlockItemsAgainstCatalog load pattern).
|
|
1970
|
+
// TASK-052 / FGAP-081: read the RAW text first. A live update inscribes git-style
|
|
1971
|
+
// failure markers INTO the block file (full-line `<<<<<<<`/`>>>>>>>` sentinels), so
|
|
1972
|
+
// the file is no longer valid JSON. Detect the sentinels by a full-line scan, STRIP
|
|
1973
|
+
// the marker lines, and parse the remainder. `strippedText` is retained so the PASS
|
|
1974
|
+
// path can raw-write it to disk BEFORE the commit's writeBlockForDir — the identity
|
|
1975
|
+
// stamp's prior-read then parses the ON-DISK stripped file and matches items by oid,
|
|
1976
|
+
// preserving oids (content_parent advances only on genuinely changed items, no
|
|
1977
|
+
// re-mint). A strip that still does not parse falls through to the parse-fail path;
|
|
1978
|
+
// on FAIL the marker file is left untouched (the no-write-on-fail contract).
|
|
1979
|
+
const blockFile = installedBlockDestPath(destRoot, name);
|
|
1980
|
+
let blockData;
|
|
1981
|
+
let wasMarked = false;
|
|
1982
|
+
let strippedText;
|
|
1983
|
+
try {
|
|
1984
|
+
if (fs.existsSync(blockFile)) {
|
|
1985
|
+
const rawText = fs.readFileSync(blockFile, "utf-8");
|
|
1986
|
+
wasMarked = MARKER_LINE_RE_MULTILINE.test(rawText);
|
|
1987
|
+
strippedText = wasMarked
|
|
1988
|
+
? rawText
|
|
1989
|
+
.split("\n")
|
|
1990
|
+
.filter((line) => !MARKER_LINE_RE.test(line))
|
|
1991
|
+
.join("\n")
|
|
1992
|
+
: rawText;
|
|
1993
|
+
blockData = JSON.parse(strippedText);
|
|
1994
|
+
}
|
|
1995
|
+
else {
|
|
1996
|
+
blockData = undefined;
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
catch (err) {
|
|
2000
|
+
return {
|
|
2001
|
+
schemaName: name,
|
|
2002
|
+
resolved: false,
|
|
2003
|
+
failures: [{ instancePath: "", keyword: "error", message: String(err) }],
|
|
2004
|
+
};
|
|
2005
|
+
}
|
|
2006
|
+
const blockVersion = blockData && typeof blockData === "object" && "schema_version" in blockData
|
|
2007
|
+
? blockData.schema_version
|
|
2008
|
+
: undefined;
|
|
2009
|
+
const targetVersion = entry.to;
|
|
2010
|
+
// Determine whether the block carries items (mirrors resyncSchema's hasItems).
|
|
2011
|
+
let hasItems = false;
|
|
2012
|
+
if (blockData && typeof blockData === "object") {
|
|
2013
|
+
forEachBlockArray(blockData, (_arrayKey, arr) => {
|
|
2014
|
+
if (arr.length > 0)
|
|
2015
|
+
hasItems = true;
|
|
2016
|
+
});
|
|
2017
|
+
}
|
|
2018
|
+
// Re-validate the block against the PINNED target body, forward-migrating its
|
|
2019
|
+
// items in memory through the entry chain when the block lags the target. A
|
|
2020
|
+
// FRESH registry seeded existing-decls-first + the chain, deduped on
|
|
2021
|
+
// (schemaName, fromVersion) — never warm the project's cached registry.
|
|
2022
|
+
let migrated = blockData;
|
|
2023
|
+
try {
|
|
2024
|
+
if (typeof blockVersion === "string" &&
|
|
2025
|
+
typeof targetVersion === "string" &&
|
|
2026
|
+
blockVersion !== targetVersion &&
|
|
2027
|
+
entry.chain.length > 0) {
|
|
2028
|
+
const registry = buildFreshRegistryWithChain(destRoot, entry.chain);
|
|
2029
|
+
migrated = runMigrations(registry, name, blockVersion, targetVersion, blockData);
|
|
2030
|
+
}
|
|
2031
|
+
validate(targetBody, migrated, name);
|
|
2032
|
+
}
|
|
2033
|
+
catch (err) {
|
|
2034
|
+
const failures = err instanceof ValidationError
|
|
2035
|
+
? mapValidationFailures(err.errors, blockData)
|
|
2036
|
+
: [{ instancePath: "", keyword: "error", message: String(err) }];
|
|
2037
|
+
return { schemaName: name, resolved: false, failures };
|
|
2038
|
+
}
|
|
2039
|
+
// PASS — commit the resolution. (1) Register the chain decls not already on
|
|
2040
|
+
// disk (the resyncSchema dedup), collecting the registered set.
|
|
2041
|
+
const existing = loadMigrationsFileForDir(destRoot);
|
|
2042
|
+
const present = new Set((existing?.migrations ?? []).map((m) => `${m.schemaName} ${m.fromVersion}`));
|
|
2043
|
+
const registeredMigrations = [];
|
|
2044
|
+
for (const decl of entry.chain) {
|
|
2045
|
+
const key = `${decl.schemaName} ${decl.fromVersion}`;
|
|
2046
|
+
if (present.has(key))
|
|
2047
|
+
continue;
|
|
2048
|
+
appendMigrationDeclForDir(destRoot, decl, ctx);
|
|
2049
|
+
present.add(key);
|
|
2050
|
+
registeredMigrations.push({ schema: decl.schemaName, from: decl.fromVersion, to: decl.toVersion });
|
|
2051
|
+
}
|
|
2052
|
+
// (2) Write the target schema (replace — meta-validated, nested-id-guarded, atomic).
|
|
2053
|
+
writeSchemaCheckedForDir(destRoot, name, targetBody, "replace", ctx);
|
|
2054
|
+
// (3) Advance the migrated block's schema_version envelope to the target + persist
|
|
2055
|
+
// it (skip when the block had no items — schema still written, base still advanced,
|
|
2056
|
+
// mirroring the live no-items handling). Identity stamping re-runs on the write.
|
|
2057
|
+
if (hasItems) {
|
|
2058
|
+
// TASK-052 / FGAP-081 (oid stability): when the on-disk block carried markers,
|
|
2059
|
+
// raw-write the STRIPPED text to the block file (tmp+rename) BEFORE
|
|
2060
|
+
// writeBlockForDir, so the identity-stamp prior-read parses the marker-free
|
|
2061
|
+
// on-disk file and preserves each item's oid (no re-mint; content_parent advances
|
|
2062
|
+
// only on genuinely changed items).
|
|
2063
|
+
if (wasMarked && strippedText !== undefined) {
|
|
2064
|
+
const tmpPath = `${blockFile}.unmark-${process.pid}.tmp`;
|
|
2065
|
+
fs.writeFileSync(tmpPath, strippedText);
|
|
2066
|
+
fs.renameSync(tmpPath, blockFile);
|
|
2067
|
+
}
|
|
2068
|
+
if (migrated &&
|
|
2069
|
+
typeof migrated === "object" &&
|
|
2070
|
+
!Array.isArray(migrated) &&
|
|
2071
|
+
typeof migrated.schema_version === "string" &&
|
|
2072
|
+
typeof targetVersion === "string") {
|
|
2073
|
+
migrated.schema_version = targetVersion;
|
|
2074
|
+
}
|
|
2075
|
+
writeBlockForDir(destRoot, name, migrated);
|
|
2076
|
+
}
|
|
2077
|
+
// (4) Advance the merge base to the target body so a subsequent update converges
|
|
2078
|
+
// (base === catalog) instead of re-deriving drift.
|
|
2079
|
+
const baseAdvancedTo = stampBaselineFromBody(cwd, name, targetBody, targetVersion ?? "");
|
|
2080
|
+
// (5) Clear the resolved entry from pending-blocked.json (remove the file when
|
|
2081
|
+
// it becomes empty — no stale empty sidecar).
|
|
2082
|
+
const remaining = (pending?.entries ?? []).filter((e) => e.name !== name);
|
|
2083
|
+
reconcilePendingBlockedForDir(destRoot, remaining, ctx);
|
|
2084
|
+
return { schemaName: name, resolved: true, registeredMigrations, baseAdvancedTo };
|
|
2085
|
+
}
|
|
2086
|
+
/**
|
|
2087
|
+
* Render an `UpdateResult["conflicts"]` set as a readable conflict report
|
|
2088
|
+
* (TASK-037 — FEAT-006 T4 / FGAP-069) — the surface the `update` op + CLI hand
|
|
2089
|
+
* to the CALLING agent, which reconciles each conflict into a resolved body and
|
|
2090
|
+
* commits it via the `resolve-conflict` op (writes the body AND advances the
|
|
2091
|
+
* merge base to the catalog so `update` stops re-reporting it; no subordinate
|
|
2092
|
+
* resolver is spawned). Mirrors `renderCheckStatus`'s grouping
|
|
2093
|
+
* style: one section per conflicting schema `name`, then each irreconcilable
|
|
2094
|
+
* `{ path, base, ours, theirs }` with its three values JSON-compacted for a
|
|
2095
|
+
* side-by-side scan, then a trailing guidance line stating how to apply a
|
|
2096
|
+
* reconciliation. Pure: no I/O, no writes.
|
|
2097
|
+
*/
|
|
2098
|
+
export function renderConflicts(conflicts) {
|
|
2099
|
+
const lines = [];
|
|
2100
|
+
lines.push("Schema merge conflicts — manual reconciliation required (no writes performed):");
|
|
2101
|
+
if (conflicts.length === 0) {
|
|
2102
|
+
lines.push(" (no conflicts)");
|
|
2103
|
+
return lines.join("\n");
|
|
2104
|
+
}
|
|
2105
|
+
for (const { name, conflicts: set } of conflicts) {
|
|
2106
|
+
lines.push(` ${name} (${set.length} conflict${set.length === 1 ? "" : "s"}):`);
|
|
2107
|
+
for (const c of set) {
|
|
2108
|
+
lines.push(` ${c.path}`);
|
|
2109
|
+
lines.push(` base: ${JSON.stringify(c.base)}`);
|
|
2110
|
+
lines.push(` ours: ${JSON.stringify(c.ours)}`);
|
|
2111
|
+
lines.push(` theirs: ${JSON.stringify(c.theirs)}`);
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
lines.push("To resolve each: reconcile the conflicting paths into a schema, then resolve-conflict --schemaName <name> --schema <reconciled> — it writes your schema AND advances the merge base to the catalog so update stops re-reporting it.");
|
|
2115
|
+
return lines.join("\n");
|
|
2116
|
+
}
|
|
2117
|
+
/**
|
|
2118
|
+
* Render the per-schema blocked-resync diagnostic (TASK-048 — FGAP-077) as a
|
|
2119
|
+
* readable report the CLI surfaces below `update`'s output when a catalog-ahead
|
|
2120
|
+
* resync was refused. One section per blocked schema `name`:
|
|
2121
|
+
* - header `blocked: <name> (<from> -> <to>)` (the installed→catalog version
|
|
2122
|
+
* pair; `?` substitutes a missing version).
|
|
2123
|
+
* - `no-migration-chain` → one line `no migration chain reaches <to> from
|
|
2124
|
+
* <from>`.
|
|
2125
|
+
* - `validation-failed` → one line per failing item, naming the item id (or the
|
|
2126
|
+
* `instancePath` when no id resolved), the field (the tail of `instancePath`),
|
|
2127
|
+
* and the constraint phrased keyword-aware — MIRRORING the CLI's
|
|
2128
|
+
* `formatAjvError` keyword switch (required / type / enum / additionalProperties
|
|
2129
|
+
* fall through to the raw message), reproduced here rather than imported to
|
|
2130
|
+
* avoid a pi-context → pi-context-cli dependency cycle (render.ts imports this
|
|
2131
|
+
* package). A failure carrying no AJV `keyword` mapping prints its raw message.
|
|
2132
|
+
*
|
|
2133
|
+
* TASK-052 / FGAP-081: a LIVE `update` that blocks a `validation-failed` resync
|
|
2134
|
+
* inscribes git-style failure markers INTO the block file at the offending items —
|
|
2135
|
+
* and ONLY then does the trailing guidance claim, in the past tense, that markers
|
|
2136
|
+
* "were written INTO the block file(s)". That claim is keyed on the per-entry
|
|
2137
|
+
* `premarker_hash` (set only when markers were actually inscribed): a dryRun preview
|
|
2138
|
+
* writes nothing and a `no-migration-chain` entry is never marked, so neither carries
|
|
2139
|
+
* `premarker_hash` — for those the report keeps each entry's reason line + neutral
|
|
2140
|
+
* fix-then-resolve guidance WITHOUT the past-tense write claim. In all cases the
|
|
2141
|
+
* schema + `migrations.json` stay byte-unchanged. Pure: no I/O, no writes.
|
|
2142
|
+
*/
|
|
2143
|
+
export function renderBlocked(blockedDetail) {
|
|
2144
|
+
const lines = [];
|
|
2145
|
+
lines.push("Schema resync blocked (schema + migrations.json unchanged):");
|
|
2146
|
+
if (blockedDetail.length === 0) {
|
|
2147
|
+
lines.push(" (no blocked schemas)");
|
|
2148
|
+
return lines.join("\n");
|
|
2149
|
+
}
|
|
2150
|
+
for (const d of blockedDetail) {
|
|
2151
|
+
const from = d.from ?? "?";
|
|
2152
|
+
const to = d.to ?? "?";
|
|
2153
|
+
lines.push(` blocked: ${d.name} (${from} -> ${to})`);
|
|
2154
|
+
if (d.reason === "no-migration-chain") {
|
|
2155
|
+
lines.push(` no migration chain reaches ${to} from ${from}`);
|
|
2156
|
+
continue;
|
|
2157
|
+
}
|
|
2158
|
+
// validation-failed
|
|
2159
|
+
for (const f of d.failures ?? []) {
|
|
2160
|
+
const subject = f.itemId ?? f.instancePath ?? "(item)";
|
|
2161
|
+
const field = f.instancePath ? f.instancePath.split("/").filter(Boolean).pop() : undefined;
|
|
2162
|
+
const fieldClause = field ? ` field \`${field}\`` : "";
|
|
2163
|
+
lines.push(` ${subject}:${fieldClause} ${describeBlockedFailure(f)}`);
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
// Past-tense write claim ONLY when at least one entry actually carries markers
|
|
2167
|
+
// (premarker_hash present). Otherwise the resync was refused without inscribing
|
|
2168
|
+
// anything (dryRun preview, or only no-migration-chain entries) — emit neutral
|
|
2169
|
+
// fix-then-resolve guidance that does NOT assert a write that did not happen.
|
|
2170
|
+
const anyMarked = blockedDetail.some((d) => d.premarker_hash);
|
|
2171
|
+
if (anyMarked) {
|
|
2172
|
+
lines.push("Git-style failure markers were written INTO the block file(s): open each block file, fix the items between the `<<<<<<< BLOCKED …` / `>>>>>>> target: …` markers, then resolve-blocked --schemaName <name> --yes — it strips the markers, re-validates the corrected block against the pinned target, writes the target schema, advances the merge base, and clears the block so update converges.");
|
|
2173
|
+
}
|
|
2174
|
+
else {
|
|
2175
|
+
lines.push("No markers were written (preview, or no migration chain to mark against). Resolve a validation-failed block by correcting the offending items in the block file, then resolve-blocked --schemaName <name> --yes — it re-validates the corrected block against the pinned target, writes the target schema, advances the merge base, and clears the block so update converges.");
|
|
2176
|
+
}
|
|
2177
|
+
return lines.join("\n");
|
|
2178
|
+
}
|
|
2179
|
+
/**
|
|
2180
|
+
* Phrase ONE blocked validation failure keyword-aware, mirroring the CLI's
|
|
2181
|
+
* `formatAjvError` keyword switch (TASK-048). The minimal {@link
|
|
2182
|
+
* BlockValidationFailure} shape drops the AJV `params`, so the required /
|
|
2183
|
+
* additionalProperties branches that need `missingProperty` / `additionalProperty`
|
|
2184
|
+
* fall back to the raw AJV `message` (which already names them); type / enum and
|
|
2185
|
+
* every other keyword likewise surface the AJV message, prefixed by the constraint
|
|
2186
|
+
* keyword so the failing constraint is named even without params.
|
|
2187
|
+
*/
|
|
2188
|
+
function describeBlockedFailure(f) {
|
|
2189
|
+
const msg = f.message || "invalid";
|
|
2190
|
+
return f.keyword && f.keyword !== "error" ? `${f.keyword} — ${msg}` : msg;
|
|
2191
|
+
}
|
|
355
2192
|
/**
|
|
356
2193
|
* /context init — scaffold the substrate dir (bootstrap pointer + substrate +
|
|
357
2194
|
* schemas directories only; no asset copying). Run accept-all + install to
|
|
@@ -804,6 +2641,18 @@ const extension = (pi) => {
|
|
|
804
2641
|
if (result.updated.length > 0) {
|
|
805
2642
|
lines.push(`Updated (${result.updated.length}): ${result.updated.join(", ")}`);
|
|
806
2643
|
}
|
|
2644
|
+
if (result.resynced.length > 0) {
|
|
2645
|
+
lines.push(`Re-synced (${result.resynced.length}): ${result.resynced.join(", ")}`);
|
|
2646
|
+
}
|
|
2647
|
+
if (result.migrated.length > 0) {
|
|
2648
|
+
lines.push(`Migrated (${result.migrated.length}, schema bumped — block items forward-migrated): ${result.migrated.join(", ")}`);
|
|
2649
|
+
}
|
|
2650
|
+
if (result.blocked.length > 0) {
|
|
2651
|
+
lines.push(`Blocked (${result.blocked.length}, no safe migration — left unchanged): ${result.blocked.join(", ")}`);
|
|
2652
|
+
}
|
|
2653
|
+
if (result.preserved.length > 0) {
|
|
2654
|
+
lines.push(`Preserved (${result.preserved.length}, populated — block data is never overwritten): ${result.preserved.join(", ")}`);
|
|
2655
|
+
}
|
|
807
2656
|
if (result.skipped.length > 0) {
|
|
808
2657
|
lines.push(`Skipped (${result.skipped.length}, exists — pass --update to overwrite): ${result.skipped.join(", ")}`);
|
|
809
2658
|
}
|
|
@@ -813,10 +2662,16 @@ const extension = (pi) => {
|
|
|
813
2662
|
if (lines.length === 0) {
|
|
814
2663
|
lines.push("Nothing declared in installed_schemas / installed_blocks — edit the substrate dir's config.json to add entries.");
|
|
815
2664
|
}
|
|
816
|
-
const level = result.notFound.length > 0 ? "warning" : "info";
|
|
2665
|
+
const level = result.notFound.length > 0 || result.blocked.length > 0 ? "warning" : "info";
|
|
817
2666
|
ctx.ui.notify(lines.join("\n"), level);
|
|
818
2667
|
},
|
|
819
2668
|
},
|
|
2669
|
+
"check-status": {
|
|
2670
|
+
description: "Preview installed-vs-catalog schema drift (read-only; writes nothing)",
|
|
2671
|
+
handler: (_args, ctx) => {
|
|
2672
|
+
ctx.ui.notify(renderCheckStatus(checkStatus(ctx.cwd)), "info");
|
|
2673
|
+
},
|
|
2674
|
+
},
|
|
820
2675
|
"accept-all": {
|
|
821
2676
|
description: "Adopt the canonical packaged conception as config.json (writes config only; run install after)",
|
|
822
2677
|
handler: (args, ctx) => handleAcceptAll(args, ctx),
|
|
@@ -997,6 +2852,10 @@ const extension = (pi) => {
|
|
|
997
2852
|
});
|
|
998
2853
|
};
|
|
999
2854
|
export default extension;
|
|
2855
|
+
// Re-export the config-registry-propagation surface (TASK-038 — FEAT-006 T5) so
|
|
2856
|
+
// consumers can type `UpdateResult.registryAdditions` and call the pure merge
|
|
2857
|
+
// helper against the public `@davidorex/pi-context` surface.
|
|
2858
|
+
export { mergeCatalogRegistries } from "./context.js";
|
|
1000
2859
|
export { contextRegistryPath, invalidateRegistry, loadRegistry, REGISTRY_FILE_VERSION, registerSubstrate, resolveAlias, resolveSubstrateDir, writeRegistry, } from "./context-registry.js";
|
|
1001
2860
|
// Re-export for consumers
|
|
1002
2861
|
export { blockStructure, buildIdIndex, CONTEXT_BLOCK_TYPES, completeTask, findAppendableBlocks, resolveItemById, resolveRef, schemaInfo, schemaVocabulary, } from "./context-sdk.js";
|