@starci/eslint-canon-fe 9.2.0 → 9.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +5 -1
  3. package/dist/brand-values.d.ts +28 -0
  4. package/dist/brand-values.js +172 -0
  5. package/dist/button-intent.d.ts +11 -0
  6. package/dist/button-intent.js +361 -0
  7. package/dist/callback-shape.d.ts +7 -0
  8. package/dist/callback-shape.js +396 -0
  9. package/dist/class-names.d.ts +19 -0
  10. package/dist/class-names.js +170 -0
  11. package/dist/client-boundary.d.ts +42 -0
  12. package/dist/client-boundary.js +404 -0
  13. package/dist/comments.d.ts +47 -0
  14. package/dist/comments.js +156 -0
  15. package/dist/control-height.d.ts +7 -0
  16. package/dist/control-height.js +83 -0
  17. package/dist/drawing.d.ts +31 -0
  18. package/dist/drawing.js +376 -0
  19. package/dist/env-owner.d.ts +22 -0
  20. package/dist/env-owner.js +291 -0
  21. package/dist/fe-stack.d.ts +10 -0
  22. package/dist/fe-stack.js +73 -0
  23. package/dist/file-layout.d.ts +71 -0
  24. package/dist/file-layout.js +465 -0
  25. package/dist/formatting.d.ts +25 -0
  26. package/dist/formatting.js +104 -0
  27. package/dist/function-budget.d.ts +9 -0
  28. package/dist/function-budget.js +48 -0
  29. package/dist/grammar-shim.d.ts +5 -0
  30. package/dist/grammar-shim.js +126 -0
  31. package/dist/hooks-folder.d.ts +34 -0
  32. package/dist/hooks-folder.js +320 -0
  33. package/dist/hygiene.d.ts +39 -0
  34. package/dist/hygiene.js +540 -0
  35. package/dist/index.d.ts +105 -0
  36. package/dist/index.js +310 -0
  37. package/{landmark.mjs → dist/landmark.d.ts} +4 -6
  38. package/dist/landmark.js +6 -0
  39. package/dist/lib/ast.d.ts +36 -0
  40. package/dist/lib/ast.js +94 -0
  41. package/dist/lib/bindings.d.ts +84 -0
  42. package/dist/lib/bindings.js +168 -0
  43. package/dist/lib/class-text.d.ts +3 -0
  44. package/dist/lib/class-text.js +30 -0
  45. package/dist/lib/config.d.ts +81 -0
  46. package/dist/lib/config.js +141 -0
  47. package/dist/lib/drawing-scope.d.ts +5 -0
  48. package/{lib/drawing-scope.mjs → dist/lib/drawing-scope.js} +6 -11
  49. package/dist/lib/eslint-boundary.d.ts +23 -0
  50. package/dist/lib/eslint-boundary.js +6 -0
  51. package/dist/lib/fe-stack.d.ts +49 -0
  52. package/dist/lib/fe-stack.js +98 -0
  53. package/dist/lib/hfs-view.d.ts +74 -0
  54. package/dist/lib/hfs-view.js +6 -0
  55. package/dist/lib/hfs.d.ts +34 -0
  56. package/dist/lib/hfs.js +72 -0
  57. package/dist/lib/next.d.ts +4 -0
  58. package/dist/lib/next.js +10 -0
  59. package/dist/lib/own-hook-origin.d.ts +17 -0
  60. package/dist/lib/own-hook-origin.js +52 -0
  61. package/dist/lib/package-root.d.ts +4 -0
  62. package/dist/lib/package-root.js +10 -0
  63. package/dist/lib/path.d.ts +2 -0
  64. package/{lib/path.mjs → dist/lib/path.js} +1 -1
  65. package/dist/lib/platform.d.ts +26 -0
  66. package/{lib/platform.mjs → dist/lib/platform.js} +38 -35
  67. package/dist/lib/program-for.d.ts +5 -0
  68. package/dist/lib/program-for.js +23 -0
  69. package/dist/lib/pure-owner-helper.d.ts +10 -0
  70. package/dist/lib/pure-owner-helper.js +156 -0
  71. package/dist/lib/react.d.ts +16 -0
  72. package/dist/lib/react.js +22 -0
  73. package/dist/lib/redux-hook-origin.d.ts +4 -0
  74. package/dist/lib/redux-hook-origin.js +50 -0
  75. package/dist/lib/rule.d.ts +28 -0
  76. package/dist/lib/rule.js +5 -0
  77. package/dist/lib/scope.d.ts +88 -0
  78. package/{lib/scope.mjs → dist/lib/scope.js} +83 -93
  79. package/dist/lib/typed-class-values.d.ts +13 -0
  80. package/dist/lib/typed-class-values.js +92 -0
  81. package/dist/lib/types.d.ts +44 -0
  82. package/dist/lib/types.js +64 -0
  83. package/dist/lib/why.d.ts +350 -0
  84. package/dist/lib/why.js +350 -0
  85. package/dist/lint-escape-hatch.d.ts +24 -0
  86. package/{lint-escape-hatch.mjs → dist/lint-escape-hatch.js} +38 -45
  87. package/dist/lists.d.ts +31 -0
  88. package/dist/lists.js +202 -0
  89. package/dist/loading.d.ts +34 -0
  90. package/dist/loading.js +157 -0
  91. package/dist/naming.d.ts +33 -0
  92. package/dist/naming.js +176 -0
  93. package/dist/native-controls.d.ts +24 -0
  94. package/dist/native-controls.js +105 -0
  95. package/dist/next-conventions.d.ts +48 -0
  96. package/dist/next-conventions.js +452 -0
  97. package/dist/project-graph.d.ts +5 -0
  98. package/{project-graph.mjs → dist/project-graph.js} +8 -12
  99. package/dist/props-and-slots.d.ts +19 -0
  100. package/dist/props-and-slots.js +320 -0
  101. package/dist/served-locale.d.ts +38 -0
  102. package/dist/served-locale.js +263 -0
  103. package/dist/shape-slot.d.ts +18 -0
  104. package/dist/shape-slot.js +324 -0
  105. package/dist/size-and-state-budget.d.ts +46 -0
  106. package/dist/size-and-state-budget.js +424 -0
  107. package/dist/size-growth.d.ts +24 -0
  108. package/dist/size-growth.js +64 -0
  109. package/dist/sonar-parity.d.ts +5 -0
  110. package/{sonar-parity.mjs → dist/sonar-parity.js} +11 -7
  111. package/dist/surface-rules.d.ts +18 -0
  112. package/dist/surface-rules.js +491 -0
  113. package/dist/swr-family.d.ts +15 -0
  114. package/dist/swr-family.js +419 -0
  115. package/dist/the-split.d.ts +27 -0
  116. package/dist/the-split.js +157 -0
  117. package/dist/tokens.d.ts +9 -0
  118. package/dist/tokens.js +35 -0
  119. package/dist/translation.d.ts +51 -0
  120. package/dist/translation.js +403 -0
  121. package/dist/transport.d.ts +73 -0
  122. package/dist/transport.js +885 -0
  123. package/dist/type-safety.d.ts +43 -0
  124. package/dist/type-safety.js +139 -0
  125. package/dist/ui-rulings.d.ts +11 -0
  126. package/dist/ui-rulings.js +121 -0
  127. package/package.json +31 -9
  128. package/runtime/knowledge/hfs/canon-pins.yaml +22 -14
  129. package/runtime/scripts/lib/package-at.mjs +5 -5
  130. package/brand-values.mjs +0 -172
  131. package/button-intent.mjs +0 -320
  132. package/callback-shape.mjs +0 -332
  133. package/class-names.mjs +0 -149
  134. package/client-boundary.mjs +0 -400
  135. package/comments.mjs +0 -159
  136. package/control-height.mjs +0 -77
  137. package/drawing.mjs +0 -315
  138. package/env-owner.mjs +0 -244
  139. package/fe-stack.mjs +0 -86
  140. package/file-layout.mjs +0 -472
  141. package/formatting.mjs +0 -110
  142. package/function-budget.mjs +0 -49
  143. package/grammar-shim.mjs +0 -123
  144. package/hooks-folder.mjs +0 -300
  145. package/hygiene.mjs +0 -513
  146. package/index.mjs +0 -317
  147. package/lib/ast.mjs +0 -97
  148. package/lib/bindings.mjs +0 -159
  149. package/lib/class-text.mjs +0 -29
  150. package/lib/config.mjs +0 -142
  151. package/lib/fe-stack.mjs +0 -77
  152. package/lib/hfs.mjs +0 -49
  153. package/lib/next.mjs +0 -6
  154. package/lib/own-hook-origin.mjs +0 -53
  155. package/lib/program-for.mjs +0 -22
  156. package/lib/pure-owner-helper.mjs +0 -148
  157. package/lib/react.mjs +0 -28
  158. package/lib/redux-hook-origin.mjs +0 -44
  159. package/lib/typed-class-values.mjs +0 -81
  160. package/lib/types.mjs +0 -63
  161. package/lib/why.mjs +0 -352
  162. package/lists.mjs +0 -193
  163. package/loading.mjs +0 -156
  164. package/naming.mjs +0 -185
  165. package/native-controls.mjs +0 -108
  166. package/next-conventions.mjs +0 -447
  167. package/props-and-slots.mjs +0 -276
  168. package/served-locale.mjs +0 -243
  169. package/size-and-state-budget.mjs +0 -376
  170. package/size-growth.mjs +0 -62
  171. package/surface-rules.mjs +0 -443
  172. package/swr-family.mjs +0 -326
  173. package/the-split.mjs +0 -155
  174. package/tokens.mjs +0 -39
  175. package/translation.mjs +0 -392
  176. package/transport.mjs +0 -830
  177. package/type-safety.mjs +0 -147
  178. package/ui-rulings.mjs +0 -134
@@ -0,0 +1,16 @@
1
+ /**
2
+ * React hooks of the front-end canon, decided by the import: a call is `useEffect` when its callee is the `useEffect`
3
+ * export of `react` (named import, alias, default or namespace member), not when something is spelled that way.
4
+ */
5
+ import type { TSESTree } from "@typescript-eslint/utils";
6
+ import type { AnyContext } from "./rule.js";
7
+ /** The hooks that run work after render and may return its cleanup. */
8
+ export declare const EFFECT_HOOKS: Set<string>;
9
+ /**
10
+ * The React export a call invokes, or null when the callee is not an export of `react`.
11
+ *
12
+ * @param context - The ESLint rule context.
13
+ * @param call - An ESTree call expression.
14
+ * @returns The export name (`useEffect`, `useSyncExternalStore`, ...).
15
+ */
16
+ export declare const reactHookOf: (context: AnyContext, call: TSESTree.CallExpression) => string | null;
@@ -0,0 +1,22 @@
1
+ import { importOf, unwrap } from "./bindings.js";
2
+ /** The hooks that run work after render and may return its cleanup. */
3
+ export const EFFECT_HOOKS = new Set(["useEffect", "useLayoutEffect"]);
4
+ /**
5
+ * The React export a call invokes, or null when the callee is not an export of `react`.
6
+ *
7
+ * @param context - The ESLint rule context.
8
+ * @param call - An ESTree call expression.
9
+ * @returns The export name (`useEffect`, `useSyncExternalStore`, ...).
10
+ */
11
+ export const reactHookOf = (context, call) => {
12
+ const callee = unwrap(call.callee);
13
+ if (callee.type === "Identifier") {
14
+ const from = importOf(context, callee);
15
+ return from && from.source === "react" && from.imported ? from.imported : null;
16
+ }
17
+ if (callee.type === "MemberExpression" && !callee.computed && callee.object.type === "Identifier" && callee.property.type === "Identifier") {
18
+ const from = importOf(context, callee.object);
19
+ return from && from.source === "react" && from.imported === null ? callee.property.name : null;
20
+ }
21
+ return null;
22
+ };
@@ -0,0 +1,4 @@
1
+ import type { ImportBindingDefinition } from "./own-hook-origin.js";
2
+ import type { AnyContext } from "./rule.js";
3
+ /** A relative/barrel import must resolve to a const initialized by a real Redux hook's withTypes(). */
4
+ export declare function importedReduxHook(context: AnyContext, definition: ImportBindingDefinition): boolean;
@@ -0,0 +1,50 @@
1
+ /** Resolve typed Redux hook factories by their import provenance, never by a local use* name. */
2
+ import ts from "typescript";
3
+ import { programFor } from "./program-for.js";
4
+ const REDUX_HOOKS = new Set(["useSelector", "useDispatch", "useStore"]);
5
+ function reduxImport(declaration) {
6
+ if (!ts.isImportSpecifier(declaration) || declaration.isTypeOnly)
7
+ return false;
8
+ const clause = declaration.parent.parent;
9
+ const specifier = clause.parent.moduleSpecifier;
10
+ return !clause.isTypeOnly && ts.isStringLiteral(specifier) && specifier.text === "react-redux"
11
+ && REDUX_HOOKS.has((declaration.propertyName ?? declaration.name).text);
12
+ }
13
+ function typedFactory(checker, declaration) {
14
+ if (!ts.isVariableDeclaration(declaration) || (declaration.parent.flags & ts.NodeFlags.Const) === 0)
15
+ return false;
16
+ const call = declaration.initializer;
17
+ if (!call || !ts.isCallExpression(call) || call.arguments.length !== 0)
18
+ return false;
19
+ const member = call.expression;
20
+ return ts.isPropertyAccessExpression(member) && member.name.text === "withTypes"
21
+ && reduxBinding(checker, member.expression);
22
+ }
23
+ function reduxBinding(checker, node) {
24
+ const symbol = checker.getSymbolAtLocation(node);
25
+ return (symbol?.declarations ?? []).some(reduxImport);
26
+ }
27
+ /** A relative/barrel import must resolve to a const initialized by a real Redux hook's withTypes(). */
28
+ export function importedReduxHook(context, definition) {
29
+ const specifier = definition.node;
30
+ const parent = definition.parent;
31
+ if (specifier.type !== "ImportSpecifier" || parent.importKind === "type" || specifier.importKind === "type")
32
+ return false;
33
+ // an import specifier always sits in an import declaration
34
+ if (parent.type !== "ImportDeclaration")
35
+ return false;
36
+ const source = String(parent.source.value);
37
+ const name = specifier.imported.type === "Identifier" ? specifier.imported.name : specifier.imported.value;
38
+ if (source === "react-redux")
39
+ return REDUX_HOOKS.has(name);
40
+ const program = programFor(context);
41
+ const target = ts.resolveModuleName(source, context.filename, program.getCompilerOptions(), ts.sys).resolvedModule;
42
+ const file = target && program.getSourceFile(target.resolvedFileName);
43
+ const checker = program.getTypeChecker();
44
+ const moduleSymbol = file && checker.getSymbolAtLocation(file);
45
+ const exported = moduleSymbol ? checker.getExportsOfModule(moduleSymbol).find(symbol => symbol.name === name) : undefined;
46
+ if (!exported)
47
+ return false;
48
+ const symbol = (exported.flags & ts.SymbolFlags.Alias) !== 0 ? checker.getAliasedSymbol(exported) : exported;
49
+ return (symbol.declarations ?? []).some(declaration => typedFactory(checker, declaration));
50
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The types every law of the canon is written in: the typescript-eslint AST (JSX and TypeScript nodes included, which
3
+ * ESLint's own ESTree types lack), its rule module, context and listener, and the shape a law module exports.
4
+ */
5
+ import type { TSESLint, TSESTree } from "@typescript-eslint/utils";
6
+ /** Any node the parser produces. */
7
+ export type Node = TSESTree.Node;
8
+ /** A rule module with message ids `M` and options `O`. */
9
+ export type Rule<M extends string = string, O extends readonly unknown[] = readonly unknown[]> = TSESLint.RuleModule<M, O>;
10
+ /** The context a rule's `create` receives. */
11
+ export type Context<M extends string = string, O extends readonly unknown[] = readonly unknown[]> = Readonly<TSESLint.RuleContext<M, O>>;
12
+ /** The context of any rule, whatever its messages: what the shared readers take. */
13
+ export type AnyContext = Readonly<TSESLint.RuleContext<string, readonly unknown[]>>;
14
+ /** The source code a context reads. */
15
+ export type SourceCode = Readonly<TSESLint.SourceCode>;
16
+ /** The visitor a rule returns. */
17
+ export type Listener = TSESLint.RuleListener;
18
+ /** A comment of the source. */
19
+ export type Comment = TSESTree.Comment;
20
+ /** The rules a law publishes, by name. */
21
+ export type Rules = Readonly<Record<string, Rule<string, readonly unknown[]>>>;
22
+ /** The level a law asks for each of its rules, by `starci-fe/<name>`. */
23
+ export type Recommended = Readonly<Record<string, "error">>;
24
+ /**
25
+ * A rule with its message ids and options inferred from the literal: `defineRule({ meta: { messages: { a: "..." } }, ... })`
26
+ * types `context.report({ messageId })` against `a`. It returns the object it is given.
27
+ */
28
+ export declare const defineRule: <M extends string, O extends readonly unknown[] = []>(rule: Rule<M, O>) => Rule<M, O>;
@@ -0,0 +1,5 @@
1
+ /**
2
+ * A rule with its message ids and options inferred from the literal: `defineRule({ meta: { messages: { a: "..." } }, ... })`
3
+ * types `context.report({ messageId })` against `a`. It returns the object it is given.
4
+ */
5
+ export const defineRule = (rule) => rule;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Path predicates shared by the laws that scope themselves by where a file sits.
3
+ *
4
+ * ONE PLACE, because a scope written twice drifts: a rule that thinks `modules/config` is one
5
+ * spelling and a sibling that thinks it is another disagree about which file may read the
6
+ * environment, and the disagreement shows up as a finding nobody can satisfy.
7
+ */
8
+ import type { TSESTree } from "@typescript-eslint/utils";
9
+ import type { HfsClassification } from "./hfs-view.js";
10
+ import type { AnyContext } from "./rule.js";
11
+ /**
12
+ * The HFS slot id of the file being linted, read from the slot view (`settings.starci.hfs`).
13
+ *
14
+ * @param {object} context - The ESLint rule context.
15
+ * @returns {string | null} The slot id, or null when no slot owns the file.
16
+ */
17
+ export declare const slotOfFile: (context: AnyContext) => string | null;
18
+ /**
19
+ * True when the file being linted sits in one of the given slots.
20
+ *
21
+ * @param {object} context - The ESLint rule context.
22
+ * @param {...string} ids - Slot ids.
23
+ * @returns {boolean} Whether the file's slot is one of them.
24
+ */
25
+ export declare const inSlot: (context: AnyContext, ...ids: readonly string[]) => boolean;
26
+ /** The filename a rule is linting. */
27
+ export declare const fileOf: (context: AnyContext) => string;
28
+ /** The classification of the linted file from the slot view: `{ slot, root, bindings, kind?, role? }` or a no-slot status. */
29
+ export declare const classOf: (context: AnyContext) => HfsClassification;
30
+ /** The tier of the linted file in the import direction matrix (`route`, `feature`, `components`, `hooks`, `foundation`, `modules`, `transport`, `package`, `none`), or null when no slot owns it. */
31
+ export declare const tierOfFile: (context: AnyContext) => string | null;
32
+ /** The folder kind the slot reports for the linted file (a component layer, a feature kind), or null. */
33
+ export declare const kindOfFile: (context: AnyContext) => string | null;
34
+ /** The role the slot reports for the linted file (`entry`, `drawing`, `styles`, `shared`, `page`, `layout`, ...), or null. */
35
+ export declare const roleOfFile: (context: AnyContext) => string | null;
36
+ /** Product source: a file of a product tier. */
37
+ export declare const isProductSource: (context: AnyContext) => boolean;
38
+ /** A file of a component tier: slot `fe.components` or `fe.package.ui`, inside one of its layer folders (blocks, composites, branches, leaves). */
39
+ export declare const isComponentFile: (context: AnyContext) => boolean;
40
+ /** True when the given file (an absolute or repo-relative name, e.g. where an import resolves to) sits in a component layer of slot `fe.components` or `fe.package.ui`. */
41
+ export declare const isComponentPath: (context: AnyContext, file: string) => boolean;
42
+ /** The one place that may read the environment: slot `fe.modules.config`. */
43
+ export declare const isConfigModule: (context: AnyContext) => boolean;
44
+ /**
45
+ * The transport owner: the folder of the auth and no-auth Apollo clients and their link chain, slot `fe.transport.client`
46
+ * (`modules/api/graphql/clients/`, one-app repository) or `fe.package.api.client` (`src/graphql/clients/` of the shared api package).
47
+ */
48
+ export declare const isApiClient: (context: AnyContext) => boolean;
49
+ /** True when the linted file sits in the API layer: the only place that talks to the back end (Apollo, axios, `fetch`). */
50
+ export declare const isApiLayer: (context: AnyContext) => boolean;
51
+ /**
52
+ * True when the linted file is a REST call of the API layer: `modules/api/rest/**` of an app (slot `fe.modules.api`) or
53
+ * `src/rest/**` of the shared api package (slot `fe.package.api`), the one home of axios.
54
+ */
55
+ export declare const isApiRest: (context: AnyContext) => boolean;
56
+ /** The file that may declare the one result vocabulary (`GraphQLResponse`): the file of the slot the manifest marks `outcomeHome`. */
57
+ export declare const isOutcomeModule: (context: AnyContext) => boolean;
58
+ /** The basename of a path without directories. */
59
+ export declare const baseName: (filename: string) => string;
60
+ /** The file name with its extension removed. */
61
+ export declare const stem: (filename: string) => string;
62
+ /**
63
+ * The references in the file that resolve to no declaration of the file, i.e. to a global (`fetch`, `Request`, `window`).
64
+ * An import, a parameter, a local `const fetch` binds the name and is not one; a type-position use is not a value reference.
65
+ *
66
+ * @param {object} context - The ESLint rule context.
67
+ * @param {Set<string>} names - The global names to look for.
68
+ * @returns {Array<object>} The ESTree identifiers that reference such a global as a value.
69
+ */
70
+ export declare const globalReferences: (context: AnyContext, names: ReadonlySet<string>) => (TSESTree.Identifier | TSESTree.JSXIdentifier)[];
71
+ /**
72
+ * Where an import specifier of the linted file points, as an absolute path without an extension: a relative specifier, or `@/`
73
+ * from the owning app's `src/` (the app directory is the directory part of slot `fe.app.next`). Null for a package or a file no app owns.
74
+ *
75
+ * @param {object} context - The ESLint rule context.
76
+ * @param {string} specifier - The module specifier.
77
+ * @returns {string | null} The absolute path, or null.
78
+ */
79
+ export declare const resolveImport: (context: AnyContext, specifier: string) => string | null;
80
+ /**
81
+ * The classification of an existing module file, resolving explicit files before extension and directory-index candidates.
82
+ * Virtual rule inputs with no existing candidate retain the path-only slot classification.
83
+ *
84
+ * @param {object} context - The ESLint rule context.
85
+ * @param {string} specifier - The module specifier.
86
+ * @returns {object | null} The resolved file's owned slot classification, or the virtual-input classification when no file exists.
87
+ */
88
+ export declare const classifyImport: (context: AnyContext, specifier: string) => HfsClassification | null;
@@ -5,20 +5,17 @@
5
5
  * spelling and a sibling that thinks it is another disagree about which file may read the
6
6
  * environment, and the disagreement shows up as a finding nobody can satisfy.
7
7
  */
8
-
9
- import { dirname, join, resolve } from "node:path"
10
- import ts from "typescript"
11
- import { hfsOf } from "./hfs.mjs"
12
- import { normalizePath } from "./path.mjs"
13
-
8
+ import { dirname, join, resolve } from "node:path";
9
+ import ts from "typescript";
10
+ import { hfsOf, requiredRoot, requiredSlot } from "./hfs.js";
11
+ import { normalizePath } from "./path.js";
14
12
  /**
15
13
  * The HFS slot id of the file being linted, read from the slot view (`settings.starci.hfs`).
16
14
  *
17
15
  * @param {object} context - The ESLint rule context.
18
16
  * @returns {string | null} The slot id, or null when no slot owns the file.
19
17
  */
20
- export const slotOfFile = (context) => hfsOf(context).slotOf(context.filename || context.getFilename())
21
-
18
+ export const slotOfFile = (context) => hfsOf(context).slotOf(context.filename || context.getFilename());
22
19
  /**
23
20
  * True when the file being linted sits in one of the given slots.
24
21
  *
@@ -26,78 +23,64 @@ export const slotOfFile = (context) => hfsOf(context).slotOf(context.filename ||
26
23
  * @param {...string} ids - Slot ids.
27
24
  * @returns {boolean} Whether the file's slot is one of them.
28
25
  */
29
- export const inSlot = (context, ...ids) => ids.includes(slotOfFile(context))
30
-
26
+ export const inSlot = (context, ...ids) => {
27
+ const slot = slotOfFile(context);
28
+ return slot !== null && ids.includes(slot);
29
+ };
31
30
  /** The filename a rule is linting. */
32
- export const fileOf = (context) => context.filename || context.getFilename()
33
-
31
+ export const fileOf = (context) => context.filename || context.getFilename();
34
32
  /** The classification of the linted file from the slot view: `{ slot, root, bindings, kind?, role? }` or a no-slot status. */
35
- export const classOf = (context) => hfsOf(context).classify(fileOf(context))
36
-
33
+ export const classOf = (context) => hfsOf(context).classify(fileOf(context));
37
34
  /** The tier of the linted file in the import direction matrix (`route`, `feature`, `components`, `hooks`, `foundation`, `modules`, `transport`, `package`, `none`), or null when no slot owns it. */
38
- export const tierOfFile = (context) => hfsOf(context).tierOf(fileOf(context))
39
-
35
+ export const tierOfFile = (context) => hfsOf(context).tierOf(fileOf(context));
40
36
  /** The folder kind the slot reports for the linted file (a component layer, a feature kind), or null. */
41
- export const kindOfFile = (context) => classOf(context).kind ?? null
42
-
37
+ export const kindOfFile = (context) => classOf(context).kind ?? null;
43
38
  /** The role the slot reports for the linted file (`entry`, `drawing`, `styles`, `shared`, `page`, `layout`, ...), or null. */
44
- export const roleOfFile = (context) => classOf(context).role ?? null
45
-
39
+ export const roleOfFile = (context) => classOf(context).role ?? null;
46
40
  /** The tiers of product source: everything a slot places in an app's route, feature, component, hook or module tiers and in a shared package. */
47
- const PRODUCT_TIERS = new Set(["route", "feature", "components", "hooks", "foundation", "modules", "transport", "package"])
48
-
41
+ const PRODUCT_TIERS = new Set(["route", "feature", "components", "hooks", "foundation", "modules", "transport", "package"]);
49
42
  /** Product source: a file of a product tier. */
50
- export const isProductSource = (context) => PRODUCT_TIERS.has(tierOfFile(context))
51
-
43
+ export const isProductSource = (context) => PRODUCT_TIERS.has(tierOfFile(context));
52
44
  /** A file of a component tier: slot `fe.components` or `fe.package.ui`, inside one of its layer folders (blocks, composites, branches, leaves). */
53
- export const isComponentFile = (context) => inSlot(context, "fe.components", "fe.package.ui") && kindOfFile(context) !== null
54
-
45
+ export const isComponentFile = (context) => inSlot(context, "fe.components", "fe.package.ui") && kindOfFile(context) !== null;
55
46
  /** True when the given file (an absolute or repo-relative name, e.g. where an import resolves to) sits in a component layer of slot `fe.components` or `fe.package.ui`. */
56
47
  export const isComponentPath = (context, file) => {
57
- const found = hfsOf(context).classify(file)
58
- return (found.slot === "fe.components" || found.slot === "fe.package.ui") && found.kind !== undefined
59
- }
60
-
48
+ const found = hfsOf(context).classify(file);
49
+ return (found.slot === "fe.components" || found.slot === "fe.package.ui") && found.kind !== undefined;
50
+ };
61
51
  /** The one place that may read the environment: slot `fe.modules.config`. */
62
- export const isConfigModule = (context) => inSlot(context, "fe.modules.config")
63
-
52
+ export const isConfigModule = (context) => inSlot(context, "fe.modules.config");
64
53
  /**
65
54
  * The transport owner: the folder of the auth and no-auth Apollo clients and their link chain, slot `fe.transport.client`
66
55
  * (`modules/api/graphql/clients/`, one-app repository) or `fe.package.api.client` (`src/graphql/clients/` of the shared api package).
67
56
  */
68
- export const isApiClient = (context) => inSlot(context, "fe.transport.client", "fe.package.api.client")
69
-
57
+ export const isApiClient = (context) => inSlot(context, "fe.transport.client", "fe.package.api.client");
70
58
  /** The slots of the API layer: the app's `modules/api` (with its clients folder, its stream reader and its GraphQLResponse file) and the shared api package. */
71
- const API_LAYER_SLOTS = Object.freeze(["fe.modules.api", "fe.transport.client", "fe.transport.stream", "fe.transport.outcome", "fe.package.api", "fe.package.api.client", "fe.package.api.outcome"])
72
-
59
+ const API_LAYER_SLOTS = Object.freeze(["fe.modules.api", "fe.transport.client", "fe.transport.stream", "fe.transport.outcome", "fe.package.api", "fe.package.api.client", "fe.package.api.outcome"]);
73
60
  /** True when the linted file sits in the API layer: the only place that talks to the back end (Apollo, axios, `fetch`). */
74
- export const isApiLayer = (context) => inSlot(context, ...API_LAYER_SLOTS)
75
-
61
+ export const isApiLayer = (context) => inSlot(context, ...API_LAYER_SLOTS);
76
62
  /**
77
63
  * True when the linted file is a REST call of the API layer: `modules/api/rest/**` of an app (slot `fe.modules.api`) or
78
64
  * `src/rest/**` of the shared api package (slot `fe.package.api`), the one home of axios.
79
65
  */
80
66
  export const isApiRest = (context) => {
81
- if (!inSlot(context, "fe.modules.api", "fe.package.api")) return false
82
- const hfs = hfsOf(context)
83
- const file = fileOf(context)
84
- const below = hfs.relative(file).slice(hfs.classify(file).root.length + 1).split("/")
85
- return (below[0] === "src" ? below[1] : below[0]) === "rest"
86
- }
87
-
67
+ if (!inSlot(context, "fe.modules.api", "fe.package.api"))
68
+ return false;
69
+ const hfs = hfsOf(context);
70
+ const file = fileOf(context);
71
+ const below = hfs.relative(file).slice(requiredRoot(hfs.classify(file)).length + 1).split("/");
72
+ return (below[0] === "src" ? below[1] : below[0]) === "rest";
73
+ };
88
74
  /** The file that may declare the one result vocabulary (`GraphQLResponse`): the file of the slot the manifest marks `outcomeHome`. */
89
75
  export const isOutcomeModule = (context) => {
90
- const hfs = hfsOf(context)
91
- const slot = slotOfFile(context)
92
- return slot !== null && hfs.slot(slot)?.outcomeHome === true
93
- }
94
-
76
+ const hfs = hfsOf(context);
77
+ const slot = slotOfFile(context);
78
+ return slot !== null && hfs.slot(slot)?.outcomeHome === true;
79
+ };
95
80
  /** The basename of a path without directories. */
96
- export const baseName = (filename) => normalizePath(filename).split("/").pop() ?? ""
97
-
81
+ export const baseName = (filename) => normalizePath(filename).split("/").pop() ?? "";
98
82
  /** The file name with its extension removed. */
99
- export const stem = (filename) => baseName(filename).replace(/\.[cm]?[jt]sx?$/, "")
100
-
83
+ export const stem = (filename) => baseName(filename).replace(/\.[cm]?[jt]sx?$/, "");
101
84
  /**
102
85
  * The references in the file that resolve to no declaration of the file, i.e. to a global (`fetch`, `Request`, `window`).
103
86
  * An import, a parameter, a local `const fetch` binds the name and is not one; a type-position use is not a value reference.
@@ -107,19 +90,23 @@ export const stem = (filename) => baseName(filename).replace(/\.[cm]?[jt]sx?$/,
107
90
  * @returns {Array<object>} The ESTree identifiers that reference such a global as a value.
108
91
  */
109
92
  export const globalReferences = (context, names) => {
110
- const sourceCode = context.sourceCode ?? context.getSourceCode()
111
- const found = []
112
- const visit = (scope) => {
113
- for (const reference of scope.references) {
114
- if (!names.has(reference.identifier.name) || reference.isValueReference === false) continue
115
- if (!reference.resolved || reference.resolved.defs.length === 0) found.push(reference.identifier)
116
- }
117
- scope.childScopes.forEach(visit)
118
- }
119
- visit(sourceCode.scopeManager.globalScope)
120
- return found
121
- }
122
-
93
+ const sourceCode = context.sourceCode ?? context.getSourceCode();
94
+ const found = [];
95
+ const visit = (scope) => {
96
+ for (const reference of scope.references) {
97
+ if (!names.has(reference.identifier.name) || reference.isValueReference === false)
98
+ continue;
99
+ if ((!reference.resolved || reference.resolved.defs.length === 0))
100
+ found.push(reference.identifier);
101
+ }
102
+ scope.childScopes.forEach(visit);
103
+ };
104
+ const globalScope = sourceCode.scopeManager?.globalScope;
105
+ if (!globalScope)
106
+ throw new TypeError("the parser gave no scope manager");
107
+ visit(globalScope);
108
+ return found;
109
+ };
123
110
  /**
124
111
  * Where an import specifier of the linted file points, as an absolute path without an extension: a relative specifier, or `@/`
125
112
  * from the owning app's `src/` (the app directory is the directory part of slot `fe.app.next`). Null for a package or a file no app owns.
@@ -129,16 +116,18 @@ export const globalReferences = (context, names) => {
129
116
  * @returns {string | null} The absolute path, or null.
130
117
  */
131
118
  export const resolveImport = (context, specifier) => {
132
- const hfs = hfsOf(context)
133
- const file = fileOf(context)
134
- if (specifier.startsWith(".")) return resolve(dirname(file), specifier)
135
- if (!specifier.startsWith("@/")) return null
136
- const app = hfs.classify(file).bindings?.app
137
- if (app === undefined) return null
138
- const appDirectory = hfs.slot("fe.app.next").path.split("/{")[0].replace("<app>", app)
139
- return join(hfs.repoRoot, appDirectory, "src", specifier.slice(2))
140
- }
141
-
119
+ const hfs = hfsOf(context);
120
+ const file = fileOf(context);
121
+ if (specifier.startsWith("."))
122
+ return resolve(dirname(file), specifier);
123
+ if (!specifier.startsWith("@/"))
124
+ return null;
125
+ const app = hfs.classify(file).bindings?.app;
126
+ if (app === undefined)
127
+ return null;
128
+ const appDirectory = requiredSlot(hfs.slot("fe.app.next"), "fe.app.next").path.split("/{")[0]?.replace("<app>", app) ?? "";
129
+ return join(hfs.repoRoot, appDirectory, "src", specifier.slice(2));
130
+ };
142
131
  /**
143
132
  * The classification of an existing module file, resolving explicit files before extension and directory-index candidates.
144
133
  * Virtual rule inputs with no existing candidate retain the path-only slot classification.
@@ -148,19 +137,20 @@ export const resolveImport = (context, specifier) => {
148
137
  * @returns {object | null} The resolved file's owned slot classification, or the virtual-input classification when no file exists.
149
138
  */
150
139
  export const classifyImport = (context, specifier) => {
151
- const target = resolveImport(context, specifier)
152
- if (target === null) return null
153
- const hfs = hfsOf(context)
154
- const candidates = [`${target}.ts`, `${target}.tsx`, `${target}/index.ts`, `${target}/index.tsx`, target]
155
- // Match the owner helper's on-disk module order; a nonexistent auth.ts must not shadow auth/index.ts.
156
- const existing = [target, ...candidates].find((candidate) => ts.sys.fileExists(candidate))
157
- if (existing !== undefined) {
158
- const found = hfs.classify(existing)
159
- return found.slot && found.status === "owned" ? found : null
160
- }
161
- // RuleTester deliberately accepts virtual source paths; preserve its existing slot/role inference only without a real target.
162
- const owned = candidates
163
- .map((candidate) => hfs.classify(candidate))
164
- .filter((found) => found.slot && found.status === "owned")
165
- return owned.find((found) => found.role) ?? owned[0] ?? null
166
- }
140
+ const target = resolveImport(context, specifier);
141
+ if (target === null)
142
+ return null;
143
+ const hfs = hfsOf(context);
144
+ const candidates = [`${target}.ts`, `${target}.tsx`, `${target}/index.ts`, `${target}/index.tsx`, target];
145
+ // Match the owner helper's on-disk module order; a nonexistent auth.ts must not shadow auth/index.ts.
146
+ const existing = [target, ...candidates].find((candidate) => ts.sys.fileExists(candidate));
147
+ if (existing !== undefined) {
148
+ const found = hfs.classify(existing);
149
+ return found.slot && found.status === "owned" ? found : null;
150
+ }
151
+ // RuleTester deliberately accepts virtual source paths; preserve its existing slot/role inference only without a real target.
152
+ const owned = candidates
153
+ .map((candidate) => hfs.classify(candidate))
154
+ .filter((found) => found.slot && found.status === "owned");
155
+ return owned.find((found) => found.role) ?? owned[0] ?? null;
156
+ };
@@ -0,0 +1,13 @@
1
+ import type { AnyContext, Node } from "./rule.js";
2
+ /** One text found for a node of the linted file. */
3
+ export interface TypedString {
4
+ readonly node: Node;
5
+ readonly text: string;
6
+ }
7
+ /**
8
+ * Imported class values are reported at their use in the linted file, while their text comes from the typed declaration.
9
+ * Syntax-only rule consumers have no imported-file program and retain same-file inspection.
10
+ */
11
+ export declare const typedClassStrings: (context: AnyContext, node: Node, mode: string) => TypedString[];
12
+ /** Known imported props spreads expose the same static property values as named JSX attributes. */
13
+ export declare const typedPropertyStrings: (context: AnyContext, node: Node, key: string, mode: string) => TypedString[];
@@ -0,0 +1,92 @@
1
+ /** Static class declarations in imported colocated modules, resolved by the consuming TypeScript program. */
2
+ import ts from "typescript";
3
+ /** True for a TypeScript node (a declaration's `initializer`, when it has one). */
4
+ const isTsNode = (value) => typeof value === "object" && value !== null && "kind" in value && typeof value.kind === "number";
5
+ /** The expression's literal fragments, following imported const/property declarations without executing code. */
6
+ const fragments = (checker, node, seen, mode) => {
7
+ if (!node || seen.has(node))
8
+ return [];
9
+ seen.add(node);
10
+ if (ts.isStringLiteralLike(node) || ts.isTemplateHead(node) || ts.isTemplateMiddle(node) || ts.isTemplateTail(node))
11
+ return mode === "style-keys" ? [] : [node.text];
12
+ if (ts.isAsExpression(node) || ts.isTypeAssertionExpression(node) || ts.isSatisfiesExpression(node) || ts.isParenthesizedExpression(node) || ts.isNonNullExpression(node))
13
+ return fragments(checker, node.expression, seen, mode);
14
+ if (ts.isIdentifier(node) || ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) {
15
+ const symbol = ts.isElementAccessExpression(node) && ts.isStringLiteralLike(node.argumentExpression)
16
+ ? checker.getTypeAtLocation(node.expression).getProperty(node.argumentExpression.text)
17
+ : checker.getSymbolAtLocation(ts.isPropertyAccessExpression(node) ? node.name : node);
18
+ return symbolFragments(checker, symbol, seen, mode);
19
+ }
20
+ if (mode === "style-keys" && ts.isObjectLiteralExpression(node)) {
21
+ return node.properties.flatMap((property) => {
22
+ if (ts.isSpreadAssignment(property))
23
+ return fragments(checker, property.expression, seen, mode);
24
+ if (!ts.isPropertyAssignment(property) && !ts.isShorthandPropertyAssignment(property))
25
+ return [];
26
+ return ts.isComputedPropertyName(property.name)
27
+ ? fragments(checker, property.name.expression, seen, "variant")
28
+ : [property.name.text];
29
+ });
30
+ }
31
+ return fragmentChildren(node, mode).flatMap((child) => fragments(checker, child, seen, mode));
32
+ };
33
+ /** An object binding reads only its selected property's symbol, including aliases and nested patterns. */
34
+ const bindingProperty = (checker, declaration) => {
35
+ const source = declaration.parent.parent;
36
+ const owner = ts.isBindingElement(source) ? bindingProperty(checker, source) : null;
37
+ const initializer = "initializer" in source ? source.initializer : undefined;
38
+ const type = owner ? checker.getTypeOfSymbolAtLocation(owner, source) : initializer ? checker.getTypeAtLocation(initializer) : null;
39
+ const name = declaration.propertyName ?? declaration.name;
40
+ return ts.isIdentifier(name) || ts.isStringLiteralLike(name) ? type?.getProperty(name.text) : null;
41
+ };
42
+ /** Alias and destructuring symbols retain their declaration values without reading sibling properties. */
43
+ const symbolFragments = (checker, input, seen, mode) => {
44
+ let symbol = input;
45
+ if (symbol && (symbol.flags & ts.SymbolFlags.Alias) !== 0)
46
+ symbol = checker.getAliasedSymbol(symbol);
47
+ return (symbol?.declarations ?? []).flatMap((declaration) => {
48
+ const selected = ts.isBindingElement(declaration) ? bindingProperty(checker, declaration) : null;
49
+ return selected ? symbolFragments(checker, selected, seen, mode) : fragments(checker, "initializer" in declaration && isTsNode(declaration.initializer) ? declaration.initializer : undefined, seen, mode);
50
+ });
51
+ };
52
+ /** Class-producing positions inside a declaration, including cn dictionaries and conditional recipes. */
53
+ const fragmentChildren = (node, mode) => {
54
+ if (ts.isCallExpression(node))
55
+ return mode === "classes" ? node.arguments : [];
56
+ if (ts.isArrayLiteralExpression(node))
57
+ return node.elements;
58
+ if (ts.isConditionalExpression(node))
59
+ return [node.whenTrue, node.whenFalse];
60
+ if (ts.isBinaryExpression(node)) {
61
+ if (node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken)
62
+ return [node.right];
63
+ return [ts.SyntaxKind.BarBarToken, ts.SyntaxKind.QuestionQuestionToken].some((kind) => kind === node.operatorToken.kind) ? [node.left, node.right] : [];
64
+ }
65
+ if (ts.isObjectLiteralExpression(node))
66
+ return node.properties.flatMap((property) => ts.isPropertyAssignment(property) ? [property.name, property.initializer] : []);
67
+ if (ts.isTemplateExpression(node) && mode === "classes")
68
+ return [node.head, ...node.templateSpans.flatMap((span) => [span.expression, span.literal])];
69
+ return [];
70
+ };
71
+ /**
72
+ * Imported class values are reported at their use in the linted file, while their text comes from the typed declaration.
73
+ * Syntax-only rule consumers have no imported-file program and retain same-file inspection.
74
+ */
75
+ export const typedClassStrings = (context, node, mode) => {
76
+ const services = context.sourceCode.parserServices;
77
+ if (!services?.program || !services.esTreeNodeToTSNodeMap)
78
+ return [];
79
+ const source = services.esTreeNodeToTSNodeMap.get(node);
80
+ const checker = services.program.getTypeChecker();
81
+ return fragments(checker, source, new Set(), mode).map((text) => ({ node, text }));
82
+ };
83
+ /** Known imported props spreads expose the same static property values as named JSX attributes. */
84
+ export const typedPropertyStrings = (context, node, key, mode) => {
85
+ const services = context.sourceCode.parserServices;
86
+ if (!services?.program || !services.esTreeNodeToTSNodeMap)
87
+ return [];
88
+ const source = services.esTreeNodeToTSNodeMap.get(node);
89
+ const checker = services.program.getTypeChecker();
90
+ const symbol = checker.getTypeAtLocation(source).getProperty(key);
91
+ return symbolFragments(checker, symbol, new Set(), mode).map((text) => ({ node, text }));
92
+ };
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Type-aware helpers of the front-end canon: a rule asks what a node IS (its TypeScript type, the symbol an identifier
3
+ * resolves to, the module a declaration comes from), never what it is called. `starciFeConfig` turns typed linting on for
4
+ * the source tree (`parserOptions.projectService`), so these helpers refuse to run without the program instead of falling
5
+ * back to a name match.
6
+ */
7
+ import ts from "typescript";
8
+ import type { AnyContext, Node } from "./rule.js";
9
+ /** The TypeScript services of a linted file: its program, the checker and the ESTree to TS node map. */
10
+ export interface Typed {
11
+ readonly program: ts.Program;
12
+ readonly checker: ts.TypeChecker;
13
+ readonly toTs: (node: Node) => ts.Node | undefined;
14
+ }
15
+ /**
16
+ * The TypeScript services of the file being linted.
17
+ *
18
+ * @param context - The ESLint rule context.
19
+ * @returns The program, its checker and the ESTree to TS node map.
20
+ */
21
+ export declare const typed: (context: AnyContext) => Typed;
22
+ /**
23
+ * True when the type of an expression is a promise (a thenable carrying a `then` method), after union members are read.
24
+ *
25
+ * @param context - The ESLint rule context.
26
+ * @param node - An ESTree expression.
27
+ * @returns Whether evaluating the node yields a promise.
28
+ */
29
+ export declare const isPromiseValued: (context: AnyContext, node: Node) => boolean;
30
+ /**
31
+ * The declarations the symbol of an identifier resolves to, after import aliases are followed.
32
+ *
33
+ * @param context - The ESLint rule context.
34
+ * @param identifier - An ESTree identifier (or any node with a symbol).
35
+ * @returns The TypeScript declarations (empty when the checker has no symbol).
36
+ */
37
+ export declare const declarationsOf: (context: AnyContext, identifier: Node) => ts.Declaration[];
38
+ /**
39
+ * The forward-slash file name a declaration lives in.
40
+ *
41
+ * @param declaration - A TypeScript declaration.
42
+ * @returns Its source file name.
43
+ */
44
+ export declare const fileOf: (declaration: ts.Node) => string;