@starci/hfs 5.4.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.
Files changed (127) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +4 -3
  3. package/cache/test-family.d.mts +1 -1
  4. package/cache/test-family.mjs +20 -15
  5. package/codemod/main.mjs +6 -1
  6. package/codemod/seeds-layout.d.mts +36 -0
  7. package/codemod/seeds-layout.mjs +113 -0
  8. package/codemod/sops-rules.d.mts +1 -1
  9. package/codemod/sops-rules.mjs +8 -3
  10. package/lint/run.d.mts +5 -4
  11. package/lint/run.mjs +53 -27
  12. package/package.json +1 -1
  13. package/release/lint-report.mjs +4 -3
  14. package/runtime/knowledge/hfs/canon-pins.yaml +12 -12
  15. package/runtime/knowledge/hfs/ci-standard.yaml +2 -2
  16. package/runtime/knowledge/hfs/rules.yaml +15 -2
  17. package/runtime/knowledge/hfs/slots.yaml +15 -18
  18. package/runtime/modules/kernel/failure-codes.yaml +64 -3
  19. package/runtime/scripts/hfs/architecture/root-packages.mjs +1 -1
  20. package/runtime/scripts/hfs/check.mjs +4 -1
  21. package/runtime/scripts/hfs/coverage-scope.d.mts +15 -0
  22. package/runtime/scripts/hfs/coverage-scope.mjs +26 -0
  23. package/runtime/scripts/hfs/db-squash.mjs +9 -2
  24. package/runtime/scripts/hfs/declaration-shape.mjs +3 -1
  25. package/runtime/scripts/hfs/rules/app-root.mjs +4 -1
  26. package/runtime/scripts/hfs/rules/ci-standard.mjs +5 -4
  27. package/runtime/scripts/hfs/rules/coverage-below.mjs +12 -4
  28. package/runtime/scripts/hfs/rules/database-config.mjs +8 -7
  29. package/runtime/scripts/hfs/rules/database-constants.d.mts +2 -1
  30. package/runtime/scripts/hfs/rules/database-constants.mjs +2 -1
  31. package/runtime/scripts/hfs/rules/database.d.mts +2 -2
  32. package/runtime/scripts/hfs/rules/database.mjs +7 -6
  33. package/runtime/scripts/hfs/rules/docker.mjs +24 -7
  34. package/runtime/scripts/hfs/rules/monorepo.d.mts +1 -1
  35. package/runtime/scripts/hfs/rules/monorepo.mjs +1 -1
  36. package/runtime/scripts/hfs/rules/seeds.d.mts +21 -0
  37. package/runtime/scripts/hfs/rules/seeds.mjs +194 -0
  38. package/runtime/scripts/hfs/rules/stacks.mjs +8 -4
  39. package/runtime/scripts/hfs/slots.d.mts +2 -0
  40. package/runtime/scripts/hfs/slots.mjs +1 -0
  41. package/runtime/scripts/hfs/stack-seeds-config.d.mts +88 -0
  42. package/runtime/scripts/hfs/stack-seeds-config.mjs +239 -0
  43. package/runtime/scripts/hfs/view.d.mts +25 -0
  44. package/runtime/scripts/hfs/view.mjs +41 -0
  45. package/scaffold/add-app.mjs +2 -2
  46. package/scaffold/add-cli-lite.mjs +41 -14
  47. package/scaffold/add-module.mjs +6 -3
  48. package/scaffold/lite-exports.mjs +1 -1
  49. package/scaffold/module-registry.mjs +10 -8
  50. package/src/main.mjs +8 -8
  51. package/stack/deploy.d.mts +3 -0
  52. package/stack/deploy.mjs +4 -1
  53. package/stack/local-up.mjs +25 -3
  54. package/stack/seeds.d.mts +29 -0
  55. package/stack/seeds.mjs +197 -0
  56. package/sync/canon-layout.d.mts +38 -0
  57. package/sync/canon-layout.mjs +62 -0
  58. package/sync/ci-commands.d.mts +2 -1
  59. package/sync/ci-commands.mjs +10 -7
  60. package/sync/index.d.mts +13 -2
  61. package/sync/index.mjs +52 -24
  62. package/templates/app/ci-workflows/github/workflows/ci.yml +2 -2
  63. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +2 -2
  64. package/templates/app/skeleton/.starcistacks/application-stacks.yaml +26 -0
  65. package/templates/app/skeleton/.starcistacks/dev/README.md +21 -10
  66. package/templates/app/skeleton/.starcistacks/dev/seeds/data/.gitkeep +0 -0
  67. package/templates/app/skeleton/.starcistacks/dev/seeds/files/.gitkeep +0 -0
  68. package/templates/app/skeleton/.starcistacks/dev/seeds/seeds.yaml +3 -0
  69. package/templates/app/skeleton/.starcistacks/production/seeds/data/.gitkeep +0 -0
  70. package/templates/app/skeleton/.starcistacks/production/seeds/files/.gitkeep +0 -0
  71. package/templates/app/skeleton/.starcistacks/production/seeds/seeds.yaml +2 -0
  72. package/templates/app/skeleton/.starcistacks/production/seeds/sql/.gitkeep +0 -0
  73. package/templates/app/skeleton/.starcistacks/seeds/data/.gitkeep +0 -0
  74. package/templates/app/skeleton/.starcistacks/seeds/files/.gitkeep +0 -0
  75. package/templates/app/skeleton/.starcistacks/seeds/seeds.yaml +2 -0
  76. package/templates/app/skeleton/.starcistacks/seeds/sql/.gitkeep +0 -0
  77. package/templates/app/skeleton/README.md +1 -1
  78. package/templates/app/skeleton-lite/.starcistacks/dev/README.md +2 -2
  79. package/templates/app/skeleton-lite/.starcistacks/dev/seeds/data/.gitkeep +0 -0
  80. package/templates/app/skeleton-lite/.starcistacks/dev/seeds/files/.gitkeep +0 -0
  81. package/templates/app/skeleton-lite/.starcistacks/dev/seeds/seeds.yaml +2 -0
  82. package/templates/app/skeleton-lite/.starcistacks/dev/seeds/sql/.gitkeep +0 -0
  83. package/templates/app/skeleton-lite/.starcistacks/production/seeds/data/.gitkeep +0 -0
  84. package/templates/app/skeleton-lite/.starcistacks/production/seeds/files/.gitkeep +0 -0
  85. package/templates/app/skeleton-lite/.starcistacks/production/seeds/seeds.yaml +2 -0
  86. package/templates/app/skeleton-lite/.starcistacks/production/seeds/sql/.gitkeep +0 -0
  87. package/templates/app/skeleton-lite/.starcistacks/seeds/data/.gitkeep +0 -0
  88. package/templates/app/skeleton-lite/.starcistacks/seeds/files/.gitkeep +0 -0
  89. package/templates/app/skeleton-lite/.starcistacks/seeds/seeds.yaml +2 -0
  90. package/templates/app/skeleton-lite/.starcistacks/seeds/sql/.gitkeep +0 -0
  91. package/templates/app/skeleton-lite/README.md +2 -2
  92. package/templates/be/auth-lite/src/features/api/auth/application/auth.contracts.ts +2 -0
  93. package/templates/be/auth-lite/src/features/api/auth/application/signup.handler.ts +6 -1
  94. package/templates/be/auth-lite/src/features/api/auth/auth.module.ts +14 -1
  95. package/templates/be/auth-lite/src/features/api/auth/transport/http/auth.controller.ts +5 -1
  96. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/reset.request.ts +0 -1
  97. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/signup.request.ts +7 -0
  98. package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.spec.ts +15 -0
  99. package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.ts +9 -3
  100. package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.spec.ts +79 -16
  101. package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.ts +39 -9
  102. package/templates/be/auth-lite/src/modules/domain/identity/identity-email.service.spec.ts +108 -20
  103. package/templates/be/auth-lite/src/modules/domain/identity/identity-email.service.ts +34 -7
  104. package/templates/be/auth-lite/src/modules/domain/identity/persistence/account-doors.port.ts +28 -4
  105. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/index.ts +6 -1
  106. package/templates/be/auth-lite/src/modules/domain/identity/rate-counter.service.spec.ts +9 -2
  107. package/templates/be/auth-lite/src/modules/domain/identity/rate-counter.service.ts +11 -2
  108. package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.spec.ts +59 -9
  109. package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.ts +4 -1
  110. package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.spec.ts +25 -4
  111. package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.ts +8 -1
  112. package/templates/be/auth-lite/src/tests/fixtures/builders/identity.builder.ts +3 -1
  113. package/templates/be/skeleton/src/features/cli/seed/subs/run.cli.ts +3 -3
  114. package/templates/be/skeleton/src/modules/platform/database/database.sql.ts +1 -1
  115. package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.spec.ts +3 -2
  116. package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.ts +6 -2
  117. package/templates/be/skeleton/src/modules/platform/database/seed-runner.service.spec.ts +14 -9
  118. package/templates/be/skeleton/src/modules/platform/database/seed-runner.service.ts +3 -3
  119. package/templates/be/skeleton-lite/src/modules/platform/database/seed-runner.service.ts +32 -7
  120. package/templates/be/tool-config/jest.config.js +1 -1
  121. package/templates/fe/auth-lite/apps/web/src/modules/auth/email-doors.ts +4 -1
  122. package/templates/fe/skeleton-lite/apps/web/src/components/blocks/EmailDoorForm/component.tsx +3 -0
  123. package/templates/fe/skeleton-lite/apps/web/src/components/blocks/EmailDoorForm/index.tsx +14 -6
  124. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/en.json +1 -0
  125. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/vi.json +1 -0
  126. package/templates/app/module/jest.config.js.tpl +0 -8
  127. /package/templates/app/skeleton/.starcistacks/dev/seeds/{primary-notes.sql → sql/primary-notes.sql} +0 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
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
+
3
22
  ## 5.4.0 - 2026-11-03
4
23
 
5
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`).
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 once, the native app
37
- processes, readiness and the origins. `prepare`, `up` and `down` are dry runs until `--execute`; `down` never removes a volume. State (process ids,
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), `db/seed.sql` (the rows of `--seed-tables`, `insert ... on conflict do nothing` in foreign-key order with their
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
@@ -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?: {
@@ -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
- let config;
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 = configs.find((entry) => entry.displayName?.name === project);
174
- if (config === undefined)
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
- const closureFrom = (roots) => {
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 setup = (name) => [...(config[name] ?? [])];
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 own = closureFrom([path.resolve(appRoot, spec)]).map(relative).filter((file) => file !== spec);
213
- const closure = [...new Set([...own, ...setupClosure])].sort();
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
  },
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) };
@@ -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
+ }
@@ -24,7 +24,7 @@ export declare function managedPathRegex(from: string): string;
24
24
  /**
25
25
  * Migrate a legacy `.sops.yaml` to the form `starci app secret init --env` writes (public recipients only, a rule per member spelling): a rule whose
26
26
  * path_regex matches the legacy `.enc` spelling alone also matches `.enc.yaml`, the spelling `secret set` writes, which sops otherwise refuses with
27
- * no creation rule. Recipients and the other rules are kept; nothing private is read or written.
27
+ * no creation rule. Recipients, the other rules and the comment header the file opens with (the managed header of `secret init`) are kept; nothing private is read or written.
28
28
  */
29
29
  export declare function migrateSopsRules({ root, write }: {
30
30
  root: string;
@@ -15,19 +15,24 @@ export function managedPathRegex(from) {
15
15
  return CURRENT;
16
16
  return /secrets/.test(from) && from.endsWith(LEGACY_END) ? `${from.slice(0, -LEGACY_END.length)}${MODERN_END}` : from;
17
17
  }
18
+ /** The comment lines a file opens with (its managed header among them), which a rewrite of the YAML body keeps as written. */
19
+ function leadingComments(text) {
20
+ return text.replace(/^((?:[ \t]*(?:#[^\n]*)?\r?\n)*)[\s\S]*$/, '$1');
21
+ }
18
22
  /**
19
23
  * Migrate a legacy `.sops.yaml` to the form `starci app secret init --env` writes (public recipients only, a rule per member spelling): a rule whose
20
24
  * path_regex matches the legacy `.enc` spelling alone also matches `.enc.yaml`, the spelling `secret set` writes, which sops otherwise refuses with
21
- * no creation rule. Recipients and the other rules are kept; nothing private is read or written.
25
+ * no creation rule. Recipients, the other rules and the comment header the file opens with (the managed header of `secret init`) are kept; nothing private is read or written.
22
26
  */
23
27
  export function migrateSopsRules({ root, write = false }) {
24
28
  const report = { schema: 'starci/sops-rules-report@1', root, write, file: FILE, changes: [], findings: [] };
25
29
  const target = path.join(root, FILE);
26
30
  if (!fs.existsSync(target))
27
31
  return report;
32
+ const text = fs.readFileSync(target, 'utf8');
28
33
  let config;
29
34
  try {
30
- config = parseYaml(fs.readFileSync(target, 'utf8'));
35
+ config = parseYaml(text);
31
36
  }
32
37
  catch {
33
38
  config = null;
@@ -46,7 +51,7 @@ export function migrateSopsRules({ root, write = false }) {
46
51
  rule.path_regex = to;
47
52
  });
48
53
  if (report.changes.length > 0 && write)
49
- fs.writeFileSync(target, stringifyYaml(config));
54
+ fs.writeFileSync(target, `${leadingComments(text)}${stringifyYaml(config)}`);
50
55
  return report;
51
56
  }
52
57
  const cell = (value) => String(value).replaceAll('|', '\\|').replaceAll('\n', ' ');
package/lint/run.d.mts CHANGED
@@ -15,8 +15,9 @@ export interface LinterRun {
15
15
  }
16
16
  /**
17
17
  * What a linter run is asked for. `list` is the files an ESLint run lints (cwd-relative, posix) when they are named one by one: they go to the child
18
- * through a file, never the command line, and `args` then holds the options alone. `files` is the number of source files the run judges, which the
19
- * heavy-command budget of the ESLint kind scales with (a list names its own count).
18
+ * through a file, never the command line, and `args` then holds the options alone. `files` is the number of source files the child's work is made of,
19
+ * which the heavy-command budget of the ESLint kind scales with: the typed program and the project graph of a side are built whole for a named list
20
+ * too, so a list run of a side whose scope is known names the side's count; without `files` a list names its own count.
20
21
  */
21
22
  export interface LinterSpec {
22
23
  cwd: string;
@@ -134,8 +135,8 @@ export interface CachedLinters {
134
135
  }
135
136
  /**
136
137
  * ESLint and stylelint through the result cache: per side, the files the records cannot vouch for go to the very launcher, chunking and result
137
- * validation of a full run (eslintRepository), the rest take their records. The answer has the shape of eslintRepository's, plus the stylelint
138
- * results, so the lint report is built the same way from either.
138
+ * validation of a full run (eslintRepository), the rest take their records; then the root packages, linted whole. Each scope runs on its own
139
+ * (scopeFailure). The answer has the shape of eslintRepository's, plus the stylelint results, so the lint report is built the same way from either.
139
140
  */
140
141
  export declare function cachedLinters({ repoRoot, app, side: only, run, session }: {
141
142
  repoRoot: string;
package/lint/run.mjs CHANGED
@@ -84,7 +84,7 @@ export async function runLinter({ cwd, pkg, bin, args, bound = null, list, files
84
84
  fs.writeFileSync(job, JSON.stringify({ files: list ?? ['.'], fix: args.includes('--fix'), ...(snapshotFile === null || graph === undefined ? {} : { graph: { out: snapshotFile, canons: graph } }) }));
85
85
  childArgs = [LIST_RUNNER, job];
86
86
  }
87
- const count = list?.length ?? files;
87
+ const count = files ?? list?.length;
88
88
  let run;
89
89
  let handed;
90
90
  try {
@@ -365,8 +365,10 @@ async function launchEslint({ app, repoRoot, opts, changed, existing, run = runL
365
365
  // the child that builds the canon's project graph for its rules hands the snapshot out when the caller keeps the graph findings (the cache); the packages are no side folder and have no graph
366
366
  const graph = handOutGraph && !packages ? graphCanonsOf(repoRoot, cwd, side) : undefined;
367
367
  // The files of a diff go to ONE child through a file (a diff of hundreds of long paths outgrows the Windows command line, and each extra
368
- // child would build the whole TypeScript program again); the whole side is a single directory argument.
369
- const launch = Promise.resolve().then(() => run(changed ? { cwd, pkg: 'eslint', bin: 'eslint', args: head, list: sources, bound: repoRoot, ...(graph === undefined ? {} : { graph }) } : { cwd, pkg: 'eslint', bin: 'eslint', args: [...head, packages ? PACKAGES : '.'], files: expected.length, bound: repoRoot, ...(graph === undefined ? {} : { graph }) }));
368
+ // child would build the whole TypeScript program again); the whole side is a single directory argument. Either child builds the typed program
369
+ // and the project graph of the whole side, so its liveness budget scales with the side's scope when the caller knows it, never with the diff alone.
370
+ const programFiles = known?.length ?? expected.length;
371
+ const launch = Promise.resolve().then(() => run(changed ? { cwd, pkg: 'eslint', bin: 'eslint', args: head, list: sources, files: programFiles, bound: repoRoot, ...(graph === undefined ? {} : { graph }) } : { cwd, pkg: 'eslint', bin: 'eslint', args: [...head, packages ? PACKAGES : '.'], files: programFiles, bound: repoRoot, ...(graph === undefined ? {} : { graph }) }));
370
372
  launches.push({ side, cwd, expected, run: launch.catch(spawnFailure) });
371
373
  }
372
374
  return launches;
@@ -489,10 +491,20 @@ export function gitBaseToken(repoRoot) {
489
491
  }
490
492
  return 'no-base';
491
493
  }
494
+ /**
495
+ * A scope whose linter reached no complete measurement (a child ended by its liveness budget, a capture cut short) is a tool error of that scope, and the
496
+ * next scope still runs: the root packages are judged whatever became of the back end. Nothing of the failed scope is recorded (its records are written
497
+ * only after its child completed). A hold of the host (HOST_HELD, HEAVY_DEFERRED) still ends the run, since no scope would be admitted either.
498
+ */
499
+ function scopeFailure(error, scope) {
500
+ if (!isNodeExecutionRefusal(error) || isHostHeld(error) || error.code === 'HEAVY_DEFERRED')
501
+ throw error;
502
+ return `eslint did not complete in ${scope}/: ${String(error.message).split('\n')[0]}`;
503
+ }
492
504
  /**
493
505
  * ESLint and stylelint through the result cache: per side, the files the records cannot vouch for go to the very launcher, chunking and result
494
- * validation of a full run (eslintRepository), the rest take their records. The answer has the shape of eslintRepository's, plus the stylelint
495
- * results, so the lint report is built the same way from either.
506
+ * validation of a full run (eslintRepository), the rest take their records; then the root packages, linted whole. Each scope runs on its own
507
+ * (scopeFailure). The answer has the shape of eslintRepository's, plus the stylelint results, so the lint report is built the same way from either.
496
508
  */
497
509
  export async function cachedLinters({ repoRoot, app, side: only, run, session }) {
498
510
  const eslint = { findings: [], errors: [], raw: [], engine: { sides: {} } };
@@ -514,23 +526,31 @@ export async function cachedLinters({ repoRoot, app, side: only, run, session })
514
526
  continue;
515
527
  }
516
528
  const styled = side === STYLE_SIDE;
517
- const outcome = await lintSide({
518
- session, side, sideRoot: cwd, scope, stylesheets: styled ? stylesheetsOf(cwd) : [],
519
- canons, styleCanon: styled ? await styleDeclaration({ repoRoot, cwd }) : null, typescript: () => loadAppTypeScript(cwd), extraKey: '', gitToken, full, write: true,
520
- eslint: async (files) => {
521
- const listed = files === null ? null : files.map((file) => `${side}/${file}`);
522
- const sub = await eslintRepository({ repoRoot, app, opts: { side }, changed: listed, existing: listed ?? [], run, scopes: { [side]: scope }, handOutGraph: true });
523
- return { results: sub.raw, errors: sub.errors, ...(sub.graphs[side] === undefined ? {} : { graph: sub.graphs[side] }) };
524
- },
525
- stylelint: styled ? async (files) => {
526
- const linted = await (run ?? runLinter)({ cwd, pkg: 'stylelint', bin: 'stylelint', args: [...(files ?? [STYLE_GLOB]), '--formatter', 'json'] });
527
- const appRelative = appRelativeMessages(STYLE_SIDE, cwd);
528
- for (const result of linted.results ?? [])
529
- for (const warning of result.warnings ?? [])
530
- warning.text = appRelative(warning.text);
531
- return { results: (linted.results ?? []), errors: linted.error ? [linted.error] : [] };
532
- } : null,
533
- });
529
+ let outcome;
530
+ try {
531
+ outcome = await lintSide({
532
+ session, side, sideRoot: cwd, scope, stylesheets: styled ? stylesheetsOf(cwd) : [],
533
+ canons, styleCanon: styled ? await styleDeclaration({ repoRoot, cwd }) : null, typescript: () => loadAppTypeScript(cwd), extraKey: '', gitToken, full, write: true,
534
+ eslint: async (files) => {
535
+ const listed = files === null ? null : files.map((file) => `${side}/${file}`);
536
+ const sub = await eslintRepository({ repoRoot, app, opts: { side }, changed: listed, existing: listed ?? [], run, scopes: { [side]: scope }, handOutGraph: true });
537
+ return { results: sub.raw, errors: sub.errors, ...(sub.graphs[side] === undefined ? {} : { graph: sub.graphs[side] }) };
538
+ },
539
+ stylelint: styled ? async (files) => {
540
+ const linted = await (run ?? runLinter)({ cwd, pkg: 'stylelint', bin: 'stylelint', args: [...(files ?? [STYLE_GLOB]), '--formatter', 'json'] });
541
+ const appRelative = appRelativeMessages(STYLE_SIDE, cwd);
542
+ for (const result of linted.results ?? [])
543
+ for (const warning of result.warnings ?? [])
544
+ warning.text = appRelative(warning.text);
545
+ return { results: (linted.results ?? []), errors: linted.error ? [linted.error] : [] };
546
+ } : null,
547
+ });
548
+ }
549
+ catch (error) {
550
+ eslint.errors.push(scopeFailure(error, side));
551
+ eslint.engine.sides[side] = { files: 0 };
552
+ continue;
553
+ }
534
554
  sides.push({ side, stats: outcome.stats });
535
555
  for (const message of outcome.errors)
536
556
  (message.startsWith('stylelint ') ? style.errors : eslint.errors).push(message);
@@ -544,11 +564,17 @@ export async function cachedLinters({ repoRoot, app, side: only, run, session })
544
564
  }
545
565
  // The root packages are small and have no result records: they are linted whole on every run, through the same launcher.
546
566
  if ((only === undefined || only === PACKAGES) && hasPackagesLint(repoRoot)) {
547
- const linted = await eslintRepository({ repoRoot, app, opts: { side: PACKAGES }, run });
548
- eslint.errors.push(...linted.errors);
549
- eslint.raw.push(...linted.raw);
550
- eslint.findings.push(...linted.findings);
551
- eslint.engine.sides[PACKAGES] = linted.engine.sides[PACKAGES] ?? { files: 0 };
567
+ try {
568
+ const linted = await eslintRepository({ repoRoot, app, opts: { side: PACKAGES }, run });
569
+ eslint.errors.push(...linted.errors);
570
+ eslint.raw.push(...linted.raw);
571
+ eslint.findings.push(...linted.findings);
572
+ eslint.engine.sides[PACKAGES] = linted.engine.sides[PACKAGES] ?? { files: 0 };
573
+ }
574
+ catch (error) {
575
+ eslint.errors.push(scopeFailure(error, PACKAGES));
576
+ eslint.engine.sides[PACKAGES] = { files: 0 };
577
+ }
552
578
  }
553
579
  session.report(lintFamilyReport(sides));
554
580
  return { eslint, stylelint: style, sides };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/hfs",
3
- "version": "5.4.0",
3
+ "version": "5.5.0",
4
4
  "description": "The StarCi app command implementation for scaffolding, linting, checking and synchronizing one product repository.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -5,7 +5,8 @@
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import { firstLine, tool } from '../stack/context.mjs';
8
- const LINT_SIDES = ['be', 'fe'];
8
+ import { PACKAGES, PACKAGES_CONFIG } from '../lint/run.mjs';
9
+ const LINT_SIDES = ['be', 'fe', PACKAGES];
9
10
  const MERGED_REPORT = 'reports/lint.sonar.json';
10
11
  const sideReport = (side) => `reports/lint.${side}.sonar.json`;
11
12
  /** CI merge: rules unique by id (last one wins, first position kept), issues concatenated in side order. */
@@ -14,8 +15,8 @@ function mergeSonarReports(reports) {
14
15
  const issues = reports.flatMap((report) => report.issues);
15
16
  return { rules, issues };
16
17
  }
17
- /** The sides the app has: a be/ or fe/ folder beside the app root. */
18
- const sidesOf = (root) => LINT_SIDES.filter((side) => fs.existsSync(path.join(root, side)));
18
+ /** The lint scopes the app has: a be/ or fe/ folder beside the app root, and its root packages when it has their managed config. */
19
+ const sidesOf = (root) => LINT_SIDES.filter((side) => fs.existsSync(path.join(root, side)) && (side !== PACKAGES || fs.existsSync(path.join(root, PACKAGES_CONFIG))));
19
20
  /**
20
21
  * Runs `starci app check --gate lint --side <side> --out <file>` for every side the app has, then writes reports/lint.sonar.json.
21
22
  * `launch` turns an argv into [command, args] for the platform. Returns { pass, text }: the honest log of every lint run and the verdict.