|
48 | 48 | // last describe block below drives exactly that. |
49 | 49 |
|
50 | 50 | import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; |
| 51 | +import { z } from 'zod'; |
51 | 52 | import { spawnSync } from 'node:child_process'; |
52 | 53 | import fs from 'node:fs'; |
53 | 54 | import os from 'node:os'; |
@@ -4529,3 +4530,283 @@ describe('build-schemas.ts — a nested retirement row is judged, not ignored (# |
4529 | 4530 | }, |
4530 | 4531 | ); |
4531 | 4532 | }); |
| 4533 | + |
| 4534 | +// ───────────────────────────────────────────────────────────────────────────── |
| 4535 | +// #18865 — proof 4's message leg, defended by a fixture instead of by a zero. |
| 4536 | +// |
| 4537 | +// `delivers()` in build-schemas.ts is a CONJUNCTION, and check (c) counts a |
| 4538 | +// refusal it cannot read as "no evidence" rather than as proof: |
| 4539 | +// |
| 4540 | +// issue !== undefined && issue.message.includes(prescription) |
| 4541 | +// |
| 4542 | +// The left half — "the door refused this write" — is what the `ui/ViewItem` |
| 4543 | +// fixture above defends: a discriminated union the probe's one-key document |
| 4544 | +// cannot drive to any arm's door raises no `unrecognized_keys` issue at all, so |
| 4545 | +// `issue` is `undefined`. That fixture ASSERTS the probe raises no such issue, |
| 4546 | +// which means `&&` short-circuits in it and the right half is never evaluated — |
| 4547 | +// delete the `message.includes` conjunct and its verdict does not move, nor does |
| 4548 | +// any other case in this file. Measured before this block was written: the leg |
| 4549 | +// was defended by a zero. |
| 4550 | +// |
| 4551 | +// The right half is the only thing that separates a def which parses through the |
| 4552 | +// declaration's own error map from a strict CLONE built to the same declaration |
| 4553 | +// that does not — `Strict.strip()`, `z.object(Strict.shape)`, an `.extend()` |
| 4554 | +// that re-posts the shape without the map. Shape identity cannot tell them apart |
| 4555 | +// (that is the narrowing `computeGuidanceRoutes`'s docblock records), and the |
| 4556 | +// clone refuses the key with zod's own bare "Unrecognized key", carrying none of |
| 4557 | +// the prescription its declaration owes. |
| 4558 | +// |
| 4559 | +// ⚠️ The failure direction is "reads as compliant": with that conjunct gone the |
| 4560 | +// silent clone reads as `prescribed` and its deletion is WAIVED — the one |
| 4561 | +// direction this gate must not err in. So the pin below is the LIT case, and the |
| 4562 | +// tree's real door is the DARK one beside it; a gate that lost the leg turns the |
| 4563 | +// first red and leaves the second green. |
| 4564 | +// |
| 4565 | +// ── Why a FIFTH box ────────────────────────────────────────────────────────── |
| 4566 | +// The subject is a def built WITHOUT its declaration's error map, and the tree |
| 4567 | +// holds no such def any more (#18578 closed the last silent twin, which is what |
| 4568 | +// turned the `ui/ViewItem` pair into this file's DARK leg). So the fixture makes |
| 4569 | +// one, by substituting `strictObject` in this box's OWN copy of `src/` — exactly |
| 4570 | +// as the #4659 and #17969 boxes substitute `RETIRED_KEYS_BY_MAJOR`, and for the |
| 4571 | +// same reason: the shared sandbox SYMLINKS `src/`, so writing a fixture there |
| 4572 | +// would write the repo's own source (and `@objectstack/spec`'s `files[]` carries |
| 4573 | +// `src/**/*.zod.ts`, so it would ship). The gate under test is still the copied |
| 4574 | +// `scripts/build-schemas.ts`, byte for byte, and no test-only seam is added to |
| 4575 | +// it — what differs is the fixture's spec source, which is data exactly as the |
| 4576 | +// fabricated `refs/remotes/origin/main` is. |
| 4577 | + |
| 4578 | +/** The anchor this fixture substitutes — `strictObject`'s own declaration. */ |
| 4579 | +const STRICT_OBJECT_ANCHOR = |
| 4580 | + /export function strictObject<T extends z\.ZodRawShape>\(options: StrictObjectOptions, shape: T\) \{\n[\s\S]*?\n\}\n/; |
| 4581 | + |
| 4582 | +describe('build-schemas.ts — proof 4 reads the REFUSAL TEXT: a strict clone without the declaration\'s error map proves nothing (#18865)', () => { |
| 4583 | + let box: string; |
| 4584 | + let boxScript: string; |
| 4585 | + let boxSurfaceDir: string; |
| 4586 | + let boxStrictObject: string; |
| 4587 | + let pristineStrictObject: string; |
| 4588 | + let canonicalBoxSurface: string; |
| 4589 | + |
| 4590 | + /** Same hermetic invocation as the sandbox's `git` — this box is a fixture |
| 4591 | + * repository too, and inherits nothing from the machine either (#9068). */ |
| 4592 | + const boxGit = (...args: string[]): string => gitIn(box, ...args); |
| 4593 | + |
| 4594 | + const runBox = (args: string[] = []): { status: number; output: string } => { |
| 4595 | + const r = spawnSync(TSX, [boxScript, ...args], { |
| 4596 | + cwd: box, |
| 4597 | + encoding: 'utf8', |
| 4598 | + timeout: SPAWN_TIMEOUT_MS, |
| 4599 | + stdio: ['ignore', 'pipe', 'pipe'], |
| 4600 | + // The gate reads `zodByDefKey` for the probe, so it must hold the real |
| 4601 | + // instances rather than `lazySchema`'s Proxy — `check:authorable-surface` |
| 4602 | + // exports this and so does `gen:schema`. |
| 4603 | + env: { ...HERMETIC_ENV, ...EAGER_SCHEMAS_ENV }, |
| 4604 | + }); |
| 4605 | + return { status: r.status ?? -1, output: `${r.stdout ?? ''}${r.stderr ?? ''}` }; |
| 4606 | + }; |
| 4607 | + |
| 4608 | + /** |
| 4609 | + * Rebuild `strictObject` in this box's own `src/` so every closed shape is a |
| 4610 | + * strict clone of its declaration that does NOT carry the declaration's error |
| 4611 | + * map: the declaration is still registered (proof 4's first half still finds |
| 4612 | + * the promise), the door is still closed (the probe still gets an |
| 4613 | + * `unrecognized_keys` issue naming the key), and the refusal carries zod's |
| 4614 | + * bare text instead of the prescription. |
| 4615 | + */ |
| 4616 | + const dropErrorMaps = (): void => { |
| 4617 | + const rendered = |
| 4618 | + `export function strictObject<T extends z.ZodRawShape>(options: StrictObjectOptions, shape: T) {\n` + |
| 4619 | + ` strictObjectError(options, shape);\n` + |
| 4620 | + ` return z.object(shape).strict();\n` + |
| 4621 | + `}\n`; |
| 4622 | + expect( |
| 4623 | + STRICT_OBJECT_ANCHOR.test(pristineStrictObject), |
| 4624 | + '`strictObject` is no longer one function declaration in src/shared/strict-object.ts — this ' + |
| 4625 | + 'fixture substitutes it textually and can no longer find it', |
| 4626 | + ).toBe(true); |
| 4627 | + fs.writeFileSync(boxStrictObject, pristineStrictObject.replace(STRICT_OBJECT_ANCHOR, rendered)); |
| 4628 | + }; |
| 4629 | + |
| 4630 | + /** Put this box's `src/` back to the tree's own bytes — the DARK leg's state. */ |
| 4631 | + const keepErrorMaps = (): void => { |
| 4632 | + fs.writeFileSync(boxStrictObject, pristineStrictObject); |
| 4633 | + }; |
| 4634 | + |
| 4635 | + beforeAll(() => { |
| 4636 | + // ── Fixture validity, loud ─────────────────────────────────────────────── |
| 4637 | + // Every half below can rot on its own, and each one rots this pin into a |
| 4638 | + // green that asserts nothing about the leg. |
| 4639 | + expect( |
| 4640 | + pristineSurface, |
| 4641 | + `${DELETED_SERVER_TWIN} is a real baseline line now — check (c) only ever sees a key the ` + |
| 4642 | + `build STOPPED emitting, so this fixture needs a key the baseline does not carry`, |
| 4643 | + ).not.toContain(DELETED_SERVER_TWIN); |
| 4644 | + expect( |
| 4645 | + pristine, |
| 4646 | + `${SERVER_TWIN_DEF} is no longer emitted — check (c) would route this deletion to the ` + |
| 4647 | + `vanished-def proof instead; re-pick the def`, |
| 4648 | + ).toContain(SERVER_TWIN_DEF); |
| 4649 | + expect( |
| 4650 | + Object.keys(RateLimitConfigSchema.shape), |
| 4651 | + `'${TWIN_LEAF}' is DECLARED on the shape again — it would never reach the unrecognized-key ` + |
| 4652 | + `path, so nothing here models a guidance route`, |
| 4653 | + ).not.toContain(TWIN_LEAF); |
| 4654 | + |
| 4655 | + // The DARK leg's tree fact: the real def parses through the declaration's own |
| 4656 | + // error map, so its refusal CARRIES the prescription. |
| 4657 | + const delivered = ServerRateLimitConfigSchema.safeParse({ ...TWIN_VALID, [TWIN_LEAF]: 'ip' }); |
| 4658 | + expect( |
| 4659 | + delivered.success, |
| 4660 | + `${SERVER_TWIN_DEF} now ACCEPTS '${TWIN_LEAF}' — its door re-opened and the DARK leg below ` + |
| 4661 | + `would stop being a delivery at all`, |
| 4662 | + ).toBe(false); |
| 4663 | + expect( |
| 4664 | + delivered.success ? '' : delivered.error.issues.map((i) => i.message).join('\n'), |
| 4665 | + `${SERVER_TWIN_DEF} rejects '${TWIN_LEAF}' with no prescription — the \`guidance\` entry that ` + |
| 4666 | + `is this block's whole subject has gone`, |
| 4667 | + ).toContain(PRESCRIPTION_BULLET); |
| 4668 | + |
| 4669 | + // The LIT leg's tree fact, taken in process on the very construction the |
| 4670 | + // fixture substitutes: a strict clone built from the same shape refuses the |
| 4671 | + // key — so the probe DOES get its `unrecognized_keys` issue and `&&` does not |
| 4672 | + // short-circuit — and the refusal carries no prescription. This is what makes |
| 4673 | + // the case below a pin on the MESSAGE half rather than a second copy of the |
| 4674 | + // `ui/ViewItem` pin on the issue-is-undefined half. |
| 4675 | + const clone = z.object(RateLimitConfigSchema.shape).strict(); |
| 4676 | + const silent = clone.safeParse({ ...TWIN_VALID, [TWIN_LEAF]: 'ip' }); |
| 4677 | + expect( |
| 4678 | + silent.success, |
| 4679 | + `a strict clone of ${SHARED_TWIN_DEF}'s shape now ACCEPTS '${TWIN_LEAF}' — the fixture would ` + |
| 4680 | + `model an OPEN door, which is the issue-is-undefined half, not this one`, |
| 4681 | + ).toBe(false); |
| 4682 | + expect( |
| 4683 | + silent.success ? [] : silent.error.issues.map((i) => i.code), |
| 4684 | + `a strict clone without the error map no longer answers with an unrecognized-key issue — ` + |
| 4685 | + `\`delivers()\` would short-circuit on its LEFT half and this block would pin nothing`, |
| 4686 | + ).toContain('unrecognized_keys'); |
| 4687 | + expect( |
| 4688 | + silent.success ? '' : silent.error.issues.map((i) => i.message).join('\n'), |
| 4689 | + `zod's own unrecognized-key message now carries the prescription bullet — a clone without the ` + |
| 4690 | + `declaration's error map has stopped being silent and this fixture models nothing`, |
| 4691 | + ).not.toContain(PRESCRIPTION_BULLET); |
| 4692 | + |
| 4693 | + box = fixtureTree('build-schemas-proof4-message-leg-'); |
| 4694 | + fs.cpSync(path.join(PKG, 'scripts'), path.join(box, 'scripts'), { recursive: true }); |
| 4695 | + fs.cpSync(path.join(PKG, 'src'), path.join(box, 'src'), { recursive: true }); |
| 4696 | + for (const entry of ['node_modules', 'package.json']) { |
| 4697 | + fs.symlinkSync(path.join(PKG, entry), path.join(box, entry)); |
| 4698 | + } |
| 4699 | + mountCommittedLedgers(box); |
| 4700 | + writeManifestShards(path.join(box, SCHEMA_MANIFEST_DIR_NAME), pristine); |
| 4701 | + boxSurfaceDir = path.join(box, AUTHORABLE_SURFACE_DIR_NAME); |
| 4702 | + writeSurfaceShards(boxSurfaceDir, pristineSurface); |
| 4703 | + // The #4666 default ratchet runs on every invocation, so every box needs its |
| 4704 | + // committed record too — otherwise a fixture fails on a missing artifact |
| 4705 | + // instead of on the row it is actually testing. |
| 4706 | + writeDefaultsShards(path.join(box, AUTHORABLE_DEFAULTS_DIR_NAME), pristineDefaults); |
| 4707 | + boxScript = path.join(box, 'scripts', 'build-schemas.ts'); |
| 4708 | + boxStrictObject = path.join(box, 'src', 'shared', 'strict-object.ts'); |
| 4709 | + pristineStrictObject = fs.readFileSync(boxStrictObject, 'utf8'); |
| 4710 | + |
| 4711 | + initFixtureRepo(box); |
| 4712 | + boxGit('add', AUTHORABLE_SURFACE_DIR_NAME, AUTHORABLE_DEFAULTS_DIR_NAME); |
| 4713 | + boxGit('commit', '-q', '-m', `baseline: committed ${AUTHORABLE_SURFACE_DIR_NAME}/`); |
| 4714 | + fs.writeFileSync( |
| 4715 | + path.join(box, 'authorable-surface.base.json'), |
| 4716 | + JSON.stringify( |
| 4717 | + { description: surfaceBaseDescription, baseRev: boxGit('rev-parse', 'HEAD'), keys: pristineSurface }, |
| 4718 | + null, |
| 4719 | + 2, |
| 4720 | + ) + '\n', |
| 4721 | + ); |
| 4722 | + boxGit('add', 'authorable-surface.base.json'); |
| 4723 | + boxGit('commit', '-q', '-m', 'baseline anchor'); |
| 4724 | + // The BASE carries one extra line; the worktree is put back to the live set |
| 4725 | + // so check (a) stays silent and check (c) sees exactly one deleted key. Same |
| 4726 | + // shape as `seedBase` above, kept local because this box owns its own repo. |
| 4727 | + writeSurfaceShards(boxSurfaceDir, [...pristineSurface, DELETED_SERVER_TWIN].sort()); |
| 4728 | + boxGit('add', AUTHORABLE_SURFACE_DIR_NAME); |
| 4729 | + boxGit('commit', '-q', '-m', 'base variant: one extra authorable line'); |
| 4730 | + boxGit('update-ref', 'refs/remotes/origin/main', boxGit('rev-parse', 'HEAD')); |
| 4731 | + canonicalBoxSurface = writeSurfaceShards(boxSurfaceDir, pristineSurface); |
| 4732 | + }); |
| 4733 | + |
| 4734 | + afterAll(() => { |
| 4735 | + if (box) fs.rmSync(sandboxRoot(box), { recursive: true, force: true }); |
| 4736 | + }); |
| 4737 | + |
| 4738 | + it( |
| 4739 | + 'LIT — a strict clone that does not carry its declaration\'s error map is NOT proof: the deletion is refused', |
| 4740 | + { timeout: SPAWN_TIMEOUT_MS }, |
| 4741 | + () => { |
| 4742 | + // The whole of #18865 in one run. The declaration still NAMES `keyBy`, the |
| 4743 | + // door is still closed, the probe still gets its `unrecognized_keys` issue |
| 4744 | + // — and the refusal carries zod's bare text, not the prescription. Only |
| 4745 | + // `issue.message.includes(prescription)` can see the difference, so this |
| 4746 | + // case is red for exactly that conjunct and for nothing else. |
| 4747 | + dropErrorMaps(); |
| 4748 | + |
| 4749 | + const { status, output } = runBox(['--check']); |
| 4750 | + |
| 4751 | + expect(status).toBe(1); |
| 4752 | + expect(output).toContain('authorable baseline line(s) were deleted without proof (#4650)'); |
| 4753 | + expect(output).toMatch( |
| 4754 | + new RegExp( |
| 4755 | + `${DELETED_SERVER_TWIN.replace(/[/$]/g, '\\$&')} — def .*; a \`strictObject\` declaration ` + |
| 4756 | + `NAMES '${TWIN_LEAF}', but writing it`, |
| 4757 | + ), |
| 4758 | + ); |
| 4759 | + // ⭐ The waiver is the failure direction, named. A gate that lost the |
| 4760 | + // message leg prints THIS instead, exits 0, and reads as compliant. |
| 4761 | + expect(output).not.toContain('carries the prescription its `strictObject` declaration owes'); |
| 4762 | + expect(output).not.toMatch( |
| 4763 | + new RegExp( |
| 4764 | + `${DELETED_SERVER_TWIN.replace(/[/$]/g, '\\$&')} — def .*is REFUSED as an unrecognized key`, |
| 4765 | + ), |
| 4766 | + ); |
| 4767 | + // …and it is not being refused by some OTHER proof either: the def is |
| 4768 | + // root-reachable and its baseline entry was never `[RETIRED]`, so without |
| 4769 | + // these legs the case would pass on a gate that had simply stopped emitting |
| 4770 | + // proof 4 at all. |
| 4771 | + expect(output).not.toMatch( |
| 4772 | + new RegExp(`${DELETED_SERVER_TWIN.replace(/[/$]/g, '\\$&')} — def not reachable from the`), |
| 4773 | + ); |
| 4774 | + expect(output).not.toMatch( |
| 4775 | + new RegExp( |
| 4776 | + `${DELETED_SERVER_TWIN.replace(/[/$]/g, '\\$&')} — def .*was LIVE \\(never tombstoned\\)\\.`, |
| 4777 | + ), |
| 4778 | + ); |
| 4779 | + |
| 4780 | + // A check reports; it does not write (#4711) — this file's founding |
| 4781 | + // assertion, and the one a fixture that mutates `src/` most needs. |
| 4782 | + expect(shardBytes(boxSurfaceDir)).toBe(canonicalBoxSurface); |
| 4783 | + }, |
| 4784 | + ); |
| 4785 | + |
| 4786 | + it( |
| 4787 | + 'DARK — the tree\'s own door, same key, same box: the refusal carries the prescription and the deletion IS waived', |
| 4788 | + { timeout: SPAWN_TIMEOUT_MS }, |
| 4789 | + () => { |
| 4790 | + // Without this leg the case above passes on a gate that waives nothing — |
| 4791 | + // "refuse everything" and "read the message" are indistinguishable from one |
| 4792 | + // red run. The ONLY difference between the two cases is whether this box's |
| 4793 | + // `strictObject` hands the built shape its declaration's error map. |
| 4794 | + keepErrorMaps(); |
| 4795 | + |
| 4796 | + const { status, output } = runBox(['--check']); |
| 4797 | + |
| 4798 | + expect(output).toContain('carry their own proof (#4650)'); |
| 4799 | + expect(output).toMatch( |
| 4800 | + new RegExp( |
| 4801 | + `${DELETED_SERVER_TWIN.replace(/[/$]/g, '\\$&')} — def .*; writing '${TWIN_LEAF}' on it is ` + |
| 4802 | + `REFUSED as an unrecognized key`, |
| 4803 | + ), |
| 4804 | + ); |
| 4805 | + expect(output).toContain('carries the prescription its `strictObject` declaration owes'); |
| 4806 | + expect(output).not.toContain('authorable baseline line(s) were deleted without proof (#4650)'); |
| 4807 | + expect(status).toBe(0); |
| 4808 | + |
| 4809 | + expect(shardBytes(boxSurfaceDir)).toBe(canonicalBoxSurface); |
| 4810 | + }, |
| 4811 | + ); |
| 4812 | +}); |
0 commit comments