@starci/hfs 5.0.7 → 5.0.8

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 (62) hide show
  1. package/CHANGELOG.md +11 -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 +143 -11
  7. package/cache/lint-family.d.mts +0 -2
  8. package/cache/lint-family.mjs +54 -33
  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/small-renames.mjs +22 -2
  20. package/lint/eslint-list.cjs +26 -0
  21. package/lint/run.d.mts +18 -58
  22. package/lint/run.mjs +54 -284
  23. package/package.json +1 -1
  24. package/release/lint-report.d.mts +1 -1
  25. package/release/lint-report.mjs +3 -3
  26. package/report/sonar.d.mts +1 -1
  27. package/report/sonar.mjs +3 -3
  28. package/report/stylelint-findings.d.mts +23 -0
  29. package/report/stylelint-findings.mjs +9 -0
  30. package/runtime/knowledge/hfs/canon-pins.yaml +7 -7
  31. package/runtime/knowledge/hfs/ci-standard.yaml +2 -2
  32. package/runtime/knowledge/hfs/rules.yaml +4 -4
  33. package/runtime/knowledge/hfs/slots.yaml +1 -1
  34. package/runtime/knowledge/sonar-gate.yaml +2 -2
  35. package/runtime/modules/kernel/failure-codes.yaml +4 -4
  36. package/runtime/scripts/api/node/run-node.d.mts +5 -0
  37. package/runtime/scripts/hfs/check.d.mts +16 -1
  38. package/runtime/scripts/hfs/check.mjs +18 -7
  39. package/runtime/scripts/hfs/gate-threshold.mjs +1 -1
  40. package/runtime/scripts/hfs/rule-memo.d.mts +30 -0
  41. package/runtime/scripts/hfs/rule-memo.mjs +24 -0
  42. package/runtime/scripts/hfs/rules/a15-findings.d.mts +6 -0
  43. package/runtime/scripts/hfs/rules/a15-findings.mjs +11 -2
  44. package/runtime/scripts/hfs/rules/ci-standard.mjs +2 -2
  45. package/runtime/scripts/hfs/rules/monorepo.d.mts +1 -3
  46. package/runtime/scripts/hfs/rules/monorepo.mjs +9 -11
  47. package/runtime/scripts/hfs/rules/pipeline.mjs +13 -9
  48. package/runtime/scripts/hfs/rules/read.d.mts +5 -0
  49. package/runtime/scripts/hfs/rules/read.mjs +13 -0
  50. package/runtime/scripts/hfs/rules/secrets.d.mts +2 -0
  51. package/runtime/scripts/hfs/rules/secrets.mjs +4 -1
  52. package/runtime/scripts/hfs/rules/stacks-release.mjs +35 -21
  53. package/runtime/scripts/lib/worktree-exclude.mjs +1 -1
  54. package/scaffold/app.mjs +3 -3
  55. package/src/check-cache-structure-support.d.mts +52 -0
  56. package/src/main.d.mts +3 -2
  57. package/src/main.mjs +123 -109
  58. package/sync/ci-commands.d.mts +2 -1
  59. package/sync/ci-commands.mjs +19 -20
  60. package/sync/index.mjs +2 -2
  61. package/templates/app/package-scripts/core.json +2 -2
  62. package/templates/app/task-graph/turbo.json +0 -3
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.0.8
4
+
5
+ - Removed: the `starci app lint` verb. `starci app check` is the one verb for app quality and carries what the lint verb carried: `--side be|fe` limits the lint gate to one side (reading and writing that side's cache records), `--out <file>` writes the merged Sonar Generic Issue Import document of the run (the findings of the selected gates under `starci-hfs`, then ESLint and stylelint), and `--fix` runs ESLint and stylelint with `--fix` before the check (with `--gate lint`). There is no `--changed` and no `--workspace`: the cache makes a whole-app check of an unchanged tree take seconds. The rendered `lint` and `lint:fix` scripts, the managed CI lint step and the release gate's Sonar row call `app check --gate lint`, and rule R13 requires that form.
6
+ - Changed: usage is printed only for a usage error (`UsageError`), not for a failed check.
7
+ - Fixed: the `small-renames` codemod keeps `size="control"` on a bar control instead of rewriting it to `lg`.
8
+ - Changed: the structure pass keeps its work in the committed cache. A side key carries the hash it took of each input, so a stable untracked input (generated code) no longer discards the record of a scoped step, and `CheckSession.unchanged(paths, captured?)` compares an input with that hash. The per-file content rules (plaintext secrets, bypass markers, soft file size) are kept by the file's hash, the cross-file finders by the read set they recorded, and `--gate secrets` reuses the same per-file records, so a warm run judges nothing and a run after an edit judges that file. The whole-program records of a side with untracked program inputs are kept.
9
+ - Changed: The app-check ESLint cache dirties a file only when the type surface it reads can have changed. Each file records the digest of its isolated declaration emit and whether that surface is open (inferred from other files); a body-only edit lints that file alone, a changed signature its direct importers, an inferred type one more level at a time. A file that only names the types of others (a re-export, an imported type in a signature) and a component that returns only JSX carry a change of those types, never an edit of their bodies. Records without a surface are read as changed.
10
+ - Fixed: A file edited while the ESLint of a side ran loses only its own cache records instead of the records of the whole side.
11
+ - Changed: The files of a diff lint go to ONE ESLint child through a job file read by a small runner (`lint/eslint-list.cjs`) on the ESLint API of the side's install, instead of several chunked command lines that each built the TypeScript program again. The heavy-node request carries the number of files it judges (`files`).
12
+ - Changed: The import graph of the app-check lint cache remembers the files and folders it probes while resolving imports, and the launcher takes the side's scope from the cache instead of enumerating it again; the graph of a large side builds in a sixth of the time.
13
+
3
14
  ## 5.0.7
4
15
 
5
16
  - Changed: Refresh aligned canonical package pins.
package/README.md CHANGED
@@ -16,9 +16,8 @@ starci app add overlay <name> --kind modal|drawer [--form] [--app <fe app>] [--c
16
16
  starci app add service <dir> <name> [--inject <binding>...] [--cwd <dir>]
17
17
  starci app add spec <file>.service.ts [--cwd <dir>]
18
18
  starci app add image [--cwd <dir>]
19
- starci app lint [--cwd <dir>] [--changed <file>...] [--workspace <dir>] [--fix] [--json] [--out <sonar file>]
20
19
  starci app sync [--execute] [--cwd <dir>]
21
- starci app check [--cwd <dir>] [--gate <hfs|format|db|secrets|wiring|types|lint|test|sonar|codecov> ...] [--no-cache] [--json] [--coverage-from <lcov>] [--quiet]
20
+ starci app check [--cwd <dir>] [--gate <hfs|format|db|secrets|wiring|types|lint|test|sonar|codecov> ...] [--side be|fe] [--fix] [--out <sonar file>] [--no-cache] [--json] [--coverage-from <lcov>] [--quiet]
22
21
  starci app check --staged [--cwd <dir>]
23
22
  starci app upgrade [--execute]
24
23
  starci app world <up|down|status> [--stack <dir>] [--services <list>] [--k3d] [--force] [--json] [--cwd <dir>]
@@ -166,7 +165,7 @@ Its own checks (`scripts/hfs/check.mjs`, `scripts/hfs/rules/`; the rendered-file
166
165
  | `HFS_UNTRACKED_ROOT_ENTRY` | error | an entry git neither tracks nor ignores (`git ls-files -o --exclude-standard`), outside an `ignored` slot |
167
166
  | `HFS_PLAINTEXT_SECRET` | error | a tracked plaintext secret: an env, key or credentials file, a value the push scan refuses (never printed), or an `.enc` that is no sops envelope (R06) |
168
167
  | `HFS_STACKS_SHAPE` | error | a `.starcistacks` path outside the standard shape (a sealed file outside `<env>/secrets/`, `runtime/files/`, root `DESIGN.md` or `k8s/`), a local Sonar not owned by the host, a service still rooted at `.stacks` (R10) |
169
- | `HFS_CI_MISSING_CANON` | error | `ci.yml` without a `run:` step of `starci app lint` (`npm run lint`, or `npx --no-install starci app lint`, the app's pinned copy) (R13) |
168
+ | `HFS_CI_MISSING_CANON` | error | `ci.yml` without a `run:` step of `starci app check --gate lint` (`npm run lint`, or `npx --no-install starci app check --gate lint`, the app's pinned copy) (R13) |
170
169
  | `HFS_DEP_VERSION_SKEW` | error | a dependency at two specs across the root and workspace `package.json` files, a dependency declared at another version than the root `overrides` pin, or a nested copy of a declared dependency in `package-lock.json` (R14) |
171
170
  | `HFS_CONTRACT_SNAPSHOT_DRIFT` | error | a back-end api app serving GraphQL without `be/contracts/<app>/schema.graphql` (R23); the front end reads that snapshot in place, so there is no copy to drift |
172
171
  | `BE_TEST_TOPOLOGY` | error | a `*.test.*` file, a `testing/` folder, a second jest configuration or a `jest` key in `package.json` (R47; `int-spec`, `harness-spec`, the retired test folders and a per-lane test config are the machine's, under the same code) |
@@ -182,7 +181,7 @@ Its own checks (`scripts/hfs/check.mjs`, `scripts/hfs/rules/`; the rendered-file
182
181
  | `BE_ASYNC_SPEC_MISSING` | error | a consumed event no e2e spec through `useTestWorld` names, or a saga step whose spec does not also name the compensated event (R167) |
183
182
  | `BE_INTEGRATION_SPEC_MISSING` | error | an integration (`src/modules/integrations/<provider>/` with `<provider>.config.ts`) without a `src/tests/integration/<provider>/*.integration-spec.ts` that registers its module through `useTestWorld({ modules })`, references its ErrorCode enum and drives an outage through the world (R112) |
184
183
  | `HFS_MONO_WORKSPACES` | error | the app root `package.json` without `workspaces` exactly `["fe/apps/*", "fe/packages/*"]`, `packageManager` `npm@<version>` or a `turbo` devDependency (R143) |
185
- | `HFS_MONO_FE_WORKSPACE` | error | an fe app `package.json` not named `@<project>/<app>`, not private, or without exactly the workspace scripts; an fe package without `build`, `typecheck` and the workspace `lint` (R144) |
184
+ | `HFS_MONO_FE_WORKSPACE` | error | an fe app `package.json` not named `@<project>/<app>`, not private, or without exactly the workspace scripts; an fe package without `build` and `typecheck`; a workspace with a `lint` script (R144) |
186
185
  | `HFS_MONO_NEST_PROJECTS` | error | `be/nest-cli.json` not `monorepo: true`, its default project not an api app, or its `projects` not exactly the declared be apps (R145) |
187
186
  | `HFS_MONO_WORKSPACE_DEP` | error | an fe workspace imports a package its own `package.json` does not declare, or the root `package.json` declares a workspace package (R146) |
188
187
  | `BE_CLI_REQUIRED` | error | a back end with a connection or a command under `src/features/cli/` and no cli app `be/apps/cli`, a cli app of another name, or no `be/apps/cli/Dockerfile` (R147) |
@@ -200,7 +199,7 @@ Its own checks (`scripts/hfs/check.mjs`, `scripts/hfs/rules/`; the rendered-file
200
199
  The managed files are judged by `sync/managed.mjs` and `sync/ts-strict.mjs` (the package renders the templates; the runtime copy does not). The list of managed
201
200
  files is the `managedBy` slots of `slots.yaml`; `starci app sync --execute` renders them and `starci app check` compares them, so a hand edit and a forgotten `sync` are the same finding.
202
201
  Each finding is reported once: the managed ESLint and stylelint factory configs under R17, `tsconfig.json` under R22 when it names a flag, a workflow or hook that lost a canon step under R13 (or R19 for the format step), everything else under R05.
203
- The root and both sides are rendered by the one mechanism. The root: the `scripts` block of `app.package-manifest` (`dev:be`, `dev:fe`, `build:be`, `build:fe`, `start:<app>`, `lint`, `lint:fix`, `test`, `test:integration`, `test:e2e`, `test:contract`, `test:world`, `codegen`, `contract:emit`, `migrate`, `typecheck`, ...; a be script runs from `be/`, where its tsconfig and jest configuration are), `app.format-config`, `app.hooks`, the CI slots (`app.ci`, `app.ci-e2e`, `app.ci-images`), `app.quality-config` and the managed block of `app.git-meta`. A side: its `be.tool-config` or `fe.tool-config` slot — the side's tsconfig (resolving `@starci/tsconfig` from the root `node_modules`) and its managed ESLint factory config, the back end's build tsconfig, tests tsconfig and jest configuration, the front end's stylelint configuration. `lint` is the one lint gate, `starci app lint`: ESLint over each side with its canon, the app check and stylelint over fe; `lint:fix` is the same with `--fix`. The front end has no test script, no test configuration and no e2e or coverage file: it has no tests, and `FE_NO_TESTS` (R97) refuses any spec, e2e file, test tool or test script under `fe/`. `turbo.json` stays the app's own
202
+ The root and both sides are rendered by the one mechanism. The root: the `scripts` block of `app.package-manifest` (`dev:be`, `dev:fe`, `build:be`, `build:fe`, `start:<app>`, `lint`, `lint:fix`, `test`, `test:integration`, `test:e2e`, `test:contract`, `test:world`, `codegen`, `contract:emit`, `migrate`, `typecheck`, ...; a be script runs from `be/`, where its tsconfig and jest configuration are), `app.format-config`, `app.hooks`, the CI slots (`app.ci`, `app.ci-e2e`, `app.ci-images`), `app.quality-config` and the managed block of `app.git-meta`. A side: its `be.tool-config` or `fe.tool-config` slot — the side's tsconfig (resolving `@starci/tsconfig` from the root `node_modules`) and its managed ESLint factory config, the back end's build tsconfig, tests tsconfig and jest configuration, the front end's stylelint configuration. `lint` is `starci app check --gate lint`: ESLint over each side with its canon and stylelint over fe; `lint:fix` is the same with `--fix`. The front end has no test script, no test configuration and no e2e or coverage file: it has no tests, and `FE_NO_TESTS` (R97) refuses any spec, e2e file, test tool or test script under `fe/`. `turbo.json` stays the app's own
204
203
  (slot `app.task-graph`): it carries the task graph, which no preset can render.
205
204
 
206
205
  The architecture machine (`scripts/hfs/architecture.mjs` of the runtime, the same code bundled here): tiers and import
@@ -218,7 +217,7 @@ Predictions remain diagnostics; actual Sonar CI/release checks and Codecov CI up
218
217
  findings n new, n known, n fixed`. Progress goes to stderr (`--quiet` silences it). `STARCI_APP_CHECK_CACHE=verify` runs diff then full and exits 2 on any difference;
219
218
  `STARCI_APP_CHECK_CACHE=full` never reads the cache (the release gate).
220
219
 
221
- Exit codes: 0 clean, 1 an error finding, 2 a tool could not run or a refusal (not a Git work tree, bad flag). `starci app check` and `starci app lint` write only `.starcistacks/cache/` (and `starci app lint --fix` lets ESLint and stylelint fix in place).
220
+ Exit codes: 0 clean, 1 an error finding, 2 a tool could not run or a refusal (not a Git work tree, bad flag). `starci app check` writes only `.starcistacks/cache/` (and `--out`'s file; `--fix` lets ESLint and stylelint fix in place).
222
221
 
223
222
  ## Secrets
224
223
 
@@ -267,7 +266,7 @@ Analysis never creates a project, replaces its history or rotates a token. OIDC
267
266
  One mechanism for both sides: every finding of the canon is imported into Sonar, and the quality gate
268
267
  fails while any is open. Nothing is configured per app; the pieces are managed files (`starci app sync`) and this package.
269
268
 
270
- One entry, one report, one file: `npm run lint` is `starci app lint`; `npm run lint -- --out reports/lint.sonar.json` writes the ONE Sonar file, read through `sonar.externalIssuesReportPaths`. It carries three engines: `starci-hfs` (the repository findings, rule id = the finding code), `eslint` (the BE canon, universal FE canon and selected Grammar-family plugin, rule id = the ESLint rule) and `stylelint` over fe (rule id = the stylelint rule). App lint shares app check's ESLint runner and calls its HFS checks in process, so each side's ESLint config runs once. `starci app lint --format json` prints the same findings as the one report `starci/lint@1` (`{ schema, ok, changed, counts.error, engines, errors[], findings[{ engine, rule, code, severity, path, line, column, message }] }`); `--changed <files...>` restricts ESLint and stylelint to those files and keeps of the repository findings the ones on a listed file or on no file. The exit code is 0 clean, 1 findings, 2 a tool could not run (a missing ESLint install is never a pass).
269
+ One entry, one report, one file: `npm run lint` is `starci app check --gate lint`; `starci app check --gate lint --out reports/lint.sonar.json` writes the ONE Sonar file, read through `sonar.externalIssuesReportPaths`. It carries three engines: `starci-hfs` (the repository findings, rule id = the finding code), `eslint` (the BE canon, universal FE canon and selected Grammar-family plugin, rule id = the ESLint rule) and `stylelint` over fe (rule id = the stylelint rule). The lint gate runs each side's ESLint config once, through the cache; `--side be|fe` limits it to one side, `--fix` runs ESLint and stylelint with `--fix` first (refused without `--gate lint`), and the findings the other gates selected join the same document. The exit code is 0 clean, 1 findings, 2 a tool could not run (a missing ESLint install is never a pass).
271
270
 
272
271
  `--out` writes the findings as a Generic Issue Import document (SonarQube 10.3+ format: `{ rules, issues }`) before the verdict, so a
273
272
  failing lint still leaves its report. A rule's name and description are the catalog's English title and Vietnamese title, meaning and next step;
@@ -278,7 +277,7 @@ Sonar's own ESLint import (`sonar.eslint.reportPaths`) is not used: it drops tho
278
277
 
279
278
  The managed `sonar-project.properties` carries `sonar.externalIssuesReportPaths` (`reports/lint.sonar.json` only) and no `sonar.host.url` (the host is
280
279
  `SONAR_HOST_URL`); the managed CI workflow produces the report and runs the scan and the gate action with `!cancelled()`, so a failed lint step still
281
- reaches Sonar while the job stays failed. There is no `continue-on-error`. CI runs `npm run lint -- --out reports/lint.sonar.json` once at the app root, before the scan. The duplicate-block threshold (`ruleParams.<side>.duplicateBlock`) has no Sonar property for TypeScript (SonarJS detects
280
+ reaches Sonar while the job stays failed. There is no `continue-on-error`. CI runs `starci app check --gate lint --side <side> --out reports/lint.<side>.sonar.json` per side and merges the reports into `reports/lint.sonar.json`, before the scan. The duplicate-block threshold (`ruleParams.<side>.duplicateBlock`) has no Sonar property for TypeScript (SonarJS detects
282
281
  clones with its own token rule), so the machine enforces it (R21) and its findings are imported like every other.
283
282
 
284
283
  The gate is `knowledge/sonar-gate.yaml`, the one declaration: the new-code conditions (coverage 100, duplication, issues of every severity, hotspots) and an `overall` part (coverage 100, 0 open issues on the whole code,
@@ -44,7 +44,7 @@ export function createFakeSession(options = {}) {
44
44
  decide: () => decision,
45
45
  records: (folder) => replaced.get(folder) ?? stored.get(folder) ?? [],
46
46
  replace: (folder, records) => void replaced.set(folder, records),
47
- unchanged: (paths) => paths.every((file) => index.hashOf(file) === initial.get(file)),
47
+ unchanged: (paths, captured) => paths.every((file) => (captured?.[file] === undefined ? index.hashOf(file) === initial.get(file) : (index.hashOf(file) ?? 'absent') === captured[file])),
48
48
  report: (report) => void reports.push(report),
49
49
  progress,
50
50
  setFile: (path, text) => index.set(path, text),
@@ -12,7 +12,9 @@ import { loadSlotManifest, readRepoDeclaration } from '../runtime/scripts/hfs/sl
12
12
  import { emitContractsAsync } from '../emit/contracts.mjs';
13
13
  import { summarize } from '../runtime/scripts/hfs/check-results.mjs';
14
14
  import { managedFindings } from '../sync/managed.mjs';
15
+ import { withRuleMemo } from '../runtime/scripts/hfs/rule-memo.mjs';
15
16
  import { RecordSet, scopedStep } from './records.mjs';
17
+ import { FILE_RULES_STEP, RULE_STEP_PREFIX, UNIT_STEP_PREFIX, createStructureMemo } from './structure-memo.mjs';
16
18
  import { makeReport } from './report.mjs';
17
19
  import { sideKey } from './side-key.mjs';
18
20
  export const HFS_FOLDER = 'check/hfs';
@@ -45,10 +47,12 @@ const beDeclaration = (repoRoot) => {
45
47
  export async function runStructurePass(session, input) {
46
48
  const { repoRoot, harness, presets } = input;
47
49
  const tracked = [...(input.tracked ?? trackedFiles(repoRoot))];
48
- const decision = session.decide('hfs');
50
+ const decision = session.decide(input.hfs ? 'hfs' : 'secrets');
49
51
  const records = new RecordSet(session.records(HFS_FOLDER));
50
52
  const previous = records.toArray();
51
53
  const reuse = decision.mode === 'diff';
54
+ // The per-file and cross-file content rules are shared by both families: a record is read only when every selected family runs diff.
55
+ const structure = createStructureMemo({ session, records, reuse: reuse && (!input.hfs || !input.secrets || session.decide('secrets').mode === 'diff'), root: repoRoot, tracked });
52
56
  const sideOf = (side) => sideKey(repoRoot, session.index, side);
53
57
  const keys = new Map(['be', 'fe'].map((side) => [side, sideOf(side)]));
54
58
  const extra = [...await managedFindings({ repoRoot, tracked, presets })];
@@ -60,7 +64,7 @@ export async function runStructurePass(session, input) {
60
64
  const bePrefix = 'be/';
61
65
  const key = keys.get('be');
62
66
  const emit = await scopedStep(session, records, {
63
- path: 'be', step: 'contract-emit', key: key?.key ?? 'none', reuse, inputs: key?.inputs ?? [],
67
+ path: 'be', step: 'contract-emit', key: key?.key ?? 'none', reuse, inputs: key?.inputs ?? [], captured: key?.hashes ?? {},
64
68
  compute: async () => {
65
69
  const files = tracked.filter((file) => file.startsWith(bePrefix)).map((file) => file.slice(bePrefix.length));
66
70
  const emitted = await contractEmitFindingsAsync({ repoRoot: path.join(repoRoot, 'be'), files, repo: be, emit: (request) => emitContractsAsync({ ...request, execute: input.execute }) });
@@ -91,7 +95,7 @@ export async function runStructurePass(session, input) {
91
95
  }
92
96
  const report = baseMachine(call);
93
97
  checked += 1;
94
- if (session.unchanged(key.inputs)) {
98
+ if (session.unchanged(key.inputs, key.hashes)) {
95
99
  records.put({ path: side, step: 'machine', hash: key.key, findings: [...report.violations, ...report.errors].map((item) => ({ path: item.path ?? side, line: item.line ?? 0, column: item.column ?? 0, rule: item.ruleId, message: item.message })), data: JSON.parse(JSON.stringify(report)) });
96
100
  }
97
101
  return report;
@@ -110,7 +114,7 @@ export async function runStructurePass(session, input) {
110
114
  }
111
115
  const found = compute();
112
116
  checked += 1;
113
- if (session.unchanged(key.inputs)) {
117
+ if (session.unchanged(key.inputs, key.hashes)) {
114
118
  const list = found;
115
119
  records.put({ path: side, step: 'unused-symbol', hash: key.key, findings: list.map(compact), data: JSON.parse(JSON.stringify(found)) });
116
120
  }
@@ -118,13 +122,15 @@ export async function runStructurePass(session, input) {
118
122
  },
119
123
  };
120
124
  const step = session.progress.step('hfs', 'structure', tracked.length);
121
- const result = input.hfs
125
+ const result = withRuleMemo(structure.memo, () => (input.hfs
122
126
  ? withUnusedSymbolMemo(unusedMemo, () => withoutBootProof(() => checkRepository({ repoRoot, harness, extraFindings: extra, machine: cachedMachine })))
123
127
  : (() => {
124
128
  const slot = withUnusedSymbolMemo(unusedMemo, () => withoutBootProof(() => checkRepo({ repoRoot, harness, extraFindings: [], surface: 'check' })));
125
129
  return { ...slot, machine: { status: 'skipped', reason: '--secrets alone runs the rules, not the machine' } };
126
- })();
127
- step.tick(tracked.length, reused, checked);
130
+ })()));
131
+ const filesChecked = structure.counts.filesChecked, filesReused = structure.counts.filesReused;
132
+ const keyedChecked = structure.counts.keyedChecked + structure.counts.unitsChecked, keyedReused = structure.counts.keyedReused + structure.counts.unitsReused;
133
+ step.tick(tracked.length, reused + filesReused + keyedReused, checked + filesChecked + keyedChecked);
128
134
  step.end();
129
135
  if (contracts)
130
136
  result.contracts = contracts;
@@ -133,6 +139,13 @@ export async function runStructurePass(session, input) {
133
139
  result.counts = summarize(result.findings);
134
140
  result.ok = result.counts.error === 0;
135
141
  }
142
+ records.retain(FILE_RULES_STEP, structure.files);
143
+ records.retainPrefix(RULE_STEP_PREFIX, new Set(['.', 'be', 'fe']));
144
+ for (const [step, paths] of structure.units)
145
+ records.retain(step, paths);
146
+ records.dropPrefixExcept(UNIT_STEP_PREFIX, new Set(structure.units.keys()));
147
+ if (!input.hfs)
148
+ session.replace(HFS_FOLDER, records.toArray());
136
149
  if (input.hfs) {
137
150
  const live = new Set(['be', 'fe']);
138
151
  records.retain('machine', live);
@@ -147,7 +160,7 @@ export async function runStructurePass(session, input) {
147
160
  }
148
161
  const hfsFindings = result.findings.filter((finding) => !SECRET_CODES.has(finding.code));
149
162
  const report = input.hfs
150
- ? makeReport({ family: 'hfs', mode: decision.mode, reason: decision.reason, checked, reused, previous: previous.flatMap((record) => record.step === 'hfs' ? record.findings : []), current: hfsFindings.map(compact) })
163
+ ? makeReport({ family: 'hfs', mode: decision.mode, reason: decision.reason, checked: checked + filesChecked + keyedChecked, reused: reused + filesReused + keyedReused, previous: previous.flatMap((record) => record.step === 'hfs' ? record.findings : []), current: hfsFindings.map(compact) })
151
164
  : null;
152
165
  if (report)
153
166
  session.report(report);
@@ -157,7 +170,7 @@ export async function runStructurePass(session, input) {
157
170
  const prior = session.records(SECRETS_FOLDER);
158
171
  const secretFindings = result.findings.filter((finding) => SECRET_CODES.has(finding.code));
159
172
  session.replace(SECRETS_FOLDER, findingRecords(secretFindings, session.index.hashOf.bind(session.index), 'secrets'));
160
- secretsReport = makeReport({ family: 'secrets', mode: secretDecision.mode, reason: secretDecision.reason, checked: tracked.length, reused: 0, previous: prior.flatMap((record) => record.findings), current: secretFindings.map(compact) });
173
+ secretsReport = makeReport({ family: 'secrets', mode: secretDecision.mode, reason: secretDecision.reason, checked: filesChecked, reused: filesReused, previous: prior.flatMap((record) => record.findings), current: secretFindings.map(compact) });
161
174
  session.report(secretsReport);
162
175
  }
163
176
  return { result, contracts, machineReused: reasons, report, secretsReport };
@@ -1,10 +1,21 @@
1
1
  import type TypeScript from 'typescript';
2
2
  type Ts = typeof TypeScript;
3
- /** One file of the graph: its content hash, the side-relative posix paths of the local files it imports, and whether it declares globals. */
3
+ /**
4
+ * One file of the graph: its content hash, the side-relative posix paths of the local files it imports, and whether it declares globals.
5
+ *
6
+ * `api` is the digest of the file's type surface, the text of TypeScript's isolated declaration emit (a source that is not TypeScript, or a
7
+ * declaration file, stands for itself: its content hash). `open` says the surface is inferred: the emit reported a diagnostic (a type the compiler
8
+ * has to infer from the bodies and the files they use), so it can change while the emit text does not. `refs` is the local files the emit names (a
9
+ * type imported from them, a re-export): an explicit surface is its emit text plus the surfaces of those files, nothing else. An absent `api`,
10
+ * `open` or `refs` (a record from before they existed) is read as "changed", "inferred" and "unknown".
11
+ */
4
12
  export interface ImportNode {
5
13
  hash: string;
6
14
  imports: string[];
7
15
  declaresGlobals: boolean;
16
+ api?: string | undefined;
17
+ open?: boolean | undefined;
18
+ refs?: string[] | undefined;
8
19
  }
9
20
  /** The graph of a side: side-relative posix path to node. Leaves (a JSON file, a declaration file out of the lint scope) are nodes with no imports. */
10
21
  export type ImportGraph = ReadonlyMap<string, ImportNode>;
@@ -39,6 +50,17 @@ export declare function referencesOf(ts: Ts, text: string): {
39
50
  };
40
51
  /** The local files `file` (side-relative) imports, resolved with the side's compiler options. Unresolved and installed-package imports are not edges. */
41
52
  export declare function resolveImports(host: GraphHost, options: TypeScript.CompilerOptions, file: string, text: string): string[];
53
+ /**
54
+ * The type surface of one source file: the digest of its isolated declaration emit (`ts.transpileDeclaration`, TypeScript 5.5 and later, which reads the
55
+ * file alone) and whether it is open. A body-only edit leaves the emit, and so the digest, as it was; a changed signature, export list or doc comment
56
+ * (`@deprecated` is read by lint rules) changes it. A compiler without `transpileDeclaration` gives no digest, which every reader takes as changed.
57
+ * `open` is true when the emit reported a diagnostic (the compiler must infer a type); `refs` is the local files the emit names.
58
+ */
59
+ export declare function surfaceOf(host: GraphHost, options: TypeScript.CompilerOptions, file: string, text: string): {
60
+ api: string | undefined;
61
+ open: boolean;
62
+ refs: string[] | undefined;
63
+ };
42
64
  /** What a build reports of its work. */
43
65
  export interface GraphBuild {
44
66
  graph: Map<string, ImportNode>;
@@ -67,9 +89,15 @@ export interface DirtySet {
67
89
  changed: Set<string>;
68
90
  }
69
91
  /**
70
- * The files to lint again after a change from `previous` (the recorded graph, null when none) to `next`: every changed path, and every file that
71
- * reaches one through imports in either graph (a file that stopped importing a changed file still holds a result from the old types), and every
72
- * file whose imports resolve to other files than recorded. A changed file that declares globals, now or before, dirties the whole side.
92
+ * The files to lint again after a change from `previous` (the recorded graph, null when none) to `next`.
93
+ *
94
+ * A file is dirty when it changed, was added or removed, resolves its imports to other files than recorded, or imports a file whose type surface
95
+ * changed, in the new graph or the recorded one (a file that stopped importing such a file still holds a result from the old types). A dirty file's
96
+ * surface changed when it was added or removed, gained an import, its surface digest differs from the record (or either is unknown), its surface
97
+ * is inferred (`open`: whatever dirtied it can have changed the inferred type), or its explicit surface names a file (`refs`) whose surface changed.
98
+ * An explicit surface is its emit text and the surfaces of the files it names, so a body-only edit of such a file dirties that file alone, a
99
+ * changed signature its direct importers, and a type it re-exports or names carries on through the files that name it. A changed file that
100
+ * declares globals, now or before, dirties the whole side.
73
101
  */
74
102
  export declare function dirtySetOf(previous: ImportGraph | null, next: ImportGraph): DirtySet;
75
103
  export {};
@@ -6,6 +6,7 @@
6
6
  // added or removed file and every file that reaches one through imports, in the new graph and in the recorded one. Everything here is pure over
7
7
  // its inputs (the compiler, a file reader, the hashes), so a spec drives it with fixtures and the cache itself stays in lint-family.mts.
8
8
  import path from 'node:path';
9
+ import { hashText } from './hash.mjs';
9
10
  export const posix = (file) => file.replace(/\\/g, '/').replace(/^\.\//, '');
10
11
  /** The compiler options of the side's own tsconfig (the file the typed ESLint run reads), or the permissive defaults when the side holds none. */
11
12
  export function compilerOptionsOf(ts, sideRoot, readText, fileExists) {
@@ -82,6 +83,16 @@ function localPath(sideRoot, absolute) {
82
83
  return null;
83
84
  return relative.split('/').includes('node_modules') ? null : relative;
84
85
  }
86
+ /** The resolution cache of one compiler-options object: a side's thousands of files share the directory lookups of their (mostly package) imports. */
87
+ const resolutionCaches = new WeakMap();
88
+ const resolutionCacheOf = (ts, sideRoot, options) => {
89
+ let cache = resolutionCaches.get(options);
90
+ if (cache === undefined) {
91
+ cache = ts.createModuleResolutionCache(sideRoot, (name) => name, options);
92
+ resolutionCaches.set(options, cache);
93
+ }
94
+ return cache;
95
+ };
85
96
  /** The local files `file` (side-relative) imports, resolved with the side's compiler options. Unresolved and installed-package imports are not edges. */
86
97
  export function resolveImports(host, options, file, text) {
87
98
  const { ts, sideRoot } = host;
@@ -95,7 +106,7 @@ export function resolveImports(host, options, file, text) {
95
106
  };
96
107
  const found = new Set();
97
108
  for (const specifier of specifiers) {
98
- const resolved = ts.resolveModuleName(specifier, absolute, options, moduleHost).resolvedModule;
109
+ const resolved = ts.resolveModuleName(specifier, absolute, options, moduleHost, resolutionCacheOf(ts, sideRoot, options)).resolvedModule;
99
110
  if (!resolved || resolved.isExternalLibraryImport)
100
111
  continue;
101
112
  const local = localPath(sideRoot, resolved.resolvedFileName);
@@ -109,6 +120,85 @@ export function resolveImports(host, options, file, text) {
109
120
  }
110
121
  return [...found].sort();
111
122
  }
123
+ /** Whether `expression` is a JSX element, fragment, `null` or `undefined`, or a conditional of those: its type does not depend on any other file. */
124
+ function isJsxValue(ts, expression) {
125
+ if (expression === undefined)
126
+ return false;
127
+ let node = expression;
128
+ while (ts.isParenthesizedExpression(node))
129
+ node = node.expression;
130
+ if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node) || node.kind === ts.SyntaxKind.NullKeyword)
131
+ return true;
132
+ if (ts.isIdentifier(node))
133
+ return node.text === 'undefined';
134
+ return ts.isConditionalExpression(node) && isJsxValue(ts, node.whenTrue) && isJsxValue(ts, node.whenFalse);
135
+ }
136
+ /**
137
+ * Whether the function (or method) the declaration diagnostic at `start` points at has the type `JSX.Element | null | undefined` whatever its body
138
+ * reads: not async, not a generator, and every `return` of its own (and its concise body) a JSX value. A React component written without a return
139
+ * type is the common case; its inferred type is one the JSX namespace of the installed packages gives, which a lockfile change dirties as a whole.
140
+ */
141
+ function returnsOnlyJsx(ts, source, start) {
142
+ let found;
143
+ const find = (node) => {
144
+ if (found !== undefined || start < node.getFullStart() || start >= node.end)
145
+ return;
146
+ const named = (ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node)) && node.name !== undefined && node.name.getStart(source) === start;
147
+ if (named || ((ts.isArrowFunction(node) || ts.isFunctionExpression(node)) && node.getStart(source) === start)) {
148
+ found = node;
149
+ return;
150
+ }
151
+ ts.forEachChild(node, find);
152
+ };
153
+ find(source);
154
+ const fn = found;
155
+ if (fn === undefined || fn.type !== undefined || fn.body === undefined)
156
+ return false;
157
+ if (fn.asteriskToken !== undefined || (ts.getCombinedModifierFlags(fn) & ts.ModifierFlags.Async) !== 0)
158
+ return false;
159
+ if (!ts.isBlock(fn.body))
160
+ return isJsxValue(ts, fn.body);
161
+ let only = true;
162
+ const visit = (node) => {
163
+ if (!only || ts.isFunctionLike(node) || ts.isClassLike(node))
164
+ return;
165
+ if (ts.isReturnStatement(node) && node.expression !== undefined && !isJsxValue(ts, node.expression))
166
+ only = false;
167
+ ts.forEachChild(node, visit);
168
+ };
169
+ ts.forEachChild(fn.body, visit);
170
+ return only;
171
+ }
172
+ const SCRIPT = /\.(?:[cm]?jsx?)$/;
173
+ const DECLARATION = /\.d\.[cm]?ts$/;
174
+ const TYPESCRIPT = /\.(?:[cm]?tsx?)$/;
175
+ /**
176
+ * The type surface of one source file: the digest of its isolated declaration emit (`ts.transpileDeclaration`, TypeScript 5.5 and later, which reads the
177
+ * file alone) and whether it is open. A body-only edit leaves the emit, and so the digest, as it was; a changed signature, export list or doc comment
178
+ * (`@deprecated` is read by lint rules) changes it. A compiler without `transpileDeclaration` gives no digest, which every reader takes as changed.
179
+ * `open` is true when the emit reported a diagnostic (the compiler must infer a type); `refs` is the local files the emit names.
180
+ */
181
+ export function surfaceOf(host, options, file, text) {
182
+ const { ts } = host;
183
+ if (DECLARATION.test(file) || SCRIPT.test(file))
184
+ return { api: hashText(text), open: true, refs: undefined };
185
+ if (!TYPESCRIPT.test(file))
186
+ return { api: hashText(text), open: false, refs: [] };
187
+ const emit = ts.transpileDeclaration;
188
+ if (emit === undefined)
189
+ return { api: undefined, open: true, refs: undefined };
190
+ try {
191
+ const declared = emit.call(ts, text, { compilerOptions: { jsx: ts.JsxEmit.Preserve, target: ts.ScriptTarget.Latest, ...(options.module === undefined ? {} : { module: options.module }) }, fileName: file, reportDiagnostics: true });
192
+ const diagnostics = (declared.diagnostics ?? []);
193
+ // a declaration whose only diagnostics are components returning JSX has the surface its emit shows
194
+ const source = diagnostics.length === 0 ? undefined : ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, file.endsWith('x') ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
195
+ const inferred = source !== undefined && !diagnostics.every((diagnostic) => (diagnostic.code === 9007 || diagnostic.code === 9008) && diagnostic.start !== undefined && returnsOnlyJsx(ts, source, diagnostic.start));
196
+ return { api: hashText(declared.outputText), open: inferred, refs: resolveImports(host, options, file, declared.outputText) };
197
+ }
198
+ catch {
199
+ return { api: undefined, open: true, refs: undefined };
200
+ }
201
+ }
112
202
  /**
113
203
  * The import graph of `files` (the side's lint scope, side-relative posix) and the leaves they reach.
114
204
  *
@@ -131,7 +221,8 @@ export function buildImportGraph(host, files, recorded, options = {}) {
131
221
  if (hash === null)
132
222
  return;
133
223
  const before = recorded?.get(file);
134
- if (!pathsChanged && before !== undefined && before.hash === hash) {
224
+ // a record from before the type surface was kept has none: the file is read once more to give it one
225
+ if (!pathsChanged && before !== undefined && before.hash === hash && (before.api !== undefined || !/\.(?:[cm]?[jt]sx?)$/.test(file))) {
135
226
  graph.set(file, before);
136
227
  reused += 1;
137
228
  }
@@ -139,11 +230,11 @@ export function buildImportGraph(host, files, recorded, options = {}) {
139
230
  const text = host.readText(path.join(host.sideRoot, file));
140
231
  scanned += 1;
141
232
  graph.set(file, text === null
142
- ? { hash, imports: [], declaresGlobals: false }
143
- : { hash, imports: resolveImports(host, compiler, file, text), declaresGlobals: declaresGlobals(host.ts, file, text) });
233
+ ? { hash, imports: [], declaresGlobals: false, api: undefined, open: true, refs: undefined }
234
+ : { hash, imports: resolveImports(host, compiler, file, text), declaresGlobals: declaresGlobals(host.ts, file, text), ...surfaceOf(host, compiler, file, text) });
144
235
  }
145
236
  else
146
- graph.set(file, { hash, imports: [], declaresGlobals: false });
237
+ graph.set(file, { hash, imports: [], declaresGlobals: false, api: hash, open: false, refs: [] });
147
238
  for (const next of graph.get(file)?.imports ?? [])
148
239
  visit(next);
149
240
  };
@@ -181,9 +272,15 @@ export function dependentsOf(graph, seeds) {
181
272
  return reached;
182
273
  }
183
274
  /**
184
- * The files to lint again after a change from `previous` (the recorded graph, null when none) to `next`: every changed path, and every file that
185
- * reaches one through imports in either graph (a file that stopped importing a changed file still holds a result from the old types), and every
186
- * file whose imports resolve to other files than recorded. A changed file that declares globals, now or before, dirties the whole side.
275
+ * The files to lint again after a change from `previous` (the recorded graph, null when none) to `next`.
276
+ *
277
+ * A file is dirty when it changed, was added or removed, resolves its imports to other files than recorded, or imports a file whose type surface
278
+ * changed, in the new graph or the recorded one (a file that stopped importing such a file still holds a result from the old types). A dirty file's
279
+ * surface changed when it was added or removed, gained an import, its surface digest differs from the record (or either is unknown), its surface
280
+ * is inferred (`open`: whatever dirtied it can have changed the inferred type), or its explicit surface names a file (`refs`) whose surface changed.
281
+ * An explicit surface is its emit text and the surfaces of the files it names, so a body-only edit of such a file dirties that file alone, a
282
+ * changed signature its direct importers, and a type it re-exports or names carries on through the files that name it. A changed file that
283
+ * declares globals, now or before, dirties the whole side.
187
284
  */
188
285
  export function dirtySetOf(previous, next) {
189
286
  const changed = changedPaths(previous, next);
@@ -195,10 +292,45 @@ export function dirtySetOf(previous, next) {
195
292
  }
196
293
  }
197
294
  // a file whose imports resolve elsewhere now (a new file shadows a module, a missing one appeared) reads other types than before
198
- const rewired = [...next].filter(([file, node]) => {
295
+ const rewired = new Set([...next].filter(([file, node]) => {
199
296
  const before = previous.get(file);
200
297
  return before !== undefined && before.hash === node.hash && JSON.stringify(before.imports) !== JSON.stringify(node.imports);
201
- }).map(([file]) => file);
202
- const dirty = new Set([...changed, ...rewired, ...dependentsOf(next, [...changed, ...rewired]), ...dependentsOf(previous, changed)]);
298
+ }).map(([file]) => file));
299
+ const importers = new Map();
300
+ for (const graph of [next, previous])
301
+ for (const [file, node] of graph)
302
+ for (const target of node.imports) {
303
+ const known = importers.get(target) ?? new Set();
304
+ known.add(file);
305
+ importers.set(target, known);
306
+ }
307
+ const dirty = new Set([...changed, ...rewired]);
308
+ const surface = new Set();
309
+ const surfaceChanged = (file) => {
310
+ const after = next.get(file), before = previous.get(file);
311
+ if (after === undefined || before === undefined)
312
+ return true;
313
+ // a new edge can resolve a name the emit already used to a file that was not there, so what the importers read of this file may differ
314
+ if (rewired.has(file) && after.imports.some((target) => !before.imports.includes(target)))
315
+ return true;
316
+ if (after.hash !== before.hash && (after.api === undefined || before.api === undefined || after.api !== before.api))
317
+ return true;
318
+ if (after.open !== false || before.open !== false || after.refs === undefined || before.refs === undefined)
319
+ return true;
320
+ return [...after.refs, ...before.refs].some((target) => surface.has(target));
321
+ };
322
+ const queue = [...dirty];
323
+ while (queue.length > 0) {
324
+ const file = queue.pop();
325
+ if (surface.has(file) || !surfaceChanged(file))
326
+ continue;
327
+ surface.add(file);
328
+ for (const importer of importers.get(file) ?? []) {
329
+ // a file already judged to keep its surface is looked at again: the file that just changed may be one its surface names
330
+ if (!dirty.has(importer))
331
+ dirty.add(importer);
332
+ queue.push(importer);
333
+ }
334
+ }
203
335
  return { dirty, wholeSide: null, changed };
204
336
  }
@@ -93,8 +93,6 @@ export interface LintSideInput {
93
93
  scope: readonly string[];
94
94
  /** The stylesheets stylelint judges (fe only): side-relative posix; empty otherwise. */
95
95
  stylesheets: readonly string[];
96
- /** The files this run reports when it reports part of a side (a workspace lint); null reports the whole side. A partial run never writes. */
97
- report: ((file: string) => boolean) | null;
98
96
  /** The ESLint canon, its grammar family, in config order. */
99
97
  canons: readonly CanonDeclarations[];
100
98
  /** The stylelint canon; null when the side has no stylesheet judge. */