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
6 changes: 6 additions & 0 deletions .changeset/test-name-line-citation-gate-8047.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
---

Tooling and test-name only. Adds the `object-ui/no-line-address-in-test-name` ESLint rule
(objectui#8047) and deletes the source line addresses from the test names it reports. No
published behaviour, API surface or runtime code changes, so this releases nothing.
2 changes: 2 additions & 0 deletions eslint-rules/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import noUnprefixedQueryParams from './no-unprefixed-query-params.js';
import buttonHasType from './button-has-type.js';
import noUnpairedBadgeColorClasses from './no-unpaired-badge-color-classes.js';
import noUnusedImports from './no-unused-imports.js';
import noLineAddressInTestName from './no-line-address-in-test-name.js';

export default {
rules: {
Expand All @@ -22,5 +23,6 @@ export default {
'button-has-type': buttonHasType,
'no-unpaired-badge-color-classes': noUnpairedBadgeColorClasses,
'no-unused-imports': noUnusedImports,
'no-line-address-in-test-name': noLineAddressInTestName,
},
};
409 changes: 409 additions & 0 deletions eslint-rules/no-line-address-in-test-name.js

Large diffs are not rendered by default.

133 changes: 133 additions & 0 deletions eslint-rules/no-line-address-in-test-name.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* Pins `no-line-address-in-test-name` against BOTH directions, because the
* class this rule closes is defined by an instrument that fails in both.
*
* The `invalid` block carries every shape the address is known to arrive in —
* the five quote/modifier flavours of a literal name, and the INTERPOLATED
* form where the address lives in a case table and never appears on an `it(`
* line at all. A rule that only read `it(` string literals would pass the
* first group and silently miss the second, which is exactly how the sweeps
* before it under-counted the population six-fold.
*
* The `valid` block is the other half, and it is the answer to "where is a
* line address legitimate". Every entry is a real shape from this tree,
* reduced: an assertion message, a data field the title never names, a data
* field a test asserts ON, a comment, and an address whose line number is
* COMPUTED from a live read (derived, so it cannot rot). Without them the rule
* would be a rule against writing `.ts:` anywhere near a test.
*/
import { describe, it, afterAll } from 'vitest';
import { RuleTester } from 'eslint';
import rule from './no-line-address-in-test-name.js';

RuleTester.afterAll = afterAll;
RuleTester.it = it;
RuleTester.describe = describe;

const ruleTester = new RuleTester();

ruleTester.run('no-line-address-in-test-name', rule, {
valid: [
// ── The carve-outs of design question 2 ──────────────────────────────
// An assertion message: a human reads it AT THE POINT OF FAILURE, where
// the assertion that failed is the context. Real shape:
// readme-app-shell-example.test.ts.
`it('AppShell declares every prop the README passes', () => {
expect(undeclared, 'AppShell destructures a fixed key list (AppShell.tsx:233-241)').toEqual([]);
});`,
// A case-table field the title never names. `$key` is spliced, `producer`
// is not, and `producer` is spliced into the FAILURE MESSAGE instead.
// Real shape: gridNonAuthorKeys.test.tsx.
`const KEYS = [{ key: 'columnState', producer: 'app-shell/src/views/ObjectView.tsx:1848' }];
it.each(KEYS)('the spec refuses $key as an unrecognized key', ({ key, producer }) => {
expect(refuse(key), \`written by \${producer}\`).toBe(true);
});`,
// Data the test asserts ON — something checks it, so it is not the
// unreadable class. Real shape: page-header-authorable-keys.test.tsx.
`const REASONS = { icon: 'reads and draws it (PageHeader.tsx:123), objectui#3829.' };
it('every renderer-own declaration says why', () => {
expect(/#\\d+/.test(REASONS.icon)).toBe(true);
});`,
// A comment. ESLint hands rules an AST and this rule never reads comments.
`// moved to SidebarNav.tsx:60 in objectui#4840
it('renders the nav', () => { expect(1).toBe(1); });`,
// Derived, not cited: the line number is COMPUTED from a live read of the
// file, so it cannot rot. Real shape: guide-layout-sidebar-nav-doc.test.ts.
'it(`the fence at layout.md:${fence.line} spells icon as a component`, () => { expect(1).toBe(1); });',
// `$#` is the case INDEX — no case string reaches the name.
`const ROWS = [{ src: 'a/b.ts:12' }];
it.each(ROWS)('case $# round-trips', ({ src }) => { expect(src).toBeTruthy(); });`,
// A fixed title cannot carry a row's string, whatever the table holds.
`const ROWS = [{ src: 'a/b.ts:12' }];
it.each(ROWS)('every corpus row round-trips', ({ src }) => { expect(src).toBeTruthy(); });`,

// ── The known-negative SHAPES: things that look like an address ───────
`it('pins the published range at 1.2.3', () => { expect(1).toBe(1); });`, // version
`it('renders the 12:30 slot', () => { expect(1).toBe(1); });`, // clock
`it('reads layout.ts without opening it', () => { expect(1).toBe(1); });`, // bare path
`it('proxies to http://localhost:3000/api', () => { expect(1).toBe(1); });`, // host:port
// Somebody else's API — a member call that merely ends in `.it`.
`page.it('navigates to Foo.tsx:12', () => {});`,
],

invalid: [
// ── Leg 1, the five literal flavours the earlier sweep enumerated ─────
{
code: `it('plugin-list ListView case chart legacy leg (ListView.tsx:2767-2768)', () => {});`,
errors: [{ messageId: 'inTitle', data: { address: 'ListView.tsx:2767' } }],
},
{
code: `it("app-shell ObjectView chart viewDef legacy leg (views/ObjectView.tsx:2218)", () => {});`,
errors: [{ messageId: 'inTitle', data: { address: 'views/ObjectView.tsx:2218' } }],
},
{
code: 'it(`DashboardRenderer widget with a provider aggregate (DashboardRenderer.tsx:620)`, () => {});',
errors: [{ messageId: 'inTitle' }],
},
{
code: `describe('the retired member at layout.ts:66', () => {});`,
errors: [{ messageId: 'inTitle', data: { address: 'layout.ts:66' } }],
},
{
code: `it.skip('cites tsconfig.typetests.json:12 by line', () => {});`,
errors: [{ messageId: 'inTitle' }],
},
{
code: `it.each([1, 2])('case %s at ObjectChart.tsx:41', () => {});`,
errors: [{ messageId: 'inTitle' }],
},

// ── Leg 2, the INTERPOLATED shape — the whole reason for the rule ─────
// The address is in a case table, twenty lines from any `it(` line.
{
code: `const CORPUS = [{ src: 'showcase/flows/index.ts:39', cel: 'a == b' }];
it.each(CORPUS)('adopts $src as structured rows', ({ cel }) => { expect(cel).toBeTruthy(); });`,
errors: [{ messageId: 'inEachCase', data: { address: 'showcase/flows/index.ts:39' } }],
},
// ...through a derivation, so a `.filter()` cannot hide a row.
{
code: `const CORPUS = [{ src: 'showcase/flows/index.ts:1696', parens: true }];
it.each(CORPUS.filter((c) => c.parens))('leaves $src on raw mode', () => {});`,
errors: [{ messageId: 'inEachCase', data: { address: 'showcase/flows/index.ts:1696' } }],
},
// ...and through a nested path, `$row.src`.
{
code: `const CASES = [{ row: { src: 'packages/react/README.md:224' } }];
it.each(CASES)('anchor for $row.src', () => {});`,
errors: [{ messageId: 'inEachCase', data: { address: 'packages/react/README.md:224' } }],
},
// A POSITIONAL title cannot say which field arrives, so the whole row is
// read — the safe direction, over-reporting rather than staying silent.
{
code: `it.each([['a', 'ListView.tsx:99']])('%s at %s', () => {});`,
errors: [{ messageId: 'inEachCase', data: { address: 'ListView.tsx:99' } }],
},

// ── Leg 3, "cannot tell" must not be spelled like "clean" ─────────────
{
code: `const ADDR = 'ObjectView.tsx:1848';
it.each(buildCases())('adopts $src', () => { use(ADDR); });`,
errors: [{ messageId: 'inUnresolvedTable', data: { address: 'ObjectView.tsx:1848' } }],
},
],
});
18 changes: 18 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,24 @@ export default tseslint.config({
rules: {
'object-ui/no-dynamic-import-in-test-hook': 'error',
},
}, {
// objectui#8047 ratchet — a `File.tsx:123` line address inside a test NAME is
// read by nothing: it is not an assertion, no gate parses it, the cited file
// is never opened. So it cannot fail, and it rots the first time a line is
// inserted above what it cites. objectui#7853 ruled the class (cite by
// CONTENT, not by line address) and five per-instance repairs followed it
// — #6548, #6998, #7289, #7913, #8045 — without closing it. Scoped to test
// files, because the property is "nothing reads a test name"; a line address
// in a comment, in an assertion message, or in data a test asserts ON is a
// human-read citation and is deliberately out of the population (the rule's
// own header carries the boundary and its measured false-positive cost).
// Error so a new one fails CI; the whole live population was converted in the
// same change, so this lints clean today.
files: ['**/*.test.{ts,tsx}', '**/__tests__/**/*.{ts,tsx}'],
plugins: { 'object-ui': objectUi },
rules: {
'object-ui/no-line-address-in-test-name': 'error',
},
}, {
// objectui#4045 ratchet — a `<button>` with no `type` is `type="submit"` per
// HTML, so it submits any <form> it is composed into instead of running its
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,23 +117,27 @@ function mount(value: string, vocab?: Record<string, unknown>) {
* parenthesised group. Extending the grammar is out of this card's scope, so
* those two are expected to stay on raw mode and are excluded from the
* round-trip denominator (counted separately, so the number stays honest). */
/* `src` names the FILE only. It carried a line address until objectui#8047:
* `$src` is spliced into the case name below, nothing reads a test name, and a
* line in another repository's example app moves without anything here going
* red. `$#` restores the identity the address was carrying. */
const CORPUS: Array<{ src: string; cel: string; parens?: true }> = [
{ src: 'showcase/dynamic-approval.flow.ts:29', cel: 'title != previous.title' },
{ src: 'showcase/flows/index.ts:39', cel: 'status == "done" && previous.status != "done"' },
{ src: 'showcase/flows/index.ts:153', cel: 'assignee != previous.assignee' },
{ src: 'showcase/flows/index.ts:212', cel: 'budget > 100000 && budget != previous.budget' },
{ src: 'showcase/flows/index.ts:325', cel: 'status == "done" && previous.status != "done"' },
{ src: 'showcase/flows/index.ts:438', cel: 'status == "done" && previous.status != "done"' },
{ src: 'showcase/flows/index.ts:691', cel: 'status == "done" && previous.status != "done"' },
{ src: 'showcase/flows/index.ts:808', cel: 'status == "completed" && previous.status != "completed"' },
{ src: 'showcase/flows/index.ts:924', cel: 'status == "done" && previous.status != "done"' },
{ src: 'showcase/flows/index.ts:1004', cel: 'status == "done" && previous.status != "done"' },
{ src: 'showcase/flows/index.ts:1099', cel: 'status == "sent" && previous.status != "sent"' },
{ src: 'showcase/flows/index.ts:1201', cel: 'health == "red" && previous.health != "red"' },
{ src: 'showcase/flows/index.ts:1532', cel: 'status == "submitted" && previous.status != "submitted"' },
{ src: 'showcase/flows/index.ts:1582', cel: 'status == "submitted" && previous.status != "submitted" && total_amount >= 5000' },
{ src: 'showcase/flows/index.ts:1696', cel: "priority == 'urgent' && (previous == null || previous.priority != 'urgent')", parens: true },
{ src: 'todo/task.flow.ts:183', cel: 'status == "completed" && previous.status != "completed"' },
{ src: 'showcase/dynamic-approval.flow.ts', cel: 'title != previous.title' },
{ src: 'showcase/flows/index.ts', cel: 'status == "done" && previous.status != "done"' },
{ src: 'showcase/flows/index.ts', cel: 'assignee != previous.assignee' },
{ src: 'showcase/flows/index.ts', cel: 'budget > 100000 && budget != previous.budget' },
{ src: 'showcase/flows/index.ts', cel: 'status == "done" && previous.status != "done"' },
{ src: 'showcase/flows/index.ts', cel: 'status == "done" && previous.status != "done"' },
{ src: 'showcase/flows/index.ts', cel: 'status == "done" && previous.status != "done"' },
{ src: 'showcase/flows/index.ts', cel: 'status == "completed" && previous.status != "completed"' },
{ src: 'showcase/flows/index.ts', cel: 'status == "done" && previous.status != "done"' },
{ src: 'showcase/flows/index.ts', cel: 'status == "done" && previous.status != "done"' },
{ src: 'showcase/flows/index.ts', cel: 'status == "sent" && previous.status != "sent"' },
{ src: 'showcase/flows/index.ts', cel: 'health == "red" && previous.health != "red"' },
{ src: 'showcase/flows/index.ts', cel: 'status == "submitted" && previous.status != "submitted"' },
{ src: 'showcase/flows/index.ts', cel: 'status == "submitted" && previous.status != "submitted" && total_amount >= 5000' },
{ src: 'showcase/flows/index.ts', cel: "priority == 'urgent' && (previous == null || previous.priority != 'urgent')", parens: true },
{ src: 'todo/task.flow.ts', cel: 'status == "completed" && previous.status != "completed"' },
{ src: '#6226 card body (HotCRM)', cel: 'contract_term_months > 24 && (previous == null || previous.contract_term_months <= 24)', parens: true },
];

Expand All @@ -142,7 +146,7 @@ const norm = (s: string) => s.replace(/\s+/g, ' ').trim();
describe('#6296 acceptance criterion — shipped flow-entry conditions round-trip into ROW mode', () => {
const roundTrippable = CORPUS.filter((c) => !c.parens);

it.each(roundTrippable)('adopts $src as structured rows', ({ cel }) => {
it.each(roundTrippable)('adopts $src (corpus row $#) as structured rows', ({ cel }) => {
const p = probe(mount(cel));
expect(p.mode).toBe('row');
// Structural, not textual: the rows exist as controls...
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,23 +186,27 @@ function mountField(opts: {
* to stay on raw mode and are held out of the round-trip denominator instead of
* flattering it.
*/
/* `src` names the FILE only. It carried a line address until objectui#8047:
* `$src` is spliced into the case name below, nothing reads a test name, and a
* line in another repository's example app moves without anything here going
* red. `$#` restores the identity the address was carrying. */
const CORPUS: Array<{ src: string; cel: string; parens?: true }> = [
{ src: 'showcase/dynamic-approval.flow.ts:29', cel: 'title != previous.title' },
{ src: 'showcase/flows/index.ts:39', cel: 'status == "done" && previous.status != "done"' },
{ src: 'showcase/flows/index.ts:153', cel: 'assignee != previous.assignee' },
{ src: 'showcase/flows/index.ts:212', cel: 'budget > 100000 && budget != previous.budget' },
{ src: 'showcase/flows/index.ts:325', cel: 'status == "done" && previous.status != "done"' },
{ src: 'showcase/flows/index.ts:438', cel: 'status == "done" && previous.status != "done"' },
{ src: 'showcase/flows/index.ts:691', cel: 'status == "done" && previous.status != "done"' },
{ src: 'showcase/flows/index.ts:808', cel: 'status == "completed" && previous.status != "completed"' },
{ src: 'showcase/flows/index.ts:924', cel: 'status == "done" && previous.status != "done"' },
{ src: 'showcase/flows/index.ts:1004', cel: 'status == "done" && previous.status != "done"' },
{ src: 'showcase/flows/index.ts:1099', cel: 'status == "sent" && previous.status != "sent"' },
{ src: 'showcase/flows/index.ts:1201', cel: 'health == "red" && previous.health != "red"' },
{ src: 'showcase/flows/index.ts:1532', cel: 'status == "submitted" && previous.status != "submitted"' },
{ src: 'showcase/flows/index.ts:1582', cel: 'status == "submitted" && previous.status != "submitted" && total_amount >= 5000' },
{ src: 'showcase/flows/index.ts:1696', cel: "priority == 'urgent' && (previous == null || previous.priority != 'urgent')", parens: true },
{ src: 'todo/task.flow.ts:183', cel: 'status == "completed" && previous.status != "completed"' },
{ src: 'showcase/dynamic-approval.flow.ts', cel: 'title != previous.title' },
{ src: 'showcase/flows/index.ts', cel: 'status == "done" && previous.status != "done"' },
{ src: 'showcase/flows/index.ts', cel: 'assignee != previous.assignee' },
{ src: 'showcase/flows/index.ts', cel: 'budget > 100000 && budget != previous.budget' },
{ src: 'showcase/flows/index.ts', cel: 'status == "done" && previous.status != "done"' },
{ src: 'showcase/flows/index.ts', cel: 'status == "done" && previous.status != "done"' },
{ src: 'showcase/flows/index.ts', cel: 'status == "done" && previous.status != "done"' },
{ src: 'showcase/flows/index.ts', cel: 'status == "completed" && previous.status != "completed"' },
{ src: 'showcase/flows/index.ts', cel: 'status == "done" && previous.status != "done"' },
{ src: 'showcase/flows/index.ts', cel: 'status == "done" && previous.status != "done"' },
{ src: 'showcase/flows/index.ts', cel: 'status == "sent" && previous.status != "sent"' },
{ src: 'showcase/flows/index.ts', cel: 'health == "red" && previous.health != "red"' },
{ src: 'showcase/flows/index.ts', cel: 'status == "submitted" && previous.status != "submitted"' },
{ src: 'showcase/flows/index.ts', cel: 'status == "submitted" && previous.status != "submitted" && total_amount >= 5000' },
{ src: 'showcase/flows/index.ts', cel: "priority == 'urgent' && (previous == null || previous.priority != 'urgent')", parens: true },
{ src: 'todo/task.flow.ts', cel: 'status == "completed" && previous.status != "completed"' },
{ src: '#6226 card body (HotCRM)', cel: 'contract_term_months > 24 && (previous == null || previous.contract_term_months <= 24)', parens: true },
];

Expand All @@ -220,7 +224,7 @@ describe('#6226 acceptance — the flow Entry condition opens in ROW mode', () =
expect(ROW_ELIGIBLE).toHaveLength(15);
});

it.each(ROW_ELIGIBLE)('adopts $src as structured rows AT THE FLOW FIELD', ({ cel }) => {
it.each(ROW_ELIGIBLE)('adopts $src (corpus row $#) as structured rows AT THE FLOW FIELD', ({ cel }) => {
const p = probe(mountField({ value: cel }));
expect(p.mode).toBe('row');
// The rows exist as controls…
Expand Down
Loading
Loading