@starci/eslint-canon-fe 9.9.9 → 9.9.11

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,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 9.9.11
4
+
5
+ - Changed: Recognize declared module hook roles through real hook-call provenance.
6
+
7
+ ## 9.9.10
8
+
9
+ - Changed: Refresh shared repository identity and release-aligned canon data.
10
+
11
+ - Fixed: Preserve generated-contract provenance in front-end tier and bounds checks.
12
+
3
13
  ## 9.9.9
4
14
 
5
15
  - Changed: Preserve mounted RootPage children through selected native body layouts only when their metadata constraints, immutable props and required context are proven. Retain exact body and import identity, and refresh the generated canon pins.
@@ -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. */
@@ -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
  }
@@ -1,7 +1,9 @@
1
1
  /** R285: API operations are wrapped by the SWR family before product code calls them. */
2
2
  import { hfsOf } from "./lib/hfs.js";
3
3
  import { readFileSync, readdirSync } from "node:fs";
4
- import { dirname, join, resolve } from "node:path";
4
+ import { join } from "node:path";
5
+ import ts from "typescript";
6
+ import { isBoundsExport } from "#runtime/scripts/hfs/architecture/contract-bounds.mjs";
5
7
  import { stringOf, walk } from "./lib/ast.js";
6
8
  import { functionOf } from "./lib/bindings.js";
7
9
  import { defineRule } from "./lib/rule.js";
@@ -152,36 +154,6 @@ const isOperationSource = (context, value, only) => {
152
154
  return found.some((entry) => entry.slot === only);
153
155
  return found.some((entry) => OPERATION_SLOTS.includes(entry.slot));
154
156
  };
155
- /** The generated input-bounds module holds constants and a pure reader derived from the contract, never an operation. */
156
- const BOUNDS_MODULE = /(?:^|\/)__generated__\/input-bounds\.ts$/;
157
- const RE_EXPORT = /export\s*\{([^}]*)\}\s*from\s*["']([^"']+)["']/g;
158
- const ENTRY = /^\s*(type\s+)?([\w$]+)(?:\s+as\s+([\w$]+))?\s*$/;
159
- /** True when the named export of `target` is, through a chain of relative named re-exports, defined by a generated input-bounds module. */
160
- const isBoundsExport = (target, name, depth = 0) => {
161
- if (depth > 4)
162
- return false;
163
- for (const file of [`${target}.ts`, `${target}/index.ts`, target]) {
164
- if (BOUNDS_MODULE.test(file.replaceAll("\\", "/")))
165
- return true;
166
- let text;
167
- try {
168
- text = readFileSync(file, "utf8");
169
- }
170
- catch {
171
- continue;
172
- }
173
- for (const [, list, source] of text.matchAll(RE_EXPORT)) {
174
- if (list === undefined || source === undefined || !source.startsWith("."))
175
- continue;
176
- for (const entry of list.split(",")) {
177
- const [, kind, origin, exported] = ENTRY.exec(entry) ?? [];
178
- if (origin !== undefined && kind === undefined && (exported ?? origin) === name && isBoundsExport(resolve(dirname(file), source), origin, depth + 1))
179
- return true;
180
- }
181
- }
182
- }
183
- return false;
184
- };
185
157
  /** The specifiers of an import or a re-export; the other inspected nodes have none. */
186
158
  const specifiersOf = (node) => node.type === "ImportDeclaration" || node.type === "ExportNamedDeclaration" ? node.specifiers : undefined;
187
159
  /** An import of named values that all come from a generated input-bounds module is data, not an operation. */
@@ -192,9 +164,16 @@ const importsOnlyBounds = (context, node, value) => {
192
164
  const target = resolveImport(context, value);
193
165
  if (target === null)
194
166
  return false;
167
+ const hfs = hfsOf(context);
168
+ const owner = hfs.ownerOf(target);
169
+ const ownsGenerated = (file) => {
170
+ const found = hfs.classify(file);
171
+ return owner !== null && hfs.ownerOf(file) === owner && found.status === "owned" &&
172
+ (found.slot === "fe.transport.generated" || found.slot === "fe.package.api");
173
+ };
195
174
  return specifiers.every((s) => {
196
175
  const name = s.type === "ImportSpecifier" ? exportName(s.imported) : s.type === "ExportSpecifier" ? exportName(s.local) : undefined;
197
- return name !== undefined && isBoundsExport(target, name);
176
+ return name !== undefined && isBoundsExport(ts, target, name, ownsGenerated);
198
177
  });
199
178
  };
200
179
  const unwrap = (start) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/eslint-canon-fe",
3
- "version": "9.9.9",
3
+ "version": "9.9.11",
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",
@@ -43,8 +43,8 @@
43
43
  "scripts/hfs/architecture/canon-jsx-clones.mjs",
44
44
  "scripts/hfs/architecture/client-reaches-server.mjs",
45
45
  "scripts/hfs/architecture/clones.mjs",
46
- "scripts/hfs/architecture/config-unread.mjs",
47
46
  "scripts/hfs/architecture/component-tiers.mjs",
47
+ "scripts/hfs/architecture/config-unread.mjs",
48
48
  "scripts/hfs/architecture/config.mjs",
49
49
  "scripts/hfs/architecture/connection-map.mjs",
50
50
  "scripts/hfs/architecture/constructor-deps.mjs",
@@ -53,6 +53,7 @@
53
53
  "scripts/hfs/architecture/context-owner.mjs",
54
54
  "scripts/hfs/architecture/context-platform-tables.mjs",
55
55
  "scripts/hfs/architecture/context-transaction.mjs",
56
+ "scripts/hfs/architecture/contract-bounds.mjs",
56
57
  "scripts/hfs/architecture/contract-fixture-guard.mjs",
57
58
  "scripts/hfs/architecture/contracts-readonly.mjs",
58
59
  "scripts/hfs/architecture/contracts.mjs",
@@ -31,35 +31,35 @@ 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.9
34
+ version: 0.21.11
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.16
41
+ version: 3.11.18
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.9
48
+ version: 9.9.11
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.9
55
+ version: 0.21.11
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.9
62
+ version: 0.21.11
63
63
  group: starci
64
64
  install: registry
65
65
  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.5
109
+ version: 5.0.7
110
110
  group: starci
111
111
  install: registry
112
112
  side: both
@@ -0,0 +1,72 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { dirname, resolve } from 'node:path';
3
+ /** The generated input-bounds owner is contract data, not an operation. */
4
+ const BOUNDS_MODULE = /(?:^|\/)__generated__\/input-bounds\.ts$/;
5
+ /** Directly exported declarations with the requested name; multiple declarations are ambiguous. */
6
+ function declaredExports(ts, source, name) {
7
+ let count = 0;
8
+ for (const statement of source.statements) {
9
+ if (!ts.canHaveModifiers(statement) || !ts.getModifiers(statement)?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword))
10
+ continue;
11
+ if (ts.isVariableStatement(statement)) {
12
+ count += statement.declarationList.declarations.filter(declaration => ts.isIdentifier(declaration.name) && declaration.name.text === name).length;
13
+ }
14
+ else if ((ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement) || ts.isInterfaceDeclaration(statement) ||
15
+ ts.isTypeAliasDeclaration(statement) || ts.isEnumDeclaration(statement)) && statement.name?.text === name)
16
+ count += 1;
17
+ }
18
+ return count;
19
+ }
20
+ /** The concrete file a relative module specifier resolves to, in the same order as the rule's owner resolver. */
21
+ function readSource(ts, target) {
22
+ for (const file of [`${target}.ts`, `${target}/index.ts`, target]) {
23
+ let text;
24
+ try {
25
+ text = readFileSync(file, 'utf8');
26
+ }
27
+ catch {
28
+ continue;
29
+ }
30
+ const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
31
+ if (source.parseDiagnostics.length)
32
+ return null;
33
+ return { file, source };
34
+ }
35
+ return null;
36
+ }
37
+ /** True only for an unambiguous named export proven through actual declarations to a generated contract declaration. */
38
+ export function isBoundsExport(ts, target, name, ownsGenerated, seen = new Set()) {
39
+ const found = readSource(ts, target);
40
+ if (!found)
41
+ return false;
42
+ const { file, source } = found;
43
+ const key = `${file}#${name}`;
44
+ if (seen.has(key) || seen.size >= 32)
45
+ return false;
46
+ const direct = declaredExports(ts, source, name);
47
+ const matches = [];
48
+ let wildcard = false;
49
+ for (const statement of source.statements) {
50
+ if (!ts.isExportDeclaration(statement))
51
+ continue;
52
+ const module = statement.moduleSpecifier && ts.isStringLiteralLike(statement.moduleSpecifier) ? statement.moduleSpecifier.text : null;
53
+ if (!statement.exportClause) {
54
+ wildcard = true;
55
+ continue;
56
+ }
57
+ if (!ts.isNamedExports(statement.exportClause))
58
+ continue;
59
+ for (const item of statement.exportClause.elements)
60
+ if (item.name.text === name) {
61
+ matches.push({ origin: (item.propertyName ?? item.name).text, module });
62
+ }
63
+ }
64
+ if (wildcard || direct + matches.length !== 1)
65
+ return false;
66
+ if (BOUNDS_MODULE.test(file.replaceAll('\\', '/')))
67
+ return ownsGenerated(file) && direct === 1 && matches.length === 0;
68
+ const match = matches[0];
69
+ if (direct || !match || !match.module?.startsWith('.'))
70
+ return false;
71
+ return isBoundsExport(ts, resolve(dirname(file), match.module), match.origin, ownsGenerated, new Set([...seen, key]));
72
+ }
@@ -243,7 +243,7 @@ function runHfsMachine(config, context, paths, injectedTypeScript, base, fast, v
243
243
  return hfsChecks;
244
244
  const graph = buildHfsGraph(config, hfsContext);
245
245
  const input = { config, context: hfsContext, graph, base };
246
- const runs = { tiers: () => checkTiers(graph), reachability: () => checkReachability(input), deadExports: () => checkDeadExports(input),
246
+ const runs = { tiers: () => checkTiers(graph, hfsContext.ts), reachability: () => checkReachability(input), deadExports: () => checkDeadExports(input),
247
247
  requiredFiles: () => checkRequiredFiles(input), clones: () => checkClones(input), symbols: () => checkSymbols(input), docLanguage: () => checkDocLanguage(input) };
248
248
  if (graph.profile === 'be')
249
249
  for (const [name, [check]] of Object.entries(BACKEND_MACHINE))
@@ -1,4 +1,6 @@
1
1
  import { byCodeUnit } from '../../lib/list.mjs';
2
+ import { dirname, relative } from 'node:path';
3
+ import { isBoundsExport } from './contract-bounds.mjs';
2
4
  import { registryListImport, registrySlot } from './entity-registry.mjs';
3
5
  /**
4
6
  * HFS checks 1 and 2 (knowledge/hfs/slots.yaml `tiers`):
@@ -139,14 +141,53 @@ function reportReasonedTierViolation(graph, edge, answer, tierRule, counts, viol
139
141
  message,
140
142
  });
141
143
  }
142
- function inspectTierEdge(graph, edge, tierRule, counts, violations, coverage) {
144
+ /** A public API entry may expose generated contract data without exposing operations to a hook. */
145
+ function contractMetadataEdge(graph, ts, edge, verdict) {
146
+ if (graph.profile !== 'fe' || verdict.reason !== 'tierDirection')
147
+ return false;
148
+ const from = graph.files.get(edge.from);
149
+ const to = graph.files.get(edge.to);
150
+ if (from?.tier !== 'hooks' || to?.tier !== 'transport' || to.slot !== 'fe.modules.api' || !to.owner || to.owner.slot !== 'fe.modules' || to.owner.bindings.capability !== 'api')
151
+ return false;
152
+ // The tier verdict precedes private-entry checks, so prove both app isolation and the owner entry here.
153
+ const fromApp = graph.resolver.classifyPath(edge.from).bindings?.app;
154
+ const toApp = graph.resolver.classifyPath(edge.to).bindings?.app;
155
+ if (!fromApp || fromApp !== toApp || edge.to !== `${to.owner.root}/index.ts`)
156
+ return false;
157
+ const declaration = edge.edge.declaration;
158
+ let names;
159
+ if (ts.isImportDeclaration(declaration)) {
160
+ const clause = declaration.importClause;
161
+ if (!clause || clause.name || !clause.namedBindings || !ts.isNamedImports(clause.namedBindings))
162
+ return false;
163
+ names = clause.namedBindings.elements.map(item => (item.propertyName ?? item.name).text);
164
+ }
165
+ else if (ts.isExportDeclaration(declaration)) {
166
+ if (!declaration.exportClause || !ts.isNamedExports(declaration.exportClause))
167
+ return false;
168
+ names = declaration.exportClause.elements.map(item => (item.propertyName ?? item.name).text);
169
+ }
170
+ else
171
+ return false;
172
+ const apiOwner = to.owner;
173
+ const dirnameOfEntry = dirname(to.abs);
174
+ const ownsGenerated = (file) => {
175
+ const rel = `${apiOwner.root}/${relative(dirnameOfEntry, file).replaceAll('\\', '/')}`;
176
+ const classified = graph.resolver.classifyPath(rel);
177
+ const owner = graph.resolver.ownerOf(rel);
178
+ return classified.status === 'owned' && classified.slot === 'fe.transport.generated' &&
179
+ owner?.slot === apiOwner.slot && owner.root === apiOwner.root;
180
+ };
181
+ return names.length > 0 && names.every(name => isBoundsExport(ts, to.abs, name, ownsGenerated));
182
+ }
183
+ function inspectTierEdge(graph, ts, edge, tierRule, counts, violations, coverage) {
143
184
  const verdict = graph.resolver.importAllowed(edge.from, edge.to);
144
185
  if (verdict.reason === 'unowned' || verdict.reason?.startsWith('slot')) {
145
186
  coverage.unclassifiedEdges += 1;
146
187
  return;
147
188
  }
148
189
  coverage.edgesChecked += 1;
149
- if (verdict.reason === 'tierDirection' && registryListImport(graph.resolver, edge))
190
+ if (verdict.reason === 'tierDirection' && (registryListImport(graph.resolver, edge) || contractMetadataEdge(graph, ts, edge, verdict)))
150
191
  return;
151
192
  reportReasonedTierViolation(graph, edge, verdict, tierRule, counts, violations);
152
193
  }
@@ -197,14 +238,14 @@ function addOwnerCycleViolations(adjacency, witness, violations) {
197
238
  }
198
239
  return components.length;
199
240
  }
200
- export function checkTiers(graph) {
241
+ export function checkTiers(graph, ts) {
201
242
  const { resolver, profile } = graph;
202
243
  const violations = [];
203
244
  const tierRule = profile === 'be' ? 'BE_TIER_DIRECTION' : 'FE_TIER_DIRECTION';
204
245
  const coverage = { edgesChecked: 0, unclassifiedEdges: 0 };
205
246
  const counts = { tierDirection: 0, layerOrder: 0, crossApp: 0 };
206
247
  for (const edge of graph.edges)
207
- inspectTierEdge(graph, edge, tierRule, counts, violations, coverage);
248
+ inspectTierEdge(graph, ts, edge, tierRule, counts, violations, coverage);
208
249
  // Owner cycles: nodes are owner units, edges the imports between two distinct units.
209
250
  const { adjacency, witness } = ownerAdjacency(graph, resolver);
210
251
  const cycles = addOwnerCycleViolations(adjacency, witness, violations);
@@ -5,10 +5,15 @@ import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { revParseQuery } from '../api/git/rev-parse-query.mjs';
7
7
  import { remote as gitRemote } from '../api/git/remote.mjs';
8
- import { gitOutputOf } from '../lib/git.mjs';
8
+ import { gitOutputOf, withoutGitLocalEnv } from '../lib/git.mjs';
9
9
  import { trimTrailingSlashes } from './trailing-slashes.mjs';
10
10
  /** One git call (a scripts/api/git call file) in `root`: its trimmed stdout; throws unless git exits 0. */
11
- const git = (call, root, args) => gitOutputOf(call(args, { cwd: root })).trim(); // gitOutputOf threw unless git exited 0, whose stdout is text
11
+ const git = (call, root, args, env) => gitOutputOf(call(args, { cwd: root, env: identityQueryEnvironment(env) })).trim(); // gitOutputOf threw unless git exited 0, whose stdout is text
12
+ /** Identity queries need repository configuration, never age identities or selected service connection credentials. */
13
+ export function identityQueryEnvironment(parent, privateNames = []) {
14
+ const selected = new Set(privateNames.map((name) => name.toUpperCase()));
15
+ return Object.fromEntries(Object.entries(withoutGitLocalEnv(parent)).filter(([name]) => !selected.has(name.toUpperCase()) && !/^(?:SOPS_AGE_KEY(?:_FILE)?|SONAR_.*)$/iu.test(name)));
16
+ }
12
17
  const real = (p) => { try {
13
18
  return fs.realpathSync(p);
14
19
  }
@@ -26,20 +31,20 @@ catch {
26
31
  return null;
27
32
  } };
28
33
  /** { repositoryRoot, inWorkTree, home } for `root`: home is the main checkout folder when root is a repository top level. */
29
- function identityOf(root) {
34
+ function identityOf(root, env = process.env) {
30
35
  const state = stateOf(root);
31
36
  if (state === null)
32
- return askGit(root);
37
+ return askGit(root, env);
33
38
  if (!known.has(state))
34
- known.set(state, askGit(root));
39
+ known.set(state, askGit(root, env));
35
40
  return known.get(state);
36
41
  }
37
- function askGit(root) {
42
+ function askGit(root, env) {
38
43
  let inWorkTree = false, repositoryRoot = false, home = null;
39
44
  try {
40
45
  // Identity comes from Git only when root IS a repository (or worktree) top level, never a folder inside one.
41
46
  // One git call answers both questions: the top level on the first line, the common git dir on the second.
42
- const [top, commonDir] = git(revParseQuery, root, ['--show-toplevel', '--git-common-dir']).split(String.fromCodePoint(10)).map((line) => line.trim());
47
+ const [top, commonDir] = git(revParseQuery, root, ['--show-toplevel', '--git-common-dir'], env).split(String.fromCodePoint(10)).map((line) => line.trim());
43
48
  repositoryRoot = real(top) === real(root);
44
49
  inWorkTree = true;
45
50
  const common = path.resolve(root, commonDir);
@@ -55,11 +60,11 @@ function askGit(root) {
55
60
  * name outside Git, else the folder name. A folder inside a larger repository (a nested product, an example) is named by
56
61
  * its own folder.
57
62
  */
58
- export function repositoryName(root) {
59
- const { inWorkTree, repositoryRoot, home } = identityOf(root);
63
+ export function repositoryName(root, { env = process.env } = {}) {
64
+ const { inWorkTree, repositoryRoot, home } = identityOf(root, env);
60
65
  if (repositoryRoot) {
61
66
  try {
62
- const remote = trimTrailingSlashes(git(gitRemote, root, ['get-url', 'origin'])).replace(GIT_SUFFIX, '');
67
+ const remote = trimTrailingSlashes(git(gitRemote, root, ['get-url', 'origin'], env)).replace(GIT_SUFFIX, '');
63
68
  const name = remote.split(/[/:]/u).pop();
64
69
  if (name)
65
70
  return name;