@starci/hfs 4.16.5 → 4.17.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -3
- package/README.md +4 -4
- package/codemod/loading-states.d.mts +50 -0
- package/codemod/loading-states.mjs +384 -0
- package/codemod/main.d.mts +1 -1
- package/codemod/main.mjs +46 -5
- package/codemod/page-navigation.d.mts +50 -0
- package/codemod/page-navigation.mjs +278 -0
- package/codemod/section-frame.d.mts +50 -0
- package/codemod/section-frame.mjs +483 -0
- package/codemod/small-renames.d.mts +54 -0
- package/codemod/small-renames.mjs +614 -0
- package/codemod/surface-card-kinds.d.mts +49 -0
- package/codemod/surface-card-kinds.mjs +404 -0
- package/codemod/theme-tokens.d.mts +45 -0
- package/codemod/theme-tokens.mjs +114 -0
- package/codemod/tier-binding.d.mts +50 -0
- package/codemod/tier-binding.mjs +214 -0
- package/package.json +1 -1
- package/runtime/knowledge/grammars/core/playbook.yaml +41 -51
- package/runtime/knowledge/grammars/offset-pop/playbook.yaml +5 -5
- package/runtime/knowledge/hfs/canon-pins.yaml +7 -7
- package/runtime/knowledge/hfs/rules.yaml +255 -30
- package/runtime/knowledge/hfs/slots.yaml +12 -5
- package/runtime/knowledge/patterns/fe/folder.yaml +1 -1
- package/runtime/modules/kernel/failure-codes.yaml +23 -13
- package/runtime/scripts/hfs/architecture/clones.mjs +12 -2
- package/runtime/scripts/hfs/architecture/component-tiers.d.mts +46 -0
- package/runtime/scripts/hfs/architecture/component-tiers.mjs +161 -0
- package/runtime/scripts/hfs/architecture/config.mjs +1 -1
- package/runtime/scripts/hfs/architecture/frontend-world.mjs +1 -1
- package/runtime/scripts/hfs/architecture/frontend.mjs +2 -2
- package/runtime/scripts/hfs/architecture/index.mjs +6 -0
- package/runtime/scripts/hfs/architecture/render-graph.d.mts +125 -0
- package/runtime/scripts/hfs/architecture/render-graph.mjs +449 -0
- package/runtime/scripts/hfs/architecture/render-prop-across-server.d.mts +23 -0
- package/runtime/scripts/hfs/architecture/render-prop-across-server.mjs +58 -0
- package/runtime/scripts/hfs/architecture/surface-nesting.d.mts +44 -0
- package/runtime/scripts/hfs/architecture/surface-nesting.mjs +127 -0
- package/runtime/scripts/hfs/architecture/unit-spec-providers.d.mts +2 -1
- package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +12 -5
- package/runtime/scripts/hfs/slot-manifest-shape.mjs +16 -1
- package/runtime/scripts/work/draw/draw-dna.mjs +1 -1
- package/scaffold/add-fe-layout.mjs +3 -3
- package/scaffold/add-fe-overlay.mjs +4 -4
- package/scaffold/add-fe-page.mjs +1 -1
- package/scaffold/add-fe-unit.d.mts +2 -1
- package/scaffold/add-fe-unit.mjs +14 -12
- package/scaffold/add.d.mts +1 -1
- package/scaffold/add.mjs +1 -1
- package/src/main.mjs +4 -4
- package/templates/fe/skeleton/apps/__app__/src/components/blocks/SignInForm/component.tsx +3 -4
- package/templates/fe/skeleton/apps/app/src/app/[locale]/layout.tsx +8 -5
- package/templates/fe/skeleton/apps/landing/src/app/[locale]/layout.tsx +1 -3
- package/templates/fe/skeleton/packages/__project__-ui/src/branches/Providers/index.tsx +3 -3
- package/templates/fe/skeleton/packages/__project__-ui/src/composites/LocaleShell/index.tsx +3 -5
- package/templates/fe/skeleton/packages/__project__-ui/src/index.ts +1 -1
- package/templates/fe/skeleton-lite/apps/web/src/features/layouts/AppLayout/index.tsx +1 -1
- package/templates/fe/skeleton-lite/apps/web/src/modules/brand/brand.css +1 -2
- package/templates/fe/units/block/component.tsx.tpl +2 -3
- package/templates/fe/units/branch/index.tsx.tpl +4 -3
- package/templates/fe/units/composite/index.tsx.tpl +3 -3
- package/templates/fe/units/frame/index.tsx.tpl +10 -0
- package/templates/fe/units/layout/index.tsx.tpl +2 -2
- package/templates/fe/units/leaf/index.tsx.tpl +3 -3
- package/templates/fe/units/page/component.tsx.tpl +2 -2
- package/templates/fe/units/page/index.tsx.tpl +2 -2
- package/templates/fe/units/page/stories.tsx.tpl +2 -2
- package/templates/fe/units/slot/SlotView.tsx.tpl +11 -10
- package/templates/fe/units/slot/data-slot.ts.tpl +20 -8
- package/templates/fe/units/slot/slot.shared.ts.tpl +52 -11
- package/templates/fe/units/slot/useSlotOfReads.ts.tpl +19 -0
- /package/templates/fe/skeleton/packages/__project__-ui/src/{composites → frames}/SiteShell/classNames.ts +0 -0
- /package/templates/fe/skeleton/packages/__project__-ui/src/{composites → frames}/SiteShell/index.tsx +0 -0
- /package/templates/fe/skeleton-lite/apps/web/src/components/{composites → frames}/SiteShell/classNames.ts +0 -0
- /package/templates/fe/skeleton-lite/apps/web/src/components/{composites → frames}/SiteShell/index.tsx +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 4.17.2 - 2026-11-02
|
|
6
|
+
|
|
7
|
+
- Changed: bundled pins follow Grammar 0.21.2 with disabled tab items and its coherent canon cohort; existing runtime checks are unchanged.
|
|
8
|
+
|
|
9
|
+
## 4.17.1 - 2026-11-02
|
|
10
|
+
|
|
11
|
+
- Fixed: unit provider contracts recognize the real Nest `Optional()` injection decorator through direct, aliased and namespace imports; TypeScript optional parameters and unrelated decorator names still require their providers.
|
|
12
|
+
- Fixed: cross-file `label-in-grid` judges the nearest peer-card layout (`Grid kind="cards"`), matching the existing one-file rule. Dated week regions keep their day headings; labelled peer tiles still fail, with generic regression coverage for both semantic week layouts and nested layout ownership. Bundled R356 knowledge names the same owner.
|
|
13
|
+
|
|
14
|
+
## 4.17.0 - 2026-11-02
|
|
15
|
+
|
|
16
|
+
- Fixed: the architecture machine run on an app root that has its own tsconfig threw "Cannot destructure property 'lines'" out of `checkClones`: the app resolver has no rule parameters, so the clone threshold (`ruleParams.common.duplicateBlock`, shared by every profile) is read from a side. Spec `architecture-clones-app-root`.
|
|
17
|
+
- Changed: the bundled playbooks, rule catalog, failure codes and draw script name the one card (`SurfaceCard kind="list"`, `kind="accordion"`) and Page's header slot; no SurfaceListCard, SurfaceAccordionCard or SectionHeader remains in `runtime`.
|
|
18
|
+
- Removed: `NavigationFeatureNav` from the surface names and the bundled Core playbook (the component is gone; `TopBar` takes `subnavigation`).
|
|
19
|
+
- Changed: the bundled rule catalog words the laws of R348, R351, R352 and R355 for the one card (no SurfaceListCard, SectionHeader or PageHeader).
|
|
20
|
+
- Changed: the status owners of the scaffold follow the flag-union `DataSlot` (`isLoading`, `isError`, `isForbidden`, `isEmpty`; `items` only on the ready member; `isValidating` beside data) and gain `slotOfReads` and `useSlotOfReads`; `isSkeleton` is `isLoading` in every template.
|
|
21
|
+
- Added: the codemod `loading-states` (`starci app codemod loading-states [--execute] [--json]`) for the grammar loading law: `isSkeleton` becomes `isLoading` on every Grammar element (and on an `isSkeleton` key inside a Grammar attribute, such as a breadcrumb item), `loadingRows` and a bubble skeleton's `rows` go, and an object literal in a `DataSlot` position loses its `items: undefined` (`SLOT_LITERAL_FLAGS`: `{ isLoading: true }`, `{ isError: true }`, `{ isForbidden: true }`, or `{ isEmpty: true }`); reported for a person: `LOADING_NAME_CONFLICT`, `IS_SKELETON_KEY`, `LOADING_CONTENT`, `SLOT_ITEMS_ACCESS`, `SLOT_LITERAL`, `HAND_SKELETON`, `SWR_DEFAULT_ARRAY`, `PLACEHOLDER_OBJECT`, `VALIDATING_AS_LOADING` and `FOREIGN_SLOT_STATUS`.
|
|
22
|
+
- Added: `tiers.grammar` in `knowledge/hfs/slots.yaml` (leaf, branch, composite, frame: a tier imports nothing above it) and the machine check `FE_GRAMMAR_TIER_DIRECTION` / `FE_GRAMMAR_TIER_STAMP` over a component library (`src/core/<tier>/<Name>/`): an import that climbs, a type-only one and one through a shared module included, and a `data-tier` stamp that is not the folder. `checkComponentLayers` runs the layer order over a component tree no hfs slot judges.
|
|
23
|
+
- Added: the codemod `tier-binding` (`starci app codemod tier-binding [--execute] [--json]`) for the grammar's tier binding: a child that became a prop (`Tooltip` `trigger`, `Disclosure`, `DragItem` and `MessageBubble` `body`, `MediaFrame` `media`, `Field` `control`, `Quote` `text`) moves into it, and `TAKES_NO_CHILDREN`, `LABEL_NOT_TEXT`, `CLASS_NAME_REMOVED`, `TOOLTIP_DISABLED_TRIGGER` and `CHILD_NOT_TEXT` are reported for a person.
|
|
24
|
+
- Changed: the scaffold follows the binding. `SlotView` takes a `render` function (not `children`), the `branch` unit takes a `body` slot, and `SiteShell`, which hosts the page, is a frame (`components/frames/SiteShell`, `packages/<project>-ui/src/frames/SiteShell`).
|
|
25
|
+
- Added: the render graph of the architecture machine (`render-graph.mts`: which component draws which, and where each places `children` and slot props) and two checks built on it: `surface-nesting` (R356, `FE_SURFACE_NESTING`: a card in a card, in `Pane surface` or in an overlay; a `DataTable` in a card; a `PaneGrid` outside `Page chrome="panes"`; a `Section` or `Heading` holding one card; a labelled card repeated in a `Grid`) and `render-prop-across-server` (R361, `FE_RENDER_PROP_ACROSS_SERVER`).
|
|
26
|
+
- Added: catalog entries R356 to R359, R361 and R367; every rule of `rules.yaml` names a doc.
|
|
27
|
+
- Changed: `starci app codemod` usage is derived from `CODEMODS`, so a new codemod cannot be missing from it. The bundle manifests of eslint-canon-be and eslint-grammar-core take `knowledge/hfs/rules.yaml` from hfs (it was a hand-kept copy that drifted), and the new architecture files are listed.
|
|
28
|
+
- Removed: failure code `FE_BUTTON_NO_VARIANT`.
|
|
29
|
+
|
|
30
|
+
- Added: the codemod `page-navigation` (`starci app codemod page-navigation [--execute] [--json]`) for the grammar's page and navigation cuts: `PrimaryRailLayout` to `Grid kind="main-rail"` and `FormGrid` to `Grid kind="form"` (a `className` or a spread blocks the rewrite), and every use of `PageHeader`, `PageContainer`, `Breadcrumbs`, `FillViewport`, `Rail`, `NavigationFeatureNav`, `Subnav` and `BottomNav` is reported with a code and the way to `Page`, `Grid` or `TopBar`.
|
|
31
|
+
- Changed: `starci app add layout` draws `Grid kind="main-rail"` and `starci app add overlay --form` a `Grid kind="form"`; R323 to R325 law text follows (the page parts are not exports).
|
|
32
|
+
|
|
33
|
+
- Added: the codemod `section-frame` (`starci app codemod section-frame [--execute] [--json]`) for the sections of the grammar: `Region` with a `SectionHeader` first in it, and a `SectionHeader` leading a plain wrapper, become one `Section`; `Region label` becomes `Section title` (reported, since the heading is new); `CollectionItem`, `ItemActions` and `ToolbarActions` become fragments; `Text weight` becomes `isStrong`. Reported for a person: `List`, `ListItem`, `LeadingNumber`, `Label`, a head with an eyebrow, a context-intro or rank 1, the removed `Text` values, and a strong block `Text` that heads a group (`TEXT_HEADING_FAKE`).
|
|
34
|
+
|
|
35
|
+
- hfs: codemod `surface-card-kinds` (`starci app codemod surface-card-kinds`).
|
|
36
|
+
- Added: the `frames` component tier for apps (grammar audit 2026-11, decision 9): slot `fe.components` layers are `blocks, frames, composites, branches, leaves`, the architecture machine and the world check know it, and `starci app add frame <name>` writes one. The grammar package folders are `leaf`, `branch`, `composite` and `frame`.
|
|
37
|
+
- Added: the codemod `small-renames` (`starci app codemod small-renames [--execute] [--json]`) for the grammar's small cuts: `ChatBubble` to `MessageBubble`, `StatusDot` with text and `ConnectionStatus` to `Badge isDot`, `CloseButton` to `IconButton intent="dismiss"`, `TextAction href` to `Link`, `SurfaceCopyGroup` to `Stack`, the removed `variant`, `size="control"`, `isError` and `OtpInput` props, and `FlowGraph layout="orbit"` to `BubbleMap`; what a person decides is reported, not rewritten.
|
|
38
|
+
|
|
3
39
|
## 4.16.5 - 2026-11-01
|
|
4
40
|
|
|
5
41
|
- Added: R354 `FE_HEADING_ABOVE_CARD` (eslint-canon-fe `no-heading-above-card`).
|
|
@@ -313,7 +349,6 @@ Breaking for lite apps (rulings lite-official-postgres-image and lite-queue-bull
|
|
|
313
349
|
- Fixed: `starci app db baseline` judges the archive (`db/migrations/archive/`) as frozen history (R257): an archived file needs only its leading 14-digit stamp and an underscore, so a retired chain's snake_case names, impossible times (`20261014300000`) and shared stamps no longer refuse the cutover. The active chain keeps R213 in full (`<ts14>_<kebab>.sql` with a valid UTC stamp); R213 and R332 set no upper bound, so an active stamp after now is no breach. A file both active and archived is refused by name. The new version sorts after every archived and active file, and the baseline header names the last valid stamp, the boundary R257 judges.
|
|
314
350
|
- Fixed: a refusal over the migration chain lists every offender as `<file>: <reason> (<rule>)`, all at once: a misnamed or invalid-time active file (`db baseline`, `db restamp`, `db plan`), and for `db baseline` the `--stamp` boundary file, the committed-history findings (R257), the queue and cron findings (R258) and each active migration a schema-only dump cannot rebuild. Spec: `src/hfs-database-baseline.spec.mjs`.
|
|
315
351
|
|
|
316
|
-
|
|
317
352
|
## 4.10.1 - 2026-10-29
|
|
318
353
|
|
|
319
354
|
- Changed: the bundled slot manifest 3.10.0 adds the lite slot `be.platform.database.entities`, the entity registry (slot fields `role: entity-registry` and `importedBy`, validated by the manifest shape); the bundled architecture machine reads it (`architecture/entity-registry.mjs`): `tier-direction` (R26 `BE_TIER_DIRECTION`) admits the registry's import of a domain or platform capability's `index.ts` that binds only that capability's `<c>Entities`, and `owner-export-bypass` (R30 `ARCH_OWNER_EXPORT_BYPASS`) admits a direct import of the registry from the files `importedBy` names (`apps/*/src/compose/*.ts`, `apps/*/src/app.module.ts`, `src/tests/world/**`); every other import of either shape stays refused, and full has no registry; the bundled canon pins name hfs 4.10.1, eslint-canon-be 3.7.1, eslint-canon-fe 9.5.1 and the Grammar family 0.15.12; the lite skeleton's `platform/database/entities.ts` names its importers (app composition and the test world).
|
|
@@ -331,7 +366,6 @@ Breaking for lite apps (ruling lite-self-hosted-postgres-passport-minio; the mig
|
|
|
331
366
|
- Changed: the bundled lite pattern knowledge (event-bus, idempotency, jobs, projections, queues, reactors, realtime, saga, webhooks) names db migrations and the local db service instead of Supabase; the command policy points `supabase` at `starci app db` and the db scripts.
|
|
332
367
|
- Changed: the bundled slot manifest 3.9.0 replaces the lite `app.supabase*` slots with `app.db` (`db/migrations`, `db/migrations/archive`, `db/seed.sql`, the managed `db/bootstrap.sql`), `be.integrations.supabase` with `be.integrations.passport` and `be.integrations.object-storage`, removes `fe.route.callback`, sets lite `schemaAuthority: sql`, gives lite TypeORM entities in `be.persistence`, adds `ruleParams.be.dbFunctions`, and moves the S3 SDKs to `integrations/object-storage` and the identity libraries to `integrations/passport` in `infraOwners` (`@supabase/*`, `minio` and `bcrypt` nowhere); the bundled rule catalog retires R216, R217, R219, R254 and R287 and adds R328-R334; the bundled canon pins name hfs 4.10.0, eslint-canon-be 3.7.0, eslint-canon-fe 9.5.0 and the Grammar family 0.15.11, pin `@nestjs/passport` 11.0.5 (CommonJS on the Nest 11 line), `passport` 0.7.0, `passport-google-oauth20` 2.0.0, `passport-local` 1.0.0, `bcryptjs` 3.0.3 and their types (tokens are signed and verified with `jose`; `@nestjs/jwt` stays allowed in `integrations/passport` but unpinned), and drop `@supabase/ssr`, `@supabase/supabase-js` and the `supabase` CLI.
|
|
333
368
|
|
|
334
|
-
|
|
335
369
|
## 4.9.11 - 2026-10-29
|
|
336
370
|
|
|
337
371
|
- Added: `starci app db drift [--url-env <NAME>] [--json]` (`db/drift.mjs`, owner ruling 2026-10-29: lite tables are mapped by TypeORM entities while `db/migrations/*.sql` stays the only DDL and `synchronize` is false). It builds TypeORM's metadata from `be/src/platform/database/entities.ts` with the app's own typescript, typeorm and pg (a `DataSource` that never connects), applies `db/bootstrap.sql` and every active migration to a scratch container of the app's `components.db` image (or the empty database `--url-env` names), and reports every missing table or column, type mismatch and nullability mismatch as `BE_ENTITY_SCHEMA_DRIFT` (R333 in the bundled rule catalog, with its failure-code entry). No Docker, no app packages or an unreadable target is no verdict (`DB_DRIFT_NEEDS_DATABASE`, exit 2), never a green; a migration that does not apply to an empty database is `DB_DRIFT_MIGRATION_FAILED`. An app with an entity list gets a `db-drift` row in its release gate (default or declared gates) and `npx starci app db drift` after `npm run db:lint` in its managed CI's build job. Specs: `src/db-drift.spec.mjs` (real TypeORM metadata over a fixture app, stubbed schema) and `src/db-drift-docker.spec.mjs` (a real postgres:17-alpine scratch database where Docker runs, else the no-verdict answer); typeorm 0.3.31 and pg 8.23.1 are dev dependencies for them.
|
|
@@ -669,7 +703,6 @@ Breaking for lite apps (ruling lite-self-hosted-postgres-passport-minio; the mig
|
|
|
669
703
|
- Changed: `HFS_DUPLICATE_CODE` drops canon scaffold shapes; `HFS_UNUSED_EXPORT` counts staged test consumers; `FE_I18N_KEYS` reads in-segment template keys (`t(`stage_${x}`)`) as globs.
|
|
670
704
|
- Changed: the scaffolded pre-commit hook passes staged files to eslint and prettier in chunks of 50 (Windows command-line limit); `DB_MIGRATION_SHAPE` orders a new migration against HEAD's active migrations, not the clock or the base branch.
|
|
671
705
|
|
|
672
|
-
|
|
673
706
|
- Added: HFS manifest 3.1.0 (rule catalog 3.1.0, owner ruling 2026-10-14, R245 and R246): every `be.app.*` slot allows the composition groups `compose/<area>.ts` and budgets its composition files (`app.module.ts` 250, `<app>.options.ts` 200, `compose/<area>.ts` 120, `main.ts` 80); `<app>.options.ts` holds env-derived options and port bindings only (`BE_APP_OPTIONS_COMPOSITION`, `BE_APP_COMPOSITION_BUDGET`).
|
|
674
707
|
- Changed: `starci app scaffold` writes `apps/<api app>/src/compose/platform.ts` (the platform capabilities as one group, `platformModules(options)`) in both editions, and the scaffolded `app.module.ts` spreads it before the domain modules and the guards.
|
|
675
708
|
- Changed: `add table` lists the table's error kinds where the app keeps its error kinds array: the root `app.module.ts`, or else the `compose/` group that holds it.
|
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ Every command runs at the app root. A side is judged with its folder as the root
|
|
|
10
10
|
```sh
|
|
11
11
|
starci app scaffold <name> [--edition full|lite] [--cwd <dir>]
|
|
12
12
|
starci app add <api|webhook|realtime|saga|job|reactor|queue|projection|cli|table|app> <name> [--fe] [--no-types] [options] [--cwd <dir>]
|
|
13
|
-
starci app add <block|composite|branch|leaf> <name> [--shapes a,b,c] [--read <read>] [--app <fe app>] [--cwd <dir>]
|
|
13
|
+
starci app add <block|frame|composite|branch|leaf> <name> [--shapes a,b,c] [--read <read>] [--app <fe app>] [--cwd <dir>]
|
|
14
14
|
starci app add <page|layout> <route> [--blocks a,b] [--tabs a,b] [--app <fe app>] [--cwd <dir>]
|
|
15
15
|
starci app add overlay <name> --kind modal|drawer [--form] [--app <fe app>] [--cwd <dir>]
|
|
16
16
|
starci app add service <dir> <name> [--inject <binding>...] [--cwd <dir>]
|
|
@@ -41,13 +41,13 @@ and `contexts/ui`, from `templates/fe/units`, with its copy in every catalog; no
|
|
|
41
41
|
- `page <route>`: `app/[locale]/<route>/{page,loading,error}.tsx`, the server page owner `<Name>Page` drawing one Grammar
|
|
42
42
|
`Page` (R325) whose header carries the trail and the heading and whose body composes `--blocks` (blocks that take no input;
|
|
43
43
|
else the page's empty sentence) and, with `--tabs a,b`, the page tabs in its header as link tabs (the first tab the route, each
|
|
44
|
-
other one its sub-route `<route>/<tab>`, the selected tab the current path's), its loading view `<Name>LoadingPage` (the same page drawn with `
|
|
44
|
+
other one its sub-route `<route>/<tab>`, the selected tab the current path's), its loading view `<Name>LoadingPage` (the same page drawn with `isLoading`, STATE-8),
|
|
45
45
|
`<Name>ErrorPage` (the page with one negative Alert), and the stories pairing the page with its loading view.
|
|
46
46
|
- `overlay <name> --kind modal|drawer`: `features/overlays/<Name>Overlay`, a `Dialog` or a trailing `Drawer` whose connected
|
|
47
47
|
half owns its open state; its decision group puts the way out first and the commit last, always in the footer; with `--form`
|
|
48
|
-
the body holds a Form with an `id` over a one-column `
|
|
48
|
+
the body holds a Form with an `id` over a one-column `Grid kind="form"` and the footer's commit submits it by `form="<id>"`; stories draw it closed and open.
|
|
49
49
|
- `layout <segment>`: `app/[locale]/<segment>/layout.tsx` and `features/layouts/<Name>Layout`, which composes Grammar's
|
|
50
|
-
`
|
|
50
|
+
`Grid kind="main-rail"`: the routed page in its primary region, `--blocks` in its rail. A frame never insets a page.
|
|
51
51
|
|
|
52
52
|
## Lite edition
|
|
53
53
|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** The TypeScript compiler API of the app's own install (loadTypescript), typed by the version this package develops against. */
|
|
2
|
+
type Ts = typeof import('typescript');
|
|
3
|
+
/** One safe rewrite of the report. */
|
|
4
|
+
export interface LoadingStatesChange {
|
|
5
|
+
file: string;
|
|
6
|
+
line: number;
|
|
7
|
+
column: number;
|
|
8
|
+
code: string;
|
|
9
|
+
from: string;
|
|
10
|
+
to: string;
|
|
11
|
+
}
|
|
12
|
+
/** One case left for a person. */
|
|
13
|
+
export interface LoadingStatesFinding {
|
|
14
|
+
file: string;
|
|
15
|
+
line: number;
|
|
16
|
+
column: number;
|
|
17
|
+
code: string;
|
|
18
|
+
reason: string;
|
|
19
|
+
component?: string;
|
|
20
|
+
source?: string;
|
|
21
|
+
}
|
|
22
|
+
/** The report of a migration (`starci app codemod loading-states`). */
|
|
23
|
+
export interface LoadingStatesReport {
|
|
24
|
+
schema: string;
|
|
25
|
+
root: string;
|
|
26
|
+
write: boolean;
|
|
27
|
+
filesScanned: number;
|
|
28
|
+
filesChanged: string[];
|
|
29
|
+
changes: LoadingStatesChange[];
|
|
30
|
+
findings: LoadingStatesFinding[];
|
|
31
|
+
}
|
|
32
|
+
/** Migrate the loading-state vocabulary with source edits; a case with no mechanical rewrite keeps its source and gains a finding. */
|
|
33
|
+
export declare function transformLoadingStates({ ts, text, file }: {
|
|
34
|
+
ts: Ts;
|
|
35
|
+
text: string;
|
|
36
|
+
file?: string;
|
|
37
|
+
}): {
|
|
38
|
+
text: string;
|
|
39
|
+
changes: LoadingStatesChange[];
|
|
40
|
+
findings: LoadingStatesFinding[];
|
|
41
|
+
};
|
|
42
|
+
/** Preview or apply the safe rewrites in one app (its fe/ tree, else the root); every unresolved case is retained in the returned report. */
|
|
43
|
+
export declare function migrateLoadingStates({ root, write, ts }: {
|
|
44
|
+
root: string;
|
|
45
|
+
write?: boolean;
|
|
46
|
+
ts?: Ts | null;
|
|
47
|
+
}): LoadingStatesReport;
|
|
48
|
+
/** Render the migration and what is left for a person as Markdown; JSON consumers read the same report object. */
|
|
49
|
+
export declare function loadingStatesMarkdown(report: LoadingStatesReport): string;
|
|
50
|
+
export {};
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { loadTypescript } from '../runtime/scripts/lib/package-at.mjs';
|
|
5
|
+
import { sourceLocation } from '../runtime/scripts/lib/ts-ast.mjs';
|
|
6
|
+
import { shadowed } from './small-renames.mjs';
|
|
7
|
+
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
8
|
+
const GRAMMAR = /^@starci\/grammar(?:\/|$)/;
|
|
9
|
+
const GENERATED = new Set(['node_modules', '.next', '.turbo', 'dist', 'build', 'coverage', '.git', 'storybook-static']);
|
|
10
|
+
/** A file worth parsing at all: it names something this codemod looks at. */
|
|
11
|
+
const RELEVANT = /isSkeleton|loadingRows|loadingContent|DataSlot|Skeleton|Placeholder|isValidating|Slot\b|\?\?\s*\[\s*\]|\brows\b/;
|
|
12
|
+
/** Grammar components that lost `loadingRows`: the Grammar draws its own default rows. */
|
|
13
|
+
const LOADING_ROWS = new Set(['Section', 'SurfaceCard', 'ListBox', 'Timeline', 'DataTable']);
|
|
14
|
+
const HAND_SKELETONS = new Set(['Skeleton', 'ChatBubbleSkeletons', 'MessageBubbleSkeletons']);
|
|
15
|
+
/** The status flags a DataSlot member is made of; `items` belongs to the ready member only. */
|
|
16
|
+
const FLAG_KEYS = new Set(['isLoading', 'isError', 'isForbidden', 'isEmpty']);
|
|
17
|
+
const SLOT_LITERAL_REASON = 'build the slot with slotOfQuery, slotOfReads, mapSlot, or one flag member';
|
|
18
|
+
const LOADING_NAME = /^(?:isLoading|loading|isPending|status)$/;
|
|
19
|
+
const STATUS_KEYS = new Set(['isLoading', 'isError', 'error']);
|
|
20
|
+
const SLOT_NAME = /slot$/i;
|
|
21
|
+
const TEST_FILE = /\.(?:spec|test)\.[cm]?[jt]sx?$/;
|
|
22
|
+
const VALIDATING_REASON = 'a background refresh never drives a loading flag or a status: a skeleton is isLoading (no data yet) alone';
|
|
23
|
+
/** Migrate the loading-state vocabulary with source edits; a case with no mechanical rewrite keeps its source and gains a finding. */
|
|
24
|
+
export function transformLoadingStates({ ts, text, file = 'component.tsx' }) {
|
|
25
|
+
const changes = [];
|
|
26
|
+
const findings = [];
|
|
27
|
+
if (!RELEVANT.test(text))
|
|
28
|
+
return { text, changes, findings };
|
|
29
|
+
const syntax = ts.transpileModule(text, { fileName: file, reportDiagnostics: true, compilerOptions: { jsx: ts.JsxEmit.Preserve, target: ts.ScriptTarget.Latest } }).diagnostics ?? [];
|
|
30
|
+
if (syntax.length)
|
|
31
|
+
return { text, changes, findings: [{ file, line: 1, column: 1, code: 'PARSE_ERROR', reason: ts.flattenDiagnosticMessageText(syntax[0]?.messageText ?? '', '\n') }] };
|
|
32
|
+
const kind = /\.tsx$/i.test(file) ? ts.ScriptKind.TSX : /\.jsx$/i.test(file) ? ts.ScriptKind.JSX : /\.[cm]?js$/i.test(file) ? ts.ScriptKind.JS : ts.ScriptKind.TS;
|
|
33
|
+
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, kind);
|
|
34
|
+
const normalized = file.replaceAll('\\', '/');
|
|
35
|
+
const reportsFindings = !TEST_FILE.test(normalized);
|
|
36
|
+
const inHooks = /(?:^|\/)hooks\//.test(normalized) && !/(?:^|\/)hooks\/swr\//.test(normalized);
|
|
37
|
+
const flag = (node, code, reason, component) => {
|
|
38
|
+
if (!reportsFindings)
|
|
39
|
+
return;
|
|
40
|
+
findings.push({ file, ...sourceLocation(source, node), code, reason, ...(component === undefined ? {} : { component }), source: node.getText(source).slice(0, 200) });
|
|
41
|
+
};
|
|
42
|
+
const change = (node, code, from, to) => { changes.push({ file, ...sourceLocation(source, node), code, from, to }); };
|
|
43
|
+
const edits = [];
|
|
44
|
+
const edit = (start, end, replacement) => { edits.push({ start, end, text: replacement }); };
|
|
45
|
+
// Grammar imports: bindings by local name and namespaces.
|
|
46
|
+
const bindings = new Map();
|
|
47
|
+
const namespaces = new Set();
|
|
48
|
+
for (const statement of source.statements) {
|
|
49
|
+
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier) || !GRAMMAR.test(statement.moduleSpecifier.text))
|
|
50
|
+
continue;
|
|
51
|
+
const named = statement.importClause?.namedBindings;
|
|
52
|
+
if (named && ts.isNamespaceImport(named))
|
|
53
|
+
namespaces.add(named.name.text);
|
|
54
|
+
if (named && ts.isNamedImports(named))
|
|
55
|
+
for (const specifier of named.elements)
|
|
56
|
+
bindings.set(specifier.name.text, specifier.propertyName?.text ?? specifier.name.text);
|
|
57
|
+
}
|
|
58
|
+
/** The Grammar export a JSX tag names (through an import, an alias or a namespace), or null. */
|
|
59
|
+
const grammarExport = (tag) => {
|
|
60
|
+
if (ts.isIdentifier(tag))
|
|
61
|
+
return shadowed(ts, tag) ? null : bindings.get(tag.text) ?? null;
|
|
62
|
+
if (ts.isPropertyAccessExpression(tag) && ts.isIdentifier(tag.expression) && namespaces.has(tag.expression.text) && !shadowed(ts, tag.expression))
|
|
63
|
+
return tag.name.text;
|
|
64
|
+
return null;
|
|
65
|
+
};
|
|
66
|
+
const tagLocalName = (tag) => ts.isIdentifier(tag) ? tag.text : ts.isPropertyAccessExpression(tag) ? tag.name.text : null;
|
|
67
|
+
const attributeOf = (opening, name) => opening.attributes.properties.find((attribute) => ts.isJsxAttribute(attribute) && attribute.name.getText(source) === name);
|
|
68
|
+
// Data slots: names declared with a `DataSlot<...>` annotation (variable, parameter or property signature) anywhere in the file.
|
|
69
|
+
const isSlotType = (type) => {
|
|
70
|
+
if (!type)
|
|
71
|
+
return false;
|
|
72
|
+
if (ts.isParenthesizedTypeNode(type))
|
|
73
|
+
return isSlotType(type.type);
|
|
74
|
+
if (ts.isUnionTypeNode(type))
|
|
75
|
+
return type.types.some(isSlotType);
|
|
76
|
+
if (!ts.isTypeReferenceNode(type))
|
|
77
|
+
return false;
|
|
78
|
+
const name = type.typeName;
|
|
79
|
+
return (ts.isIdentifier(name) ? name.text : name.right.text) === 'DataSlot';
|
|
80
|
+
};
|
|
81
|
+
const nameOf = (name) => ts.isIdentifier(name) || ts.isStringLiteral(name) ? name.text : null;
|
|
82
|
+
const slotNames = new Set();
|
|
83
|
+
const collect = (node) => {
|
|
84
|
+
if ((ts.isVariableDeclaration(node) || ts.isParameter(node) || ts.isPropertySignature(node) || ts.isPropertyDeclaration(node)) && isSlotType(node.type)) {
|
|
85
|
+
const name = nameOf(node.name);
|
|
86
|
+
if (name)
|
|
87
|
+
slotNames.add(name);
|
|
88
|
+
}
|
|
89
|
+
ts.forEachChild(node, collect);
|
|
90
|
+
};
|
|
91
|
+
collect(source);
|
|
92
|
+
/** Functions of the file declared to return a DataSlot: an object literal passed to one is in a slot context. */
|
|
93
|
+
const slotFunctions = new Set();
|
|
94
|
+
for (const statement of source.statements) {
|
|
95
|
+
if (ts.isFunctionDeclaration(statement) && statement.name && isSlotType(statement.type))
|
|
96
|
+
slotFunctions.add(statement.name.text);
|
|
97
|
+
if (ts.isVariableStatement(statement))
|
|
98
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
99
|
+
const init = declaration.initializer;
|
|
100
|
+
if (ts.isIdentifier(declaration.name) && init && (ts.isArrowFunction(init) || ts.isFunctionExpression(init)) && isSlotType(init.type))
|
|
101
|
+
slotFunctions.add(declaration.name.text);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const unwrap = (node) => {
|
|
105
|
+
let current = node;
|
|
106
|
+
while (ts.isParenthesizedExpression(current) || ts.isNonNullExpression(current))
|
|
107
|
+
current = current.expression;
|
|
108
|
+
return current;
|
|
109
|
+
};
|
|
110
|
+
/** An identifier or property access named like a declared DataSlot. */
|
|
111
|
+
const isSlotRef = (node) => {
|
|
112
|
+
const value = unwrap(node);
|
|
113
|
+
if (ts.isIdentifier(value))
|
|
114
|
+
return slotNames.has(value.text);
|
|
115
|
+
return ts.isPropertyAccessExpression(value) && slotNames.has(value.name.text);
|
|
116
|
+
};
|
|
117
|
+
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
118
|
+
/** Whether `node` sits in a branch that narrows `receiver` by a flag or by `"items" in receiver`. */
|
|
119
|
+
const narrowed = (node, receiver) => {
|
|
120
|
+
const name = escapeRegExp(receiver);
|
|
121
|
+
const narrows = new RegExp(`"items"\\s+in\\s+${name}\\b|\\b${name}\\.is(?:Loading|Error|Forbidden|Empty)\\b`);
|
|
122
|
+
const mentions = (expression) => narrows.test(expression.getText(source).replaceAll('?.', '.'));
|
|
123
|
+
for (let child = node, at = node.parent; at; child = at, at = at.parent) {
|
|
124
|
+
if (ts.isIfStatement(at) && at.thenStatement === child && mentions(at.expression))
|
|
125
|
+
return true;
|
|
126
|
+
if (ts.isConditionalExpression(at) && at.whenTrue === child && mentions(at.condition))
|
|
127
|
+
return true;
|
|
128
|
+
if (ts.isBinaryExpression(at) && at.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken && at.right === child && mentions(at.left))
|
|
129
|
+
return true;
|
|
130
|
+
if (ts.isCaseClause(at) && ts.isCaseBlock(at.parent) && ts.isSwitchStatement(at.parent.parent) && mentions(at.parent.parent.expression))
|
|
131
|
+
return true;
|
|
132
|
+
if (ts.isFunctionLike(at) || ts.isSourceFile(at))
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
return false;
|
|
136
|
+
};
|
|
137
|
+
const hasKey = (literal, matches) => literal.properties.some(property => (ts.isPropertyAssignment(property) || ts.isShorthandPropertyAssignment(property)) && matches(nameOf(property.name) ?? ''));
|
|
138
|
+
const slotLiterals = new Set();
|
|
139
|
+
const legacyLiterals = new Set();
|
|
140
|
+
const itemsOf = (literal) => literal.properties.find((property) => ts.isPropertyAssignment(property) && nameOf(property.name) === 'items');
|
|
141
|
+
const isUndefined = (expression) => { const value = unwrap(expression); return ts.isIdentifier(value) && value.text === 'undefined'; };
|
|
142
|
+
/** The object literals a typed slot position yields: the expression itself, through parentheses and conditional branches. */
|
|
143
|
+
const literalsOf = (expression) => {
|
|
144
|
+
if (!expression)
|
|
145
|
+
return [];
|
|
146
|
+
const value = unwrap(expression);
|
|
147
|
+
if (ts.isObjectLiteralExpression(value))
|
|
148
|
+
return [value];
|
|
149
|
+
if (ts.isConditionalExpression(value))
|
|
150
|
+
return [...literalsOf(value.whenTrue), ...literalsOf(value.whenFalse)];
|
|
151
|
+
return [];
|
|
152
|
+
};
|
|
153
|
+
/** Whether the file reads the status of the read `name`: `name.isLoading`, `name.error`, or `isLoading` destructured (for a bare `data`). */
|
|
154
|
+
const readsStatus = (name) => {
|
|
155
|
+
const escaped = escapeRegExp(name);
|
|
156
|
+
return new RegExp(`\\b${escaped}\\??\\.(?:isLoading|error|isError)\\b`).test(text) || new RegExp(`\\{[^}]*\\b(?:isLoading|error)\\b[^}]*\\}\\s*=\\s*${escaped}\\b`).test(text) || (name === 'data' && /\bisLoading\b/.test(text));
|
|
157
|
+
};
|
|
158
|
+
const rootOf = (input) => {
|
|
159
|
+
let current = unwrap(input);
|
|
160
|
+
for (;;) {
|
|
161
|
+
if (ts.isIdentifier(current))
|
|
162
|
+
return current.text;
|
|
163
|
+
if (ts.isPropertyAccessExpression(current) || ts.isElementAccessExpression(current) || ts.isCallExpression(current))
|
|
164
|
+
current = unwrap(current.expression);
|
|
165
|
+
else
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
const placeholder = (node, initializer) => {
|
|
170
|
+
if (!initializer)
|
|
171
|
+
return;
|
|
172
|
+
let value = initializer;
|
|
173
|
+
while (ts.isParenthesizedExpression(value) || ts.isAsExpression(value) || ts.isSatisfiesExpression(value))
|
|
174
|
+
value = value.expression;
|
|
175
|
+
if (!ts.isObjectLiteralExpression(value))
|
|
176
|
+
return;
|
|
177
|
+
const emptied = value.properties.some(property => ts.isPropertyAssignment(property) && ts.isArrayLiteralExpression(property.initializer) && property.initializer.elements.length === 0);
|
|
178
|
+
if (emptied)
|
|
179
|
+
flag(node, 'PLACEHOLDER_OBJECT', 'a placeholder is the ready tree with its data stripped to []: let the component rest as itself with a loading flag');
|
|
180
|
+
};
|
|
181
|
+
/** Whether `node` is inside a JSX attribute of an element that resolves to a Grammar export (a breadcrumb item, for one). */
|
|
182
|
+
const insideGrammarAttribute = (node) => {
|
|
183
|
+
for (let at = node.parent; at; at = at.parent) {
|
|
184
|
+
if (ts.isJsxAttribute(at)) {
|
|
185
|
+
const opening = at.parent.parent;
|
|
186
|
+
return (ts.isJsxOpeningElement(opening) || ts.isJsxSelfClosingElement(opening)) && grammarExport(opening.tagName) !== null;
|
|
187
|
+
}
|
|
188
|
+
if (ts.isSourceFile(at))
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
return false;
|
|
192
|
+
};
|
|
193
|
+
const visit = (node) => {
|
|
194
|
+
if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
|
|
195
|
+
const exported = grammarExport(node.tagName);
|
|
196
|
+
const local = tagLocalName(node.tagName);
|
|
197
|
+
if (exported !== null) {
|
|
198
|
+
const skeleton = attributeOf(node, 'isSkeleton');
|
|
199
|
+
if (skeleton && attributeOf(node, 'isLoading'))
|
|
200
|
+
flag(skeleton, 'LOADING_NAME_CONFLICT', 'the element has isSkeleton and isLoading both: merge them into isLoading by hand', exported);
|
|
201
|
+
else if (skeleton) {
|
|
202
|
+
edit(skeleton.name.getStart(source), skeleton.name.end, 'isLoading');
|
|
203
|
+
change(skeleton, 'RENAME_IS_SKELETON', 'isSkeleton', 'isLoading');
|
|
204
|
+
}
|
|
205
|
+
const loadingRows = attributeOf(node, 'loadingRows');
|
|
206
|
+
if (loadingRows && LOADING_ROWS.has(exported)) {
|
|
207
|
+
edit(loadingRows.getFullStart(), loadingRows.end, '');
|
|
208
|
+
change(loadingRows, 'REMOVE_LOADING_ROWS', loadingRows.getText(source), '');
|
|
209
|
+
}
|
|
210
|
+
const rows = attributeOf(node, 'rows');
|
|
211
|
+
if (rows && exported === 'MessageBubble' && (skeleton || attributeOf(node, 'isLoading'))) {
|
|
212
|
+
edit(rows.getFullStart(), rows.end, '');
|
|
213
|
+
change(rows, 'REMOVE_BUBBLE_ROWS', rows.getText(source), '');
|
|
214
|
+
}
|
|
215
|
+
const content = attributeOf(node, 'loadingContent');
|
|
216
|
+
if (content && exported === 'DataTable')
|
|
217
|
+
flag(content, 'LOADING_CONTENT', 'loadingContent is removed: the table draws its own loading rows', exported);
|
|
218
|
+
}
|
|
219
|
+
if (local !== null && HAND_SKELETONS.has(local))
|
|
220
|
+
flag(node, 'HAND_SKELETON', 'the Grammar component draws its loading structure from isLoading; pass the status, not a skeleton', local);
|
|
221
|
+
}
|
|
222
|
+
if (ts.isPropertyAccessExpression(node) && node.name.text === 'items' && isSlotRef(node.expression) && !narrowed(node, unwrap(node.expression).getText(source).replaceAll('?.', '.'))) {
|
|
223
|
+
flag(node, 'SLOT_ITEMS_ACCESS', 'items exists only on the ready member: narrow with `"items" in slot` or render through SlotView');
|
|
224
|
+
}
|
|
225
|
+
if ((ts.isVariableDeclaration(node) || ts.isPropertyDeclaration(node)) && isSlotType(node.type))
|
|
226
|
+
for (const literal of literalsOf(node.initializer))
|
|
227
|
+
slotLiterals.add(literal);
|
|
228
|
+
if (ts.isArrowFunction(node) && isSlotType(node.type) && !ts.isBlock(node.body))
|
|
229
|
+
for (const literal of literalsOf(node.body))
|
|
230
|
+
slotLiterals.add(literal);
|
|
231
|
+
if (ts.isReturnStatement(node)) {
|
|
232
|
+
for (let at = node.parent; at; at = at.parent) {
|
|
233
|
+
if (!ts.isFunctionLike(at))
|
|
234
|
+
continue;
|
|
235
|
+
if (isSlotType(at.type))
|
|
236
|
+
for (const literal of literalsOf(node.expression))
|
|
237
|
+
slotLiterals.add(literal);
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (ts.isObjectLiteralExpression(node) && hasKey(node, key => key === 'items') && hasKey(node, key => FLAG_KEYS.has(key)))
|
|
242
|
+
legacyLiterals.add(node);
|
|
243
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && slotFunctions.has(node.expression.text))
|
|
244
|
+
for (const argument of node.arguments)
|
|
245
|
+
for (const literal of literalsOf(argument))
|
|
246
|
+
slotLiterals.add(literal);
|
|
247
|
+
if (ts.isParameter(node) && isSlotType(node.type))
|
|
248
|
+
for (const literal of literalsOf(node.initializer))
|
|
249
|
+
slotLiterals.add(literal);
|
|
250
|
+
if (inHooks && ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken && ts.isArrayLiteralExpression(node.right) && node.right.elements.length === 0) {
|
|
251
|
+
const left = unwrap(node.left);
|
|
252
|
+
const isData = (ts.isPropertyAccessExpression(left) && left.name.text === 'data') || (ts.isIdentifier(left) && left.text === 'data');
|
|
253
|
+
const isMap = ts.isCallExpression(left) && ts.isPropertyAccessExpression(left.expression) && left.expression.name.text === 'map';
|
|
254
|
+
const root = isData || isMap ? rootOf(left) : null;
|
|
255
|
+
if (root !== null && !readsStatus(root))
|
|
256
|
+
flag(node, 'SWR_DEFAULT_ARRAY', "return the read's status with the list, or fold every read of the frame with slotOfReads");
|
|
257
|
+
}
|
|
258
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) {
|
|
259
|
+
if (LOADING_NAME.test(node.name.text) && node.initializer && /\bisValidating\b/.test(node.initializer.getText(source)))
|
|
260
|
+
flag(node, 'VALIDATING_AS_LOADING', VALIDATING_REASON);
|
|
261
|
+
if (/Placeholder$/.test(node.name.text))
|
|
262
|
+
placeholder(node, node.initializer);
|
|
263
|
+
}
|
|
264
|
+
if ((ts.isPropertyAssignment(node) || ts.isShorthandPropertyAssignment(node)) && nameOf(node.name) === 'isSkeleton' && insideGrammarAttribute(node)) {
|
|
265
|
+
if (ts.isPropertyAssignment(node) && !hasKey(node.parent, key => key === 'isLoading')) {
|
|
266
|
+
edit(node.name.getStart(source), node.name.end, 'isLoading');
|
|
267
|
+
change(node, 'RENAME_IS_SKELETON', 'isSkeleton', 'isLoading');
|
|
268
|
+
}
|
|
269
|
+
else
|
|
270
|
+
flag(node, 'IS_SKELETON_KEY', 'isSkeleton is now isLoading: rename the key by hand (a shorthand or a second isLoading key blocks the rewrite)');
|
|
271
|
+
}
|
|
272
|
+
if (ts.isPropertyAssignment(node)) {
|
|
273
|
+
const key = nameOf(node.name);
|
|
274
|
+
if (key !== null && LOADING_NAME.test(key) && /\bisValidating\b/.test(node.initializer.getText(source)))
|
|
275
|
+
flag(node, 'VALIDATING_AS_LOADING', VALIDATING_REASON);
|
|
276
|
+
if (key !== null && /Placeholder$/.test(key))
|
|
277
|
+
placeholder(node, node.initializer);
|
|
278
|
+
const value = unwrap(node.initializer);
|
|
279
|
+
if (key !== null && STATUS_KEYS.has(key) && ts.isPropertyAccessExpression(value) && value.name.text === key) {
|
|
280
|
+
const owner = unwrap(value.expression);
|
|
281
|
+
const ownerName = ts.isIdentifier(owner) ? owner.text : ts.isPropertyAccessExpression(owner) ? owner.name.text : null;
|
|
282
|
+
if (ownerName !== null && SLOT_NAME.test(ownerName))
|
|
283
|
+
flag(node, 'FOREIGN_SLOT_STATUS', "a slot takes its loading and error from its own reads, never from another slot's: build it with slotOfReads");
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
ts.forEachChild(node, visit);
|
|
287
|
+
};
|
|
288
|
+
visit(source);
|
|
289
|
+
for (const literal of slotLiterals) {
|
|
290
|
+
legacyLiterals.delete(literal);
|
|
291
|
+
const items = itemsOf(literal);
|
|
292
|
+
if (literal.properties.some(property => ts.isSpreadAssignment(property)) || (items && ts.isConditionalExpression(unwrap(items.initializer)))) {
|
|
293
|
+
flag(literal, 'SLOT_LITERAL', SLOT_LITERAL_REASON);
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
if (!items)
|
|
297
|
+
continue;
|
|
298
|
+
const others = literal.properties.filter(property => property !== items);
|
|
299
|
+
const flagged = others.filter(property => FLAG_KEYS.has(ts.isPropertyAssignment(property) || ts.isShorthandPropertyAssignment(property) ? nameOf(property.name) ?? '' : ''));
|
|
300
|
+
if (!isUndefined(items.initializer)) {
|
|
301
|
+
if (flagged.length)
|
|
302
|
+
flag(literal, 'SLOT_LITERAL', SLOT_LITERAL_REASON);
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (!others.length) {
|
|
306
|
+
edit(literal.getStart(source), literal.end, '{ isEmpty: true }');
|
|
307
|
+
change(literal, 'SLOT_LITERAL_FLAGS', literal.getText(source), '{ isEmpty: true }');
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
const [only] = others;
|
|
311
|
+
if (others.length === 1 && only && ts.isPropertyAssignment(only) && ['isLoading', 'isError', 'isForbidden'].includes(nameOf(only.name) ?? '') && only.initializer.kind === ts.SyntaxKind.TrueKeyword) {
|
|
312
|
+
edit(literal.getStart(source), literal.end, `{ ${only.getText(source)} }`);
|
|
313
|
+
change(literal, 'SLOT_LITERAL_FLAGS', literal.getText(source), `{ ${only.getText(source)} }`);
|
|
314
|
+
}
|
|
315
|
+
else
|
|
316
|
+
flag(literal, 'SLOT_LITERAL', SLOT_LITERAL_REASON);
|
|
317
|
+
}
|
|
318
|
+
for (const literal of legacyLiterals)
|
|
319
|
+
flag(literal, 'SLOT_LITERAL', SLOT_LITERAL_REASON);
|
|
320
|
+
edits.sort((first, second) => first.start - second.start || first.end - second.end);
|
|
321
|
+
const unique = [];
|
|
322
|
+
for (const current of edits) {
|
|
323
|
+
const previous = unique.at(-1);
|
|
324
|
+
if (previous && previous.start === current.start && previous.end === current.end && previous.text === current.text)
|
|
325
|
+
continue;
|
|
326
|
+
if (previous && current.start < previous.end) {
|
|
327
|
+
return { text, changes: [], findings: [...findings, { file, line: 1, column: 1, code: 'EDIT_CONFLICT', reason: 'two rewrites overlap: nothing in this file was changed; migrate by hand' }] };
|
|
328
|
+
}
|
|
329
|
+
unique.push(current);
|
|
330
|
+
}
|
|
331
|
+
let output = text;
|
|
332
|
+
for (const current of unique.reverse())
|
|
333
|
+
output = output.slice(0, current.start) + current.text + output.slice(current.end);
|
|
334
|
+
findings.sort((first, second) => first.line - second.line || first.column - second.column);
|
|
335
|
+
return { text: output, changes, findings };
|
|
336
|
+
}
|
|
337
|
+
function sourceFiles(directory) {
|
|
338
|
+
return fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
|
|
339
|
+
const target = path.join(directory, entry.name);
|
|
340
|
+
if (entry.isDirectory())
|
|
341
|
+
return GENERATED.has(entry.name) ? [] : sourceFiles(target);
|
|
342
|
+
return entry.isFile() && /\.tsx?$/i.test(entry.name) && !/\.d\.[cm]?ts$/i.test(entry.name) ? [target] : [];
|
|
343
|
+
}).sort();
|
|
344
|
+
}
|
|
345
|
+
/** Preview or apply the safe rewrites in one app (its fe/ tree, else the root); every unresolved case is retained in the returned report. */
|
|
346
|
+
export function migrateLoadingStates({ root, write = false, ts = loadTypescript(root, PACKAGE_ROOT) }) {
|
|
347
|
+
if (!ts)
|
|
348
|
+
throw new Error('loading-states needs TypeScript installed in the app or @starci/hfs development checkout');
|
|
349
|
+
const frontend = path.join(root, 'fe');
|
|
350
|
+
const base = fs.statSync(frontend, { throwIfNoEntry: false })?.isDirectory() ? frontend : root;
|
|
351
|
+
const report = { schema: 'starci/loading-states-report@1', root, write, filesScanned: 0, filesChanged: [], changes: [], findings: [] };
|
|
352
|
+
for (const target of sourceFiles(base)) {
|
|
353
|
+
const file = path.relative(root, target).split(path.sep).join('/');
|
|
354
|
+
const original = fs.readFileSync(target, 'utf8');
|
|
355
|
+
const result = transformLoadingStates({ ts, text: original, file });
|
|
356
|
+
report.filesScanned += 1;
|
|
357
|
+
report.changes.push(...result.changes);
|
|
358
|
+
report.findings.push(...result.findings);
|
|
359
|
+
if (result.text !== original) {
|
|
360
|
+
report.filesChanged.push(file);
|
|
361
|
+
if (write)
|
|
362
|
+
fs.writeFileSync(target, result.text);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return report;
|
|
366
|
+
}
|
|
367
|
+
const cell = (value) => String(value).replaceAll('|', '\\|').replaceAll('\n', ' ');
|
|
368
|
+
/** Render the migration and what is left for a person as Markdown; JSON consumers read the same report object. */
|
|
369
|
+
export function loadingStatesMarkdown(report) {
|
|
370
|
+
const out = ['# loading-states', '', `Scanned ${report.filesScanned} files, ${report.filesChanged.length} ${report.write ? 'changed' : 'would change'}, ${report.changes.length} rewrites, ${report.findings.length} left for a person.`, ''];
|
|
371
|
+
if (report.findings.length) {
|
|
372
|
+
out.push('## Left for a person', '', '| location | code | reason |', '|---|---|---|');
|
|
373
|
+
for (const finding of report.findings)
|
|
374
|
+
out.push(`| ${cell(`${finding.file}:${finding.line}:${finding.column}`)} | ${finding.code} | ${cell(finding.reason)} |`);
|
|
375
|
+
out.push('');
|
|
376
|
+
}
|
|
377
|
+
if (report.changes.length) {
|
|
378
|
+
out.push('## Rewrites', '', '| location | code | from | to |', '|---|---|---|---|');
|
|
379
|
+
for (const item of report.changes)
|
|
380
|
+
out.push(`| ${cell(`${item.file}:${item.line}:${item.column}`)} | ${item.code} | \`${cell(item.from)}\` | \`${cell(item.to)}\` |`);
|
|
381
|
+
out.push('');
|
|
382
|
+
}
|
|
383
|
+
return `${out.join('\n')}\n`;
|
|
384
|
+
}
|
package/codemod/main.d.mts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** The codemods `starci app codemod` runs, by name. */
|
|
2
2
|
export declare const CODEMODS: readonly string[];
|
|
3
|
-
/** The arguments of `starci app codemod spacing-roles`: --execute, --json, --format and --out; any other is refused. */
|
|
3
|
+
/** The arguments of `starci app codemod spacing-roles`, `theme-tokens`, `small-renames`, `page-navigation`, `section-frame`, `surface-card-kinds`, `tier-binding` and `loading-states`: --execute, --json, --format and --out; any other is refused. */
|
|
4
4
|
export declare function spacingRolesArguments(argv: readonly string[]): {
|
|
5
5
|
write: boolean;
|
|
6
6
|
format: 'json' | 'markdown';
|
package/codemod/main.mjs
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { codemodMain as buttonIntentMain } from './button-intent.mjs';
|
|
4
|
+
import { migratePageNavigation, pageNavigationMarkdown } from './page-navigation.mjs';
|
|
5
|
+
import { loadingStatesMarkdown, migrateLoadingStates } from './loading-states.mjs';
|
|
6
|
+
import { migrateSectionFrame, sectionFrameMarkdown } from './section-frame.mjs';
|
|
7
|
+
import { migrateSmallRenames, smallRenamesMarkdown } from './small-renames.mjs';
|
|
4
8
|
import { migrateSpacingRoles, spacingRolesMarkdown } from './spacing-roles.mjs';
|
|
9
|
+
import { migrateSurfaceCardKinds, surfaceCardKindsMarkdown } from './surface-card-kinds.mjs';
|
|
10
|
+
import { migrateThemeTokens, themeTokensMarkdown } from './theme-tokens.mjs';
|
|
11
|
+
import { migrateTierBinding, tierBindingMarkdown } from './tier-binding.mjs';
|
|
5
12
|
/** The codemods `starci app codemod` runs, by name. */
|
|
6
|
-
export const CODEMODS = Object.freeze(['button-intent', 'spacing-roles']);
|
|
7
|
-
/** The arguments of `starci app codemod spacing-roles`: --execute, --json, --format and --out; any other is refused. */
|
|
13
|
+
export const CODEMODS = Object.freeze(['button-intent', 'spacing-roles', 'theme-tokens', 'small-renames', 'page-navigation', 'section-frame', 'surface-card-kinds', 'tier-binding', 'loading-states']);
|
|
14
|
+
/** The arguments of `starci app codemod spacing-roles`, `theme-tokens`, `small-renames`, `page-navigation`, `section-frame`, `surface-card-kinds`, `tier-binding` and `loading-states`: --execute, --json, --format and --out; any other is refused. */
|
|
8
15
|
export function spacingRolesArguments(argv) {
|
|
9
16
|
const options = { write: false, format: 'markdown' };
|
|
10
17
|
const args = argv.values();
|
|
@@ -35,6 +42,39 @@ export function spacingRolesArguments(argv) {
|
|
|
35
42
|
}
|
|
36
43
|
return options;
|
|
37
44
|
}
|
|
45
|
+
/** One of the report-style codemods run at an app root: its report object and the Markdown of it. */
|
|
46
|
+
function runCodemod(name, root, write) {
|
|
47
|
+
if (name === 'theme-tokens') {
|
|
48
|
+
const report = migrateThemeTokens({ root, write });
|
|
49
|
+
return { report, markdown: themeTokensMarkdown(report) };
|
|
50
|
+
}
|
|
51
|
+
if (name === 'surface-card-kinds') {
|
|
52
|
+
const report = migrateSurfaceCardKinds({ root, write });
|
|
53
|
+
return { report, markdown: surfaceCardKindsMarkdown(report) };
|
|
54
|
+
}
|
|
55
|
+
if (name === 'tier-binding') {
|
|
56
|
+
const report = migrateTierBinding({ root, write });
|
|
57
|
+
return { report, markdown: tierBindingMarkdown(report) };
|
|
58
|
+
}
|
|
59
|
+
if (name === 'page-navigation') {
|
|
60
|
+
const report = migratePageNavigation({ root, write });
|
|
61
|
+
return { report, markdown: pageNavigationMarkdown(report) };
|
|
62
|
+
}
|
|
63
|
+
if (name === 'section-frame') {
|
|
64
|
+
const report = migrateSectionFrame({ root, write });
|
|
65
|
+
return { report, markdown: sectionFrameMarkdown(report) };
|
|
66
|
+
}
|
|
67
|
+
if (name === 'loading-states') {
|
|
68
|
+
const report = migrateLoadingStates({ root, write });
|
|
69
|
+
return { report, markdown: loadingStatesMarkdown(report) };
|
|
70
|
+
}
|
|
71
|
+
if (name === 'small-renames') {
|
|
72
|
+
const report = migrateSmallRenames({ root, write });
|
|
73
|
+
return { report, markdown: smallRenamesMarkdown(report) };
|
|
74
|
+
}
|
|
75
|
+
const report = migrateSpacingRoles({ root, write });
|
|
76
|
+
return { report, markdown: spacingRolesMarkdown(report) };
|
|
77
|
+
}
|
|
38
78
|
/**
|
|
39
79
|
* CLI boundary of `starci app codemod <name>`. Dry run by default; --execute writes only the safe rewrites. A codemod
|
|
40
80
|
* that leaves a case for a person exits 1 with the complete report.
|
|
@@ -43,13 +83,14 @@ export function codemodMain(argv, io) {
|
|
|
43
83
|
const [name, ...rest] = argv;
|
|
44
84
|
if (name === 'button-intent')
|
|
45
85
|
return buttonIntentMain(argv, io);
|
|
46
|
-
if (name !== 'spacing-roles')
|
|
86
|
+
if (name !== 'spacing-roles' && name !== 'theme-tokens' && name !== 'small-renames' && name !== 'page-navigation' && name !== 'section-frame' && name !== 'surface-card-kinds' && name !== 'tier-binding' && name !== 'loading-states')
|
|
47
87
|
throw new Error(`starci app codemod takes ${CODEMODS.join(' or ')}`);
|
|
48
88
|
const options = spacingRolesArguments(rest);
|
|
49
89
|
if (options.out && !/\.(?:json|md)$/i.test(options.out))
|
|
50
90
|
throw new Error('--out needs a .json or .md report file');
|
|
51
|
-
const
|
|
52
|
-
const
|
|
91
|
+
const root = path.resolve(io.cwd);
|
|
92
|
+
const { report, markdown } = runCodemod(name, root, options.write);
|
|
93
|
+
const output = options.format === 'json' ? `${JSON.stringify(report, null, 2)}\n` : markdown;
|
|
53
94
|
if (options.out) {
|
|
54
95
|
const target = path.resolve(io.cwd, options.out);
|
|
55
96
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|