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
45 changes: 45 additions & 0 deletions .changeset/authorable-surface-anchor-explicit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
"@objectstack/spec": patch
---

fix(spec): re-anchoring `authorable-surface.base.json` is an explicit act, not a build side effect (#5358)

`packages/spec/authorable-surface.base.json` is the in-tree anchor the #4650 deletion gate
falls back to where `origin/main` is out of reach (#5235). It is not a projection of this
package's source — it is a snapshot of an **upstream** commit, and it is the baseline
precisely because the commit under test cannot rewrite it.

Until now every `gen:schema` run rewrote it whenever it had drifted from the git-resolved
baseline. `gen:schema` is `pnpm build`'s first step, so this fired on any build of any
package that merely has `@objectstack/spec` in its dependency closure, and on `check:docs`
(whose first step is `gen:schema`). Three developers hit it independently, from three
unrelated tasks:

- a plain `pnpm build`: `baseRev` advanced to HEAD, **−110 keys**;
- `pnpm --filter "@objectstack/cli^..." build`: the same 110;
- `pnpm --filter "@objectstack/service-automation^..." build`: `baseRev` advanced, +3 keys.

The 110 were the `ui/ComponentAnimation` family that had just been retired. An anchor
advanced past a retirement cannot see that retirement any more — and the gate is green
before *and* after, because both states are internally consistent. All three were caught
only by reading `git status` line by line before committing; a `git add -A` would have
carried the moved baseline into a PR about something else.

The anchor now moves only in a mode of its own:

```bash
pnpm --filter @objectstack/spec gen:authorable-surface-base # build-schemas.ts --update-base
```

- `gen:schema` and any build: never write it. A lagging anchor prints one ℹ️ line saying so,
naming this command — lag was already not an error (on `main` the merge base is HEAD, so
the file necessarily trails its own surface by one PR).
- `check:authorable-surface`: unchanged, still strictly read-only, and still fatal when the
committed anchor is missing, malformed, or inauthentic.
- `--check --update-base` is refused: a check that repairs what it detects can never report it.

Nothing about anchor **authenticity** changes: `baseRev` must still be an ancestor of
`origin/main` with keys matching that commit's `authorable-surface.json`, the anchor is
still written only from a git-resolved baseline (never from the build being checked), and
the write still happens after the deletion gate has adjudicated the run, so the explicit
mode cannot walk the baseline past an unproven deletion either.
1 change: 1 addition & 0 deletions packages/spec/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@
"dev": "tsc --watch",
"clean": "rm -rf dist",
"gen:schema": "OS_EAGER_SCHEMAS=1 tsx scripts/build-schemas.ts",
"gen:authorable-surface-base": "OS_EAGER_SCHEMAS=1 tsx scripts/build-schemas.ts --update-base",
"gen:openapi": "tsx scripts/build-openapi.ts",
"gen:docs": "tsx scripts/build-docs.ts",
"check:docs": "pnpm gen:schema && tsx scripts/build-docs.ts --check",
Expand Down
217 changes: 212 additions & 5 deletions packages/spec/scripts/build-schemas-check-mode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
// then a faithful model of a build environment with no route to GitHub — the
// last describe block below drives exactly that.

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
Expand Down Expand Up @@ -705,7 +705,10 @@ describe('build-schemas.ts — an in-tree anchor carries the deletion gate offli
expect(status).toBe(1);
expect(output).toContain('is not the baseline it claims to be');
expect(output).toContain(SHED_FROM_ANCHOR);
expect(output).toContain('gen:schema');
// The remedy names the one command that may write this file — `gen:schema`
// stopped being it at #5358, and a prescription pointing at a command that
// no longer touches the file is the defect that issue is about.
expect(output).toContain('gen:authorable-surface-base');
},
);

Expand Down Expand Up @@ -782,8 +785,8 @@ describe('build-schemas.ts — an in-tree anchor carries the deletion gate offli
);

it(
'is a committed artifact: --check reports it missing without writing it, gen:schema creates it from the git baseline',
{ timeout: SPAWN_TIMEOUT_MS * 2 },
'is a committed artifact: --check reports it missing without writing it, and only --update-base creates it',
{ timeout: SPAWN_TIMEOUT_MS * 3 },
() => {
fs.rmSync(surfaceBasePath);

Expand All @@ -793,7 +796,15 @@ describe('build-schemas.ts — an in-tree anchor carries the deletion gate offli
// A check reports; it does not repair (#4711).
expect(fs.existsSync(surfaceBasePath)).toBe(false);

const write = run([]);
// A plain build says so loudly and still does not create it (#5358) — the
// gate above is where a missing committed artifact goes red, not here.
const build = run([]);
expect(build.status).toBe(0);
expect(build.output).toContain('a build no longer creates it (#5358)');
expect(build.output).toContain('gen:authorable-surface-base');
expect(fs.existsSync(surfaceBasePath)).toBe(false);

const write = run(['--update-base']);
expect(write.status).toBe(0);
expect(write.output).toContain('authorable-surface.base.json created at');
const doc = JSON.parse(readSurfaceBase()) as { baseRev: string; keys: string[] };
Expand All @@ -803,3 +814,199 @@ describe('build-schemas.ts — an in-tree anchor carries the deletion gate offli
},
);
});

// ─────────────────────────────────────────────────────────────────────────────
// #5358 — the anchor moves only when a human asks for it.
//
// #5235 gave the anchor two properties: it may only be written from a
// git-resolved baseline, and it is verified against origin/main wherever that is
// reachable. Both are about WHAT gets written. Neither says WHEN — and the answer
// was "on every run that regenerates", which meant every `pnpm build`, every
// `pnpm --filter '<pkg>^...' build` whose closure contains @objectstack/spec, and
// every `check:docs` (whose first step is `gen:schema`).
//
// Three developers reported the consequence independently, from three unrelated
// tasks (#4990, #5155, #5660): a file they had never opened showed up modified in
// `git status`, with `baseRev` advanced to the tip they branched from. Twice the
// same run also dropped 110 keys — the `ui/ComponentAnimation` family #4988/#5321
// had just retired. An anchor advanced past a retirement is an anchor that can no
// longer SEE that retirement, and the #4650 deletion gate is green before and
// after, because both states are internally consistent. The only thing standing
// between that and a merged PR was three people reading `git diff` line by line.
//
// The fix is a mode, not a smarter heuristic: `--update-base` writes the anchor,
// and nothing else does. What these tests pin is the negative — that a run which
// is NOT that mode leaves the working tree exactly as it found it — because that
// is the property `git add -A` can silently violate.
describe('build-schemas.ts — only --update-base moves the in-tree anchor (#5358)', () => {
/** A live key held back from the older commit, so the anchor legitimately lags. */
const LAGGING_KEY = 'data/Object:label';

let older: string;
let tip: string;
let laggingAnchor: string;

beforeEach(() => {
// The real-world shape, built honestly: an OLDER upstream commit whose
// baseline is one key short, then the current origin/main tip carrying the
// full baseline. The anchor mirrors the older commit — authentic (its keys ARE
// that commit's baseline) and legitimately behind the merge base, which is
// exactly the state every checkout is in right after a surface change lands.
seedManifest((s) => s);
older = seedBase((s) => s.filter((k) => k !== LAGGING_KEY));
tip = seedBase((s) => s);
seedSurface((s) => s);
laggingAnchor = seedSurfaceBase(older, (k) => k.filter((x) => x !== LAGGING_KEY));
// Commit the fixture so the tree is CLEAN — `git diff --exit-code` is the
// acceptance criterion, and it can only mean something from a clean start.
// Only the two tracked artifacts: `git add -A` here would track the ~1600-file
// json-schema/ output, which every subsequent run rewrites, and the cleanliness
// assertions below would then measure the generator's own scratch space.
git('add', 'authorable-surface.base.json');
git('commit', '-q', '-m', 'fixture: lagging but authentic anchor');
git('update-ref', 'refs/remotes/origin/main', tip);
expect(git('status', '--porcelain', '-uno')).toBe('');
});

afterEach(() => {
git('update-ref', 'refs/remotes/origin/main', tip);
});

it(
'a plain build leaves the anchor byte-identical and the working tree clean',
{ timeout: SPAWN_TIMEOUT_MS },
() => {
// THE regression test. Before #5358 this run rewrote the anchor to
// {baseRev: tip, keys: full} and exited 0, so `git status` showed a file the
// build's author never touched. `gen:schema` is `pnpm build`'s first step,
// which is why an unrelated PR was one `git add -A` away from carrying it.
const { status, output } = run([]);

expect(status).toBe(0);
expect(readSurfaceBase()).toBe(laggingAnchor);
expect(git('status', '--porcelain', '-uno')).toBe('');
// Silent is not enough: the run says the anchor lags, that this is not an
// error, and what the deliberate act would be.
expect(output).toContain('trails the baseline at');
expect(output).toContain('not an error');
expect(output).toContain('gen:authorable-surface-base');
expect(output).not.toContain('⚓');
},
);

it(
'a --check run leaves the anchor byte-identical and the working tree clean',
{ timeout: SPAWN_TIMEOUT_MS },
() => {
// The literal acceptance criterion of #5358: run the gate on a clean tree,
// then `git diff --exit-code`. Unlike the case above this one already held
// on `main` — the anchor's write branch was `!CHECK` before this change too
// — so it is a pin, not a repair. It is here because "a check is read-only"
// is the property the whole file exists for (#4711), and the entry point
// that violated it was the neighbouring one.
const { status } = run(['--check']);

expect(status).toBe(0);
expect(readSurfaceBase()).toBe(laggingAnchor);
expect(git('status', '--porcelain', '-uno')).toBe('');
},
);

it(
'--update-base re-anchors to the git-resolved baseline, and says so',
{ timeout: SPAWN_TIMEOUT_MS },
() => {
const { status, output } = run(['--update-base']);

expect(status).toBe(0);
expect(output).toContain('⚓');
expect(output).toContain(tip.slice(0, 12));
const doc = JSON.parse(readSurfaceBase()) as { baseRev: string; keys: string[] };
// From the merge base, never from this build's own emitted surface (#5235).
expect(doc.baseRev).toBe(tip);
expect(doc.keys).toEqual((JSON.parse(pristineSurface) as { keys: string[] }).keys);
expect(doc.keys).toContain(LAGGING_KEY);
// Restore the fixture for the sibling cases — beforeEach re-commits anyway,
// but a dirty tree between tests would make a failure here read as a failure
// there.
fs.writeFileSync(surfaceBasePath, laggingAnchor);
},
);

it(
'--update-base on an already-current anchor writes nothing and says nothing to do',
{ timeout: SPAWN_TIMEOUT_MS },
() => {
const current = seedSurfaceBase(tip, (k) => k);
git('add', 'authorable-surface.base.json');
git('commit', '-q', '-m', 'fixture: anchor already at the merge base');

const { status, output } = run(['--update-base']);

expect(status).toBe(0);
expect(output).toContain('nothing to re-anchor');
expect(readSurfaceBase()).toBe(current);
expect(git('status', '--porcelain', '-uno')).toBe('');
},
);

it(
'refuses --check --update-base: a check that repairs what it detects can never report it',
{ timeout: SPAWN_TIMEOUT_MS },
() => {
const { status, output } = run(['--check', '--update-base']);

expect(status).toBe(1);
expect(output).toContain('mutually exclusive');
expect(readSurfaceBase()).toBe(laggingAnchor);
expect(git('status', '--porcelain', '-uno')).toBe('');
// Refused before the 1600-schema generation, not after it.
expect(output).not.toContain('Generating JSON Schemas');
},
);

it(
'never writes the anchor from the build being checked — --update-base is powerless offline',
{ timeout: SPAWN_TIMEOUT_MS },
() => {
// #5235's rule survives the new mode: with origin/main unreachable there is
// no git-resolved baseline, so there is nothing the flag may write FROM. A
// `--update-base` that fell back to this build's own surface would be the
// tree anchoring itself — the #4650 defect with a flag in front of it.
git('update-ref', '-d', 'refs/remotes/origin/main');
try {
const { status, output } = run(['--update-base']);

expect(status).toBe(0);
expect(output).toContain('origin/main is not resolvable');
expect(readSurfaceBase()).toBe(laggingAnchor);
expect(git('status', '--porcelain', '-uno')).toBe('');
} finally {
git('update-ref', 'refs/remotes/origin/main', tip);
}
},
);

it(
'still refuses to bless an unproven deletion in --update-base mode, and leaves the anchor alone',
{ timeout: SPAWN_TIMEOUT_MS },
() => {
// Order is load-bearing (see the anchor block in build-schemas.ts): the
// deletion gate adjudicates first, so the re-anchoring mode cannot be used to
// walk the baseline past a deletion nothing proved. Without this, #5358's
// explicit command would be a laundering route the old side effect never was.
// The sabotage goes in the merge base — the anchor's own keys stay honest, so
// only the gate, not the authenticity check, can be what fires.
seedBase((s) => [...s, DELETED_LIVE].sort());
seedSurface((s) => s);

const { status, output } = run(['--update-base']);

expect(status).toBe(1);
expect(output).toContain('deleted without proof (#4650)');
expect(output).toContain(DELETED_LIVE);
expect(readSurfaceBase()).toBe(laggingAnchor);
expect(output).not.toContain('⚓');
},
);
});
Loading
Loading