@starci/hfs 5.0.7 → 5.1.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 (204) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +7 -8
  3. package/cache/fake-session.mjs +1 -1
  4. package/cache/hfs-family.mjs +22 -9
  5. package/cache/import-graph.d.mts +32 -4
  6. package/cache/import-graph.mjs +88 -11
  7. package/cache/lint-family.d.mts +10 -3
  8. package/cache/lint-family.mjs +65 -37
  9. package/cache/records.d.mts +7 -0
  10. package/cache/records.mjs +17 -1
  11. package/cache/run.d.mts +1 -1
  12. package/cache/session.mjs +6 -2
  13. package/cache/side-key.d.mts +2 -0
  14. package/cache/side-key.mjs +5 -1
  15. package/cache/structure-memo.d.mts +34 -0
  16. package/cache/structure-memo.mjs +150 -0
  17. package/cache/types.d.mts +6 -2
  18. package/cache/wiring-family.mjs +1 -1
  19. package/codemod/directive-first.d.mts +48 -0
  20. package/codemod/directive-first.mjs +136 -0
  21. package/codemod/explicit-surface-edit.d.mts +14 -0
  22. package/codemod/explicit-surface-edit.mjs +57 -0
  23. package/codemod/explicit-surface-names.d.mts +30 -0
  24. package/codemod/explicit-surface-names.mjs +135 -0
  25. package/codemod/explicit-surface-render.d.mts +29 -0
  26. package/codemod/explicit-surface-render.mjs +197 -0
  27. package/codemod/explicit-surface.d.mts +50 -0
  28. package/codemod/explicit-surface.mjs +374 -0
  29. package/codemod/main.d.mts +7 -5
  30. package/codemod/main.mjs +37 -16
  31. package/codemod/small-renames.mjs +22 -2
  32. package/codemod/source-tree.d.mts +8 -0
  33. package/codemod/source-tree.mjs +23 -0
  34. package/lint/eslint-list.cjs +55 -0
  35. package/lint/run.d.mts +35 -61
  36. package/lint/run.mjs +91 -288
  37. package/package.json +1 -1
  38. package/release/lint-report.d.mts +1 -1
  39. package/release/lint-report.mjs +3 -3
  40. package/report/sonar.d.mts +1 -1
  41. package/report/sonar.mjs +3 -3
  42. package/report/stylelint-findings.d.mts +23 -0
  43. package/report/stylelint-findings.mjs +9 -0
  44. package/runtime/knowledge/hfs/canon-pins.yaml +8 -8
  45. package/runtime/knowledge/hfs/ci-standard.yaml +2 -2
  46. package/runtime/knowledge/hfs/rules.yaml +56 -5
  47. package/runtime/knowledge/hfs/slots.yaml +1 -1
  48. package/runtime/knowledge/sonar-gate.yaml +2 -2
  49. package/runtime/modules/kernel/failure-codes.yaml +4 -4
  50. package/runtime/scripts/api/node/run-node.d.mts +5 -0
  51. package/runtime/scripts/hfs/architecture/test-world-files.d.mts +4 -3
  52. package/runtime/scripts/hfs/architecture/test-world-files.mjs +26 -7
  53. package/runtime/scripts/hfs/check.d.mts +16 -1
  54. package/runtime/scripts/hfs/check.mjs +18 -7
  55. package/runtime/scripts/hfs/explicit-surface-rule.d.mts +58 -0
  56. package/runtime/scripts/hfs/explicit-surface-rule.mjs +59 -0
  57. package/runtime/scripts/hfs/gate-threshold.mjs +1 -1
  58. package/runtime/scripts/hfs/rule-memo.d.mts +30 -0
  59. package/runtime/scripts/hfs/rule-memo.mjs +24 -0
  60. package/runtime/scripts/hfs/rules/a15-findings.d.mts +6 -0
  61. package/runtime/scripts/hfs/rules/a15-findings.mjs +11 -2
  62. package/runtime/scripts/hfs/rules/ci-standard.mjs +2 -2
  63. package/runtime/scripts/hfs/rules/monorepo.d.mts +1 -3
  64. package/runtime/scripts/hfs/rules/monorepo.mjs +9 -11
  65. package/runtime/scripts/hfs/rules/pipeline.mjs +13 -9
  66. package/runtime/scripts/hfs/rules/read.d.mts +5 -0
  67. package/runtime/scripts/hfs/rules/read.mjs +13 -0
  68. package/runtime/scripts/hfs/rules/secrets.d.mts +2 -0
  69. package/runtime/scripts/hfs/rules/secrets.mjs +4 -1
  70. package/runtime/scripts/hfs/rules/stacks-release.mjs +35 -21
  71. package/runtime/scripts/lib/declaration-surface.d.mts +35 -0
  72. package/runtime/scripts/lib/declaration-surface.mjs +112 -0
  73. package/runtime/scripts/lib/worktree-exclude.mjs +1 -1
  74. package/scaffold/app.mjs +3 -3
  75. package/src/check-cache-structure-support.d.mts +52 -0
  76. package/src/main.d.mts +3 -2
  77. package/src/main.mjs +123 -109
  78. package/sync/ci-commands.d.mts +2 -1
  79. package/sync/ci-commands.mjs +19 -20
  80. package/sync/index.mjs +2 -2
  81. package/templates/app/package-scripts/core.json +2 -2
  82. package/templates/app/task-graph/turbo.json +0 -3
  83. package/templates/be/inbox/delivery-recorded.event.ts.tpl +2 -2
  84. package/templates/be/inbox/entities.index.ts.tpl +1 -1
  85. package/templates/be/inbox/module-definition.ts.tpl +8 -2
  86. package/templates/be/inbox/platform/event-bus/event-bus.module-definition.ts.tpl +10 -2
  87. package/templates/be/inbox/platform/inbox/inbox.module-definition.ts.tpl +8 -2
  88. package/templates/be/patterns/api/feature.module-definition.ts.tpl +9 -3
  89. package/templates/be/patterns/api/graphql.module-definition.ts.tpl +9 -3
  90. package/templates/be/patterns/cli/group.module-definition.ts.tpl +9 -3
  91. package/templates/be/patterns/event-bus/platform/event-bus.module-definition.ts.tpl +8 -3
  92. package/templates/be/patterns/event-bus/platform/persistence/connection.ts.tpl +3 -2
  93. package/templates/be/patterns/event-bus/platform/persistence/event-bus.sql.ts.tpl +4 -3
  94. package/templates/be/patterns/idempotency/platform/inbox/inbox.module-definition.ts.tpl +8 -3
  95. package/templates/be/patterns/idempotency/platform/inbox/persistence/connection.ts.tpl +3 -2
  96. package/templates/be/patterns/idempotency/platform/inbox/persistence/inbox.sql.ts.tpl +7 -6
  97. package/templates/be/patterns/jobs/job.module-definition.ts.tpl +9 -3
  98. package/templates/be/patterns/jobs/job.processor.ts.tpl +1 -1
  99. package/templates/be/patterns/jobs/job.queue.module-definition.ts.tpl +9 -3
  100. package/templates/be/patterns/jobs/platform/jobs.module-definition.ts.tpl +8 -3
  101. package/templates/be/patterns/jobs/platform/persistence.connection.ts.tpl +3 -2
  102. package/templates/be/patterns/jobs/platform/persistence.jobs.sql.ts.tpl +5 -4
  103. package/templates/be/patterns/projections/persistence.connection.ts.tpl +3 -2
  104. package/templates/be/patterns/projections/projection.module-definition.ts.tpl +8 -3
  105. package/templates/be/patterns/queues/platform/persistence.connection.ts.tpl +3 -2
  106. package/templates/be/patterns/queues/platform/persistence.queue.sql.ts.tpl +4 -3
  107. package/templates/be/patterns/queues/platform/queue.module-definition.ts.tpl +8 -3
  108. package/templates/be/patterns/queues/queue.module-definition.ts.tpl +9 -3
  109. package/templates/be/patterns/reactors/consumer.ts.tpl +1 -1
  110. package/templates/be/patterns/reactors/message.module-definition.ts.tpl +9 -3
  111. package/templates/be/patterns/reactors/reactor.module-definition.ts.tpl +9 -3
  112. package/templates/be/patterns/realtime/channel.graphql.module-definition.ts.tpl +9 -3
  113. package/templates/be/patterns/realtime/channel.sse.module-definition.ts.tpl +9 -3
  114. package/templates/be/patterns/realtime/channel.websocket.module-definition.ts.tpl +9 -3
  115. package/templates/be/patterns/saga/saga.done.consumer.ts.tpl +1 -1
  116. package/templates/be/patterns/saga/saga.failed.consumer.ts.tpl +1 -1
  117. package/templates/be/patterns/saga/saga.message.module-definition.ts.tpl +9 -3
  118. package/templates/be/patterns/saga/saga.module-definition.ts.tpl +9 -3
  119. package/templates/be/patterns/webhooks/webhook.http.module-definition.ts.tpl +9 -3
  120. package/templates/be/platform/graphql/graphql.module-definition.ts +8 -3
  121. package/templates/be/skeleton/src/features/api/system-health/system-health.module-definition.ts +9 -3
  122. package/templates/be/skeleton/src/features/api/system-health/transport/http/system-health-http.module-definition.ts +9 -3
  123. package/templates/be/skeleton/src/features/cli/cli.module-definition.ts +9 -3
  124. package/templates/be/skeleton/src/features/cli/migrate/migrate.module-definition.ts +9 -3
  125. package/templates/be/skeleton/src/features/cli/seed/seed.module-definition.ts +9 -3
  126. package/templates/be/skeleton/src/modules/domain/identity/identity.module-definition.ts +8 -3
  127. package/templates/be/skeleton/src/modules/domain/liveness/liveness.module-definition.ts +8 -3
  128. package/templates/be/skeleton/src/modules/domain/note/note.module-definition.ts +8 -3
  129. package/templates/be/skeleton/src/modules/domain/note/persistence/connection.ts +3 -2
  130. package/templates/be/skeleton/src/modules/domain/note/persistence/note.sql.ts +2 -1
  131. package/templates/be/skeleton/src/modules/platform/clock/clock.module-definition.ts +8 -3
  132. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.module-definition.ts +8 -3
  133. package/templates/be/skeleton/src/modules/platform/database/database.module-definition.ts +8 -3
  134. package/templates/be/skeleton/src/modules/platform/database/index.ts +1 -0
  135. package/templates/be/skeleton/src/modules/platform/errors/errors.module-definition.ts +10 -4
  136. package/templates/be/skeleton/src/modules/platform/http/http.module-definition.ts +8 -4
  137. package/templates/be/skeleton/src/modules/platform/http-security/http-security.module-definition.ts +10 -4
  138. package/templates/be/skeleton/src/modules/platform/http-security/persistence/connection.ts +3 -2
  139. package/templates/be/skeleton/src/modules/platform/http-security/persistence/rate-limit.sql.ts +3 -2
  140. package/templates/be/skeleton/src/modules/platform/i18n/i18n.module-definition.ts +10 -4
  141. package/templates/be/skeleton/src/modules/platform/ids/ids.module-definition.ts +8 -3
  142. package/templates/be/skeleton/src/modules/platform/logging/logging.module-definition.ts +8 -3
  143. package/templates/be/skeleton/src/modules/platform/retry/retry.module-definition.ts +8 -3
  144. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +1 -1
  145. package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/google.strategy.ts +5 -1
  146. package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/local.strategy.ts +5 -1
  147. package/templates/be/skeleton-lite/src/modules/platform/database/database.port.ts +1 -1
  148. package/templates/be/skeleton-lite/src/modules/platform/http-security/persistence/entities/index.ts +1 -1
  149. package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +7 -1
  150. package/templates/be/skeleton-lite/src/tests/fixtures/builders/cli.builder.ts +7 -1
  151. package/templates/be/table/api/feature.module-definition.ts.tpl +9 -3
  152. package/templates/be/table/api/graphql.module-definition.ts.tpl +9 -3
  153. package/templates/be/table/entities.index.ts.tpl +1 -1
  154. package/templates/be/table/module-definition.ts.tpl +8 -3
  155. package/templates/be/test-world/fixtures/migrations.sql.ts.tpl +1 -0
  156. package/templates/be/test-world/world/test-world.config.ts.tpl +8 -1
  157. package/templates/fe/common/app-layout.tsx.partial +1 -1
  158. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSession.ts +2 -1
  159. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSignIn.ts +11 -3
  160. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSignOut.ts +8 -2
  161. package/templates/fe/skeleton/apps/__app__/src/hooks/zustand/appGuide/store.ts +2 -1
  162. package/templates/fe/skeleton/apps/__app__/src/modules/toast/hooks.ts +1 -1
  163. package/templates/fe/skeleton/apps/__app__/src/modules/toast/toast.tsx +4 -1
  164. package/templates/fe/skeleton/apps/__app__/src/redux/hooks.ts +3 -2
  165. package/templates/fe/skeleton/apps/__app__/src/redux/index.ts +1 -0
  166. package/templates/fe/skeleton/apps/__app__/src/redux/slices/session.ts +12 -4
  167. package/templates/fe/skeleton/apps/__app__/src/redux/store.ts +8 -3
  168. package/templates/fe/skeleton/apps/app/src/app/[locale]/layout.tsx +4 -3
  169. package/templates/fe/skeleton/apps/app/src/features/layouts/AppLayout/index.tsx +1 -1
  170. package/templates/fe/skeleton/apps/app/src/features/pages/AppHomePage/index.tsx +2 -1
  171. package/templates/fe/skeleton/apps/app/src/features/pages/AppLoadingPage/index.tsx +2 -1
  172. package/templates/fe/skeleton/apps/app/src/features/pages/AppNotFoundPage/index.tsx +2 -1
  173. package/templates/fe/skeleton/apps/app/src/modules/i18n/index.ts +2 -1
  174. package/templates/fe/skeleton/apps/app/src/modules/i18n/request.ts +4 -1
  175. package/templates/fe/skeleton/apps/landing/src/app/[locale]/layout.tsx +4 -3
  176. package/templates/fe/skeleton/apps/landing/src/features/layouts/LandingLayout/index.tsx +2 -2
  177. package/templates/fe/skeleton/apps/landing/src/features/pages/LandingLoadingPage/index.tsx +2 -1
  178. package/templates/fe/skeleton/apps/landing/src/features/pages/LandingNotFoundPage/index.tsx +2 -1
  179. package/templates/fe/skeleton/apps/landing/src/features/pages/LandingPage/index.tsx +2 -1
  180. package/templates/fe/skeleton/apps/landing/src/modules/i18n/index.ts +2 -1
  181. package/templates/fe/skeleton/apps/landing/src/modules/i18n/request.ts +4 -1
  182. package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +19 -1
  183. package/templates/fe/skeleton/packages/__project__-i18n/src/proxy.ts +1 -1
  184. package/templates/fe/skeleton/packages/__project__-i18n/src/routing.ts +5 -1
  185. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/layout.tsx +4 -3
  186. package/templates/fe/skeleton-lite/apps/web/src/features/layouts/AppLayout/index.tsx +2 -2
  187. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/index.tsx +2 -1
  188. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppLoadingPage/index.tsx +2 -1
  189. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppNotFoundPage/index.tsx +2 -1
  190. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +3 -1
  191. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/routing.ts +5 -1
  192. package/templates/fe/table/graphql/useMutateSwr.ts.tpl +2 -1
  193. package/templates/fe/table/graphql/useQuerySwr.ts.tpl +2 -1
  194. package/templates/fe/transport/graphql/clients/clients/auth.ts +1 -1
  195. package/templates/fe/transport/graphql/clients/clients/no-auth.ts +1 -1
  196. package/templates/fe/transport/graphql/clients/links/attach-access-token.ts +1 -1
  197. package/templates/fe/transport/graphql/clients/links/error.ts +1 -1
  198. package/templates/fe/transport/graphql/clients/links/http.ts +1 -1
  199. package/templates/fe/transport/graphql/clients/links/locale.ts +1 -1
  200. package/templates/fe/transport/graphql/clients/links/refresh-token.ts +1 -1
  201. package/templates/fe/transport/graphql/clients/links/retry.ts +1 -1
  202. package/templates/fe/transport/graphql/clients/links/timeout.ts +1 -1
  203. package/templates/fe/units/page/index.tsx.tpl +3 -2
  204. package/templates/fe/units/slot/SlotView.tsx.tpl +1 -1
package/lint/run.mjs CHANGED
@@ -6,107 +6,31 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
6
6
  }
7
7
  return path;
8
8
  };
9
- // starci app lint - the ONE lint entry and the ONE report of a StarCi app.
9
+ // The linters of `starci app check --gate lint`: ESLint once per side and stylelint over the fe stylesheets, through the app's own install.
10
10
  //
11
- // starci app lint [--cwd <dir>] [--changed <file>...] [--workspace <dir>] [--side be|fe] [--fix] [--full] [--json] [--out <file>]
12
- //
13
- // It runs at the app root (the folder of hfs.json), over the app (or over the app-relative files named by --changed):
14
- // 1. ESLint once per side, from the side folder, through the app's own install and that side's eslint.config.mjs (be/ the be canon,
15
- // fe/ the fe canon: the per-file rules and the project-graph rules of the architecture machine, findings on the line of the
16
- // offending TypeScript file); each side lints only its own files, so the be rules never see fe/ and the fe rules never see be/;
17
- // 2. `starci app check` for what has no TypeScript file to sit on (the tree, managed files, pins, CI, contracts, docs, .starciwork), root and sides;
18
- // 3. stylelint over the fe side's stylesheets (sync STYLE_GLOB), from fe/ with its stylelint.config.mjs.
19
- // Their findings become one list of one shape (`starci/lint@1`), every path app-relative. `--json` prints that report on stdout;
20
- // `--out <file>` writes the same findings as THE Sonar Generic Issue Import file (engine ids starci-hfs, eslint, stylelint in one
21
- // document); the exit code is the land-gate input: 0 no finding, 1 at least one, 2 a tool could not run (never a pass).
22
- // --changed restricts ESLint and stylelint to the listed files and keeps of the app findings only those on a listed file or on no file.
23
- // --workspace scopes the same lint to one npm workspace of the fe side (fe/apps/<app> or fe/packages/<pkg>, app-relative): ESLint and
24
- // stylelint over that folder only, and of the app findings only those inside it. It is the `lint` script of every fe workspace (the
25
- // turbo `lint` task); the root `npm run lint` stays the whole app. There is no second linter: a workspace lint is this lint, scoped.
26
- // The result cache (contexts/project/check-cache.md): when the caller hands lintRepository a cache session, ESLint and stylelint judge only what
27
- // the records of `.starcistacks/cache/lint/<side>/` cannot vouch for (cache/lint-family.mjs) - the same records and fingerprint `app check --lint`
28
- // uses, so the two verbs never hold two answers. `--side` reads and writes its side's records, `--workspace` reads and writes none, `--changed`
29
- // and `--fix` bypass the cache, and `--full` judges everything again and rewrites the records.
11
+ // 1. ESLint runs from the side folder with that side's eslint.config.mjs (be/ the be canon, fe/ the fe canon: the per-file rules and the
12
+ // project-graph rules of the architecture machine, findings on the line of the offending TypeScript file); each side lints only its own
13
+ // files, so the be rules never see fe/ and the fe rules never see be/;
14
+ // 2. stylelint judges the fe side's stylesheets (sync STYLE_GLOB) from fe/ with its stylelint.config.mjs.
15
+ // `cachedLinters` is the one entry of `app check`: per side it judges only what the records of `.starcistacks/cache/lint/<side>/` cannot
16
+ // vouch for (cache/lint-family.mjs, contexts/project/check-cache.md), and `eslintRepository` is the launcher it and the cacheless run share.
30
17
  import fs from 'node:fs';
31
18
  import path from 'node:path';
32
19
  import { createRequire } from 'node:module';
20
+ import { makeTempDir } from '../runtime/scripts/api/fs/make-temp-dir.mjs';
33
21
  import { completedNode, isHostHeld, isNodeExecutionRefusal, runHeavyNode } from '../runtime/scripts/api/node/run-node.mjs';
34
22
  import { fileURLToPath, pathToFileURL } from 'node:url';
35
- import { linterReport, mergeReports, sonarReport, sourceRootsOf } from '../report/sonar.mjs';
36
23
  import { SIDES, appRelativeMessages, loadSlotManifest, readRepoDeclaration } from '../runtime/scripts/hfs/slots.mjs';
37
- import { isSide } from '../runtime/scripts/hfs/slot-manifest-shape.mjs';
38
- import { chunkByChars, mapBounded, mergeRuns } from './chunk.mjs';
39
24
  import { STYLE_GLOB } from '../sync/index.mjs';
40
25
  import { byCodeUnit } from '../report/order.mjs';
41
26
  import { braceVariants, globExpression } from '../runtime/scripts/lib/glob.mjs';
42
27
  import { grammarLintFor } from '../runtime/scripts/lib/grammar-lint.mjs';
43
- import { gateVerdict } from '../runtime/scripts/hfs/gate-threshold.mjs';
44
28
  import { mergeBase } from '../runtime/scripts/api/git/merge-base.mjs';
45
29
  import { lintFamilyReport, lintSide, loadAppTypeScript } from '../cache/lint-family.mjs';
46
- // --side judges only that side; root HFS findings belong to be, its Sonar report stays on that side.
47
- export const LINT_SCHEMA = 'starci/lint@1';
48
30
  /** The side whose stylesheets stylelint judges. */
49
31
  const STYLE_SIDE = 'fe';
50
32
  const CODE_PREFIX = /^\[([A-Z][A-Z0-9_]+)\] /;
51
33
  const posix = (file) => String(file).replace(/\\/g, '/').replace(/^\.\//, '');
52
- /** An npm workspace of the fe side, app-relative: one folder below fe/apps or fe/packages (the root package.json workspaces). */
53
- const WORKSPACE = /^fe\/(?:apps|packages)\/[a-z0-9][a-z0-9-]*$/;
54
- /** The app-relative workspace of `opts.workspace` (already app-relative), or a refusal when it is not one folder of fe/apps or fe/packages. */
55
- export function workspaceOf(workspace) {
56
- const rel = posix(workspace).replace(/\/+$/, '');
57
- if (!WORKSPACE.test(rel))
58
- throw new Error(`--workspace ${workspace} is not an fe workspace (fe/apps/<app> or fe/packages/<pkg>, from the app root)`);
59
- return rel;
60
- }
61
- /** Side scopes partition the full lint; Sonar reports share that scope and a frontend workspace cannot belong to be. */
62
- function validateSide(opts) {
63
- if (opts.side === undefined)
64
- return;
65
- if (!isSide(opts.side))
66
- throw new Error('LINT_SIDE_INVALID: --side is be or fe');
67
- if (opts.side === 'be' && opts.workspace !== undefined)
68
- throw new Error('LINT_SIDE_WORKSPACE_CONFLICT: --side be cannot judge a frontend workspace');
69
- }
70
- /** Side findings stay with their side; findings outside both sides belong to the backend lint. */
71
- function findingOnSide(finding, side) {
72
- if (side === undefined)
73
- return true;
74
- const file = posix(finding.path ?? '');
75
- const owner = SIDES.find((candidate) => file === candidate || file.startsWith(`${candidate}/`));
76
- return (owner ?? 'be') === side;
77
- }
78
- /** The flags of `starci app lint`; `--changed` takes every argument up to the next flag, and a repeated `--changed` adds to the list (the
79
- * catalog's list flag, `--changed a --changed b`, as the CLI dispatcher passes it through). */
80
- export function parseLintArgs(argv) {
81
- const opts = { changed: null, fix: false, format: 'text', out: undefined, workspace: undefined };
82
- for (let i = 0; i < argv.length; i += 1) {
83
- const arg = argv[i];
84
- if (arg === '--changed') {
85
- opts.changed ??= [];
86
- while (i + 1 < argv.length && !argv[i + 1].startsWith('--'))
87
- opts.changed.push(posix(argv[++i]));
88
- }
89
- else if (arg === '--fix')
90
- opts.fix = true;
91
- else if (arg === '--full')
92
- opts.full = true;
93
- else if (arg === '--json')
94
- opts.format = 'json';
95
- else if (['--out', '--workspace', '--side'].includes(arg)) {
96
- if (arg === '--side' && (argv[i + 1] === undefined || argv[i + 1].startsWith('--')))
97
- throw new Error('LINT_SIDE_INVALID: --side needs be or fe');
98
- if (argv[i + 1] === undefined)
99
- throw new Error(`${arg} needs a value`);
100
- opts[arg.slice(2)] = argv[++i];
101
- }
102
- else
103
- throw new Error(`unknown argument ${arg}`);
104
- }
105
- if (opts.workspace !== undefined && opts.changed !== null)
106
- throw new Error('--workspace and --changed do not combine: a workspace lint judges the whole workspace');
107
- validateSide(opts);
108
- return opts;
109
- }
110
34
  /** The typed ESLint run reads nothing above the app root: its TypeScript `sys` is bound by this preload (3.3, bound-sys.cjs). */
111
35
  const BOUND_ENV = 'STARCI_LINT_BOUND';
112
36
  const linterBoundArgs = () => ['--require', fileURLToPath(new URL('./bound-sys.cjs', import.meta.url))];
@@ -119,8 +43,25 @@ export function eslintHeapArgs(env = process.env) {
119
43
  const mb = Number(env[ESLINT_HEAP_ENV]);
120
44
  return Number.isInteger(mb) && mb > 0 ? [`--max-old-space-size=${mb}`] : [];
121
45
  }
46
+ /** The ESLint list runner (eslint-list.cts, built beside this file). */
47
+ const LIST_RUNNER = fileURLToPath(new URL('./eslint-list.cjs', import.meta.url));
48
+ /** The snapshots an ESLint child wrote, by canon name; undefined when it wrote none or a file that is not a snapshot list (the cache then builds the graph itself). */
49
+ function readGraphSnapshots(file) {
50
+ try {
51
+ const written = JSON.parse(fs.readFileSync(file, 'utf8'));
52
+ const snapshots = written?.snapshots;
53
+ if (snapshots === null || typeof snapshots !== 'object' || Array.isArray(snapshots))
54
+ return undefined;
55
+ const sound = Object.values(snapshots).every((snapshot) => snapshot !== null && typeof snapshot === 'object' && snapshot.byFile !== null && typeof snapshot.byFile === 'object' && !Array.isArray(snapshot.byFile)
56
+ && Object.values(snapshot.byFile).every((findings) => Array.isArray(findings)));
57
+ return sound ? snapshots : undefined;
58
+ }
59
+ catch {
60
+ return undefined;
61
+ }
62
+ }
122
63
  /** A linter's json results through the app's own install, run from `cwd` (a side folder): `{ results }` or `{ error }`. The child runs while the caller goes on. */
123
- export async function runLinter({ cwd, pkg, bin, args, bound = null }, execute) {
64
+ export async function runLinter({ cwd, pkg, bin, args, bound = null, list, files, graph }, execute) {
124
65
  let entry;
125
66
  try {
126
67
  const manifest = createRequire(path.join(cwd, 'package.json')).resolve(`${pkg}/package.json`);
@@ -131,7 +72,27 @@ export async function runLinter({ cwd, pkg, bin, args, bound = null }, execute)
131
72
  return { error: `${pkg} is not installed for ${cwd}` };
132
73
  }
133
74
  const heap = pkg === 'eslint' ? eslintHeapArgs() : [];
134
- const run = completedNode(await runHeavyNode(execute, { kind: 'eslint', label: `app lint ${pkg} ${path.basename(cwd)}`, args: [...heap, ...(bound ? linterBoundArgs() : []), entry, ...args], cwd, maxBuffer: 64 * 1024 * 1024, ...(bound ? { env: { ...process.env, [BOUND_ENV]: bound } } : {}) }), pkg);
75
+ // A named list, or a run that hands its project graph out, goes through the list runner: the job is a file it reads (ESLint lints all of it in one
76
+ // process, no command line holds a path), the whole side is the list `.`, and the graph snapshots come back in a file beside the job.
77
+ const listed = pkg === 'eslint' && (list !== undefined || graph !== undefined) ? makeTempDir('starci-eslint-') : null;
78
+ let childArgs = [entry, ...args];
79
+ const snapshotFile = listed === null || graph === undefined ? null : path.join(listed, 'graph.json');
80
+ if (listed !== null) {
81
+ const job = path.join(listed, 'job.json');
82
+ fs.writeFileSync(job, JSON.stringify({ files: list ?? ['.'], fix: args.includes('--fix'), ...(snapshotFile === null || graph === undefined ? {} : { graph: { out: snapshotFile, canons: graph } }) }));
83
+ childArgs = [LIST_RUNNER, job];
84
+ }
85
+ const count = list?.length ?? files;
86
+ let run;
87
+ let handed;
88
+ try {
89
+ run = completedNode(await runHeavyNode(execute, { kind: 'eslint', label: `app check ${pkg} ${path.basename(cwd)}`, args: [...heap, ...(bound ? linterBoundArgs() : []), ...childArgs], cwd, maxBuffer: 64 * 1024 * 1024, ...(count === undefined || pkg !== 'eslint' ? {} : { files: count }), ...(bound ? { env: { ...process.env, [BOUND_ENV]: bound } } : {}) }), pkg);
90
+ handed = snapshotFile === null ? undefined : readGraphSnapshots(snapshotFile);
91
+ }
92
+ finally {
93
+ if (listed !== null)
94
+ fs.rmSync(listed, { recursive: true, force: true });
95
+ }
135
96
  // ESLint prints its report on stdout; stylelint uses stderr. JSON does not prove a completed child: only exits 0/1
136
97
  // without a spawn error or signal are measurable, and exit 1 must carry findings.
137
98
  let results;
@@ -145,17 +106,12 @@ export async function runLinter({ cwd, pkg, bin, args, bound = null }, execute)
145
106
  return { error: `${pkg} produced a report that is not a result list` };
146
107
  const completed = !run.error && !run.signal && [0, 1].includes(run.status)
147
108
  && (run.status === 0 || results.some((result) => result?.messages?.length || result?.warnings?.length));
148
- return { results, ...(completed ? {} : { error: `${pkg} could not complete in ${path.basename(cwd)}/ (exit ${run.status}, signal ${run.signal ?? 'none'}): ${String(run.error?.message || run.stderr || run.stdout || '').trim().split('\n')[0]}` }) };
109
+ return { results, ...(handed === undefined || !completed ? {} : { graph: handed }), ...(completed ? {} : { error: `${pkg} could not complete in ${path.basename(cwd)}/ (exit ${run.status}, signal ${run.signal ?? 'none'}): ${String(run.error?.message || run.stderr || run.stdout || '').trim().split('\n')[0]}` }) };
149
110
  }
150
111
  const eslintFindings = (results, appRoot) => results.flatMap((result) => (result.messages ?? []).map((message) => ({
151
112
  engine: 'eslint', rule: message.ruleId ?? 'eslint-error', code: CODE_PREFIX.exec(message.message ?? '')?.[1] ?? null, severity: message.severity === 1 ? 'warning' : 'error',
152
113
  path: posix(path.relative(appRoot, result.filePath)), line: message.line ?? null, column: message.column ?? null, message: message.message,
153
114
  })));
154
- const stylelintFindings = (results, appRoot) => results.flatMap((result) => (result.warnings ?? []).map((warning) => ({
155
- engine: 'stylelint', rule: warning.rule || 'stylelint-error', code: null, severity: 'error',
156
- path: posix(path.relative(appRoot, result.source)), line: warning.line ?? null, column: warning.column ?? null, message: warning.text,
157
- })));
158
- const byLocation = (a, b) => `${a.path ?? ''}:${String(a.line ?? 0).padStart(7, '0')}:${a.rule}`.localeCompare(`${b.path ?? ''}:${String(b.line ?? 0).padStart(7, '0')}:${b.rule}`);
159
115
  /** The files of `changed` (app-relative) below `side`/, relative to the side folder. */
160
116
  const onSide = (changed, side) => changed.filter((file) => file.startsWith(`${side}/`)).map((file) => file.slice(side.length + 1));
161
117
  const fileKey = (file) => process.platform === 'win32' ? path.resolve(file).toLowerCase() : path.resolve(file);
@@ -276,7 +232,7 @@ export async function installedLintConfig({ repoRoot, cwd, side }) {
276
232
  return [...config, ...familyConfig];
277
233
  }
278
234
  /** Read both installed canons' source selectors, then enumerate that scope; unavailable selectors cannot prove coverage. */
279
- async function sourceFiles({ repoRoot, cwd, side, scope: folderScope }) {
235
+ async function sourceFiles({ repoRoot, cwd, side }) {
280
236
  const config = await installedLintConfig({ repoRoot, cwd, side });
281
237
  const scope = canonScope(config);
282
238
  const files = [];
@@ -294,11 +250,11 @@ async function sourceFiles({ repoRoot, cwd, side, scope: folderScope }) {
294
250
  files.push(file);
295
251
  }
296
252
  };
297
- visit(folderScope ?? '');
253
+ visit('');
298
254
  return files.sort(byCodeUnit);
299
255
  }
300
256
  /** Every requested source needs an explicit, well-formed result; suppressed findings and foreign paths cannot witness a completed lint. */
301
- function checkedEslintResults({ results, expected, cwd, scope, changed }) {
257
+ function checkedEslintResults({ results, expected, cwd, changed }) {
302
258
  const required = new Set(expected.map((file) => fileKey(path.join(cwd, file))));
303
259
  const seen = new Set(), valid = [], errors = [];
304
260
  for (const result of results) {
@@ -309,7 +265,7 @@ function checkedEslintResults({ results, expected, cwd, scope, changed }) {
309
265
  continue;
310
266
  }
311
267
  const key = fileKey(result.filePath);
312
- if (!inside(cwd, result.filePath) || (scope && !inside(path.join(cwd, scope), result.filePath)) || (changed && !required.has(key))) {
268
+ if (!inside(cwd, result.filePath) || (changed && !required.has(key))) {
313
269
  errors.push(`eslint produced a result outside the requested scope: ${result.filePath}`);
314
270
  continue;
315
271
  }
@@ -325,22 +281,26 @@ function checkedEslintResults({ results, expected, cwd, scope, changed }) {
325
281
  errors.push(`eslint produced no result for requested source files: ${missing.join(', ')}`);
326
282
  return { results: valid, errors };
327
283
  }
328
- const ESLINT_ARGV_BUDGET = 24_000;
329
- const ESLINT_CONCURRENCY = 3;
330
284
  /** A launch that threw instead of returning a record is a tool error of the run (exit 2), not a crash of the CLI. */
331
285
  const spawnFailure = (error) => {
332
286
  if (isNodeExecutionRefusal(error))
333
287
  throw error;
334
288
  return { error: `could not start eslint: ${String(error?.message ?? error)}` };
335
289
  };
290
+ /** The installed canon of `side`, which publishes the project-graph rules, as the child imports it; undefined when it cannot be resolved (the cache then builds the graph itself). */
291
+ function graphCanonsOf(repoRoot, cwd, side) {
292
+ const name = `@starci/eslint-canon-${side}`;
293
+ try {
294
+ return [{ name, entry: ownCanonEntry({ repoRoot, cwd }, name) }];
295
+ }
296
+ catch {
297
+ return undefined;
298
+ }
299
+ }
336
300
  /** Starts the ESLint child of every side that has work and returns one record per side: skipped, a scope error, or the running child. */
337
- async function launchEslint({ app, repoRoot, opts, changed, existing, workspace, onFe, run = runLinter }) {
301
+ async function launchEslint({ app, repoRoot, opts, changed, existing, run = runLinter, scopes = {}, handOutGraph = false }) {
338
302
  const launches = [];
339
303
  for (const side of app ? SIDES.filter((candidate) => opts.side === undefined || opts.side === candidate) : []) {
340
- if (workspace !== null && side !== STYLE_SIDE) {
341
- launches.push({ side, skipped: `outside --workspace ${workspace}` });
342
- continue;
343
- }
344
304
  const sources = onSide(existing, side).filter((file) => /\.(?:[cm]?[jt]sx?)$/.test(file));
345
305
  if (changed !== null && !sources.length) {
346
306
  launches.push({ side, skipped: 'no changed source file' });
@@ -348,28 +308,32 @@ async function launchEslint({ app, repoRoot, opts, changed, existing, workspace,
348
308
  }
349
309
  const cwd = path.join(repoRoot, side);
350
310
  let expected;
311
+ // a caller that enumerated the side's scope already (the cache) hands it over: the canon config is loaded and the folders walked once per run
312
+ const known = scopes[side];
351
313
  try {
352
- expected = changed ? sources : await sourceFiles({ repoRoot, cwd, side, scope: onFe });
314
+ expected = changed ? sources : known !== undefined ? [...known] : await sourceFiles({ repoRoot, cwd, side });
353
315
  }
354
316
  catch (error) {
355
317
  launches.push({ side, scopeError: `eslint source scope is unavailable in ${side}/: ${String(error?.message ?? error)}` });
356
318
  continue;
357
319
  }
358
320
  const head = ['--format', 'json', ...(opts.fix ? ['--fix'] : [])];
359
- const launch = (files) => Promise.resolve().then(() => run({ cwd, pkg: 'eslint', bin: 'eslint', args: [...head, ...files], bound: repoRoot }));
360
- // A diff of hundreds of files exceeds the Windows command line: the sources go in chunks and the reports are merged.
361
- const chunks = changed ? chunkByChars(sources, ESLINT_ARGV_BUDGET) : [[onFe ?? '.']];
362
- launches.push({ side, cwd, expected, run: chunks.length === 1 ? launch(chunks[0]).catch(spawnFailure) : mapBounded(chunks, launch, ESLINT_CONCURRENCY).then(mergeRuns) });
321
+ // 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)
322
+ const graph = handOutGraph ? graphCanonsOf(repoRoot, cwd, side) : undefined;
323
+ // 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
324
+ // child would build the whole TypeScript program again); the whole side is a single directory argument.
325
+ 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, '.'], files: expected.length, bound: repoRoot, ...(graph === undefined ? {} : { graph }) }));
326
+ launches.push({ side, cwd, expected, run: launch.catch(spawnFailure) });
363
327
  }
364
328
  return launches;
365
329
  }
366
- /** Execute each side's installed ESLint config once; check and lint share scope, coverage and tool-failure judgment. */
367
- export async function eslintRepository({ repoRoot, app = readRepoDeclaration(loadSlotManifest(), repoRoot), opts = {}, changed = null, existing = [], workspace = null, onFe = null, run }) {
368
- const findings = [], errors = [], raw = [];
330
+ /** Execute each side's installed ESLint config once, whole or over a named list; the cached and the cacheless run share scope, coverage and tool-failure judgment. */
331
+ export async function eslintRepository({ repoRoot, app = readRepoDeclaration(loadSlotManifest(), repoRoot), opts = {}, changed = null, existing = [], run, scopes, handOutGraph }) {
332
+ const findings = [], errors = [], raw = [], graphs = {};
369
333
  const engine = { sides: {} };
370
334
  if (!app.sides)
371
335
  throw new Error('ESLint runs at the app root, the folder of hfs.json');
372
- const launches = await launchEslint({ app, repoRoot, opts, changed, existing, workspace, onFe, run });
336
+ const launches = await launchEslint({ app, repoRoot, opts, changed, existing, run, ...(scopes === undefined ? {} : { scopes }), ...(handOutGraph === true ? { handOutGraph } : {}) });
373
337
  const settled = await Promise.allSettled(launches.map(launch => launch.run ?? null));
374
338
  const rejected = settled.filter((run) => run.status === 'rejected');
375
339
  const failed = rejected.find(run => isHostHeld(run.reason)) ?? rejected[0];
@@ -391,7 +355,7 @@ export async function eslintRepository({ repoRoot, app = readRepoDeclaration(loa
391
355
  if (linted.error)
392
356
  errors.push(linted.error);
393
357
  if (linted.results) {
394
- const checked = checkedEslintResults({ results: linted.results, expected: launch.expected, cwd: launch.cwd, scope: onFe, changed: changed !== null });
358
+ const checked = checkedEslintResults({ results: linted.results, expected: launch.expected, cwd: launch.cwd, changed: changed !== null });
395
359
  errors.push(...checked.errors);
396
360
  linted.results = checked.results;
397
361
  const appRelative = appRelativeMessages(side, path.join(repoRoot, side));
@@ -402,8 +366,10 @@ export async function eslintRepository({ repoRoot, app = readRepoDeclaration(loa
402
366
  findings.push(...eslintFindings(linted.results, repoRoot));
403
367
  }
404
368
  engine.sides[side] = { files: linted.results?.length ?? 0 };
369
+ if (linted.graph !== undefined)
370
+ graphs[side] = linted.graph;
405
371
  });
406
- return { findings, errors, raw, engine };
372
+ return { findings, errors, raw, engine, graphs };
407
373
  }
408
374
  // ------------------------------------------------------------------------------------------------ the cached lint
409
375
  /** A `crossFileInputs` export that has the shape the cache reads, or null (an installed canon without it, or with a broken one, is linted whole). */
@@ -483,22 +449,18 @@ export function gitBaseToken(repoRoot) {
483
449
  * validation of a full run (eslintRepository), the rest take their records. The answer has the shape of eslintRepository's, plus the stylelint
484
450
  * results, so the lint report is built the same way from either.
485
451
  */
486
- export async function cachedLinters({ repoRoot, app, opts, workspace, onFe, run, cache }) {
452
+ export async function cachedLinters({ repoRoot, app, side: only, run, session }) {
487
453
  const eslint = { findings: [], errors: [], raw: [], engine: { sides: {} } };
488
454
  const style = { results: [], errors: [], ran: false };
489
455
  const sides = [];
490
- const decision = cache.session.decide('lint');
491
- const full = opts.full === true ? '--full' : decision.mode === 'full' ? decision.reason : null;
456
+ const decision = session.decide('lint');
457
+ const full = decision.mode === 'full' ? decision.reason : null;
492
458
  const gitToken = gitBaseToken(repoRoot);
493
- for (const side of SIDES.filter((candidate) => opts.side === undefined || opts.side === candidate)) {
494
- if (workspace !== null && side !== STYLE_SIDE) {
495
- eslint.engine.sides[side] = { files: 0, skipped: `outside --workspace ${workspace}` };
496
- continue;
497
- }
459
+ for (const side of SIDES.filter((candidate) => only === undefined || only === candidate)) {
498
460
  const cwd = path.join(repoRoot, side);
499
461
  let scope, canons;
500
462
  try {
501
- scope = await sourceFiles({ repoRoot, cwd, side, scope: null });
463
+ scope = await sourceFiles({ repoRoot, cwd, side });
502
464
  canons = await canonDeclarations({ repoRoot, cwd, side });
503
465
  }
504
466
  catch (error) {
@@ -508,16 +470,15 @@ export async function cachedLinters({ repoRoot, app, opts, workspace, onFe, run,
508
470
  }
509
471
  const styled = side === STYLE_SIDE;
510
472
  const outcome = await lintSide({
511
- session: cache.session, side, sideRoot: cwd, scope, stylesheets: styled ? stylesheetsOf(cwd) : [],
512
- report: onFe === null ? null : (file) => file.startsWith(`${onFe}/`),
513
- canons, styleCanon: styled ? await styleDeclaration({ repoRoot, cwd }) : null, typescript: () => loadAppTypeScript(cwd), extraKey: '', gitToken, full, write: workspace === null,
473
+ session, side, sideRoot: cwd, scope, stylesheets: styled ? stylesheetsOf(cwd) : [],
474
+ canons, styleCanon: styled ? await styleDeclaration({ repoRoot, cwd }) : null, typescript: () => loadAppTypeScript(cwd), extraKey: '', gitToken, full, write: true,
514
475
  eslint: async (files) => {
515
476
  const listed = files === null ? null : files.map((file) => `${side}/${file}`);
516
- const sub = await eslintRepository({ repoRoot, app, opts: { side }, changed: listed, existing: listed ?? [], workspace: listed === null ? workspace : null, onFe: listed === null ? onFe : null, run });
517
- return { results: sub.raw, errors: sub.errors };
477
+ const sub = await eslintRepository({ repoRoot, app, opts: { side }, changed: listed, existing: listed ?? [], run, scopes: { [side]: scope }, handOutGraph: true });
478
+ return { results: sub.raw, errors: sub.errors, ...(sub.graphs[side] === undefined ? {} : { graph: sub.graphs[side] }) };
518
479
  },
519
480
  stylelint: styled ? async (files) => {
520
- const linted = await (run ?? runLinter)({ cwd, pkg: 'stylelint', bin: 'stylelint', args: [...(files ?? [onFe ? `${onFe}/src/**/*.css` : STYLE_GLOB]), '--formatter', 'json'] });
481
+ const linted = await (run ?? runLinter)({ cwd, pkg: 'stylelint', bin: 'stylelint', args: [...(files ?? [STYLE_GLOB]), '--formatter', 'json'] });
521
482
  const appRelative = appRelativeMessages(STYLE_SIDE, cwd);
522
483
  for (const result of linted.results ?? [])
523
484
  for (const warning of result.warnings ?? [])
@@ -536,164 +497,6 @@ export async function cachedLinters({ repoRoot, app, opts, workspace, onFe, run,
536
497
  style.ran = true;
537
498
  }
538
499
  }
539
- cache.session.report(lintFamilyReport(sides));
500
+ session.report(lintFamilyReport(sides));
540
501
  return { eslint, stylelint: style, sides };
541
502
  }
542
- /** Starts stylelint over the fe stylesheets (the running child), or returns null when no stylesheet is in scope. */
543
- function launchStylelint({ repoRoot, opts, changed, existing, onFe, run }) {
544
- if (opts.side === 'be')
545
- return null;
546
- const styles = onSide(existing, STYLE_SIDE).filter((file) => file.endsWith('.css'));
547
- if (changed !== null && !styles.length)
548
- return null;
549
- return (run ?? runLinter)({ cwd: path.join(repoRoot, STYLE_SIDE), pkg: 'stylelint', bin: 'stylelint', args: [...(changed ? styles : [onFe ? `${onFe}/src/**/*.css` : STYLE_GLOB]), '--formatter', 'json', ...(opts.fix ? ['--fix'] : [])] });
550
- }
551
- /**
552
- * Run the whole lint of the app at `repoRoot`. `hfsCheck(repoRoot)` returns the `starci app check` result (`{ findings, tracked }`); the CLI
553
- * injects it. Returns `{ report, sonar, exit }`; `sonar` is the merged Generic Issue Import document.
554
- */
555
- export async function lintRepository({ repoRoot, opts, hfsCheck, trackedFiles = () => [], runLinter: linter, cache }) {
556
- const changed = opts.changed === null ? null : new Set(opts.changed);
557
- const errors = [];
558
- let workspace = null;
559
- let validSide = true;
560
- try {
561
- validateSide(opts);
562
- }
563
- catch (error) {
564
- errors.push(String(error?.message ?? error));
565
- validSide = false;
566
- }
567
- if (opts.workspace !== undefined) {
568
- try {
569
- workspace = workspaceOf(opts.workspace);
570
- }
571
- catch (error) {
572
- errors.push(String(error?.message ?? error));
573
- }
574
- }
575
- /** Whether an app-relative path lies inside the scoped workspace. */
576
- const inWorkspace = (file) => posix(file).startsWith(`${workspace}/`);
577
- const existing = changed ? [...changed].filter((file) => fs.existsSync(path.join(repoRoot, file))) : [];
578
- const findings = [];
579
- const raw = { eslint: [], stylelint: [] };
580
- const engines = {};
581
- let app = null;
582
- try {
583
- app = readRepoDeclaration(loadSlotManifest(), repoRoot);
584
- }
585
- catch (error) {
586
- errors.push(`starci app lint runs at the app root: ${String(error?.message ?? error)}`);
587
- }
588
- if (app && !app.sides) {
589
- errors.push(`${repoRoot} is the ${app.side} side of an app; starci app lint runs at the app root, the folder of hfs.json`);
590
- app = null;
591
- }
592
- if (!validSide || (opts.workspace !== undefined && workspace === null))
593
- app = null;
594
- if (workspace !== null && !fs.existsSync(path.join(repoRoot, workspace, 'package.json'))) {
595
- errors.push(`--workspace ${workspace} holds no package.json; it is not a workspace of this app`);
596
- app = null;
597
- }
598
- /** The workspace folder relative to the fe side folder (apps/<app> or packages/<pkg>). */
599
- const onFe = workspace === null ? null : workspace.slice(STYLE_SIDE.length + 1);
600
- // The linters and the app check are independent: every child starts first, the app check runs while they work, and the results are read in a fixed order.
601
- // The cache serves a whole-side lint only: --changed judges named files and --fix rewrites them, so neither reads nor writes a record.
602
- const cached = cache !== undefined && changed === null && !opts.fix ? cache : null;
603
- const cachedRun = app && cached ? cachedLinters({ app, repoRoot, opts, workspace, onFe, run: linter, cache: cached }) : null;
604
- const eslintRun = app ? (cachedRun ? cachedRun.then((answer) => answer.eslint) : eslintRepository({ app, repoRoot, opts, changed: changed ? [...changed] : null, existing, workspace, onFe, run: linter })) : null;
605
- const styleLaunch = app && !cachedRun ? launchStylelint({ repoRoot, opts, changed, existing, onFe, run: linter }) : null;
606
- const checkRun = app ? Promise.resolve().then(() => hfsCheck(repoRoot)).then((value) => ({ value, error: undefined }), (error) => ({ value: undefined, error })) : null;
607
- // A transport refusal does not abandon a sibling child or let its rejection escape unobserved.
608
- const completed = await Promise.allSettled([eslintRun, styleLaunch, checkRun]);
609
- const refused = completed.filter((run) => run.status === 'rejected');
610
- const failure = refused.find(run => isHostHeld(run.reason)) ?? refused[0];
611
- if (failure !== undefined)
612
- throw failure.reason;
613
- // 1. ESLint, once per side, from the side folder with that side's config.
614
- const eslint = eslintRun ? await eslintRun : { engine: { sides: {} }, errors: [], raw: [], findings: [] };
615
- engines.eslint = eslint.engine;
616
- errors.push(...eslint.errors);
617
- raw.eslint.push(...eslint.raw);
618
- findings.push(...eslint.findings);
619
- // 3. stylelint over the fe side's stylesheets.
620
- if (app) {
621
- if (cachedRun) {
622
- const answer = (await cachedRun).stylelint;
623
- errors.push(...answer.errors);
624
- if (answer.ran)
625
- raw.stylelint = answer.results;
626
- findings.push(...stylelintFindings(answer.results, repoRoot));
627
- }
628
- else if (styleLaunch) {
629
- const linted = await styleLaunch;
630
- if (linted.error)
631
- errors.push(linted.error);
632
- if (linted.results) {
633
- const appRelative = appRelativeMessages(STYLE_SIDE, path.join(repoRoot, STYLE_SIDE));
634
- for (const result of linted.results)
635
- for (const warning of result.warnings ?? [])
636
- warning.text = appRelative(warning.text);
637
- raw.stylelint = linted.results;
638
- findings.push(...stylelintFindings(linted.results, repoRoot));
639
- }
640
- }
641
- engines.stylelint = { side: STYLE_SIDE, files: raw.stylelint.length };
642
- }
643
- // 2. The app check: root and sides.
644
- let checked = { findings: [] };
645
- if (checkRun) {
646
- const settled = await checkRun;
647
- if (isNodeExecutionRefusal(settled.error))
648
- throw settled.error;
649
- if (settled.error)
650
- errors.push(`starci app check could not run: ${String(settled.error?.message ?? settled.error)}`);
651
- else
652
- checked = settled.value;
653
- }
654
- const repoErrors = checked.findings.filter((finding) => finding.level === 'error' && findingOnSide(finding, opts.side));
655
- // A workspace lint keeps only the app findings inside the workspace; the rest belong to the root lint of the whole app.
656
- const kept = workspace !== null
657
- ? repoErrors.filter((finding) => finding.path && inWorkspace(finding.path))
658
- : changed === null ? repoErrors : repoErrors.filter((finding) => !finding.path || changed.has(posix(finding.path)));
659
- for (const finding of kept) {
660
- findings.push({ engine: 'hfs', rule: finding.code, code: finding.code, severity: 'error', path: finding.path ? posix(finding.path) : null, line: finding.line ?? null, column: finding.column ?? null, message: finding.message, titleVi: finding.titleVi, nextStepVi: finding.nextStepVi });
661
- }
662
- engines.hfs = { findings: kept.length };
663
- findings.sort(byLocation);
664
- let properties = '';
665
- try {
666
- properties = fs.readFileSync(path.join(repoRoot, 'sonar-project.properties'), 'utf8');
667
- }
668
- catch { /* no properties: every finding keeps its own path */ }
669
- const sourceRoots = sourceRootsOf(properties).filter((root) => findingOnSide({ path: root }, opts.side));
670
- const tracked = sourceRoots.length ? trackedFiles(repoRoot) : [];
671
- const sonar = mergeReports([
672
- sonarReport(kept, { sourceRoots, tracked }),
673
- linterReport('eslint', raw.eslint, { root: repoRoot, sourceRoots, tracked }),
674
- linterReport('stylelint', raw.stylelint, { root: repoRoot, sourceRoots, tracked }),
675
- ]);
676
- // Green under the threshold (gate-threshold.mjs): every finding is still reported, only the verdict changes. A warning is a
677
- // rule of the canon's counting cohort (eslint-canon-fe COUNTING): reported and counted per rule, never part of the verdict.
678
- const failing = findings.filter((finding) => finding.severity !== 'warning');
679
- const counting = {};
680
- for (const finding of findings)
681
- if (finding.severity === 'warning')
682
- counting[finding.rule] = (counting[finding.rule] ?? 0) + 1;
683
- const gate = gateVerdict(failing);
684
- const report = { schema: LINT_SCHEMA, ok: gate.pass && errors.length === 0, repoRoot, changed: changed ? [...changed].sort(byCodeUnit) : null, workspace, ...(opts.side === undefined ? {} : { side: opts.side }), counts: { error: failing.length, warning: findings.length - failing.length }, counting, engines, errors, findings, gate };
685
- return { report, sonar, exit: errors.length ? 2 : gate.pass ? 0 : 1 };
686
- }
687
- export function printLintText(report, out) {
688
- for (const f of report.findings)
689
- out(`${f.path ?? '-'}${f.line ? `:${f.line}${f.column ? `:${f.column}` : ''}` : ''} ${f.severity === 'warning' ? 'warn ' : ''}${f.engine}/${f.rule} ${f.message}\n`);
690
- for (const e of report.errors)
691
- out(`ERROR ${e}\n`);
692
- const per = Object.keys(report.engines).map((engine) => `${engine} ${report.findings.filter((f) => f.engine === engine && f.severity !== 'warning').length}`).join(', ');
693
- out(`\nstarci app lint: ${report.counts.error} finding${report.counts.error === 1 ? '' : 's'} (${per})${report.errors.length ? `, ${report.errors.length} tool error${report.errors.length === 1 ? '' : 's'}` : ''}\n`);
694
- const counting = Object.entries((report.counting ?? {})).sort(([a], [b]) => a.localeCompare(b));
695
- if (counting.length)
696
- out(`counting (warn, not in the verdict): ${counting.map(([rule, count]) => `${rule} ${count}`).join(', ')}\n`);
697
- if (report.gate?.line)
698
- out(`${report.gate.line}\n`);
699
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/hfs",
3
- "version": "5.0.7",
3
+ "version": "5.1.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",
@@ -1,6 +1,6 @@
1
1
  import type { VerbContext } from './index.mjs';
2
2
  /**
3
- * Runs `starci app lint --side <side> --out <file>` for every side the app has, then writes reports/lint.sonar.json.
3
+ * Runs `starci app check --gate lint --side <side> --out <file>` for every side the app has, then writes reports/lint.sonar.json.
4
4
  * `launch` turns an argv into [command, args] for the platform. Returns { pass, text }: the honest log of every lint run and the verdict.
5
5
  * A lint that exits non-zero (findings or error) or writes no report fails the row; nothing is skipped.
6
6
  */
@@ -1,5 +1,5 @@
1
1
  // lint-report.mjs - the external issues report the release gate's sonar row produces before it runs the scanner.
2
- // The app declares `sonar.externalIssuesReportPaths=reports/lint.sonar.json`; the managed CI makes it with `lint --side <side> --out reports/lint.<side>.sonar.json`
2
+ // The app declares `sonar.externalIssuesReportPaths=reports/lint.sonar.json`; the managed CI makes it with `app check --gate lint --side <side> --out reports/lint.<side>.sonar.json`
3
3
  // per lint matrix side and a "merge Sonar lint reports" step. This module does the same locally, through the same process seam as every gate
4
4
  // (an argument array through ctx.spawn, never a shell). The merge below is the one in packages/hfs/sync/ci-commands.mjs (sonarMergeSteps): keep them identical.
5
5
  import fs from 'node:fs';
@@ -17,7 +17,7 @@ function mergeSonarReports(reports) {
17
17
  /** The sides the app has: a be/ or fe/ folder beside the app root. */
18
18
  const sidesOf = (root) => LINT_SIDES.filter((side) => fs.existsSync(path.join(root, side)));
19
19
  /**
20
- * Runs `starci app lint --side <side> --out <file>` for every side the app has, then writes reports/lint.sonar.json.
20
+ * Runs `starci app check --gate lint --side <side> --out <file>` for every side the app has, then writes reports/lint.sonar.json.
21
21
  * `launch` turns an argv into [command, args] for the platform. Returns { pass, text }: the honest log of every lint run and the verdict.
22
22
  * A lint that exits non-zero (findings or error) or writes no report fails the row; nothing is skipped.
23
23
  */
@@ -30,7 +30,7 @@ export function produceLintReports(ctx, { launch, platform, env }) {
30
30
  for (const side of sides) {
31
31
  const file = path.join(ctx.cwd, sideReport(side));
32
32
  fs.rmSync(file, { force: true });
33
- const argv = ['npx', 'starci', 'app', 'lint', '--side', side, '--out', sideReport(side)];
33
+ const argv = ['npx', 'starci', 'app', 'check', '--gate', 'lint', '--side', side, '--out', sideReport(side)];
34
34
  const [command, args] = launch(argv, platform);
35
35
  out.push(`$ ${argv.join(' ')}\n`);
36
36
  const run = tool(ctx, command, args, { cwd: ctx.cwd, env });
@@ -48,7 +48,7 @@ interface CheckFinding {
48
48
  line?: number;
49
49
  }
50
50
  type LinterResult = any;
51
- /** One document of several (the starci-hfs, eslint and stylelint documents of one app lint): rules by id, issues in the one sort order. */
51
+ /** One document of several (the starci-hfs, eslint and stylelint documents of one app check): rules by id, issues in the one sort order. */
52
52
  export declare function mergeReports(reports: readonly SonarDocument[]): SonarDocument;
53
53
  /**
54
54
  * The Generic Issue Import document of a check's findings (the `findings` of checkRepository, each with the catalog's
package/report/sonar.mjs CHANGED
@@ -1,10 +1,10 @@
1
- // Sonar documents of the ONE lint entry `starci app lint`: the building blocks that turn the
1
+ // Sonar documents of the lint gate of `starci app check` (`--out`): the building blocks that turn the
2
2
  // findings of the StarCi canon into a Sonar Generic Issue Import document (SonarQube 10.3+ format: `{ rules, issues }`).
3
3
  // sonarReport(findings) the repository findings of `starci app check`, engineId `starci-hfs`, rule id = the finding code
4
4
  // linterReport(kind, results) a linter's own json output (`eslint -f json`, `stylelint --formatter json`), engineId `eslint` / `stylelint`,
5
5
  // rule id = the linter's rule. Sonar's own ESLint import (sonar.eslint.reportPaths) is not used: it drops an
6
6
  // issue on a file outside sonar.sources, and a stylelint result has no native import at all.
7
- // mergeReports(reports) the documents of one `starci app lint` run as ONE file
7
+ // mergeReports(reports) the documents of one `starci app check` run as ONE file
8
8
  // One placement rule for every engine: Sonar imports an issue only on a file it indexes (a tracked source or stylesheet under
9
9
  // sonar.sources). A finding on any other path (hfs.json, a workflow, a package under an unindexed root, e2e/, a directory) is filed
10
10
  // on the first source file of sonar.sources and its message names the real path, so no finding is dropped.
@@ -33,7 +33,7 @@ function document(rules, issues) {
33
33
  const used = new Set(sorted.map((issue) => issue.ruleId));
34
34
  return { rules: [...rules.values()].filter((rule) => used.has(rule.id)).sort((a, b) => cmp(a.id, b.id)), issues: sorted };
35
35
  }
36
- /** One document of several (the starci-hfs, eslint and stylelint documents of one app lint): rules by id, issues in the one sort order. */
36
+ /** One document of several (the starci-hfs, eslint and stylelint documents of one app check): rules by id, issues in the one sort order. */
37
37
  export function mergeReports(reports) {
38
38
  const rules = new Map(reports.flatMap((report) => report.rules).map((rule) => [rule.id, rule]));
39
39
  return document(rules, reports.flatMap((report) => report.issues));