@starci/eslint-canon-fe 9.9.11 → 9.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +11 -1
- package/dist/directive-first.d.ts +16 -0
- package/dist/directive-first.js +37 -0
- package/dist/explicit-surface.d.ts +7 -0
- package/dist/explicit-surface.js +19 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +6 -2
- package/dist/lib/config.d.ts +1 -1
- package/dist/lib/config.js +2 -2
- package/dist/lib/cross-file-inputs.d.ts +1 -1
- package/dist/lib/cross-file-inputs.js +1 -1
- package/dist/lib/why.d.ts +10 -0
- package/dist/lib/why.js +10 -0
- package/dist/project-graph.d.ts +15 -1
- package/dist/project-graph.js +17 -2
- package/dist/skeleton-states.js +9 -4
- package/docs/surface-rules.md +2 -2
- package/package.json +1 -1
- package/runtime/bundle.json +2 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +8 -8
- package/runtime/knowledge/sonar-gate.yaml +2 -2
- package/runtime/scripts/hfs/architecture/test-world-files.mjs +26 -7
- package/runtime/scripts/hfs/explicit-surface-rule.mjs +59 -0
- package/runtime/scripts/hfs/rules/read.mjs +13 -0
- package/runtime/scripts/lib/declaration-surface.mjs +112 -0
- package/runtime/scripts/lib/sonar-syntax-rules.mjs +1 -1
- package/runtime/scripts/lib/sonar-way.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 9.10.0
|
|
4
|
+
|
|
5
|
+
- Added: `starci-fe/directive-first` (R369, `FE_DIRECTIVE_FIRST`) at error: a `"use client"` or `"use server"` directive is the first statement of its module; below an import it does nothing and `next build` refuses the file.
|
|
6
|
+
- Added: `starci-fe/explicit-export-surface` (R368, `HFS_EXPLICIT_EXPORT_SURFACE`) at error: an exported function's return type, an exported variable's type and a parameter's type are written, not inferred from a body or from other files; a component that returns only JSX, `null` or `undefined` is explicit. It reports each export the isolated declaration emit cannot write, at the offending span, and says what to annotate. It reads the classifier the lint result cache reads.
|
|
7
|
+
- Added: `projectGraphSnapshot`, the findings of the project graph the process has already built, which the ESLint child hands to the cache; `projectGraph` stays the fresh build.
|
|
8
|
+
- Changed: carry the bundled runtime of @starci/hfs 5.1.0 (the classifier, the rule factory, the rule catalog with R368, the canon pins).
|
|
9
|
+
- Fixed: `no-swr-default-array` (R367) judges a `?? []` inside a `useMemo` callback or a `slotOfReads` projector by the hook that holds it, so a slot carrying the read's status is no longer reported.
|
|
10
|
+
|
|
11
|
+
## 9.9.12
|
|
12
|
+
|
|
13
|
+
- Changed: Carry the bundled runtime of @starci/hfs 5.0.8 (the rule texts name `app check`, the read-set helper of the rule memo) and its canon pins; the comments name `app check` for the removed lint verb.
|
|
14
|
+
|
|
3
15
|
## 9.9.11
|
|
4
16
|
|
|
5
17
|
- Changed: Recognize declared module hook roles through real hook-call provenance.
|
package/README.md
CHANGED
|
@@ -48,7 +48,7 @@ columns inside a joined card, where `PaneGrid` owns both (`FE_HAND_BUILT_PANES`)
|
|
|
48
48
|
The spacing law (R350) leaves every distance to Grammar: `no-spacing-utility` refuses a padding, margin, gap
|
|
49
49
|
or space utility (`p-*`, `px-*`, `m*-*`, `gap-*`, `space-*`, arbitrary values and variants included) in an app class
|
|
50
50
|
string, where a role prop names the relation instead (`Stack`/`Cluster` `gap`, `Grid` `kind`, `PaneSection`, `Page`;
|
|
51
|
-
`FE_SPACING_UTILITY`). It runs in the counting cohort (`COUNTING` of `src/lib/config.ts`): `starci app lint` reports
|
|
51
|
+
`FE_SPACING_UTILITY`). It runs in the counting cohort (`COUNTING` of `src/lib/config.ts`): `starci app check --gate lint` reports
|
|
52
52
|
and counts every finding at warn and fails on none, while `starci app codemod spacing-roles` migrates the app.
|
|
53
53
|
|
|
54
54
|
The [button intent law](docs/button-intent.md) (CTA-6) requires every app Button and IconButton to
|
|
@@ -62,6 +62,16 @@ canonical page renderer. Only those exact input members may take `children`; ord
|
|
|
62
62
|
renderers and shape records containing rendered trees remain refused. The selected canonical Grammar catalog owns
|
|
63
63
|
semantic renderer bindings; universal code carries no concrete renderer spelling.
|
|
64
64
|
|
|
65
|
+
The [explicit exported surface](../../contexts/code/shared/typing.md) (R368, SHARED-TYPING-5) is `explicit-export-surface`
|
|
66
|
+
(`HFS_EXPLICIT_EXPORT_SURFACE`): an exported function's return type, an exported variable's type and a parameter's type are
|
|
67
|
+
written where they are exported, never inferred from a body or from other files; a component that returns only JSX, `null` or
|
|
68
|
+
`undefined` is explicit. It reports each export TypeScript's isolated declaration emit cannot write, at the offending span, and says
|
|
69
|
+
which annotation to add. It reads the classifier the lint result cache reads (`declarationSurface`, the bundled
|
|
70
|
+
`runtime/scripts/lib/declaration-surface.mjs`), so it reports exactly the files the cache counts as open.
|
|
71
|
+
|
|
72
|
+
`projectGraph` and `projectGraphSnapshot` are the cache's two readers of the project graph the graph rules share: the first builds it
|
|
73
|
+
afresh, the second answers from the graph the process has already built, which is how the ESLint child hands its graph to the cache.
|
|
74
|
+
|
|
65
75
|
Regression specs belong to this package. Application front ends and Grammar libraries have no specs.
|
|
66
76
|
|
|
67
77
|
## The runtime copy
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A module directive is the first statement of its module (HFS R369, `FE_DIRECTIVE_FIRST`).
|
|
3
|
+
*
|
|
4
|
+
* `"use client"` and `"use server"` are directives only in the prologue, before any import or other statement. Below an import the string is an
|
|
5
|
+
* expression statement that does nothing, Prettier wraps it as `("use client")`, and `next build` stops with "The 'use client' directive must be placed
|
|
6
|
+
* before other expressions". ESLint's parser marks a real directive with `directive`, so a string statement of that text without one is misplaced.
|
|
7
|
+
*/
|
|
8
|
+
import { type Recommended } from "./lib/rule.js";
|
|
9
|
+
/** A module directive stays in the prologue. */
|
|
10
|
+
export declare const directiveFirst: import("./lib/rule.js").Rule<"misplaced", []>;
|
|
11
|
+
/** The rules this law contributes to the plugin. */
|
|
12
|
+
export declare const rules: {
|
|
13
|
+
"directive-first": import("./lib/rule.js").Rule<"misplaced", []>;
|
|
14
|
+
};
|
|
15
|
+
/** Every misplaced directive is a build error. */
|
|
16
|
+
export declare const recommended: Recommended;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A module directive is the first statement of its module (HFS R369, `FE_DIRECTIVE_FIRST`).
|
|
3
|
+
*
|
|
4
|
+
* `"use client"` and `"use server"` are directives only in the prologue, before any import or other statement. Below an import the string is an
|
|
5
|
+
* expression statement that does nothing, Prettier wraps it as `("use client")`, and `next build` stops with "The 'use client' directive must be placed
|
|
6
|
+
* before other expressions". ESLint's parser marks a real directive with `directive`, so a string statement of that text without one is misplaced.
|
|
7
|
+
*/
|
|
8
|
+
import { defineRule } from "./lib/rule.js";
|
|
9
|
+
/** The directives Next.js reads from the prologue of a module. */
|
|
10
|
+
const DIRECTIVES = new Set(["use client", "use server"]);
|
|
11
|
+
/** A module directive stays in the prologue. */
|
|
12
|
+
export const directiveFirst = defineRule({
|
|
13
|
+
meta: {
|
|
14
|
+
type: "problem",
|
|
15
|
+
docs: { description: "R369: a 'use client' or 'use server' directive is the first statement of its module." },
|
|
16
|
+
schema: [],
|
|
17
|
+
messages: {
|
|
18
|
+
misplaced: 'FE_DIRECTIVE_FIRST: "{{directive}}" is not at the top of the module, so it is an expression that does nothing and `next build` refuses the file. Move it above every import and other statement.',
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
create(context) {
|
|
22
|
+
return {
|
|
23
|
+
ExpressionStatement(node) {
|
|
24
|
+
if (node.parent.type !== "Program" || "directive" in node && typeof node.directive === "string")
|
|
25
|
+
return;
|
|
26
|
+
const expression = node.expression;
|
|
27
|
+
if (expression.type !== "Literal" || typeof expression.value !== "string" || !DIRECTIVES.has(expression.value))
|
|
28
|
+
return;
|
|
29
|
+
context.report({ node, messageId: "misplaced", data: { directive: expression.value } });
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
/** The rules this law contributes to the plugin. */
|
|
35
|
+
export const rules = { "directive-first": directiveFirst };
|
|
36
|
+
/** Every misplaced directive is a build error. */
|
|
37
|
+
export const recommended = { "starci-fe/directive-first": "error" };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Recommended } from "./lib/rule.js";
|
|
2
|
+
/** The rules this law contributes to the plugin. */
|
|
3
|
+
export declare const rules: {
|
|
4
|
+
"explicit-export-surface": import("@typescript-eslint/utils/ts-eslint").RuleModule<"inferred", readonly unknown[], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener>;
|
|
5
|
+
};
|
|
6
|
+
/** Every exported type is written out, at error. */
|
|
7
|
+
export declare const recommended: Recommended;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The explicit exported surface (HFS R368, `HFS_EXPLICIT_EXPORT_SURFACE`): an exported declaration states its type, in the file that exports it.
|
|
3
|
+
*
|
|
4
|
+
* A return type, variable type or parameter type left to inference is a surface importers read from the body and from the files the body uses, so
|
|
5
|
+
* the result cache of `starci app check --gate lint` lints every importer again whenever the file changes (check-cache.md, ESLint). The rule
|
|
6
|
+
* reports each export the isolated declaration emit (`ts.transpileDeclaration`) cannot write without inference, where it is written, and says
|
|
7
|
+
* what to annotate. A component that returns only JSX has the type the JSX namespace gives it and is not reported. It is the very classifier the
|
|
8
|
+
* cache reads (`declarationSurface` of the bundled runtime), so the rule reports exactly the files the cache counts as open.
|
|
9
|
+
*/
|
|
10
|
+
import ts from "typescript";
|
|
11
|
+
import { explicitSurfaceRule } from "#runtime/scripts/hfs/explicit-surface-rule.mjs";
|
|
12
|
+
/** The rules this law contributes to the plugin. */
|
|
13
|
+
export const rules = {
|
|
14
|
+
"explicit-export-surface": explicitSurfaceRule({ ts }),
|
|
15
|
+
};
|
|
16
|
+
/** Every exported type is written out, at error. */
|
|
17
|
+
export const recommended = {
|
|
18
|
+
"starci-fe/explicit-export-surface": "error",
|
|
19
|
+
};
|
package/dist/index.d.ts
CHANGED
|
@@ -106,5 +106,5 @@ export declare const starciTypedSourceConfig: ({ root }?: {
|
|
|
106
106
|
}) => Linter.Config[];
|
|
107
107
|
export { crossFileInputs } from "./lib/cross-file-inputs.js";
|
|
108
108
|
export type { CrossFileInput } from "./lib/cross-file-inputs.js";
|
|
109
|
-
export { projectGraph } from "./project-graph.js";
|
|
109
|
+
export { projectGraph, projectGraphSnapshot } from "./project-graph.js";
|
|
110
110
|
export type { ProjectGraphFinding, ProjectGraphSnapshot } from "./project-graph.js";
|
package/dist/index.js
CHANGED
|
@@ -27,6 +27,8 @@ import { recommended as hygieneRecommended, rules as hygieneRules } from "./hygi
|
|
|
27
27
|
import { recommended as landmarkRecommended, rules as landmarkRules } from "./landmark.js";
|
|
28
28
|
import { recommended as listsRecommended, rules as listsRules } from "./lists.js";
|
|
29
29
|
import { recommended as loadingRecommended, rules as loadingRules } from "./loading.js";
|
|
30
|
+
import { recommended as directiveFirstRecommended, rules as directiveFirstRules } from "./directive-first.js";
|
|
31
|
+
import { recommended as explicitSurfaceRecommended, rules as explicitSurfaceRules } from "./explicit-surface.js";
|
|
30
32
|
import { recommended as skeletonStatesRecommended, rules as skeletonStatesRules } from "./skeleton-states.js";
|
|
31
33
|
import { recommended as lintEscapeRecommended, rules as lintEscapeRules } from "./lint-escape-hatch.js";
|
|
32
34
|
import { recommended as namingRecommended, rules as namingRules } from "./naming.js";
|
|
@@ -74,6 +76,8 @@ const CONTRIBUTIONS = [
|
|
|
74
76
|
{ law: "landmark", rules: landmarkRules, recommended: landmarkRecommended },
|
|
75
77
|
{ law: "lists", rules: listsRules, recommended: listsRecommended },
|
|
76
78
|
{ law: "loading", rules: loadingRules, recommended: loadingRecommended },
|
|
79
|
+
{ law: "directive-first", rules: directiveFirstRules, recommended: directiveFirstRecommended },
|
|
80
|
+
{ law: "explicit-surface", rules: explicitSurfaceRules, recommended: explicitSurfaceRecommended },
|
|
77
81
|
{ law: "skeleton-states", rules: skeletonStatesRules, recommended: skeletonStatesRecommended },
|
|
78
82
|
{ law: "lint-escape-hatch", rules: lintEscapeRules, recommended: lintEscapeRecommended },
|
|
79
83
|
{ law: "naming", rules: namingRules, recommended: namingRecommended },
|
|
@@ -311,6 +315,6 @@ for (const addition of A14_CONTRIBUTIONS) {
|
|
|
311
315
|
Object.assign(rules, addition.rules);
|
|
312
316
|
Object.assign(recommended, addition.recommended);
|
|
313
317
|
}
|
|
314
|
-
// The result cache of `starci app lint` (contexts/project/check-cache.md) reads these two declarations of the installed canon.
|
|
318
|
+
// The result cache of `starci app check --gate lint` (contexts/project/check-cache.md) reads these two declarations of the installed canon.
|
|
315
319
|
export { crossFileInputs } from "./lib/cross-file-inputs.js";
|
|
316
|
-
export { projectGraph } from "./project-graph.js";
|
|
320
|
+
export { projectGraph, projectGraphSnapshot } from "./project-graph.js";
|
package/dist/lib/config.d.ts
CHANGED
|
@@ -29,7 +29,7 @@ export declare const IGNORED: readonly string[];
|
|
|
29
29
|
export declare const BORROWED: Readonly<Record<string, "error">>;
|
|
30
30
|
/**
|
|
31
31
|
* The counting cohort: a rule the owner ruled to ship at `warn` for one release while a codemod moves apps onto the API
|
|
32
|
-
* it asks for, each with the HFS rule id it enforces. `starci app lint` reports and counts its findings and never fails
|
|
32
|
+
* it asks for, each with the HFS rule id it enforces. `starci app check --gate lint` reports and counts its findings and never fails
|
|
33
33
|
* on them; the next cohort moves the rule to error and deletes its entry. No other rule may be below error.
|
|
34
34
|
*/
|
|
35
35
|
export declare const COUNTING: Readonly<Record<string, {
|
package/dist/lib/config.js
CHANGED
|
@@ -42,7 +42,7 @@ export const BORROWED = Object.freeze({
|
|
|
42
42
|
const levelOf = (setting) => (Array.isArray(setting) ? setting[0] : setting);
|
|
43
43
|
/**
|
|
44
44
|
* The counting cohort: a rule the owner ruled to ship at `warn` for one release while a codemod moves apps onto the API
|
|
45
|
-
* it asks for, each with the HFS rule id it enforces. `starci app lint` reports and counts its findings and never fails
|
|
45
|
+
* it asks for, each with the HFS rule id it enforces. `starci app check --gate lint` reports and counts its findings and never fails
|
|
46
46
|
* on them; the next cohort moves the rule to error and deletes its entry. No other rule may be below error.
|
|
47
47
|
*/
|
|
48
48
|
export const COUNTING = Object.freeze({
|
|
@@ -105,7 +105,7 @@ export const SLOT_FREE_RULES = Object.freeze([
|
|
|
105
105
|
"outcome-kinds-exhaustive", "no-await-in-loop", "prefer-code-point", "prefer-string-raw", "no-nested-conditional", "no-void-operator", "no-unused-import",
|
|
106
106
|
"prefer-export-from", "no-unused-prop-types", "media-has-caption", "max-params",
|
|
107
107
|
"no-list-panel-padding", "no-hand-built-panes", "no-table-in-card", "no-heading-above-card", "no-selectable-tag-group", "no-fill-command-in-header", "no-app-surface-paint", "divider-full-bleed", "control-height", "selection-fill-no-check", "no-grammar-shim",
|
|
108
|
-
"no-swr-default-array", "no-foreign-slot-status", "no-validating-as-loading", "no-placeholder-object", "no-hand-skeleton",
|
|
108
|
+
"no-swr-default-array", "no-foreign-slot-status", "no-validating-as-loading", "no-placeholder-object", "no-hand-skeleton", "explicit-export-surface", "directive-first",
|
|
109
109
|
"button-intent-required", "destructive-confirm-in-dialog", "single-commit-per-render", "no-commit-in-collection-item",
|
|
110
110
|
]);
|
|
111
111
|
/** The source of a plain front-end tree: its `src/`. */
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The files a rule of this canon reads that ESLint's own import graph does not carry.
|
|
3
3
|
*
|
|
4
|
-
* The result cache of `starci app lint` (contexts/project/check-cache.md, ESLint) re-lints a file when it or a file it reaches through imports
|
|
4
|
+
* The result cache of `starci app check --gate lint` (contexts/project/check-cache.md, ESLint) re-lints a file when it or a file it reaches through imports
|
|
5
5
|
* changed. A rule that reads another file by path, with `fs` (a sibling, a directory listing, a manifest, a module file it follows by hand),
|
|
6
6
|
* has an input the import graph cannot see, so the rule declares it here and the cache dirties the files that depend on it:
|
|
7
7
|
*
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The files a rule of this canon reads that ESLint's own import graph does not carry.
|
|
3
3
|
*
|
|
4
|
-
* The result cache of `starci app lint` (contexts/project/check-cache.md, ESLint) re-lints a file when it or a file it reaches through imports
|
|
4
|
+
* The result cache of `starci app check --gate lint` (contexts/project/check-cache.md, ESLint) re-lints a file when it or a file it reaches through imports
|
|
5
5
|
* changed. A rule that reads another file by path, with `fs` (a sibling, a directory listing, a manifest, a module file it follows by hand),
|
|
6
6
|
* has an input the import graph cannot see, so the rule declares it here and the cache dirties the files that depend on it:
|
|
7
7
|
*
|
package/dist/lib/why.d.ts
CHANGED
|
@@ -102,6 +102,16 @@ export declare const why: {
|
|
|
102
102
|
en: string;
|
|
103
103
|
fix: string;
|
|
104
104
|
};
|
|
105
|
+
"directive-first": {
|
|
106
|
+
code: string;
|
|
107
|
+
en: string;
|
|
108
|
+
fix: string;
|
|
109
|
+
};
|
|
110
|
+
"explicit-export-surface": {
|
|
111
|
+
code: string;
|
|
112
|
+
en: string;
|
|
113
|
+
fix: string;
|
|
114
|
+
};
|
|
105
115
|
"destructive-confirm-in-dialog": {
|
|
106
116
|
code: string;
|
|
107
117
|
en: string;
|
package/dist/lib/why.js
CHANGED
|
@@ -102,6 +102,16 @@ export const why = {
|
|
|
102
102
|
en: "`<file>` draws a skeleton by hand.",
|
|
103
103
|
fix: "Pass isLoading to the Grammar component and let it draw its loading structure; the block hands it the same-shape placeholder (FE-ERROR-1).",
|
|
104
104
|
},
|
|
105
|
+
"directive-first": {
|
|
106
|
+
code: "FE_DIRECTIVE_FIRST",
|
|
107
|
+
en: "`<file>` has a `<what>` directive below another statement.",
|
|
108
|
+
fix: "Put the directive on the first line of the module, above every import; Next.js reads it only there.",
|
|
109
|
+
},
|
|
110
|
+
"explicit-export-surface": {
|
|
111
|
+
code: "HFS_EXPLICIT_EXPORT_SURFACE",
|
|
112
|
+
en: "`<file>` exports `<what>` with a type left to inference.",
|
|
113
|
+
fix: "Write the type where it is exported: the return type after the parameter list, the type of the exported variable, or the type of the parameter.",
|
|
114
|
+
},
|
|
105
115
|
"destructive-confirm-in-dialog": {
|
|
106
116
|
code: "FE_DESTRUCTIVE_CONFIRM_IN_DIALOG",
|
|
107
117
|
en: "`<file>` renders a final destructive press outside its confirmation dialog.",
|
package/dist/project-graph.d.ts
CHANGED
|
@@ -19,7 +19,7 @@ export interface ProjectGraphSnapshot {
|
|
|
19
19
|
/**
|
|
20
20
|
* The findings every project-graph rule of this canon reports, per file, from one fresh build of the side's graph.
|
|
21
21
|
*
|
|
22
|
-
* The result cache of `starci app lint` calls this once for a side whose files changed and compares each file's findings with the recorded
|
|
22
|
+
* The result cache of `starci app check --gate lint` calls this once for a side whose files changed and compares each file's findings with the recorded
|
|
23
23
|
* ones: a file whose graph findings differ is linted again by ESLint itself, so ESLint stays the only emitter of each message. It is the very
|
|
24
24
|
* graph the rules read (same machine, same manifest, same filter), never a second judge.
|
|
25
25
|
*
|
|
@@ -30,3 +30,17 @@ export declare const projectGraph: ({ repoRoot, injectedTypeScript }: {
|
|
|
30
30
|
repoRoot: string;
|
|
31
31
|
injectedTypeScript?: object;
|
|
32
32
|
}) => ProjectGraphSnapshot;
|
|
33
|
+
/**
|
|
34
|
+
* The same findings from the graph this process has already built, so a process that linted the side hands them out without building the graph again.
|
|
35
|
+
*
|
|
36
|
+
* The rules read one graph per process and per side, built when the first file is linted. The ESLint child of `starci app check --gate lint` calls
|
|
37
|
+
* this after its run and writes the result beside its report; the result cache keeps the graph findings from it instead of building the graph a
|
|
38
|
+
* second time in the CLI. A process that has not built the graph yet builds it here, once, exactly as `projectGraph` does, and the value is the
|
|
39
|
+
* same: `projectGraph` is a fresh build, this is the build the rules used.
|
|
40
|
+
*
|
|
41
|
+
* @param input - The side folder (`repoRoot`, the folder of the side's `eslint.config.mjs`).
|
|
42
|
+
* @returns The findings of the rules of `recommended`, keyed by path relative to the side folder.
|
|
43
|
+
*/
|
|
44
|
+
export declare const projectGraphSnapshot: ({ repoRoot }: {
|
|
45
|
+
repoRoot: string;
|
|
46
|
+
}) => ProjectGraphSnapshot;
|
package/dist/project-graph.js
CHANGED
|
@@ -21,7 +21,7 @@ export const recommended = Object.fromEntries(mine.map((e) => [`starci-fe/${e.id
|
|
|
21
21
|
/**
|
|
22
22
|
* The findings every project-graph rule of this canon reports, per file, from one fresh build of the side's graph.
|
|
23
23
|
*
|
|
24
|
-
* The result cache of `starci app lint` calls this once for a side whose files changed and compares each file's findings with the recorded
|
|
24
|
+
* The result cache of `starci app check --gate lint` calls this once for a side whose files changed and compares each file's findings with the recorded
|
|
25
25
|
* ones: a file whose graph findings differ is linted again by ESLint itself, so ESLint stays the only emitter of each message. It is the very
|
|
26
26
|
* graph the rules read (same machine, same manifest, same filter), never a second judge.
|
|
27
27
|
*
|
|
@@ -30,7 +30,22 @@ export const recommended = Object.fromEntries(mine.map((e) => [`starci-fe/${e.id
|
|
|
30
30
|
*/
|
|
31
31
|
export const projectGraph = ({ repoRoot, injectedTypeScript }) => {
|
|
32
32
|
resetProjectGraphs();
|
|
33
|
-
|
|
33
|
+
return snapshotOf(buildProjectGraph({ repoRoot, ...(injectedTypeScript === undefined ? {} : { injectedTypeScript }), runtimeRoot: RUNTIME }));
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* The same findings from the graph this process has already built, so a process that linted the side hands them out without building the graph again.
|
|
37
|
+
*
|
|
38
|
+
* The rules read one graph per process and per side, built when the first file is linted. The ESLint child of `starci app check --gate lint` calls
|
|
39
|
+
* this after its run and writes the result beside its report; the result cache keeps the graph findings from it instead of building the graph a
|
|
40
|
+
* second time in the CLI. A process that has not built the graph yet builds it here, once, exactly as `projectGraph` does, and the value is the
|
|
41
|
+
* same: `projectGraph` is a fresh build, this is the build the rules used.
|
|
42
|
+
*
|
|
43
|
+
* @param input - The side folder (`repoRoot`, the folder of the side's `eslint.config.mjs`).
|
|
44
|
+
* @returns The findings of the rules of `recommended`, keyed by path relative to the side folder.
|
|
45
|
+
*/
|
|
46
|
+
export const projectGraphSnapshot = ({ repoRoot }) => snapshotOf(buildProjectGraph({ repoRoot, runtimeRoot: RUNTIME }));
|
|
47
|
+
/** The findings of the rules of this canon in a built graph, by side-relative path, each list sorted by line, column, code and message. */
|
|
48
|
+
const snapshotOf = (graph) => {
|
|
34
49
|
const byFile = {};
|
|
35
50
|
for (const [file, findings] of graph.byFile) {
|
|
36
51
|
for (const finding of findings) {
|
package/dist/skeleton-states.js
CHANGED
|
@@ -29,12 +29,17 @@ const rootOf = (input) => {
|
|
|
29
29
|
}
|
|
30
30
|
return null;
|
|
31
31
|
};
|
|
32
|
-
|
|
32
|
+
/**
|
|
33
|
+
* The outermost function around a node: the hook itself. A fold inside a `useMemo` callback or a `slotOfReads` projector is judged by what the
|
|
34
|
+
* hook returns of the read's status and by the slot it builds, not by the callback alone, which holds neither.
|
|
35
|
+
*/
|
|
36
|
+
const hookFunction = (node) => {
|
|
37
|
+
let found = null;
|
|
33
38
|
for (let current = node.parent; current; current = current.parent) {
|
|
34
39
|
if (current.type === "ArrowFunctionExpression" || current.type === "FunctionDeclaration" || current.type === "FunctionExpression")
|
|
35
|
-
|
|
40
|
+
found = current;
|
|
36
41
|
}
|
|
37
|
-
return
|
|
42
|
+
return found;
|
|
38
43
|
};
|
|
39
44
|
const nameOfKey = (key) => (key.type === "Identifier" ? key.name : key.type === "Literal" && typeof key.value === "string" ? key.value : null);
|
|
40
45
|
// -- no-swr-default-array -----------------------------------------------------------------------------------
|
|
@@ -79,7 +84,7 @@ const noSwrDefaultArray = defineRule({
|
|
|
79
84
|
const root = rootOf(node.left);
|
|
80
85
|
if (!root || !isHookResult(context, node, root))
|
|
81
86
|
return;
|
|
82
|
-
const fn =
|
|
87
|
+
const fn = hookFunction(node);
|
|
83
88
|
if (!fn)
|
|
84
89
|
return;
|
|
85
90
|
const text = source.getText(fn);
|
package/docs/surface-rules.md
CHANGED
|
@@ -4,7 +4,7 @@ Law module: `surface-rules.mjs`. HFS rule: R318. Canon: ANATOMY-2 and PADDING-0.
|
|
|
4
4
|
|
|
5
5
|
## `starci-fe/no-app-surface-paint`
|
|
6
6
|
|
|
7
|
-
Finding code: `FE_GRAMMAR_LAYOUT_ONLY`. App classes carry layout. A raw `bg-surface` utility paints a surface and is refused, including conditional classes, class builders, responsive variants, important modifiers and opacity suffixes. Compose a painted Grammar surface such as `SurfaceCard`. Static props spreads keep the same obligations.
|
|
7
|
+
Finding code: `FE_GRAMMAR_LAYOUT_ONLY`. App classes carry layout. A raw `bg-surface` utility paints a surface and is refused, including conditional classes, class builders, responsive variants, important modifiers and opacity suffixes. Compose a painted Grammar surface such as `SurfaceCard`. Static props spreads keep the same obligations. The typed lint of an app follows imported colocated class declarations as well as local constants.
|
|
8
8
|
|
|
9
9
|
## `starci-fe/no-list-panel-padding`
|
|
10
10
|
|
|
@@ -26,7 +26,7 @@ const Panel = () => <div className="px-4"><SurfaceCard><ListBox inset="panel" la
|
|
|
26
26
|
|
|
27
27
|
## `starci-fe/no-hand-built-panes`
|
|
28
28
|
|
|
29
|
-
Finding code: `FE_HAND_BUILT_PANES`. A multi-pane frame is a `PaneGrid` of `Pane`s, which draws the dividers between panes, sizes the columns and collapses a side pane below medium. A vertical `Divider` used as a column seam is a pane frame drawn by hand: a vertical `Divider` (or the plain wrapper element that holds only it) standing among plain column elements (`div`, `aside`, `Region`) of a flex row, or a vertical `Divider` anywhere inside a `SurfaceCard composition="joined"`; and a joined card that holds a plain flex row of two or more columns as one of its own children. `joined` stacks bands divided by horizontal separators. A short vertical separator between controls of a toolbar, a `flex-col` stack, and a row whose classes cannot be read in the file are not judged.
|
|
29
|
+
Finding code: `FE_HAND_BUILT_PANES`. A multi-pane frame is a `PaneGrid` of `Pane`s, which draws the dividers between panes, sizes the columns and collapses a side pane below medium. A vertical `Divider` used as a column seam is a pane frame drawn by hand: a vertical `Divider` (or the plain wrapper element that holds only it) standing among plain column elements (`div`, `aside`, `Region`) of a flex row, or a vertical `Divider` anywhere inside a `SurfaceCard composition="joined"`; and a joined card that holds a plain flex row of two or more columns as one of its own children. `joined` stacks bands divided by horizontal separators. A short vertical separator between controls of a toolbar, a `flex-col` stack, and a row whose classes cannot be read in the file are not judged. The typed lint of an app follows imported colocated class declarations.
|
|
30
30
|
|
|
31
31
|
Invalid:
|
|
32
32
|
|
package/package.json
CHANGED
package/runtime/bundle.json
CHANGED
|
@@ -125,6 +125,7 @@
|
|
|
125
125
|
"scripts/hfs/declaration-shape.mjs",
|
|
126
126
|
"scripts/hfs/declaration-slots.mjs",
|
|
127
127
|
"scripts/hfs/edition-slots.mjs",
|
|
128
|
+
"scripts/hfs/explicit-surface-rule.mjs",
|
|
128
129
|
"scripts/hfs/linear-text.mjs",
|
|
129
130
|
"scripts/hfs/param-names.mjs",
|
|
130
131
|
"scripts/hfs/path-findings.mjs",
|
|
@@ -150,6 +151,7 @@
|
|
|
150
151
|
"scripts/hfs/trailing-slashes.mjs",
|
|
151
152
|
"scripts/hfs/typescript-programs.mjs",
|
|
152
153
|
"scripts/hfs/view.mjs",
|
|
154
|
+
"scripts/lib/declaration-surface.mjs",
|
|
153
155
|
"scripts/lib/dockerfile.mjs",
|
|
154
156
|
"scripts/lib/env.mjs",
|
|
155
157
|
"scripts/lib/event-contract.mjs",
|
|
@@ -31,42 +31,42 @@ pins:
|
|
|
31
31
|
source: packages/event-stream/package.json
|
|
32
32
|
why: 'the one server-sent-events client of every front end (fe-stack event-stream, owner:event-stream-package): the transport client layer (modules/api/sse/clients) creates one instance for the app with the bearer token source and the stream operations follow on it; it carries Last-Event-ID resume, the capped doubling backoff, the heartbeat timeout, the single 401 renewal and the useSWRSubscription adapter, so no app keeps a hand-written reader.'
|
|
33
33
|
'@starci/grammar':
|
|
34
|
-
version: 0.21.
|
|
34
|
+
version: 0.21.13
|
|
35
35
|
group: starci
|
|
36
36
|
install: registry
|
|
37
37
|
side: fe
|
|
38
38
|
source: packages/grammar/package.json
|
|
39
39
|
why: 'the grammar front ends read: the brand layer sets `--font-sans` and `--font-mono` and the grammar reads them; it carries the Input tel kind, the copyable and read-only Input, the IconButton disclosure props and count badge, ChatBubble, FillViewport, Textarea mentions and Enter-to-send, the leading PrimaryRailLayout rail, the plate-less Image, ListRow, the soft Alert, Progress steps, the floating primary IconButton, FlowGraph, the STATE-8 loading views and the bordered selectable TagGroup chips. The runtime source version wins for a @starci package.'
|
|
40
40
|
'@starci/eslint-canon-be':
|
|
41
|
-
version: 3.
|
|
41
|
+
version: 3.12.0
|
|
42
42
|
group: starci
|
|
43
43
|
install: registry
|
|
44
44
|
side: be
|
|
45
45
|
source: packages/eslint-canon-be/package.json
|
|
46
46
|
why: 'the back-end ESLint canon: `loadHfs(import.meta.url)` of be/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the be side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; `starciBeConfig({ hfs: loadHfs(import.meta.url) })` is the typed factory and the BE-CONVENTION laws are its rules.'
|
|
47
47
|
'@starci/eslint-canon-fe':
|
|
48
|
-
version: 9.
|
|
48
|
+
version: 9.10.0
|
|
49
49
|
group: starci
|
|
50
50
|
install: registry
|
|
51
51
|
side: fe
|
|
52
52
|
source: packages/eslint-canon-fe/package.json
|
|
53
53
|
why: 'the front-end ESLint canon: `loadHfs(import.meta.url)` of fe/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the fe side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; the front end has no tests (FE_NO_TESTS R97 in hfs) and no-vietnamese-in-source (R91) applies.'
|
|
54
54
|
'@starci/eslint-grammar-core':
|
|
55
|
-
version: 0.21.
|
|
55
|
+
version: 0.21.13
|
|
56
56
|
group: starci
|
|
57
57
|
install: registry
|
|
58
58
|
side: fe
|
|
59
59
|
source: packages/eslint-grammar-core/package.json
|
|
60
60
|
why: 'the StarCi Grammar component API lint, versioned with @starci/grammar; front ends compose this family package with the universal FE canon.'
|
|
61
61
|
'@starci/eslint-grammar-offset-pop':
|
|
62
|
-
version: 0.21.
|
|
62
|
+
version: 0.21.13
|
|
63
63
|
group: starci
|
|
64
64
|
install: registry
|
|
65
65
|
side: fe
|
|
66
66
|
source: packages/eslint-grammar-offset-pop/package.json
|
|
67
67
|
why: 'the Offset Pop Grammar component API lint, versioned with @starci/grammar; its shared component contract comes from the StarCi family lint without copying rules.'
|
|
68
68
|
'@starci/stylelint-canon':
|
|
69
|
-
version: 2.5.
|
|
69
|
+
version: 2.5.1
|
|
70
70
|
group: starci
|
|
71
71
|
install: registry
|
|
72
72
|
side: fe
|
|
@@ -92,7 +92,7 @@ pins:
|
|
|
92
92
|
source: packages/jest-preset/package.json
|
|
93
93
|
why: 'the Jest preset of a back end: the world runner runs up to min(--maxWorkers, slots) world files at once, each in its own process bound to one data slot of the test world (STARCI_TEST_WORLD_SLOT), and reads test-world state protocol 2 only: it pairs exactly with @starci/test-world, and any other state file is JEST_PRESET_WORLD_PAIR_MISMATCH. The unit run writes the lcov Sonar and Codecov import (services only); the integration, e2e and contract projects run every spec file in a worker process of its own (world-runner.cjs), so a process-global framework registry (@nestjs/graphql type metadata) never leaks between e2e files; it ships the unit kit (mockEntityManager, fakeTransaction, fakeQueryBuilder, queueQueryBuilders, fakeIds, FakeClock, Outcome matchers) and the recordingOutbox claim side.'
|
|
94
94
|
'@starci/test-world':
|
|
95
|
-
version: 1.3.
|
|
95
|
+
version: 1.3.4
|
|
96
96
|
group: starci
|
|
97
97
|
install: registry
|
|
98
98
|
side: be
|
|
@@ -106,7 +106,7 @@ pins:
|
|
|
106
106
|
source: packages/ai-router/package.json
|
|
107
107
|
why: 'the AI pool router of a back end: a NestJS module that routes OpenAI-compatible chat, stream and embeddings calls across keys, models and providers, with @starci/ai-router/testing for its specs; @starci/jest-preset compiles it as an ESM-only dependency.'
|
|
108
108
|
'@starci/hfs':
|
|
109
|
-
version: 5.0
|
|
109
|
+
version: 5.1.0
|
|
110
110
|
group: starci
|
|
111
111
|
install: registry
|
|
112
112
|
side: both
|
|
@@ -40,11 +40,11 @@ newCode:
|
|
|
40
40
|
metric: new_security_hotspots_reviewed
|
|
41
41
|
minReviewedPercent: 100
|
|
42
42
|
unreviewedMax: 0
|
|
43
|
-
# The whole code, not only the new code. Every finding of the HFS canon reaches Sonar through ONE entry, `starci app lint`
|
|
43
|
+
# The whole code, not only the new code. Every finding of the HFS canon reaches Sonar through ONE entry, `starci app check --gate lint --out`
|
|
44
44
|
# (packages/hfs, contract changes hfs-sonar-import and hfs-lint-entry). CI runs it per side with --side and --out,
|
|
45
45
|
# then merges rules by id and joins issues into ONE file, reports/lint.sonar.json, named by sonar.externalIssuesReportPaths; the gate holds the open-issue count of
|
|
46
46
|
# the whole project at zero. The file carries three engines:
|
|
47
|
-
# starci-hfs the repository findings of
|
|
47
|
+
# starci-hfs the repository findings of the gates a run selects (managed files, tree, pins, CI, contracts; the rule id is the finding code)
|
|
48
48
|
# eslint ESLint's json over the repository (the BE and FE canon plugins alike, per-file and project-graph rules)
|
|
49
49
|
# stylelint stylelint's json over the style glob (front ends)
|
|
50
50
|
# The engines share ONE placement rule (a finding on a file Sonar does not index is filed on the first source file); Sonar's own
|
|
@@ -15,9 +15,10 @@ import { byCodeUnit } from '../../lib/list.mjs';
|
|
|
15
15
|
* its own slot, be.tests.world.kit) plus files at its root whose role suffix is in ruleParams.be.suffixes
|
|
16
16
|
* (`stripe.client.ts`, `checkout.contracts.ts`, `test-world.config.ts`, ...). Every tracked file below the world root is
|
|
17
17
|
* matched by the slot's own entries through `allowsFile`.
|
|
18
|
-
* 2. The declaration. `test-world.config.ts` declares the world in
|
|
19
|
-
* `export const { useTestWorld, useSandbox } = defineTestWorld({ ... })
|
|
20
|
-
* `
|
|
18
|
+
* 2. The declaration. `test-world.config.ts` declares the world in a named form R89 allows: the destructured export
|
|
19
|
+
* `export const { useTestWorld, useSandbox } = defineTestWorld({ ... })`, or the typed form
|
|
20
|
+
* `const world: TestWorldApi = defineTestWorld({ ... })` whose `useTestWorld` and `useSandbox` are exported consts typed from
|
|
21
|
+
* `TestWorldApi` (a `const` initialized by a `defineTestWorld(<object literal>)` call). A config without it (a default export, or a declaration the machine cannot
|
|
21
22
|
* read) is refused: the world it declares could not be judged.
|
|
22
23
|
* 3. Fakes against the stack (owner refinement 2026-09-30). Every service the stack declares runs real in the world; a fake
|
|
23
24
|
* (a `fakes/<provider>/` folder, or a `fakes` entry of the declaration such as `smtp: smtpFake()`) is a network-edge fake
|
|
@@ -38,6 +39,7 @@ const FAKES_DIRECTORY = 'fakes/';
|
|
|
38
39
|
const CONFIG_FILE = 'test-world.config.ts';
|
|
39
40
|
const DEFINE = 'defineTestWorld';
|
|
40
41
|
const NAMED_FORM = `export const { useTestWorld, useSandbox } = ${DEFINE}({ ... })`;
|
|
42
|
+
const TYPED_FORM = `const world: TestWorldApi = ${DEFINE}({ ... })` + ' with export const useTestWorld / useSandbox typed from it';
|
|
41
43
|
const defaultEnvironment = DEFAULT_ENVIRONMENT;
|
|
42
44
|
const KEBAB = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
43
45
|
const unwrap = (ts, node) => unwrapEach(ts, node, [ts.isParenthesizedExpression, ts.isAsExpression, ts.isSatisfiesExpression]);
|
|
@@ -52,21 +54,38 @@ function defineLiteral(ts, expression) {
|
|
|
52
54
|
const literal = first ? unwrap(ts, first) : null;
|
|
53
55
|
return literal && ts.isObjectLiteralExpression(literal) ? literal : null;
|
|
54
56
|
}
|
|
55
|
-
/**
|
|
57
|
+
/**
|
|
58
|
+
* The declaration of the world: the object literal of a top-level `const` initialized by `defineTestWorld({ ... })`, or null. The config
|
|
59
|
+
* exports `useTestWorld` and `useSandbox` either by destructuring that call (the original form) or, typed, from the world the call
|
|
60
|
+
* built: `const world: TestWorldApi = defineTestWorld({ ... })` with `export const useTestWorld: TestWorldApi["useTestWorld"] = world.useTestWorld`.
|
|
61
|
+
* Both are read; a world no exported name reaches is refused by the caller.
|
|
62
|
+
*/
|
|
56
63
|
function configLiteral(ts, sourceFile) {
|
|
57
64
|
for (const statement of sourceFile.statements) {
|
|
58
|
-
if (!ts.isVariableStatement(statement)
|
|
65
|
+
if (!ts.isVariableStatement(statement))
|
|
59
66
|
continue;
|
|
60
67
|
if (!hasAnyFlag(statement.declarationList.flags, ts.NodeFlags.Const))
|
|
61
68
|
continue;
|
|
62
69
|
for (const declaration of statement.declarationList.declarations) {
|
|
63
70
|
const literal = declaration.initializer ? defineLiteral(ts, declaration.initializer) : null;
|
|
64
|
-
if (literal)
|
|
71
|
+
if (literal && (isExported(ts, statement) || exportsWorld(ts, sourceFile)))
|
|
65
72
|
return literal;
|
|
66
73
|
}
|
|
67
74
|
}
|
|
68
75
|
return null;
|
|
69
76
|
}
|
|
77
|
+
/** Whether the file exports both `useTestWorld` and `useSandbox` as consts (the typed form re-exports them from the built world). */
|
|
78
|
+
function exportsWorld(ts, sourceFile) {
|
|
79
|
+
const exported = new Set();
|
|
80
|
+
for (const statement of sourceFile.statements) {
|
|
81
|
+
if (!ts.isVariableStatement(statement) || !isExported(ts, statement))
|
|
82
|
+
continue;
|
|
83
|
+
for (const declaration of statement.declarationList.declarations)
|
|
84
|
+
if (ts.isIdentifier(declaration.name))
|
|
85
|
+
exported.add(declaration.name.text);
|
|
86
|
+
}
|
|
87
|
+
return exported.has('useTestWorld') && exported.has('useSandbox');
|
|
88
|
+
}
|
|
70
89
|
/** `stack` as the environment it names: the last segment of `".starcistacks/<env>"`; null when absent or not a literal. */
|
|
71
90
|
function environmentOf(ts, literal) {
|
|
72
91
|
const node = literal ? propertyOf(ts, literal, 'stack') : null;
|
|
@@ -152,7 +171,7 @@ function worldDeclaration(graph, ts, worldRoot, violations) {
|
|
|
152
171
|
if (configFile && !literal) {
|
|
153
172
|
violations.push({
|
|
154
173
|
ruleId: RULE, path: configPath, line: 1, column: 1, slot: WORLD_SLOT,
|
|
155
|
-
message: `${configPath} declares no world the machine can read. Declare it in the
|
|
174
|
+
message: `${configPath} declares no world the machine can read. Declare it in the named form \`${NAMED_FORM}\` or the typed form \`${TYPED_FORM}\` (a const of a ${DEFINE} call with an object literal, imported from @starci/test-world); a default export is refused by R89 and is not read.`,
|
|
156
175
|
});
|
|
157
176
|
}
|
|
158
177
|
return { configPath, configFile, literal };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { declarationSurface, isEmittableSource } from '../lib/declaration-surface.mjs';
|
|
2
|
+
/** What to write, by what the classifier says to annotate. */
|
|
3
|
+
const HINTS = {
|
|
4
|
+
'return type': 'Write the return type after the parameter list: `(): Result =>` or `function name(): Result`.',
|
|
5
|
+
'variable type': 'Give the variable a type: `export const name: Result = ...`.',
|
|
6
|
+
'parameter type': 'Give the parameter a type: `(value: Input)`.',
|
|
7
|
+
'property type': 'Give the property a type: `name: Result = ...`.',
|
|
8
|
+
'accessor type': 'Give the accessor a type: `get name(): Result` (a setter takes it from its parameter).',
|
|
9
|
+
'destructured export': 'A destructuring pattern cannot carry a type per name: export each name as its own variable with a type, `export const name: Type = source.name`.',
|
|
10
|
+
'exported value': 'Declare the type (an interface or a type alias) and annotate the export with it: `export const name: Shape = ...`.',
|
|
11
|
+
};
|
|
12
|
+
/** The `--isolatedDeclarations` flag name TypeScript ends its reasons with says nothing to the reader. */
|
|
13
|
+
const plain = (reason) => reason.replace(/ with --isolatedDeclarations\.?/, '').replace(/\.$/, '');
|
|
14
|
+
/**
|
|
15
|
+
* The rule. `ts` is the canon's own TypeScript (its peer dependency); the side's `module` option comes from the typed program when the file is linted with one.
|
|
16
|
+
*
|
|
17
|
+
* @param input - `ts` is the compiler.
|
|
18
|
+
* @returns An ESLint rule reporting each export of a TypeScript source whose type is inferred.
|
|
19
|
+
*/
|
|
20
|
+
export const explicitSurfaceRule = ({ ts }) => ({
|
|
21
|
+
meta: {
|
|
22
|
+
type: 'problem',
|
|
23
|
+
docs: { description: 'An exported declaration states its type: the return type, variable type or parameter type is written, not inferred from a body or from other files.' },
|
|
24
|
+
schema: [],
|
|
25
|
+
messages: {
|
|
26
|
+
inferred: 'HFS_EXPLICIT_EXPORT_SURFACE: `{{name}}` is exported with an inferred type ({{reason}}), so importers cannot read it from this file and every one of them is linted again whenever the file changes. Annotate the {{annotate}}. {{hint}}',
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
create(context) {
|
|
30
|
+
return {
|
|
31
|
+
Program() {
|
|
32
|
+
const file = context.filename;
|
|
33
|
+
if (!isEmittableSource(file))
|
|
34
|
+
return;
|
|
35
|
+
const { sourceCode } = context;
|
|
36
|
+
const module = sourceCode.parserServices?.program?.getCompilerOptions().module;
|
|
37
|
+
const whole = { start: { line: 1, column: 0 }, end: { line: 1, column: 0 } };
|
|
38
|
+
let surface;
|
|
39
|
+
try {
|
|
40
|
+
surface = declarationSurface(ts, file, sourceCode.text, module);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
surface = null;
|
|
44
|
+
}
|
|
45
|
+
if (surface === null) {
|
|
46
|
+
context.report({ loc: whole, messageId: 'inferred', data: { name: 'this file', reason: 'the declaration emit could not read it', annotate: 'exported value', hint: HINTS['exported value'] } });
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
for (const problem of surface.problems) {
|
|
50
|
+
context.report({
|
|
51
|
+
loc: { start: sourceCode.getLocFromIndex(problem.start), end: sourceCode.getLocFromIndex(problem.start + problem.length) },
|
|
52
|
+
messageId: 'inferred',
|
|
53
|
+
data: { name: problem.name, reason: plain(problem.reason), annotate: problem.annotate, hint: HINTS[problem.annotate] },
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
});
|
|
@@ -3,8 +3,21 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
/** Files above this size are not read by a tree check (a tracked binary or a generated dump is no source of these laws). */
|
|
5
5
|
const MAX_READ_BYTES = 1024 * 1024;
|
|
6
|
+
let sink = null;
|
|
7
|
+
/** Runs `run` with every `readText` / `readJson` it makes reported to `onRead` (before the read), and returns its value. */
|
|
8
|
+
export function recordReads(onRead, run) {
|
|
9
|
+
const before = sink;
|
|
10
|
+
sink = onRead;
|
|
11
|
+
try {
|
|
12
|
+
return run();
|
|
13
|
+
}
|
|
14
|
+
finally {
|
|
15
|
+
sink = before;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
6
18
|
/** The text of `rel` under `repoRoot`, or null when it is absent, unreadable, over MAX_READ_BYTES or binary. */
|
|
7
19
|
export function readText(repoRoot, rel) {
|
|
20
|
+
sink?.(repoRoot, rel);
|
|
8
21
|
try {
|
|
9
22
|
const target = path.join(repoRoot, rel);
|
|
10
23
|
if (fs.statSync(target).size > MAX_READ_BYTES)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/** A script file (`.js`, `.mjs`, `.cjs`, `.jsx`): it has no declaration emit and stands for its content. */
|
|
2
|
+
export const SCRIPT = /\.(?:[cm]?jsx?)$/;
|
|
3
|
+
/** A declaration file: it is its own surface. */
|
|
4
|
+
export const DECLARATION = /\.d\.[cm]?ts$/;
|
|
5
|
+
/** A TypeScript source file whose surface the declaration emit writes. */
|
|
6
|
+
export const TYPESCRIPT = /\.(?:[cm]?tsx?)$/;
|
|
7
|
+
/** Whether `file` is a TypeScript source (not a declaration file) the declaration emit reads. */
|
|
8
|
+
export const isEmittableSource = (file) => TYPESCRIPT.test(file) && !DECLARATION.test(file);
|
|
9
|
+
/** Whether `expression` is a JSX element, fragment, `null` or `undefined`, or a conditional of those: its type does not depend on any other file. */
|
|
10
|
+
function isJsxValue(ts, expression) {
|
|
11
|
+
if (expression === undefined)
|
|
12
|
+
return false;
|
|
13
|
+
let node = expression;
|
|
14
|
+
while (ts.isParenthesizedExpression(node))
|
|
15
|
+
node = node.expression;
|
|
16
|
+
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node) || node.kind === ts.SyntaxKind.NullKeyword)
|
|
17
|
+
return true;
|
|
18
|
+
if (ts.isIdentifier(node))
|
|
19
|
+
return node.text === 'undefined';
|
|
20
|
+
return ts.isConditionalExpression(node) && isJsxValue(ts, node.whenTrue) && isJsxValue(ts, node.whenFalse);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Whether the function (or method) the declaration diagnostic at `start` points at has the type `JSX.Element | null | undefined` whatever its body
|
|
24
|
+
* 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
|
|
25
|
+
* 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.
|
|
26
|
+
*/
|
|
27
|
+
function returnsOnlyJsx(ts, source, start) {
|
|
28
|
+
let found;
|
|
29
|
+
const find = (node) => {
|
|
30
|
+
if (found !== undefined || start < node.getFullStart() || start >= node.end)
|
|
31
|
+
return;
|
|
32
|
+
const named = (ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node)) && node.name !== undefined && node.name.getStart(source) === start;
|
|
33
|
+
if (named || ((ts.isArrowFunction(node) || ts.isFunctionExpression(node)) && node.getStart(source) === start)) {
|
|
34
|
+
found = node;
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
ts.forEachChild(node, find);
|
|
38
|
+
};
|
|
39
|
+
find(source);
|
|
40
|
+
const fn = found;
|
|
41
|
+
if (fn === undefined || fn.type !== undefined || fn.body === undefined)
|
|
42
|
+
return false;
|
|
43
|
+
if (fn.asteriskToken !== undefined || (ts.getCombinedModifierFlags(fn) & ts.ModifierFlags.Async) !== 0)
|
|
44
|
+
return false;
|
|
45
|
+
if (!ts.isBlock(fn.body))
|
|
46
|
+
return isJsxValue(ts, fn.body);
|
|
47
|
+
let only = true;
|
|
48
|
+
const visit = (node) => {
|
|
49
|
+
if (!only || ts.isFunctionLike(node) || ts.isClassLike(node))
|
|
50
|
+
return;
|
|
51
|
+
if (ts.isReturnStatement(node) && node.expression !== undefined && !isJsxValue(ts, node.expression))
|
|
52
|
+
only = false;
|
|
53
|
+
ts.forEachChild(node, visit);
|
|
54
|
+
};
|
|
55
|
+
ts.forEachChild(fn.body, visit);
|
|
56
|
+
return only;
|
|
57
|
+
}
|
|
58
|
+
/** What to annotate for each isolated-declaration diagnostic code; any other code asks for the exported value's type. */
|
|
59
|
+
const ANNOTATE = new Map([
|
|
60
|
+
[9007, 'return type'], [9008, 'return type'], [9009, 'accessor type'], [9010, 'variable type'], [9011, 'parameter type'], [9012, 'property type'],
|
|
61
|
+
[9019, 'destructured export'], [9025, 'parameter type'],
|
|
62
|
+
]);
|
|
63
|
+
/** The declaration kinds whose name labels the export a diagnostic sits in, innermost first. */
|
|
64
|
+
function nameOf(ts, source, start) {
|
|
65
|
+
let innermost = source;
|
|
66
|
+
const descend = (node) => {
|
|
67
|
+
if (start < node.getFullStart() || start >= node.end)
|
|
68
|
+
return;
|
|
69
|
+
innermost = node;
|
|
70
|
+
ts.forEachChild(node, descend);
|
|
71
|
+
};
|
|
72
|
+
ts.forEachChild(source, descend);
|
|
73
|
+
for (let node = innermost; node !== undefined && node !== source; node = node.parent) {
|
|
74
|
+
if (ts.isExportAssignment(node))
|
|
75
|
+
return 'default export';
|
|
76
|
+
if (ts.isBindingElement(node) && ts.isIdentifier(node.name))
|
|
77
|
+
return node.name.text;
|
|
78
|
+
if ((ts.isVariableDeclaration(node) || ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node) || ts.isMethodDeclaration(node) || ts.isPropertyDeclaration(node) || ts.isPropertyAssignment(node) || ts.isGetAccessorDeclaration(node)) && node.name !== undefined && ts.isIdentifier(node.name))
|
|
79
|
+
return node.name.text;
|
|
80
|
+
if (ts.isFunctionDeclaration(node) && (ts.getCombinedModifierFlags(node) & ts.ModifierFlags.Default) !== 0)
|
|
81
|
+
return 'default export';
|
|
82
|
+
}
|
|
83
|
+
return 'default export';
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The declaration emit of one TypeScript source and the exports it could not write explicitly (a type inferred from bodies and other files), or null
|
|
87
|
+
* when the compiler has no `transpileDeclaration` (before TypeScript 5.5). A declaration whose only diagnostics are components returning JSX has the
|
|
88
|
+
* surface its emit shows. `module` is the side's `module` compiler option, which the emit reads. A throw from the compiler propagates: the caller
|
|
89
|
+
* decides what an unreadable file means.
|
|
90
|
+
*/
|
|
91
|
+
export function declarationSurface(ts, file, text, module) {
|
|
92
|
+
const emit = ts.transpileDeclaration;
|
|
93
|
+
if (emit === undefined)
|
|
94
|
+
return null;
|
|
95
|
+
const declared = emit.call(ts, text, { compilerOptions: { jsx: ts.JsxEmit.Preserve, target: ts.ScriptTarget.Latest, ...(module === undefined ? {} : { module }) }, fileName: file, reportDiagnostics: true });
|
|
96
|
+
const diagnostics = (declared.diagnostics ?? []);
|
|
97
|
+
if (diagnostics.length === 0)
|
|
98
|
+
return { outputText: declared.outputText, problems: [] };
|
|
99
|
+
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, file.endsWith('x') ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
|
|
100
|
+
const problems = [];
|
|
101
|
+
for (const diagnostic of diagnostics) {
|
|
102
|
+
const exempt = (diagnostic.code === 9007 || diagnostic.code === 9008) && diagnostic.start !== undefined && returnsOnlyJsx(ts, source, diagnostic.start);
|
|
103
|
+
if (exempt)
|
|
104
|
+
continue;
|
|
105
|
+
const start = diagnostic.start ?? 0;
|
|
106
|
+
problems.push({
|
|
107
|
+
code: diagnostic.code, start, length: diagnostic.length ?? 0, name: nameOf(ts, source, start), annotate: ANNOTATE.get(diagnostic.code) ?? 'exported value',
|
|
108
|
+
reason: ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n').split('\n')[0] ?? '',
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return { outputText: declared.outputText, problems };
|
|
112
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// sonar-syntax-rules.mjs - the ESLint rules that keep a TypeScript app clear of the smells SonarCloud flagged in the example apps, judged on
|
|
2
2
|
// the syntax tree alone (no type information, no dependency). Both lint canons (@starci/eslint-canon-be and -fe) publish them from their
|
|
3
|
-
// bundled runtime copy under the names below, so a product inherits them and `starci app lint` refuses the finding before the scan sees it.
|
|
3
|
+
// bundled runtime copy under the names below, so a product inherits them and `starci app check --gate lint` refuses the finding before the scan sees it.
|
|
4
4
|
// Each message names the Sonar rule it stands for, and a rule judges the files the example scans judge: a unit or end-to-end spec is outside the
|
|
5
5
|
// scan (`sonar.exclusions`), so it is outside these rules. The rules that need the type checker (S1874, S6551, S7503) are borrowed from
|
|
6
6
|
// typescript-eslint by the canon factories; the one that needs a regex ambiguity analysis (S8786) runs in the runtime's own `sonar-rules` check.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// sonar-way.mjs - the Sonar way TypeScript profile (the default quality profile sonar.starci.org and SonarCloud apply) as ESLint rules:
|
|
2
|
-
// the one mapping both lint canons (@starci/eslint-canon-be and -fe) publish, so `starci app lint` in the op loop refuses what the scan would
|
|
2
|
+
// the one mapping both lint canons (@starci/eslint-canon-be and -fe) publish, so `starci app check --gate lint` in the op loop refuses what the scan would
|
|
3
3
|
// raise while the code is written. The base is eslint-plugin-sonarjs's recommended config, SonarSource's own port of Sonar way (each rule's
|
|
4
4
|
// docs url names its S-id); its rules the canons already publish in another form stay with that owner (SONAR_WAY_OWNED), and the Sonar way
|
|
5
5
|
// rules SonarSource builds on other ESLint rules are SONAR_WAY_EXTRA. Every rule is `error`; no rule is left to the server because its port
|