@starci/hfs 4.7.33 → 4.7.35

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 (48) hide show
  1. package/CHANGELOG.md +8 -1
  2. package/README.md +8 -11
  3. package/lint/run.mjs +70 -29
  4. package/package.json +2 -2
  5. package/runtime/knowledge/grammars/index.yaml +102 -0
  6. package/runtime/knowledge/grammars/offset-pop/playbook.yaml +302 -0
  7. package/runtime/knowledge/grammars/starci/playbook.yaml +1159 -0
  8. package/runtime/knowledge/hfs/canon-pins.yaml +22 -8
  9. package/runtime/knowledge/hfs/fe-stack.yaml +7 -0
  10. package/runtime/knowledge/hfs/rules.yaml +133 -83
  11. package/runtime/knowledge/hfs/slots.yaml +3 -3
  12. package/runtime/knowledge/ui/presentation/font.yaml +101 -0
  13. package/runtime/knowledge/ui/presentation/padding.yaml +216 -0
  14. package/runtime/modules/kernel/failure-codes.yaml +40 -20
  15. package/runtime/modules/kernel/removed-vocabulary.yaml +29 -0
  16. package/runtime/packages/cli/src/catalog.generated.mjs +7 -7
  17. package/runtime/scripts/gates/canon-pins.mjs +103 -0
  18. package/runtime/scripts/gates/gate.mjs +3 -36
  19. package/runtime/scripts/gates/output.mjs +40 -0
  20. package/runtime/scripts/gates/test-world-run.mjs +2 -4
  21. package/runtime/scripts/hfs/architecture/config.mjs +8 -8
  22. package/runtime/scripts/hfs/check.mjs +1 -1
  23. package/runtime/scripts/hfs/db-restamp-verb.mjs +2 -2
  24. package/runtime/scripts/hfs/declaration-shape.mjs +8 -2
  25. package/runtime/scripts/hfs/grammar-presentation.mjs +152 -0
  26. package/runtime/scripts/hfs/rule-catalog.mjs +2 -2
  27. package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -0
  28. package/runtime/scripts/hfs/rules/fe-stack.mjs +44 -1
  29. package/runtime/scripts/hfs/slots.mjs +1 -0
  30. package/runtime/scripts/hfs/view.mjs +1 -0
  31. package/runtime/scripts/lib/canon-binding-text.mjs +129 -0
  32. package/runtime/scripts/lib/example-refs.mjs +1 -1
  33. package/runtime/scripts/lib/grammar-lint.mjs +48 -0
  34. package/runtime/scripts/lib/grammar-vendor-parity.mjs +31 -111
  35. package/runtime/scripts/lib/grammar-vendor-source.mjs +345 -0
  36. package/runtime/scripts/lib/numeric-match.mjs +5 -0
  37. package/runtime/scripts/lib/source-package-imports.mjs +111 -0
  38. package/runtime/scripts/work/draw/draw-layer.mjs +5 -4
  39. package/scaffold/app.mjs +7 -5
  40. package/src/main.mjs +26 -13
  41. package/stack/config.mjs +2 -1
  42. package/stack/render.mjs +24 -3
  43. package/sync/index.mjs +8 -2
  44. package/templates/app/skeleton/.starcistacks/dev/infra/compose/stack.yaml +0 -1
  45. package/templates/app/skeleton/.starcistacks/production/infra/swarm/stack.yaml +0 -1
  46. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +0 -1
  47. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +0 -1
  48. package/templates/fe/tool-config/eslint.config.mjs +4 -1
package/CHANGELOG.md CHANGED
@@ -1,6 +1,13 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 4.7.35 - 2026-10-26
4
+ - Changed: the bundled canon pins select @starci/stylelint-canon 2.0.12, @starci/eslint-canon-be 3.4.44, @starci/eslint-canon-fe 9.0.0, the Grammar lint families 0.12.3 and @starci/grammar 0.12.3.
5
+ - Added: `app lint` composes the selected Grammar family lint with the universal front-end canon and refuses canon-fe 9 without it; the Grammar vendor-parity proof reads the generated HeroUI reference; the UI proof checks component ownership and surface layers.
6
+ - Fixed: managed stack renders match and leave unpinned the compose `version` the scaffold declares; the history-hook spec judges the stdin handshake, not a fixed kill; the restamp inventory is active-only and clearer.
7
+ - Changed: the runtime centralizes its parsing and gate summaries.
8
+
9
+ ## 4.7.34 - 2026-10-26
10
+ - Fixed: `starci app db restamp` considers only active migrations when validating names and choosing unoccupied versions. Legacy archive names no longer block preview or apply, and archived files stay untouched.
4
11
  - Changed: the loosening check reads the tree as it stands and never its history. The bundled `scripts/lib/loosening-state.mjs` (with `js-lex.mjs` and `loosening-config.mjs`) replaces the diff detector; `HFS_BYPASS_MARKER` (R303) reads it: a suppression word opens a comment, a test marker stands in code, a swallowed failure stands in a shell line or in the string a script hands a shell, a package.json is judged by its scripts, and the same words quoted as data are no bypass. It also refuses a coverage target or threshold below the canon, a relaxing switch, a disabled ESLint rule, a tsconfig strictness option turned off and an exception file that `knowledge/hfs/design-truths.yaml` does not list. `--passWithNoTests`, `--coverageThreshold`, `--no-error-on-unmatched-pattern` and a `--max-warnings` budget are bypasses in a script, a hook or a workflow.
5
12
  - Added: `starci app emit` carries class-validator `MaxLength`, `ArrayMaxSize`, `Min` and `Max` of GraphQL input types and REST operation DTOs into the contract (a GraphQL `@inputBounds` directive and an OpenAPI constraint), and the `@starci/hfs/form-bounds` codegen plugin turns them into typed `INPUT_BOUNDS` and `inputBoundsIssues` for the form canon. The app codegen template refuses legacy or unsupported bounds metadata.
6
13
  - Changed: the operation emit refuses a non-strict `z.object` (open on the wire), publishes the real maximum length of fixed-format strings (`z.uuid()`, `z.ipv4()` and kin) and keeps `readOnly` out of request schemas.
package/README.md CHANGED
@@ -84,9 +84,7 @@ Every file of a spec-required role of `ruleParams.be.unitRoles` — a `*.service
84
84
 
85
85
  The spec skeleton is `Test.createTestingModule({ providers: [Service, { provide: TOKEN, useValue: double }, ...] }).compile()` and `moduleRef.get(Service)`, with one provider per constructor dependency and nothing else, every double imported from `@starci/jest-preset` (the root), and one placeholder `it` per public method. The double of a token comes from `ruleParams.be.specDoubles` of the slot manifest, the table the lint law `spec-infra-double-from-kit` holds a spec to (`*_ENTITY_MANAGER` -> `mockEntityManager()`, `CLOCK` -> `new FakeClock(...)`, `EVENT_BUS` -> `recordingEventBus()`, `QUEUE_OUTBOX` -> `recordingQueueOutbox()`, `CACHE` -> `fakeCache(clock)`, lock, lease, fence and hold -> `fakeLock(clock)`, ids -> `fakeIds()`, `*_OPTIONS` -> a literal to fill in, everything else and every class -> `mock<T>()`), so the skeleton satisfies the law by construction: no cast, no `new` of the service, no ambient clock. A back end only (a front end has no services); the files are written for prettier (print width 120).
86
86
 
87
- `starci app check` reads the app's `hfs.json` and the tracked paths (`git ls-files`), checks the work tree (the root rules once, the side rules per side), and then runs the
88
- whole architecture machine over each side folder. Every finding carries a why code and its Vietnamese text
89
- (`modules/kernel/failure-codes.yaml`).
87
+ `starci app check` runs ESLint once per side through the app's installed managed flat config: the backend canon on `be/`, and the universal FE canon plus the explicitly selected Grammar-family package on `fe/`. It also reads the app's `hfs.json` and tracked paths (`git ls-files`), checks the work tree (the root rules once, the side rules per side), and runs the whole architecture machine over each side folder. HFS findings carry their why code and Vietnamese text (`modules/kernel/failure-codes.yaml`); ESLint findings retain their rule id and failure code.
90
88
 
91
89
  Its own checks (`scripts/hfs/check.mjs`, `scripts/hfs/rules/`; the rendered-file checks (`sync/managed.mjs`) and the prettier check (`sync/format.mjs`) live in `sync/` because they read the templates and the repository's own install, and reach `starci app check` as `extraFindings`; the jest / vitest preset the coverage exclusions come from and prettier are read from the repository's `node_modules`, so run `npm ci` first):
92
90
 
@@ -103,7 +101,7 @@ Its own checks (`scripts/hfs/check.mjs`, `scripts/hfs/rules/`; the rendered-file
103
101
  | `HFS_CANON_PIN_DRIFT` | error | a dependency not at the exact version of `knowledge/hfs/canon-pins.yaml` |
104
102
  | `HFS_SIZE_SOFT_BACKLOG` | info | a source file over `ruleParams.fileLines.soft`; report only, never fails |
105
103
  | `HFS_MANAGED_FILE_DRIFT` | error | a managed file that exists but differs from its render (the `managedBy` slots: hooks, workflows, sonar, the back-end `be.tool-config` files, `app.format-config`, the `scripts` block of `app.package-manifest`, compared as parsed JSON) |
106
- | `HFS_RULE_OFF_WITHOUT_REPLACEMENT` | error | `eslint.config.mjs`, or a front end's `stylelint.config.mjs`, differs from its one-line render, so a rule could be off, warned or redefined in it |
104
+ | `HFS_RULE_OFF_WITHOUT_REPLACEMENT` | error | `eslint.config.mjs`, or a front end's `stylelint.config.mjs`, differs from its managed factory render, so a rule could be off, warned or redefined in it |
107
105
  | `HFS_TOOL_CONFIG_LOCAL` | error | a repository holds a tool config outside the managed set (`.eslintrc*`, `.eslintignore`, a second `eslint.config.*` or `stylelint.config.*`, another prettier or lint-staged config, or a jest config in a back end that is not the one root file), a file that defines an ESLint rule or a stylelint plugin, a tool configuration key in a `package.json` (`eslintConfig`, `stylelint`, `prettier`, `lint-staged`, `jest`), or a script that runs eslint, stylelint or prettier with a flag that swaps the configuration |
108
106
  | `HFS_SONAR_CONFIG` | error | `sonar-project.properties` differs from its render (no host URL; the ESLint report and HFS import paths), or the stack declaration names another quality gate than the one of `knowledge/sonar-gate.yaml` |
109
107
  | `HFS_TS_STRICT` | error | the root `tsconfig.json` sets, lowers or adds anything but `extends` the preset (`be.json`, `next.json`), the template's `exclude` and (back end) the three `paths`; the finding names the flag |
@@ -145,8 +143,8 @@ Its own checks (`scripts/hfs/check.mjs`, `scripts/hfs/rules/`; the rendered-file
145
143
 
146
144
  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
147
145
  files is the `managedBy` slots of `slots.yaml`; `starci app sync --write` renders them and `starci app check` compares them, so a hand edit and a forgotten `sync` are the same finding.
148
- Each finding is reported once: the eslint and stylelint one-liners 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.
149
- 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:stack`, `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 eslint one-liner, 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
146
+ 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.
147
+ 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:stack`, `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
150
148
  (slot `app.task-graph`): it carries the task graph, which no preset can render.
151
149
 
152
150
  The architecture machine (`scripts/hfs/architecture.mjs` of the runtime, the same code bundled here): tiers and import
@@ -156,12 +154,12 @@ finding under the machine's own rule id (`BE_TIER_DIRECTION`, `HFS_UNUSED_EXPORT
156
154
  repository without `typescript` installed fails with `ARCH_TYPESCRIPT_MISSING`; the check never passes because it could not run.
157
155
 
158
156
  `--fast` judges only what changed since the merge-base of `HEAD` with
159
- `origin/main` (else `main`; `--base <ref>` names another ref): the slot and pin checks on the changed paths, the machine on the owners
157
+ `origin/main` (else `main`; `--base <ref>` names another ref): ESLint on changed source files, the slot and pin checks on the changed paths, the machine on the owners
160
158
  of the changed source files without clones and dead exports, and no file-system tree checks. The required-file and minimum-instance
161
159
  checks still cover the whole tree. With no merge-base `--fast` is a refusal (exit 2) that names the fix, never a silent full
162
160
  pass.
163
161
 
164
- Exit codes: 0 clean, 1 an error finding, 2 a refusal (not a Git work tree, bad flag, `--fast` with no merge-base). `starci app check` and `starci app lint` never write to the app
162
+ Exit codes: 0 clean, 1 an error finding, 2 a tool could not run or a refusal (not a Git work tree, bad flag, `--fast` with no merge-base). `starci app check` and `starci app lint` never write to the app
165
163
  (`starci app lint --fix` lets ESLint and stylelint fix in place).
166
164
 
167
165
  ## Sonar
@@ -169,7 +167,7 @@ Exit codes: 0 clean, 1 an error finding, 2 a refusal (not a Git work tree, bad f
169
167
  One mechanism for both sides: every finding of the canon is imported into Sonar, and the quality gate
170
168
  fails while any is open. Nothing is configured per app; the pieces are managed files (`starci app sync`) and this package.
171
169
 
172
- One entry, one report, one file: `npm run lint` is `starci app lint`; `npm run lint -- --sonar reports/lint.sonar.json` writes the ONE Sonar file, read through `sonar.externalIssuesReportPaths`. It carries three engines: `starci-hfs` (the repository findings of `starci app check`, rule id = the finding code), `eslint` (the BE and FE canon plugins alike, rule id = the ESLint rule) and `stylelint` over fe (rule id = the stylelint rule). `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).
170
+ One entry, one report, one file: `npm run lint` is `starci app lint`; `npm run lint -- --sonar 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).
173
171
 
174
172
  `--sonar` writes the findings as a Generic Issue Import document (SonarQube 10.3+ format: `{ rules, issues }`) before the verdict, so a
175
173
  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;
@@ -202,8 +200,7 @@ derived from the machine's rule id lists). After changing any of those files, `k
202
200
  `starci release sync-runtime --check` fails on a stale copy. Bump `version` here and in the pin when the behaviour changes.
203
201
 
204
202
  The examples workflow (`.github/workflows/examples.yml`) runs `starci app lint` of this CLI at the root of every `examples/*`
205
- app with an `hfs.json` (each `examples/<app>` root) and fails on any finding or any tool that could not run;
206
- `starci app check` alone would miss the machine's source rules, which the canons judge.
203
+ app with an `hfs.json` (each `examples/<app>` root) and fails on any finding or any tool that could not run. This includes the ESLint canons and repository checks also run by `starci app check`, plus stylelint over the frontend stylesheets.
207
204
 
208
205
  ## Serving knowledge to other packages
209
206
 
package/lint/run.mjs CHANGED
@@ -17,7 +17,7 @@
17
17
  // turbo `lint` task); the root `npm run lint` stays the whole app. There is no second linter: a workspace lint is this lint, scoped.
18
18
  import fs from 'node:fs';
19
19
  import path from 'node:path';
20
- import { createRequire } from 'node:module';
20
+ import { createRequire, registerHooks } from 'node:module';
21
21
  import { spawn } from 'node:child_process';
22
22
  import { fileURLToPath, pathToFileURL } from 'node:url';
23
23
  import { linterReport, mergeReports, sonarReport, sourceRootsOf } from '../report/sonar.mjs';
@@ -25,6 +25,7 @@ import { SIDES, appRelativeMessages, loadSlotManifest, readRepoDeclaration } fro
25
25
  import { STYLE_GLOB } from '../sync/index.mjs';
26
26
  import { byCodeUnit } from '../report/order.mjs';
27
27
  import { braceVariants, globExpression } from '../runtime/scripts/lib/glob.mjs';
28
+ import { grammarLintFor } from '../runtime/scripts/lib/grammar-lint.mjs';
28
29
 
29
30
  // --side judges only that side; root HFS findings belong to be, its Sonar report stays on that side.
30
31
  export const LINT_SCHEMA = 'starci/lint@1';
@@ -164,18 +165,46 @@ export function canonScope(config) {
164
165
  };
165
166
  }
166
167
 
167
- /** Read the installed canon's source selectors, then enumerate that scope; unavailable or unsupported selectors cannot prove coverage. */
168
- async function sourceFiles({ repoRoot, cwd, side, scope: folderScope }) {
169
- const entry = createRequire(path.join(cwd, 'package.json')).resolve(`@starci/eslint-canon-${side}`);
168
+ /** Resolve one canon from this app's install; a package from an enclosing repository cannot prove its lint contract. */
169
+ function ownCanonEntry({ repoRoot, cwd }, packageName) {
170
+ const require = createRequire(path.join(cwd, 'package.json'));
171
+ // The entry is imported below, so resolve with Node's import conditions even when a package publishes no require target.
172
+ // The synchronous hook exists only for this lookup; later package resolution keeps its own conditions.
173
+ const hook = registerHooks({ resolve: (specifier, context, nextResolve) => nextResolve(specifier, specifier === packageName
174
+ ? { ...context, conditions: [...new Set([...context.conditions.filter(condition => condition !== 'require'), 'import'])] }
175
+ : context) });
176
+ let entry;
177
+ try { entry = require.resolve(packageName); }
178
+ finally { hook.deregister(); }
170
179
  // The canon must be the app's own install: resolved inside the app root, or inside the app's node_modules when that folder is a link to the install
171
180
  // (the runtime's examples lint in a sealed copy whose node_modules links the example's own install, scripts/hfs/example-secrets.mjs).
172
181
  const ownInstall = [repoRoot, path.join(repoRoot, 'node_modules')].filter((dir) => fs.existsSync(dir)).map((dir) => fs.realpathSync(dir));
173
182
  if (!ownInstall.some((root) => inside(root, fs.realpathSync(entry)))) throw new Error('the installed canon is outside the app root');
183
+ return entry;
184
+ }
185
+
186
+ /** Both installed FE canons supply the source config; family selection is explicit and an unavailable factory fails closed. */
187
+ export async function installedLintConfig({ repoRoot, cwd, side }) {
188
+ const entry = ownCanonEntry({ repoRoot, cwd }, `@starci/eslint-canon-${side}`);
174
189
  const canon = await import(pathToFileURL(entry).href);
175
190
  const factory = side === 'be' ? canon.starciBeConfig : canon.starciFeConfig;
176
191
  if (typeof factory !== 'function' || typeof canon.loadHfs !== 'function') throw new Error('the installed canon has no supported config factory');
177
- const config = await factory({ hfs: canon.loadHfs(pathToFileURL(path.join(cwd, 'eslint.config.mjs')).href) });
192
+ const hfs = canon.loadHfs(pathToFileURL(path.join(cwd, 'eslint.config.mjs')).href);
193
+ const config = await factory({ hfs });
178
194
  if (!Array.isArray(config)) throw new Error('the installed canon did not return a flat config');
195
+ if (side !== 'fe') return config;
196
+ const family = grammarLintFor(hfs.grammar);
197
+ if (family.package === null) return config;
198
+ const familyCanon = await import(pathToFileURL(ownCanonEntry({ repoRoot, cwd }, family.package)).href);
199
+ if (typeof familyCanon[family.factory] !== 'function') throw new Error(`${family.package} has no supported ${family.factory} factory`);
200
+ const familyConfig = await familyCanon[family.factory]({ hfs });
201
+ if (!Array.isArray(familyConfig)) throw new Error(`${family.package} did not return a flat config`);
202
+ return [...config, ...familyConfig];
203
+ }
204
+
205
+ /** Read both installed canons' source selectors, then enumerate that scope; unavailable selectors cannot prove coverage. */
206
+ async function sourceFiles({ repoRoot, cwd, side, scope: folderScope }) {
207
+ const config = await installedLintConfig({ repoRoot, cwd, side });
179
208
  const scope = canonScope(config);
180
209
  const files = [];
181
210
  const visit = (folder) => {
@@ -217,7 +246,7 @@ function checkedEslintResults({ results, expected, cwd, scope, changed }) {
217
246
  }
218
247
 
219
248
  /** Starts the ESLint child of every side that has work and returns one record per side: skipped, a scope error, or the running child. */
220
- async function launchEslint({ app, repoRoot, opts, changed, existing, workspace, onFe }) {
249
+ async function launchEslint({ app, repoRoot, opts, changed, existing, workspace, onFe, run = runLinter }) {
221
250
  const launches = [];
222
251
  for (const side of app ? SIDES.filter((candidate) => opts.side === undefined || opts.side === candidate) : []) {
223
252
  if (workspace !== null && side !== STYLE_SIDE) { launches.push({ side, skipped: `outside --workspace ${workspace}` }); continue; }
@@ -227,11 +256,38 @@ async function launchEslint({ app, repoRoot, opts, changed, existing, workspace,
227
256
  let expected;
228
257
  try { expected = changed ? sources : await sourceFiles({ repoRoot, cwd, side, scope: onFe }); }
229
258
  catch (error) { launches.push({ side, scopeError: `eslint source scope is unavailable in ${side}/: ${String(error?.message ?? error)}` }); continue; }
230
- launches.push({ side, cwd, expected, run: runLinter({ cwd, pkg: 'eslint', bin: 'eslint', args: ['--format', 'json', ...(opts.fix ? ['--fix'] : []), ...(changed ? sources : [onFe ?? '.'])], bound: repoRoot }) });
259
+ launches.push({ side, cwd, expected, run: run({ cwd, pkg: 'eslint', bin: 'eslint', args: ['--format', 'json', ...(opts.fix ? ['--fix'] : []), ...(changed ? sources : [onFe ?? '.'])], bound: repoRoot }) });
231
260
  }
232
261
  return launches;
233
262
  }
234
263
 
264
+ /** Execute each side's installed ESLint config once; check and lint share scope, coverage and tool-failure judgment. */
265
+ export async function eslintRepository({ repoRoot, app = readRepoDeclaration(loadSlotManifest(), repoRoot), opts = {}, changed = null, existing = [], workspace = null, onFe = null, run }) {
266
+ const findings = [], errors = [], raw = [];
267
+ const engine = { sides: {} };
268
+ if (!app.sides) throw new Error('ESLint runs at the app root, the folder of hfs.json');
269
+ const launches = await launchEslint({ app, repoRoot, opts, changed, existing, workspace, onFe, run });
270
+ const runs = await Promise.all(launches.map(launch => launch.run ?? null));
271
+ launches.forEach((launch, at) => {
272
+ const { side } = launch;
273
+ if (launch.skipped) { engine.sides[side] = { files: 0, skipped: launch.skipped }; return; }
274
+ if (launch.scopeError) { errors.push(launch.scopeError); engine.sides[side] = { files: 0 }; return; }
275
+ const linted = runs[at];
276
+ if (linted.error) errors.push(linted.error);
277
+ if (linted.results) {
278
+ const checked = checkedEslintResults({ results: linted.results, expected: launch.expected, cwd: launch.cwd, scope: onFe, changed: changed !== null });
279
+ errors.push(...checked.errors);
280
+ linted.results = checked.results;
281
+ const appRelative = appRelativeMessages(side, path.join(repoRoot, side));
282
+ for (const result of linted.results) for (const message of result.messages ?? []) message.message = appRelative(message.message);
283
+ raw.push(...linted.results);
284
+ findings.push(...eslintFindings(linted.results, repoRoot));
285
+ }
286
+ engine.sides[side] = { files: linted.results?.length ?? 0 };
287
+ });
288
+ return { findings, errors, raw, engine };
289
+ }
290
+
235
291
  /** Starts stylelint over the fe stylesheets (the running child), or returns null when no stylesheet is in scope. */
236
292
  function launchStylelint({ repoRoot, opts, changed, existing, onFe }) {
237
293
  if (opts.side === 'be') return null;
@@ -244,7 +300,7 @@ function launchStylelint({ repoRoot, opts, changed, existing, onFe }) {
244
300
  * Run the whole lint of the app at `repoRoot`. `hfsCheck(repoRoot)` returns the `starci app check` result (`{ findings, tracked }`); the CLI
245
301
  * injects it. Returns `{ report, sonar, exit }`; `sonar` is the merged Generic Issue Import document.
246
302
  */
247
- export async function lintRepository({ repoRoot, opts, hfsCheck, trackedFiles = () => [] }) {
303
+ export async function lintRepository({ repoRoot, opts, hfsCheck, trackedFiles = () => [], runLinter: linter }) {
248
304
  const changed = opts.changed === null ? null : new Set(opts.changed);
249
305
  const errors = [];
250
306
  let workspace = null;
@@ -269,31 +325,16 @@ export async function lintRepository({ repoRoot, opts, hfsCheck, trackedFiles =
269
325
  const onFe = workspace === null ? null : workspace.slice(STYLE_SIDE.length + 1);
270
326
 
271
327
  // 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.
272
- const eslintLaunches = await launchEslint({ app, repoRoot, opts, changed, existing, workspace, onFe });
328
+ const eslintRun = app ? eslintRepository({ app, repoRoot, opts, changed, existing, workspace, onFe, run: linter }) : null;
273
329
  const styleLaunch = app ? launchStylelint({ repoRoot, opts, changed, existing, onFe }) : null;
274
330
  const checkRun = app ? Promise.resolve().then(() => hfsCheck(repoRoot)).then((value) => ({ value }), (error) => ({ error })) : null;
275
331
 
276
332
  // 1. ESLint, once per side, from the side folder with that side's config.
277
- engines.eslint = { sides: {} };
278
- const eslintRuns = await Promise.all(eslintLaunches.map((launch) => launch.run ?? null));
279
- eslintLaunches.forEach((launch, at) => {
280
- const { side } = launch;
281
- if (launch.skipped) { engines.eslint.sides[side] = { files: 0, skipped: launch.skipped }; return; }
282
- if (launch.scopeError) { errors.push(launch.scopeError); engines.eslint.sides[side] = { files: 0 }; return; }
283
- const linted = eslintRuns[at];
284
- if (linted.error) errors.push(linted.error);
285
- if (linted.results) {
286
- const checkedResults = checkedEslintResults({ results: linted.results, expected: launch.expected, cwd: launch.cwd, scope: onFe, changed: changed !== null });
287
- errors.push(...checkedResults.errors);
288
- linted.results = checkedResults.results;
289
- // The side canon names side-relative paths in its messages; the report names every path from the app root.
290
- const appRelative = appRelativeMessages(side, path.join(repoRoot, side));
291
- for (const result of linted.results) for (const message of result.messages ?? []) message.message = appRelative(message.message);
292
- raw.eslint.push(...linted.results);
293
- findings.push(...eslintFindings(linted.results, repoRoot));
294
- }
295
- engines.eslint.sides[side] = { files: linted.results?.length ?? 0 };
296
- });
333
+ const eslint = eslintRun ? await eslintRun : { engine: { sides: {} }, errors: [], raw: [], findings: [] };
334
+ engines.eslint = eslint.engine;
335
+ errors.push(...eslint.errors);
336
+ raw.eslint.push(...eslint.raw);
337
+ findings.push(...eslint.findings);
297
338
 
298
339
  // 3. stylelint over the fe side's stylesheets.
299
340
  if (app) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/hfs",
3
- "version": "4.7.33",
3
+ "version": "4.7.35",
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",
@@ -50,7 +50,7 @@
50
50
  "CHANGELOG.md"
51
51
  ],
52
52
  "engines": {
53
- "node": ">=22.13.0"
53
+ "node": ">=22.15.0"
54
54
  },
55
55
  "publishConfig": {
56
56
  "access": "public"
@@ -0,0 +1,102 @@
1
+ schema: starci/knowledge-source@1
2
+ id: grammars
3
+ title: Grammar families
4
+ purpose: |
5
+ Catalog of authored UI Grammar families. Inventory only — not a default-family rule.
6
+ The selected design specification names the applicable installed family. The app binds that choice
7
+ explicitly in sides.fe.grammar; the catalog resolves its lint package and component contract.
8
+ A missing or ambiguous family never silently resolves to a family. DNA snapshots describe the
9
+ observed implementation; they do not install packages or prove current rendered behavior.
10
+ appliesTo:
11
+ - grammar
12
+ provenance:
13
+ limitations: |
14
+ Component names, tokens and gap tables describe the reference family at observation time.
15
+ Verify actual package identity/version, exported types, CSS and renderer source in the selected
16
+ repository before using snapshot APIs or gaps. Record current source references in the selected
17
+ .starciwork node; an old source census is not current UAT evidence. The current public consumer
18
+ entry observed for FE work is @starci/grammar/common — confirm installed exports before import.
19
+ The `common` entry is a DNA snapshot of that semantic entry and nothing more: it declares no palette,
20
+ no family boundary and no composition playbook, because the entry itself ships none of them.
21
+ guidance:
22
+ - id: catalog-not-default
23
+ requirement: |
24
+ Do not treat this catalog as selecting a default Grammar family. Family choice belongs to the
25
+ current design specification and its explicit sides.fe.grammar declaration.
26
+ rationale: |
27
+ Silent fallback to StarCi would invent product direction and override an absent/ambiguous design.
28
+ - id: verify-before-use
29
+ requirement: |
30
+ Before applying snapshot APIs or recorded gaps, inspect the selected repository's package
31
+ identity/version, exported types, CSS and renderer source.
32
+ rationale: |
33
+ Snapshots are historical; installed exports may differ. Inventing missing APIs is forbidden.
34
+ - id: ui-owns-universal
35
+ requirement: |
36
+ Common semantics, renderer anatomy, state and accessibility remain owned by knowledge/ui.
37
+ A family owns its scoped visual realization and promoted idioms; the product owns its facts,
38
+ content, routes and approved composition.
39
+ Apply FE-RULE-OWNERSHIP in knowledge/patterns/fe/index.yaml when placing requirements and checks.
40
+ rationale: |
41
+ Keeps universal UI law separate from family-scoped DNA and product composition.
42
+ topics:
43
+ - id: common
44
+ path: common/DNA.yaml
45
+ title: Common Grammar — DNA
46
+ summary: |
47
+ Generated DNA snapshot of the `@starci/grammar/common` public entry: custom properties,
48
+ the 110 renderers of its registry, their classes, stamped rule ids and recorded gaps. The DNA owns the observed package
49
+ version and source inventory. Common supplies anatomy, not colour. The snapshot is drift-checked.
50
+ - id: starci
51
+ path: starci/index.yaml
52
+ title: StarCi Core Grammar
53
+ summary: |
54
+ Historical snapshot of the StarCi Core family — DNA, idioms, playbook (including composing the
55
+ 0.5.0 forms, overlays and navigation chrome) and family boundaries.
56
+ - id: offset-pop
57
+ path: offset-pop/index.yaml
58
+ title: Offset Pop Grammar
59
+ summary: |
60
+ Snapshot of the product-neutral Offset Pop family — cream canvas, ink outline plus hard offset
61
+ shadow, one pink decision accent with a text-safe accent for text — with DNA, idioms, a
62
+ mobile-first playbook for playful learning or consumer apps, and family boundaries including two
63
+ decisions flagged for the family owner.
64
+ families:
65
+ - id: common
66
+ title: common
67
+ componentContract:
68
+ package: '@starci/grammar'
69
+ entry: '@starci/grammar/common'
70
+ styleEntry: '@starci/grammar/common.css'
71
+ peers: [react, '@heroui/react', '@xyflow/react']
72
+ packageSnapshot: common/DNA.yaml
73
+ scope: |
74
+ DNA snapshot only. This catalog carries no Common-side family boundary, idiom set or composition
75
+ playbook: `@starci/grammar/common` is the semantic/props entry every visual family is defined over
76
+ (`defineGrammarFamily`), not a family that ships a palette, so `authority`, `reusableStyle`,
77
+ `productComposition` and `index` have nothing to name here. Universal semantics, renderer anatomy,
78
+ state and accessibility remain owned by knowledge/ui.
79
+ - id: starci
80
+ title: starci
81
+ contractRef: common
82
+ lint:
83
+ package: '@starci/eslint-grammar-starci'
84
+ factory: starciGrammarConfig
85
+ prefix: starci-grammar
86
+ authority: starci/family.yaml
87
+ packageSnapshot: starci/DNA.yaml
88
+ reusableStyle: starci/idioms.yaml
89
+ productComposition: starci/playbook.yaml
90
+ index: starci/index.yaml
91
+ - id: offset-pop
92
+ title: offset-pop
93
+ contractRef: common
94
+ lint:
95
+ package: '@starci/eslint-grammar-offset-pop'
96
+ factory: offsetPopGrammarConfig
97
+ prefix: starci-grammar
98
+ authority: offset-pop/family.yaml
99
+ packageSnapshot: offset-pop/DNA.yaml
100
+ reusableStyle: offset-pop/idioms.yaml
101
+ productComposition: offset-pop/playbook.yaml
102
+ index: offset-pop/index.yaml