Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
6731160
feat(ast): add vanishCharStyleIds to ObjectMetaSchema
thewrz Aug 4, 2026
1832b73
fix(parser): honor w:vanish w:val=0 toggle on style's own rPr
thewrz Aug 4, 2026
d799fda
feat(parser): resolve rStyle-referenced character-style vanish in has…
thewrz Aug 4, 2026
15dfa27
feat(parser): thread vanishCharStyleIds through capture into persiste…
thewrz Aug 4, 2026
d8019de
fix(parser): satisfy lint gates for #650 vanish threading
thewrz Aug 4, 2026
7899947
feat(parser): thread vanishCharStyleIds through object-blob rewrite p…
thewrz Aug 4, 2026
af3b1fe
fix(cross): preserve rStyle-hidden object text across DOCX regenerati…
thewrz Aug 4, 2026
ea1d8e0
feat(ast): reject textBox objects with rows/columns set
thewrz Aug 4, 2026
86ec991
feat(ast): couple SpecNodeSchema type to meta.object presence
thewrz Aug 4, 2026
f511efa
test(ast): audit .shape spread consumers of ObjectMetaSchema/SpecNode…
thewrz Aug 4, 2026
f5317a9
fix(generator): namespace vanish character-style ids per source tree …
thewrz Aug 4, 2026
ce1dfdc
test(parser): fix tautological hasRunVanish purity test (#650)
thewrz Aug 4, 2026
01768bd
docs(adr): record ADR-094 (#650 vanish char-style persistence), close…
thewrz Aug 4, 2026
b7a244b
revert(docs): drop ADR-094 and the ADR-092 amendment (#650)
thewrz Aug 4, 2026
2f5613d
test(db): prove vanishCharStyleIds reaches a real object_data column …
thewrz Aug 5, 2026
4b348cc
fix(parser,generator): close four adversarial-review findings (#650)
thewrz Aug 5, 2026
6e336b3
fix(parser,ast): close four CodeRabbit review findings (#650)
thewrz Aug 5, 2026
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
48 changes: 48 additions & 0 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7992,6 +7992,27 @@ components:
SpecNode:
type: object
required: [id, type, text, children, meta]
allOf:
# Cross-field invariant enforced by SpecNodeSchema's `.check()`
# (src/ast/spec-tree-schemas.ts, #650 Part B): `meta.object` is
# present on EXACTLY the `object` node type — an 'object' node is
# meaningless without its captured blob, and no other node type may
# carry one. A biconditional, hence the else-branch.
- if:
required: [type]
properties:
type:
const: object
then:
required: [meta]
properties:
meta:
required: [object]
else:
properties:
meta:
not:
required: [object]
properties:
id:
type: string
Expand Down Expand Up @@ -8113,6 +8134,22 @@ components:
round-trips its OOXML byte-for-byte via `blob` rather than modeling
its interior as CSI structure.
required: [kind, floating, generation, blob]
allOf:
# Cross-field invariant enforced by ObjectMetaSchema's `.check()`
# (src/ast/object-schemas.ts, #516): rows/columns are table-grid
# dimensions, and a textBox has no grid to describe. Stated
# structurally so a consumer generating types/validators from this
# contract rejects the same shapes the server does.
- if:
required: [kind]
properties:
kind:
const: textBox
then:
not:
anyOf:
- required: [rows]
- required: [columns]
properties:
kind:
type: string
Expand All @@ -8132,6 +8169,17 @@ components:
type: integer
minimum: 1
description: Table column count (table kind only).
vanishCharStyleIds:
type: array
description: >-
Resolved character-style IDs (#650) carrying an enabled
`w:vanish` — the styles a run's `w:rStyle` must resolve through
to be treated as hidden. Captured at import so hidden text stays
excluded from `objectText` on later edits, without needing
`styles.xml` at edit time. Absent and `[]` are interchangeable;
an object captured before this field existed has neither.
items:
type: string
Comment thread
coderabbitai[bot] marked this conversation as resolved.
blob:
type: array
description: >-
Expand Down
213 changes: 213 additions & 0 deletions src/ast/object-schemas.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { describe, it, expect } from 'vitest';
import { readFileSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import {
ObjectKindSchema,
ObjectGenerationSchema,
Expand Down Expand Up @@ -111,6 +114,114 @@ describe('ObjectMetaSchema', () => {
});
});

// ── kind/rows/columns cross-field check (#650 Part B) ──────────────────────
// rows/columns are table-grid dimensions; a textBox has no grid. The check
// names exactly kind, rows, columns — it must never touch vanishCharStyleIds
// or any other field, additive or otherwise.
describe('ObjectMetaSchema — textBox/rows/columns cross-field check (#650)', () => {
const baseTextBox = {
kind: 'textBox' as const,
floating: true,
generation: 'vml' as const,
blob: [{ '#text': 'boxed text' }],
};
const baseTable = {
kind: 'table' as const,
floating: false,
generation: 'drawingml' as const,
rows: 1,
columns: 1,
blob: TABLE_BLOB,
};

it('rejects a textBox with rows set', () => {
expect(ObjectMetaSchema.safeParse({ ...baseTextBox, rows: 1 }).success).toBe(false);
});

it('rejects a textBox with columns set', () => {
expect(ObjectMetaSchema.safeParse({ ...baseTextBox, columns: 1 }).success).toBe(false);
});

it('accepts a textBox with neither rows nor columns', () => {
expect(ObjectMetaSchema.safeParse(baseTextBox).success).toBe(true);
});

it('leaves table with rows/columns unaffected', () => {
expect(ObjectMetaSchema.safeParse(baseTable).success).toBe(true);
});

it('accepts a textBox with vanishCharStyleIds populated and no rows/columns — the check never touches vanishCharStyleIds', () => {
const result = ObjectMetaSchema.safeParse({
...baseTextBox,
vanishCharStyleIds: ['HiddenChar'],
});
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.data.vanishCharStyleIds).toEqual(['HiddenChar']);
expect(result.data.rows).toBeUndefined();
expect(result.data.columns).toBeUndefined();
});
});

// ── vanishCharStyleIds (#650) ───────────────────────────────────────────────
// Resolved w:rStyle → character-style w:vanish IDs, captured alongside the
// object so capture and rewrite share one source of truth without needing
// styles.xml at rewrite time. Additive JSONB field: absent and [] are
// interchangeable, and a row captured before this change (no key at all)
// must still load/parse identically to today.
describe('ObjectMetaSchema — vanishCharStyleIds (#650)', () => {
const validTable = {
kind: 'table' as const,
floating: false,
generation: 'drawingml' as const,
rows: 1,
columns: 1,
blob: TABLE_BLOB,
};

it('a row/object captured before this change (no vanishCharStyleIds key) loads identically', () => {
const result = ObjectMetaSchema.safeParse(validTable);
expect(result.success).toBe(true);
if (!result.success) return;
expect('vanishCharStyleIds' in result.data).toBe(false);
});

it('accepts a table object with a populated vanishCharStyleIds array', () => {
const withVanish = { ...validTable, vanishCharStyleIds: ['HiddenChar', 'Redacted'] };
const result = ObjectMetaSchema.safeParse(withVanish);
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.data.vanishCharStyleIds).toEqual(['HiddenChar', 'Redacted']);
});

it('accepts an empty vanishCharStyleIds array, interchangeable with absent', () => {
expect(ObjectMetaSchema.safeParse({ ...validTable, vanishCharStyleIds: [] }).success).toBe(
true
);
});

it('rejects a non-string entry in vanishCharStyleIds', () => {
const bad = { ...validTable, vanishCharStyleIds: ['HiddenChar', 42] };
expect(ObjectMetaSchema.safeParse(bad).success).toBe(false);
});

it('a textBox object (no rows/columns) still validates with vanishCharStyleIds populated — the field never couples to kind', () => {
const textBox = {
kind: 'textBox' as const,
floating: true,
generation: 'vml' as const,
blob: [{ '#text': 'boxed text' }],
vanishCharStyleIds: ['HiddenChar'],
};
const result = ObjectMetaSchema.safeParse(textBox);
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.data.vanishCharStyleIds).toEqual(['HiddenChar']);
expect(result.data.rows).toBeUndefined();
expect(result.data.columns).toBeUndefined();
});
});

// ── Editability fixation (#300, ADR-072 decision 2) ────────────────────────
// An 'object' node is always locked (a captured OOXML blob is never
// paragraph-editable text) and its 'objectText' children are always editable
Expand Down Expand Up @@ -185,3 +296,105 @@ describe('SpecNodeSchema — editability fixation for object/objectText (#300)',
expect(SpecNodeSchema.safeParse(malformed).success).toBe(false);
});
});

// ── type<->meta.object presence coupling (#650 Part B) ─────────────────────
// An 'object' node is meaningless without its captured blob, and a non-object
// node must never carry one (a leftover/misattached meta.object would silently
// smuggle a captured OOXML blob onto a node no renderer expects it on). Scoped
// strictly to presence — this must never re-derive or assert editability
// (classify.ts already owns producing that pairing).
describe('SpecNodeSchema — type<->meta.object presence coupling (#650)', () => {
const validObject = {
kind: 'table' as const,
floating: false,
generation: 'drawingml' as const,
rows: 1,
columns: 1,
blob: TABLE_BLOB,
};

it('rejects type=object with no meta.object', () => {
const node = {
id: VALID_UUID,
type: 'object',
text: 'Table (1x1)',
children: [],
meta: {},
};
expect(SpecNodeSchema.safeParse(node).success).toBe(false);
});

it('rejects a non-object type carrying a meta.object', () => {
const node = {
id: VALID_UUID,
type: 'article',
text: 'REFERENCES',
children: [],
meta: { object: validObject },
};
expect(SpecNodeSchema.safeParse(node).success).toBe(false);
});

it('accepts type=object with meta.object present (control)', () => {
const node = {
id: VALID_UUID,
type: 'object',
text: 'Table (1x1)',
children: [],
meta: { object: validObject },
};
expect(SpecNodeSchema.safeParse(node).success).toBe(true);
});
});

// ── .shape spread audit (#650 Task 9/10) ────────────────────────────────────
// contract-schema-sharing-map.ts's Item 5 gate exists because a `{
// ...Schema.shape }` rebuild silently drops a schema's own object-level
// `.check()`/`.strict()` rule when an MCP tool's inputSchema is built by
// spreading a REST body schema (isFullSchemaInstance, tool-schema-
// introspect.ts). ObjectMetaSchema gained the kind/rows/columns cross-field
// check and SpecNodeSchema gained the type<->meta.object presence check in
// this issue, so both need the same audit that map runs for every REST body
// schema with an object-level rule: does anything spread `.shape` off them
// and lose the rule?
//
// The answer here is non-applicability, not enforcement: neither schema is
// ever an MCP tool's inputSchema (or nested inside one) — src/mcp/tools.ts
// and every src/mcp/*-handlers.ts file were grepped for both schema names and
// found clean. Both schemas are consumed exclusively via `.parse()`/
// `.safeParse()` on the full schema instance — db/queries/object-meta.ts
// (parseObjectMeta/updateObjectData), revision-snapshot.ts, revision-diff.ts,
// history-diff.ts — which is why their cross-field checks already run intact
// wherever these schemas are actually used today (see the DB-boundary
// rejection tests in object-meta.test.ts). This test makes that a durable,
// CI-enforced fact instead of a point-in-time grep result recorded only in a
// PR body: a future PR that spreads either schema's `.shape` into a hand-
// built object (MCP tool or otherwise) fails it immediately.
describe('ObjectMetaSchema / SpecNodeSchema — .shape spread audit (#650)', () => {
const srcRoot = fileURLToPath(new URL('..', import.meta.url));
const selfFile = fileURLToPath(import.meta.url);

function tsFilesUnder(root: string): string[] {
return readdirSync(root, { recursive: true })
.filter((entry): entry is string => typeof entry === 'string' && entry.endsWith('.ts'))
.map((entry) => path.join(root, entry));
}

it('no file under src/ spreads ObjectMetaSchema.shape or SpecNodeSchema.shape', () => {
const offenders = tsFilesUnder(srcRoot)
.filter((file) => file !== selfFile)
.flatMap((file) => {
const text = readFileSync(file, 'utf8');
const hits: string[] = [];
if (/ObjectMetaSchema\.shape/.test(text)) hits.push(`${file}: ObjectMetaSchema.shape`);
if (/SpecNodeSchema\.shape/.test(text)) hits.push(`${file}: SpecNodeSchema.shape`);
return hits;
});
expect(offenders).toEqual([]);
});

it('the MCP tool surface (src/mcp/tools.ts) never references either schema — no tool input can lose their checks', () => {
const toolsSource = readFileSync(path.join(srcRoot, 'mcp', 'tools.ts'), 'utf8');
expect(toolsSource).not.toMatch(/ObjectMetaSchema|SpecNodeSchema/);
});
});
45 changes: 37 additions & 8 deletions src/ast/object-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,15 +72,44 @@ export const ObjectBlobNodeSchema: z.ZodType<ObjectBlobNode> = z.custom<ObjectBl
* decision 2). `rows`/`columns` are table-only (grid dimensions); `blob` is
* the object's own top-level OOXML node(s) in document order, always
* non-empty — an object with no captured content is never modeled at all.
*
* `vanishCharStyleIds` (#650) is the resolved set of character-style IDs
* that carry an enabled `w:vanish` — i.e. the `StyleMap.vanishCharStyleIds`
* a run's `w:rStyle` must resolve through to be treated as hidden. It is
* captured alongside the object so capture (`collectText`) and the edit
* rewrite path (`rewriteFirstText`) share one persisted source of truth
* without needing `styles.xml` at rewrite time, where it is unavailable.
* Additive JSONB field, no migration: an absent key and an empty array are
* fully interchangeable (today's behaviour — no runs treated as hidden by
* style), so a row captured before this change loads/edits unchanged.
*/
export const ObjectMetaSchema = z.object({
kind: ObjectKindSchema,
floating: z.boolean(),
generation: ObjectGenerationSchema,
rows: z.number().int().positive().exactOptional(),
columns: z.number().int().positive().exactOptional(),
blob: z.array(ObjectBlobNodeSchema).check(z.minLength(1)),
});
export const ObjectMetaSchema = z
.object({
kind: ObjectKindSchema,
floating: z.boolean(),
generation: ObjectGenerationSchema,
rows: z.number().int().positive().exactOptional(),
columns: z.number().int().positive().exactOptional(),
vanishCharStyleIds: z.array(z.string()).exactOptional(),
blob: z.array(ObjectBlobNodeSchema).check(z.minLength(1)),
})
.check((ctx) => {
// rows/columns are table-grid dimensions; a textBox has no grid to
// describe. Named fields only (kind, rows, columns) — this must never
// scan for or react to other fields (vanishCharStyleIds included), so
// future additive fields stay structurally untouched by this rule.
const { kind, rows, columns } = ctx.value;
if (kind !== 'textBox') return;
const offendingKeys: string[] = [];
if (rows !== undefined) offendingKeys.push('rows');
if (columns !== undefined) offendingKeys.push('columns');
if (offendingKeys.length === 0) return;
ctx.issues.push({
code: 'custom',
input: ctx.value,
message: `textBox objects have no grid — ${offendingKeys.join(', ')} must not be set`,
});
});

export type ObjectKind = z.infer<typeof ObjectKindSchema>;
export type ObjectGeneration = z.infer<typeof ObjectGenerationSchema>;
Expand Down
36 changes: 29 additions & 7 deletions src/ast/spec-tree-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,13 +273,35 @@ export const SpecNodeMetaSchema = z.object({
});

export const SpecNodeSchema: z.ZodType<SpecNode> = z.lazy(() =>
z.object({
id: z.uuid(),
type: NodeTypeSchema,
text: z.string().check(z.minLength(1)),
children: z.array(SpecNodeSchema),
meta: SpecNodeMetaSchema,
})
z
.object({
id: z.uuid(),
type: NodeTypeSchema,
text: z.string().check(z.minLength(1)),
children: z.array(SpecNodeSchema),
meta: SpecNodeMetaSchema,
})
.check((ctx) => {
// type<->meta.object presence coupling (#650 Part B): an 'object' node
// is meaningless without its captured blob, and no other node type may
// carry one. Presence only — never re-derives editability, which
// classify.ts already owns producing alongside this pairing.
const { type, meta } = ctx.value;
const hasObject = meta.object !== undefined;
if (type === 'object' && !hasObject) {
ctx.issues.push({
code: 'custom',
input: ctx.value,
message: "an 'object' node requires meta.object",
});
} else if (type !== 'object' && hasObject) {
ctx.issues.push({
code: 'custom',
input: ctx.value,
message: `meta.object must only be set on an 'object' node, not '${type}'`,
});
}
})
);

export const SecRefSchema = z.discriminatedUnion('targetType', [
Expand Down
Loading
Loading