@starci/hfs 4.7.34 → 4.7.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +8 -11
- package/codemod/button-intent.mjs +356 -0
- package/lint/run.mjs +70 -29
- package/package.json +3 -2
- package/runtime/knowledge/grammars/index.yaml +111 -0
- package/runtime/knowledge/grammars/offset-pop/playbook.yaml +302 -0
- package/runtime/knowledge/grammars/starci/playbook.yaml +1190 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +22 -8
- package/runtime/knowledge/hfs/fe-stack.yaml +12 -4
- package/runtime/knowledge/hfs/rules.yaml +163 -83
- package/runtime/knowledge/hfs/slots.yaml +3 -3
- package/runtime/knowledge/ui/presentation/font.yaml +101 -0
- package/runtime/knowledge/ui/presentation/padding.yaml +217 -0
- package/runtime/modules/kernel/failure-codes.yaml +102 -20
- package/runtime/modules/kernel/removed-vocabulary.yaml +29 -0
- package/runtime/modules/kernel/roles.yaml +1 -1
- package/runtime/packages/cli/src/catalog.generated.mjs +119 -9
- package/runtime/scripts/gates/canon-pins.mjs +103 -0
- package/runtime/scripts/gates/gate.mjs +3 -36
- package/runtime/scripts/gates/output.mjs +40 -0
- package/runtime/scripts/gates/test-world-run.mjs +2 -4
- package/runtime/scripts/guards/release-definition.mjs +22 -3
- package/runtime/scripts/hfs/architecture/config.mjs +8 -8
- package/runtime/scripts/hfs/check.mjs +1 -1
- package/runtime/scripts/hfs/db-restamp-verb.mjs +2 -2
- package/runtime/scripts/hfs/declaration-shape.mjs +8 -2
- package/runtime/scripts/hfs/grammar-presentation.mjs +152 -0
- package/runtime/scripts/hfs/rule-catalog.mjs +2 -2
- package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -0
- package/runtime/scripts/hfs/rules/fe-stack.mjs +44 -1
- package/runtime/scripts/hfs/slots.mjs +1 -0
- package/runtime/scripts/hfs/view.mjs +1 -0
- package/runtime/scripts/lib/canon-binding-text.mjs +129 -0
- package/runtime/scripts/lib/css-rules.mjs +18 -0
- package/runtime/scripts/lib/example-refs.mjs +1 -1
- package/runtime/scripts/lib/grammar-lint.mjs +48 -0
- package/runtime/scripts/lib/grammar-vendor-parity-jsx.mjs +360 -0
- package/runtime/scripts/lib/grammar-vendor-parity.mjs +110 -157
- package/runtime/scripts/lib/numeric-match.mjs +5 -0
- package/runtime/scripts/lib/release-version.mjs +119 -0
- package/runtime/scripts/lib/runtime-publication-tags.mjs +3 -46
- package/runtime/scripts/lib/source-package-imports.mjs +111 -0
- package/runtime/scripts/work/draw/draw-layer.mjs +5 -4
- package/scaffold/app.mjs +7 -5
- package/src/main.mjs +30 -15
- package/stack/config.mjs +2 -1
- package/stack/render.mjs +24 -3
- package/sync/index.mjs +8 -2
- package/templates/app/skeleton/.starcistacks/dev/infra/compose/stack.yaml +0 -1
- package/templates/app/skeleton/.starcistacks/production/infra/swarm/stack.yaml +0 -1
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +0 -1
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +0 -1
- package/templates/fe/tool-config/eslint.config.mjs +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.7.36 - 2026-10-26
|
|
4
|
+
- Changed: the bundled canon pins select @starci/eslint-canon-be 3.4.45, @starci/eslint-canon-fe 9.1.0, the Grammar lint families 0.12.4 and @starci/grammar 0.12.4.
|
|
5
|
+
- Added: the five R319 button-intent finding codes are catalogued and proven, and the `app codemod button-intent` migration is bundled.
|
|
6
|
+
- Changed: the Grammar vendor-parity proof is strict. It reads the DNA vendor, parity and arrangement declarations, judges undeclared wraps and stale overrides, and exports repaint findings with `starci work export-repaints`; `wrappers.yaml` carries the owner-ruled overrides and one pin.
|
|
7
|
+
- Changed: releases follow the alpha.N.M counter (owner ruling `release-waves`): the next tag is derived from the highest released one.
|
|
8
|
+
|
|
9
|
+
## 4.7.35 - 2026-10-26
|
|
10
|
+
- 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.
|
|
11
|
+
- 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.
|
|
12
|
+
- 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.
|
|
13
|
+
- Changed: the runtime centralizes its parsing and gate summaries.
|
|
14
|
+
|
|
3
15
|
## 4.7.34 - 2026-10-26
|
|
16
|
+
- Added: `starci app codemod button-intent` previews legacy Grammar button migrations, applies safe edits with `--write`, and reports ambiguous consequences, competing commits and repeated item commits in JSON or Markdown.
|
|
4
17
|
- 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.
|
|
5
18
|
- 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.
|
|
6
19
|
- 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.
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { loadTypescript } from '../runtime/scripts/lib/package-at.mjs';
|
|
5
|
+
import { sourceLocation } from '../runtime/scripts/lib/ts-ast.mjs';
|
|
6
|
+
|
|
7
|
+
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
8
|
+
const GRAMMAR = /^@starci\/grammar(?:\/|$)/;
|
|
9
|
+
const VARIANTS = Object.freeze({ primary: 'commit', tertiary: 'dismiss', danger: 'destructive', 'danger-soft': 'destructive', outline: 'neutral', ghost: 'neutral' });
|
|
10
|
+
const WEAK = new Set(['outline', 'ghost', 'secondary']);
|
|
11
|
+
const GENERATED = new Set(['node_modules', '.next', '.turbo', 'dist', 'coverage', '.git']);
|
|
12
|
+
|
|
13
|
+
function attributes(ts, opening) {
|
|
14
|
+
return new Map(opening.attributes.properties.filter(ts.isJsxAttribute).map(prop => [prop.name.getText(), prop]));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function literal(ts, attribute) {
|
|
18
|
+
const value = attribute?.initializer;
|
|
19
|
+
if (value && ts.isStringLiteralLike(value)) return value.text;
|
|
20
|
+
return value && ts.isJsxExpression(value) && value.expression && ts.isStringLiteralLike(value.expression) ? value.expression.text : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function buttonImports(ts, source) {
|
|
24
|
+
const names = new Set();
|
|
25
|
+
const namespaces = new Set();
|
|
26
|
+
for (const node of source.statements) {
|
|
27
|
+
if (!ts.isImportDeclaration(node) || !GRAMMAR.test(node.moduleSpecifier.text)) continue;
|
|
28
|
+
const bindings = node.importClause?.namedBindings;
|
|
29
|
+
if (bindings && ts.isNamespaceImport(bindings)) namespaces.add(bindings.name.text);
|
|
30
|
+
if (bindings && ts.isNamedImports(bindings)) {
|
|
31
|
+
for (const binding of bindings.elements) {
|
|
32
|
+
if (['Button', 'IconButton'].includes(binding.propertyName?.text ?? binding.name.text)) names.add(binding.name.text);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return { names, namespaces };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isButton(ts, opening, imports) {
|
|
40
|
+
const tag = opening.tagName;
|
|
41
|
+
if (ts.isIdentifier(tag)) return imports.names.has(tag.text);
|
|
42
|
+
return ts.isPropertyAccessExpression(tag) && ts.isIdentifier(tag.expression)
|
|
43
|
+
&& imports.namespaces.has(tag.expression.text) && ['Button', 'IconButton'].includes(tag.name.text);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function bindingNames(ts, binding) {
|
|
47
|
+
if (ts.isIdentifier(binding)) return [binding.text];
|
|
48
|
+
if (ts.isObjectBindingPattern(binding) || ts.isArrayBindingPattern(binding)) return binding.elements.flatMap(element => ts.isBindingElement(element) ? bindingNames(ts, element.name) : []);
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function shadowedBinding(ts, opening) {
|
|
53
|
+
const tag = opening.tagName;
|
|
54
|
+
const name = ts.isIdentifier(tag) ? tag.text : tag.expression.text;
|
|
55
|
+
for (let at = opening.parent; at && !ts.isSourceFile(at); at = at.parent) {
|
|
56
|
+
if (ts.isFunctionLike(at) && (at.parameters.some(parameter => bindingNames(ts, parameter.name).includes(name))
|
|
57
|
+
|| (ts.isFunctionExpression(at) && at.name?.text === name))) return true;
|
|
58
|
+
if (ts.isCatchClause(at) && at.variableDeclaration && bindingNames(ts, at.variableDeclaration.name).includes(name)) return true;
|
|
59
|
+
const statements = ts.isBlock(at) ? at.statements : [];
|
|
60
|
+
for (const statement of statements) {
|
|
61
|
+
if ((ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) && statement.name?.text === name) return true;
|
|
62
|
+
if (ts.isVariableStatement(statement) && statement.declarationList.declarations.some(declaration => bindingNames(ts, declaration.name).includes(name))) return true;
|
|
63
|
+
}
|
|
64
|
+
if ((ts.isForStatement(at) || ts.isForOfStatement(at) || ts.isForInStatement(at)) && at.initializer && ts.isVariableDeclarationList(at.initializer)
|
|
65
|
+
&& at.initializer.declarations.some(declaration => bindingNames(ts, declaration.name).includes(name))) return true;
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isMap(ts, node) {
|
|
71
|
+
if (!ts.isCallExpression(node)) return false;
|
|
72
|
+
return ts.isPropertyAccessExpression(node.expression) ? node.expression.name.text === 'map'
|
|
73
|
+
: ts.isElementAccessExpression(node.expression) && ts.isStringLiteralLike(node.expression.argumentExpression) && node.expression.argumentExpression.text === 'map';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function callbackArgument(ts, node) {
|
|
77
|
+
let expression = node;
|
|
78
|
+
while (expression.parent && ts.isParenthesizedExpression(expression.parent)) expression = expression.parent;
|
|
79
|
+
return isMap(ts, expression.parent) && expression.parent.arguments[0] === expression;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function inlineRenderCallback(ts, node) {
|
|
83
|
+
let expression = node;
|
|
84
|
+
while (expression.parent && ts.isParenthesizedExpression(expression.parent)) expression = expression.parent;
|
|
85
|
+
if (!expression.parent || !ts.isJsxExpression(expression.parent)) return false;
|
|
86
|
+
const container = expression.parent.parent;
|
|
87
|
+
if (ts.isJsxElement(container) || ts.isJsxFragment(container)) return true;
|
|
88
|
+
return ts.isJsxAttribute(container) && /^(?:children|render[A-Z].*|.*Renderer)$/.test(container.name.getText());
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Resolve same-file callback aliases before walking buttons: ancestry alone misses rows.map(renderRow). */
|
|
92
|
+
function collectionCallbacks(ts, source) {
|
|
93
|
+
const definitions = new Map();
|
|
94
|
+
const callbacks = [];
|
|
95
|
+
const collect = node => {
|
|
96
|
+
if (isMap(ts, node) && node.arguments[0]) callbacks.push(node.arguments[0]);
|
|
97
|
+
const name = (ts.isVariableDeclaration(node) || ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node)) && node.name;
|
|
98
|
+
if (name && ts.isIdentifier(name)) definitions.set(name.text, [...(definitions.get(name.text) ?? []), ts.isVariableDeclaration(node) ? node.initializer : node]);
|
|
99
|
+
ts.forEachChild(node, collect);
|
|
100
|
+
};
|
|
101
|
+
collect(source);
|
|
102
|
+
const owners = new Set();
|
|
103
|
+
const seen = new Set();
|
|
104
|
+
const resolve = node => {
|
|
105
|
+
if (!node || seen.has(node)) return;
|
|
106
|
+
seen.add(node);
|
|
107
|
+
if (ts.isArrowFunction(node) || ts.isFunctionExpression(node) || ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node)) owners.add(node);
|
|
108
|
+
else if (ts.isIdentifier(node)) for (const definition of definitions.get(node.text) ?? []) resolve(definition);
|
|
109
|
+
else if (ts.isParenthesizedExpression(node)) resolve(node.expression);
|
|
110
|
+
else if (ts.isPropertyAccessExpression(node)) for (const definition of definitions.get(node.name.text) ?? []) resolve(definition);
|
|
111
|
+
else if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'bind') resolve(node.expression.expression);
|
|
112
|
+
};
|
|
113
|
+
for (const callback of callbacks) resolve(callback);
|
|
114
|
+
return owners;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function renderOwner(ts, node, source) {
|
|
118
|
+
for (let at = node.parent; at; at = at.parent) {
|
|
119
|
+
if (ts.isArrowFunction(at) || ts.isFunctionExpression(at) || ts.isFunctionDeclaration(at) || ts.isMethodDeclaration(at)) {
|
|
120
|
+
// Collection and JSX render callbacks belong to their enclosing component's render, including nested SlotView children.
|
|
121
|
+
if (callbackArgument(ts, at) || inlineRenderCallback(ts, at)) continue;
|
|
122
|
+
return at;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return source;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function inMap(ts, node, owner) {
|
|
129
|
+
for (let at = node.parent; at && at !== owner; at = at.parent) {
|
|
130
|
+
if (isMap(ts, at)) return true;
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function siblingGroup(ts, node, owner) {
|
|
136
|
+
for (let at = node.parent; at && at !== owner; at = at.parent) {
|
|
137
|
+
if (ts.isJsxElement(at) || ts.isJsxFragment(at)) return at;
|
|
138
|
+
}
|
|
139
|
+
return owner;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function stableName(ts, node) {
|
|
143
|
+
if (ts.isIdentifier(node)) return node.text;
|
|
144
|
+
if (ts.isPropertyAccessExpression(node) && stableName(ts, node.expression)) return `${stableName(ts, node.expression)}.${node.name.text}`;
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function conditionFact(ts, expression, choice) {
|
|
149
|
+
if (ts.isParenthesizedExpression(expression)) return conditionFact(ts, expression.expression, choice);
|
|
150
|
+
if (ts.isPrefixUnaryExpression(expression) && expression.operator === ts.SyntaxKind.ExclamationToken) return conditionFact(ts, expression.operand, !choice);
|
|
151
|
+
if (ts.isBinaryExpression(expression)) {
|
|
152
|
+
const operator = expression.operatorToken.kind;
|
|
153
|
+
const equality = operator === ts.SyntaxKind.EqualsEqualsEqualsToken;
|
|
154
|
+
const inequality = operator === ts.SyntaxKind.ExclamationEqualsEqualsToken;
|
|
155
|
+
if (!equality && !inequality) return null;
|
|
156
|
+
const name = stableName(ts, expression.left);
|
|
157
|
+
const value = expression.right;
|
|
158
|
+
if (name && (ts.isStringLiteralLike(value) || ts.isNumericLiteral(value) || [ts.SyntaxKind.TrueKeyword, ts.SyntaxKind.FalseKeyword, ts.SyntaxKind.NullKeyword].includes(value.kind))) {
|
|
159
|
+
const canonical = ts.isStringLiteralLike(value) ? `string:${value.text}` : ts.isNumericLiteral(value) ? `number:${Number(value.text)}` : value.getText();
|
|
160
|
+
return { name, value: canonical, equal: equality === choice };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const name = stableName(ts, expression);
|
|
164
|
+
return name ? { name, value: 'truthy', equal: choice } : null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function branchChoices(ts, node, owner) {
|
|
168
|
+
const choices = new Map();
|
|
169
|
+
const facts = [];
|
|
170
|
+
const record = (branch, expression, choice) => {
|
|
171
|
+
choices.set(branch, choice);
|
|
172
|
+
const fact = conditionFact(ts, expression, choice);
|
|
173
|
+
if (fact) facts.push(fact);
|
|
174
|
+
};
|
|
175
|
+
for (let at = node; at.parent && at.parent !== owner; at = at.parent) {
|
|
176
|
+
const parent = at.parent;
|
|
177
|
+
if (ts.isConditionalExpression(parent)) {
|
|
178
|
+
if (at === parent.whenTrue) record(parent, parent.condition, true);
|
|
179
|
+
if (at === parent.whenFalse) record(parent, parent.condition, false);
|
|
180
|
+
}
|
|
181
|
+
if (ts.isIfStatement(parent)) {
|
|
182
|
+
if (at === parent.thenStatement) record(parent, parent.expression, true);
|
|
183
|
+
if (at === parent.elseStatement) record(parent, parent.expression, false);
|
|
184
|
+
}
|
|
185
|
+
if (ts.isBinaryExpression(parent) && at === parent.right) {
|
|
186
|
+
if (parent.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) record(parent, parent.left, true);
|
|
187
|
+
if (parent.operatorToken.kind === ts.SyntaxKind.BarBarToken) record(parent, parent.left, false);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return { choices, facts };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function canCoexist(first, second) {
|
|
194
|
+
for (const [branch, choice] of first.branches) if (second.branches.has(branch) && second.branches.get(branch) !== choice) return false;
|
|
195
|
+
for (const left of first.facts) for (const right of second.facts) {
|
|
196
|
+
if (left.name !== right.name) continue;
|
|
197
|
+
if (left.value === right.value && left.equal !== right.equal) return false;
|
|
198
|
+
if (left.equal && right.equal && left.value !== right.value && left.value !== 'truthy' && right.value !== 'truthy') return false;
|
|
199
|
+
}
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function destructiveSignal(text) {
|
|
204
|
+
const words = text.normalize('NFD').replace(/\p{M}/gu, '').replaceAll('\u0111', 'd').replaceAll('\u0110', 'D')
|
|
205
|
+
.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ').toLowerCase();
|
|
206
|
+
return /\b(delete|remove|revoke|discard|destroy|purge|trash|terminate|clear|erase|reset|reject|decline|xoa)\b|\bthu[\s_-]*hoi\b|\b(?:cancel|huy)[\s_-]+(?:offer|booking|appointment|order|subscription|lich|don|de\s+nghi)\b/.test(words);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function describe(ts, source, opening, imports, callbacks) {
|
|
210
|
+
const props = attributes(ts, opening);
|
|
211
|
+
const element = ts.isJsxOpeningElement(opening) ? opening.parent : opening;
|
|
212
|
+
const owner = renderOwner(ts, element, source);
|
|
213
|
+
const variant = literal(ts, props.get('variant'));
|
|
214
|
+
const intent = literal(ts, props.get('intent'));
|
|
215
|
+
const branch = branchChoices(ts, element, owner);
|
|
216
|
+
return { opening, element, owner, props, variant, intent, branches: branch.choices, facts: branch.facts,
|
|
217
|
+
group: siblingGroup(ts, element, owner), mapped: variant === 'secondary' ? null : VARIANTS[variant],
|
|
218
|
+
repeated: callbacks.has(owner) || inMap(ts, element, owner), shadowed: shadowedBinding(ts, opening), name: opening.tagName.getText(source), imports };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Migrate imported Grammar buttons using source edits; ambiguous buttons retain their original props and gain a report finding. */
|
|
222
|
+
export function transformButtonIntent({ ts, text, file = 'component.tsx' }) {
|
|
223
|
+
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, /\.jsx$/i.test(file) ? ts.ScriptKind.JSX : ts.ScriptKind.TSX);
|
|
224
|
+
const imports = buttonImports(ts, source);
|
|
225
|
+
const callbacks = collectionCallbacks(ts, source);
|
|
226
|
+
const buttons = [];
|
|
227
|
+
const visit = node => {
|
|
228
|
+
if ((ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) && isButton(ts, node, imports)) buttons.push(describe(ts, source, node, imports, callbacks));
|
|
229
|
+
ts.forEachChild(node, visit);
|
|
230
|
+
};
|
|
231
|
+
visit(source);
|
|
232
|
+
const findings = [];
|
|
233
|
+
const changes = [];
|
|
234
|
+
const edits = [];
|
|
235
|
+
const flag = (button, code, reason) => findings.push({ file, ...sourceLocation(source, button.opening), code, reason,
|
|
236
|
+
component: button.name, variant: button.variant, source: button.element.getText(source) });
|
|
237
|
+
if (source.parseDiagnostics.length) {
|
|
238
|
+
return { text, changes, findings: [{ file, line: 1, column: 1, code: 'parse-error', reason: ts.flattenDiagnosticMessageText(source.parseDiagnostics[0].messageText, '\n') }] };
|
|
239
|
+
}
|
|
240
|
+
const commits = buttons.filter(button => button.variant === 'primary' || button.intent === 'commit');
|
|
241
|
+
for (const button of buttons) {
|
|
242
|
+
if (button.shadowed) {
|
|
243
|
+
flag(button, 'shadowed-button-binding', 'A local binding shadows the Grammar import; inspect its actual component before migrating.');
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
const pending = [];
|
|
247
|
+
if (button.intent === 'commit' || button.variant === 'primary') {
|
|
248
|
+
if (button.repeated) pending.push(['commit-in-collection', 'A repeated item needs a human choice of alternative or neutral.']);
|
|
249
|
+
if (commits.some(other => other !== button && other.owner === button.owner && canCoexist(button, other))) pending.push(['multiple-commits', 'More than one commit can render in this component; choose the decision owner and next action.']);
|
|
250
|
+
}
|
|
251
|
+
if (button.props.has('intent') && !button.props.has('variant')) {
|
|
252
|
+
for (const [code, reason] of pending) flag(button, code, reason);
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (button.props.has('intent')) pending.push(['intent-and-variant', 'Existing intent and variant need a human reconciliation.']);
|
|
256
|
+
if (!button.props.has('variant')) pending.push(['missing-variant', 'No old variant declares the command role; choose its consequence and rank.']);
|
|
257
|
+
else if (button.variant === null) pending.push(['dynamic-variant', 'A dynamic variant needs a human intent for every branch.']);
|
|
258
|
+
else if (button.variant !== 'secondary' && !button.mapped) pending.push(['unknown-variant', 'This variant has no published mechanical intent mapping.']);
|
|
259
|
+
if (button.opening.attributes.properties.some(ts.isJsxSpreadAttribute)) pending.push(['spread-props', 'Spread props can supply or override intent, step or variant; inspect the source.']);
|
|
260
|
+
if (WEAK.has(button.variant) && destructiveSignal(button.element.getText(source))) pending.push(['destructive-verb', 'A destructive or negative command on a weak variant needs its actual consequence and confirmation step reviewed.']);
|
|
261
|
+
if (button.props.has('step')) pending.push(['existing-step', 'An existing destructive step needs reconciliation with the migrated intent.']);
|
|
262
|
+
if (pending.length) {
|
|
263
|
+
for (const [code, reason] of pending) flag(button, code, reason);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
const intent = button.variant === 'secondary'
|
|
267
|
+
? commits.some(other => other.group === button.group && canCoexist(button, other)) ? 'alternative' : 'neutral'
|
|
268
|
+
: button.mapped;
|
|
269
|
+
const step = button.variant === 'danger' ? 'confirm' : button.variant === 'danger-soft' ? 'entry' : null;
|
|
270
|
+
const attribute = button.props.get('variant');
|
|
271
|
+
const replacement = `intent="${intent}"${step ? ` step="${step}"` : ''}`;
|
|
272
|
+
edits.push({ start: attribute.getStart(source), end: attribute.end, replacement });
|
|
273
|
+
changes.push({ file, ...sourceLocation(source, button.opening), component: button.name, from: button.variant, intent, ...(step ? { step } : {}) });
|
|
274
|
+
}
|
|
275
|
+
let output = text;
|
|
276
|
+
for (const edit of edits.sort((first, second) => second.start - first.start)) output = output.slice(0, edit.start) + edit.replacement + output.slice(edit.end);
|
|
277
|
+
return { text: output, changes, findings };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function sourceFiles(directory) {
|
|
281
|
+
return fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
|
|
282
|
+
const target = path.join(directory, entry.name);
|
|
283
|
+
if (entry.isDirectory()) return GENERATED.has(entry.name) ? [] : sourceFiles(target);
|
|
284
|
+
return entry.isFile() && /\.[jt]sx$/i.test(entry.name) ? [target] : [];
|
|
285
|
+
}).sort();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Preview or apply safe migrations in one app's FE tree; every unresolved case is retained in the returned report. */
|
|
289
|
+
export function migrateButtonIntent({ root, write = false, ts = loadTypescript(root, PACKAGE_ROOT) }) {
|
|
290
|
+
if (!ts) throw new Error('button-intent needs TypeScript installed in the app or @starci/hfs development checkout');
|
|
291
|
+
const frontend = path.join(root, 'fe');
|
|
292
|
+
if (!fs.statSync(frontend, { throwIfNoEntry: false })?.isDirectory()) throw new Error('button-intent runs at an app root with a fe/ directory');
|
|
293
|
+
const report = { schema: 'starci/button-intent-codemod@1', root, write, filesScanned: 0, filesChanged: [], changes: [], findings: [] };
|
|
294
|
+
for (const target of sourceFiles(frontend)) {
|
|
295
|
+
const file = path.relative(root, target).split(path.sep).join('/');
|
|
296
|
+
const original = fs.readFileSync(target, 'utf8');
|
|
297
|
+
const result = transformButtonIntent({ ts, text: original, file });
|
|
298
|
+
report.filesScanned += 1;
|
|
299
|
+
report.changes.push(...result.changes);
|
|
300
|
+
report.findings.push(...result.findings);
|
|
301
|
+
if (result.text !== original) {
|
|
302
|
+
report.filesChanged.push(file);
|
|
303
|
+
if (write) fs.writeFileSync(target, result.text);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return report;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const cell = value => String(value).replaceAll('|', '\\|').replaceAll('\n', ' ');
|
|
310
|
+
|
|
311
|
+
/** Render the migration and human decision list as Markdown; JSON consumers use the same unabridged report object. */
|
|
312
|
+
export function buttonIntentMarkdown(report) {
|
|
313
|
+
const lines = ['# Button intent migration', '', `${report.write ? 'Applied' : 'Dry run'}: ${report.changes.length} safe changes in ${report.filesChanged.length} files; ${report.findings.length} unresolved findings.`, '',
|
|
314
|
+
'## Safe changes', '', '| Location | Before | Intent | Step |', '| --- | --- | --- | --- |',
|
|
315
|
+
...report.changes.map(change => `| ${cell(`${change.file}:${change.line}:${change.column}`)} | ${change.from} | ${change.intent} | ${change.step ?? ''} |`), '',
|
|
316
|
+
'## Human decisions', '', '| Location | Code | Reason |', '| --- | --- | --- |',
|
|
317
|
+
...report.findings.map(finding => `| ${cell(`${finding.file}:${finding.line}:${finding.column}`)} | ${finding.code} | ${cell(finding.reason)} |`), ''];
|
|
318
|
+
return lines.join('\n');
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function parse(argv) {
|
|
322
|
+
const [name, ...rest] = argv;
|
|
323
|
+
if (name !== 'button-intent') throw new Error('starci app codemod takes button-intent');
|
|
324
|
+
const options = { write: false, format: 'markdown' };
|
|
325
|
+
const args = rest.values();
|
|
326
|
+
for (const arg of args) {
|
|
327
|
+
const [flag, inline] = arg.split(/=(.*)/s);
|
|
328
|
+
if (flag === '--write' || flag === '--json') {
|
|
329
|
+
if (inline !== undefined && !['true', 'false'].includes(inline)) throw new Error(`${flag} expects true or false`);
|
|
330
|
+
const value = inline === undefined || inline === 'true';
|
|
331
|
+
if (flag === '--write') options.write = value;
|
|
332
|
+
else if (value) options.format = 'json';
|
|
333
|
+
} else if (flag === '--format' || flag === '--report') {
|
|
334
|
+
const value = inline ?? args.next().value;
|
|
335
|
+
if (!value || value.startsWith('--')) throw new Error(`${flag} needs a value`);
|
|
336
|
+
options[flag.slice(2)] = value;
|
|
337
|
+
} else throw new Error(`unknown codemod option ${arg}`);
|
|
338
|
+
}
|
|
339
|
+
if (!['json', 'markdown'].includes(options.format)) throw new Error('--format accepts json or markdown');
|
|
340
|
+
return options;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** CLI boundary: dry-run by default, --write mutates only safe FE source; findings return exit 1 with a complete report. */
|
|
344
|
+
export function codemodMain(argv, { cwd, stdout }) {
|
|
345
|
+
const options = parse(argv);
|
|
346
|
+
if (options.report && !/\.(?:json|md)$/i.test(options.report)) throw new Error('--report needs a .json or .md report file');
|
|
347
|
+
const report = migrateButtonIntent({ root: path.resolve(cwd), write: options.write });
|
|
348
|
+
const output = options.format === 'json' ? `${JSON.stringify(report, null, 2)}\n` : buttonIntentMarkdown(report);
|
|
349
|
+
if (options.report) {
|
|
350
|
+
const target = path.resolve(cwd, options.report);
|
|
351
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
352
|
+
fs.writeFileSync(target, output);
|
|
353
|
+
}
|
|
354
|
+
stdout(output);
|
|
355
|
+
return report.findings.length ? 1 : 0;
|
|
356
|
+
}
|
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
|
-
/**
|
|
168
|
-
|
|
169
|
-
const
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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) {
|