diff --git a/.changeset/lucide-icon-record-name-gate.md b/.changeset/lucide-icon-record-name-gate.md new file mode 100644 index 0000000000..2a2f9ca90f --- /dev/null +++ b/.changeset/lucide-icon-record-name-gate.md @@ -0,0 +1,14 @@ +--- +--- + +Adds `check:icon-record-names`, a repo-level gate asserting that every authored icon +name reaching a resolver that reads lucide's runtime `icons` record is a live key of +that record. lucide retires a spelling by dropping it from that record while keeping +it as a deprecated named export, so a retired name still imports, still type-checks +and still renders as a component while resolving to nothing as a string — the class +behind objectui#5586 and objectui#5622, each of which left a local pin behind. The +gate judges against the record itself rather than any list of retired spellings, and +re-discovers the resolver population on every run. + +No published behaviour changes: the touched `src/` files carry comment updates only, +and the three repaired spellings are in example schemas and the docs playground. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5c6e6180d..be3d0e1d92 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -251,6 +251,25 @@ jobs: if: steps.relevant.outputs.should_run == 'true' run: pnpm check:action-forward-parity + # lucide retires a spelling by DROPPING IT FROM the runtime `icons` record + # while keeping it as a deprecated named export. A retired name therefore + # still imports, still type-checks and still renders wherever it is used as + # a COMPONENT (`Edit === SquarePen`, `Filter === Funnel`) — and resolves to + # nothing wherever it is used as a STRING, because the string lookups read + # that record. Nothing goes red in either direction, which is why it had to + # be repaired twice, in two packages, by two cards, each leaving a LOCAL pin + # behind (objectui#5586, objectui#5622). This gate replaces those pins with + # one predicate over the population, judged by the record itself and never + # by a list of retired spellings — a list would age the moment lucide + # retires the next name, and age silently. It also RE-DISCOVERS the + # resolvers on every run: its first pass found four record-reading resolvers + # objectui#5633's hand-built table did not know about. Reads sources with + # `typescript` and the installed lucide, so it needs the install and nothing + # built — same placement rationale as the step above. + - name: Verify authored icon names are live lucide `icons` keys + if: steps.relevant.outputs.should_run == 'true' + run: pnpm check:icon-record-names + # A key a component asks `t()` for must exist in the `en` pack. # `all-locales-key-parity.test.ts` compares packs to EACH OTHER, so ten # packs identically missing a key is full parity and full parity is green diff --git a/apps/site/app/playground/page.tsx b/apps/site/app/playground/page.tsx index 6ee89297c5..eb31cf0bfc 100644 --- a/apps/site/app/playground/page.tsx +++ b/apps/site/app/playground/page.tsx @@ -851,7 +851,7 @@ const EXAMPLE_SCHEMAS = { type: "button", variant: "outline", label: "Filter", - icon: "Filter" + icon: "Funnel" }, { type: "button", diff --git a/content/docs/guide/ci-cd-pipeline.md b/content/docs/guide/ci-cd-pipeline.md index 3ce204699b..83c80bd5c1 100644 --- a/content/docs/guide/ci-cd-pipeline.md +++ b/content/docs/guide/ci-cd-pipeline.md @@ -172,7 +172,7 @@ it green — which is how two of `type-check`'s gates came to be missing from th | Job key | Appears as | What it runs | When | |---|---|---|---| | `changeset-check` | Changeset Fixed Group Check | `scripts/check-changeset-fixed.mjs` — every workspace package must be in the changeset `fixed` group or explicitly ignored. It checks group *membership*; it does **not** check whether the PR added a changeset. | Every run | -| `type-check` | Type Check | `scripts/check-type-check-coverage.mjs`, then `pnpm check:phantom-deps`, then `pnpm check:self-import`, then `pnpm check:esm-specifiers`, then `pnpm check:spec-symbols`, then `pnpm check:action-forward-parity`, then `pnpm check:i18n-keys`, then `pnpm check:i18n-drift`, then `pnpm type-check:scripts`, then `pnpm type-check`, then `pnpm type-check:vitest-setup`. The coverage guard runs first because turbo silently skips packages that have no `type-check` script, so a package without one would otherwise read as passing (#2911). `pnpm check:phantom-deps` fails when a released package imports a bare specifier its own `package.json` does not declare — a *phantom dependency*, invisible locally because the workspace root's `devDependencies` sit on the upward resolution path from every package directory and on no consumer's, so `require.resolve('react', { paths: ['packages/core/src'] })` succeeds while `@object-ui/core` declares react in no field at all ([#4394](https://github.com/objectstack-ai/objectui/issues/4394)). `pnpm check:self-import` runs next because it reuses that gate's parser: it fails when a file inside a package names its OWN package, a specifier that resolves through the package's `exports` map to `dist/` while `type-check` waits on `^build` — the *dependencies'* builds, never the package's own — so on a cold cache the declarations do not exist yet and the file fails with `TS2307`. Locally it is always green, because every local workflow builds before it type-checks and leaves a `dist/` behind; PR #4789's first run was red on exactly one such line ([#4801](https://github.com/objectstack-ai/objectui/issues/4801)). `pnpm check:esm-specifiers` follows it for the same reason — sources only, no build: it fails when a published package whose build preserves import specifiers (a bare emitting `tsc`, which never rewrites them) writes a relative specifier with no file extension. Node's ESM resolver does not extension-search relative specifiers, so such a specifier makes the published entry unloadable outside a bundler; `@object-ui/react`'s entry died with `ERR_MODULE_NOT_FOUND` while every bundler-based consumer, the whole test suite and CI stayed green ([#4538](https://github.com/objectstack-ai/objectui/issues/4538)). The half that actually *imports* each built entry needs a full build and runs in `node-esm-load-gate.yml`. `pnpm check:action-forward-parity` fails when an action renderer's forward whitelist drops a key the action runtime reads — the class that shipped six times one key at a time, each time green, because the key parses and publishes while the payload is dropped one hop before the runner ([#4050](https://github.com/objectstack-ai/objectui/issues/4050)). The two locale gates sit in the middle because both parse the sources with `typescript`: they need the install and nothing built. `pnpm check:i18n-keys` fails when a `t()` call site asks for a key the `en` pack does not define ([#3530](https://github.com/objectstack-ai/objectui/issues/3530)); `pnpm check:i18n-drift` fails when a change to an `en` string is not accompanied by the nine translation packs ([#3650](https://github.com/objectstack-ai/objectui/issues/3650)), and it is why this job's checkout sets `fetch-depth: 0` — it diffs against the merge base, which a depth-1 clone cannot resolve. `pnpm type-check:scripts` (`tsconfig.scripts.json`) covers `scripts/**/*.ts`, which `pnpm type-check` cannot reach at all — `scripts/` has no package.json, so turbo never walks it, and the coverage guard decides coverage per *package*. Until [#3494](https://github.com/objectstack-ai/objectui/issues/3494) that left the pin tests in `scripts/__tests__/` — including the one pinning this very page — compiled by nothing. `pnpm type-check:vitest-setup` (`tsconfig.vitest-setup.json`) closes the same gap for the four repo-root `vitest.setup.*` files, uncovered until [#3515](https://github.com/objectstack-ai/objectui/issues/3515); it runs *last*, after `pnpm type-check`, because `vitest.setup.dom.tsx` side-effect-imports four `@object-ui/*` packages and resolves them through the declarations that turbo's `^build` produces. | Every run; on a PR the steps short-circuit when only ignored paths changed | +| `type-check` | Type Check | `scripts/check-type-check-coverage.mjs`, then `pnpm check:phantom-deps`, then `pnpm check:self-import`, then `pnpm check:esm-specifiers`, then `pnpm check:spec-symbols`, then `pnpm check:action-forward-parity`, then `pnpm check:icon-record-names`, then `pnpm check:i18n-keys`, then `pnpm check:i18n-drift`, then `pnpm type-check:scripts`, then `pnpm type-check`, then `pnpm type-check:vitest-setup`. The coverage guard runs first because turbo silently skips packages that have no `type-check` script, so a package without one would otherwise read as passing (#2911). `pnpm check:phantom-deps` fails when a released package imports a bare specifier its own `package.json` does not declare — a *phantom dependency*, invisible locally because the workspace root's `devDependencies` sit on the upward resolution path from every package directory and on no consumer's, so `require.resolve('react', { paths: ['packages/core/src'] })` succeeds while `@object-ui/core` declares react in no field at all ([#4394](https://github.com/objectstack-ai/objectui/issues/4394)). `pnpm check:self-import` runs next because it reuses that gate's parser: it fails when a file inside a package names its OWN package, a specifier that resolves through the package's `exports` map to `dist/` while `type-check` waits on `^build` — the *dependencies'* builds, never the package's own — so on a cold cache the declarations do not exist yet and the file fails with `TS2307`. Locally it is always green, because every local workflow builds before it type-checks and leaves a `dist/` behind; PR #4789's first run was red on exactly one such line ([#4801](https://github.com/objectstack-ai/objectui/issues/4801)). `pnpm check:esm-specifiers` follows it for the same reason — sources only, no build: it fails when a published package whose build preserves import specifiers (a bare emitting `tsc`, which never rewrites them) writes a relative specifier with no file extension. Node's ESM resolver does not extension-search relative specifiers, so such a specifier makes the published entry unloadable outside a bundler; `@object-ui/react`'s entry died with `ERR_MODULE_NOT_FOUND` while every bundler-based consumer, the whole test suite and CI stayed green ([#4538](https://github.com/objectstack-ai/objectui/issues/4538)). The half that actually *imports* each built entry needs a full build and runs in `node-esm-load-gate.yml`. `pnpm check:action-forward-parity` fails when an action renderer's forward whitelist drops a key the action runtime reads — the class that shipped six times one key at a time, each time green, because the key parses and publishes while the payload is dropped one hop before the runner ([#4050](https://github.com/objectstack-ai/objectui/issues/4050)). `pnpm check:icon-record-names` fails when an authored icon NAME that reaches a resolver reading lucide's runtime `icons` record is not a live key of that record. lucide retires a spelling by dropping it from that record while keeping it as a deprecated named export, so the retired name still imports, still type-checks and still renders wherever it is used as a *component* — `Edit === SquarePen` is true — and resolves to nothing wherever it is used as a *string*: nothing goes red in either direction, which is why the class was repaired twice in two packages before anyone gated it ([#5586](https://github.com/objectstack-ai/objectui/issues/5586), [#5622](https://github.com/objectstack-ai/objectui/issues/5622), [#5633](https://github.com/objectstack-ai/objectui/issues/5633)). It carries no list of retired spellings — the record itself is the judgement — and it re-discovers the resolver population from source on every run, which is how its first pass found four record-reading resolvers nobody had catalogued. It sits here because it parses the sources with `typescript` and reads the installed lucide: the install, and nothing built. The two locale gates sit in the middle because both parse the sources with `typescript`: they need the install and nothing built. `pnpm check:i18n-keys` fails when a `t()` call site asks for a key the `en` pack does not define ([#3530](https://github.com/objectstack-ai/objectui/issues/3530)); `pnpm check:i18n-drift` fails when a change to an `en` string is not accompanied by the nine translation packs ([#3650](https://github.com/objectstack-ai/objectui/issues/3650)), and it is why this job's checkout sets `fetch-depth: 0` — it diffs against the merge base, which a depth-1 clone cannot resolve. `pnpm type-check:scripts` (`tsconfig.scripts.json`) covers `scripts/**/*.ts`, which `pnpm type-check` cannot reach at all — `scripts/` has no package.json, so turbo never walks it, and the coverage guard decides coverage per *package*. Until [#3494](https://github.com/objectstack-ai/objectui/issues/3494) that left the pin tests in `scripts/__tests__/` — including the one pinning this very page — compiled by nothing. `pnpm type-check:vitest-setup` (`tsconfig.vitest-setup.json`) closes the same gap for the four repo-root `vitest.setup.*` files, uncovered until [#3515](https://github.com/objectstack-ai/objectui/issues/3515); it runs *last*, after `pnpm type-check`, because `vitest.setup.dom.tsx` side-effect-imports four `@object-ui/*` packages and resolves them through the declarations that turbo's `^build` produces. | Every run; on a PR the steps short-circuit when only ignored paths changed | | `test` | Test (shard N/4) | `pnpm test --shard=N/4` across a 4-runner matrix with `fail-fast: false`, so every shard reports its own failures. No coverage instrumentation — v8 adds 40–100% overhead. | Pull requests and merge-queue builds (everything but `push`); steps short-circuit on a PR that changed only ignored paths | | `test-coverage` | Test (coverage shard N/4) | `pnpm test:coverage --reporter=blob --shard=N/4` across a 4-runner matrix with `fail-fast: false`. Each shard writes `.vitest-reports/blob-N-4.json` — raw coverage and test results in one file — and uploads it as an artifact even when the shard is red, which is what makes a failing coverage run diagnosable at all (vitest deletes `coverage/` on a red run unless `coverage.reportOnFailure` is set, [#5402](https://github.com/objectstack-ai/objectui/issues/5402)). The configured coverage thresholds are neutralised on the shard legs, because a quarter of the suite judged against a whole-suite threshold is not a defect signal; they are enforced once, on the merged report, by the job below ([#5403](https://github.com/objectstack-ai/objectui/issues/5403)). | **Push only** | | `coverage-report` | Test (coverage) | Downloads the four blob reports, refuses to continue unless all four arrived, merges them with `pnpm test:coverage --merge-reports` into one complete report — which is where the configured coverage thresholds are enforced, over the whole merged map, the shard legs having overridden them to zero — and publishes that report as the `coverage-report` artifact (kept 7 days, the same as the blobs it is derived from). Its last step runs on every path and states the outcome: the job is **red, with an error annotation**, whenever the gate did not run for the commit — before [#5403](https://github.com/objectstack-ai/objectui/issues/5403) the final step carried the implicit `success()` and was silently skipped by 311 of 373 coverage jobs, which is how four days of a 100%-failing coverage job went unnoticed. A breach of the thresholds is reported *separately* from a lane that never delivered, because the two call for opposite actions. ⛔ It never merges a report from fewer than four shards: a wrong coverage number is worse than a missing one. The Codecov upload this job used to carry was retired by [#5436](https://github.com/objectstack-ai/objectui/issues/5436) — `CODECOV_TOKEN` was never set, so it failed on every push; the trend dashboard and PR coverage comments are gone with it, the gate is not. | **Push only** | diff --git a/examples/schema-catalog/src/schemas/components-overlay-dropdown-menu/with-icons.json b/examples/schema-catalog/src/schemas/components-overlay-dropdown-menu/with-icons.json index d110e97cf1..cac1ac1fd3 100644 --- a/examples/schema-catalog/src/schemas/components-overlay-dropdown-menu/with-icons.json +++ b/examples/schema-catalog/src/schemas/components-overlay-dropdown-menu/with-icons.json @@ -3,7 +3,7 @@ "trigger": { "type": "button", "label": "Actions", - "icon": "more-horizontal" + "icon": "ellipsis" }, "items": [ { diff --git a/examples/schema-catalog/src/schemas/plugin-grid/product-inventory-grid.json b/examples/schema-catalog/src/schemas/plugin-grid/product-inventory-grid.json index 6e54aff764..06962d6b06 100644 --- a/examples/schema-catalog/src/schemas/plugin-grid/product-inventory-grid.json +++ b/examples/schema-catalog/src/schemas/plugin-grid/product-inventory-grid.json @@ -21,7 +21,7 @@ "label": "Filter", "variant": "outline", "size": "sm", - "icon": "filter" + "icon": "funnel" }, { "type": "button", diff --git a/package.json b/package.json index 1d7a1dca8c..44d62640dd 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "type-check:vitest-setup": "tsc -p tsconfig.vitest-setup.json", "check:spec-symbols": "node scripts/check-spec-symbol-derivation.mjs", "check:action-forward-parity": "node scripts/check-action-forward-parity.mjs", + "check:icon-record-names": "node scripts/check-lucide-icon-record-names.mjs", "check:phantom-deps": "node scripts/check-phantom-dependencies.mjs", "check:self-import": "node scripts/check-package-self-import.mjs", "check:esm-specifiers": "node scripts/check-node-esm-load.mjs --specifiers-only", diff --git a/packages/components/src/__tests__/icon-renderer-declared-default.test.ts b/packages/components/src/__tests__/icon-renderer-declared-default.test.ts index 66da26836d..e16dd435f9 100644 --- a/packages/components/src/__tests__/icon-renderer-declared-default.test.ts +++ b/packages/components/src/__tests__/icon-renderer-declared-default.test.ts @@ -32,6 +32,16 @@ import '../renderers/basic/icon'; // // Read off the REGISTRY rather than out of source: the registry entry is the // artifact the designer palette actually consumes. +// +// ⚠️ NOT retired by objectui#5633's repo-level gate +// (`scripts/check-lucide-icon-record-names.mjs`), deliberately. That gate judges +// names reaching a resolver it can SEE reading the record, and this repository +// contains no first-party consumer of a registration's `icon` meta at all — +// measured: `getMeta(...).icon` is read nowhere under `packages/**` or `apps/**`. +// The palette that renders it lives outside this repo, so the claim this pin +// makes is one the gate has no measured basis to generalise. It also asserts +// something no membership check can: that the palette glyph and the dropped +// default stay the SAME name. // --------------------------------------------------------------------------- /** diff --git a/packages/plugin-detail/src/__tests__/DetailView.systemActionIconNames.test.ts b/packages/plugin-detail/src/__tests__/DetailView.systemActionIconNames.test.ts deleted file mode 100644 index c7d7468451..0000000000 --- a/packages/plugin-detail/src/__tests__/DetailView.systemActionIconNames.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -/** - * 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. - */ - -import { describe, it, expect } from 'vitest'; -import { existsSync, readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { icons } from 'lucide-react'; - -// --------------------------------------------------------------------------- -// objectui#5622 — a system action's icon NAME that stopped resolving draws a -// label with nothing beside it. -// -// `DetailView` builds its overflow items as an `action:bar` schema, and the -// `packages/components` action renderers turn each `icon` STRING into a -// component through `renderers/action/resolve-icon.ts`, which reads lucide's -// runtime `icons` record and returns `null` on a miss. lucide retires a -// spelling by DROPPING IT FROM THAT RECORD while keeping it as a deprecated -// named export — so a retired name still imports, still type-checks, and still -// renders anywhere it is used as a COMPONENT, and silently resolves to nothing -// here. That is how `icon: 'edit'` took the icon off the mobile Edit entry with -// nothing going red. -// -// ⚠️ MEMBERSHIP is what this pins, deliberately — not "does the name resolve to -// something". `Edit === SquarePen` is TRUE on the installed lucide: the retired -// alias is the very same component object under a dead name, so any assertion -// that reaches for the export, or renders the glyph and looks at it, passes on -// the broken spelling. Only absence from the record tells the two apart. -// -// The pin is over EVERY `icon:` literal this file supplies, not over the one -// that broke — a pin scoped to `edit` would not have caught this and would not -// catch the next lucide bump. -// --------------------------------------------------------------------------- - -/** - * Read a sibling source file, from the repo root or from the package directory. - * - * NOT `new URL('../x', import.meta.url)`: Vite rewrites `import.meta.url` to a - * SERVER-ROOT-relative path, which is ENOENT for the whole suite. Same pair as - * `plugin-view/src/__tests__/ViewSwitcher.test.tsx` uses, for the same reason; - * the first candidate is the canonical repo-root invocation (objectui#3378). - */ -function readSibling(file: string): string { - const candidates = [ - resolve(process.cwd(), 'packages/plugin-detail/src', file), - resolve(process.cwd(), 'src', file), - ]; - const found = candidates.find(candidate => existsSync(candidate)); - if (!found) { - throw new Error( - `cannot locate plugin-detail/src/${file} from ${process.cwd()} — tried:\n ${candidates.join('\n ')}`, - ); - } - return readFileSync(found, 'utf8'); -} - -/** - * `[action name, icon name]` for every system action item pushed in - * `DetailView.tsx`, read out of source: the items are built inside a `useMemo` - * in a component that needs a whole console host to render, and exporting them - * for a test would widen the package's surface. A parse that quietly found - * nothing is caught by the precondition test below. - */ -function parseSystemActionIcons(source: string): Array<[string, string]> { - return source - .split('items.push({') - .slice(1) - .map(chunk => { - const name = /^\s*name:\s*'([\w-]+)'/m.exec(chunk); - const icon = /^\s*icon:\s*'([\w-]+)'/m.exec(chunk); - return name && icon ? ([name[1], icon[1]] as [string, string]) : null; - }) - .filter((entry): entry is [string, string] => entry !== null); -} - -/** - * The transform `resolve-icon.ts` applies before its record lookup, copied - * because it is module-private there. Copied EXACTLY: a pin that normalised - * names differently from the consumer would answer a question nobody asks. - */ -function toPascalCase(str: string): string { - return str - .split('-') - .map(word => word.charAt(0).toUpperCase() + word.slice(1)) - .join(''); -} -const iconNameMap: Record = { Home: 'House' }; -const recordKeyFor = (name: string): string => { - const pascal = toPascalCase(name); - return iconNameMap[pascal] ?? pascal; -}; - -const SYSTEM_ACTION_ICONS = parseSystemActionIcons(readSibling('DetailView.tsx')); - -describe('every icon name DetailView supplies is a live `icons` key (objectui#5622)', () => { - it('the source read found the system action items — the precondition for "every"', () => { - // A parse that found nothing would leave the assertion below vacuously - // green, which is the failure mode a source-reading pin invites. - expect( - SYSTEM_ACTION_ICONS.length, - 'cannot read the `items.push({ … })` system actions out of DetailView.tsx — the block moved\n' - + 'or was restructured. Fix the reader; do not delete the pin.', - ).toBeGreaterThanOrEqual(3); - - expect( - SYSTEM_ACTION_ICONS.map(([name]) => name), - 'the reader no longer reaches the mobile Edit action — the entry objectui#5622 repaired.', - ).toContain('sys_edit_mobile'); - }); - - it('names only live `icons` keys', () => { - const retired = SYSTEM_ACTION_ICONS.filter( - ([, icon]) => !Object.prototype.hasOwnProperty.call(icons, recordKeyFor(icon)), - ); - - expect( - retired, - 'These `DetailView` action icons name spellings that are NOT keys of lucide\'s runtime\n' - + '`icons` record — i.e. deprecated aliases. `resolve-icon.ts` reads that record and returns\n' - + '`null` on a miss, so each of these draws a label with NO icon. They still import and\n' - + 'type-check wherever they are used as components, so nothing else goes red. Replace each\n' - + 'with the spelling the record carries (objectui#5622).', - ).toEqual([]); - }); - - it('rejects a name the record does not carry — the control', () => { - // Same record, same membership predicate, same `recordKeyFor` transform as - // the assertion above, so it fails on exactly what that one passes on: - // without it, "no icon is missing from `icons`" would hold just as well if - // the predicate said yes to everything. - expect( - Object.prototype.hasOwnProperty.call(icons, recordKeyFor('no-such-lucide-icon')), - ).toBe(false); - }); - - it('rejects a name lucide keeps ONLY as a deprecated export — the control that matters', () => { - // The control above would also pass against a predicate that merely asked - // "is this importable from lucide-react". `Edit` is: it imports, it - // type-checks, and it IS `SquarePen` — the same object under a dead name. - // Membership is the only thing that separates them, and this is the exact - // spelling that shipped broken. - expect(Object.prototype.hasOwnProperty.call(icons, 'Edit')).toBe(false); - }); -}); diff --git a/packages/plugin-list/src/ViewSwitcher.tsx b/packages/plugin-list/src/ViewSwitcher.tsx index 67fc6d81d5..517ed93e77 100644 --- a/packages/plugin-list/src/ViewSwitcher.tsx +++ b/packages/plugin-list/src/ViewSwitcher.tsx @@ -52,8 +52,9 @@ export interface ViewSwitcherProps { // spelling that is dead for the LOOKUP must not get to look alive in a map and // be copied into a string map next to it — which is exactly how `bar-chart-3` // reached `plugin-view`'s producer map (objectui#5586, same three aliases in -// the sibling switcher's `DEFAULT_VIEW_ICONS`). Pinned by -// `__tests__/ViewSwitcher.iconNames.test.ts`. +// the sibling switcher's `DEFAULT_VIEW_ICONS`). Judged by the repo-level gate +// `scripts/check-lucide-icon-record-names.mjs` (objectui#5633), which replaced +// this package's local pin with one predicate over every such map. // // `gantt` is the one real glyph change of the three: `GanttChartSquare` and // `ChartGantt` are DIFFERENT objects (the identity-preserving live spelling is diff --git a/packages/plugin-list/src/__tests__/ViewSwitcher.iconNames.test.ts b/packages/plugin-list/src/__tests__/ViewSwitcher.iconNames.test.ts deleted file mode 100644 index 50e7a97944..0000000000 --- a/packages/plugin-list/src/__tests__/ViewSwitcher.iconNames.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * 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. - */ - -import { describe, it, expect } from 'vitest'; -import { existsSync, readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { icons } from 'lucide-react'; - -// --------------------------------------------------------------------------- -// objectui#5622 — `VIEW_ICONS` names imported COMPONENTS, so a retired lucide -// alias goes on rendering here and nothing goes red. That is precisely why it -// needs a pin rather than a render test. -// -// lucide retires a spelling by dropping it from its runtime `icons` record -// while KEEPING it as a deprecated named export. `Grid`, `GanttChartSquare` and -// `BarChart3` all imported, all type-checked, and all three were absent from -// that record. Nothing on screen was wrong — but the sibling switcher in -// `packages/plugin-view` supplies the SAME glyphs as STRINGS, which are looked -// up in exactly that record, and a spelling that looks alive in a component map -// is how `bar-chart-3` was copied into the string map next to it (objectui#5586). -// -// So: MEMBERSHIP of the record, not resolvability of the import. `Grid === -// Grid3x3` and `BarChart3 === ChartColumn` are both TRUE on the installed -// lucide — the retired alias is the same object under a dead name, and any -// assertion that renders the glyph or reaches for the export passes on the -// broken spelling. -// --------------------------------------------------------------------------- - -/** - * Read a sibling source file, from the repo root or from the package directory. - * NOT `new URL('../x', import.meta.url)` — Vite rewrites `import.meta.url` to a - * server-root-relative path, ENOENT for the whole suite. Same pair as - * `plugin-view/src/__tests__/ViewSwitcher.test.tsx`; the first candidate is the - * canonical repo-root invocation (objectui#3378). - */ -function readSibling(file: string): string { - const candidates = [ - resolve(process.cwd(), 'packages/plugin-list/src', file), - resolve(process.cwd(), 'src', file), - ]; - const found = candidates.find(candidate => existsSync(candidate)); - if (!found) { - throw new Error( - `cannot locate plugin-list/src/${file} from ${process.cwd()} — tried:\n ${candidates.join('\n ')}`, - ); - } - return readFileSync(found, 'utf8'); -} - -/** - * `[view type, lucide identifier]` for every entry of the `VIEW_ICONS` map, - * read out of source — the map is module-private and stays that way. Values are - * JSX elements (``), so the identifier is the - * element's tag. - */ -function parseViewIconComponents(source: string): Array<[string, string]> { - const declaration = 'const VIEW_ICONS: Record = {'; - const start = source.indexOf(declaration); - if (start === -1) return []; - const open = start + declaration.length; - const close = source.indexOf('};', open); - if (close === -1) return []; - return [...source.slice(open, close).matchAll(/^\s*(\w+)\s*:\s*<(\w+)\b/gm)].map( - m => [m[1], m[2]] as [string, string], - ); -} - -/** - * Every member of `ViewType`, spelled out rather than inferred — adding a - * member without extending this list is a failure here, the same guard - * `VIEW_ICONS` gets from being annotated `Record`. - */ -const ALL_VIEW_TYPES = [ - 'grid', - 'kanban', - 'gallery', - 'calendar', - 'timeline', - 'gantt', - 'map', - 'chart', - 'tree', -]; - -const VIEW_ICON_COMPONENTS = parseViewIconComponents(readSibling('ViewSwitcher.tsx')); - -describe('every icon plugin-list\'s ViewSwitcher supplies is a live `icons` key (objectui#5622)', () => { - it('the source read found a real, TOTAL map — the precondition for "every"', () => { - // A parse that quietly found nothing would leave the assertion below - // vacuously green. The key-set comparison carries the totality claim too: - // the map is `Record`, so the compiler will not let a view - // type land without an entry — re-annotated to `Record`, "every - // icon" would shrink to "every icon someone remembered". - expect( - VIEW_ICON_COMPONENTS.map(([type]) => type).sort(), - 'cannot read `VIEW_ICONS` out of ViewSwitcher.tsx — the declaration moved, was re-annotated,\n' - + 'or no longer covers every ViewType. Fix the reader or the map; do not delete the pin.', - ).toEqual([...ALL_VIEW_TYPES].sort()); - }); - - it('names only live `icons` keys', () => { - const retired = VIEW_ICON_COMPONENTS.filter( - ([, ident]) => !Object.prototype.hasOwnProperty.call(icons, ident), - ); - - expect( - retired, - 'These `VIEW_ICONS` entries name lucide exports that are NOT keys of the runtime `icons`\n' - + 'record — i.e. deprecated aliases. They keep rendering, so nothing else goes red, and a\n' - + 'spelling that is dead for the lookup gets copied into a string map next to it. Replace\n' - + 'each with the name the record carries (objectui#5622).', - ).toEqual([]); - }); - - it('rejects an identifier the record does not carry — the control', () => { - // Same record, same membership predicate as the assertion above, so it - // fails on exactly what that one passes on. - expect(Object.prototype.hasOwnProperty.call(icons, 'NoSuchLucideIcon')).toBe(false); - }); - - it('rejects the three spellings that shipped here — the control that matters', () => { - // The control above would also pass against a predicate that merely asked - // "is this importable from lucide-react". All three of these are: they - // import, they type-check, and two of them ARE the replacement object under - // a dead name. Membership is the only thing that separates them. - for (const retired of ['Grid', 'GanttChartSquare', 'BarChart3']) { - expect( - Object.prototype.hasOwnProperty.call(icons, retired), - `\`${retired}\` is back in the runtime record — re-check the repair before relaxing this.`, - ).toBe(false); - } - }); -}); diff --git a/packages/plugin-view/src/ViewSwitcher.tsx b/packages/plugin-view/src/ViewSwitcher.tsx index 53822e03e4..37f5ba478c 100644 --- a/packages/plugin-view/src/ViewSwitcher.tsx +++ b/packages/plugin-view/src/ViewSwitcher.tsx @@ -79,7 +79,9 @@ const DEFAULT_VIEW_LABELS: Record = { // exactly that record: there a deprecated spelling resolves to nothing and the // view renders with no icon at all. Keeping this map on record-live names keeps // the two halves comparable and keeps a dead spelling from being copied back -// into the string map. Both maps are pinned in `ViewSwitcher.test.tsx`. +// into the string map. This map's membership is judged by the repo-level gate +// `scripts/check-lucide-icon-record-names.mjs` (objectui#5633); the string map +// beside it is judged there too and rendered in `ViewSwitcher.test.tsx`. const DEFAULT_VIEW_ICONS: Record = { list: List, detail: FileText, diff --git a/packages/plugin-view/src/__tests__/ViewSwitcher.test.tsx b/packages/plugin-view/src/__tests__/ViewSwitcher.test.tsx index 5a02d0a85a..803efbd9ab 100644 --- a/packages/plugin-view/src/__tests__/ViewSwitcher.test.tsx +++ b/packages/plugin-view/src/__tests__/ViewSwitcher.test.tsx @@ -10,7 +10,6 @@ import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; import { existsSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; -import { icons } from 'lucide-react'; import { ViewSwitcher } from '../ViewSwitcher'; import type { ViewSwitcherSchema, ViewType } from '@object-ui/types'; @@ -94,11 +93,11 @@ const readSibling = (file: string): string => { }; /** - * The `key: value` pairs of one named const object literal, read out of source: - * a quoted string for the icon-NAME map, a bare identifier for the - * icon-COMPONENT map. Read from source because both maps are module-private and - * stay that way — exporting them would widen the package's surface for the sake - * of a test. A parse that finds nothing is caught by the precondition test. + * The `key: value` pairs of one named const object literal, read out of source + * — here the quoted strings of `ObjectView`'s icon-NAME map. Read from source + * because the map is module-private and stays that way: exporting it would + * widen the package's surface for the sake of a test. A parse that finds + * nothing is caught by the precondition test. */ function parseMapEntries(source: string, declaration: string): Array<[string, string]> { const start = source.indexOf(declaration); @@ -170,11 +169,13 @@ describe('ViewSwitcher default view labels and icons', () => { // took the `chart` and `gantt` icons out of the switcher with nothing going // red: `packages/plugin-view` names the same glyphs both ways. // -// So the pin is over EVERY name the two maps supply, not over the two that -// happened to break — a pin scoped to those two would not have caught this bug -// and would not catch the next bump. Both maps are annotated -// `Record`, so their key set IS the union and this coverage widens -// by itself when a view type is added. +// ⚠️ The MEMBERSHIP half of this pin was retired by objectui#5633 and lives in +// `scripts/check-lucide-icon-record-names.mjs`, which judges `iconMap` and +// `DEFAULT_VIEW_ICONS` — plus the other six record-reading resolvers this +// package's local pin could never see — against the same record with the same +// predicate. What stays here is what the gate does NOT do: RENDER this +// component and look. A membership check cannot see an icon slot that stopped +// being rendered at all, and that is the other half of "renders nothing". // --------------------------------------------------------------------------- /** `ObjectView`'s producer map: view type → icon NAME, resolved at render time. */ @@ -183,18 +184,12 @@ const HOST_ICON_NAMES = parseMapEntries( 'const iconMap: Record = {', ); -/** `ViewSwitcher`'s own fallback map: view type → icon COMPONENT, imported by name. */ -const DEFAULT_ICON_COMPONENTS = parseMapEntries( - readSibling('ViewSwitcher.tsx'), - 'const DEFAULT_VIEW_ICONS: Record = {', -); - describe('every icon name plugin-view supplies still resolves (objectui#5586)', () => { - it('both source reads found a real, TOTAL map — the precondition for "every"', () => { + it('the source read found a real, TOTAL map — the precondition for "every"', () => { // A parse that quietly found nothing would leave every assertion below // vacuously green, which is the failure mode a widened pin invites. The - // key-set comparison carries the totality claim too: both maps are - // `Record`, so the compiler will not let a view type land + // key-set comparison carries the totality claim too: `iconMap` is annotated + // `Record`, so the compiler will not let a view type land // without an entry. Re-annotated to `Record`, "every name" // would quietly shrink to "every name someone remembered". expect( @@ -202,10 +197,6 @@ describe('every icon name plugin-view supplies still resolves (objectui#5586)', 'cannot read `iconMap` out of ObjectView.tsx — the declaration moved, was re-annotated, or\n' + 'no longer covers every ViewType. Fix the reader or the map; do not delete the pin.', ).toEqual([...ALL_VIEW_TYPES].sort()); - expect( - DEFAULT_ICON_COMPONENTS.map(([type]) => type).sort(), - 'cannot read `DEFAULT_VIEW_ICONS` out of ViewSwitcher.tsx — same reading.', - ).toEqual([...ALL_VIEW_TYPES].sort()); }); it('renders an icon for every name `ObjectView` supplies', () => { @@ -239,31 +230,4 @@ describe('every icon name plugin-view supplies still resolves (objectui#5586)', expect(button, 'no button rendered for the control view').toBeDefined(); expect(button!.querySelector('svg')).toBeNull(); }); - - it('names only live `icons` keys in `DEFAULT_VIEW_ICONS`', () => { - // The other half of the same class, and NOT implied by the render test - // above: these entries are imported components, so a retired alias goes on - // rendering here while the string beside it in `iconMap` renders nothing. - // Record membership is what keeps the two halves from drifting — a - // spelling that is dead for the lookup does not get to look alive in the - // defaults and be copied back into the string map, which is how - // `bar-chart-3` and `gantt-chart` got there. - const retired = DEFAULT_ICON_COMPONENTS.filter( - ([, ident]) => !Object.prototype.hasOwnProperty.call(icons, ident), - ); - - expect( - retired, - 'These `DEFAULT_VIEW_ICONS` entries name lucide exports that are NOT keys of the runtime\n' - + '`icons` record — i.e. deprecated aliases. They keep rendering, so nothing else goes red.\n' - + 'Replace each with the name the record carries (objectui#5586).', - ).toEqual([]); - }); - - it('rejects an identifier the record does not carry — the control', () => { - // Same record, same membership predicate as the assertion above: without - // it, "no entry is missing from `icons`" would also pass if the predicate - // said yes to everything. - expect(Object.prototype.hasOwnProperty.call(icons, 'NoSuchLucideIcon')).toBe(false); - }); }); diff --git a/scripts/__tests__/check-lucide-icon-record-names.test.ts b/scripts/__tests__/check-lucide-icon-record-names.test.ts new file mode 100644 index 0000000000..6c7f34b9c6 --- /dev/null +++ b/scripts/__tests__/check-lucide-icon-record-names.test.ts @@ -0,0 +1,441 @@ +import { afterAll, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + ANCHORED_MAPS, + DECLARED_DYNAMIC_READERS, + DECLARED_RECORD_READERS, + DISCOVERY_NEGATIVE_CONTROL, + RECORD_READING_TYPES, + analyze, + describeName, + icons, + iconNames, + isLiveKey, + liveSpellingFor, + selfTest, + toRecordKey, +} from '../check-lucide-icon-record-names.mjs'; + +/** + * objectui#5633 — an authored `icon:` literal reaching a record-reading lucide + * resolver must be a live key of the runtime `icons` record. + * + * The class is invisible in BOTH directions. lucide retires a spelling by + * dropping it from that record while keeping it as a deprecated named export, + * so the retired name still imports, still type-checks, and still renders where + * it is used as a COMPONENT — `Edit === SquarePen` and `Filter === Funnel` are + * both TRUE — and resolves to `null` where it is used as a STRING. It had been + * repaired twice, in two packages, by two cards, each leaving a LOCAL pin + * behind (objectui#5586, objectui#5622). + * + * What this file pins, in the order the gate can go wrong: + * + * 1. **The instrument is not blind.** This whole card exists because the + * current tooling reports nothing; a probe that also reports nothing reads + * as green. So the predicate is shown REJECTING the exact species it is + * for, before any silence of it is quoted. + * 2. **The discriminating pin and its two controls**, over throwaway trees: + * a retired-but-exported name goes red naming the site; a live name at the + * same site goes green AND is shown to have been judged; a name that is not + * a lucide export at all goes red for a visibly DIFFERENT reason. + * 3. **It is not a blanket string scan.** The same retired name on a node + * whose `type` is not a censused record-reading renderer is declined, not + * flagged. A gate that flagged those is a gate that gets suppressed. + * 4. **The census is measured, not remembered** — an undeclared resolver and a + * declared-but-vanished one both fail — and discovery matches the IMPORT, + * not the name. + * 5. **The anchors cannot collapse quietly.** A short read is an error, not + * zero violations. + * 6. **This repository is green, with non-zero counters**, so green is a + * judgement rather than a walk that found nothing. + * 7. **The gate is wired**, and the local pins it replaced are gone. + */ +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const GATE = 'scripts/check-lucide-icon-record-names.mjs'; + +/** + * THE repository scan — computed exactly once. It is a full TypeScript parse of + * every source under `packages/`, `apps/` and `examples/`; running it inside + * each `it()` would multiply that cost by the number of assertions. + */ +const repoResult = analyze(repoRoot); + +// ── fixture trees ──────────────────────────────────────────────────────────── + +const fixtures: string[] = []; +afterAll(() => { + for (const dir of fixtures) fs.rmSync(dir, { recursive: true, force: true }); +}); + +/** + * A record-reading resolver, spelled the way the real ones are. Every fixture + * carries one: `analyze` treats "no record-reading resolver discovered at all" + * as an error precisely because a scan that finds none makes every other + * verdict vacuous, and a fixture without one would be testing that error + * instead of what it means to test. + */ +const RESOLVER_FILE = 'packages/fixture-widgets/src/resolve-icon.ts'; +const RESOLVER_SOURCE = [ + "import { icons, type LucideIcon } from 'lucide-react';", + 'export function resolveIcon(name: string): LucideIcon | null {', + " return (icons as Record)[name] ?? null;", + '}', +].join('\n'); + +interface FixtureOptions { + /** Extra `path -> body` files on top of the resolver. */ + files: Record; + anchors?: typeof ANCHORED_MAPS; + declaredRecordReaders?: string[]; + declaredDynamicReaders?: string[]; + negativeControl?: string; +} + +function fixtureRepo(label: string, files: Record): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), `icon-record-${label}-`)); + fixtures.push(root); + for (const [rel, body] of Object.entries({ [RESOLVER_FILE]: RESOLVER_SOURCE, ...files })) { + fs.mkdirSync(path.join(root, path.dirname(rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), body); + } + return root; +} + +function judge(label: string, options: FixtureOptions) { + const root = fixtureRepo(label, options.files); + return analyze(root, { + anchors: options.anchors ?? [], + declaredRecordReaders: options.declaredRecordReaders ?? [RESOLVER_FILE], + declaredDynamicReaders: options.declaredDynamicReaders ?? [], + negativeControl: options.negativeControl, + }); +} + +/** One authored `ui:button` node in a TS module — the shape that broke live. */ +const buttonModule = (icon: string): string => [ + "export const schema = {", + " type: 'button',", + " label: 'Filter',", + ` icon: '${icon}',`, + '};', +].join('\n'); + +// ── 1. the instrument is not blind ─────────────────────────────────────────── + +describe('the instrument can see the distinction it claims to judge', () => { + it('passes its own self-test on the installed lucide', () => { + expect(selfTest()).toEqual([]); + }); + + it('the two vocabularies differ in exactly the way every verdict here depends on', () => { + // Not decoration. If the dynamic list ever stopped being a superset — or if + // these names came back into the record — every "this is retired" verdict + // below would be true of nothing, and the suite would still be green while + // checking nothing at all. + expect(iconNames.length).toBeGreaterThan(Object.keys(icons).length); + for (const retired of ['edit', 'smile', 'filter', 'more-horizontal', 'alert-triangle']) { + expect(iconNames, `\`${retired}\` left the dynamic vocabulary`).toContain(retired); + expect(isLiveKey(retired), `\`${retired}\` is back in the runtime record`).toBe(false); + } + for (const live of ['square-pen', 'funnel', 'ellipsis', 'face-slightly-smiling']) { + expect(isLiveKey(live), `\`${live}\` is not in the runtime record`).toBe(true); + } + }); + + it('rejects a retired alias even though it is the SAME OBJECT as its live spelling', () => { + // The reason membership is the predicate and resolvability is not: any + // assertion that reached for the export, or rendered the glyph and looked, + // would pass on the broken spelling. + const live = liveSpellingFor('filter'); + expect(live?.kebab).toBe('funnel'); + expect(isLiveKey('filter')).toBe(false); + expect(isLiveKey('funnel')).toBe(true); + }); + + it('applies the resolvers\' own `Home` -> `House` alias rather than bypassing it', () => { + // Five of the eight censused resolvers carry this map. Judging `home` dead + // would be a violation none of them would ever produce. + expect(toRecordKey('home')).toBe('House'); + expect(isLiveKey('home')).toBe(true); + }); +}); + +// ── 2. the discriminating pin, and its two controls ────────────────────────── + +describe('an authored icon name reaching a record-reading resolver', () => { + it('goes RED on a retired-but-still-exported spelling, naming the site and the name', () => { + const result = judge('retired', { files: { 'packages/app/src/toolbar.ts': buttonModule('filter') } }); + + expect(result.errors).toEqual([]); + expect(result.violations).toHaveLength(1); + const [violation] = result.violations; + expect(violation.where).toBe('packages/app/src/toolbar.ts:4'); + expect(violation.site).toBe('button'); + expect(violation.resolver).toBe(RECORD_READING_TYPES.button.resolver); + expect(violation.detail).toContain('"filter"'); + expect(violation.detail).toContain('`Filter`'); + // The replacement is DERIVED by object identity from the record, never read + // off a list this gate maintains. + expect(violation.detail).toContain('write `funnel`'); + }); + + it('goes GREEN on a live name at the SAME site — and the name was really judged', () => { + const result = judge('live', { files: { 'packages/app/src/toolbar.ts': buttonModule('funnel') } }); + + expect(result.violations).toEqual([]); + expect(result.errors).toEqual([]); + // Without this, "no violations" would read identically to a walk that never + // reached the file. + expect(result.counters.authoredJudged).toBe(1); + }); + + it('goes RED for a visibly DIFFERENT reason on a name lucide does not export at all', () => { + const result = judge('unknown', { files: { 'packages/app/src/toolbar.ts': buttonModule('no-such-lucide-icon') } }); + + expect(result.violations).toHaveLength(1); + expect(result.violations[0].detail).toContain('is not a lucide icon at all'); + // The two diagnoses must not collapse into one: "retired alias, write X" + // and "never existed" call for different repairs. + expect(result.violations[0].detail).not.toContain('DEPRECATED EXPORT'); + expect(describeName('filter')).toContain('DEPRECATED EXPORT'); + }); + + it('judges authored JSON with the same predicate, including a child array path', () => { + const result = judge('json', { + files: { + 'examples/catalog/toolbar.json': JSON.stringify({ + type: 'action:bar', + actions: [{ name: 'a', icon: 'square-pen' }, { name: 'b', icon: 'edit' }], + }, null, 2), + }, + }); + + expect(result.counters.authoredJudged).toBe(2); + expect(result.violations).toHaveLength(1); + expect(result.violations[0].where).toBe('examples/catalog/toolbar.json $.actions[1].icon'); + expect(result.violations[0].detail).toContain('write `square-pen`'); + }); +}); + +// ── 3. it is not a blanket string scan ─────────────────────────────────────── + +describe('a name whose resolver this gate cannot identify is declined, not flagged', () => { + it('leaves the same retired spelling alone on an untyped and a non-censused node', () => { + // Both shapes are live in this repository: Tailwind tone maps keyed `icon`, + // and catalog child items under `button-group`/`breadcrumb`/`command` + // (three of which never read `icon`, and a fourth that renders it as text). + // A gate that flagged these would be suppressed on day one, and then it + // would catch nothing at all. + const result = judge('declined', { + files: { + 'packages/app/src/tones.ts': "export const tone = { icon: 'text-amber-500' };", + 'packages/app/src/other.ts': "export const node = { type: 'text', icon: 'filter' };", + 'examples/catalog/items.json': JSON.stringify({ type: 'breadcrumb', items: [{ icon: 'layout' }] }, null, 2), + }, + }); + + expect(result.violations).toEqual([]); + expect(result.counters.authoredJudged).toBe(0); + // …and it SAW them — silence here is a decision, not a miss. + expect(result.counters.authoredDeclined).toBe(3); + }); +}); + +// ── 4. the census is measured, not remembered ──────────────────────────────── + +describe('the surface census is re-derived on every run', () => { + it('fails on a record-reading resolver nobody declared', () => { + const result = judge('undeclared', { + files: { + 'packages/app/src/sneaky.tsx': [ + "import { icons } from 'lucide-react';", + 'export const pick = (name: string) => (icons as any)[name];', + ].join('\n'), + }, + }); + + expect(result.violations).toEqual([]); + expect(result.errors.join('\n')).toContain('UNDECLARED record-reading resolver: packages/app/src/sneaky.tsx'); + }); + + it('fails on a declared entry that no longer reads the record', () => { + const result = judge('stale', { + files: {}, + declaredRecordReaders: [RESOLVER_FILE, 'packages/app/src/gone.ts'], + }); + + expect(result.errors.join('\n')).toContain('STALE record-reading resolver census entry: packages/app/src/gone.ts'); + }); + + it('separates the DYNAMIC vocabulary from the record one', () => { + // Getting this backwards is worse than having no gate: the dynamic list + // still carries `edit`, so a gate pointed at it would bless the exact names + // this class is about. + const result = judge('dynamic', { + files: { + 'packages/app/src/lazy.ts': [ + "import { iconNames } from 'lucide-react/dynamic.mjs';", + 'export const known = new Set(iconNames as string[]);', + ].join('\n'), + }, + declaredDynamicReaders: ['packages/app/src/lazy.ts'], + }); + + expect(result.errors).toEqual([]); + expect(result.discovered.dynamic).toEqual(['packages/app/src/lazy.ts']); + expect(result.discovered.record).toEqual([RESOLVER_FILE]); + }); + + it('matches the IMPORT, not the name — a local `icons` object is not a resolver', () => { + // The blind-probe control for discovery. `plugin-chatbot/src/elements/tool.tsx` + // is the live specimen: it builds its own `icons` map of ReactNodes and + // indexes it by tool state. + const result = judge('local-icons', { + files: { + 'packages/app/src/status.tsx': [ + "import { CircleIcon } from 'lucide-react';", + 'const icons: Record = { ok: CircleIcon };', + 'export const pick = (state: string) => icons[state];', + ].join('\n'), + }, + }); + + expect(result.discovered.record).toEqual([RESOLVER_FILE]); + expect(result.errors).toEqual([]); + }); +}); + +// ── 5. the anchors cannot collapse quietly ─────────────────────────────────── + +describe('an anchored first-party map', () => { + const mapModule = (name: string, first: string): string => [ + "import type { LucideIcon } from 'lucide-react';", + `const ${name}: Record = {`, + ` a: ${first},`, + ' b: ChartColumn,', + ' c: SquarePen,', + '};', + `export default ${name};`, + ].join('\n'); + + it('flags a retired IDENTIFIER sitting in a component map', () => { + const result = judge('anchor-red', { + files: { 'packages/app/src/icons.ts': mapModule('VIEW_ICONS', 'BarChart3') }, + anchors: [{ file: 'packages/app/src/icons.ts', anchor: 'VIEW_ICONS', kind: 'identifiers', min: 3, why: 'fixture' }], + }); + + expect(result.errors).toEqual([]); + expect(result.violations).toHaveLength(1); + expect(result.violations[0].detail).toContain('write `chart-column`'); + expect(result.counters.anchoredJudged).toBe(3); + }); + + it('ERRORS rather than passing when the reader comes up short', () => { + // The failure mode a source-reading pin invites: the declaration is + // renamed, the extractor finds nothing, and "zero violations" reads exactly + // like a clean map. + const result = judge('anchor-drift', { + files: { 'packages/app/src/icons.ts': mapModule('RENAMED_ICONS', 'ChartColumn') }, + anchors: [{ file: 'packages/app/src/icons.ts', anchor: 'VIEW_ICONS', kind: 'identifiers', min: 3, why: 'fixture' }], + }); + + expect(result.violations).toEqual([]); + expect(result.errors.join('\n')).toContain('yielded 0 entries, fewer than the 3'); + expect(result.counters.anchoredJudged).toBe(0); + }); + + it('ERRORS when the anchored source is gone entirely', () => { + const result = judge('anchor-missing', { + files: {}, + anchors: [{ file: 'packages/app/src/icons.ts', anchor: 'VIEW_ICONS', kind: 'identifiers', min: 3, why: 'fixture' }], + }); + + expect(result.errors.join('\n')).toContain('anchored map source is gone: packages/app/src/icons.ts'); + }); +}); + +// ── 6. this repository ─────────────────────────────────────────────────────── + +describe('this repository', () => { + it('is green', () => { + expect(repoResult.violations.map((v) => `${v.where} :: ${v.detail}`)).toEqual([]); + expect(repoResult.errors).toEqual([]); + }); + + it('was actually scanned — green is a judgement, not an empty walk', () => { + expect(repoResult.counters.sources).toBeGreaterThan(1000); + expect(repoResult.counters.documents).toBeGreaterThan(100); + expect(repoResult.counters.authoredJudged).toBeGreaterThan(20); + expect(repoResult.counters.anchoredJudged).toBeGreaterThan(30); + }); + + it('carries more record-reading resolvers than objectui#5633 catalogued by hand', () => { + // The card's table listed four. Discovery found eight, which is the whole + // argument for measuring the population instead of maintaining a list: the + // four it missed each resolve authored strings through the same record. + expect(repoResult.discovered.record).toEqual([...DECLARED_RECORD_READERS].sort()); + expect(repoResult.discovered.record.length).toBeGreaterThanOrEqual(8); + for (const late of [ + 'packages/components/src/renderers/form/button.tsx', + 'packages/plugin-list/src/ListView.tsx', + 'packages/plugin-detail/src/RelatedList.tsx', + 'packages/app-shell/src/views/metadata-admin/previews/ActionPreview.tsx', + ]) { + expect(repoResult.discovered.record).toContain(late); + } + }); + + it('keeps the dynamic surface declared and separate', () => { + expect(repoResult.discovered.dynamic).toEqual([...DECLARED_DYNAMIC_READERS].sort()); + }); + + it('does not mistake the live local-`icons` specimen for a resolver', () => { + expect(fs.existsSync(path.join(repoRoot, DISCOVERY_NEGATIVE_CONTROL))).toBe(true); + expect(repoResult.discovered.record).not.toContain(DISCOVERY_NEGATIVE_CONTROL); + expect(repoResult.discovered.dynamic).not.toContain(DISCOVERY_NEGATIVE_CONTROL); + }); +}); + +// ── 7. wiring, and the pins this gate replaced ─────────────────────────────── + +describe('the gate is wired and the local pins it subsumes are gone', () => { + it('has a `check:*` script and a CI step', () => { + const manifest = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); + const scripts: Record = manifest.scripts; + const entry = Object.entries(scripts).find(([, command]) => command.includes(GATE)); + expect(entry, `no root script runs ${GATE}`).toBeDefined(); + + const ci = fs.readFileSync(path.join(repoRoot, '.github/workflows/ci.yml'), 'utf8'); + expect(ci, `${GATE} is not run by ci.yml — an unrun gate is not a gate`).toContain(`pnpm ${entry![0]}`); + }); + + it('the two fully-subsumed local pins are deleted', () => { + for (const pin of [ + 'packages/plugin-list/src/__tests__/ViewSwitcher.iconNames.test.ts', + 'packages/plugin-detail/src/__tests__/DetailView.systemActionIconNames.test.ts', + ]) { + expect(fs.existsSync(path.join(repoRoot, pin)), `${pin} still exists — the gate and a copy of what it checks`).toBe(false); + } + }); + + it('their populations moved into the gate rather than being dropped', () => { + const anchored = ANCHORED_MAPS.map((a) => `${a.file}::${a.anchor}`); + expect(anchored).toContain('packages/plugin-list/src/ViewSwitcher.tsx::VIEW_ICONS'); + expect(anchored).toContain('packages/plugin-detail/src/DetailView.tsx::items.push'); + expect(anchored).toContain('packages/plugin-view/src/ViewSwitcher.tsx::DEFAULT_VIEW_ICONS'); + expect(anchored).toContain('packages/plugin-view/src/ObjectView.tsx::iconMap'); + }); + + it('the pin the gate does NOT subsume is kept, and says why', () => { + // `ui:icon`'s registration meta: no first-party consumer of a + // registration's `icon` exists in this repository, so the gate has no + // measured basis to judge it. Retiring that pin would drop coverage. + const kept = 'packages/components/src/__tests__/icon-renderer-declared-default.test.ts'; + expect(fs.existsSync(path.join(repoRoot, kept))).toBe(true); + expect(fs.readFileSync(path.join(repoRoot, kept), 'utf8')).toContain('NOT retired by objectui#5633'); + }); +}); diff --git a/scripts/check-lucide-icon-record-names.mjs b/scripts/check-lucide-icon-record-names.mjs new file mode 100644 index 0000000000..ca7988bd3f --- /dev/null +++ b/scripts/check-lucide-icon-record-names.mjs @@ -0,0 +1,617 @@ +#!/usr/bin/env node +/** + * Every authored icon NAME that reaches a resolver reading lucide's runtime + * `icons` record must be a live key of that record. + * + * ── The class this gate exists for ────────────────────────────────────────── + * lucide retires a spelling by DROPPING IT FROM THE RUNTIME `icons` RECORD + * while keeping it as a deprecated named export. A retired name therefore + * still imports, still type-checks, and still renders wherever it is used as a + * COMPONENT — and resolves to `null` wherever it is used as a STRING, because + * the string lookups read that record. Nothing goes red in either direction: + * not the compiler, not a render test that only looks at the label, not a + * test that reaches for the export (`Edit === SquarePen` and + * `Smile === FaceSlightlySmiling` are both TRUE — the retired alias is the + * very same object under a dead name). MEMBERSHIP of the record is the only + * thing that separates them. + * + * It had been repaired twice, in two packages, by two cards (objectui#5586, + * objectui#5622), each leaving behind a LOCAL pin over the names that card + * happened to touch. objectui#5633 asked for one gate over the population + * instead of a fifth local pin. + * + * ── What judges ──────────────────────────────────────────────────────────── + * The runtime `icons` record itself, loaded from the installed lucide, and + * NOTHING ELSE. This gate deliberately carries no list of retired spellings: + * a hand-kept vocabulary is the same defect one level up — it ages the moment + * lucide retires the next name, and it ages SILENTLY. When it has to name a + * replacement it derives one, by identity: the retired export and its live + * spelling are the same object, so the live key is looked up in the record + * rather than remembered. + * + * ── The two surfaces, and why picking the wrong one is worse than no gate ── + * This repo resolves icon names against TWO different lucide vocabularies: + * + * RECORD — `icons` from 'lucide-react' (1767 keys, measured) + * DYNAMIC — `iconNames` from 'lucide-react/dynamic.mjs' (2025 names) + * + * DYNAMIC is a strict superset: it still carries `edit`, `smile`, `filter`, + * `alert-triangle`. So a gate that checked the dynamic list would BLESS every + * name this class is about. Only names reaching a RECORD-reading resolver are + * judged here; the dynamic sites are censused (below) precisely so that the + * split stays declared and a site cannot move between surfaces unnoticed. + * + * ── What it checks (three parts, each self-verifying) ─────────────────────── + * 1. SURFACE CENSUS — rediscovers, from source, every module that reads either + * vocabulary, and fails when the discovered set differs from the declared + * one. The population is measured on every run rather than remembered: this + * is what stops a ninth hand-copied resolver appearing in silence. Its first + * run found FOUR record-reading resolvers that objectui#5633's own table did + * not know about (`form/button.tsx`, `plugin-list/ListView.tsx`, + * `plugin-detail/RelatedList.tsx`, `previews/ActionPreview.tsx`). + * + * 2. AUTHORED NODES — walks authored SDUI metadata (JSON documents and the + * schema object literals embedded in first-party TS) and checks the `icon` + * names on nodes whose `type` is a censused record-reading renderer. The + * node's own `type` is what answers "which resolver does this string + * reach?", which is why this check can be broad without being suppressible: + * an `icon` on an UNTYPED node is not judged at all (measured: the eight + * such names in the schema catalog are child items of `button-group`, + * `breadcrumb`, `command` and `dropdown-menu` — three of which never read + * `icon`, and the fourth renders it as raw text). + * + * 3. ANCHORED MAPS — the first-party const maps that feed a record-reading + * resolver but are not authored nodes. This is the population the retired + * local pins covered, generalised: each anchor carries a minimum entry + * count, so a declaration that moved or was re-annotated fails LOUDLY + * instead of quietly extracting nothing and passing. + * + * ── Deliberate boundaries ────────────────────────────────────────────────── + * - Test files are not scanned for authored nodes. Suites legitimately build + * fixtures out of names that must NOT resolve (`not-a-real-icon` is a + * control in two of them), and a gate that flagged its own controls gets + * suppressed. + * - Imported lucide IDENTIFIERS are not checked repo-wide. 54 distinct retired + * identifiers are imported across ~350 sites and every one of them renders; + * flagging them would be a gate suppressed on day one. Identifiers ARE + * checked in the anchored icon maps of part 3, where a component map sits + * beside a string map that resolves the same glyphs and a dead spelling gets + * copied across — the exact path by which `bar-chart-3` and `gantt-chart` + * reached a string map (objectui#5586). + * + * Run: node scripts/check-lucide-icon-record-names.mjs + * node scripts/check-lucide-icon-record-names.mjs --report + * Exit: 0 = OK, 1 = a violation, a census drift, or a blind instrument + */ + +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +/** This gate's OWN repo — where lucide and typescript are resolved from. */ +const gateRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +// ── The declared census ────────────────────────────────────────────────────── +// Sites, NOT spellings. Rediscovered from source on every run by +// `discoverResolvers`: an entry that disappears and a module that appears both +// fail the gate. Add a new resolver here only after deciding which vocabulary +// it reads — that decision is the whole point of the split. +export const DECLARED_RECORD_READERS = [ + 'packages/app-shell/src/views/metadata-admin/previews/ActionPreview.tsx', + 'packages/components/src/renderers/action/resolve-icon.ts', + 'packages/components/src/renderers/basic/icon.tsx', + 'packages/components/src/renderers/form/button.tsx', + 'packages/plugin-detail/src/RelatedList.tsx', + 'packages/plugin-list/src/ListView.tsx', + 'packages/plugin-list/src/components/TabBar.tsx', + 'packages/plugin-view/src/ViewSwitcher.tsx', +]; + +export const DECLARED_DYNAMIC_READERS = [ + 'apps/console/src/utils/getIcon.ts', + 'packages/app-shell/src/utils/getIcon.ts', + 'packages/app-shell/src/views/metadata-admin/widgets.tsx', + 'packages/components/src/lib/lazy-icon.tsx', +]; + +/** + * A module that builds its OWN `icons` object and indexes it is not a lucide + * resolver. `plugin-chatbot/src/elements/tool.tsx` does exactly that, which + * makes it a free negative control on discovery: if it ever shows up as a + * reader, discovery is matching the NAME rather than the IMPORT, and every + * other conclusion this gate draws is suspect. + */ +export const DISCOVERY_NEGATIVE_CONTROL = 'packages/plugin-chatbot/src/elements/tool.tsx'; + +export const SCAN_ROOTS = ['packages', 'apps', 'examples']; + +// ── Authored-node census: component `type` -> where its icon NAMES sit ─────── +// Every path below was read off the renderer named beside it. A `type` absent +// from this table is not judged, because nothing here knows which vocabulary +// (if any) its icons reach — and a gate that guessed would be a gate that gets +// suppressed. +export const RECORD_READING_TYPES = { + 'button': { paths: ['icon'], resolver: 'packages/components/src/renderers/form/button.tsx' }, + 'action:bar': { paths: ['actions[].icon'], resolver: 'packages/components/src/renderers/action/resolve-icon.ts' }, + 'action:button': { paths: ['icon'], resolver: 'packages/components/src/renderers/action/resolve-icon.ts' }, + 'action:group': { paths: ['icon', 'actions[].icon'], resolver: 'packages/components/src/renderers/action/resolve-icon.ts' }, + 'action:icon': { paths: ['icon'], resolver: 'packages/components/src/renderers/action/resolve-icon.ts' }, + 'action:menu': { paths: ['icon', 'actions[].icon'], resolver: 'packages/components/src/renderers/action/resolve-icon.ts' }, + 'data-table': { paths: ['rowActionDefs[].icon'], resolver: 'packages/components/src/renderers/complex/data-table.tsx' }, + 'view-switcher': { paths: ['views[].icon', 'viewActions[].icon'], resolver: 'packages/plugin-view/src/ViewSwitcher.tsx' }, +}; + +// ── Anchored first-party maps ──────────────────────────────────────────────── +// The population the retired local pins covered, generalised. `min` is the +// precondition that makes "every entry" mean something: an extractor that finds +// fewer entries than the map is known to carry did not read the map, and +// reporting zero violations off zero entries is the failure mode this shape +// invites — which is why a short read is an ERROR, not a shrug. +export const ANCHORED_MAPS = [ + { + file: 'packages/plugin-view/src/ObjectView.tsx', + anchor: 'iconMap', + kind: 'strings', + min: 9, + why: 'the producer handing ViewSwitcher its icon NAMES; `chart`/`gantt` died here (objectui#5586)', + }, + { + file: 'packages/plugin-view/src/ViewSwitcher.tsx', + anchor: 'DEFAULT_VIEW_ICONS', + kind: 'identifiers', + min: 9, + why: 'the component map beside that string map — a dead spelling here gets copied across', + }, + { + file: 'packages/plugin-list/src/ViewSwitcher.tsx', + anchor: 'VIEW_ICONS', + kind: 'identifiers', + min: 9, + why: 'the sibling switcher naming the same glyphs as components (objectui#5622)', + }, + { + file: 'packages/plugin-detail/src/DetailView.tsx', + anchor: 'items.push', + kind: 'pushed-objects', + min: 3, + why: 'system action items built as an `action:bar` schema; `edit` died here (objectui#5622)', + }, +]; + +// ── Load the judgement ─────────────────────────────────────────────────────── +// `lucide-react` is not resolvable from the repo root — only the packages that +// declare it have it. Resolve it from the package owning the canonical +// resolver, so this gate reads the very copy `resolve-icon.ts` reads. +export const LUCIDE_OWNER_PKG = 'packages/components/package.json'; +const lucideRequire = createRequire(join(gateRoot, LUCIDE_OWNER_PKG)); +export const lucide = await import(pathToFileURL(lucideRequire.resolve('lucide-react')).href); +export const { iconNames } = await import(pathToFileURL(lucideRequire.resolve('lucide-react/dynamic.mjs')).href); +export const icons = lucide.icons; +export const lucideVersion = JSON.parse(readFileSync(lucideRequire.resolve('lucide-react/package.json'), 'utf8')).version; +const ts = createRequire(join(gateRoot, 'package.json'))('typescript'); + +/** + * Prove the instrument can see the distinction it claims to judge, BEFORE any + * silence of its is quoted as evidence. This gate's whole subject is that the + * current tooling reports nothing; a blind probe would report nothing too, and + * read as green. + */ +export function selfTest() { + const problems = []; + const recordSize = icons ? Object.keys(icons).length : 0; + if (recordSize < 500) { + problems.push(`the loaded \`icons\` record has ${recordSize} keys — that is not lucide's record; every result below is meaningless.`); + } + if (!Array.isArray(iconNames) || iconNames.length <= recordSize) { + problems.push(`the dynamic \`iconNames\` list (${iconNames?.length}) is not larger than the \`icons\` record (${recordSize}) — the two surfaces this gate distinguishes are not distinguishable in this install.`); + } + // A name lucide keeps ONLY as a deprecated export. If the predicate cannot + // reject THIS, it cannot reject anything: `Edit` imports, type-checks, and IS + // `SquarePen`. Absence from the record is the only difference, and it is the + // difference every conclusion below rests on. + if (icons && Object.prototype.hasOwnProperty.call(icons, 'Edit')) { + problems.push('`Edit` is a key of the runtime `icons` record in this install — the membership predicate no longer separates a retired alias from a live one.'); + } + if (icons && !Object.prototype.hasOwnProperty.call(icons, 'SquarePen')) { + problems.push('`SquarePen` is NOT a key of the runtime `icons` record — the predicate is rejecting live names, so it would fail everything for the wrong reason.'); + } + return problems; +} + +// ── Normalisation ──────────────────────────────────────────────────────────── +// The transform the record-reading resolvers apply before their lookup. Five of +// the eight also map `Home` -> `House`; three do not, and three different +// tokenisers are in use. Taking the WIDEST tokeniser and the alias map means +// this gate never invents a violation a resolver would not have: a name is +// judged dead only when EVERY censused normalisation would still miss it. +export const toRecordKey = (name) => { + const pascal = String(name) + .split(/[-_\s]+/) + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(''); + return pascal === 'Home' ? 'House' : pascal; +}; + +export const isLiveKey = (name) => Object.prototype.hasOwnProperty.call(icons, toRecordKey(name)); + +// Derive the live spelling of a retired name BY IDENTITY, never from a list: +// lucide keeps the retired export pointing at the same object as its live key. +const keyByComponent = new Map(); +for (const [key, component] of Object.entries(icons)) if (!keyByComponent.has(component)) keyByComponent.set(component, key); +const kebabByKey = new Map(); +for (const kebab of iconNames) kebabByKey.set(toRecordKey(kebab), kebab); + +/** `{ key, kebab }` of the live spelling naming the SAME glyph, or null. */ +export function liveSpellingFor(name) { + const retiredExport = lucide[toRecordKey(name)]; + if (!retiredExport) return null; + const liveKey = keyByComponent.get(retiredExport); + if (!liveKey) return null; + return { key: liveKey, kebab: kebabByKey.get(liveKey) ?? null }; +} + +/** The sentence a violation prints — three distinct diagnoses, not one. */ +export function describeName(name) { + const key = toRecordKey(name); + const live = liveSpellingFor(name); + if (live) { + return `"${name}" -> \`${key}\` is not a key of the runtime \`icons\` record. lucide keeps it only as a DEPRECATED EXPORT of the same glyph — write \`${live.kebab ?? live.key}\` (the spelling the record carries).`; + } + if (lucide[key]) { + return `"${name}" -> \`${key}\` is exported by lucide but is not a key of the runtime \`icons\` record, and no live key names the same glyph.`; + } + return `"${name}" -> \`${key}\` is not a lucide icon at all.`; +} + +// ── Source inventory ───────────────────────────────────────────────────────── +const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', 'coverage', '.git', '.turbo', '.next', 'storybook-static']); + +/** + * Test files are NOT scanned for authored nodes: suites legitimately build + * fixtures out of names that must NOT resolve (`not-a-real-icon` is a control + * in two of them), and a gate that flagged its own controls gets suppressed. + */ +export const isTestPath = (file) => /(^|\/)(__tests__|__mocks__|e2e)\//.test(file) || /\.(test|spec)\.[cm]?[jt]sx?$/.test(file); + +export function collectFiles(root) { + const sources = []; + const documents = []; + const walk = (absolute) => { + let entries; + try { entries = readdirSync(absolute, { withFileTypes: true }); } catch { return; } + for (const entry of entries) { + if (entry.name.startsWith('.') && entry.name !== '.') continue; + const child = join(absolute, entry.name); + if (entry.isDirectory()) { + if (!SKIP_DIRS.has(entry.name)) walk(child); + continue; + } + if (!entry.isFile()) continue; + const rel = relative(root, child).split(sep).join('/'); + if (/\.tsx?$/.test(rel) && !rel.endsWith('.d.ts')) sources.push(rel); + else if (rel.endsWith('.json') && !/(^|\/)(package|tsconfig|package-lock)\.json$/.test(rel)) documents.push(rel); + } + }; + for (const scanRoot of SCAN_ROOTS) walk(join(root, scanRoot)); + sources.sort(); + documents.sort(); + return { sources, documents }; +} + +// ── AST helpers ────────────────────────────────────────────────────────────── +function parseSource(root, file) { + const text = readFileSync(join(root, file), 'utf8'); + return ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS); +} +const lineOf = (sf, node) => sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1; + +function unwrap(node) { + let current = node; + for (;;) { + if (ts.isParenthesizedExpression(current) || ts.isAsExpression(current) || ts.isNonNullExpression(current)) current = current.expression; + else if (typeof ts.isTypeAssertionExpression === 'function' && ts.isTypeAssertionExpression(current)) current = current.expression; + else return current; + } +} + +function objectProp(objectLiteral, name) { + for (const property of objectLiteral.properties) { + if (!ts.isPropertyAssignment(property)) continue; + const key = ts.isIdentifier(property.name) || ts.isStringLiteral(property.name) ? property.name.text : null; + if (key === name) return property.initializer; + } + return null; +} + +// ── Part 1: surface census ─────────────────────────────────────────────────── +/** + * Which modules read which lucide vocabulary — rediscovered from source, so the + * population is MEASURED on every run rather than remembered. Its first run + * found four record-reading resolvers and two dynamic ones that objectui#5633's + * own table did not know about. + */ +export function discoverResolvers(root, files) { + const record = []; + const dynamic = []; + for (const file of files) { + if (isTestPath(file)) continue; + const text = readFileSync(join(root, file), 'utf8'); + if (!text.includes('lucide-react')) continue; + const sf = parseSource(root, file); + let recordLocal = null; + let readsDynamic = false; + sf.forEachChild((node) => { + if (!ts.isImportDeclaration(node) || !ts.isStringLiteral(node.moduleSpecifier)) return; + const specifier = node.moduleSpecifier.text; + if (specifier.startsWith('lucide-react/dynamic')) readsDynamic = true; + if (specifier !== 'lucide-react') return; + const bindings = node.importClause?.namedBindings; + if (!bindings || !ts.isNamedImports(bindings)) return; + for (const element of bindings.elements) { + if ((element.propertyName ?? element.name).text === 'icons') recordLocal = element.name.text; + } + }); + if (readsDynamic) dynamic.push(file); + if (!recordLocal) continue; + let indexes = false; + const visit = (node) => { + if (ts.isElementAccessExpression(node)) { + const base = unwrap(node.expression); + if (ts.isIdentifier(base) && base.text === recordLocal) indexes = true; + } + ts.forEachChild(node, visit); + }; + ts.forEachChild(sf, visit); + if (indexes) record.push(file); + } + return { record: record.sort(), dynamic: dynamic.sort() }; +} + +// ── Part 2: authored nodes ─────────────────────────────────────────────────── +const ARRAY_PATH = /^(\w+)\[\]\.icon$/; + +function judgeAuthoredNodes(root, { sources, documents }) { + const violations = []; + let judged = 0; + let declined = 0; + + const judge = (typeName, gather, locate) => { + const spec = RECORD_READING_TYPES[typeName]; + if (!spec) return; + for (const path of spec.paths) { + for (const found of gather(path)) { + judged += 1; + if (!isLiveKey(found.value)) { + violations.push({ where: locate(found.where), site: typeName, resolver: spec.resolver, detail: describeName(found.value) }); + } + } + } + }; + + for (const file of documents) { + if (isTestPath(file)) continue; + let document; + try { document = JSON.parse(readFileSync(join(root, file), 'utf8')); } catch { continue; } + // One walker, carrying a JSON-pointer trail so a violation names the node. + const walk = (node, trail) => { + if (Array.isArray(node)) { node.forEach((child, index) => walk(child, `${trail}[${index}]`)); return; } + if (!node || typeof node !== 'object') return; + const typeName = typeof node.type === 'string' ? node.type : null; + if (typeof node.icon === 'string' && !(typeName && RECORD_READING_TYPES[typeName])) declined += 1; + if (typeName) { + judge(typeName, (path) => { + const found = []; + const arrayMatch = ARRAY_PATH.exec(path); + if (path === 'icon') { + if (typeof node.icon === 'string') found.push({ value: node.icon, where: `${trail}.icon` }); + } else if (arrayMatch && Array.isArray(node[arrayMatch[1]])) { + node[arrayMatch[1]].forEach((child, index) => { + if (child && typeof child.icon === 'string') found.push({ value: child.icon, where: `${trail}.${arrayMatch[1]}[${index}].icon` }); + }); + } + return found; + }, (where) => `${file} ${where}`); + } + for (const [key, value] of Object.entries(node)) walk(value, `${trail}.${key}`); + }; + walk(document, '$'); + } + + for (const file of sources) { + if (isTestPath(file)) continue; + const text = readFileSync(join(root, file), 'utf8'); + if (!text.includes('icon')) continue; + const sf = parseSource(root, file); + const visit = (node) => { + if (ts.isObjectLiteralExpression(node)) { + const typeInit = objectProp(node, 'type'); + const iconInit = objectProp(node, 'icon'); + const typeName = typeInit && ts.isStringLiteral(typeInit) ? typeInit.text : null; + if (iconInit && ts.isStringLiteral(iconInit) && !(typeName && RECORD_READING_TYPES[typeName])) declined += 1; + if (typeName) { + judge(typeName, (path) => { + const found = []; + const arrayMatch = ARRAY_PATH.exec(path); + if (path === 'icon') { + if (iconInit && ts.isStringLiteral(iconInit)) found.push({ value: iconInit.text, where: iconInit }); + } else if (arrayMatch) { + const arrayInit = objectProp(node, arrayMatch[1]); + if (arrayInit && ts.isArrayLiteralExpression(arrayInit)) { + for (const element of arrayInit.elements) { + if (!ts.isObjectLiteralExpression(element)) continue; + const childIcon = objectProp(element, 'icon'); + if (childIcon && ts.isStringLiteral(childIcon)) found.push({ value: childIcon.text, where: childIcon }); + } + } + } + return found; + }, (where) => `${file}:${lineOf(sf, where)}`); + } + } + ts.forEachChild(node, visit); + }; + ts.forEachChild(sf, visit); + } + + return { violations, judged, declined }; +} + +// ── Part 3: anchored first-party maps ──────────────────────────────────────── +function judgeAnchoredMaps(root, anchors) { + const violations = []; + const errors = []; + let judged = 0; + + for (const anchor of anchors) { + if (!existsSync(join(root, anchor.file))) { + errors.push(`anchored map source is gone: ${anchor.file} (${anchor.why}). Fix the anchor; do not delete it.`); + continue; + } + const sf = parseSource(root, anchor.file); + const found = []; + const visit = (node) => { + if (anchor.kind === 'pushed-objects') { + if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) + && `${node.expression.expression.getText(sf)}.${node.expression.name.text}` === anchor.anchor) { + for (const argument of node.arguments) { + if (!ts.isObjectLiteralExpression(argument)) continue; + const iconInit = objectProp(argument, 'icon'); + if (iconInit && ts.isStringLiteral(iconInit)) found.push({ value: iconInit.text, node: iconInit }); + } + } + } else if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === anchor.anchor + && node.initializer && ts.isObjectLiteralExpression(node.initializer)) { + for (const property of node.initializer.properties) { + if (!ts.isPropertyAssignment(property)) continue; + const init = property.initializer; + if (anchor.kind === 'strings' && ts.isStringLiteral(init)) found.push({ value: init.text, node: init }); + if (anchor.kind === 'identifiers') { + if (ts.isIdentifier(init)) found.push({ value: init.text, node: init }); + else if (ts.isJsxSelfClosingElement(init) && ts.isIdentifier(init.tagName)) found.push({ value: init.tagName.text, node: init }); + else if (ts.isJsxElement(init) && ts.isIdentifier(init.openingElement.tagName)) found.push({ value: init.openingElement.tagName.text, node: init }); + } + } + } + ts.forEachChild(node, visit); + }; + ts.forEachChild(sf, visit); + + if (found.length < anchor.min) { + errors.push( + `anchored map \`${anchor.anchor}\` in ${anchor.file} yielded ${found.length} entries, fewer than the ${anchor.min} it is known to carry — ` + + 'the declaration moved, was re-annotated, or the reader broke. A reader that finds nothing reports no violations, ' + + `which is why this is an ERROR and not a shrug. (${anchor.why})`, + ); + continue; + } + judged += found.length; + for (const entry of found) { + // Identifier anchors name lucide EXPORTS, already record-key shaped; + // string anchors name authored kebab spellings. `toRecordKey` is the + // identity on the former, so one predicate serves both. + if (!isLiveKey(entry.value)) { + violations.push({ where: `${anchor.file}:${lineOf(sf, entry.node)}`, site: `${anchor.anchor} (${anchor.kind})`, resolver: anchor.why, detail: describeName(entry.value) }); + } + } + } + return { violations, errors, judged }; +} + +// ── The whole judgement ────────────────────────────────────────────────────── +export function analyze(root, { + anchors = ANCHORED_MAPS, + declaredRecordReaders = DECLARED_RECORD_READERS, + declaredDynamicReaders = DECLARED_DYNAMIC_READERS, + negativeControl = DISCOVERY_NEGATIVE_CONTROL, +} = {}) { + const errors = [...selfTest()]; + const { sources, documents } = collectFiles(root); + const discovered = discoverResolvers(root, sources); + + const censusDiff = (label, found, declared, hint) => { + for (const file of found) { + if (!declared.includes(file)) { + errors.push(`UNDECLARED ${label}: ${file}\n ${hint}\n Add it to the census in scripts/check-lucide-icon-record-names.mjs after deciding which vocabulary it reads.`); + } + } + for (const file of declared) { + if (!found.includes(file)) { + errors.push(`STALE ${label} census entry: ${file} no longer reads that vocabulary (or moved). Update the census — do not delete the gate.`); + } + } + }; + censusDiff('record-reading resolver', discovered.record, declaredRecordReaders, + 'It resolves an icon NAME through lucide\'s runtime `icons` record, where a retired spelling resolves to nothing and NOTHING goes red.'); + censusDiff('dynamic-surface resolver', discovered.dynamic, declaredDynamicReaders, + 'It resolves names through `lucide-react/dynamic.mjs`, which still carries retired spellings — a second, more forgiving vocabulary.'); + + if (discovered.record.length === 0) { + errors.push('discovery found NO record-reading resolver at all — it is not matching imports any more, and every "no violations" below is vacuous.'); + } + if (negativeControl && (discovered.record.includes(negativeControl) || discovered.dynamic.includes(negativeControl))) { + errors.push(`discovery classified ${negativeControl} as a lucide resolver. It builds its OWN local \`icons\` object — discovery is matching the NAME rather than the IMPORT.`); + } + + const authored = judgeAuthoredNodes(root, { sources, documents }); + const anchored = judgeAnchoredMaps(root, anchors); + errors.push(...anchored.errors); + + return { + discovered, + errors, + violations: [...authored.violations, ...anchored.violations], + counters: { + sources: sources.length, + documents: documents.length, + authoredJudged: authored.judged, + authoredDeclined: authored.declined, + anchoredJudged: anchored.judged, + }, + }; +} + +// ── CLI ────────────────────────────────────────────────────────────────────── +const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)); +if (invokedDirectly) { + const result = analyze(gateRoot); + const { counters, discovered, errors, violations } = result; + + if (process.argv.includes('--report')) { + console.log(`lucide ${lucideVersion} resolved from ${LUCIDE_OWNER_PKG}`); + console.log(`RECORD vocabulary: ${Object.keys(icons).length} keys | DYNAMIC vocabulary: ${iconNames.length} names (superset by ${iconNames.length - Object.keys(icons).length})`); + console.log(`scanned ${counters.sources} TS sources + ${counters.documents} JSON documents under ${SCAN_ROOTS.join('/, ')}/`); + console.log(`record-reading resolvers discovered (${discovered.record.length}):`); + for (const file of discovered.record) console.log(` ${file}`); + console.log(`dynamic-surface resolvers discovered (${discovered.dynamic.length}), NOT judged here:`); + for (const file of discovered.dynamic) console.log(` ${file}`); + console.log(`authored icon names judged: ${counters.authoredJudged} | icon names on nodes this gate declines to judge: ${counters.authoredDeclined}`); + console.log(`anchored map entries judged: ${counters.anchoredJudged}`); + console.log(''); + } + + if (errors.length === 0 && violations.length === 0) { + console.log( + `OK lucide icon names: ${counters.authoredJudged + counters.anchoredJudged} authored/declared names reaching ` + + `${discovered.record.length} record-reading resolvers are live \`icons\` keys ` + + `(record ${Object.keys(icons).length} keys; dynamic surface ${discovered.dynamic.length} sites, ${iconNames.length} names, not judged here).`, + ); + process.exit(0); + } + + console.error('FAIL lucide icon names\n'); + for (const violation of violations) { + console.error(` - ${violation.where} [${violation.site}]`); + console.error(` ${violation.detail}`); + console.error(` Resolved through: ${violation.resolver}`); + } + if (violations.length > 0 && errors.length > 0) console.error(''); + for (const message of errors) console.error(` - ${message}`); + console.error( + '\nlucide retires a spelling by dropping it from the runtime `icons` record while keeping it as a\n' + + 'deprecated export, so a retired name still imports, still type-checks and still renders as a\n' + + 'COMPONENT — and resolves to nothing as a STRING. Nothing else goes red. See objectui#5633.', + ); + process.exit(1); +}