@starci/hfs 5.3.0 → 5.5.0
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 +28 -0
- package/README.md +5 -3
- package/cache/test-family.d.mts +1 -1
- package/cache/test-family.mjs +20 -15
- package/codemod/account-doors.mjs +7 -1
- package/codemod/app-prettier.d.mts +5 -0
- package/codemod/app-prettier.mjs +27 -0
- package/codemod/explicit-surface.mjs +1 -25
- package/codemod/main.mjs +14 -5
- package/codemod/module-definition.d.mts +10 -3
- package/codemod/module-definition.mjs +32 -17
- package/codemod/seeds-layout.d.mts +36 -0
- package/codemod/seeds-layout.mjs +113 -0
- package/codemod/sops-rules.d.mts +1 -1
- package/codemod/sops-rules.mjs +8 -3
- package/lint/run.d.mts +5 -4
- package/lint/run.mjs +53 -27
- package/package.json +1 -1
- package/release/lint-report.mjs +4 -3
- package/runtime/knowledge/hfs/canon-pins.yaml +12 -12
- package/runtime/knowledge/hfs/ci-standard.yaml +2 -2
- package/runtime/knowledge/hfs/rules.yaml +15 -2
- package/runtime/knowledge/hfs/slots.yaml +15 -18
- package/runtime/modules/kernel/failure-codes.yaml +64 -3
- package/runtime/scripts/hfs/architecture/root-packages.mjs +1 -1
- package/runtime/scripts/hfs/check.mjs +4 -1
- package/runtime/scripts/hfs/coverage-scope.d.mts +15 -0
- package/runtime/scripts/hfs/coverage-scope.mjs +26 -0
- package/runtime/scripts/hfs/db-squash.mjs +9 -2
- package/runtime/scripts/hfs/declaration-shape.mjs +3 -1
- package/runtime/scripts/hfs/rules/app-root.mjs +4 -1
- package/runtime/scripts/hfs/rules/ci-standard.mjs +5 -4
- package/runtime/scripts/hfs/rules/coverage-below.mjs +12 -4
- package/runtime/scripts/hfs/rules/database-config.mjs +8 -7
- package/runtime/scripts/hfs/rules/database-constants.d.mts +2 -1
- package/runtime/scripts/hfs/rules/database-constants.mjs +2 -1
- package/runtime/scripts/hfs/rules/database.d.mts +2 -2
- package/runtime/scripts/hfs/rules/database.mjs +7 -6
- package/runtime/scripts/hfs/rules/docker.mjs +24 -7
- package/runtime/scripts/hfs/rules/monorepo.d.mts +1 -1
- package/runtime/scripts/hfs/rules/monorepo.mjs +1 -1
- package/runtime/scripts/hfs/rules/seeds.d.mts +21 -0
- package/runtime/scripts/hfs/rules/seeds.mjs +194 -0
- package/runtime/scripts/hfs/rules/stacks.mjs +8 -4
- package/runtime/scripts/hfs/slots.d.mts +2 -0
- package/runtime/scripts/hfs/slots.mjs +1 -0
- package/runtime/scripts/hfs/stack-seeds-config.d.mts +88 -0
- package/runtime/scripts/hfs/stack-seeds-config.mjs +239 -0
- package/runtime/scripts/hfs/view.d.mts +25 -0
- package/runtime/scripts/hfs/view.mjs +41 -0
- package/scaffold/add-app.mjs +2 -2
- package/scaffold/add-cli-lite.mjs +41 -14
- package/scaffold/add-module.mjs +6 -3
- package/scaffold/lite-exports.mjs +1 -1
- package/scaffold/module-registry.mjs +10 -8
- package/src/main.mjs +8 -8
- package/stack/deploy.d.mts +3 -0
- package/stack/deploy.mjs +4 -1
- package/stack/local-up.mjs +25 -3
- package/stack/seeds.d.mts +29 -0
- package/stack/seeds.mjs +197 -0
- package/sync/canon-layout.d.mts +38 -0
- package/sync/canon-layout.mjs +62 -0
- package/sync/ci-commands.d.mts +2 -1
- package/sync/ci-commands.mjs +10 -7
- package/sync/compile-guard.d.mts +36 -0
- package/sync/compile-guard.mjs +189 -0
- package/sync/index.d.mts +22 -3
- package/sync/index.mjs +80 -32
- package/templates/app/ci-workflows/github/workflows/ci.yml +2 -2
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +2 -2
- package/templates/app/skeleton/.starcistacks/application-stacks.yaml +26 -0
- package/templates/app/skeleton/.starcistacks/dev/README.md +21 -10
- package/templates/app/skeleton/.starcistacks/dev/seeds/data/.gitkeep +0 -0
- package/templates/app/skeleton/.starcistacks/dev/seeds/files/.gitkeep +0 -0
- package/templates/app/skeleton/.starcistacks/dev/seeds/seeds.yaml +3 -0
- package/templates/app/skeleton/.starcistacks/production/seeds/data/.gitkeep +0 -0
- package/templates/app/skeleton/.starcistacks/production/seeds/files/.gitkeep +0 -0
- package/templates/app/skeleton/.starcistacks/production/seeds/seeds.yaml +2 -0
- package/templates/app/skeleton/.starcistacks/production/seeds/sql/.gitkeep +0 -0
- package/templates/app/skeleton/.starcistacks/seeds/data/.gitkeep +0 -0
- package/templates/app/skeleton/.starcistacks/seeds/files/.gitkeep +0 -0
- package/templates/app/skeleton/.starcistacks/seeds/seeds.yaml +2 -0
- package/templates/app/skeleton/.starcistacks/seeds/sql/.gitkeep +0 -0
- package/templates/app/skeleton/README.md +1 -1
- package/templates/app/skeleton-lite/.starcistacks/dev/README.md +2 -2
- package/templates/app/skeleton-lite/.starcistacks/dev/seeds/data/.gitkeep +0 -0
- package/templates/app/skeleton-lite/.starcistacks/dev/seeds/files/.gitkeep +0 -0
- package/templates/app/skeleton-lite/.starcistacks/dev/seeds/seeds.yaml +2 -0
- package/templates/app/skeleton-lite/.starcistacks/dev/seeds/sql/.gitkeep +0 -0
- package/templates/app/skeleton-lite/.starcistacks/production/seeds/data/.gitkeep +0 -0
- package/templates/app/skeleton-lite/.starcistacks/production/seeds/files/.gitkeep +0 -0
- package/templates/app/skeleton-lite/.starcistacks/production/seeds/seeds.yaml +2 -0
- package/templates/app/skeleton-lite/.starcistacks/production/seeds/sql/.gitkeep +0 -0
- package/templates/app/skeleton-lite/.starcistacks/seeds/data/.gitkeep +0 -0
- package/templates/app/skeleton-lite/.starcistacks/seeds/files/.gitkeep +0 -0
- package/templates/app/skeleton-lite/.starcistacks/seeds/seeds.yaml +2 -0
- package/templates/app/skeleton-lite/.starcistacks/seeds/sql/.gitkeep +0 -0
- package/templates/app/skeleton-lite/README.md +2 -2
- package/templates/app/skeleton-lite/db/migrations/__timestamp___baseline.sql +3 -1
- package/templates/be/auth-lite/src/features/api/auth/application/auth.contracts.ts +2 -0
- package/templates/be/auth-lite/src/features/api/auth/application/signup.handler.ts +6 -1
- package/templates/be/auth-lite/src/features/api/auth/auth.module.ts +14 -1
- package/templates/be/auth-lite/src/features/api/auth/transport/http/auth.controller.ts +5 -1
- package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/reset.request.ts +0 -1
- package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/signup.request.ts +7 -0
- package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.spec.ts +15 -0
- package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.ts +9 -3
- package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.spec.ts +79 -16
- package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.ts +39 -9
- package/templates/be/auth-lite/src/modules/domain/identity/identity-email.service.spec.ts +108 -20
- package/templates/be/auth-lite/src/modules/domain/identity/identity-email.service.ts +34 -7
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/account-doors.port.ts +28 -4
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/identity-refresh-token.entity.ts +4 -0
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/index.ts +6 -1
- package/templates/be/auth-lite/src/modules/domain/identity/rate-counter.service.spec.ts +9 -2
- package/templates/be/auth-lite/src/modules/domain/identity/rate-counter.service.ts +11 -2
- package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.spec.ts +63 -10
- package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.ts +10 -2
- package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.spec.ts +25 -4
- package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.ts +8 -1
- package/templates/be/auth-lite/src/tests/fixtures/builders/identity.builder.ts +3 -1
- package/templates/be/skeleton/src/features/cli/seed/subs/run.cli.ts +3 -3
- package/templates/be/skeleton/src/modules/platform/database/database.sql.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.spec.ts +3 -2
- package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.ts +6 -2
- package/templates/be/skeleton/src/modules/platform/database/seed-runner.service.spec.ts +14 -9
- package/templates/be/skeleton/src/modules/platform/database/seed-runner.service.ts +3 -3
- package/templates/be/skeleton-lite/src/modules/platform/database/seed-runner.service.ts +32 -7
- package/templates/be/tool-config/jest.config.js +1 -1
- package/templates/fe/auth-lite/apps/web/src/modules/auth/email-doors.ts +4 -1
- package/templates/fe/skeleton-lite/apps/web/src/components/blocks/EmailDoorForm/component.tsx +3 -0
- package/templates/fe/skeleton-lite/apps/web/src/components/blocks/EmailDoorForm/index.tsx +14 -6
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/en.json +1 -0
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/vi.json +1 -0
- package/templates/app/module/jest.config.js.tpl +0 -8
- /package/templates/app/skeleton/.starcistacks/dev/seeds/{primary-notes.sql → sql/primary-notes.sql} +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 5.5.0 - 2026-11-03
|
|
4
|
+
|
|
5
|
+
- Changed: the back end's unit run holds the root packages (`packages/<name>` with a `package.json` and a `src/`, never the registry): their specs run in `npm test`, `app check --gate test` and the CI shards, and their logic is measured at 100 per file (the workbench, the public face and the barrels aside) inside the Sonar and Codecov scope. `test:modules`, the fast job's module-packages step and the per-package `jest.config.js` are removed; `starci app sync` renders the change (PROJECT-3, BE-TEST-13).
|
|
6
|
+
- Fixed: each lint scope runs on its own: a side whose ESLint child does not complete is a tool error of that side (exit 2), and the next side and the root packages are still judged; a host hold still ends the run. The ESLint liveness budget of a diff run scales with the files of the side, whose typed program the child builds, not with the length of the diff.
|
|
7
|
+
- Added: `app check --gate lint --side packages`; an app with root packages lints them in a job of the CI lint matrix (`[be, fe, packages]`) with their own Sonar report, and the release gate's lint report does the same.
|
|
8
|
+
- Added: a root workspace package declares its own error base and result union in its `package.json` (`starci.errorBase`, `starci.outcome`), read by the root-packages view (`readPackageContract`).
|
|
9
|
+
- Fixed: `app sync` renders the root `eslint.config.mjs` and the module registry (`tsconfig*.json`, `src/template`, `src/manifest`) as the canon Prettier config prints them, so an app's prettier hook passes them and formatting them is no longer `HFS_SYNC_DRIFT`; the managed lite sign-in templates are Prettier output.
|
|
10
|
+
- Added: hfs.json `"modules": "data"` declares that an app keeps its agent modules as data (`.starcistacks/seeds/data/modules`): `app sync` renders no registry package `packages/modules` (one the app keeps is its own code), and `app add|remove module` refuse `HFS_MODULES_ARE_DATA`. An app without the key keeps the module-packages render.
|
|
11
|
+
- Added: seed levels `.starcistacks/seeds/` (every environment, first) and `.starcistacks/<env>/seeds/`, each a `seeds.yaml` (`starci/seeds@1`) with `sql/`, `data/` and `files/` and entry kinds `sql`, `data`, `test-account` and `files`, judged by R375 (`HFS_SEEDS_SHAPE`, `HFS_SEED_MANIFEST`, `HFS_SEED_DDL`, `HFS_SEED_DATA_SCHEMA`, `HFS_SEED_TEST_DATA`, `HFS_SEED_SECRET_REF`); `HFS_SEED_PII` also reads JSON and YAML seed data; a production level holds test data only in its one declared `test-account` entry. New apps are scaffolded with the three levels.
|
|
12
|
+
- Added: the seed step (`stack/seeds.mts`): receipts in `public.starci_seed_receipts` (scope, id, content hash, secret member names with a digest), `once` and `upsert` entries, secret references (`{ "secret": "seeds-<name>" }`) opened in memory with the env's age identity and passed to the app's seed command on stdin, never to disk, argv, logs or receipts; `files` entries are checked only. `app stack up` runs it after the migrations, and the deploy after convergence, its dry run listing the entries (PROJECT-STACKS-6, PROJECT-STACKS-12).
|
|
13
|
+
- Changed: `db baseline --squash` writes `.starcistacks/seeds/sql/reference.sql` and declares the entry `reference`; the scaffold's `cli seed run` reads the shared then the env `sql/` folder (the lite one takes `--env`); slot `app.db.seed` is removed and R332 refuses `db/seed.sql`.
|
|
14
|
+
- Fixed: `app codemod sops-rules` keeps the comment header `.sops.yaml` opens with (the managed header of `secret init`).
|
|
15
|
+
- Fixed: a fresh `app scaffold --edition full` passes its own check: the dev environment declares the `local` block `app stack up --env dev` runs, and its runbook names the `starci app stack` verbs instead of docker commands.
|
|
16
|
+
- Fixed: the Next image of a front end that reads `be/contracts/` copies it only when the back end holds it; R187 (`HFS_DOCKER_BUILD_CONTEXT`) refuses a COPY of a missing `be/contracts` and a missing COPY once contracts exist.
|
|
17
|
+
- Changed: the lite sign-up door takes an optional full name and keeps it as `full_name` and `name` in the account's profile data, and the scaffolded sign-up form asks for it; the lite account directory and the Google sign-in read it back into the session (`AccountStanding.name`, `@starci/auth` 0.3.0).
|
|
18
|
+
- Changed: the canon pins carry no release-age wording (a version, security fixes included, may be adopted as soon as it is published), and name `@starci/auth` 0.3.0, `@starci/auth-oidc` 0.1.2, the Grammar family 0.21.17, `@starci/eslint-canon-be` 3.15.0, `@starci/eslint-canon-fe` 9.13.0 and `@starci/jest-preset` 2.7.0.
|
|
19
|
+
- Upgrade: (1) `npx starci app codemod seeds-layout`, read the report, then `--execute`: it moves `db/seed.sql` and loose `.starcistacks/<env>/seeds/*.sql` into the seed levels and writes their manifests; move any secret out of seed data into a `seeds-<name>` member and declare production's one test account as a `test-account` entry. (2) An app that keeps its modules as data adds `"modules": "data"` to hfs.json. (3) An app whose back end emits no contract deletes `COPY be/contracts be/contracts` from its `fe/apps/*/Dockerfile`, which the app owns. (4) `npx starci app sync --execute` renders the jest packages scope, the CI lint matrix and the Prettier-clean managed files.
|
|
20
|
+
- Changed: slot `app.packages` places any root workspace package `packages/<name>/` (the agent SDK, a block library such as `packages/blocks-core`, a module package, later a reviewed plugin package), not only `agent-sdk` and `module-<name>`, so each is linted by the root config (`--side packages`) and read as a first-party package by the import graph; slot manifest 3.19.0 (ruling `root-packages-any-name`).
|
|
21
|
+
|
|
22
|
+
## 5.4.0 - 2026-11-03
|
|
23
|
+
|
|
24
|
+
- Fixed: `starci app sync --execute` never leaves an app uncompilable. Before it writes a managed TypeScript file or deletes a retired one, it compiles each front-end and back-end project with the change applied (the app's own TypeScript; the changed files and every file importing them, against what is on disk). A managed group whose render adds a type error, in its own files or in an app file that imports one, is held whole: `HFS_SYNC_GROUP_HELD`, exit 1, naming the group, its files and the first errors (what the app must add or still imports); the rest is written, and a side without TypeScript installed is named in a notice (`sync/compile-guard.mts`).
|
|
25
|
+
- Fixed: `starci app codemod module-definition` drops every imported name the rewrite made unused (`ConfigurableModuleCls`, `ConfigurableModuleHost`, `ConfigurableModuleBuilder`), keeps the names the file still uses and `import type`, keeps the builder's JSDoc, and writes each file as the app's Prettier prints it, so no TS6133 and no prettier-hook refusal follow it (`codemod/app-prettier.mts`).
|
|
26
|
+
- Changed: `starci app codemod account-doors --contract` says why it writes nothing: the contract migration is already in the chain, or no migration grants the app role on schema `auth`.
|
|
27
|
+
- Changed: the lite sign-in renders `@starci/auth` 0.2.1: `identity_refresh_tokens.raced_count` in the baseline (the package's `identity-sessions.sql`), the refresh-token entity's `racedCount` and the session store's `markTokenRaced`. An app whose baseline holds the 0.2.0 tables adds `@starci/auth/migrations/identity-refresh-raced-successors.sql` as a migration.
|
|
28
|
+
- Changed: the canon pins name `@starci/auth` 0.2.1, `@starci/auth-oidc` 0.1.1 and the Grammar family 0.21.16.
|
|
29
|
+
- Upgrade, for an app on the CLI 1.1.0-alpha.49 / hfs 5.0.8 render moving to this version: (1) `npx starci app codemod module-definition --execute`, then `npm install`; (2) `npx starci app sync --execute`, which writes everything that compiles and holds the auth-lite sign-in glue while the app's own identity code differs, naming each missing or still-imported symbol; (3) adopt `@starci/mail` and make the identity domain provide what the held errors name; (4) `npx starci app sync --execute` again, which writes the glue once it compiles; (5) `npx starci app codemod account-doors --execute`, and `--contract` when the app still grants on schema `auth`; (6) add the `raced_count` migration above.
|
|
30
|
+
|
|
3
31
|
## 5.3.0 - 2026-11-03
|
|
4
32
|
|
|
5
33
|
- Fixed: the secrets family of the committed check cache stores codes, paths, positions and digests only (`cache/secret-records.mts`, `runtime/scripts/lib/secret-records.mts`), and the one secret judgement of R06 judges a file of `.starcistacks/cache/check/secrets/` by that contract, so `starci app check` can no longer write a cache the staged guard refuses (`HFS_PLAINTEXT_SECRET` for the `secrets/` folder name). A cache written before reads back as digests. Codemod: `starci app codemod secrets-cache`.
|
package/README.md
CHANGED
|
@@ -33,8 +33,9 @@ starci app ci secret set <NAME> [--from-custody] [--env <env>] [--repo <owner/na
|
|
|
33
33
|
`.starcistacks/application-stacks.yaml` (PROJECT-STACKS-6 of `contexts/project/stacks.md`): the host table (`doctor` prints the owner-run install
|
|
34
34
|
step per OS for each missing prerequisite and installs nothing), the dev custody (`secret init --env dev --execute` creates the dev-only values once),
|
|
35
35
|
the setup commands, the compose services and their health, the environment's own database role, the migrations with a receipt (`db migrate` runs its
|
|
36
|
-
client tools in the database container named by `STARCI_DB_CONTAINER`, so the host needs no PostgreSQL client), the seed
|
|
37
|
-
|
|
36
|
+
client tools in the database container named by `STARCI_DB_CONTAINER`, so the host needs no PostgreSQL client), the seed levels of `.starcistacks/`
|
|
37
|
+
with their receipts in the database (`stack/seeds.mts`, PROJECT-STACKS-12; `app codemod seeds-layout` moves an older app's seed data into them),
|
|
38
|
+
the declared seed commands once, the native app processes, readiness and the origins. `prepare`, `up` and `down` are dry runs until `--execute`; `down` never removes a volume. State (process ids,
|
|
38
39
|
logs, receipts) is under the state base (`STARCI_LOCAL_ROOT`), the opened custody under the private secrets directory outside the checkout.
|
|
39
40
|
|
|
40
41
|
`starci app add` writes every front-end unit, in either edition, already in the shape of the harness's `contexts/code/fe`
|
|
@@ -114,7 +115,7 @@ one is refused with both names. Object references and function bodies stay byte-
|
|
|
114
115
|
the kept pgmq queues and the grants on objects outside the app schemas), a marked section of `db/bootstrap.sql` (the source's app roles
|
|
115
116
|
with their attributes and no password, their memberships, their allowed role settings such as `search_path`, the grants on schemas the
|
|
116
117
|
bootstrap creates and the database-wide default privileges, none of which a per-database dump keeps; `starci app sync` judges and keeps
|
|
117
|
-
the rest of the file),
|
|
118
|
+
the rest of the file), `.starcistacks/seeds/sql/reference.sql` (the rows of `--seed-tables`, declared as the entry `reference` of the shared seed level, `insert ... on conflict do nothing` in foreign-key order with their
|
|
118
119
|
sequences set; a table without a primary key or unique constraint is refused) and moves the chain to `db/migrations/archive/`. Before
|
|
119
120
|
writing, a scratch container of the app's db image (built from `.starcistacks/images/db/Dockerfile`, or `--image`) replays bootstrap, baseline and seed (the
|
|
120
121
|
seed twice); the same projection read from the replay must equal the source's, or the verb refuses with the differences. Docker runs every
|
|
@@ -207,6 +208,7 @@ Its own checks (`scripts/hfs/check.mjs`, `scripts/hfs/rules/`; the rendered-file
|
|
|
207
208
|
|
|
208
209
|
The managed files are judged by `sync/managed.mjs` and `sync/ts-strict.mjs` (the package renders the templates; the runtime copy does not). The list of managed
|
|
209
210
|
files is the `managedBy` slots of `slots.yaml`; `starci app sync --execute` renders them and `starci app check` compares them, so a hand edit and a forgotten `sync` are the same finding.
|
|
211
|
+
`starci app sync --execute` never leaves the app uncompilable (`sync/compile-guard.mjs`): before it writes a managed TypeScript source or deletes a retired one, the side's own TypeScript compiles the app with those writes over the disk, and a managed group (`managedBy`) whose writes add a type error, in its own files or in an app file that imports one, is held whole: `HFS_SYNC_GROUP_HELD` (exit 1) names the group, its files and the errors (the names the render reads that the app does not provide yet, or the names it drops that the app still imports), and the rest of the render is written. The held files stay `HFS_SYNC_DRIFT` until the app provides what the render reads and sync runs again; a side whose TypeScript is not installed is written and named in a notice.
|
|
210
212
|
Each finding is reported once: the managed ESLint and stylelint factory configs under R17, `tsconfig.json` under R22 when it names a flag, a workflow or hook that lost a canon step under R13 (or R19 for the format step), everything else under R05.
|
|
211
213
|
The root and both sides are rendered by the one mechanism. The root: the `scripts` block of `app.package-manifest` (`dev:be`, `dev:fe`, `build:be`, `build:fe`, `start:<app>`, `lint`, `lint:fix`, `test`, `test:integration`, `test:e2e`, `test:contract`, `test:world`, `codegen`, `contract:emit`, `migrate`, `typecheck`, ...; a be script runs from `be/`, where its tsconfig and jest configuration are), `app.format-config`, `app.hooks`, the CI slots (`app.ci`, `app.ci-e2e`, `app.ci-images`), `app.quality-config` and the managed block of `app.git-meta`. A side: its `be.tool-config` or `fe.tool-config` slot — the side's tsconfig (resolving `@starci/tsconfig` from the root `node_modules`) and its managed ESLint factory config, the back end's build tsconfig, tests tsconfig and jest configuration, the front end's stylelint configuration. `lint` is `starci app check --gate lint`: ESLint over each side with its canon and stylelint over fe; `lint:fix` is the same with `--fix`. The front end has no test script, no test configuration and no e2e or coverage file: it has no tests, and `FE_NO_TESTS` (R97) refuses any spec, e2e file, test tool or test script under `fe/`. `turbo.json` stays the app's own
|
|
212
214
|
(slot `app.task-graph`): it carries the task graph, which no preset can render.
|
package/cache/test-family.d.mts
CHANGED
|
@@ -96,7 +96,7 @@ export declare function sourcesOf(record: CacheRecord): Record<string, SourceIte
|
|
|
96
96
|
* a failing one's is not, and the folder is replaced with exactly the current specs' records. The merged total is written to `<side>/coverage/lcov.info` and judged.
|
|
97
97
|
*/
|
|
98
98
|
export declare function runTestFamily(options: TestFamilyOptions): Promise<TestFamilyResult>;
|
|
99
|
-
/** The part of a Jest project config the closure needs. */
|
|
99
|
+
/** The part of a Jest project config the closure needs. A project's `roots` are where its haste map finds its files. */
|
|
100
100
|
interface ProjectConfigLike {
|
|
101
101
|
readonly rootDir: string;
|
|
102
102
|
readonly displayName?: {
|
package/cache/test-family.mjs
CHANGED
|
@@ -49,8 +49,9 @@ export function sourcesOf(record) {
|
|
|
49
49
|
}
|
|
50
50
|
function recordOf(spec, key, sources, side) {
|
|
51
51
|
const prefixed = {};
|
|
52
|
+
// A source is relative to the Jest root; a root package's (`../packages/<name>/...`) is outside it, so the joined path is normalised to the app root.
|
|
52
53
|
for (const [source, items] of Object.entries(sources))
|
|
53
|
-
prefixed[side === '' || side === '.' ? source : `${side}/${source}`] = items;
|
|
54
|
+
prefixed[side === '' || side === '.' ? source : path.posix.normalize(`${side}/${source}`)] = items;
|
|
54
55
|
return { path: spec, step: SPEC_STEP, hash: key, findings: [], data: { sources: prefixed } };
|
|
55
56
|
}
|
|
56
57
|
/**
|
|
@@ -163,24 +164,27 @@ export async function createJestEnvironment({ appRoot, side, jestFrom, project =
|
|
|
163
164
|
const Runtime = runtimeModule.default;
|
|
164
165
|
const { DependencyResolver } = fromCore('jest-resolve-dependencies');
|
|
165
166
|
const jestBin = path.join(path.dirname(jestPackage), 'bin', 'jest.js');
|
|
166
|
-
// The preset's config reads the working directory (the back end it describes), so it is read from the Jest root.
|
|
167
|
+
// The preset's config reads the working directory (the back end it describes), so it is read from the Jest root. Several projects may carry the name
|
|
168
|
+
// (the back end's unit project and the one of the app's root packages, rooted at the app root): each keeps its own haste map and resolver.
|
|
167
169
|
const previous = process.cwd();
|
|
168
|
-
|
|
169
|
-
let context;
|
|
170
|
+
const projects = [];
|
|
170
171
|
try {
|
|
171
172
|
process.chdir(root);
|
|
172
173
|
const { configs } = await readConfigs({ $0: 'jest', _: [], selectProjects: [project] }, [root]);
|
|
173
|
-
config
|
|
174
|
-
|
|
174
|
+
for (const config of configs.filter((entry) => entry.displayName?.name === project)) {
|
|
175
|
+
const context = await Runtime.createContext(config, { maxWorkers: 1, watchman: false, console: { log: () => undefined, warn: () => undefined, error: () => undefined } });
|
|
176
|
+
projects.push({ config, resolver: new DependencyResolver(context.resolver, context.hasteFS, undefined), hasteFS: context.hasteFS });
|
|
177
|
+
}
|
|
178
|
+
if (projects.length === 0)
|
|
175
179
|
throw new Error(`the Jest config under ${side} has no project named ${project}`);
|
|
176
|
-
context = await Runtime.createContext(config, { maxWorkers: 1, watchman: false, console: { log: () => undefined, warn: () => undefined, error: () => undefined } });
|
|
177
180
|
}
|
|
178
181
|
finally {
|
|
179
182
|
process.chdir(previous);
|
|
180
183
|
}
|
|
181
|
-
const resolver = new DependencyResolver(context.resolver, context.hasteFS, undefined);
|
|
182
184
|
const tracked = (file) => !file.split(/[\\/]/u).includes('node_modules');
|
|
183
|
-
|
|
185
|
+
/** The project whose haste map holds `file`: a module is resolved the way the project that runs its spec resolves it. */
|
|
186
|
+
const projectOf = (file) => projects.find((entry) => entry.hasteFS.exists(file)) ?? projects[0];
|
|
187
|
+
const closureFrom = (roots, owner) => {
|
|
184
188
|
const seen = new Set();
|
|
185
189
|
const queue = roots.filter(tracked);
|
|
186
190
|
while (queue.length > 0) {
|
|
@@ -188,15 +192,14 @@ export async function createJestEnvironment({ appRoot, side, jestFrom, project =
|
|
|
188
192
|
if (file === undefined || seen.has(file))
|
|
189
193
|
continue;
|
|
190
194
|
seen.add(file);
|
|
191
|
-
for (const next of resolver.resolve(file))
|
|
195
|
+
for (const next of owner.resolver.resolve(file))
|
|
192
196
|
if (tracked(next) && !seen.has(next))
|
|
193
197
|
queue.push(next);
|
|
194
198
|
}
|
|
195
199
|
return [...seen];
|
|
196
200
|
};
|
|
197
201
|
const relative = (file) => posix(path.relative(appRoot, file));
|
|
198
|
-
const
|
|
199
|
-
const setupClosure = closureFrom([...setup('setupFiles'), ...setup('setupFilesAfterEnv')]).map(relative);
|
|
202
|
+
const setupClosures = new Map(projects.map((entry) => [entry, closureFrom([...(entry.config.setupFiles ?? []), ...(entry.config.setupFilesAfterEnv ?? [])], entry).map(relative)]));
|
|
200
203
|
const listed = completedNode(await runHeavyNode(execute, { kind: 'test', label: `app check jest ${project} discovery`, args: [jestBin, '--selectProjects', project, '--listTests'], cwd: root, maxBuffer: 64 * 1024 * 1024 }), 'jest discovery');
|
|
201
204
|
if (listed.status !== 0)
|
|
202
205
|
throw new Error(`jest --listTests failed under ${side}: ${(listed.stderr || listed.stdout).split('\n')[0]}`);
|
|
@@ -204,13 +207,15 @@ export async function createJestEnvironment({ appRoot, side, jestFrom, project =
|
|
|
204
207
|
const closures = new Map();
|
|
205
208
|
return {
|
|
206
209
|
specs,
|
|
207
|
-
configHash: projectConfigHash(config),
|
|
210
|
+
configHash: projects.length === 1 ? projectConfigHash(projects[0].config) : combineHashes(Object.fromEntries(projects.map((entry, index) => [String(index), projectConfigHash(entry.config)]))),
|
|
208
211
|
closureOf(spec) {
|
|
209
212
|
const cached = closures.get(spec);
|
|
210
213
|
if (cached !== undefined)
|
|
211
214
|
return cached;
|
|
212
|
-
const
|
|
213
|
-
const
|
|
215
|
+
const file = path.resolve(appRoot, spec);
|
|
216
|
+
const owner = projectOf(file);
|
|
217
|
+
const own = closureFrom([file], owner).map(relative).filter((entry) => entry !== spec);
|
|
218
|
+
const closure = [...new Set([...own, ...(setupClosures.get(owner) ?? [])])].sort();
|
|
214
219
|
closures.set(spec, closure);
|
|
215
220
|
return closure;
|
|
216
221
|
},
|
|
@@ -97,8 +97,14 @@ export function migrateAccountDoors({ root, write = false, contract = false, now
|
|
|
97
97
|
}
|
|
98
98
|
if (report.findings.length)
|
|
99
99
|
return report;
|
|
100
|
-
if (names.some((name) => name.includes(`_${CONTRACT_NAME}`))
|
|
100
|
+
if (names.some((name) => name.includes(`_${CONTRACT_NAME}`))) {
|
|
101
|
+
report.next.push('the contract migration is already in the chain: nothing is left to revoke');
|
|
101
102
|
return report;
|
|
103
|
+
}
|
|
104
|
+
if (!names.some((name) => AUTH_GRANT.test(statementsOf(root, name)))) {
|
|
105
|
+
report.next.push('no migration grants the application role on schema auth (the app reached accounts through the doors from the start): nothing to revoke');
|
|
106
|
+
return report;
|
|
107
|
+
}
|
|
102
108
|
const file = `${MIGRATIONS_DIR}/${stamp}_${CONTRACT_NAME}.sql`;
|
|
103
109
|
report.changes.push({ file, phase, why: 'revokes the direct grants on schema auth, auth.users and auth.identities from the application role' });
|
|
104
110
|
report.next.push('the deploy applies it right after the accept; the release before the doors cannot sign in after a rollback unless the grants are given back');
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type Prettier } from '../sync/format.mjs';
|
|
2
|
+
/** The app's own Prettier, from the first side or the root that installs one, or undefined. */
|
|
3
|
+
export declare function prettierOf(root: string, folders: readonly string[]): Prettier | undefined;
|
|
4
|
+
/** `text` formatted by the app's Prettier the way `app check --format` judges it, or unchanged when the file is ignored or cannot be formatted. */
|
|
5
|
+
export declare function formatted(prettier: Prettier, root: string, file: string, text: string): Promise<string>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { loadPrettier } from '../sync/format.mjs';
|
|
4
|
+
/** The app's own Prettier, from the first side or the root that installs one, or undefined. */
|
|
5
|
+
export function prettierOf(root, folders) {
|
|
6
|
+
for (const folder of [...folders, root]) {
|
|
7
|
+
try {
|
|
8
|
+
return loadPrettier(folder);
|
|
9
|
+
}
|
|
10
|
+
catch { /* the next folder */ }
|
|
11
|
+
}
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
/** `text` formatted by the app's Prettier the way `app check --format` judges it, or unchanged when the file is ignored or cannot be formatted. */
|
|
15
|
+
export async function formatted(prettier, root, file, text) {
|
|
16
|
+
const ignorePath = path.join(root, '.prettierignore');
|
|
17
|
+
try {
|
|
18
|
+
const info = await prettier.getFileInfo(file, { ignorePath: fs.existsSync(ignorePath) ? ignorePath : undefined, resolveConfig: true });
|
|
19
|
+
if (info.ignored || !info.inferredParser)
|
|
20
|
+
return text;
|
|
21
|
+
const options = (await prettier.resolveConfig(file, { editorconfig: true, useCache: true })) ?? {};
|
|
22
|
+
return await prettier.format(text, { ...options, filepath: file });
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return text;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -3,7 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { declarationSurface, isEmittableSource } from '../runtime/scripts/lib/declaration-surface.mjs';
|
|
5
5
|
import { loadTypescript } from '../runtime/scripts/lib/package-at.mjs';
|
|
6
|
-
import {
|
|
6
|
+
import { formatted, prettierOf } from './app-prettier.mjs';
|
|
7
7
|
import { applyEdits, importEdits } from './explicit-surface-edit.mjs';
|
|
8
8
|
import { conventionType, MAX_TYPE_LENGTH, renderType } from './explicit-surface-render.mjs';
|
|
9
9
|
import { GENERATED, relativePosix, sideFolders } from './source-tree.mjs';
|
|
@@ -212,30 +212,6 @@ async function sideSources(root, side, folder) {
|
|
|
212
212
|
visit(folder);
|
|
213
213
|
return { files: files.sort(), scope: selects === undefined ? 'walk' : 'canon' };
|
|
214
214
|
}
|
|
215
|
-
/** The app's own Prettier, from the first side or the root that installs one, or undefined. */
|
|
216
|
-
function prettierOf(root, folders) {
|
|
217
|
-
for (const folder of [...folders, root]) {
|
|
218
|
-
try {
|
|
219
|
-
return loadPrettier(folder);
|
|
220
|
-
}
|
|
221
|
-
catch { /* the next folder */ }
|
|
222
|
-
}
|
|
223
|
-
return undefined;
|
|
224
|
-
}
|
|
225
|
-
/** `text` formatted by the app's Prettier the way `app check --format` judges it, or unchanged when the file is ignored or cannot be formatted. */
|
|
226
|
-
async function formatted(prettier, root, file, text) {
|
|
227
|
-
const ignorePath = path.join(root, '.prettierignore');
|
|
228
|
-
try {
|
|
229
|
-
const info = await prettier.getFileInfo(file, { ignorePath: fs.existsSync(ignorePath) ? ignorePath : undefined, resolveConfig: true });
|
|
230
|
-
if (info.ignored || !info.inferredParser)
|
|
231
|
-
return text;
|
|
232
|
-
const options = (await prettier.resolveConfig(file, { editorconfig: true, useCache: true })) ?? {};
|
|
233
|
-
return await prettier.format(text, { ...options, filepath: file });
|
|
234
|
-
}
|
|
235
|
-
catch {
|
|
236
|
-
return text;
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
215
|
/** The side-relative name a report gives `absolute`. */
|
|
240
216
|
const nameOf = (root, absolute) => relativePosix(root, absolute);
|
|
241
217
|
/** Plans every open file of one program, checks that the rewritten files read no worse than the originals, and returns what each file becomes. */
|
package/codemod/main.mjs
CHANGED
|
@@ -14,10 +14,11 @@ import { migrateModuleDefinition, moduleDefinitionMarkdown } from './module-defi
|
|
|
14
14
|
import { migrateQualityBinding, qualityBindingMarkdown } from './quality-binding.mjs';
|
|
15
15
|
import { migrateSopsRules, sopsRulesMarkdown } from './sops-rules.mjs';
|
|
16
16
|
import { migrateSecretsCache, secretsCacheMarkdown } from './secrets-cache.mjs';
|
|
17
|
+
import { migrateSeedsLayout, seedsLayoutMarkdown } from './seeds-layout.mjs';
|
|
17
18
|
import { migrateThemeTokens, themeTokensMarkdown } from './theme-tokens.mjs';
|
|
18
19
|
import { migrateTierBinding, tierBindingMarkdown } from './tier-binding.mjs';
|
|
19
20
|
/** The codemods `starci app codemod` runs, by name. */
|
|
20
|
-
export const CODEMODS = Object.freeze(['button-intent', 'spacing-roles', 'theme-tokens', 'small-renames', 'page-navigation', 'section-frame', 'surface-card-kinds', 'tier-binding', 'loading-states', 'explicit-surface', 'directive-first', 'secrets-cache', 'module-definition', 'sops-rules', 'quality-binding', 'account-doors']);
|
|
21
|
+
export const CODEMODS = Object.freeze(['button-intent', 'spacing-roles', 'theme-tokens', 'small-renames', 'page-navigation', 'section-frame', 'surface-card-kinds', 'tier-binding', 'loading-states', 'explicit-surface', 'directive-first', 'secrets-cache', 'module-definition', 'sops-rules', 'quality-binding', 'account-doors', 'seeds-layout']);
|
|
21
22
|
/** The arguments of every report-style `starci app codemod`: --execute, --json, --format and --out; any other is refused. */
|
|
22
23
|
export function spacingRolesArguments(argv) {
|
|
23
24
|
const options = { write: false, format: 'markdown' };
|
|
@@ -51,6 +52,10 @@ export function spacingRolesArguments(argv) {
|
|
|
51
52
|
}
|
|
52
53
|
/** One of the report-style codemods run at an app root: its report object and the Markdown of it. */
|
|
53
54
|
function runCodemod(name, root, write) {
|
|
55
|
+
if (name === 'seeds-layout') {
|
|
56
|
+
const report = migrateSeedsLayout({ root, write });
|
|
57
|
+
return { report, markdown: seedsLayoutMarkdown(report) };
|
|
58
|
+
}
|
|
54
59
|
if (name === 'quality-binding') {
|
|
55
60
|
const report = migrateQualityBinding({ root, write });
|
|
56
61
|
return { report, markdown: qualityBindingMarkdown(report) };
|
|
@@ -59,10 +64,6 @@ function runCodemod(name, root, write) {
|
|
|
59
64
|
const report = migrateSopsRules({ root, write });
|
|
60
65
|
return { report, markdown: sopsRulesMarkdown(report) };
|
|
61
66
|
}
|
|
62
|
-
if (name === 'module-definition') {
|
|
63
|
-
const report = migrateModuleDefinition({ root, write });
|
|
64
|
-
return { report, markdown: moduleDefinitionMarkdown(report) };
|
|
65
|
-
}
|
|
66
67
|
if (name === 'secrets-cache') {
|
|
67
68
|
const report = migrateSecretsCache({ root, write });
|
|
68
69
|
return { report, markdown: secretsCacheMarkdown(report) };
|
|
@@ -126,6 +127,12 @@ async function explicitSurfaceMain(argv, io) {
|
|
|
126
127
|
const report = await migrateExplicitSurface({ root: path.resolve(io.cwd), write: options.write });
|
|
127
128
|
return emit(options, { report, markdown: explicitSurfaceMarkdown(report) }, io);
|
|
128
129
|
}
|
|
130
|
+
/** `starci app codemod module-definition`: it formats each rewritten definition with the app's Prettier, which is asynchronous. */
|
|
131
|
+
async function moduleDefinitionMain(argv, io) {
|
|
132
|
+
const options = reportArguments(argv);
|
|
133
|
+
const report = await migrateModuleDefinition({ root: path.resolve(io.cwd), write: options.write });
|
|
134
|
+
return emit(options, { report, markdown: moduleDefinitionMarkdown(report) }, io);
|
|
135
|
+
}
|
|
129
136
|
/** `starci app codemod account-doors [--contract]`: the expand migration of the account doors, or (a release later) the contract migration that revokes the old grants. */
|
|
130
137
|
function accountDoorsMain(argv, io) {
|
|
131
138
|
const contract = argv.includes('--contract');
|
|
@@ -145,6 +152,8 @@ export function codemodMain(argv, io) {
|
|
|
145
152
|
return explicitSurfaceMain(rest, io);
|
|
146
153
|
if (name === 'account-doors')
|
|
147
154
|
return accountDoorsMain(rest, io);
|
|
155
|
+
if (name === 'module-definition')
|
|
156
|
+
return moduleDefinitionMain(rest, io);
|
|
148
157
|
if (name === undefined || !CODEMODS.includes(name))
|
|
149
158
|
throw new Error(`starci app codemod takes ${CODEMODS.join(' or ')}`);
|
|
150
159
|
const options = reportArguments(rest);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Prettier } from '../sync/format.mjs';
|
|
1
2
|
/** The TypeScript compiler API of the app's own install (loadTypescript), typed by the version this package develops against. */
|
|
2
3
|
type Ts = typeof import('typescript');
|
|
3
4
|
/** One definition the migration rewrites: which form it was written in, the helper it became and the names it exports. */
|
|
@@ -41,12 +42,18 @@ export declare function transformModuleDefinition({ ts, text, file }: {
|
|
|
41
42
|
changes: ModuleDefinitionChange[];
|
|
42
43
|
findings: ModuleDefinitionFinding[];
|
|
43
44
|
};
|
|
44
|
-
/**
|
|
45
|
-
export
|
|
45
|
+
/** What `migrateModuleDefinition` takes: the app root, whether to write, and the compiler and formatter of the app (found in it when left out; `prettier: null` writes the codemod's own layout). */
|
|
46
|
+
export interface ModuleDefinitionOptions {
|
|
46
47
|
root: string;
|
|
47
48
|
write?: boolean;
|
|
48
49
|
ts?: Ts | null;
|
|
49
|
-
|
|
50
|
+
prettier?: Prettier | null;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Preview or apply the rewrite in one app: every `*.module-definition.ts` under its sides, each rewritten file formatted by the app's Prettier
|
|
54
|
+
* (so `app check --format` and the app's hook accept it as written), then the dependency in the root package.json.
|
|
55
|
+
*/
|
|
56
|
+
export declare function migrateModuleDefinition({ root, write, ts, prettier }: ModuleDefinitionOptions): Promise<ModuleDefinitionReport>;
|
|
50
57
|
/** Render the rewrite and what is left for a person as Markdown; JSON consumers read the same report object. */
|
|
51
58
|
export declare function moduleDefinitionMarkdown(report: ModuleDefinitionReport): string;
|
|
52
59
|
export {};
|
|
@@ -3,6 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { bundledPins } from '../runtime/scripts/hfs/canon-pins.mjs';
|
|
5
5
|
import { loadTypescript } from '../runtime/scripts/lib/package-at.mjs';
|
|
6
|
+
import { formatted, prettierOf } from './app-prettier.mjs';
|
|
6
7
|
import { filesUnder, relativePosix, sideFolders } from './source-tree.mjs';
|
|
7
8
|
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
8
9
|
const KIT = '@starci/nest-kit';
|
|
@@ -118,7 +119,7 @@ function readDefinition(ts, source) {
|
|
|
118
119
|
drop.add(statement);
|
|
119
120
|
if (at === null) {
|
|
120
121
|
at = statement;
|
|
121
|
-
doc = docOf(ts, source, statement);
|
|
122
|
+
doc = docOf(ts, source, statement) || docOf(ts, source, builtStatement);
|
|
122
123
|
}
|
|
123
124
|
}
|
|
124
125
|
if (at === null || names.length === 0)
|
|
@@ -130,25 +131,34 @@ function readDefinition(ts, source) {
|
|
|
130
131
|
return unfit(source, builtStatement, 'MODULE_DEFINITION_NAMES', 'another statement of the file reads the builder result or `Host`');
|
|
131
132
|
return { ...read, names: [...names].sort((a, b) => [...EXPORTED].indexOf(a) - [...EXPORTED].indexOf(b)), doc, drop, at, from: 'typed builder' };
|
|
132
133
|
}
|
|
133
|
-
/** The one export of the new definition, laid out the way Prettier prints it at the width of the canon (
|
|
134
|
+
/** The one export of the new definition, laid out the way Prettier prints it at the width of the canon (one line; else the options object opens on the line of the `=` when that fits; else the call breaks after the `=`). */
|
|
134
135
|
function exportLine(reading, helper) {
|
|
135
136
|
const head = `export const { ${reading.names.join(', ')} } =`;
|
|
136
137
|
const call = `${helper}<${reading.options}>(${reading.token === null ? '' : `{ optionsInjectionToken: ${reading.token} }`})`;
|
|
137
138
|
if (`${head} ${call}`.length <= PRINT_WIDTH)
|
|
138
139
|
return `${head} ${call}`;
|
|
139
|
-
|
|
140
|
+
const hug = `${head} ${helper}<${reading.options}>({`;
|
|
141
|
+
return reading.token !== null && hug.length <= PRINT_WIDTH ? `${hug}\n optionsInjectionToken: ${reading.token},\n})` : `${head}\n ${call}`;
|
|
140
142
|
}
|
|
141
|
-
/** The
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
143
|
+
/** The words of `text` (identifier characters), so a binding is known to be read by its name. */
|
|
144
|
+
const wordsOf = (text) => new Set(text.match(/[\w$]+/g));
|
|
145
|
+
/**
|
|
146
|
+
* An import declaration without the named bindings the rewrite made unused (read by the dropped statements, by none of the kept ones), or null
|
|
147
|
+
* when nothing is left of it; `import type` and inline `type` stay as written.
|
|
148
|
+
*/
|
|
149
|
+
function prunedImport(ts, source, statement, before, after) {
|
|
150
|
+
const clause = statement.importClause;
|
|
151
|
+
const bindings = clause?.namedBindings;
|
|
152
|
+
if (clause === undefined || bindings === undefined || !ts.isNamedImports(bindings))
|
|
145
153
|
return statement.getText(source);
|
|
146
|
-
const kept = bindings.elements.filter((element) =>
|
|
154
|
+
const kept = bindings.elements.filter((element) => after.has(element.name.text) || !before.has(element.name.text));
|
|
147
155
|
if (kept.length === bindings.elements.length)
|
|
148
156
|
return statement.getText(source);
|
|
149
|
-
if (kept.length === 0 &&
|
|
157
|
+
if (kept.length === 0 && clause.name === undefined)
|
|
150
158
|
return null;
|
|
151
|
-
|
|
159
|
+
const named = kept.length === 0 ? '' : `{ ${kept.map((element) => element.getText(source)).join(', ')} }`;
|
|
160
|
+
const head = [clause.name?.text, named].filter((part) => part !== undefined && part !== '').join(', ');
|
|
161
|
+
return `import ${clause.isTypeOnly ? 'type ' : ''}${head} from ${statement.moduleSpecifier.getText(source)}`;
|
|
152
162
|
}
|
|
153
163
|
/** Rewrite one definition file's text to the helper form; `text` unchanged when the file holds no definition the codemod can rewrite. */
|
|
154
164
|
export function transformModuleDefinition({ ts, text, file = 'x.module-definition.ts' }) {
|
|
@@ -162,13 +172,11 @@ export function transformModuleDefinition({ ts, text, file = 'x.module-definitio
|
|
|
162
172
|
if ('reason' in read)
|
|
163
173
|
return { ...none, findings: [{ file, line: read.line, code: read.code, reason: read.reason }] };
|
|
164
174
|
const helper = read.representative ? 'defineCapabilityModule' : 'defineConfigurableModule';
|
|
165
|
-
const imports = [`import { ${helper} } from "${KIT}"`];
|
|
166
175
|
const body = [];
|
|
176
|
+
const declarations = [];
|
|
167
177
|
for (const statement of source.statements) {
|
|
168
178
|
if (ts.isImportDeclaration(statement)) {
|
|
169
|
-
|
|
170
|
-
if (nest !== null)
|
|
171
|
-
imports.push(nest);
|
|
179
|
+
declarations.push(statement);
|
|
172
180
|
continue;
|
|
173
181
|
}
|
|
174
182
|
if (read.drop.has(statement))
|
|
@@ -179,6 +187,9 @@ export function transformModuleDefinition({ ts, text, file = 'x.module-definitio
|
|
|
179
187
|
}
|
|
180
188
|
body.push(text.slice(statement.pos, statement.end).replace(/^\s*\n/, '').trimEnd());
|
|
181
189
|
}
|
|
190
|
+
const before = wordsOf(source.statements.filter((statement) => !ts.isImportDeclaration(statement)).map((statement) => statement.getText(source)).join('\n'));
|
|
191
|
+
const after = wordsOf(body.join('\n'));
|
|
192
|
+
const imports = [`import { ${helper} } from "${KIT}"`, ...declarations.flatMap((statement) => prunedImport(ts, source, statement, before, after) ?? [])];
|
|
182
193
|
const output = `${imports.join('\n')}\n\n${body.join('\n\n')}\n`;
|
|
183
194
|
return { text: output, changes: [{ file, from: read.from, helper, names: read.names }], findings: [] };
|
|
184
195
|
}
|
|
@@ -186,13 +197,17 @@ export function transformModuleDefinition({ ts, text, file = 'x.module-definitio
|
|
|
186
197
|
function kitVersion() {
|
|
187
198
|
return bundledPins()[KIT]?.version ?? '0.1.0';
|
|
188
199
|
}
|
|
189
|
-
/**
|
|
190
|
-
|
|
200
|
+
/**
|
|
201
|
+
* Preview or apply the rewrite in one app: every `*.module-definition.ts` under its sides, each rewritten file formatted by the app's Prettier
|
|
202
|
+
* (so `app check --format` and the app's hook accept it as written), then the dependency in the root package.json.
|
|
203
|
+
*/
|
|
204
|
+
export async function migrateModuleDefinition({ root, write = false, ts = loadTypescript(root, PACKAGE_ROOT), prettier }) {
|
|
191
205
|
if (!ts)
|
|
192
206
|
throw new Error('module-definition needs TypeScript installed in the app or @starci/hfs development checkout');
|
|
193
207
|
const folders = sideFolders(root);
|
|
194
208
|
if (!folders.length)
|
|
195
209
|
throw new Error('module-definition runs at an app root with a be/ directory');
|
|
210
|
+
const formatter = prettier === undefined ? prettierOf(root, folders) : prettier ?? undefined;
|
|
196
211
|
const packageFile = path.join(root, 'package.json');
|
|
197
212
|
const manifest = fs.existsSync(packageFile) ? JSON.parse(fs.readFileSync(packageFile, 'utf8')) : null; // reason: package.json parsed from disk, only dependencies is read
|
|
198
213
|
const declared = manifest?.dependencies?.[KIT] !== undefined;
|
|
@@ -207,7 +222,7 @@ export function migrateModuleDefinition({ root, write = false, ts = loadTypescri
|
|
|
207
222
|
if (result.text !== original) {
|
|
208
223
|
report.filesChanged.push(file);
|
|
209
224
|
if (write)
|
|
210
|
-
fs.writeFileSync(target, result.text);
|
|
225
|
+
fs.writeFileSync(target, formatter === undefined ? result.text : await formatted(formatter, root, target, result.text));
|
|
211
226
|
}
|
|
212
227
|
}
|
|
213
228
|
if (report.filesChanged.length > 0 && !declared && manifest !== null) {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** One seed file the migration moves into a level's `sql/` and declares in its manifest; `drop` when the target already holds its bytes. */
|
|
2
|
+
export interface SeedsLayoutMove {
|
|
3
|
+
from: string;
|
|
4
|
+
to: string;
|
|
5
|
+
entry: string;
|
|
6
|
+
action: 'move' | 'drop';
|
|
7
|
+
}
|
|
8
|
+
/** One case left for a person. */
|
|
9
|
+
export interface SeedsLayoutFinding {
|
|
10
|
+
file: string;
|
|
11
|
+
code: string;
|
|
12
|
+
reason: string;
|
|
13
|
+
}
|
|
14
|
+
/** The report of a migration (`starci app codemod seeds-layout`). */
|
|
15
|
+
export interface SeedsLayoutReport {
|
|
16
|
+
schema: string;
|
|
17
|
+
root: string;
|
|
18
|
+
write: boolean;
|
|
19
|
+
environments: string[];
|
|
20
|
+
moves: SeedsLayoutMove[];
|
|
21
|
+
created: string[];
|
|
22
|
+
findings: SeedsLayoutFinding[];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Migrate an app's seed data into the seed levels of `.starcistacks/` (ruling seed-levels, contexts/project/stacks.md PROJECT-STACKS-12):
|
|
26
|
+
* `db/seed.sql` moves to the shared level's `sql/reference.sql` and each loose SQL file of `<env>/seeds/` to that level's `sql/`, each
|
|
27
|
+
* declared in its level's `seeds.yaml` (target the primary database, mode upsert); every level gets its manifest and a `.gitkeep` for each
|
|
28
|
+
* kind folder that holds nothing. A target with the same bytes drops the old copy; other bytes, a loose seed that is no SQL and a source that
|
|
29
|
+
* still names an old home are left for a person. A second run finds nothing to do.
|
|
30
|
+
*/
|
|
31
|
+
export declare function migrateSeedsLayout({ root, write }: {
|
|
32
|
+
root: string;
|
|
33
|
+
write?: boolean;
|
|
34
|
+
}): SeedsLayoutReport;
|
|
35
|
+
/** Render the migration and what is left for a person as Markdown; JSON consumers read the same report object. */
|
|
36
|
+
export declare function seedsLayoutMarkdown(report: SeedsLayoutReport): string;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { declaredEnvironments } from '../runtime/scripts/hfs/rules/seeds.mjs';
|
|
4
|
+
import { SEED_FILE } from '../runtime/scripts/hfs/rules/database-constants.mjs';
|
|
5
|
+
import { addSeedEntries, SEED_FOLDERS, SEEDS_KEEP, SEEDS_MANIFEST, SEEDS_SCHEMA, seedLevelDir } from '../runtime/scripts/hfs/stack-seeds-config.mjs';
|
|
6
|
+
import { filesUnder, relativePosix, sideFolders } from './source-tree.mjs';
|
|
7
|
+
/** The lite SQL seed of manifest 3.17 (slot app.db.seed): its rows are every environment's, so it moves to the shared level. */
|
|
8
|
+
const LEGACY_DB_SEED = 'db/seed.sql';
|
|
9
|
+
/** The id and target a moved SQL seed is declared with: the primary database, applied again whenever its content changes. */
|
|
10
|
+
const SQL_TARGET = 'primary';
|
|
11
|
+
/** How a source names an old seed home: the lite seed path, or the seeds folder of an environment read without its sql/ folder. */
|
|
12
|
+
const LEGACY_REFERENCE = /db\/seed\.sql|"\.starcistacks",\s*[A-Za-z_$][\w$]*,\s*"seeds"\)/u;
|
|
13
|
+
const SOURCE = /\.(?:[cm]?[jt]s|json)$/u;
|
|
14
|
+
const isFile = (target) => fs.statSync(target, { throwIfNoEntry: false })?.isFile() === true;
|
|
15
|
+
const isFolder = (target) => fs.statSync(target, { throwIfNoEntry: false })?.isDirectory() === true;
|
|
16
|
+
const entryId = (name) => name.replace(/\.sql$/u, '').toLowerCase().replaceAll(/[^a-z0-9]+/gu, '-').replaceAll(/^-+|-+$/gu, '');
|
|
17
|
+
/** The loose seed files of each declared environment's level (directly in `<env>/seeds/`) and the lite `db/seed.sql`, with where each goes. */
|
|
18
|
+
function legacySeeds(root, environments, findings) {
|
|
19
|
+
const loose = environments.flatMap((env) => {
|
|
20
|
+
const level = path.join(root, seedLevelDir(env));
|
|
21
|
+
if (!isFolder(level))
|
|
22
|
+
return [];
|
|
23
|
+
return fs.readdirSync(level, { withFileTypes: true }).filter((entry) => entry.isFile() && entry.name !== SEEDS_MANIFEST && entry.name !== SEEDS_KEEP).flatMap((entry) => {
|
|
24
|
+
const from = `${seedLevelDir(env)}/${entry.name}`;
|
|
25
|
+
if (entry.name.endsWith('.sql'))
|
|
26
|
+
return [{ env, from, name: entry.name }];
|
|
27
|
+
findings.push({ file: from, code: 'SEEDS_LAYOUT_NOT_SQL', reason: `only a SQL seed moves by itself; put it under ${seedLevelDir(env)}/data/ or files/ and declare its target (and, for data, its schema) in ${SEEDS_MANIFEST}` });
|
|
28
|
+
return [];
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
return [...loose, ...(isFile(path.join(root, LEGACY_DB_SEED)) ? [{ env: null, from: LEGACY_DB_SEED, name: path.posix.basename(SEED_FILE) }] : [])];
|
|
32
|
+
}
|
|
33
|
+
/** The sources of the app's sides that still name an old seed home (a seed runner the app owns): each is left for a person. */
|
|
34
|
+
function referenceFindings(root) {
|
|
35
|
+
return sideFolders(root).flatMap((side) => filesUnder(side, SOURCE)).flatMap((file) => {
|
|
36
|
+
const line = fs.readFileSync(file, 'utf8').split('\n').findIndex((text) => LEGACY_REFERENCE.test(text));
|
|
37
|
+
return line === -1 ? [] : [{ file: `${relativePosix(root, file)}:${line + 1}`, code: 'SEEDS_LAYOUT_REFERENCE', reason: 'names the old seed home; read the sql/ folder of .starcistacks/seeds/ and then of .starcistacks/<env>/seeds/ (the scaffold\'s seed runner is the model)' }];
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
/** The placeholders and empty manifests a level lacks: `seeds.yaml`, and a `.gitkeep` in each kind folder that holds nothing. */
|
|
41
|
+
function levelSkeleton(root, env, landed) {
|
|
42
|
+
const dir = seedLevelDir(env);
|
|
43
|
+
const manifest = isFile(path.join(root, dir, SEEDS_MANIFEST)) || landed.has(`${dir}/${SEEDS_MANIFEST}`) ? [] : [`${dir}/${SEEDS_MANIFEST}`];
|
|
44
|
+
const keeps = SEED_FOLDERS.map((kind) => `${dir}/${kind}`).filter((folder) => !landed.has(folder) && (!isFolder(path.join(root, folder)) || fs.readdirSync(path.join(root, folder)).length === 0));
|
|
45
|
+
return [...manifest, ...keeps.map((folder) => `${folder}/${SEEDS_KEEP}`)];
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Migrate an app's seed data into the seed levels of `.starcistacks/` (ruling seed-levels, contexts/project/stacks.md PROJECT-STACKS-12):
|
|
49
|
+
* `db/seed.sql` moves to the shared level's `sql/reference.sql` and each loose SQL file of `<env>/seeds/` to that level's `sql/`, each
|
|
50
|
+
* declared in its level's `seeds.yaml` (target the primary database, mode upsert); every level gets its manifest and a `.gitkeep` for each
|
|
51
|
+
* kind folder that holds nothing. A target with the same bytes drops the old copy; other bytes, a loose seed that is no SQL and a source that
|
|
52
|
+
* still names an old home are left for a person. A second run finds nothing to do.
|
|
53
|
+
*/
|
|
54
|
+
export function migrateSeedsLayout({ root, write = false }) {
|
|
55
|
+
const environments = [...(declaredEnvironments(root)?.keys() ?? [])];
|
|
56
|
+
const report = { schema: 'starci/seeds-layout-report@1', root, write, environments, moves: [], created: [], findings: [] };
|
|
57
|
+
const landed = new Set();
|
|
58
|
+
const entries = new Map();
|
|
59
|
+
for (const { env, from, name } of legacySeeds(root, environments, report.findings)) {
|
|
60
|
+
const to = `${seedLevelDir(env)}/sql/${name}`;
|
|
61
|
+
const source = path.join(root, from);
|
|
62
|
+
const target = path.join(root, to);
|
|
63
|
+
if (isFile(target) && !fs.readFileSync(target).equals(fs.readFileSync(source))) {
|
|
64
|
+
report.findings.push({ file: from, code: 'SEEDS_LAYOUT_CONFLICT', reason: `${to} already holds other data; merge the two by hand and delete ${from}` });
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const id = entryId(name);
|
|
68
|
+
report.moves.push({ from, to, entry: id, action: isFile(target) ? 'drop' : 'move' });
|
|
69
|
+
entries.set(env, [...(entries.get(env) ?? []), { id, kind: 'sql', path: `sql/${name}`, target: SQL_TARGET, mode: 'upsert' }]);
|
|
70
|
+
landed.add(`${seedLevelDir(env)}/sql`).add(`${seedLevelDir(env)}/${SEEDS_MANIFEST}`);
|
|
71
|
+
if (!write)
|
|
72
|
+
continue;
|
|
73
|
+
if (isFile(target))
|
|
74
|
+
fs.rmSync(source);
|
|
75
|
+
else {
|
|
76
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
77
|
+
fs.renameSync(source, target);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
for (const [env, list] of entries)
|
|
81
|
+
addSeedEntries(root, env, list, write);
|
|
82
|
+
for (const env of [null, ...environments])
|
|
83
|
+
report.created.push(...levelSkeleton(root, env, landed));
|
|
84
|
+
if (write) {
|
|
85
|
+
for (const file of report.created) {
|
|
86
|
+
fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true });
|
|
87
|
+
fs.writeFileSync(path.join(root, file), file.endsWith(SEEDS_MANIFEST) ? `schema: ${SEEDS_SCHEMA}\nentries: []\n` : '');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
report.findings.push(...referenceFindings(root));
|
|
91
|
+
return report;
|
|
92
|
+
}
|
|
93
|
+
const cell = (value) => String(value).replaceAll('|', '\\|').replaceAll('\n', ' ');
|
|
94
|
+
/** Render the migration and what is left for a person as Markdown; JSON consumers read the same report object. */
|
|
95
|
+
export function seedsLayoutMarkdown(report) {
|
|
96
|
+
const done = report.write ? 'moved' : 'would be moved';
|
|
97
|
+
const out = ['# seeds-layout', '', `${report.moves.length} seed file(s) ${done}, ${report.created.length} level file(s) ${report.write ? 'created' : 'would be created'}, ${report.findings.length} left for a person.`, ''];
|
|
98
|
+
if (report.findings.length) {
|
|
99
|
+
out.push('## Left for a person', '', '| file | code | reason |', '|---|---|---|');
|
|
100
|
+
for (const finding of report.findings)
|
|
101
|
+
out.push(`| ${cell(finding.file)} | ${finding.code} | ${cell(finding.reason)} |`);
|
|
102
|
+
out.push('');
|
|
103
|
+
}
|
|
104
|
+
if (report.moves.length) {
|
|
105
|
+
out.push('## Moved', '', '| from | to | entry | action |', '|---|---|---|---|');
|
|
106
|
+
for (const item of report.moves)
|
|
107
|
+
out.push(`| ${cell(item.from)} | ${cell(item.to)} | ${item.entry} | ${item.action} |`);
|
|
108
|
+
out.push('');
|
|
109
|
+
}
|
|
110
|
+
if (report.created.length)
|
|
111
|
+
out.push('## Created', '', ...report.created.map((file) => `- ${file}`), '');
|
|
112
|
+
return `${out.join('\n')}\n`;
|
|
113
|
+
}
|