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
11 changes: 11 additions & 0 deletions .changeset/6705-parity-scan-reads-ast.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
---
---

Test-only change to `zod-mirror-parity.test.ts`'s `SPEC_DERIVED_PAIRS` re-check: it now
reads `Spec…` references off the TypeScript AST instead of scanning raw text between
`export const` boundaries, so a token mentioned in a comment is no longer counted as a
reference and no longer misattributed to the neighbouring export (objectui#6705). The one
change under `packages/types/src/zod/` is a docstring: the wording PR #6704 had to contort
around the old scanner is restored, along with the removal of the trap comment that asked
the next editor to remember. No published behaviour changes — no runtime, type or schema
surface moves.
250 changes: 237 additions & 13 deletions packages/types/src/__tests__/zod-mirror-parity.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -113,6 +113,7 @@ import { describe, it, expect } from 'vitest';
import { readdirSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import ts from 'typescript';
import type { z } from 'zod';

import { AppActionSchema, AppComponentSchema, NavigationAreaSchema } from '../zod/app.zod.js';
Expand DownExpand Up@@ -762,8 +763,17 @@ interface KnownDrift {
* are recorded here so whoever works them off does not re-derive them:
*
* - **SPEC-DERIVED (3 entries, 13 keys)** — `DashboardComponentSchema`,
* `DashboardWidgetSchema`, `ObjectViewSchema` are in `SPEC_DERIVED_PAIRS` below,
* so their mirror takes its shape BY REFERENCE from `@objectstack/spec`. An
* `DashboardWidgetSchema`, `ObjectViewSchema`. ⚠️ objectui#6705 invalidated the
* evidence for ONE of the three: `ObjectViewSchema` is no longer in
* `SPEC_DERIVED_PAIRS` below, because it never referenced a spec schema — it is
* `BaseSchema.extend({…})` of local literals, and the pre-#6705 text scanner
* charged it a neighbouring private const's `Spec…` token. The split is left
* STANDING as written, counts and all: re-routing those keys from #2231's
* unification question to a local mirror edit is a remedy decision on the
* `UnmirroredDeclared` ledger, which #6705 was fenced out of. Whoever works
* this split off must re-derive `ObjectViewSchema`'s side first.
* For the other two the reading is unchanged:
* their mirror takes its shape BY REFERENCE from `@objectstack/spec`. An
* unmirrored declared key there means the LOCAL declaration carries members the
* spec schema does not model, which is objectui#2231's unification question and
* NOT a local mirror edit. They are marked, not exempted: exempting them in the
Expand DownExpand Up@@ -1212,8 +1222,9 @@ export const assertionDriftMatchesLedger: never = 0 as unknown as LedgerMismatch
* ⚠️ And it is a COMPILE-TIME assertion. The `describe` block at the bottom of this
* file is a population census — it checks that the registry is closed and that
* `SPEC_DERIVED_PAIRS` re-derives, and it never compares keys at all. Its
* `Tests 5 passed (5)` line does not move when this half reddens, correctly, and it
* did not move under the ablation either. Reading the runtime half for evidence
* `Tests 12 passed (12)` line does not move when this half reddens, correctly, and
* it did not move under the ablation either. (It read `5 passed (5)` until
* objectui#6705 added the seven-fixture suite pinning the re-check's scanner.) Reading the runtime half for evidence
* about drift measures the wrong instrument and concludes the guard does nothing.
*
* When it fires, fix it by MEASURING (the compiler-API recipe and the
Expand DownExpand Up@@ -1434,14 +1445,26 @@ const EXCLUSIONS: Readonly<Record<string, string>> = {
const SPEC_DERIVED_PAIRS: readonly string[] = [
'app.zod.ts#AppComponentSchema',
'app.zod.ts#NavigationAreaSchema',
'base.zod.ts#BaseSchema',
// `base.zod.ts#BaseSchema` and `objectql.zod.ts#ObjectViewSchema` used to stand
// here and were removed by objectui#6705 — NOT because either mirror changed,
// but because the re-check below stopped mis-reading them. Neither references a
// spec schema; each was held here by one of the two defects that card names:
// - `BaseSchema` — its file's ONLY `Spec…` token is in a COMMENT
// (`base.zod.ts`, "rather than calling `SpecSchema.omit(…)`"). Prose.
// - `ObjectViewSchema` — `BaseSchema.extend({…})` with every member a local
// literal. Its declaration ends ~50 lines above the next `export const`, and
// the old text window ran on to that boundary, swallowing the private
// `KanbanConfig = SpecKanbanConfigSchema…` block that belongs to no export.
// ⚠️ The `ObjectViewSchema` removal has a consequence this card did NOT settle:
// the objectui#6058 split in this file's header routes its unmirrored declared
// keys as SPEC-DERIVED — a routing that rests on the false positive. That
// classification is deliberately left as it stands here; see the note there.
'complex.zod.ts#DashboardComponentSchema',
'complex.zod.ts#DashboardWidgetSchema',
'form.zod.ts#SelectOptionSchema',
'layout.zod.ts#PageNodeSchema',
'objectql.zod.ts#ObjectGanttSchema',
'objectql.zod.ts#ObjectMapSchema',
'objectql.zod.ts#ObjectViewSchema',
];

/* ── Runtime: the population is closed ──────────────────────────────────────── */
Expand All@@ -1459,6 +1482,122 @@ function exportedConsts(): string[] {
return out;
}

/* ── Which exports reference a spec symbol (AST, not raw text) ───────────────── */

/**
* The exported consts in ONE mirror source whose definition references a `Spec…`
* symbol — the input to `SPEC_DERIVED_PAIRS`' re-check below.
*
* ## Why this parses instead of scanning text (objectui#6705)
*
* This used to slice the source between `export const` boundaries and test the
* slice against a `\bSpec[A-Z]` word pattern. Raw text cannot tell a reference from a mention,
* and the boundary is not the declaration's end, so the rule had two independent
* defects that both fired on ONE docstring:
*
* - **A comment counted as a reference.** A docstring naming `SpecGanttConfigSchema`
* in PROSE made this test red with nothing about the emitted types, the runtime
* or the mirror's actual spec dependency having moved.
* - **The window charged text to the wrong declaration.** The docstring sat above
* the PRIVATE `GanttConfigExtensionFields` const, which lives textually between
* the `ObjectTreeSchema` and `ObjectGanttSchema` exports — so the slice starting
* at `ObjectTreeSchema` ran on past its own end and the failure named
* `ObjectTreeSchema`, a mirror that references no spec schema at all. The person
* who edits a docstring gets a red naming an innocent neighbour several
* declarations away.
*
* The workaround that shipped (PR #6704) was a docstring reworded to dodge the
* literal token plus a comment asking the next editor to remember — a convention
* held by discipline, which is what a gate is supposed to replace.
*
* ## What it does instead
*
* `Spec…` identifiers are collected from the AST, so a mention inside a comment or
* a string literal is not a reference — those are not `Identifier` nodes and never
* reach the test. There is no comment-stripping regex to get wrong on a string that
* contains `*` `/` sequences, because nothing strips anything.
*
* Attribution is by DECLARATION, not by text window: each top-level declaration
* owns exactly its own initializer. A private const between two exports is charged
* to neither — except through the one link that is a real dependency, which this
* follows: a mirror that spreads a private const built from a spec schema IS spec
* derived, so file-local references are resolved to a fixed point before the
* exported names are read off. `ObjectGanttSchema`'s dependency survives whether it
* extends `SpecGanttConfigSchema` inline or through a local field map.
*
* ⚠️ This is a precision fix, and the direction that must not be lost is the
* POSITIVE one: a scanner that stopped seeing comments AND stopped seeing real
* references would go green on the list below while checking nothing. The fixture
* suite asserts both directions, and the re-check itself is the live positive
* proof — all eight pairs in `SPEC_DERIVED_PAIRS` are found by code reference alone.
*/
export function specReferencingExports(fileName: string, source: string): Set<string> {
const sf = ts.createSourceFile(fileName, source, ts.ScriptTarget.ESNext, false, ts.ScriptKind.TS);

/** Per top-level declaration: does it name a `Spec…` symbol, and what else does it name? */
const decls = new Map<string, { spec: boolean; refs: Set<string> }>();
const exported = new Set<string>();

const collect = (node: ts.Node | undefined, into: { spec: boolean; refs: Set<string> }): void => {
if (!node) return;
const walk = (n: ts.Node): void => {
if (ts.isIdentifier(n)) {
if (/^Spec[A-Z]/.test(n.text)) into.spec = true;
else into.refs.add(n.text);
}
ts.forEachChild(n, walk);
};
walk(node);
};

const record = (name: string, isExported: boolean, ...bodies: (ts.Node | undefined)[]): void => {
const rec = decls.get(name) ?? { spec: false, refs: new Set<string>() };
for (const b of bodies) collect(b, rec);
decls.set(name, rec);
if (isExported) exported.add(name);
};

for (const stmt of sf.statements) {
const isExported = (ts.canHaveModifiers(stmt) ? ts.getModifiers(stmt) : undefined)
?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) === true;
if (ts.isVariableStatement(stmt)) {
for (const d of stmt.declarationList.declarations) {
if (!ts.isIdentifier(d.name)) continue;
record(d.name.text, isExported, d.initializer, d.type);
}
} else if (ts.isFunctionDeclaration(stmt) && stmt.name) {
record(stmt.name.text, isExported, stmt.body, stmt.type);
}
}

// File-local references resolved to a fixed point: a declaration built from one
// that is spec-derived is itself spec-derived. Iterative, so a reference cycle
// terminates instead of recursing.
const specDerived = new Set<string>();
for (const [name, rec] of decls) if (rec.spec) specDerived.add(name);
for (let changed = true; changed; ) {
changed = false;
for (const [name, rec] of decls) {
if (specDerived.has(name)) continue;
for (const r of rec.refs) {
// Only through PRIVATE declarations. An exported mirror is a registered
// pair with an entry of its own, so its spec sensitivity is already
// recorded under its own key; hopping through it would re-attribute one
// mirror's dependency to every mirror that merely names it. A private
// const has no key of its own, so its dependency must be charged to the
// export that uses it or it is lost.
if (specDerived.has(r) && !exported.has(r)) {
specDerived.add(name);
changed = true;
break;
}
}
}
}

return new Set([...exported].filter((n) => specDerived.has(n)));
}

describe('zod mirror parity — the population is closed', () => {
it('every exported const in ../zod/ is either a registered pair or an excluded one', () => {
const registered = new Set(Object.keys(MIRRORS));
Expand DownExpand Up@@ -1487,16 +1626,17 @@ exactly how objectui#4605 and #5186 stayed latent.`).toEqual([]);
it('SPEC_DERIVED_PAIRS matches what the mirror sources actually do', () => {
// Re-derived, not trusted: a mirror that starts referencing a `Spec…` schema
// joins the spec-sensitive set whether or not anyone updates the list.
// Read from the AST — see `specReferencingExports` for why prose does not count.
const derived: string[] = [];
const byFile = new Map<string, Set<string>>();
for (const key of Object.keys(MIRRORS)) {
const [file, name] = key.split('#');
const src = readFileSync(join(ZOD_DIR, file), 'utf8');
const at = src.search(new RegExp(`^export const ${name}\\b`, 'm'));
if (at < 0) continue;
const rest = src.slice(at);
const next = rest.slice(10).search(/\n(?=export (const|type|function))/);
const body = next < 0 ? rest : rest.slice(0, next + 10);
if (/\bSpec[A-Z]\w*/.test(body)) derived.push(key);
let refs = byFile.get(file);
if (!refs) {
refs = specReferencingExports(file, readFileSync(join(ZOD_DIR, file), 'utf8'));
byFile.set(file, refs);
}
if (refs.has(name)) derived.push(key);
}
expect(derived.sort(), 'a mirror gained or lost a spec dependency — update SPEC_DERIVED_PAIRS')
.toEqual([...SPEC_DERIVED_PAIRS].sort());
Expand All@@ -1507,3 +1647,87 @@ exactly how objectui#4605 and #5186 stayed latent.`).toEqual([]);
expect(empty.map(([k]) => k), 'an exclusion without a reason is an oversight').toEqual([]);
});
});

describe('the spec-reference scan reads code, not prose (objectui#6705)', () => {
const scan = (src: string): string[] => [...specReferencingExports('fixture.zod.ts', src)].sort();

it('a `Spec…` token mentioned only in a COMMENT is not a reference', () => {
// The exact shape that flipped this file red: prose, nothing else moved.
expect(
scan(`
/**
* Extends the spec's SpecGanttConfigSchema — mentioned in prose only.
*/
export const ObjectTreeSchema = z.object({ objectName: z.string() });
`),
).toEqual([]);
});

it('a `Spec…` token referenced in CODE still fires — the direction that must not be lost', () => {
// ⚠️ The whole risk of this fix: a scanner that stops seeing comments AND
// stops seeing real references goes green while checking nothing.
expect(
scan(`
export const ObjectGanttSchema = SpecGanttConfigSchema.extend({ lockField: z.string() });
`),
).toEqual(['ObjectGanttSchema']);
});

it('a `Spec…` token inside a STRING LITERAL is not a reference either', () => {
// Including one carrying comment-like sequences, which is the hazard a
// regex-based comment stripper would have had. Nothing is stripped here.
expect(
scan(`
export const TextSchema = z.string().describe('SpecFooSchema */ // not a reference');
`),
).toEqual([]);
});

it('a PRIVATE const between two exports charges its prose to NEITHER neighbour', () => {
// The live misattribution: the docstring sat above the private const, and the
// text window starting at the preceding export ran on past its own end.
expect(
scan(`
export const ObjectTreeSchema = z.object({ objectName: z.string() });

/** Everything beyond the spec's SpecGanttConfigSchema. */
const GanttConfigExtensionFields = { lockField: z.string() };

export const ObjectGanttSchema = z.object({ ...GanttConfigExtensionFields });
`),
).toEqual([]);
});

it('a PRIVATE const with a REAL spec reference is charged to its USER, not its neighbour', () => {
// Attribution by declaration plus file-local resolution: `ObjectGanttSchema`
// really is spec-derived through the field map; `ObjectTreeSchema` is not, and
// the text window used to blame exactly it.
expect(
scan(`
export const ObjectTreeSchema = z.object({ objectName: z.string() });

const GanttConfigExtensionFields = SpecGanttConfigSchema.shape;

export const ObjectGanttSchema = z.object({ ...GanttConfigExtensionFields });
`),
).toEqual(['ObjectGanttSchema']);
});

it("the NEXT export's docstring is not charged to the previous export", () => {
expect(
scan(`
export const ObjectTreeSchema = z.object({ objectName: z.string() });

/** Built from SpecGanttConfigSchema. */
export const ObjectGanttSchema = SpecGanttConfigSchema.extend({});
`),
).toEqual(['ObjectGanttSchema']);
});

it('the scanner can actually see a real mirror source (non-vacuity)', () => {
// A broken parser returns an empty set and every negative case above passes
// while checking nothing. Pin it against the file this bug was found in.
const src = readFileSync(join(ZOD_DIR, 'objectql.zod.ts'), 'utf8');
expect([...specReferencingExports('objectql.zod.ts', src)]).toContain('ObjectGanttSchema');
});
});
15 changes: 4 additions & 11 deletions packages/types/src/zod/objectql.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -606,23 +606,16 @@ export const ObjectTreeSchema = BaseSchema.extend({

/**
* objectui's own `GanttConfig` extensions — everything `../objectql.ts` declares
* on {@link GanttConfig} beyond the spec's `GanttConfigSchema` (objectui#6051
* on {@link GanttConfig} beyond the spec's `SpecGanttConfigSchema` (objectui#6051
* lifted nine of them out of `plugin-gantt`'s package-private `GanttConfigEx`;
* `timeSegments` was already there).
*
* Held as ONE field map rather than inlined, so the flattened top-level spelling
* below is built from a single source — the same way the TS side derives its
* flattened members from `GanttConfig`. It is also the shape the nested `gantt`
* block is built from (objectui#6475), one line extending the spec's gantt
* config schema with this map — so both authoring faces share one vocabulary
* and cannot fork from each other.
*
* ⚠️ Keep this docstring free of a literal `Spec` + capital-letter token: it
* sits between the `ObjectTreeSchema` and `ObjectGanttSchema` export
* boundaries, and `zod-mirror-parity.test.ts`'s `SPEC_DERIVED_PAIRS` re-check
* scans raw text between export boundaries for `\bSpec[A-Z]\w*` — a match here
* is misattributed to `ObjectTreeSchema`, which references no spec schema at
* all (measured: this comment alone flipped that test red).
* block is built from (objectui#6475): one line extending `SpecGanttConfigSchema`
* with this map — so both authoring faces share one vocabulary and cannot fork
* from each other.
*
* Not exported: the parity census in `__tests__/zod-mirror-parity.test.ts` reads
* `^export const` out of this directory and would require a registered TS
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
11 changes: 11 additions & 0 deletions .changeset/6705-parity-scan-reads-ast.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
---
---

Test-only change to `zod-mirror-parity.test.ts`'s `SPEC_DERIVED_PAIRS` re-check: it now
reads `Spec…` references off the TypeScript AST instead of scanning raw text between
`export const` boundaries, so a token mentioned in a comment is no longer counted as a
reference and no longer misattributed to the neighbouring export (objectui#6705). The one
change under `packages/types/src/zod/` is a docstring: the wording PR #6704 had to contort
around the old scanner is restored, along with the removal of the trap comment that asked
the next editor to remember. No published behaviour changes — no runtime, type or schema
surface moves.
250 changes: 237 additions & 13 deletions packages/types/src/__tests__/zod-mirror-parity.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -113,6 +113,7 @@ import { describe, it, expect } from 'vitest';
import { readdirSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import ts from 'typescript';
import type { z } from 'zod';

import { AppActionSchema, AppComponentSchema, NavigationAreaSchema } from '../zod/app.zod.js';
Expand DownExpand Up@@ -762,8 +763,17 @@ interface KnownDrift {
* are recorded here so whoever works them off does not re-derive them:
*
* - **SPEC-DERIVED (3 entries, 13 keys)** — `DashboardComponentSchema`,
* `DashboardWidgetSchema`, `ObjectViewSchema` are in `SPEC_DERIVED_PAIRS` below,
* so their mirror takes its shape BY REFERENCE from `@objectstack/spec`. An
* `DashboardWidgetSchema`, `ObjectViewSchema`. ⚠️ objectui#6705 invalidated the
* evidence for ONE of the three: `ObjectViewSchema` is no longer in
* `SPEC_DERIVED_PAIRS` below, because it never referenced a spec schema — it is
* `BaseSchema.extend({…})` of local literals, and the pre-#6705 text scanner
* charged it a neighbouring private const's `Spec…` token. The split is left
* STANDING as written, counts and all: re-routing those keys from #2231's
* unification question to a local mirror edit is a remedy decision on the
* `UnmirroredDeclared` ledger, which #6705 was fenced out of. Whoever works
* this split off must re-derive `ObjectViewSchema`'s side first.
* For the other two the reading is unchanged:
* their mirror takes its shape BY REFERENCE from `@objectstack/spec`. An
* unmirrored declared key there means the LOCAL declaration carries members the
* spec schema does not model, which is objectui#2231's unification question and
* NOT a local mirror edit. They are marked, not exempted: exempting them in the
Expand DownExpand Up@@ -1212,8 +1222,9 @@ export const assertionDriftMatchesLedger: never = 0 as unknown as LedgerMismatch
* ⚠️ And it is a COMPILE-TIME assertion. The `describe` block at the bottom of this
* file is a population census — it checks that the registry is closed and that
* `SPEC_DERIVED_PAIRS` re-derives, and it never compares keys at all. Its
* `Tests 5 passed (5)` line does not move when this half reddens, correctly, and it
* did not move under the ablation either. Reading the runtime half for evidence
* `Tests 12 passed (12)` line does not move when this half reddens, correctly, and
* it did not move under the ablation either. (It read `5 passed (5)` until
* objectui#6705 added the seven-fixture suite pinning the re-check's scanner.) Reading the runtime half for evidence
* about drift measures the wrong instrument and concludes the guard does nothing.
*
* When it fires, fix it by MEASURING (the compiler-API recipe and the
Expand DownExpand Up@@ -1434,14 +1445,26 @@ const EXCLUSIONS: Readonly<Record<string, string>> = {
const SPEC_DERIVED_PAIRS: readonly string[] = [
'app.zod.ts#AppComponentSchema',
'app.zod.ts#NavigationAreaSchema',
'base.zod.ts#BaseSchema',
// `base.zod.ts#BaseSchema` and `objectql.zod.ts#ObjectViewSchema` used to stand
// here and were removed by objectui#6705 — NOT because either mirror changed,
// but because the re-check below stopped mis-reading them. Neither references a
// spec schema; each was held here by one of the two defects that card names:
// - `BaseSchema` — its file's ONLY `Spec…` token is in a COMMENT
// (`base.zod.ts`, "rather than calling `SpecSchema.omit(…)`"). Prose.
// - `ObjectViewSchema` — `BaseSchema.extend({…})` with every member a local
// literal. Its declaration ends ~50 lines above the next `export const`, and
// the old text window ran on to that boundary, swallowing the private
// `KanbanConfig = SpecKanbanConfigSchema…` block that belongs to no export.
// ⚠️ The `ObjectViewSchema` removal has a consequence this card did NOT settle:
// the objectui#6058 split in this file's header routes its unmirrored declared
// keys as SPEC-DERIVED — a routing that rests on the false positive. That
// classification is deliberately left as it stands here; see the note there.
'complex.zod.ts#DashboardComponentSchema',
'complex.zod.ts#DashboardWidgetSchema',
'form.zod.ts#SelectOptionSchema',
'layout.zod.ts#PageNodeSchema',
'objectql.zod.ts#ObjectGanttSchema',
'objectql.zod.ts#ObjectMapSchema',
'objectql.zod.ts#ObjectViewSchema',
];

/* ── Runtime: the population is closed ──────────────────────────────────────── */
Expand All@@ -1459,6 +1482,122 @@ function exportedConsts(): string[] {
return out;
}

/* ── Which exports reference a spec symbol (AST, not raw text) ───────────────── */

/**
* The exported consts in ONE mirror source whose definition references a `Spec…`
* symbol — the input to `SPEC_DERIVED_PAIRS`' re-check below.
*
* ## Why this parses instead of scanning text (objectui#6705)
*
* This used to slice the source between `export const` boundaries and test the
* slice against a `\bSpec[A-Z]` word pattern. Raw text cannot tell a reference from a mention,
* and the boundary is not the declaration's end, so the rule had two independent
* defects that both fired on ONE docstring:
*
* - **A comment counted as a reference.** A docstring naming `SpecGanttConfigSchema`
* in PROSE made this test red with nothing about the emitted types, the runtime
* or the mirror's actual spec dependency having moved.
* - **The window charged text to the wrong declaration.** The docstring sat above
* the PRIVATE `GanttConfigExtensionFields` const, which lives textually between
* the `ObjectTreeSchema` and `ObjectGanttSchema` exports — so the slice starting
* at `ObjectTreeSchema` ran on past its own end and the failure named
* `ObjectTreeSchema`, a mirror that references no spec schema at all. The person
* who edits a docstring gets a red naming an innocent neighbour several
* declarations away.
*
* The workaround that shipped (PR #6704) was a docstring reworded to dodge the
* literal token plus a comment asking the next editor to remember — a convention
* held by discipline, which is what a gate is supposed to replace.
*
* ## What it does instead
*
* `Spec…` identifiers are collected from the AST, so a mention inside a comment or
* a string literal is not a reference — those are not `Identifier` nodes and never
* reach the test. There is no comment-stripping regex to get wrong on a string that
* contains `*` `/` sequences, because nothing strips anything.
*
* Attribution is by DECLARATION, not by text window: each top-level declaration
* owns exactly its own initializer. A private const between two exports is charged
* to neither — except through the one link that is a real dependency, which this
* follows: a mirror that spreads a private const built from a spec schema IS spec
* derived, so file-local references are resolved to a fixed point before the
* exported names are read off. `ObjectGanttSchema`'s dependency survives whether it
* extends `SpecGanttConfigSchema` inline or through a local field map.
*
* ⚠️ This is a precision fix, and the direction that must not be lost is the
* POSITIVE one: a scanner that stopped seeing comments AND stopped seeing real
* references would go green on the list below while checking nothing. The fixture
* suite asserts both directions, and the re-check itself is the live positive
* proof — all eight pairs in `SPEC_DERIVED_PAIRS` are found by code reference alone.
*/
export function specReferencingExports(fileName: string, source: string): Set<string> {
const sf = ts.createSourceFile(fileName, source, ts.ScriptTarget.ESNext, false, ts.ScriptKind.TS);

/** Per top-level declaration: does it name a `Spec…` symbol, and what else does it name? */
const decls = new Map<string, { spec: boolean; refs: Set<string> }>();
const exported = new Set<string>();

const collect = (node: ts.Node | undefined, into: { spec: boolean; refs: Set<string> }): void => {
if (!node) return;
const walk = (n: ts.Node): void => {
if (ts.isIdentifier(n)) {
if (/^Spec[A-Z]/.test(n.text)) into.spec = true;
else into.refs.add(n.text);
}
ts.forEachChild(n, walk);
};
walk(node);
};

const record = (name: string, isExported: boolean, ...bodies: (ts.Node | undefined)[]): void => {
const rec = decls.get(name) ?? { spec: false, refs: new Set<string>() };
for (const b of bodies) collect(b, rec);
decls.set(name, rec);
if (isExported) exported.add(name);
};

for (const stmt of sf.statements) {
const isExported = (ts.canHaveModifiers(stmt) ? ts.getModifiers(stmt) : undefined)
?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) === true;
if (ts.isVariableStatement(stmt)) {
for (const d of stmt.declarationList.declarations) {
if (!ts.isIdentifier(d.name)) continue;
record(d.name.text, isExported, d.initializer, d.type);
}
} else if (ts.isFunctionDeclaration(stmt) && stmt.name) {
record(stmt.name.text, isExported, stmt.body, stmt.type);
}
}

// File-local references resolved to a fixed point: a declaration built from one
// that is spec-derived is itself spec-derived. Iterative, so a reference cycle
// terminates instead of recursing.
const specDerived = new Set<string>();
for (const [name, rec] of decls) if (rec.spec) specDerived.add(name);
for (let changed = true; changed; ) {
changed = false;
for (const [name, rec] of decls) {
if (specDerived.has(name)) continue;
for (const r of rec.refs) {
// Only through PRIVATE declarations. An exported mirror is a registered
// pair with an entry of its own, so its spec sensitivity is already
// recorded under its own key; hopping through it would re-attribute one
// mirror's dependency to every mirror that merely names it. A private
// const has no key of its own, so its dependency must be charged to the
// export that uses it or it is lost.
if (specDerived.has(r) && !exported.has(r)) {
specDerived.add(name);
changed = true;
break;
}
}
}
}

return new Set([...exported].filter((n) => specDerived.has(n)));
}

describe('zod mirror parity — the population is closed', () => {
it('every exported const in ../zod/ is either a registered pair or an excluded one', () => {
const registered = new Set(Object.keys(MIRRORS));
Expand DownExpand Up@@ -1487,16 +1626,17 @@ exactly how objectui#4605 and #5186 stayed latent.`).toEqual([]);
it('SPEC_DERIVED_PAIRS matches what the mirror sources actually do', () => {
// Re-derived, not trusted: a mirror that starts referencing a `Spec…` schema
// joins the spec-sensitive set whether or not anyone updates the list.
// Read from the AST — see `specReferencingExports` for why prose does not count.
const derived: string[] = [];
const byFile = new Map<string, Set<string>>();
for (const key of Object.keys(MIRRORS)) {
const [file, name] = key.split('#');
const src = readFileSync(join(ZOD_DIR, file), 'utf8');
const at = src.search(new RegExp(`^export const ${name}\\b`, 'm'));
if (at < 0) continue;
const rest = src.slice(at);
const next = rest.slice(10).search(/\n(?=export (const|type|function))/);
const body = next < 0 ? rest : rest.slice(0, next + 10);
if (/\bSpec[A-Z]\w*/.test(body)) derived.push(key);
let refs = byFile.get(file);
if (!refs) {
refs = specReferencingExports(file, readFileSync(join(ZOD_DIR, file), 'utf8'));
byFile.set(file, refs);
}
if (refs.has(name)) derived.push(key);
}
expect(derived.sort(), 'a mirror gained or lost a spec dependency — update SPEC_DERIVED_PAIRS')
.toEqual([...SPEC_DERIVED_PAIRS].sort());
Expand All@@ -1507,3 +1647,87 @@ exactly how objectui#4605 and #5186 stayed latent.`).toEqual([]);
expect(empty.map(([k]) => k), 'an exclusion without a reason is an oversight').toEqual([]);
});
});

describe('the spec-reference scan reads code, not prose (objectui#6705)', () => {
const scan = (src: string): string[] => [...specReferencingExports('fixture.zod.ts', src)].sort();

it('a `Spec…` token mentioned only in a COMMENT is not a reference', () => {
// The exact shape that flipped this file red: prose, nothing else moved.
expect(
scan(`
/**
* Extends the spec's SpecGanttConfigSchema — mentioned in prose only.
*/
export const ObjectTreeSchema = z.object({ objectName: z.string() });
`),
).toEqual([]);
});

it('a `Spec…` token referenced in CODE still fires — the direction that must not be lost', () => {
// ⚠️ The whole risk of this fix: a scanner that stops seeing comments AND
// stops seeing real references goes green while checking nothing.
expect(
scan(`
export const ObjectGanttSchema = SpecGanttConfigSchema.extend({ lockField: z.string() });
`),
).toEqual(['ObjectGanttSchema']);
});

it('a `Spec…` token inside a STRING LITERAL is not a reference either', () => {
// Including one carrying comment-like sequences, which is the hazard a
// regex-based comment stripper would have had. Nothing is stripped here.
expect(
scan(`
export const TextSchema = z.string().describe('SpecFooSchema */ // not a reference');
`),
).toEqual([]);
});

it('a PRIVATE const between two exports charges its prose to NEITHER neighbour', () => {
// The live misattribution: the docstring sat above the private const, and the
// text window starting at the preceding export ran on past its own end.
expect(
scan(`
export const ObjectTreeSchema = z.object({ objectName: z.string() });

/** Everything beyond the spec's SpecGanttConfigSchema. */
const GanttConfigExtensionFields = { lockField: z.string() };

export const ObjectGanttSchema = z.object({ ...GanttConfigExtensionFields });
`),
).toEqual([]);
});

it('a PRIVATE const with a REAL spec reference is charged to its USER, not its neighbour', () => {
// Attribution by declaration plus file-local resolution: `ObjectGanttSchema`
// really is spec-derived through the field map; `ObjectTreeSchema` is not, and
// the text window used to blame exactly it.
expect(
scan(`
export const ObjectTreeSchema = z.object({ objectName: z.string() });

const GanttConfigExtensionFields = SpecGanttConfigSchema.shape;

export const ObjectGanttSchema = z.object({ ...GanttConfigExtensionFields });
`),
).toEqual(['ObjectGanttSchema']);
});

it("the NEXT export's docstring is not charged to the previous export", () => {
expect(
scan(`
export const ObjectTreeSchema = z.object({ objectName: z.string() });

/** Built from SpecGanttConfigSchema. */
export const ObjectGanttSchema = SpecGanttConfigSchema.extend({});
`),
).toEqual(['ObjectGanttSchema']);
});

it('the scanner can actually see a real mirror source (non-vacuity)', () => {
// A broken parser returns an empty set and every negative case above passes
// while checking nothing. Pin it against the file this bug was found in.
const src = readFileSync(join(ZOD_DIR, 'objectql.zod.ts'), 'utf8');
expect([...specReferencingExports('objectql.zod.ts', src)]).toContain('ObjectGanttSchema');
});
});
15 changes: 4 additions & 11 deletions packages/types/src/zod/objectql.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -606,23 +606,16 @@ export const ObjectTreeSchema = BaseSchema.extend({

/**
* objectui's own `GanttConfig` extensions — everything `../objectql.ts` declares
* on {@link GanttConfig} beyond the spec's `GanttConfigSchema` (objectui#6051
* on {@link GanttConfig} beyond the spec's `SpecGanttConfigSchema` (objectui#6051
* lifted nine of them out of `plugin-gantt`'s package-private `GanttConfigEx`;
* `timeSegments` was already there).
*
* Held as ONE field map rather than inlined, so the flattened top-level spelling
* below is built from a single source — the same way the TS side derives its
* flattened members from `GanttConfig`. It is also the shape the nested `gantt`
* block is built from (objectui#6475), one line extending the spec's gantt
* config schema with this map — so both authoring faces share one vocabulary
* and cannot fork from each other.
*
* ⚠️ Keep this docstring free of a literal `Spec` + capital-letter token: it
* sits between the `ObjectTreeSchema` and `ObjectGanttSchema` export
* boundaries, and `zod-mirror-parity.test.ts`'s `SPEC_DERIVED_PAIRS` re-check
* scans raw text between export boundaries for `\bSpec[A-Z]\w*` — a match here
* is misattributed to `ObjectTreeSchema`, which references no spec schema at
* all (measured: this comment alone flipped that test red).
* block is built from (objectui#6475): one line extending `SpecGanttConfigSchema`
* with this map — so both authoring faces share one vocabulary and cannot fork
* from each other.
*
* Not exported: the parity census in `__tests__/zod-mirror-parity.test.ts` reads
* `^export const` out of this directory and would require a registered TS
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
11 changes: 11 additions & 0 deletions .changeset/6705-parity-scan-reads-ast.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
---
---

Test-only change to `zod-mirror-parity.test.ts`'s `SPEC_DERIVED_PAIRS` re-check: it now
reads `Spec…` references off the TypeScript AST instead of scanning raw text between
`export const` boundaries, so a token mentioned in a comment is no longer counted as a
reference and no longer misattributed to the neighbouring export (objectui#6705). The one
change under `packages/types/src/zod/` is a docstring: the wording PR #6704 had to contort
around the old scanner is restored, along with the removal of the trap comment that asked
the next editor to remember. No published behaviour changes — no runtime, type or schema
surface moves.
250 changes: 237 additions & 13 deletions packages/types/src/__tests__/zod-mirror-parity.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -113,6 +113,7 @@ import { describe, it, expect } from 'vitest';
import { readdirSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import ts from 'typescript';
import type { z } from 'zod';

import { AppActionSchema, AppComponentSchema, NavigationAreaSchema } from '../zod/app.zod.js';
Expand DownExpand Up@@ -762,8 +763,17 @@ interface KnownDrift {
* are recorded here so whoever works them off does not re-derive them:
*
* - **SPEC-DERIVED (3 entries, 13 keys)** — `DashboardComponentSchema`,
* `DashboardWidgetSchema`, `ObjectViewSchema` are in `SPEC_DERIVED_PAIRS` below,
* so their mirror takes its shape BY REFERENCE from `@objectstack/spec`. An
* `DashboardWidgetSchema`, `ObjectViewSchema`. ⚠️ objectui#6705 invalidated the
* evidence for ONE of the three: `ObjectViewSchema` is no longer in
* `SPEC_DERIVED_PAIRS` below, because it never referenced a spec schema — it is
* `BaseSchema.extend({…})` of local literals, and the pre-#6705 text scanner
* charged it a neighbouring private const's `Spec…` token. The split is left
* STANDING as written, counts and all: re-routing those keys from #2231's
* unification question to a local mirror edit is a remedy decision on the
* `UnmirroredDeclared` ledger, which #6705 was fenced out of. Whoever works
* this split off must re-derive `ObjectViewSchema`'s side first.
* For the other two the reading is unchanged:
* their mirror takes its shape BY REFERENCE from `@objectstack/spec`. An
* unmirrored declared key there means the LOCAL declaration carries members the
* spec schema does not model, which is objectui#2231's unification question and
* NOT a local mirror edit. They are marked, not exempted: exempting them in the
Expand DownExpand Up@@ -1212,8 +1222,9 @@ export const assertionDriftMatchesLedger: never = 0 as unknown as LedgerMismatch
* ⚠️ And it is a COMPILE-TIME assertion. The `describe` block at the bottom of this
* file is a population census — it checks that the registry is closed and that
* `SPEC_DERIVED_PAIRS` re-derives, and it never compares keys at all. Its
* `Tests 5 passed (5)` line does not move when this half reddens, correctly, and it
* did not move under the ablation either. Reading the runtime half for evidence
* `Tests 12 passed (12)` line does not move when this half reddens, correctly, and
* it did not move under the ablation either. (It read `5 passed (5)` until
* objectui#6705 added the seven-fixture suite pinning the re-check's scanner.) Reading the runtime half for evidence
* about drift measures the wrong instrument and concludes the guard does nothing.
*
* When it fires, fix it by MEASURING (the compiler-API recipe and the
Expand DownExpand Up@@ -1434,14 +1445,26 @@ const EXCLUSIONS: Readonly<Record<string, string>> = {
const SPEC_DERIVED_PAIRS: readonly string[] = [
'app.zod.ts#AppComponentSchema',
'app.zod.ts#NavigationAreaSchema',
'base.zod.ts#BaseSchema',
// `base.zod.ts#BaseSchema` and `objectql.zod.ts#ObjectViewSchema` used to stand
// here and were removed by objectui#6705 — NOT because either mirror changed,
// but because the re-check below stopped mis-reading them. Neither references a
// spec schema; each was held here by one of the two defects that card names:
// - `BaseSchema` — its file's ONLY `Spec…` token is in a COMMENT
// (`base.zod.ts`, "rather than calling `SpecSchema.omit(…)`"). Prose.
// - `ObjectViewSchema` — `BaseSchema.extend({…})` with every member a local
// literal. Its declaration ends ~50 lines above the next `export const`, and
// the old text window ran on to that boundary, swallowing the private
// `KanbanConfig = SpecKanbanConfigSchema…` block that belongs to no export.
// ⚠️ The `ObjectViewSchema` removal has a consequence this card did NOT settle:
// the objectui#6058 split in this file's header routes its unmirrored declared
// keys as SPEC-DERIVED — a routing that rests on the false positive. That
// classification is deliberately left as it stands here; see the note there.
'complex.zod.ts#DashboardComponentSchema',
'complex.zod.ts#DashboardWidgetSchema',
'form.zod.ts#SelectOptionSchema',
'layout.zod.ts#PageNodeSchema',
'objectql.zod.ts#ObjectGanttSchema',
'objectql.zod.ts#ObjectMapSchema',
'objectql.zod.ts#ObjectViewSchema',
];

/* ── Runtime: the population is closed ──────────────────────────────────────── */
Expand All@@ -1459,6 +1482,122 @@ function exportedConsts(): string[] {
return out;
}

/* ── Which exports reference a spec symbol (AST, not raw text) ───────────────── */

/**
* The exported consts in ONE mirror source whose definition references a `Spec…`
* symbol — the input to `SPEC_DERIVED_PAIRS`' re-check below.
*
* ## Why this parses instead of scanning text (objectui#6705)
*
* This used to slice the source between `export const` boundaries and test the
* slice against a `\bSpec[A-Z]` word pattern. Raw text cannot tell a reference from a mention,
* and the boundary is not the declaration's end, so the rule had two independent
* defects that both fired on ONE docstring:
*
* - **A comment counted as a reference.** A docstring naming `SpecGanttConfigSchema`
* in PROSE made this test red with nothing about the emitted types, the runtime
* or the mirror's actual spec dependency having moved.
* - **The window charged text to the wrong declaration.** The docstring sat above
* the PRIVATE `GanttConfigExtensionFields` const, which lives textually between
* the `ObjectTreeSchema` and `ObjectGanttSchema` exports — so the slice starting
* at `ObjectTreeSchema` ran on past its own end and the failure named
* `ObjectTreeSchema`, a mirror that references no spec schema at all. The person
* who edits a docstring gets a red naming an innocent neighbour several
* declarations away.
*
* The workaround that shipped (PR #6704) was a docstring reworded to dodge the
* literal token plus a comment asking the next editor to remember — a convention
* held by discipline, which is what a gate is supposed to replace.
*
* ## What it does instead
*
* `Spec…` identifiers are collected from the AST, so a mention inside a comment or
* a string literal is not a reference — those are not `Identifier` nodes and never
* reach the test. There is no comment-stripping regex to get wrong on a string that
* contains `*` `/` sequences, because nothing strips anything.
*
* Attribution is by DECLARATION, not by text window: each top-level declaration
* owns exactly its own initializer. A private const between two exports is charged
* to neither — except through the one link that is a real dependency, which this
* follows: a mirror that spreads a private const built from a spec schema IS spec
* derived, so file-local references are resolved to a fixed point before the
* exported names are read off. `ObjectGanttSchema`'s dependency survives whether it
* extends `SpecGanttConfigSchema` inline or through a local field map.
*
* ⚠️ This is a precision fix, and the direction that must not be lost is the
* POSITIVE one: a scanner that stopped seeing comments AND stopped seeing real
* references would go green on the list below while checking nothing. The fixture
* suite asserts both directions, and the re-check itself is the live positive
* proof — all eight pairs in `SPEC_DERIVED_PAIRS` are found by code reference alone.
*/
export function specReferencingExports(fileName: string, source: string): Set<string> {
const sf = ts.createSourceFile(fileName, source, ts.ScriptTarget.ESNext, false, ts.ScriptKind.TS);

/** Per top-level declaration: does it name a `Spec…` symbol, and what else does it name? */
const decls = new Map<string, { spec: boolean; refs: Set<string> }>();
const exported = new Set<string>();

const collect = (node: ts.Node | undefined, into: { spec: boolean; refs: Set<string> }): void => {
if (!node) return;
const walk = (n: ts.Node): void => {
if (ts.isIdentifier(n)) {
if (/^Spec[A-Z]/.test(n.text)) into.spec = true;
else into.refs.add(n.text);
}
ts.forEachChild(n, walk);
};
walk(node);
};

const record = (name: string, isExported: boolean, ...bodies: (ts.Node | undefined)[]): void => {
const rec = decls.get(name) ?? { spec: false, refs: new Set<string>() };
for (const b of bodies) collect(b, rec);
decls.set(name, rec);
if (isExported) exported.add(name);
};

for (const stmt of sf.statements) {
const isExported = (ts.canHaveModifiers(stmt) ? ts.getModifiers(stmt) : undefined)
?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) === true;
if (ts.isVariableStatement(stmt)) {
for (const d of stmt.declarationList.declarations) {
if (!ts.isIdentifier(d.name)) continue;
record(d.name.text, isExported, d.initializer, d.type);
}
} else if (ts.isFunctionDeclaration(stmt) && stmt.name) {
record(stmt.name.text, isExported, stmt.body, stmt.type);
}
}

// File-local references resolved to a fixed point: a declaration built from one
// that is spec-derived is itself spec-derived. Iterative, so a reference cycle
// terminates instead of recursing.
const specDerived = new Set<string>();
for (const [name, rec] of decls) if (rec.spec) specDerived.add(name);
for (let changed = true; changed; ) {
changed = false;
for (const [name, rec] of decls) {
if (specDerived.has(name)) continue;
for (const r of rec.refs) {
// Only through PRIVATE declarations. An exported mirror is a registered
// pair with an entry of its own, so its spec sensitivity is already
// recorded under its own key; hopping through it would re-attribute one
// mirror's dependency to every mirror that merely names it. A private
// const has no key of its own, so its dependency must be charged to the
// export that uses it or it is lost.
if (specDerived.has(r) && !exported.has(r)) {
specDerived.add(name);
changed = true;
break;
}
}
}
}

return new Set([...exported].filter((n) => specDerived.has(n)));
}

describe('zod mirror parity — the population is closed', () => {
it('every exported const in ../zod/ is either a registered pair or an excluded one', () => {
const registered = new Set(Object.keys(MIRRORS));
Expand DownExpand Up@@ -1487,16 +1626,17 @@ exactly how objectui#4605 and #5186 stayed latent.`).toEqual([]);
it('SPEC_DERIVED_PAIRS matches what the mirror sources actually do', () => {
// Re-derived, not trusted: a mirror that starts referencing a `Spec…` schema
// joins the spec-sensitive set whether or not anyone updates the list.
// Read from the AST — see `specReferencingExports` for why prose does not count.
const derived: string[] = [];
const byFile = new Map<string, Set<string>>();
for (const key of Object.keys(MIRRORS)) {
const [file, name] = key.split('#');
const src = readFileSync(join(ZOD_DIR, file), 'utf8');
const at = src.search(new RegExp(`^export const ${name}\\b`, 'm'));
if (at < 0) continue;
const rest = src.slice(at);
const next = rest.slice(10).search(/\n(?=export (const|type|function))/);
const body = next < 0 ? rest : rest.slice(0, next + 10);
if (/\bSpec[A-Z]\w*/.test(body)) derived.push(key);
let refs = byFile.get(file);
if (!refs) {
refs = specReferencingExports(file, readFileSync(join(ZOD_DIR, file), 'utf8'));
byFile.set(file, refs);
}
if (refs.has(name)) derived.push(key);
}
expect(derived.sort(), 'a mirror gained or lost a spec dependency — update SPEC_DERIVED_PAIRS')
.toEqual([...SPEC_DERIVED_PAIRS].sort());
Expand All@@ -1507,3 +1647,87 @@ exactly how objectui#4605 and #5186 stayed latent.`).toEqual([]);
expect(empty.map(([k]) => k), 'an exclusion without a reason is an oversight').toEqual([]);
});
});

describe('the spec-reference scan reads code, not prose (objectui#6705)', () => {
const scan = (src: string): string[] => [...specReferencingExports('fixture.zod.ts', src)].sort();

it('a `Spec…` token mentioned only in a COMMENT is not a reference', () => {
// The exact shape that flipped this file red: prose, nothing else moved.
expect(
scan(`
/**
* Extends the spec's SpecGanttConfigSchema — mentioned in prose only.
*/
export const ObjectTreeSchema = z.object({ objectName: z.string() });
`),
).toEqual([]);
});

it('a `Spec…` token referenced in CODE still fires — the direction that must not be lost', () => {
// ⚠️ The whole risk of this fix: a scanner that stops seeing comments AND
// stops seeing real references goes green while checking nothing.
expect(
scan(`
export const ObjectGanttSchema = SpecGanttConfigSchema.extend({ lockField: z.string() });
`),
).toEqual(['ObjectGanttSchema']);
});

it('a `Spec…` token inside a STRING LITERAL is not a reference either', () => {
// Including one carrying comment-like sequences, which is the hazard a
// regex-based comment stripper would have had. Nothing is stripped here.
expect(
scan(`
export const TextSchema = z.string().describe('SpecFooSchema */ // not a reference');
`),
).toEqual([]);
});

it('a PRIVATE const between two exports charges its prose to NEITHER neighbour', () => {
// The live misattribution: the docstring sat above the private const, and the
// text window starting at the preceding export ran on past its own end.
expect(
scan(`
export const ObjectTreeSchema = z.object({ objectName: z.string() });

/** Everything beyond the spec's SpecGanttConfigSchema. */
const GanttConfigExtensionFields = { lockField: z.string() };

export const ObjectGanttSchema = z.object({ ...GanttConfigExtensionFields });
`),
).toEqual([]);
});

it('a PRIVATE const with a REAL spec reference is charged to its USER, not its neighbour', () => {
// Attribution by declaration plus file-local resolution: `ObjectGanttSchema`
// really is spec-derived through the field map; `ObjectTreeSchema` is not, and
// the text window used to blame exactly it.
expect(
scan(`
export const ObjectTreeSchema = z.object({ objectName: z.string() });

const GanttConfigExtensionFields = SpecGanttConfigSchema.shape;

export const ObjectGanttSchema = z.object({ ...GanttConfigExtensionFields });
`),
).toEqual(['ObjectGanttSchema']);
});

it("the NEXT export's docstring is not charged to the previous export", () => {
expect(
scan(`
export const ObjectTreeSchema = z.object({ objectName: z.string() });

/** Built from SpecGanttConfigSchema. */
export const ObjectGanttSchema = SpecGanttConfigSchema.extend({});
`),
).toEqual(['ObjectGanttSchema']);
});

it('the scanner can actually see a real mirror source (non-vacuity)', () => {
// A broken parser returns an empty set and every negative case above passes
// while checking nothing. Pin it against the file this bug was found in.
const src = readFileSync(join(ZOD_DIR, 'objectql.zod.ts'), 'utf8');
expect([...specReferencingExports('objectql.zod.ts', src)]).toContain('ObjectGanttSchema');
});
});
15 changes: 4 additions & 11 deletions packages/types/src/zod/objectql.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -606,23 +606,16 @@ export const ObjectTreeSchema = BaseSchema.extend({

/**
* objectui's own `GanttConfig` extensions — everything `../objectql.ts` declares
* on {@link GanttConfig} beyond the spec's `GanttConfigSchema` (objectui#6051
* on {@link GanttConfig} beyond the spec's `SpecGanttConfigSchema` (objectui#6051
* lifted nine of them out of `plugin-gantt`'s package-private `GanttConfigEx`;
* `timeSegments` was already there).
*
* Held as ONE field map rather than inlined, so the flattened top-level spelling
* below is built from a single source — the same way the TS side derives its
* flattened members from `GanttConfig`. It is also the shape the nested `gantt`
* block is built from (objectui#6475), one line extending the spec's gantt
* config schema with this map — so both authoring faces share one vocabulary
* and cannot fork from each other.
*
* ⚠️ Keep this docstring free of a literal `Spec` + capital-letter token: it
* sits between the `ObjectTreeSchema` and `ObjectGanttSchema` export
* boundaries, and `zod-mirror-parity.test.ts`'s `SPEC_DERIVED_PAIRS` re-check
* scans raw text between export boundaries for `\bSpec[A-Z]\w*` — a match here
* is misattributed to `ObjectTreeSchema`, which references no spec schema at
* all (measured: this comment alone flipped that test red).
* block is built from (objectui#6475): one line extending `SpecGanttConfigSchema`
* with this map — so both authoring faces share one vocabulary and cannot fork
* from each other.
*
* Not exported: the parity census in `__tests__/zod-mirror-parity.test.ts` reads
* `^export const` out of this directory and would require a registered TS
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
11 changes: 11 additions & 0 deletions .changeset/6705-parity-scan-reads-ast.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
---
---

Test-only change to `zod-mirror-parity.test.ts`'s `SPEC_DERIVED_PAIRS` re-check: it now
reads `Spec…` references off the TypeScript AST instead of scanning raw text between
`export const` boundaries, so a token mentioned in a comment is no longer counted as a
reference and no longer misattributed to the neighbouring export (objectui#6705). The one
change under `packages/types/src/zod/` is a docstring: the wording PR #6704 had to contort
around the old scanner is restored, along with the removal of the trap comment that asked
the next editor to remember. No published behaviour changes — no runtime, type or schema
surface moves.
250 changes: 237 additions & 13 deletions packages/types/src/__tests__/zod-mirror-parity.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -113,6 +113,7 @@ import { describe, it, expect } from 'vitest';
import { readdirSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import ts from 'typescript';
import type { z } from 'zod';

import { AppActionSchema, AppComponentSchema, NavigationAreaSchema } from '../zod/app.zod.js';
Expand DownExpand Up@@ -762,8 +763,17 @@ interface KnownDrift {
* are recorded here so whoever works them off does not re-derive them:
*
* - **SPEC-DERIVED (3 entries, 13 keys)** — `DashboardComponentSchema`,
* `DashboardWidgetSchema`, `ObjectViewSchema` are in `SPEC_DERIVED_PAIRS` below,
* so their mirror takes its shape BY REFERENCE from `@objectstack/spec`. An
* `DashboardWidgetSchema`, `ObjectViewSchema`. ⚠️ objectui#6705 invalidated the
* evidence for ONE of the three: `ObjectViewSchema` is no longer in
* `SPEC_DERIVED_PAIRS` below, because it never referenced a spec schema — it is
* `BaseSchema.extend({…})` of local literals, and the pre-#6705 text scanner
* charged it a neighbouring private const's `Spec…` token. The split is left
* STANDING as written, counts and all: re-routing those keys from #2231's
* unification question to a local mirror edit is a remedy decision on the
* `UnmirroredDeclared` ledger, which #6705 was fenced out of. Whoever works
* this split off must re-derive `ObjectViewSchema`'s side first.
* For the other two the reading is unchanged:
* their mirror takes its shape BY REFERENCE from `@objectstack/spec`. An
* unmirrored declared key there means the LOCAL declaration carries members the
* spec schema does not model, which is objectui#2231's unification question and
* NOT a local mirror edit. They are marked, not exempted: exempting them in the
Expand DownExpand Up@@ -1212,8 +1222,9 @@ export const assertionDriftMatchesLedger: never = 0 as unknown as LedgerMismatch
* ⚠️ And it is a COMPILE-TIME assertion. The `describe` block at the bottom of this
* file is a population census — it checks that the registry is closed and that
* `SPEC_DERIVED_PAIRS` re-derives, and it never compares keys at all. Its
* `Tests 5 passed (5)` line does not move when this half reddens, correctly, and it
* did not move under the ablation either. Reading the runtime half for evidence
* `Tests 12 passed (12)` line does not move when this half reddens, correctly, and
* it did not move under the ablation either. (It read `5 passed (5)` until
* objectui#6705 added the seven-fixture suite pinning the re-check's scanner.) Reading the runtime half for evidence
* about drift measures the wrong instrument and concludes the guard does nothing.
*
* When it fires, fix it by MEASURING (the compiler-API recipe and the
Expand DownExpand Up@@ -1434,14 +1445,26 @@ const EXCLUSIONS: Readonly<Record<string, string>> = {
const SPEC_DERIVED_PAIRS: readonly string[] = [
'app.zod.ts#AppComponentSchema',
'app.zod.ts#NavigationAreaSchema',
'base.zod.ts#BaseSchema',
// `base.zod.ts#BaseSchema` and `objectql.zod.ts#ObjectViewSchema` used to stand
// here and were removed by objectui#6705 — NOT because either mirror changed,
// but because the re-check below stopped mis-reading them. Neither references a
// spec schema; each was held here by one of the two defects that card names:
// - `BaseSchema` — its file's ONLY `Spec…` token is in a COMMENT
// (`base.zod.ts`, "rather than calling `SpecSchema.omit(…)`"). Prose.
// - `ObjectViewSchema` — `BaseSchema.extend({…})` with every member a local
// literal. Its declaration ends ~50 lines above the next `export const`, and
// the old text window ran on to that boundary, swallowing the private
// `KanbanConfig = SpecKanbanConfigSchema…` block that belongs to no export.
// ⚠️ The `ObjectViewSchema` removal has a consequence this card did NOT settle:
// the objectui#6058 split in this file's header routes its unmirrored declared
// keys as SPEC-DERIVED — a routing that rests on the false positive. That
// classification is deliberately left as it stands here; see the note there.
'complex.zod.ts#DashboardComponentSchema',
'complex.zod.ts#DashboardWidgetSchema',
'form.zod.ts#SelectOptionSchema',
'layout.zod.ts#PageNodeSchema',
'objectql.zod.ts#ObjectGanttSchema',
'objectql.zod.ts#ObjectMapSchema',
'objectql.zod.ts#ObjectViewSchema',
];

/* ── Runtime: the population is closed ──────────────────────────────────────── */
Expand All@@ -1459,6 +1482,122 @@ function exportedConsts(): string[] {
return out;
}

/* ── Which exports reference a spec symbol (AST, not raw text) ───────────────── */

/**
* The exported consts in ONE mirror source whose definition references a `Spec…`
* symbol — the input to `SPEC_DERIVED_PAIRS`' re-check below.
*
* ## Why this parses instead of scanning text (objectui#6705)
*
* This used to slice the source between `export const` boundaries and test the
* slice against a `\bSpec[A-Z]` word pattern. Raw text cannot tell a reference from a mention,
* and the boundary is not the declaration's end, so the rule had two independent
* defects that both fired on ONE docstring:
*
* - **A comment counted as a reference.** A docstring naming `SpecGanttConfigSchema`
* in PROSE made this test red with nothing about the emitted types, the runtime
* or the mirror's actual spec dependency having moved.
* - **The window charged text to the wrong declaration.** The docstring sat above
* the PRIVATE `GanttConfigExtensionFields` const, which lives textually between
* the `ObjectTreeSchema` and `ObjectGanttSchema` exports — so the slice starting
* at `ObjectTreeSchema` ran on past its own end and the failure named
* `ObjectTreeSchema`, a mirror that references no spec schema at all. The person
* who edits a docstring gets a red naming an innocent neighbour several
* declarations away.
*
* The workaround that shipped (PR #6704) was a docstring reworded to dodge the
* literal token plus a comment asking the next editor to remember — a convention
* held by discipline, which is what a gate is supposed to replace.
*
* ## What it does instead
*
* `Spec…` identifiers are collected from the AST, so a mention inside a comment or
* a string literal is not a reference — those are not `Identifier` nodes and never
* reach the test. There is no comment-stripping regex to get wrong on a string that
* contains `*` `/` sequences, because nothing strips anything.
*
* Attribution is by DECLARATION, not by text window: each top-level declaration
* owns exactly its own initializer. A private const between two exports is charged
* to neither — except through the one link that is a real dependency, which this
* follows: a mirror that spreads a private const built from a spec schema IS spec
* derived, so file-local references are resolved to a fixed point before the
* exported names are read off. `ObjectGanttSchema`'s dependency survives whether it
* extends `SpecGanttConfigSchema` inline or through a local field map.
*
* ⚠️ This is a precision fix, and the direction that must not be lost is the
* POSITIVE one: a scanner that stopped seeing comments AND stopped seeing real
* references would go green on the list below while checking nothing. The fixture
* suite asserts both directions, and the re-check itself is the live positive
* proof — all eight pairs in `SPEC_DERIVED_PAIRS` are found by code reference alone.
*/
export function specReferencingExports(fileName: string, source: string): Set<string> {
const sf = ts.createSourceFile(fileName, source, ts.ScriptTarget.ESNext, false, ts.ScriptKind.TS);

/** Per top-level declaration: does it name a `Spec…` symbol, and what else does it name? */
const decls = new Map<string, { spec: boolean; refs: Set<string> }>();
const exported = new Set<string>();

const collect = (node: ts.Node | undefined, into: { spec: boolean; refs: Set<string> }): void => {
if (!node) return;
const walk = (n: ts.Node): void => {
if (ts.isIdentifier(n)) {
if (/^Spec[A-Z]/.test(n.text)) into.spec = true;
else into.refs.add(n.text);
}
ts.forEachChild(n, walk);
};
walk(node);
};

const record = (name: string, isExported: boolean, ...bodies: (ts.Node | undefined)[]): void => {
const rec = decls.get(name) ?? { spec: false, refs: new Set<string>() };
for (const b of bodies) collect(b, rec);
decls.set(name, rec);
if (isExported) exported.add(name);
};

for (const stmt of sf.statements) {
const isExported = (ts.canHaveModifiers(stmt) ? ts.getModifiers(stmt) : undefined)
?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) === true;
if (ts.isVariableStatement(stmt)) {
for (const d of stmt.declarationList.declarations) {
if (!ts.isIdentifier(d.name)) continue;
record(d.name.text, isExported, d.initializer, d.type);
}
} else if (ts.isFunctionDeclaration(stmt) && stmt.name) {
record(stmt.name.text, isExported, stmt.body, stmt.type);
}
}

// File-local references resolved to a fixed point: a declaration built from one
// that is spec-derived is itself spec-derived. Iterative, so a reference cycle
// terminates instead of recursing.
const specDerived = new Set<string>();
for (const [name, rec] of decls) if (rec.spec) specDerived.add(name);
for (let changed = true; changed; ) {
changed = false;
for (const [name, rec] of decls) {
if (specDerived.has(name)) continue;
for (const r of rec.refs) {
// Only through PRIVATE declarations. An exported mirror is a registered
// pair with an entry of its own, so its spec sensitivity is already
// recorded under its own key; hopping through it would re-attribute one
// mirror's dependency to every mirror that merely names it. A private
// const has no key of its own, so its dependency must be charged to the
// export that uses it or it is lost.
if (specDerived.has(r) && !exported.has(r)) {
specDerived.add(name);
changed = true;
break;
}
}
}
}

return new Set([...exported].filter((n) => specDerived.has(n)));
}

describe('zod mirror parity — the population is closed', () => {
it('every exported const in ../zod/ is either a registered pair or an excluded one', () => {
const registered = new Set(Object.keys(MIRRORS));
Expand DownExpand Up@@ -1487,16 +1626,17 @@ exactly how objectui#4605 and #5186 stayed latent.`).toEqual([]);
it('SPEC_DERIVED_PAIRS matches what the mirror sources actually do', () => {
// Re-derived, not trusted: a mirror that starts referencing a `Spec…` schema
// joins the spec-sensitive set whether or not anyone updates the list.
// Read from the AST — see `specReferencingExports` for why prose does not count.
const derived: string[] = [];
const byFile = new Map<string, Set<string>>();
for (const key of Object.keys(MIRRORS)) {
const [file, name] = key.split('#');
const src = readFileSync(join(ZOD_DIR, file), 'utf8');
const at = src.search(new RegExp(`^export const ${name}\\b`, 'm'));
if (at < 0) continue;
const rest = src.slice(at);
const next = rest.slice(10).search(/\n(?=export (const|type|function))/);
const body = next < 0 ? rest : rest.slice(0, next + 10);
if (/\bSpec[A-Z]\w*/.test(body)) derived.push(key);
let refs = byFile.get(file);
if (!refs) {
refs = specReferencingExports(file, readFileSync(join(ZOD_DIR, file), 'utf8'));
byFile.set(file, refs);
}
if (refs.has(name)) derived.push(key);
}
expect(derived.sort(), 'a mirror gained or lost a spec dependency — update SPEC_DERIVED_PAIRS')
.toEqual([...SPEC_DERIVED_PAIRS].sort());
Expand All@@ -1507,3 +1647,87 @@ exactly how objectui#4605 and #5186 stayed latent.`).toEqual([]);
expect(empty.map(([k]) => k), 'an exclusion without a reason is an oversight').toEqual([]);
});
});

describe('the spec-reference scan reads code, not prose (objectui#6705)', () => {
const scan = (src: string): string[] => [...specReferencingExports('fixture.zod.ts', src)].sort();

it('a `Spec…` token mentioned only in a COMMENT is not a reference', () => {
// The exact shape that flipped this file red: prose, nothing else moved.
expect(
scan(`
/**
* Extends the spec's SpecGanttConfigSchema — mentioned in prose only.
*/
export const ObjectTreeSchema = z.object({ objectName: z.string() });
`),
).toEqual([]);
});

it('a `Spec…` token referenced in CODE still fires — the direction that must not be lost', () => {
// ⚠️ The whole risk of this fix: a scanner that stops seeing comments AND
// stops seeing real references goes green while checking nothing.
expect(
scan(`
export const ObjectGanttSchema = SpecGanttConfigSchema.extend({ lockField: z.string() });
`),
).toEqual(['ObjectGanttSchema']);
});

it('a `Spec…` token inside a STRING LITERAL is not a reference either', () => {
// Including one carrying comment-like sequences, which is the hazard a
// regex-based comment stripper would have had. Nothing is stripped here.
expect(
scan(`
export const TextSchema = z.string().describe('SpecFooSchema */ // not a reference');
`),
).toEqual([]);
});

it('a PRIVATE const between two exports charges its prose to NEITHER neighbour', () => {
// The live misattribution: the docstring sat above the private const, and the
// text window starting at the preceding export ran on past its own end.
expect(
scan(`
export const ObjectTreeSchema = z.object({ objectName: z.string() });

/** Everything beyond the spec's SpecGanttConfigSchema. */
const GanttConfigExtensionFields = { lockField: z.string() };

export const ObjectGanttSchema = z.object({ ...GanttConfigExtensionFields });
`),
).toEqual([]);
});

it('a PRIVATE const with a REAL spec reference is charged to its USER, not its neighbour', () => {
// Attribution by declaration plus file-local resolution: `ObjectGanttSchema`
// really is spec-derived through the field map; `ObjectTreeSchema` is not, and
// the text window used to blame exactly it.
expect(
scan(`
export const ObjectTreeSchema = z.object({ objectName: z.string() });

const GanttConfigExtensionFields = SpecGanttConfigSchema.shape;

export const ObjectGanttSchema = z.object({ ...GanttConfigExtensionFields });
`),
).toEqual(['ObjectGanttSchema']);
});

it("the NEXT export's docstring is not charged to the previous export", () => {
expect(
scan(`
export const ObjectTreeSchema = z.object({ objectName: z.string() });

/** Built from SpecGanttConfigSchema. */
export const ObjectGanttSchema = SpecGanttConfigSchema.extend({});
`),
).toEqual(['ObjectGanttSchema']);
});

it('the scanner can actually see a real mirror source (non-vacuity)', () => {
// A broken parser returns an empty set and every negative case above passes
// while checking nothing. Pin it against the file this bug was found in.
const src = readFileSync(join(ZOD_DIR, 'objectql.zod.ts'), 'utf8');
expect([...specReferencingExports('objectql.zod.ts', src)]).toContain('ObjectGanttSchema');
});
});
15 changes: 4 additions & 11 deletions packages/types/src/zod/objectql.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -606,23 +606,16 @@ export const ObjectTreeSchema = BaseSchema.extend({

/**
* objectui's own `GanttConfig` extensions — everything `../objectql.ts` declares
* on {@link GanttConfig} beyond the spec's `GanttConfigSchema` (objectui#6051
* on {@link GanttConfig} beyond the spec's `SpecGanttConfigSchema` (objectui#6051
* lifted nine of them out of `plugin-gantt`'s package-private `GanttConfigEx`;
* `timeSegments` was already there).
*
* Held as ONE field map rather than inlined, so the flattened top-level spelling
* below is built from a single source — the same way the TS side derives its
* flattened members from `GanttConfig`. It is also the shape the nested `gantt`
* block is built from (objectui#6475), one line extending the spec's gantt
* config schema with this map — so both authoring faces share one vocabulary
* and cannot fork from each other.
*
* ⚠️ Keep this docstring free of a literal `Spec` + capital-letter token: it
* sits between the `ObjectTreeSchema` and `ObjectGanttSchema` export
* boundaries, and `zod-mirror-parity.test.ts`'s `SPEC_DERIVED_PAIRS` re-check
* scans raw text between export boundaries for `\bSpec[A-Z]\w*` — a match here
* is misattributed to `ObjectTreeSchema`, which references no spec schema at
* all (measured: this comment alone flipped that test red).
* block is built from (objectui#6475): one line extending `SpecGanttConfigSchema`
* with this map — so both authoring faces share one vocabulary and cannot fork
* from each other.
*
* Not exported: the parity census in `__tests__/zod-mirror-parity.test.ts` reads
* `^export const` out of this directory and would require a registered TS
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
11 changes: 11 additions & 0 deletions .changeset/6705-parity-scan-reads-ast.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
---
---

Test-only change to `zod-mirror-parity.test.ts`'s `SPEC_DERIVED_PAIRS` re-check: it now
reads `Spec…` references off the TypeScript AST instead of scanning raw text between
`export const` boundaries, so a token mentioned in a comment is no longer counted as a
reference and no longer misattributed to the neighbouring export (objectui#6705). The one
change under `packages/types/src/zod/` is a docstring: the wording PR #6704 had to contort
around the old scanner is restored, along with the removal of the trap comment that asked
the next editor to remember. No published behaviour changes — no runtime, type or schema
surface moves.
250 changes: 237 additions & 13 deletions packages/types/src/__tests__/zod-mirror-parity.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -113,6 +113,7 @@ import { describe, it, expect } from 'vitest';
import { readdirSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import ts from 'typescript';
import type { z } from 'zod';

import { AppActionSchema, AppComponentSchema, NavigationAreaSchema } from '../zod/app.zod.js';
Expand DownExpand Up@@ -762,8 +763,17 @@ interface KnownDrift {
* are recorded here so whoever works them off does not re-derive them:
*
* - **SPEC-DERIVED (3 entries, 13 keys)** — `DashboardComponentSchema`,
* `DashboardWidgetSchema`, `ObjectViewSchema` are in `SPEC_DERIVED_PAIRS` below,
* so their mirror takes its shape BY REFERENCE from `@objectstack/spec`. An
* `DashboardWidgetSchema`, `ObjectViewSchema`. ⚠️ objectui#6705 invalidated the
* evidence for ONE of the three: `ObjectViewSchema` is no longer in
* `SPEC_DERIVED_PAIRS` below, because it never referenced a spec schema — it is
* `BaseSchema.extend({…})` of local literals, and the pre-#6705 text scanner
* charged it a neighbouring private const's `Spec…` token. The split is left
* STANDING as written, counts and all: re-routing those keys from #2231's
* unification question to a local mirror edit is a remedy decision on the
* `UnmirroredDeclared` ledger, which #6705 was fenced out of. Whoever works
* this split off must re-derive `ObjectViewSchema`'s side first.
* For the other two the reading is unchanged:
* their mirror takes its shape BY REFERENCE from `@objectstack/spec`. An
* unmirrored declared key there means the LOCAL declaration carries members the
* spec schema does not model, which is objectui#2231's unification question and
* NOT a local mirror edit. They are marked, not exempted: exempting them in the
Expand DownExpand Up@@ -1212,8 +1222,9 @@ export const assertionDriftMatchesLedger: never = 0 as unknown as LedgerMismatch
* ⚠️ And it is a COMPILE-TIME assertion. The `describe` block at the bottom of this
* file is a population census — it checks that the registry is closed and that
* `SPEC_DERIVED_PAIRS` re-derives, and it never compares keys at all. Its
* `Tests 5 passed (5)` line does not move when this half reddens, correctly, and it
* did not move under the ablation either. Reading the runtime half for evidence
* `Tests 12 passed (12)` line does not move when this half reddens, correctly, and
* it did not move under the ablation either. (It read `5 passed (5)` until
* objectui#6705 added the seven-fixture suite pinning the re-check's scanner.) Reading the runtime half for evidence
* about drift measures the wrong instrument and concludes the guard does nothing.
*
* When it fires, fix it by MEASURING (the compiler-API recipe and the
Expand DownExpand Up@@ -1434,14 +1445,26 @@ const EXCLUSIONS: Readonly<Record<string, string>> = {
const SPEC_DERIVED_PAIRS: readonly string[] = [
'app.zod.ts#AppComponentSchema',
'app.zod.ts#NavigationAreaSchema',
'base.zod.ts#BaseSchema',
// `base.zod.ts#BaseSchema` and `objectql.zod.ts#ObjectViewSchema` used to stand
// here and were removed by objectui#6705 — NOT because either mirror changed,
// but because the re-check below stopped mis-reading them. Neither references a
// spec schema; each was held here by one of the two defects that card names:
// - `BaseSchema` — its file's ONLY `Spec…` token is in a COMMENT
// (`base.zod.ts`, "rather than calling `SpecSchema.omit(…)`"). Prose.
// - `ObjectViewSchema` — `BaseSchema.extend({…})` with every member a local
// literal. Its declaration ends ~50 lines above the next `export const`, and
// the old text window ran on to that boundary, swallowing the private
// `KanbanConfig = SpecKanbanConfigSchema…` block that belongs to no export.
// ⚠️ The `ObjectViewSchema` removal has a consequence this card did NOT settle:
// the objectui#6058 split in this file's header routes its unmirrored declared
// keys as SPEC-DERIVED — a routing that rests on the false positive. That
// classification is deliberately left as it stands here; see the note there.
'complex.zod.ts#DashboardComponentSchema',
'complex.zod.ts#DashboardWidgetSchema',
'form.zod.ts#SelectOptionSchema',
'layout.zod.ts#PageNodeSchema',
'objectql.zod.ts#ObjectGanttSchema',
'objectql.zod.ts#ObjectMapSchema',
'objectql.zod.ts#ObjectViewSchema',
];

/* ── Runtime: the population is closed ──────────────────────────────────────── */
Expand All@@ -1459,6 +1482,122 @@ function exportedConsts(): string[] {
return out;
}

/* ── Which exports reference a spec symbol (AST, not raw text) ───────────────── */

/**
* The exported consts in ONE mirror source whose definition references a `Spec…`
* symbol — the input to `SPEC_DERIVED_PAIRS`' re-check below.
*
* ## Why this parses instead of scanning text (objectui#6705)
*
* This used to slice the source between `export const` boundaries and test the
* slice against a `\bSpec[A-Z]` word pattern. Raw text cannot tell a reference from a mention,
* and the boundary is not the declaration's end, so the rule had two independent
* defects that both fired on ONE docstring:
*
* - **A comment counted as a reference.** A docstring naming `SpecGanttConfigSchema`
* in PROSE made this test red with nothing about the emitted types, the runtime
* or the mirror's actual spec dependency having moved.
* - **The window charged text to the wrong declaration.** The docstring sat above
* the PRIVATE `GanttConfigExtensionFields` const, which lives textually between
* the `ObjectTreeSchema` and `ObjectGanttSchema` exports — so the slice starting
* at `ObjectTreeSchema` ran on past its own end and the failure named
* `ObjectTreeSchema`, a mirror that references no spec schema at all. The person
* who edits a docstring gets a red naming an innocent neighbour several
* declarations away.
*
* The workaround that shipped (PR #6704) was a docstring reworded to dodge the
* literal token plus a comment asking the next editor to remember — a convention
* held by discipline, which is what a gate is supposed to replace.
*
* ## What it does instead
*
* `Spec…` identifiers are collected from the AST, so a mention inside a comment or
* a string literal is not a reference — those are not `Identifier` nodes and never
* reach the test. There is no comment-stripping regex to get wrong on a string that
* contains `*` `/` sequences, because nothing strips anything.
*
* Attribution is by DECLARATION, not by text window: each top-level declaration
* owns exactly its own initializer. A private const between two exports is charged
* to neither — except through the one link that is a real dependency, which this
* follows: a mirror that spreads a private const built from a spec schema IS spec
* derived, so file-local references are resolved to a fixed point before the
* exported names are read off. `ObjectGanttSchema`'s dependency survives whether it
* extends `SpecGanttConfigSchema` inline or through a local field map.
*
* ⚠️ This is a precision fix, and the direction that must not be lost is the
* POSITIVE one: a scanner that stopped seeing comments AND stopped seeing real
* references would go green on the list below while checking nothing. The fixture
* suite asserts both directions, and the re-check itself is the live positive
* proof — all eight pairs in `SPEC_DERIVED_PAIRS` are found by code reference alone.
*/
export function specReferencingExports(fileName: string, source: string): Set<string> {
const sf = ts.createSourceFile(fileName, source, ts.ScriptTarget.ESNext, false, ts.ScriptKind.TS);

/** Per top-level declaration: does it name a `Spec…` symbol, and what else does it name? */
const decls = new Map<string, { spec: boolean; refs: Set<string> }>();
const exported = new Set<string>();

const collect = (node: ts.Node | undefined, into: { spec: boolean; refs: Set<string> }): void => {
if (!node) return;
const walk = (n: ts.Node): void => {
if (ts.isIdentifier(n)) {
if (/^Spec[A-Z]/.test(n.text)) into.spec = true;
else into.refs.add(n.text);
}
ts.forEachChild(n, walk);
};
walk(node);
};

const record = (name: string, isExported: boolean, ...bodies: (ts.Node | undefined)[]): void => {
const rec = decls.get(name) ?? { spec: false, refs: new Set<string>() };
for (const b of bodies) collect(b, rec);
decls.set(name, rec);
if (isExported) exported.add(name);
};

for (const stmt of sf.statements) {
const isExported = (ts.canHaveModifiers(stmt) ? ts.getModifiers(stmt) : undefined)
?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) === true;
if (ts.isVariableStatement(stmt)) {
for (const d of stmt.declarationList.declarations) {
if (!ts.isIdentifier(d.name)) continue;
record(d.name.text, isExported, d.initializer, d.type);
}
} else if (ts.isFunctionDeclaration(stmt) && stmt.name) {
record(stmt.name.text, isExported, stmt.body, stmt.type);
}
}

// File-local references resolved to a fixed point: a declaration built from one
// that is spec-derived is itself spec-derived. Iterative, so a reference cycle
// terminates instead of recursing.
const specDerived = new Set<string>();
for (const [name, rec] of decls) if (rec.spec) specDerived.add(name);
for (let changed = true; changed; ) {
changed = false;
for (const [name, rec] of decls) {
if (specDerived.has(name)) continue;
for (const r of rec.refs) {
// Only through PRIVATE declarations. An exported mirror is a registered
// pair with an entry of its own, so its spec sensitivity is already
// recorded under its own key; hopping through it would re-attribute one
// mirror's dependency to every mirror that merely names it. A private
// const has no key of its own, so its dependency must be charged to the
// export that uses it or it is lost.
if (specDerived.has(r) && !exported.has(r)) {
specDerived.add(name);
changed = true;
break;
}
}
}
}

return new Set([...exported].filter((n) => specDerived.has(n)));
}

describe('zod mirror parity — the population is closed', () => {
it('every exported const in ../zod/ is either a registered pair or an excluded one', () => {
const registered = new Set(Object.keys(MIRRORS));
Expand DownExpand Up@@ -1487,16 +1626,17 @@ exactly how objectui#4605 and #5186 stayed latent.`).toEqual([]);
it('SPEC_DERIVED_PAIRS matches what the mirror sources actually do', () => {
// Re-derived, not trusted: a mirror that starts referencing a `Spec…` schema
// joins the spec-sensitive set whether or not anyone updates the list.
// Read from the AST — see `specReferencingExports` for why prose does not count.
const derived: string[] = [];
const byFile = new Map<string, Set<string>>();
for (const key of Object.keys(MIRRORS)) {
const [file, name] = key.split('#');
const src = readFileSync(join(ZOD_DIR, file), 'utf8');
const at = src.search(new RegExp(`^export const ${name}\\b`, 'm'));
if (at < 0) continue;
const rest = src.slice(at);
const next = rest.slice(10).search(/\n(?=export (const|type|function))/);
const body = next < 0 ? rest : rest.slice(0, next + 10);
if (/\bSpec[A-Z]\w*/.test(body)) derived.push(key);
let refs = byFile.get(file);
if (!refs) {
refs = specReferencingExports(file, readFileSync(join(ZOD_DIR, file), 'utf8'));
byFile.set(file, refs);
}
if (refs.has(name)) derived.push(key);
}
expect(derived.sort(), 'a mirror gained or lost a spec dependency — update SPEC_DERIVED_PAIRS')
.toEqual([...SPEC_DERIVED_PAIRS].sort());
Expand All@@ -1507,3 +1647,87 @@ exactly how objectui#4605 and #5186 stayed latent.`).toEqual([]);
expect(empty.map(([k]) => k), 'an exclusion without a reason is an oversight').toEqual([]);
});
});

describe('the spec-reference scan reads code, not prose (objectui#6705)', () => {
const scan = (src: string): string[] => [...specReferencingExports('fixture.zod.ts', src)].sort();

it('a `Spec…` token mentioned only in a COMMENT is not a reference', () => {
// The exact shape that flipped this file red: prose, nothing else moved.
expect(
scan(`
/**
* Extends the spec's SpecGanttConfigSchema — mentioned in prose only.
*/
export const ObjectTreeSchema = z.object({ objectName: z.string() });
`),
).toEqual([]);
});

it('a `Spec…` token referenced in CODE still fires — the direction that must not be lost', () => {
// ⚠️ The whole risk of this fix: a scanner that stops seeing comments AND
// stops seeing real references goes green while checking nothing.
expect(
scan(`
export const ObjectGanttSchema = SpecGanttConfigSchema.extend({ lockField: z.string() });
`),
).toEqual(['ObjectGanttSchema']);
});

it('a `Spec…` token inside a STRING LITERAL is not a reference either', () => {
// Including one carrying comment-like sequences, which is the hazard a
// regex-based comment stripper would have had. Nothing is stripped here.
expect(
scan(`
export const TextSchema = z.string().describe('SpecFooSchema */ // not a reference');
`),
).toEqual([]);
});

it('a PRIVATE const between two exports charges its prose to NEITHER neighbour', () => {
// The live misattribution: the docstring sat above the private const, and the
// text window starting at the preceding export ran on past its own end.
expect(
scan(`
export const ObjectTreeSchema = z.object({ objectName: z.string() });

/** Everything beyond the spec's SpecGanttConfigSchema. */
const GanttConfigExtensionFields = { lockField: z.string() };

export const ObjectGanttSchema = z.object({ ...GanttConfigExtensionFields });
`),
).toEqual([]);
});

it('a PRIVATE const with a REAL spec reference is charged to its USER, not its neighbour', () => {
// Attribution by declaration plus file-local resolution: `ObjectGanttSchema`
// really is spec-derived through the field map; `ObjectTreeSchema` is not, and
// the text window used to blame exactly it.
expect(
scan(`
export const ObjectTreeSchema = z.object({ objectName: z.string() });

const GanttConfigExtensionFields = SpecGanttConfigSchema.shape;

export const ObjectGanttSchema = z.object({ ...GanttConfigExtensionFields });
`),
).toEqual(['ObjectGanttSchema']);
});

it("the NEXT export's docstring is not charged to the previous export", () => {
expect(
scan(`
export const ObjectTreeSchema = z.object({ objectName: z.string() });

/** Built from SpecGanttConfigSchema. */
export const ObjectGanttSchema = SpecGanttConfigSchema.extend({});
`),
).toEqual(['ObjectGanttSchema']);
});

it('the scanner can actually see a real mirror source (non-vacuity)', () => {
// A broken parser returns an empty set and every negative case above passes
// while checking nothing. Pin it against the file this bug was found in.
const src = readFileSync(join(ZOD_DIR, 'objectql.zod.ts'), 'utf8');
expect([...specReferencingExports('objectql.zod.ts', src)]).toContain('ObjectGanttSchema');
});
});
15 changes: 4 additions & 11 deletions packages/types/src/zod/objectql.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -606,23 +606,16 @@ export const ObjectTreeSchema = BaseSchema.extend({

/**
* objectui's own `GanttConfig` extensions — everything `../objectql.ts` declares
* on {@link GanttConfig} beyond the spec's `GanttConfigSchema` (objectui#6051
* on {@link GanttConfig} beyond the spec's `SpecGanttConfigSchema` (objectui#6051
* lifted nine of them out of `plugin-gantt`'s package-private `GanttConfigEx`;
* `timeSegments` was already there).
*
* Held as ONE field map rather than inlined, so the flattened top-level spelling
* below is built from a single source — the same way the TS side derives its
* flattened members from `GanttConfig`. It is also the shape the nested `gantt`
* block is built from (objectui#6475), one line extending the spec's gantt
* config schema with this map — so both authoring faces share one vocabulary
* and cannot fork from each other.
*
* ⚠️ Keep this docstring free of a literal `Spec` + capital-letter token: it
* sits between the `ObjectTreeSchema` and `ObjectGanttSchema` export
* boundaries, and `zod-mirror-parity.test.ts`'s `SPEC_DERIVED_PAIRS` re-check
* scans raw text between export boundaries for `\bSpec[A-Z]\w*` — a match here
* is misattributed to `ObjectTreeSchema`, which references no spec schema at
* all (measured: this comment alone flipped that test red).
* block is built from (objectui#6475): one line extending `SpecGanttConfigSchema`
* with this map — so both authoring faces share one vocabulary and cannot fork
* from each other.
*
* Not exported: the parity census in `__tests__/zod-mirror-parity.test.ts` reads
* `^export const` out of this directory and would require a registered TS
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
11 changes: 11 additions & 0 deletions .changeset/6705-parity-scan-reads-ast.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
---
---

Test-only change to `zod-mirror-parity.test.ts`'s `SPEC_DERIVED_PAIRS` re-check: it now
reads `Spec…` references off the TypeScript AST instead of scanning raw text between
`export const` boundaries, so a token mentioned in a comment is no longer counted as a
reference and no longer misattributed to the neighbouring export (objectui#6705). The one
change under `packages/types/src/zod/` is a docstring: the wording PR #6704 had to contort
around the old scanner is restored, along with the removal of the trap comment that asked
the next editor to remember. No published behaviour changes — no runtime, type or schema
surface moves.
250 changes: 237 additions & 13 deletions packages/types/src/__tests__/zod-mirror-parity.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -113,6 +113,7 @@ import { describe, it, expect } from 'vitest';
import { readdirSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import ts from 'typescript';
import type { z } from 'zod';

import { AppActionSchema, AppComponentSchema, NavigationAreaSchema } from '../zod/app.zod.js';
Expand DownExpand Up@@ -762,8 +763,17 @@ interface KnownDrift {
* are recorded here so whoever works them off does not re-derive them:
*
* - **SPEC-DERIVED (3 entries, 13 keys)** — `DashboardComponentSchema`,
* `DashboardWidgetSchema`, `ObjectViewSchema` are in `SPEC_DERIVED_PAIRS` below,
* so their mirror takes its shape BY REFERENCE from `@objectstack/spec`. An
* `DashboardWidgetSchema`, `ObjectViewSchema`. ⚠️ objectui#6705 invalidated the
* evidence for ONE of the three: `ObjectViewSchema` is no longer in
* `SPEC_DERIVED_PAIRS` below, because it never referenced a spec schema — it is
* `BaseSchema.extend({…})` of local literals, and the pre-#6705 text scanner
* charged it a neighbouring private const's `Spec…` token. The split is left
* STANDING as written, counts and all: re-routing those keys from #2231's
* unification question to a local mirror edit is a remedy decision on the
* `UnmirroredDeclared` ledger, which #6705 was fenced out of. Whoever works
* this split off must re-derive `ObjectViewSchema`'s side first.
* For the other two the reading is unchanged:
* their mirror takes its shape BY REFERENCE from `@objectstack/spec`. An
* unmirrored declared key there means the LOCAL declaration carries members the
* spec schema does not model, which is objectui#2231's unification question and
* NOT a local mirror edit. They are marked, not exempted: exempting them in the
Expand DownExpand Up@@ -1212,8 +1222,9 @@ export const assertionDriftMatchesLedger: never = 0 as unknown as LedgerMismatch
* ⚠️ And it is a COMPILE-TIME assertion. The `describe` block at the bottom of this
* file is a population census — it checks that the registry is closed and that
* `SPEC_DERIVED_PAIRS` re-derives, and it never compares keys at all. Its
* `Tests 5 passed (5)` line does not move when this half reddens, correctly, and it
* did not move under the ablation either. Reading the runtime half for evidence
* `Tests 12 passed (12)` line does not move when this half reddens, correctly, and
* it did not move under the ablation either. (It read `5 passed (5)` until
* objectui#6705 added the seven-fixture suite pinning the re-check's scanner.) Reading the runtime half for evidence
* about drift measures the wrong instrument and concludes the guard does nothing.
*
* When it fires, fix it by MEASURING (the compiler-API recipe and the
Expand DownExpand Up@@ -1434,14 +1445,26 @@ const EXCLUSIONS: Readonly<Record<string, string>> = {
const SPEC_DERIVED_PAIRS: readonly string[] = [
'app.zod.ts#AppComponentSchema',
'app.zod.ts#NavigationAreaSchema',
'base.zod.ts#BaseSchema',
// `base.zod.ts#BaseSchema` and `objectql.zod.ts#ObjectViewSchema` used to stand
// here and were removed by objectui#6705 — NOT because either mirror changed,
// but because the re-check below stopped mis-reading them. Neither references a
// spec schema; each was held here by one of the two defects that card names:
// - `BaseSchema` — its file's ONLY `Spec…` token is in a COMMENT
// (`base.zod.ts`, "rather than calling `SpecSchema.omit(…)`"). Prose.
// - `ObjectViewSchema` — `BaseSchema.extend({…})` with every member a local
// literal. Its declaration ends ~50 lines above the next `export const`, and
// the old text window ran on to that boundary, swallowing the private
// `KanbanConfig = SpecKanbanConfigSchema…` block that belongs to no export.
// ⚠️ The `ObjectViewSchema` removal has a consequence this card did NOT settle:
// the objectui#6058 split in this file's header routes its unmirrored declared
// keys as SPEC-DERIVED — a routing that rests on the false positive. That
// classification is deliberately left as it stands here; see the note there.
'complex.zod.ts#DashboardComponentSchema',
'complex.zod.ts#DashboardWidgetSchema',
'form.zod.ts#SelectOptionSchema',
'layout.zod.ts#PageNodeSchema',
'objectql.zod.ts#ObjectGanttSchema',
'objectql.zod.ts#ObjectMapSchema',
'objectql.zod.ts#ObjectViewSchema',
];

/* ── Runtime: the population is closed ──────────────────────────────────────── */
Expand All@@ -1459,6 +1482,122 @@ function exportedConsts(): string[] {
return out;
}

/* ── Which exports reference a spec symbol (AST, not raw text) ───────────────── */

/**
* The exported consts in ONE mirror source whose definition references a `Spec…`
* symbol — the input to `SPEC_DERIVED_PAIRS`' re-check below.
*
* ## Why this parses instead of scanning text (objectui#6705)
*
* This used to slice the source between `export const` boundaries and test the
* slice against a `\bSpec[A-Z]` word pattern. Raw text cannot tell a reference from a mention,
* and the boundary is not the declaration's end, so the rule had two independent
* defects that both fired on ONE docstring:
*
* - **A comment counted as a reference.** A docstring naming `SpecGanttConfigSchema`
* in PROSE made this test red with nothing about the emitted types, the runtime
* or the mirror's actual spec dependency having moved.
* - **The window charged text to the wrong declaration.** The docstring sat above
* the PRIVATE `GanttConfigExtensionFields` const, which lives textually between
* the `ObjectTreeSchema` and `ObjectGanttSchema` exports — so the slice starting
* at `ObjectTreeSchema` ran on past its own end and the failure named
* `ObjectTreeSchema`, a mirror that references no spec schema at all. The person
* who edits a docstring gets a red naming an innocent neighbour several
* declarations away.
*
* The workaround that shipped (PR #6704) was a docstring reworded to dodge the
* literal token plus a comment asking the next editor to remember — a convention
* held by discipline, which is what a gate is supposed to replace.
*
* ## What it does instead
*
* `Spec…` identifiers are collected from the AST, so a mention inside a comment or
* a string literal is not a reference — those are not `Identifier` nodes and never
* reach the test. There is no comment-stripping regex to get wrong on a string that
* contains `*` `/` sequences, because nothing strips anything.
*
* Attribution is by DECLARATION, not by text window: each top-level declaration
* owns exactly its own initializer. A private const between two exports is charged
* to neither — except through the one link that is a real dependency, which this
* follows: a mirror that spreads a private const built from a spec schema IS spec
* derived, so file-local references are resolved to a fixed point before the
* exported names are read off. `ObjectGanttSchema`'s dependency survives whether it
* extends `SpecGanttConfigSchema` inline or through a local field map.
*
* ⚠️ This is a precision fix, and the direction that must not be lost is the
* POSITIVE one: a scanner that stopped seeing comments AND stopped seeing real
* references would go green on the list below while checking nothing. The fixture
* suite asserts both directions, and the re-check itself is the live positive
* proof — all eight pairs in `SPEC_DERIVED_PAIRS` are found by code reference alone.
*/
export function specReferencingExports(fileName: string, source: string): Set<string> {
const sf = ts.createSourceFile(fileName, source, ts.ScriptTarget.ESNext, false, ts.ScriptKind.TS);

/** Per top-level declaration: does it name a `Spec…` symbol, and what else does it name? */
const decls = new Map<string, { spec: boolean; refs: Set<string> }>();
const exported = new Set<string>();

const collect = (node: ts.Node | undefined, into: { spec: boolean; refs: Set<string> }): void => {
if (!node) return;
const walk = (n: ts.Node): void => {
if (ts.isIdentifier(n)) {
if (/^Spec[A-Z]/.test(n.text)) into.spec = true;
else into.refs.add(n.text);
}
ts.forEachChild(n, walk);
};
walk(node);
};

const record = (name: string, isExported: boolean, ...bodies: (ts.Node | undefined)[]): void => {
const rec = decls.get(name) ?? { spec: false, refs: new Set<string>() };
for (const b of bodies) collect(b, rec);
decls.set(name, rec);
if (isExported) exported.add(name);
};

for (const stmt of sf.statements) {
const isExported = (ts.canHaveModifiers(stmt) ? ts.getModifiers(stmt) : undefined)
?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) === true;
if (ts.isVariableStatement(stmt)) {
for (const d of stmt.declarationList.declarations) {
if (!ts.isIdentifier(d.name)) continue;
record(d.name.text, isExported, d.initializer, d.type);
}
} else if (ts.isFunctionDeclaration(stmt) && stmt.name) {
record(stmt.name.text, isExported, stmt.body, stmt.type);
}
}

// File-local references resolved to a fixed point: a declaration built from one
// that is spec-derived is itself spec-derived. Iterative, so a reference cycle
// terminates instead of recursing.
const specDerived = new Set<string>();
for (const [name, rec] of decls) if (rec.spec) specDerived.add(name);
for (let changed = true; changed; ) {
changed = false;
for (const [name, rec] of decls) {
if (specDerived.has(name)) continue;
for (const r of rec.refs) {
// Only through PRIVATE declarations. An exported mirror is a registered
// pair with an entry of its own, so its spec sensitivity is already
// recorded under its own key; hopping through it would re-attribute one
// mirror's dependency to every mirror that merely names it. A private
// const has no key of its own, so its dependency must be charged to the
// export that uses it or it is lost.
if (specDerived.has(r) && !exported.has(r)) {
specDerived.add(name);
changed = true;
break;
}
}
}
}

return new Set([...exported].filter((n) => specDerived.has(n)));
}

describe('zod mirror parity — the population is closed', () => {
it('every exported const in ../zod/ is either a registered pair or an excluded one', () => {
const registered = new Set(Object.keys(MIRRORS));
Expand DownExpand Up@@ -1487,16 +1626,17 @@ exactly how objectui#4605 and #5186 stayed latent.`).toEqual([]);
it('SPEC_DERIVED_PAIRS matches what the mirror sources actually do', () => {
// Re-derived, not trusted: a mirror that starts referencing a `Spec…` schema
// joins the spec-sensitive set whether or not anyone updates the list.
// Read from the AST — see `specReferencingExports` for why prose does not count.
const derived: string[] = [];
const byFile = new Map<string, Set<string>>();
for (const key of Object.keys(MIRRORS)) {
const [file, name] = key.split('#');
const src = readFileSync(join(ZOD_DIR, file), 'utf8');
const at = src.search(new RegExp(`^export const ${name}\\b`, 'm'));
if (at < 0) continue;
const rest = src.slice(at);
const next = rest.slice(10).search(/\n(?=export (const|type|function))/);
const body = next < 0 ? rest : rest.slice(0, next + 10);
if (/\bSpec[A-Z]\w*/.test(body)) derived.push(key);
let refs = byFile.get(file);
if (!refs) {
refs = specReferencingExports(file, readFileSync(join(ZOD_DIR, file), 'utf8'));
byFile.set(file, refs);
}
if (refs.has(name)) derived.push(key);
}
expect(derived.sort(), 'a mirror gained or lost a spec dependency — update SPEC_DERIVED_PAIRS')
.toEqual([...SPEC_DERIVED_PAIRS].sort());
Expand All@@ -1507,3 +1647,87 @@ exactly how objectui#4605 and #5186 stayed latent.`).toEqual([]);
expect(empty.map(([k]) => k), 'an exclusion without a reason is an oversight').toEqual([]);
});
});

describe('the spec-reference scan reads code, not prose (objectui#6705)', () => {
const scan = (src: string): string[] => [...specReferencingExports('fixture.zod.ts', src)].sort();

it('a `Spec…` token mentioned only in a COMMENT is not a reference', () => {
// The exact shape that flipped this file red: prose, nothing else moved.
expect(
scan(`
/**
* Extends the spec's SpecGanttConfigSchema — mentioned in prose only.
*/
export const ObjectTreeSchema = z.object({ objectName: z.string() });
`),
).toEqual([]);
});

it('a `Spec…` token referenced in CODE still fires — the direction that must not be lost', () => {
// ⚠️ The whole risk of this fix: a scanner that stops seeing comments AND
// stops seeing real references goes green while checking nothing.
expect(
scan(`
export const ObjectGanttSchema = SpecGanttConfigSchema.extend({ lockField: z.string() });
`),
).toEqual(['ObjectGanttSchema']);
});

it('a `Spec…` token inside a STRING LITERAL is not a reference either', () => {
// Including one carrying comment-like sequences, which is the hazard a
// regex-based comment stripper would have had. Nothing is stripped here.
expect(
scan(`
export const TextSchema = z.string().describe('SpecFooSchema */ // not a reference');
`),
).toEqual([]);
});

it('a PRIVATE const between two exports charges its prose to NEITHER neighbour', () => {
// The live misattribution: the docstring sat above the private const, and the
// text window starting at the preceding export ran on past its own end.
expect(
scan(`
export const ObjectTreeSchema = z.object({ objectName: z.string() });

/** Everything beyond the spec's SpecGanttConfigSchema. */
const GanttConfigExtensionFields = { lockField: z.string() };

export const ObjectGanttSchema = z.object({ ...GanttConfigExtensionFields });
`),
).toEqual([]);
});

it('a PRIVATE const with a REAL spec reference is charged to its USER, not its neighbour', () => {
// Attribution by declaration plus file-local resolution: `ObjectGanttSchema`
// really is spec-derived through the field map; `ObjectTreeSchema` is not, and
// the text window used to blame exactly it.
expect(
scan(`
export const ObjectTreeSchema = z.object({ objectName: z.string() });

const GanttConfigExtensionFields = SpecGanttConfigSchema.shape;

export const ObjectGanttSchema = z.object({ ...GanttConfigExtensionFields });
`),
).toEqual(['ObjectGanttSchema']);
});

it("the NEXT export's docstring is not charged to the previous export", () => {
expect(
scan(`
export const ObjectTreeSchema = z.object({ objectName: z.string() });

/** Built from SpecGanttConfigSchema. */
export const ObjectGanttSchema = SpecGanttConfigSchema.extend({});
`),
).toEqual(['ObjectGanttSchema']);
});

it('the scanner can actually see a real mirror source (non-vacuity)', () => {
// A broken parser returns an empty set and every negative case above passes
// while checking nothing. Pin it against the file this bug was found in.
const src = readFileSync(join(ZOD_DIR, 'objectql.zod.ts'), 'utf8');
expect([...specReferencingExports('objectql.zod.ts', src)]).toContain('ObjectGanttSchema');
});
});
15 changes: 4 additions & 11 deletions packages/types/src/zod/objectql.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -606,23 +606,16 @@ export const ObjectTreeSchema = BaseSchema.extend({

/**
* objectui's own `GanttConfig` extensions — everything `../objectql.ts` declares
* on {@link GanttConfig} beyond the spec's `GanttConfigSchema` (objectui#6051
* on {@link GanttConfig} beyond the spec's `SpecGanttConfigSchema` (objectui#6051
* lifted nine of them out of `plugin-gantt`'s package-private `GanttConfigEx`;
* `timeSegments` was already there).
*
* Held as ONE field map rather than inlined, so the flattened top-level spelling
* below is built from a single source — the same way the TS side derives its
* flattened members from `GanttConfig`. It is also the shape the nested `gantt`
* block is built from (objectui#6475), one line extending the spec's gantt
* config schema with this map — so both authoring faces share one vocabulary
* and cannot fork from each other.
*
* ⚠️ Keep this docstring free of a literal `Spec` + capital-letter token: it
* sits between the `ObjectTreeSchema` and `ObjectGanttSchema` export
* boundaries, and `zod-mirror-parity.test.ts`'s `SPEC_DERIVED_PAIRS` re-check
* scans raw text between export boundaries for `\bSpec[A-Z]\w*` — a match here
* is misattributed to `ObjectTreeSchema`, which references no spec schema at
* all (measured: this comment alone flipped that test red).
* block is built from (objectui#6475): one line extending `SpecGanttConfigSchema`
* with this map — so both authoring faces share one vocabulary and cannot fork
* from each other.
*
* Not exported: the parity census in `__tests__/zod-mirror-parity.test.ts` reads
* `^export const` out of this directory and would require a registered TS
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
11 changes: 11 additions & 0 deletions .changeset/6705-parity-scan-reads-ast.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
---
---

Test-only change to `zod-mirror-parity.test.ts`'s `SPEC_DERIVED_PAIRS` re-check: it now
reads `Spec…` references off the TypeScript AST instead of scanning raw text between
`export const` boundaries, so a token mentioned in a comment is no longer counted as a
reference and no longer misattributed to the neighbouring export (objectui#6705). The one
change under `packages/types/src/zod/` is a docstring: the wording PR #6704 had to contort
around the old scanner is restored, along with the removal of the trap comment that asked
the next editor to remember. No published behaviour changes — no runtime, type or schema
surface moves.
250 changes: 237 additions & 13 deletions packages/types/src/__tests__/zod-mirror-parity.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -113,6 +113,7 @@ import { describe, it, expect } from 'vitest';
import { readdirSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import ts from 'typescript';
import type { z } from 'zod';

import { AppActionSchema, AppComponentSchema, NavigationAreaSchema } from '../zod/app.zod.js';
Expand DownExpand Up@@ -762,8 +763,17 @@ interface KnownDrift {
* are recorded here so whoever works them off does not re-derive them:
*
* - **SPEC-DERIVED (3 entries, 13 keys)** — `DashboardComponentSchema`,
* `DashboardWidgetSchema`, `ObjectViewSchema` are in `SPEC_DERIVED_PAIRS` below,
* so their mirror takes its shape BY REFERENCE from `@objectstack/spec`. An
* `DashboardWidgetSchema`, `ObjectViewSchema`. ⚠️ objectui#6705 invalidated the
* evidence for ONE of the three: `ObjectViewSchema` is no longer in
* `SPEC_DERIVED_PAIRS` below, because it never referenced a spec schema — it is
* `BaseSchema.extend({…})` of local literals, and the pre-#6705 text scanner
* charged it a neighbouring private const's `Spec…` token. The split is left
* STANDING as written, counts and all: re-routing those keys from #2231's
* unification question to a local mirror edit is a remedy decision on the
* `UnmirroredDeclared` ledger, which #6705 was fenced out of. Whoever works
* this split off must re-derive `ObjectViewSchema`'s side first.
* For the other two the reading is unchanged:
* their mirror takes its shape BY REFERENCE from `@objectstack/spec`. An
* unmirrored declared key there means the LOCAL declaration carries members the
* spec schema does not model, which is objectui#2231's unification question and
* NOT a local mirror edit. They are marked, not exempted: exempting them in the
Expand DownExpand Up@@ -1212,8 +1222,9 @@ export const assertionDriftMatchesLedger: never = 0 as unknown as LedgerMismatch
* ⚠️ And it is a COMPILE-TIME assertion. The `describe` block at the bottom of this
* file is a population census — it checks that the registry is closed and that
* `SPEC_DERIVED_PAIRS` re-derives, and it never compares keys at all. Its
* `Tests 5 passed (5)` line does not move when this half reddens, correctly, and it
* did not move under the ablation either. Reading the runtime half for evidence
* `Tests 12 passed (12)` line does not move when this half reddens, correctly, and
* it did not move under the ablation either. (It read `5 passed (5)` until
* objectui#6705 added the seven-fixture suite pinning the re-check's scanner.) Reading the runtime half for evidence
* about drift measures the wrong instrument and concludes the guard does nothing.
*
* When it fires, fix it by MEASURING (the compiler-API recipe and the
Expand DownExpand Up@@ -1434,14 +1445,26 @@ const EXCLUSIONS: Readonly<Record<string, string>> = {
const SPEC_DERIVED_PAIRS: readonly string[] = [
'app.zod.ts#AppComponentSchema',
'app.zod.ts#NavigationAreaSchema',
'base.zod.ts#BaseSchema',
// `base.zod.ts#BaseSchema` and `objectql.zod.ts#ObjectViewSchema` used to stand
// here and were removed by objectui#6705 — NOT because either mirror changed,
// but because the re-check below stopped mis-reading them. Neither references a
// spec schema; each was held here by one of the two defects that card names:
// - `BaseSchema` — its file's ONLY `Spec…` token is in a COMMENT
// (`base.zod.ts`, "rather than calling `SpecSchema.omit(…)`"). Prose.
// - `ObjectViewSchema` — `BaseSchema.extend({…})` with every member a local
// literal. Its declaration ends ~50 lines above the next `export const`, and
// the old text window ran on to that boundary, swallowing the private
// `KanbanConfig = SpecKanbanConfigSchema…` block that belongs to no export.
// ⚠️ The `ObjectViewSchema` removal has a consequence this card did NOT settle:
// the objectui#6058 split in this file's header routes its unmirrored declared
// keys as SPEC-DERIVED — a routing that rests on the false positive. That
// classification is deliberately left as it stands here; see the note there.
'complex.zod.ts#DashboardComponentSchema',
'complex.zod.ts#DashboardWidgetSchema',
'form.zod.ts#SelectOptionSchema',
'layout.zod.ts#PageNodeSchema',
'objectql.zod.ts#ObjectGanttSchema',
'objectql.zod.ts#ObjectMapSchema',
'objectql.zod.ts#ObjectViewSchema',
];

/* ── Runtime: the population is closed ──────────────────────────────────────── */
Expand All@@ -1459,6 +1482,122 @@ function exportedConsts(): string[] {
return out;
}

/* ── Which exports reference a spec symbol (AST, not raw text) ───────────────── */

/**
* The exported consts in ONE mirror source whose definition references a `Spec…`
* symbol — the input to `SPEC_DERIVED_PAIRS`' re-check below.
*
* ## Why this parses instead of scanning text (objectui#6705)
*
* This used to slice the source between `export const` boundaries and test the
* slice against a `\bSpec[A-Z]` word pattern. Raw text cannot tell a reference from a mention,
* and the boundary is not the declaration's end, so the rule had two independent
* defects that both fired on ONE docstring:
*
* - **A comment counted as a reference.** A docstring naming `SpecGanttConfigSchema`
* in PROSE made this test red with nothing about the emitted types, the runtime
* or the mirror's actual spec dependency having moved.
* - **The window charged text to the wrong declaration.** The docstring sat above
* the PRIVATE `GanttConfigExtensionFields` const, which lives textually between
* the `ObjectTreeSchema` and `ObjectGanttSchema` exports — so the slice starting
* at `ObjectTreeSchema` ran on past its own end and the failure named
* `ObjectTreeSchema`, a mirror that references no spec schema at all. The person
* who edits a docstring gets a red naming an innocent neighbour several
* declarations away.
*
* The workaround that shipped (PR #6704) was a docstring reworded to dodge the
* literal token plus a comment asking the next editor to remember — a convention
* held by discipline, which is what a gate is supposed to replace.
*
* ## What it does instead
*
* `Spec…` identifiers are collected from the AST, so a mention inside a comment or
* a string literal is not a reference — those are not `Identifier` nodes and never
* reach the test. There is no comment-stripping regex to get wrong on a string that
* contains `*` `/` sequences, because nothing strips anything.
*
* Attribution is by DECLARATION, not by text window: each top-level declaration
* owns exactly its own initializer. A private const between two exports is charged
* to neither — except through the one link that is a real dependency, which this
* follows: a mirror that spreads a private const built from a spec schema IS spec
* derived, so file-local references are resolved to a fixed point before the
* exported names are read off. `ObjectGanttSchema`'s dependency survives whether it
* extends `SpecGanttConfigSchema` inline or through a local field map.
*
* ⚠️ This is a precision fix, and the direction that must not be lost is the
* POSITIVE one: a scanner that stopped seeing comments AND stopped seeing real
* references would go green on the list below while checking nothing. The fixture
* suite asserts both directions, and the re-check itself is the live positive
* proof — all eight pairs in `SPEC_DERIVED_PAIRS` are found by code reference alone.
*/
export function specReferencingExports(fileName: string, source: string): Set<string> {
const sf = ts.createSourceFile(fileName, source, ts.ScriptTarget.ESNext, false, ts.ScriptKind.TS);

/** Per top-level declaration: does it name a `Spec…` symbol, and what else does it name? */
const decls = new Map<string, { spec: boolean; refs: Set<string> }>();
const exported = new Set<string>();

const collect = (node: ts.Node | undefined, into: { spec: boolean; refs: Set<string> }): void => {
if (!node) return;
const walk = (n: ts.Node): void => {
if (ts.isIdentifier(n)) {
if (/^Spec[A-Z]/.test(n.text)) into.spec = true;
else into.refs.add(n.text);
}
ts.forEachChild(n, walk);
};
walk(node);
};

const record = (name: string, isExported: boolean, ...bodies: (ts.Node | undefined)[]): void => {
const rec = decls.get(name) ?? { spec: false, refs: new Set<string>() };
for (const b of bodies) collect(b, rec);
decls.set(name, rec);
if (isExported) exported.add(name);
};

for (const stmt of sf.statements) {
const isExported = (ts.canHaveModifiers(stmt) ? ts.getModifiers(stmt) : undefined)
?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) === true;
if (ts.isVariableStatement(stmt)) {
for (const d of stmt.declarationList.declarations) {
if (!ts.isIdentifier(d.name)) continue;
record(d.name.text, isExported, d.initializer, d.type);
}
} else if (ts.isFunctionDeclaration(stmt) && stmt.name) {
record(stmt.name.text, isExported, stmt.body, stmt.type);
}
}

// File-local references resolved to a fixed point: a declaration built from one
// that is spec-derived is itself spec-derived. Iterative, so a reference cycle
// terminates instead of recursing.
const specDerived = new Set<string>();
for (const [name, rec] of decls) if (rec.spec) specDerived.add(name);
for (let changed = true; changed; ) {
changed = false;
for (const [name, rec] of decls) {
if (specDerived.has(name)) continue;
for (const r of rec.refs) {
// Only through PRIVATE declarations. An exported mirror is a registered
// pair with an entry of its own, so its spec sensitivity is already
// recorded under its own key; hopping through it would re-attribute one
// mirror's dependency to every mirror that merely names it. A private
// const has no key of its own, so its dependency must be charged to the
// export that uses it or it is lost.
if (specDerived.has(r) && !exported.has(r)) {
specDerived.add(name);
changed = true;
break;
}
}
}
}

return new Set([...exported].filter((n) => specDerived.has(n)));
}

describe('zod mirror parity — the population is closed', () => {
it('every exported const in ../zod/ is either a registered pair or an excluded one', () => {
const registered = new Set(Object.keys(MIRRORS));
Expand DownExpand Up@@ -1487,16 +1626,17 @@ exactly how objectui#4605 and #5186 stayed latent.`).toEqual([]);
it('SPEC_DERIVED_PAIRS matches what the mirror sources actually do', () => {
// Re-derived, not trusted: a mirror that starts referencing a `Spec…` schema
// joins the spec-sensitive set whether or not anyone updates the list.
// Read from the AST — see `specReferencingExports` for why prose does not count.
const derived: string[] = [];
const byFile = new Map<string, Set<string>>();
for (const key of Object.keys(MIRRORS)) {
const [file, name] = key.split('#');
const src = readFileSync(join(ZOD_DIR, file), 'utf8');
const at = src.search(new RegExp(`^export const ${name}\\b`, 'm'));
if (at < 0) continue;
const rest = src.slice(at);
const next = rest.slice(10).search(/\n(?=export (const|type|function))/);
const body = next < 0 ? rest : rest.slice(0, next + 10);
if (/\bSpec[A-Z]\w*/.test(body)) derived.push(key);
let refs = byFile.get(file);
if (!refs) {
refs = specReferencingExports(file, readFileSync(join(ZOD_DIR, file), 'utf8'));
byFile.set(file, refs);
}
if (refs.has(name)) derived.push(key);
}
expect(derived.sort(), 'a mirror gained or lost a spec dependency — update SPEC_DERIVED_PAIRS')
.toEqual([...SPEC_DERIVED_PAIRS].sort());
Expand All@@ -1507,3 +1647,87 @@ exactly how objectui#4605 and #5186 stayed latent.`).toEqual([]);
expect(empty.map(([k]) => k), 'an exclusion without a reason is an oversight').toEqual([]);
});
});

describe('the spec-reference scan reads code, not prose (objectui#6705)', () => {
const scan = (src: string): string[] => [...specReferencingExports('fixture.zod.ts', src)].sort();

it('a `Spec…` token mentioned only in a COMMENT is not a reference', () => {
// The exact shape that flipped this file red: prose, nothing else moved.
expect(
scan(`
/**
* Extends the spec's SpecGanttConfigSchema — mentioned in prose only.
*/
export const ObjectTreeSchema = z.object({ objectName: z.string() });
`),
).toEqual([]);
});

it('a `Spec…` token referenced in CODE still fires — the direction that must not be lost', () => {
// ⚠️ The whole risk of this fix: a scanner that stops seeing comments AND
// stops seeing real references goes green while checking nothing.
expect(
scan(`
export const ObjectGanttSchema = SpecGanttConfigSchema.extend({ lockField: z.string() });
`),
).toEqual(['ObjectGanttSchema']);
});

it('a `Spec…` token inside a STRING LITERAL is not a reference either', () => {
// Including one carrying comment-like sequences, which is the hazard a
// regex-based comment stripper would have had. Nothing is stripped here.
expect(
scan(`
export const TextSchema = z.string().describe('SpecFooSchema */ // not a reference');
`),
).toEqual([]);
});

it('a PRIVATE const between two exports charges its prose to NEITHER neighbour', () => {
// The live misattribution: the docstring sat above the private const, and the
// text window starting at the preceding export ran on past its own end.
expect(
scan(`
export const ObjectTreeSchema = z.object({ objectName: z.string() });

/** Everything beyond the spec's SpecGanttConfigSchema. */
const GanttConfigExtensionFields = { lockField: z.string() };

export const ObjectGanttSchema = z.object({ ...GanttConfigExtensionFields });
`),
).toEqual([]);
});

it('a PRIVATE const with a REAL spec reference is charged to its USER, not its neighbour', () => {
// Attribution by declaration plus file-local resolution: `ObjectGanttSchema`
// really is spec-derived through the field map; `ObjectTreeSchema` is not, and
// the text window used to blame exactly it.
expect(
scan(`
export const ObjectTreeSchema = z.object({ objectName: z.string() });

const GanttConfigExtensionFields = SpecGanttConfigSchema.shape;

export const ObjectGanttSchema = z.object({ ...GanttConfigExtensionFields });
`),
).toEqual(['ObjectGanttSchema']);
});

it("the NEXT export's docstring is not charged to the previous export", () => {
expect(
scan(`
export const ObjectTreeSchema = z.object({ objectName: z.string() });

/** Built from SpecGanttConfigSchema. */
export const ObjectGanttSchema = SpecGanttConfigSchema.extend({});
`),
).toEqual(['ObjectGanttSchema']);
});

it('the scanner can actually see a real mirror source (non-vacuity)', () => {
// A broken parser returns an empty set and every negative case above passes
// while checking nothing. Pin it against the file this bug was found in.
const src = readFileSync(join(ZOD_DIR, 'objectql.zod.ts'), 'utf8');
expect([...specReferencingExports('objectql.zod.ts', src)]).toContain('ObjectGanttSchema');
});
});
15 changes: 4 additions & 11 deletions packages/types/src/zod/objectql.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -606,23 +606,16 @@ export const ObjectTreeSchema = BaseSchema.extend({

/**
* objectui's own `GanttConfig` extensions — everything `../objectql.ts` declares
* on {@link GanttConfig} beyond the spec's `GanttConfigSchema` (objectui#6051
* on {@link GanttConfig} beyond the spec's `SpecGanttConfigSchema` (objectui#6051
* lifted nine of them out of `plugin-gantt`'s package-private `GanttConfigEx`;
* `timeSegments` was already there).
*
* Held as ONE field map rather than inlined, so the flattened top-level spelling
* below is built from a single source — the same way the TS side derives its
* flattened members from `GanttConfig`. It is also the shape the nested `gantt`
* block is built from (objectui#6475), one line extending the spec's gantt
* config schema with this map — so both authoring faces share one vocabulary
* and cannot fork from each other.
*
* ⚠️ Keep this docstring free of a literal `Spec` + capital-letter token: it
* sits between the `ObjectTreeSchema` and `ObjectGanttSchema` export
* boundaries, and `zod-mirror-parity.test.ts`'s `SPEC_DERIVED_PAIRS` re-check
* scans raw text between export boundaries for `\bSpec[A-Z]\w*` — a match here
* is misattributed to `ObjectTreeSchema`, which references no spec schema at
* all (measured: this comment alone flipped that test red).
* block is built from (objectui#6475): one line extending `SpecGanttConfigSchema`
* with this map — so both authoring faces share one vocabulary and cannot fork
* from each other.
*
* Not exported: the parity census in `__tests__/zod-mirror-parity.test.ts` reads
* `^export const` out of this directory and would require a registered TS
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
11 changes: 11 additions & 0 deletions .changeset/6705-parity-scan-reads-ast.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
---
---

Test-only change to `zod-mirror-parity.test.ts`'s `SPEC_DERIVED_PAIRS` re-check: it now
reads `Spec…` references off the TypeScript AST instead of scanning raw text between
`export const` boundaries, so a token mentioned in a comment is no longer counted as a
reference and no longer misattributed to the neighbouring export (objectui#6705). The one
change under `packages/types/src/zod/` is a docstring: the wording PR #6704 had to contort
around the old scanner is restored, along with the removal of the trap comment that asked
the next editor to remember. No published behaviour changes — no runtime, type or schema
surface moves.
250 changes: 237 additions & 13 deletions packages/types/src/__tests__/zod-mirror-parity.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -113,6 +113,7 @@ import { describe, it, expect } from 'vitest';
import { readdirSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import ts from 'typescript';
import type { z } from 'zod';

import { AppActionSchema, AppComponentSchema, NavigationAreaSchema } from '../zod/app.zod.js';
Expand DownExpand Up@@ -762,8 +763,17 @@ interface KnownDrift {
* are recorded here so whoever works them off does not re-derive them:
*
* - **SPEC-DERIVED (3 entries, 13 keys)** — `DashboardComponentSchema`,
* `DashboardWidgetSchema`, `ObjectViewSchema` are in `SPEC_DERIVED_PAIRS` below,
* so their mirror takes its shape BY REFERENCE from `@objectstack/spec`. An
* `DashboardWidgetSchema`, `ObjectViewSchema`. ⚠️ objectui#6705 invalidated the
* evidence for ONE of the three: `ObjectViewSchema` is no longer in
* `SPEC_DERIVED_PAIRS` below, because it never referenced a spec schema — it is
* `BaseSchema.extend({…})` of local literals, and the pre-#6705 text scanner
* charged it a neighbouring private const's `Spec…` token. The split is left
* STANDING as written, counts and all: re-routing those keys from #2231's
* unification question to a local mirror edit is a remedy decision on the
* `UnmirroredDeclared` ledger, which #6705 was fenced out of. Whoever works
* this split off must re-derive `ObjectViewSchema`'s side first.
* For the other two the reading is unchanged:
* their mirror takes its shape BY REFERENCE from `@objectstack/spec`. An
* unmirrored declared key there means the LOCAL declaration carries members the
* spec schema does not model, which is objectui#2231's unification question and
* NOT a local mirror edit. They are marked, not exempted: exempting them in the
Expand DownExpand Up@@ -1212,8 +1222,9 @@ export const assertionDriftMatchesLedger: never = 0 as unknown as LedgerMismatch
* ⚠️ And it is a COMPILE-TIME assertion. The `describe` block at the bottom of this
* file is a population census — it checks that the registry is closed and that
* `SPEC_DERIVED_PAIRS` re-derives, and it never compares keys at all. Its
* `Tests 5 passed (5)` line does not move when this half reddens, correctly, and it
* did not move under the ablation either. Reading the runtime half for evidence
* `Tests 12 passed (12)` line does not move when this half reddens, correctly, and
* it did not move under the ablation either. (It read `5 passed (5)` until
* objectui#6705 added the seven-fixture suite pinning the re-check's scanner.) Reading the runtime half for evidence
* about drift measures the wrong instrument and concludes the guard does nothing.
*
* When it fires, fix it by MEASURING (the compiler-API recipe and the
Expand DownExpand Up@@ -1434,14 +1445,26 @@ const EXCLUSIONS: Readonly<Record<string, string>> = {
const SPEC_DERIVED_PAIRS: readonly string[] = [
'app.zod.ts#AppComponentSchema',
'app.zod.ts#NavigationAreaSchema',
'base.zod.ts#BaseSchema',
// `base.zod.ts#BaseSchema` and `objectql.zod.ts#ObjectViewSchema` used to stand
// here and were removed by objectui#6705 — NOT because either mirror changed,
// but because the re-check below stopped mis-reading them. Neither references a
// spec schema; each was held here by one of the two defects that card names:
// - `BaseSchema` — its file's ONLY `Spec…` token is in a COMMENT
// (`base.zod.ts`, "rather than calling `SpecSchema.omit(…)`"). Prose.
// - `ObjectViewSchema` — `BaseSchema.extend({…})` with every member a local
// literal. Its declaration ends ~50 lines above the next `export const`, and
// the old text window ran on to that boundary, swallowing the private
// `KanbanConfig = SpecKanbanConfigSchema…` block that belongs to no export.
// ⚠️ The `ObjectViewSchema` removal has a consequence this card did NOT settle:
// the objectui#6058 split in this file's header routes its unmirrored declared
// keys as SPEC-DERIVED — a routing that rests on the false positive. That
// classification is deliberately left as it stands here; see the note there.
'complex.zod.ts#DashboardComponentSchema',
'complex.zod.ts#DashboardWidgetSchema',
'form.zod.ts#SelectOptionSchema',
'layout.zod.ts#PageNodeSchema',
'objectql.zod.ts#ObjectGanttSchema',
'objectql.zod.ts#ObjectMapSchema',
'objectql.zod.ts#ObjectViewSchema',
];

/* ── Runtime: the population is closed ──────────────────────────────────────── */
Expand All@@ -1459,6 +1482,122 @@ function exportedConsts(): string[] {
return out;
}

/* ── Which exports reference a spec symbol (AST, not raw text) ───────────────── */

/**
* The exported consts in ONE mirror source whose definition references a `Spec…`
* symbol — the input to `SPEC_DERIVED_PAIRS`' re-check below.
*
* ## Why this parses instead of scanning text (objectui#6705)
*
* This used to slice the source between `export const` boundaries and test the
* slice against a `\bSpec[A-Z]` word pattern. Raw text cannot tell a reference from a mention,
* and the boundary is not the declaration's end, so the rule had two independent
* defects that both fired on ONE docstring:
*
* - **A comment counted as a reference.** A docstring naming `SpecGanttConfigSchema`
* in PROSE made this test red with nothing about the emitted types, the runtime
* or the mirror's actual spec dependency having moved.
* - **The window charged text to the wrong declaration.** The docstring sat above
* the PRIVATE `GanttConfigExtensionFields` const, which lives textually between
* the `ObjectTreeSchema` and `ObjectGanttSchema` exports — so the slice starting
* at `ObjectTreeSchema` ran on past its own end and the failure named
* `ObjectTreeSchema`, a mirror that references no spec schema at all. The person
* who edits a docstring gets a red naming an innocent neighbour several
* declarations away.
*
* The workaround that shipped (PR #6704) was a docstring reworded to dodge the
* literal token plus a comment asking the next editor to remember — a convention
* held by discipline, which is what a gate is supposed to replace.
*
* ## What it does instead
*
* `Spec…` identifiers are collected from the AST, so a mention inside a comment or
* a string literal is not a reference — those are not `Identifier` nodes and never
* reach the test. There is no comment-stripping regex to get wrong on a string that
* contains `*` `/` sequences, because nothing strips anything.
*
* Attribution is by DECLARATION, not by text window: each top-level declaration
* owns exactly its own initializer. A private const between two exports is charged
* to neither — except through the one link that is a real dependency, which this
* follows: a mirror that spreads a private const built from a spec schema IS spec
* derived, so file-local references are resolved to a fixed point before the
* exported names are read off. `ObjectGanttSchema`'s dependency survives whether it
* extends `SpecGanttConfigSchema` inline or through a local field map.
*
* ⚠️ This is a precision fix, and the direction that must not be lost is the
* POSITIVE one: a scanner that stopped seeing comments AND stopped seeing real
* references would go green on the list below while checking nothing. The fixture
* suite asserts both directions, and the re-check itself is the live positive
* proof — all eight pairs in `SPEC_DERIVED_PAIRS` are found by code reference alone.
*/
export function specReferencingExports(fileName: string, source: string): Set<string> {
const sf = ts.createSourceFile(fileName, source, ts.ScriptTarget.ESNext, false, ts.ScriptKind.TS);

/** Per top-level declaration: does it name a `Spec…` symbol, and what else does it name? */
const decls = new Map<string, { spec: boolean; refs: Set<string> }>();
const exported = new Set<string>();

const collect = (node: ts.Node | undefined, into: { spec: boolean; refs: Set<string> }): void => {
if (!node) return;
const walk = (n: ts.Node): void => {
if (ts.isIdentifier(n)) {
if (/^Spec[A-Z]/.test(n.text)) into.spec = true;
else into.refs.add(n.text);
}
ts.forEachChild(n, walk);
};
walk(node);
};

const record = (name: string, isExported: boolean, ...bodies: (ts.Node | undefined)[]): void => {
const rec = decls.get(name) ?? { spec: false, refs: new Set<string>() };
for (const b of bodies) collect(b, rec);
decls.set(name, rec);
if (isExported) exported.add(name);
};

for (const stmt of sf.statements) {
const isExported = (ts.canHaveModifiers(stmt) ? ts.getModifiers(stmt) : undefined)
?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) === true;
if (ts.isVariableStatement(stmt)) {
for (const d of stmt.declarationList.declarations) {
if (!ts.isIdentifier(d.name)) continue;
record(d.name.text, isExported, d.initializer, d.type);
}
} else if (ts.isFunctionDeclaration(stmt) && stmt.name) {
record(stmt.name.text, isExported, stmt.body, stmt.type);
}
}

// File-local references resolved to a fixed point: a declaration built from one
// that is spec-derived is itself spec-derived. Iterative, so a reference cycle
// terminates instead of recursing.
const specDerived = new Set<string>();
for (const [name, rec] of decls) if (rec.spec) specDerived.add(name);
for (let changed = true; changed; ) {
changed = false;
for (const [name, rec] of decls) {
if (specDerived.has(name)) continue;
for (const r of rec.refs) {
// Only through PRIVATE declarations. An exported mirror is a registered
// pair with an entry of its own, so its spec sensitivity is already
// recorded under its own key; hopping through it would re-attribute one
// mirror's dependency to every mirror that merely names it. A private
// const has no key of its own, so its dependency must be charged to the
// export that uses it or it is lost.
if (specDerived.has(r) && !exported.has(r)) {
specDerived.add(name);
changed = true;
break;
}
}
}
}

return new Set([...exported].filter((n) => specDerived.has(n)));
}

describe('zod mirror parity — the population is closed', () => {
it('every exported const in ../zod/ is either a registered pair or an excluded one', () => {
const registered = new Set(Object.keys(MIRRORS));
Expand DownExpand Up@@ -1487,16 +1626,17 @@ exactly how objectui#4605 and #5186 stayed latent.`).toEqual([]);
it('SPEC_DERIVED_PAIRS matches what the mirror sources actually do', () => {
// Re-derived, not trusted: a mirror that starts referencing a `Spec…` schema
// joins the spec-sensitive set whether or not anyone updates the list.
// Read from the AST — see `specReferencingExports` for why prose does not count.
const derived: string[] = [];
const byFile = new Map<string, Set<string>>();
for (const key of Object.keys(MIRRORS)) {
const [file, name] = key.split('#');
const src = readFileSync(join(ZOD_DIR, file), 'utf8');
const at = src.search(new RegExp(`^export const ${name}\\b`, 'm'));
if (at < 0) continue;
const rest = src.slice(at);
const next = rest.slice(10).search(/\n(?=export (const|type|function))/);
const body = next < 0 ? rest : rest.slice(0, next + 10);
if (/\bSpec[A-Z]\w*/.test(body)) derived.push(key);
let refs = byFile.get(file);
if (!refs) {
refs = specReferencingExports(file, readFileSync(join(ZOD_DIR, file), 'utf8'));
byFile.set(file, refs);
}
if (refs.has(name)) derived.push(key);
}
expect(derived.sort(), 'a mirror gained or lost a spec dependency — update SPEC_DERIVED_PAIRS')
.toEqual([...SPEC_DERIVED_PAIRS].sort());
Expand All@@ -1507,3 +1647,87 @@ exactly how objectui#4605 and #5186 stayed latent.`).toEqual([]);
expect(empty.map(([k]) => k), 'an exclusion without a reason is an oversight').toEqual([]);
});
});

describe('the spec-reference scan reads code, not prose (objectui#6705)', () => {
const scan = (src: string): string[] => [...specReferencingExports('fixture.zod.ts', src)].sort();

it('a `Spec…` token mentioned only in a COMMENT is not a reference', () => {
// The exact shape that flipped this file red: prose, nothing else moved.
expect(
scan(`
/**
* Extends the spec's SpecGanttConfigSchema — mentioned in prose only.
*/
export const ObjectTreeSchema = z.object({ objectName: z.string() });
`),
).toEqual([]);
});

it('a `Spec…` token referenced in CODE still fires — the direction that must not be lost', () => {
// ⚠️ The whole risk of this fix: a scanner that stops seeing comments AND
// stops seeing real references goes green while checking nothing.
expect(
scan(`
export const ObjectGanttSchema = SpecGanttConfigSchema.extend({ lockField: z.string() });
`),
).toEqual(['ObjectGanttSchema']);
});

it('a `Spec…` token inside a STRING LITERAL is not a reference either', () => {
// Including one carrying comment-like sequences, which is the hazard a
// regex-based comment stripper would have had. Nothing is stripped here.
expect(
scan(`
export const TextSchema = z.string().describe('SpecFooSchema */ // not a reference');
`),
).toEqual([]);
});

it('a PRIVATE const between two exports charges its prose to NEITHER neighbour', () => {
// The live misattribution: the docstring sat above the private const, and the
// text window starting at the preceding export ran on past its own end.
expect(
scan(`
export const ObjectTreeSchema = z.object({ objectName: z.string() });

/** Everything beyond the spec's SpecGanttConfigSchema. */
const GanttConfigExtensionFields = { lockField: z.string() };

export const ObjectGanttSchema = z.object({ ...GanttConfigExtensionFields });
`),
).toEqual([]);
});

it('a PRIVATE const with a REAL spec reference is charged to its USER, not its neighbour', () => {
// Attribution by declaration plus file-local resolution: `ObjectGanttSchema`
// really is spec-derived through the field map; `ObjectTreeSchema` is not, and
// the text window used to blame exactly it.
expect(
scan(`
export const ObjectTreeSchema = z.object({ objectName: z.string() });

const GanttConfigExtensionFields = SpecGanttConfigSchema.shape;

export const ObjectGanttSchema = z.object({ ...GanttConfigExtensionFields });
`),
).toEqual(['ObjectGanttSchema']);
});

it("the NEXT export's docstring is not charged to the previous export", () => {
expect(
scan(`
export const ObjectTreeSchema = z.object({ objectName: z.string() });

/** Built from SpecGanttConfigSchema. */
export const ObjectGanttSchema = SpecGanttConfigSchema.extend({});
`),
).toEqual(['ObjectGanttSchema']);
});

it('the scanner can actually see a real mirror source (non-vacuity)', () => {
// A broken parser returns an empty set and every negative case above passes
// while checking nothing. Pin it against the file this bug was found in.
const src = readFileSync(join(ZOD_DIR, 'objectql.zod.ts'), 'utf8');
expect([...specReferencingExports('objectql.zod.ts', src)]).toContain('ObjectGanttSchema');
});
});
15 changes: 4 additions & 11 deletions packages/types/src/zod/objectql.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -606,23 +606,16 @@ export const ObjectTreeSchema = BaseSchema.extend({

/**
* objectui's own `GanttConfig` extensions — everything `../objectql.ts` declares
* on {@link GanttConfig} beyond the spec's `GanttConfigSchema` (objectui#6051
* on {@link GanttConfig} beyond the spec's `SpecGanttConfigSchema` (objectui#6051
* lifted nine of them out of `plugin-gantt`'s package-private `GanttConfigEx`;
* `timeSegments` was already there).
*
* Held as ONE field map rather than inlined, so the flattened top-level spelling
* below is built from a single source — the same way the TS side derives its
* flattened members from `GanttConfig`. It is also the shape the nested `gantt`
* block is built from (objectui#6475), one line extending the spec's gantt
* config schema with this map — so both authoring faces share one vocabulary
* and cannot fork from each other.
*
* ⚠️ Keep this docstring free of a literal `Spec` + capital-letter token: it
* sits between the `ObjectTreeSchema` and `ObjectGanttSchema` export
* boundaries, and `zod-mirror-parity.test.ts`'s `SPEC_DERIVED_PAIRS` re-check
* scans raw text between export boundaries for `\bSpec[A-Z]\w*` — a match here
* is misattributed to `ObjectTreeSchema`, which references no spec schema at
* all (measured: this comment alone flipped that test red).
* block is built from (objectui#6475): one line extending `SpecGanttConfigSchema`
* with this map — so both authoring faces share one vocabulary and cannot fork
* from each other.
*
* Not exported: the parity census in `__tests__/zod-mirror-parity.test.ts` reads
* `^export const` out of this directory and would require a registered TS
Expand Down
Loading