@starci/eslint-canon-fe 9.9.10 → 9.9.12

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 CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 9.9.12
4
+
5
+ - 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.
6
+
7
+ ## 9.9.11
8
+
9
+ - Changed: Recognize declared module hook roles through real hook-call provenance.
10
+
3
11
  ## 9.9.10
4
12
 
5
13
  - Changed: Refresh shared repository identity and release-aligned canon data.
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
@@ -28,6 +28,7 @@
28
28
  import { defineRule } from "./lib/rule.js";
29
29
  import { importedReduxHook } from "./lib/redux-hook-origin.js";
30
30
  import { importedOwnHook } from "./lib/own-hook-origin.js";
31
+ import { builtinHook, isLibrarySource, namedOrigin, OWN_HOOK } from "./lib/hook-library-origin.js";
31
32
  import { hfsOf } from "./lib/hfs.js";
32
33
  import { walk } from "./lib/ast.js";
33
34
  import { functionOf, isFn, unwrap, variableOf } from "./lib/bindings.js";
@@ -75,55 +76,11 @@ const exportedEntries = (node) => {
75
76
  }
76
77
  return [];
77
78
  };
78
- /** Actual React hook APIs and SWR public entry points, rather than an arbitrary `use*` spelling. */
79
- const REACT_HOOKS = new Set([
80
- "use", "useState", "useReducer", "useEffect", "useLayoutEffect", "useInsertionEffect", "useRef", "useImperativeHandle",
81
- "useMemo", "useCallback", "useContext", "useDebugValue", "useDeferredValue", "useTransition", "useId",
82
- "useSyncExternalStore", "useActionState", "useOptimistic",
83
- ]);
84
- const SWR_HOOKS = new Map([
85
- ["swr", new Set(["useSWR", "useSWRConfig"])],
86
- ["swr/immutable", new Set(["useSWRImmutable"])],
87
- ["swr/infinite", new Set(["useSWRInfinite"])],
88
- ["swr/mutation", new Set(["useSWRMutation"])],
89
- ["swr/subscription", new Set(["useSWRSubscription"])],
90
- ]);
91
- const SWR_DEFAULTS = new Map([["swr", "useSWR"], ["swr/immutable", "useSWRImmutable"], ["swr/infinite", "useSWRInfinite"], ["swr/mutation", "useSWRMutation"], ["swr/subscription", "useSWRSubscription"]]);
92
- /**
93
- * The canonical library hooks a composite hook may call (R56, FE-THEME): next-intl, next/navigation,
94
- * next-themes, react-hook-form and zustand's React entry points. A name outside the list is not a hook by its spelling.
95
- */
96
- const LIBRARY_HOOKS = new Map([
97
- ["next-intl", new Set(["useTranslations", "useLocale", "useFormatter", "useNow", "useTimeZone", "useMessages"])],
98
- ["next/navigation", new Set(["useRouter", "usePathname", "useSearchParams", "useParams", "useSelectedLayoutSegment", "useSelectedLayoutSegments"])],
99
- ["next-themes", new Set(["useTheme"])],
100
- ["react-hook-form", new Set(["useForm", "useFormContext", "useWatch", "useController", "useFieldArray", "useFormState"])],
101
- ["zustand", new Set(["useStore"])],
102
- ["zustand/react/shallow", new Set(["useShallow"])],
103
- ]);
104
- const OWN_HOOK = "own-hooks";
105
- /** True when `set` holds `name`. */
106
- const hasName = (set, name) => set !== undefined && typeof name === "string" && set.has(name);
107
- const builtinHook = (origin) => {
108
- if (origin === null)
109
- return false;
110
- return origin.source === OWN_HOOK || hasName(LIBRARY_HOOKS.get(origin.source), origin.name) || origin.source === "react-redux" || (origin.source === "react" ? hasName(REACT_HOOKS, origin.name) : hasName(SWR_HOOKS.get(origin.source), origin.name));
111
- };
112
79
  const propertyName = (node) => {
113
80
  if (!node.computed && node.property.type === "Identifier")
114
81
  return node.property.name;
115
82
  return node.property.type === "Literal" ? node.property.value : null;
116
83
  };
117
- /** SWR request hooks are default exports; its root also names useSWRConfig. */
118
- const namedOrigin = (source, name) => {
119
- if (source === "react")
120
- return { source, name };
121
- if (hasName(LIBRARY_HOOKS.get(source), name))
122
- return { source, name };
123
- if (name === "default" && SWR_DEFAULTS.has(source))
124
- return { source, name: SWR_DEFAULTS.get(source) };
125
- return source === "swr" && name === "useSWRConfig" ? { source, name } : null;
126
- };
127
84
  /** A runtime import records whether its value is a hook or its module namespace. */
128
85
  const importOrigin = (definition) => {
129
86
  const specifier = definition.node;
@@ -132,14 +89,14 @@ const importOrigin = (definition) => {
132
89
  const source = String(definition.parent.source.value);
133
90
  if (definition.parent.importKind === "type" || ("importKind" in specifier && specifier.importKind === "type"))
134
91
  return null;
135
- if (source !== "react" && !SWR_HOOKS.has(source) && !LIBRARY_HOOKS.has(source))
92
+ if (!isLibrarySource(source))
136
93
  return null;
137
94
  if (source === "next-themes" && specifier.type === "ImportDefaultSpecifier")
138
95
  return null;
139
96
  if (specifier.type === "ImportSpecifier")
140
97
  return namedOrigin(source, spelled(specifier.imported));
141
- if (specifier.type === "ImportDefaultSpecifier" && SWR_DEFAULTS.has(source))
142
- return { source, name: SWR_DEFAULTS.get(source) };
98
+ if (specifier.type === "ImportDefaultSpecifier")
99
+ return source === "react" ? { source, name: null } : namedOrigin(source, "default") ?? { source, name: null };
143
100
  return { source, name: null };
144
101
  };
145
102
  /** Resolve a constant alias, including destructuring a React/SWR namespace. */
package/dist/index.js CHANGED
@@ -311,6 +311,6 @@ for (const addition of A14_CONTRIBUTIONS) {
311
311
  Object.assign(rules, addition.rules);
312
312
  Object.assign(recommended, addition.recommended);
313
313
  }
314
- // The result cache of `starci app lint` (contexts/project/check-cache.md) reads these two declarations of the installed canon.
314
+ // The result cache of `starci app check --gate lint` (contexts/project/check-cache.md) reads these two declarations of the installed canon.
315
315
  export { crossFileInputs } from "./lib/cross-file-inputs.js";
316
316
  export { projectGraph } from "./project-graph.js";
@@ -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, {
@@ -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({
@@ -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
  *
@@ -0,0 +1,13 @@
1
+ /** The marker for an app hook proven by symbol provenance. */
2
+ export declare const OWN_HOOK = "own-hooks";
3
+ /** Where a hook comes from: the module and the export it names (null for the module object itself; any key a member reads). */
4
+ export interface HookOrigin {
5
+ readonly source: string;
6
+ readonly name: unknown;
7
+ }
8
+ /** Judge an origin against the canonical library hook catalog. */
9
+ export declare const builtinHook: (origin: HookOrigin | null) => boolean;
10
+ /** SWR request hooks are default exports; its root also names useSWRConfig. */
11
+ export declare const namedOrigin: (source: string, name: unknown) => HookOrigin | null;
12
+ /** A library with a known hook entry. */
13
+ export declare const isLibrarySource: (source: string) => boolean;
@@ -0,0 +1,48 @@
1
+ /** Actual React hook APIs and SWR public entry points, rather than an arbitrary `use*` spelling. */
2
+ const REACT_HOOKS = new Set([
3
+ "use", "useState", "useReducer", "useEffect", "useLayoutEffect", "useInsertionEffect", "useRef", "useImperativeHandle",
4
+ "useMemo", "useCallback", "useContext", "useDebugValue", "useDeferredValue", "useTransition", "useId",
5
+ "useSyncExternalStore", "useActionState", "useOptimistic",
6
+ ]);
7
+ const SWR_HOOKS = new Map([
8
+ ["swr", new Set(["useSWR", "useSWRConfig"])],
9
+ ["swr/immutable", new Set(["useSWRImmutable"])],
10
+ ["swr/infinite", new Set(["useSWRInfinite"])],
11
+ ["swr/mutation", new Set(["useSWRMutation"])],
12
+ ["swr/subscription", new Set(["useSWRSubscription"])],
13
+ ]);
14
+ const SWR_DEFAULTS = new Map([["swr", "useSWR"], ["swr/immutable", "useSWRImmutable"], ["swr/infinite", "useSWRInfinite"], ["swr/mutation", "useSWRMutation"], ["swr/subscription", "useSWRSubscription"]]);
15
+ /**
16
+ * The canonical library hooks a composite hook may call (R56, FE-THEME): next-intl, next/navigation,
17
+ * next-themes, react-hook-form and zustand's React entry points. A name outside the list is not a hook by its spelling.
18
+ */
19
+ const LIBRARY_HOOKS = new Map([
20
+ ["next-intl", new Set(["useTranslations", "useLocale", "useFormatter", "useNow", "useTimeZone", "useMessages"])],
21
+ ["next/navigation", new Set(["useRouter", "usePathname", "useSearchParams", "useParams", "useSelectedLayoutSegment", "useSelectedLayoutSegments"])],
22
+ ["next-themes", new Set(["useTheme"])],
23
+ ["react-hook-form", new Set(["useForm", "useFormContext", "useWatch", "useController", "useFieldArray", "useFormState"])],
24
+ ["zustand", new Set(["useStore"])],
25
+ ["zustand/react/shallow", new Set(["useShallow"])],
26
+ ]);
27
+ /** The marker for an app hook proven by symbol provenance. */
28
+ export const OWN_HOOK = "own-hooks";
29
+ /** True when `set` holds `name`. */
30
+ const hasName = (set, name) => set !== undefined && typeof name === "string" && set.has(name);
31
+ /** Judge an origin against the canonical library hook catalog. */
32
+ export const builtinHook = (origin) => {
33
+ if (origin === null)
34
+ return false;
35
+ return origin.source === OWN_HOOK || hasName(LIBRARY_HOOKS.get(origin.source), origin.name) || origin.source === "react-redux" || (origin.source === "react" ? hasName(REACT_HOOKS, origin.name) : hasName(SWR_HOOKS.get(origin.source), origin.name));
36
+ };
37
+ /** SWR request hooks are default exports; its root also names useSWRConfig. */
38
+ export const namedOrigin = (source, name) => {
39
+ if (source === "react")
40
+ return { source, name };
41
+ if (hasName(LIBRARY_HOOKS.get(source), name))
42
+ return { source, name };
43
+ if (name === "default" && SWR_DEFAULTS.has(source))
44
+ return { source, name: SWR_DEFAULTS.get(source) };
45
+ return source === "swr" && name === "useSWRConfig" ? { source, name } : null;
46
+ };
47
+ /** A library with a known hook entry. */
48
+ export const isLibrarySource = (source) => source === "react" || SWR_HOOKS.has(source) || LIBRARY_HOOKS.has(source);
@@ -1,5 +1,7 @@
1
1
  import ts from "typescript";
2
2
  import { programFor } from "./program-for.js";
3
+ import { hfsOf } from "./hfs.js";
4
+ import { builtinHook, namedOrigin } from "./hook-library-origin.js";
3
5
  const HOOKS_FOLDER = /(^|\/)hooks\//;
4
6
  const isHookName = (name) => /^use[A-Z0-9]/.test(name);
5
7
  /** The program that holds the import target: the project program has it; a program rooted at the linted file alone (its disk text may differ from the linted text) is widened with it. */
@@ -23,6 +25,133 @@ const inAppHooksFolder = (file) => {
23
25
  const fileName = file.fileName.replaceAll("\\", "/");
24
26
  return !fileName.includes("/node_modules/") && !file.isDeclarationFile && HOOKS_FOLDER.test(fileName);
25
27
  };
28
+ /** A type-only alias never supplies a runtime hook. */
29
+ const runtimeAlias = (declaration) => {
30
+ if (ts.isImportSpecifier(declaration))
31
+ return !declaration.isTypeOnly && !declaration.parent.parent.isTypeOnly;
32
+ if (ts.isImportClause(declaration))
33
+ return !declaration.isTypeOnly;
34
+ if (ts.isExportSpecifier(declaration))
35
+ return !declaration.isTypeOnly && !declaration.parent.parent.isTypeOnly;
36
+ if (ts.isNamespaceExport(declaration))
37
+ return !declaration.parent.isTypeOnly;
38
+ return false;
39
+ };
40
+ /** Follow every alias separately, so a type-only link or cycle never becomes a runtime binding. */
41
+ function runtimeSymbol(checker, start) {
42
+ let symbol = start;
43
+ const seen = new Set();
44
+ while ((symbol.flags & ts.SymbolFlags.Alias) !== 0) {
45
+ if (seen.has(symbol))
46
+ return null;
47
+ seen.add(symbol);
48
+ const declarations = symbol.declarations ?? [];
49
+ if (declarations.length !== 1 || !declarations.every(runtimeAlias))
50
+ return null;
51
+ const next = checker.getImmediateAliasedSymbol(symbol);
52
+ if (!next)
53
+ return null;
54
+ symbol = next;
55
+ }
56
+ return symbol;
57
+ }
58
+ /** The module hook role is slot-defined and belongs to the same app as the caller. */
59
+ function moduleHookRole(context, file) {
60
+ const hfs = hfsOf(context);
61
+ const found = hfs.classify(file.fileName);
62
+ const caller = hfs.classify(context.filename);
63
+ return !file.isDeclarationFile && !file.fileName.replaceAll("\\", "/").includes("/node_modules/") &&
64
+ found.status === "owned" && found.slot?.startsWith("fe.modules.") === true && found.role === "hooks" &&
65
+ found.bindings?.app !== undefined && found.bindings.app === caller.bindings?.app && hfs.allows(file.fileName)?.allowed === true;
66
+ }
67
+ /** A known library import is proven by its own runtime import declaration and canonical exported name. */
68
+ function libraryImport(declaration) {
69
+ if (!runtimeAlias(declaration))
70
+ return false;
71
+ if (ts.isImportSpecifier(declaration)) {
72
+ const module = declaration.parent.parent.parent.moduleSpecifier;
73
+ return ts.isStringLiteral(module) && builtinHook(namedOrigin(module.text, (declaration.propertyName ?? declaration.name).text));
74
+ }
75
+ if (ts.isExportSpecifier(declaration)) {
76
+ const module = declaration.parent.parent.moduleSpecifier;
77
+ return module !== undefined && ts.isStringLiteral(module) && builtinHook(namedOrigin(module.text, (declaration.propertyName ?? declaration.name).text));
78
+ }
79
+ return false;
80
+ }
81
+ /** Follow runtime re-exports until a known library import itself proves the binding. */
82
+ function librarySymbol(checker, start) {
83
+ let symbol = start;
84
+ const visited = new Set();
85
+ while (!visited.has(symbol)) {
86
+ visited.add(symbol);
87
+ const declarations = symbol.declarations ?? [];
88
+ if (declarations.length !== 1)
89
+ return false;
90
+ const declaration = declarations[0];
91
+ if (!declaration)
92
+ return false;
93
+ if (libraryImport(declaration))
94
+ return true;
95
+ if ((symbol.flags & ts.SymbolFlags.Alias) === 0 || !runtimeAlias(declaration))
96
+ return false;
97
+ const next = checker.getImmediateAliasedSymbol(symbol);
98
+ if (!next)
99
+ return false;
100
+ symbol = next;
101
+ }
102
+ return false;
103
+ }
104
+ /** Resolve calls through lexical symbols, constant aliases and runtime import/re-export chains. */
105
+ function moduleHookBody(context, checker, declaration, seen) {
106
+ if (!moduleHookRole(context, declaration.getSourceFile()))
107
+ return false;
108
+ if (ts.isVariableDeclaration(declaration) && (declaration.parent.flags & ts.NodeFlags.Const) === 0)
109
+ return false;
110
+ const body = ts.isFunctionDeclaration(declaration) ? declaration.body : ts.isVariableDeclaration(declaration) && declaration.initializer &&
111
+ (ts.isArrowFunction(declaration.initializer) || ts.isFunctionExpression(declaration.initializer)) ? declaration.initializer.body : undefined;
112
+ if (!body)
113
+ return false;
114
+ const proves = (expression, visited) => {
115
+ if (!ts.isIdentifier(expression))
116
+ return false;
117
+ const symbol = checker.getSymbolAtLocation(expression);
118
+ if (!symbol || visited.has(symbol))
119
+ return false;
120
+ const nextSeen = new Set([...visited, symbol]);
121
+ if (librarySymbol(checker, symbol))
122
+ return true;
123
+ const resolved = runtimeSymbol(checker, symbol);
124
+ if (!resolved || nextSeen.has(resolved) && resolved !== symbol)
125
+ return false;
126
+ const actual = resolved.declarations ?? [];
127
+ if (actual.length !== 1)
128
+ return false;
129
+ const target = actual[0];
130
+ if (!target)
131
+ return false;
132
+ if (hookValue(target) && inAppHooksFolder(target.getSourceFile()) && isHookName(resolved.name)) {
133
+ const hfs = hfsOf(context);
134
+ const app = hfs.classify(context.filename).bindings?.app;
135
+ const found = hfs.classify(target.getSourceFile().fileName);
136
+ return app !== undefined && found.status === "owned" && found.slot?.startsWith("fe.hooks") === true && found.bindings?.app === app;
137
+ }
138
+ if (ts.isVariableDeclaration(target) && (target.parent.flags & ts.NodeFlags.Const) !== 0 && target.initializer && ts.isIdentifier(target.initializer))
139
+ return proves(target.initializer, nextSeen);
140
+ return moduleHookBody(context, checker, target, nextSeen);
141
+ };
142
+ let found = false;
143
+ const visit = (node) => {
144
+ if (found || node !== body && ts.isFunctionLike(node))
145
+ return;
146
+ if (ts.isCallExpression(node) && proves(node.expression, seen)) {
147
+ found = true;
148
+ return;
149
+ }
150
+ ts.forEachChild(node, visit);
151
+ };
152
+ visit(body);
153
+ return found;
154
+ }
26
155
  /** True when the import binding is a runtime `use*` import that resolves to a hook declared in a `hooks/` folder of the app. */
27
156
  export function importedOwnHook(context, definition) {
28
157
  const specifier = definition.node;
@@ -47,6 +176,11 @@ export function importedOwnHook(context, definition) {
47
176
  const exported = moduleSymbol ? checker.getExportsOfModule(moduleSymbol).find((symbol) => symbol.name === name) : undefined;
48
177
  if (!exported)
49
178
  return false;
50
- const symbol = (exported.flags & ts.SymbolFlags.Alias) !== 0 ? checker.getAliasedSymbol(exported) : exported;
51
- return (symbol.declarations ?? []).some((declaration) => hookValue(declaration) && inAppHooksFolder(declaration.getSourceFile()));
179
+ const original = (exported.flags & ts.SymbolFlags.Alias) !== 0 ? checker.getAliasedSymbol(exported) : exported;
180
+ if ((original.declarations ?? []).some((declaration) => hookValue(declaration) && inAppHooksFolder(declaration.getSourceFile())))
181
+ return true;
182
+ const symbol = runtimeSymbol(checker, exported);
183
+ if (!symbol)
184
+ return false;
185
+ return (symbol.declarations ?? []).some((declaration) => hookValue(declaration) && moduleHookBody(context, checker, declaration, new Set([symbol])));
52
186
  }
@@ -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
  *
@@ -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
  *
@@ -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. Typed app lint follows imported colocated class declarations as well as local constants.
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. Typed app lint follows imported colocated class declarations.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/eslint-canon-fe",
3
- "version": "9.9.10",
3
+ "version": "9.9.12",
4
4
  "description": "Universal front-end lint laws for TypeScript and Next.js, independent of the selected Grammar family.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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.10
34
+ version: 0.21.12
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.11.17
41
+ version: 3.11.19
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.9.10
48
+ version: 9.9.12
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.10
55
+ version: 0.21.12
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.10
62
+ version: 0.21.12
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.0
69
+ version: 2.5.1
70
70
  group: starci
71
71
  install: registry
72
72
  side: fe
@@ -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.6
109
+ version: 5.0.8
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 `starci app check` (managed files, tree, pins, CI, contracts; the rule id is the finding code)
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
@@ -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)
@@ -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