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
12 changes: 12 additions & 0 deletions .changeset/issue-8704-census-matcher-ast.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
---

Repo script and its pin test only. `scripts/census-recorder-wait-shape.mjs` gains an AST
matcher (objectui#8704): recorder identity resolved over bindings and aliases instead of
name spelling, forward windows scoped to the enclosing test body in statements, and every
occurrence classified read / write / declaration. The original regex census is kept behind
`--matcher=regex` so objectui#8690's and objectui#8703's published numbers stay
reproducible. objectui#8703's five fixtures are committed as the script's test suite, with
a sixth that must stay flagged. The header's "no count here is a corpus fact" caveat stays,
with its residuals restated. The census is still not wired into CI. No published behaviour
changes.
152 changes: 152 additions & 0 deletions scripts/__tests__/census-recorder-wait-shape.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* Pins for `scripts/census-recorder-wait-shape.mjs` (objectui#8704).
*
* The six fixtures next to this file are objectui#8703's five throwaway cases,
* kept, plus the sixth objectui#8704 asked for. Each one was FIRST observed
* giving the wrong answer on the pre-repair script — that is why the regex
* block below exists at all. Both matchers are asserted over the SAME six
* files, so the repair is pinned as a direction and not just as a state:
*
* fixture regex ident regex path AST
* f1 member push / member read miss FLAG FLAG
* f2 member push / bare read FLAG miss FLAG
* f3 bare push / member read miss miss FLAG
* f4 runaway window (D1 + D2) 6 wrong 6 wrong none
* f5 hazard behind one await miss miss FLAG
* f6 absence read (the control) FLAG FLAG FLAG
*
* ⛔ This suite runs the matcher over the FIXTURES ONLY, never over the
* corpus. The census stays out of CI (objectui#8703's fence, restated in the
* script header): nothing here depends on what the repository reads.
*
* ⭐ f6 is the anti-caricature control. "Flag nothing" is strictly worse than
* the bug this card repairs, and it passes every other assertion in this file.
*/

import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { basename, join } from 'node:path';

import { analyzeAst, analyzeRegex } from '../census-recorder-wait-shape.mjs';

const DIR = join(__dirname, 'fixtures', 'census-recorder-wait-shape');

const F1 = 'f1-member-push-member-read.fixture.ts';
const F2 = 'f2-member-push-bare-read.fixture.ts';
const F3 = 'f3-bare-push-member-read.fixture.ts';
const F4 = 'f4-window-crosses-test.fixture.ts';
const F5 = 'f5-window-truncated.fixture.ts';
const F6 = 'f6-absence-read-must-stay-flagged.fixture.ts';
const ALL = [F1, F2, F3, F4, F5, F6];

const paths = ALL.map((f) => join(DIR, f));

interface Flag {
file: string;
line: number;
waitLine: number;
recorder: string;
waitSet: string[];
}

/** The source line a flag points at, trimmed — the only address that survives an edit. */
const lineAt = (file: string, line: number) =>
readFileSync(file, 'utf8').split('\n')[line - 1]!.trim();

/** Strict flags for one fixture, as `<what the flagged line says>` entries. */
const strictLines = (flags: Flag[], fixture: string) =>
flags
.filter((f) => f.waitSet.length > 0 && basename(f.file) === fixture)
.sort((a, b) => a.line - b.line)
.map((f) => lineAt(f.file, f.line));

const ast = () => analyzeAst(paths) as Flag[];
const regex = (mode: 'ident' | 'path') => analyzeRegex(paths, mode) as Flag[];

describe('the pre-repair regex matcher — objectui#8704\'s three defects, executable', () => {
it('D1+D2: flags DECLARATIONS in the next test, and a reset in this one', () => {
// Every one of these was measured on the pre-repair script before the AST
// matcher existed. `scratch.length = 0` is D2 with the window rule already
// correct (same test); `expect(shared[0])` is D1 with the occurrence rule
// already correct (a genuine read, wrong test).
const wrong = [
'const second: number[] = [];',
'const third: number[] = [];',
'const scratch: number[] = [];',
'scratch.length = 0;',
'const shared: number[] = [];',
'expect(shared[0]).toBe(4);',
];
expect(strictLines(regex('path'), F4)).toEqual(wrong);
expect(strictLines(regex('ident'), F4)).toEqual(wrong);
});

it('D1, the mirror: a real hazard one ordinary `await` further on draws ZERO', () => {
expect(strictLines(regex('path'), F5)).toEqual([]);
expect(strictLines(regex('ident'), F5)).toEqual([]);
});

it('M2: a recorder pushed bare and read through its host is invisible to BOTH modes', () => {
expect(strictLines(regex('path'), F3)).toEqual([]);
expect(strictLines(regex('ident'), F3)).toEqual([]);
});

it('M1: the two modes are incomparable — each sees a shape the other cannot', () => {
expect(strictLines(regex('path'), F1)).toEqual(['expect(server.savedOpts[0]).toMatchObject({ mode: \'draft\' });']);
expect(strictLines(regex('ident'), F1)).toEqual([]);

expect(strictLines(regex('ident'), F2)).toEqual(['expect(inits[0]).toBe(1);']);
expect(strictLines(regex('path'), F2)).toEqual([]);
});

it('gets f6 right — so the repair has something it must NOT break', () => {
expect(strictLines(regex('path'), F6)).toEqual(['expect(deletes).toEqual([]);']);
expect(strictLines(regex('ident'), F6)).toEqual(['expect(deletes).toEqual([]);']);
});
});

describe('the AST matcher — identity, test-scoped windows, read/write/declare', () => {
it('D1+D2 repaired: the runaway window flags NOTHING in f4', () => {
expect(strictLines(ast(), F4)).toEqual([]);
});

it('D1 mirror repaired: the hazard behind an ordinary `await` is found', () => {
// A bare `await` settles nothing, so it must not close the window.
expect(strictLines(ast(), F5)).toEqual(['expect(payloads[0]).toBe(2);']);
});

it('M2 repaired: a host member and the bare array it holds are ONE recorder', () => {
expect(strictLines(ast(), F3)).toEqual(['expect(host.inits[0]).toBe(1);']);
});

it('M1 dissolved: both spellings flag, with no mode to choose', () => {
expect(strictLines(ast(), F1)).toEqual(['expect(server.savedOpts[0]).toMatchObject({ mode: \'draft\' });']);
expect(strictLines(ast(), F2)).toEqual(['expect(inits[0]).toBe(1);']);
});

it('⭐ still flags the absence read objectui#8690 repaired — NOT a matcher that reports nothing', () => {
// The caricature this card names: an implementation strictly worse than the
// bug ("flag nothing") passes f4 and every "must be empty" case above. It
// fails here, and here is the only place it can fail.
expect(strictLines(ast(), F6)).toEqual(['expect(deletes).toEqual([]);']);
expect(ast().filter((f) => f.waitSet.length > 0)).toHaveLength(5);
});

it('names the recorder as the READ site spells it, not as its push site does', () => {
// Two distinct arrays can share a push-site spelling; printing that made a
// correct flag read as "waits [calls] reads calls" on the real corpus.
const f3 = ast().find((f) => basename(f.file) === F3)!;
expect({ waits: f3.waitSet, reads: f3.recorder }).toEqual({
waits: ['host.calls'],
reads: 'host.inits',
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* f1 — pushed and read as the SAME member path.
*
* Pre-repair: flagged by `--recorder-match=path` only; invisible to `ident`,
* whose name regex has a lookbehind that forbids a preceding `.`.
* Repaired: flagged. It is a genuine cross-recorder read.
*/
import { it, expect, waitFor } from './harness';

it('merges the slice on save', async () => {
const server = { saved: [] as string[], savedOpts: [] as Record<string, unknown>[] };
const save = (name: string, opts: Record<string, unknown>) => {
server.saved.push(name);
server.savedOpts.push(opts);
};

save('app.a', { mode: 'draft' });
await waitFor(() => expect(server.saved.length).toBe(1));
expect(server.savedOpts[0]).toMatchObject({ mode: 'draft' });
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* f2 — pushed as a member, waited and read under bare aliases.
*
* Pre-repair: flagged by `--recorder-match=ident` only; invisible to `path`,
* which knows the recorders as `host.calls` / `host.inits` and never matches
* the bare spellings.
* Repaired: flagged. The alias and the member path are the same array.
*/
import { it, expect, waitFor } from './harness';

it('records the init alongside the call', async () => {
const host = { calls: [] as string[], inits: [] as number[] };
const record = (n: number) => {
host.calls.push('c');
host.inits.push(n);
};

record(1);
const { calls, inits } = host;
await waitFor(() => expect(calls.length).toBe(1));
expect(inits[0]).toBe(1);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* f3 — pushed bare, waited and read through the host that holds the SAME array.
*
* Pre-repair: ZERO flags in BOTH modes — the shared blind spot objectui#8703
* corrected the header about. The lookbehind blocks the dotted read whichever
* way the push site was spelled.
* Repaired: flagged. `host.inits` and `inits` resolve to one binding.
*/
import { it, expect, waitFor } from './harness';

it('records the init alongside the call', async () => {
const calls: string[] = [];
const inits: number[] = [];
const host = { calls, inits };
const record = (n: number) => {
calls.push('c');
inits.push(n);
};

record(1);
await waitFor(() => expect(host.calls.length).toBe(1));
expect(host.inits[0]).toBe(1);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* f4 — the forward window is not scoped to the enclosing test (D1), and any
* textual occurrence counts as a read (D2).
*
* Cases `a` / `b` are objectui#8704's own reproduction, verbatim: the wait is
* the last `await` of test `a`, so its window runs on into test `b`, whose
* opening line DECLARES a recorder. Both defects fire at once there.
*
* `c` and `d` / `e` were added here to separate them, because the fixed
* versions mask each other on `a` / `b` alone:
* c — D2 with the window rule already correct: a reset and a push, in
* the SAME test, after the wait. Nothing crosses a test boundary.
* d / e — D1 with the occurrence rule already correct: the next test's
* recorder is DECLARED before both tests, so the first occurrence
* inside the runaway window is a genuine READ.
*
* Pre-repair: flagged in both modes.
* Repaired: ZERO flags.
*/
import { describe, it, expect, waitFor } from './harness';

it('a — its wait is the last await of this test', async () => {
const first: number[] = [];
first.push(1);
await waitFor(() => expect(first.length).toBe(1));
expect(first[0]).toBe(1);
});

it('b — a different test, with its own recorder', async () => {
const second: number[] = [];
second.push(2);
await waitFor(() => expect(second.length).toBe(1));
});

it('c — a reset and a push after the wait, inside the SAME test', async () => {
const third: number[] = [];
const scratch: number[] = [];
third.push(3);
await waitFor(() => expect(third.length).toBe(1));
scratch.length = 0;
scratch.push(4);
});

describe('d/e — the runaway window reaches a real read in the next test', () => {
const shared: number[] = [];

it('d — its wait is the last await of this test', async () => {
const anchor: number[] = [];
anchor.push(1);
await waitFor(() => expect(anchor.length).toBe(1));
});

it('e — a different test reads the recorder declared above both', () => {
shared.push(4);
expect(shared[0]).toBe(4);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* f5 — a genuine cross-recorder hazard sitting one `await` further on.
*
* Pre-repair: ZERO flags in BOTH modes. The forward window ends at the next
* textual `await`, so `await Promise.resolve()` truncates it before the read.
* That is the MIRROR of f4: the same rule that over-reports also goes blind.
* Repaired: flagged. A bare `await` settles nothing, so it does not close the
* window; only another awaited settling anchor does.
*/
import { it, expect, waitFor } from './harness';

it('reads the payload the wait never mentioned', async () => {
const arrivals: string[] = [];
const payloads: number[] = [];
const receive = (tag: string, n: number) => {
arrivals.push(tag);
setTimeout(() => payloads.push(n), 0);
};

receive('a', 2);
await waitFor(() => expect(arrivals.length).toBe(1));
await Promise.resolve();
expect(payloads[0]).toBe(2);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* f6 — the shape objectui#8690 audited and PR #8702 repaired
* (`MetadataObjectsPage.lookupKeying`): wait on the write log, then assert the
* delete log is EMPTY. An absence dated to the first write cannot see a delete
* issued after it.
*
* Pre-repair: flagged (it was one of the nine).
* Repaired: STILL flagged.
*
* ⭐ This fixture is the anti-caricature control. A matcher that "fixes"
* over-reporting by reporting nothing passes f4 and fails here.
*/
import { it, expect, waitFor } from './harness';

it('issues no delete for a rename', async () => {
const puts: string[] = [];
const deletes: string[] = [];
const client = {
put: (name: string) => {
puts.push(name);
},
reset: (name: string) => {
deletes.push(name);
},
};

client.put('contact');
await waitFor(() => expect(puts.length).toBe(1));
expect(deletes).toEqual([]);
});
30 changes: 30 additions & 0 deletions scripts/__tests__/fixtures/census-recorder-wait-shape/harness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* Local stand-ins for `vitest` / `@testing-library/react`, so the fixtures next
* to this file are self-contained TypeScript.
*
* They are NOT executed. `tsconfig.scripts.json` compiles every `.ts` under
* `scripts/`, so the fixtures have to type-check; importing the real `vitest`
* and `@testing-library/react` would drag DOM lib types into a project whose
* `lib` is `ES2022` + `types: ["node"]`. Declaring the three names locally
* keeps the fixtures compiling while leaving their SOURCE SHAPE — which is the
* only thing the census reads — identical to a real test file.
*/

export declare function describe(name: string, fn: () => void): void;
export declare function it(name: string, fn: () => Promise<void> | void): void;
export declare function waitFor(fn: () => void): Promise<void>;

export interface Matchers {
toBe(expected: unknown): void;
toEqual(expected: unknown): void;
toMatchObject(expected: object): void;
}
export declare function expect(actual: unknown): Matchers;
Loading
Loading