Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/20809-guidance-slot-docblocks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/spec': patch
---

docs(spec): the `strictObject` `guidance` option and the `ToolSchema` guidance table no longer describe `guidance` rows as tombstones for retired keys

Clause-②: no

`StrictObjectOptions.guidance` (in the published declarations) and the docblock above
`TOOL_RETIRED_KEY_GUIDANCE` in `src/ai/tool.zod.ts` (shipped as source) called the slot a
place for "tombstones for retired keys". A tombstone is `retiredKey()` in the shape, which
keeps the key declared, and a `guidance` row for a declared key never fires. The docblocks
now say what the slot is for: prescriptions for keys the shape does not declare —
wrong-layer pointers, and the upgrade for a spelling removed from the shape.

Text only: no schema, no guidance entry, no prescription and no parse behaviour changes.
74 changes: 74 additions & 0 deletions packages/spec/scripts/build-migration-registry-entry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// `build-migration-registry.ts` has TWO kinds of caller now, and its entry guard
// is the only thing telling them apart:
//
// - pnpm RUNS it (`gen:migration-registry`, `check:migration-registry`), and
// write mode rewrites `src/migrations/registry.ts`;
// - `build-schemas.ts` IMPORTS it, for `shardNameFor` — so the entry file each
// registration remedy names is the one name the generator accepts, read from
// the rule `entries/README.md` documents instead of restated beside it.
//
// Both directions are pinned, because each one fails silently on its own. A
// guard that answered "imported" for a real run turns the CI gate into exit 0
// with no output; one that answered "run" for an import executes the generator
// inside every build of the schemas. Each probe is a real `tsx` process, the
// runner both callers use, so neither half is a model of the other.

import { describe, it, expect, afterAll } from 'vitest';
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const HERE = path.dirname(fileURLToPath(import.meta.url));
const PKG = path.resolve(HERE, '..');
const TSX = path.join(PKG, 'node_modules', '.bin', 'tsx');
const GENERATOR = path.join(HERE, 'build-migration-registry.ts');
const SPAWN_TIMEOUT_MS = 60_000;

const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'build-migration-registry-entry-'));
afterAll(() => fs.rmSync(scratch, { recursive: true, force: true }));

function tsx(args: string[]): { status: number; stdout: string; stderr: string } {
const r = spawnSync(TSX, args, {
cwd: PKG,
encoding: 'utf8',
timeout: SPAWN_TIMEOUT_MS,
stdio: ['ignore', 'pipe', 'pipe'],
});
return { status: r.status ?? -1, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
}

describe('build-migration-registry.ts — run, it speaks; import, it runs nothing', () => {
it('a direct run still reaches the generator (its self-test answers)', { timeout: SPAWN_TIMEOUT_MS }, () => {
const r = tsx([GENERATOR, '--self-test']);
expect(r.status, r.stderr).toBe(0);
expect(r.stdout).toContain('build-migration-registry --self-test: ok');
});

it('a run through a symlink still reaches it — node keeps argv[1] as typed', { timeout: SPAWN_TIMEOUT_MS }, () => {
const link = path.join(scratch, 'linked-generator.ts');
fs.symlinkSync(GENERATOR, link);
const r = tsx([link, '--self-test']);
expect(r.status, r.stderr).toBe(0);
expect(r.stdout).toContain('build-migration-registry --self-test: ok');
});

it('an import runs nothing: the importer prints exactly what it asked for', { timeout: SPAWN_TIMEOUT_MS }, () => {
// The README's own example, so a reader can check the expectation by eye.
const probe = path.join(scratch, 'import-probe.ts');
fs.writeFileSync(
probe,
`import { shardNameFor } from ${JSON.stringify(GENERATOR)};\n` +
`console.log(shardNameFor(17, 'data/AggregationNode:distinct'));\n`,
);
const r = tsx([probe]);
expect(r.status, r.stderr).toBe(0);
// Write mode would have printed `✓ wrote src/migrations/registry.ts (…)`, and
// check mode `✓ … is current` — any line beyond the probe's own is the defect.
expect(r.stdout).toBe('17.data__AggregationNode__distinct.ts\n');
expect(r.stderr).toBe('');
});
});
117 changes: 71 additions & 46 deletions packages/spec/scripts/build-migration-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@
* pnpm --filter @objectstack/spec check:migration-registry # --self-test && --check
*/

import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { readFileSync, readdirSync, realpathSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

Expand Down Expand Up @@ -144,6 +144,10 @@ export interface Entry {
* FUNCTION (one id → one name), never reversible — the id is written inside the
* file, and two ids that collided on a name would collide as an add/add
* conflict, which is the behaviour duplicates should get anyway.
*
* `build-schemas.ts` names the file each of its registration remedies asks for
* through this same function, so a gate never prints a name this generator
* would refuse.
*/
export function shardNameFor(major: number, id: string): string {
return `${major}.${id.replaceAll('/', '__').replaceAll(':', '__')}.ts`;
Expand Down Expand Up @@ -423,58 +427,79 @@ function selfTest(): string[] {
}

// ---------------------------------------------------------------------------
// main
// main — only when node RAN this file, never when something imported it
// ---------------------------------------------------------------------------
//
// `build-schemas.ts` imports `shardNameFor`, `KINDS` and `ENTRIES_DIR`, so every
// remedy it prints names the entry file THIS rule accepts — the one naming rule
// `entries/README.md` documents, read rather than restated beside it. An import
// must therefore run nothing: write mode rewrites `registry.ts`. Spelled as
// `check-generated.ts` spells it, realpath on BOTH sides — node resolves
// symlinks for the module graph but leaves `argv[1]` as typed, and a guard that
// compared the two raw would turn `check:migration-registry` into exit 0 with
// no output. `build-migration-registry-entry.test.ts` pins both directions.

function main(argv: readonly string[]): void {
if (argv.includes('--self-test')) {
const failures = selfTest();
if (failures.length) {
console.error('build-migration-registry --self-test FAILED:\n' + failures.map((f) => ` - ${f}`).join('\n'));
process.exit(1);
}
console.log('build-migration-registry --self-test: ok');
if (argv.length === 1) process.exit(0);
}

const argv = process.argv.slice(2);
const check = argv.includes('--check');
const registryPath = join(pkgRoot, REGISTRY_PATH);
const source = readFileSync(registryPath, 'utf8');

if (argv.includes('--self-test')) {
const failures = selfTest();
if (failures.length) {
console.error('build-migration-registry --self-test FAILED:\n' + failures.map((f) => ` - ${f}`).join('\n'));
process.exit(1);
const byKind = {} as Record<Kind, Entry[]>;
const errors: string[] = [];
for (const { kind } of KINDS) {
const loaded = loadEntries(kind);
byKind[kind] = loaded.entries;
errors.push(...loaded.errors);
}
console.log('build-migration-registry --self-test: ok');
if (argv.length === 1) process.exit(0);
}

const check = argv.includes('--check');
const registryPath = join(pkgRoot, REGISTRY_PATH);
const source = readFileSync(registryPath, 'utf8');
const { text, errors: spliceErrors } = renderRegistry(source, byKind);
errors.push(...spliceErrors);

const byKind = {} as Record<Kind, Entry[]>;
const errors: string[] = [];
for (const { kind } of KINDS) {
const loaded = loadEntries(kind);
byKind[kind] = loaded.entries;
errors.push(...loaded.errors);
}

const { text, errors: spliceErrors } = renderRegistry(source, byKind);
errors.push(...spliceErrors);
if (errors.length) {
console.error('build-migration-registry: the entry directories are not readable as a registry.\n');
for (const e of errors) console.error(` ✗ ${e}`);
process.exit(1);
}

if (errors.length) {
console.error('build-migration-registry: the entry directories are not readable as a registry.\n');
for (const e of errors) console.error(` ✗ ${e}`);
process.exit(1);
const counts = KINDS.map(({ kind }) => `${byKind[kind].length} ${kind}`).join(', ');

if (check) {
if (text !== source) {
console.error(
`✗ ${REGISTRY_PATH} is stale — its generated regions do not match ${ENTRIES_DIR}/.\n\n` +
' Run: pnpm --filter @objectstack/spec gen:migration-registry\n\n' +
' If you reached this after resolving a merge conflict INSIDE a marked region: do not\n' +
' hand-merge it. The entry files are the source and git merged them as a set; the only\n' +
' correct resolution is to regenerate. This gate exists because a resolution that drops\n' +
" one side's entry produces no other error anywhere (#6957).",
);
process.exit(1);
}
console.log(`✓ ${REGISTRY_PATH} is current (${counts})`);
} else {
if (text !== source) writeFileSync(registryPath, text);
console.log(`✓ wrote ${REGISTRY_PATH} (${counts})`);
}
}

const counts = KINDS.map(({ kind }) => `${byKind[kind].length} ${kind}`).join(', ');

if (check) {
if (text !== source) {
console.error(
`✗ ${REGISTRY_PATH} is stale — its generated regions do not match ${ENTRIES_DIR}/.\n\n` +
' Run: pnpm --filter @objectstack/spec gen:migration-registry\n\n' +
' If you reached this after resolving a merge conflict INSIDE a marked region: do not\n' +
' hand-merge it. The entry files are the source and git merged them as a set; the only\n' +
' correct resolution is to regenerate. This gate exists because a resolution that drops\n' +
" one side's entry produces no other error anywhere (#6957).",
);
process.exit(1);
const invokedDirectly = (() => {
const entry = process.argv[1];
if (!entry) return false;
try {
return realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
console.log(`✓ ${REGISTRY_PATH} is current (${counts})`);
} else {
if (text !== source) writeFileSync(registryPath, text);
console.log(`✓ wrote ${REGISTRY_PATH} (${counts})`);
}
})();

if (invokedDirectly) main(process.argv.slice(2));
Loading
Loading