@starci/eslint-canon-fe 9.9.3 → 9.9.5

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
@@ -2,6 +2,14 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 9.9.5 - 2026-11-02
6
+
7
+ - Changed: synchronized the canonical function-provenance runtime and HFS 5.0.1 cohort pins; the Grammar component runtime stays at 0.21.3.
8
+
9
+ ## 9.9.4 - 2026-11-02
10
+
11
+ - Changed: refreshed bundled canonical pins for the HFS 5.0.0 project quality-gate cohort; the Grammar component runtime stays at 0.21.3.
12
+
5
13
  ## 9.9.3 - 2026-11-02
6
14
 
7
15
  - Changed: bundled canonical pins follow the consolidated Grammar 0.21.3 preservation API cohort; runtime rules are unchanged.
@@ -0,0 +1,6 @@
1
+ import type { TSESTree } from "@typescript-eslint/utils";
2
+ import type { AnyContext, Node } from "./rule.js";
3
+ /** A memo is a projection, not a read: trace data accesses inside it back to their hook-result bindings. */
4
+ export declare const projectedReads: (context: AnyContext, root: TSESTree.Identifier) => readonly TSESTree.Identifier[] | null;
5
+ /** Every underlying read is folded and the returned projector actually consumes this fallback expression. */
6
+ export declare const slotProtectsFold: (context: AnyContext, fold: TSESTree.LogicalExpression, fn: Node) => boolean;
@@ -0,0 +1,203 @@
1
+ /** R367: prove that a returned canonical slot consumes the folded data and the reads that produced it. */
2
+ import path from "node:path";
3
+ import ts from "typescript";
4
+ import { importOf, unwrap, variableOf } from "./bindings.js";
5
+ const initializer = (context, node) => {
6
+ const definition = variableOf(context, node)?.defs.find(entry => entry.type === "Variable" && entry.parent?.kind === "const");
7
+ return definition?.node.type === "VariableDeclarator" && definition.node.id.type === "Identifier" ? definition.node.init : null;
8
+ };
9
+ /** Property names and object keys do not read an identically named variable. */
10
+ const reference = (node) => !(node.parent.type === "MemberExpression" && node.parent.property === node && !node.parent.computed)
11
+ && !(node.parent.type === "Property" && node.parent.key === node && !node.parent.computed && !node.parent.shorthand);
12
+ const visits = (context, root, visit) => {
13
+ visit(root);
14
+ for (const key of context.sourceCode.visitorKeys[root.type] ?? []) {
15
+ const value = Reflect.get(root, key);
16
+ const children = Array.isArray(value) ? value : [value];
17
+ for (const child of children)
18
+ if (child !== null && typeof child === "object" && "type" in child)
19
+ visits(context, child, visit);
20
+ }
21
+ };
22
+ /** Follow const aliases by lexical binding; a similarly named variable in another callback is unrelated. */
23
+ const dependsOn = (context, input, target, seen = new Set()) => {
24
+ if (input === target)
25
+ return true;
26
+ if (seen.has(input))
27
+ return false;
28
+ seen.add(input);
29
+ let found = false;
30
+ visits(context, input, node => {
31
+ if (node === target)
32
+ found = true;
33
+ if (node.type !== "Identifier" || !reference(node))
34
+ return;
35
+ const value = initializer(context, node);
36
+ if (value && dependsOn(context, value, target, seen))
37
+ found = true;
38
+ });
39
+ return found;
40
+ };
41
+ const access = (input, computed = false) => {
42
+ const keys = [];
43
+ let node = unwrap(input);
44
+ while (node.type === "MemberExpression" || node.type === "CallExpression") {
45
+ if (node.type === "CallExpression") {
46
+ node = unwrap(node.callee);
47
+ continue;
48
+ }
49
+ const key = !node.computed && node.property.type === "Identifier" ? node.property.name : node.property.type === "Literal" && typeof node.property.value === "string" ? node.property.value : null;
50
+ if (key === null && !computed)
51
+ return null;
52
+ keys.unshift(key ?? "*");
53
+ node = unwrap(node.object);
54
+ }
55
+ return node.type === "Identifier" ? { root: node, keys } : null;
56
+ };
57
+ const sameAccess = (context, input, root, keys) => {
58
+ const candidate = access(input);
59
+ return candidate !== null && variableOf(context, candidate.root) === variableOf(context, root)
60
+ && candidate.keys.length === keys.length && candidate.keys.every((key, index) => key === keys[index]);
61
+ };
62
+ const canonicalFold = (context, node) => {
63
+ const callee = unwrap(node.callee);
64
+ if (callee.type !== "Identifier")
65
+ return false;
66
+ const imported = importOf(context, callee);
67
+ if (imported?.imported !== "slotOfReads")
68
+ return false;
69
+ const source = imported.source.startsWith(".") ? path.resolve(path.dirname(context.filename), imported.source).replaceAll("\\", "/") : imported.source;
70
+ return /(?:^|\/)hooks\/slot(?:\/index)?(?:\.[cm]?ts)?$/.test(source);
71
+ };
72
+ const ownFunction = (node) => {
73
+ for (let parent = node.parent; parent; parent = parent.parent) {
74
+ if (parent.type === "ArrowFunctionExpression" || parent.type === "FunctionDeclaration" || parent.type === "FunctionExpression")
75
+ return parent;
76
+ }
77
+ return null;
78
+ };
79
+ /** A memo is a projection, not a read: trace data accesses inside it back to their hook-result bindings. */
80
+ export const projectedReads = (context, root) => {
81
+ const call = unwrap(initializer(context, root));
82
+ if (call?.type !== "CallExpression")
83
+ return null;
84
+ const callee = unwrap(call.callee);
85
+ const imported = callee.type === "Identifier" ? importOf(context, callee) : null;
86
+ if (imported?.source !== "react" || imported.imported !== "useMemo")
87
+ return null;
88
+ const roots = [];
89
+ visits(context, call, node => {
90
+ if (node.type !== "MemberExpression" || node.computed || node.property.type !== "Identifier" || node.property.name !== "data")
91
+ return;
92
+ const owner = access(node.object);
93
+ if (!owner)
94
+ return;
95
+ const origin = unwrap(initializer(context, owner.root));
96
+ if (origin?.type === "CallExpression" && origin.callee.type === "Identifier" && /^use[A-Z]/.test(origin.callee.name)
97
+ && !roots.some(known => variableOf(context, known) === variableOf(context, owner.root)))
98
+ roots.push(owner.root);
99
+ });
100
+ return roots;
101
+ };
102
+ const readArray = (context, input, seen = new Set()) => {
103
+ const node = unwrap(input);
104
+ if (!node || seen.has(node))
105
+ return null;
106
+ seen.add(node);
107
+ if (node.type === "Identifier")
108
+ return readArray(context, initializer(context, node) ?? undefined, seen);
109
+ return node.type === "ArrayExpression" && node.elements.every(element => element !== null && element.type !== "SpreadElement") ? node.elements : null;
110
+ };
111
+ /** A composite hook's returned data is traced to its own read declarations, then back to its public read paths. */
112
+ const compositeReads = (context, root, output) => {
113
+ const definition = variableOf(context, root)?.defs.find(entry => entry.type === "Variable");
114
+ const call = definition?.node.type === "VariableDeclarator" ? unwrap(definition.node.init) : null;
115
+ const services = context.sourceCode.parserServices;
116
+ const checker = services?.program?.getTypeChecker();
117
+ const mapped = call && services?.esTreeNodeToTSNodeMap?.get(call);
118
+ if (!checker || !mapped || !ts.isCallExpression(mapped))
119
+ return null;
120
+ const declaration = checker.getResolvedSignature(mapped)?.declaration;
121
+ if (!declaration || !(ts.isArrowFunction(declaration) || ts.isFunctionDeclaration(declaration) || ts.isFunctionExpression(declaration)) || !declaration.body)
122
+ return null;
123
+ const returned = ts.isBlock(declaration.body) ? declaration.body.statements.filter(ts.isReturnStatement) : [];
124
+ const value = ts.isBlock(declaration.body) ? returned.length === 1 ? returned[0]?.expression : undefined : declaration.body;
125
+ if (!value || !ts.isObjectLiteralExpression(value))
126
+ return null;
127
+ const expressionOf = (property) => ts.isPropertyAssignment(property) ? property.initializer : ts.isShorthandPropertyAssignment(property) ? property.name : undefined;
128
+ const field = value.properties.find(property => property.name && (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) && property.name.text === output);
129
+ const data = field && expressionOf(field);
130
+ if (!data)
131
+ return null;
132
+ const symbolOf = (node) => ts.isShorthandPropertyAssignment(node.parent)
133
+ ? checker.getShorthandAssignmentValueSymbol(node.parent) : checker.getSymbolAtLocation(node);
134
+ const variable = (node) => symbolOf(node)?.declarations?.find(ts.isVariableDeclaration);
135
+ const hookRead = (node) => {
136
+ const init = variable(node)?.initializer;
137
+ return init && ts.isCallExpression(init) && ts.isIdentifier(init.expression) && /^use[A-Z]/.test(init.expression.text) ? symbolOf(node) : undefined;
138
+ };
139
+ const origins = new Set();
140
+ const seen = new Set();
141
+ const collect = (node) => {
142
+ if (seen.has(node))
143
+ return;
144
+ seen.add(node);
145
+ if (ts.isPropertyAccessExpression(node) && node.name.text === "data" && ts.isIdentifier(node.expression)) {
146
+ const origin = hookRead(node.expression);
147
+ if (origin) {
148
+ origins.add(origin);
149
+ return;
150
+ }
151
+ }
152
+ if (ts.isIdentifier(node) && !(ts.isPropertyAccessExpression(node.parent) && node.parent.name === node)) {
153
+ const init = variable(node)?.initializer;
154
+ if (init && init.getSourceFile() === declaration.getSourceFile())
155
+ collect(init);
156
+ }
157
+ ts.forEachChild(node, collect);
158
+ };
159
+ collect(data);
160
+ if (origins.size === 0)
161
+ return null;
162
+ const paths = new Map();
163
+ const exposed = (node, keys) => {
164
+ if (ts.isIdentifier(node)) {
165
+ const origin = hookRead(node);
166
+ if (origin)
167
+ paths.set(origin, keys);
168
+ }
169
+ else if (ts.isObjectLiteralExpression(node)) {
170
+ for (const property of node.properties) {
171
+ const expression = expressionOf(property);
172
+ if (expression && property.name && (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)))
173
+ exposed(expression, [...keys, property.name.text]);
174
+ }
175
+ }
176
+ };
177
+ exposed(value, []);
178
+ return [...origins].every(origin => paths.has(origin)) ? [...origins].map(origin => paths.get(origin)) : null;
179
+ };
180
+ /** Every underlying read is folded and the returned projector actually consumes this fallback expression. */
181
+ export const slotProtectsFold = (context, fold, fn) => {
182
+ const source = access(fold.left, true);
183
+ if (!source)
184
+ return false;
185
+ const required = source.keys.length > 0 ? compositeReads(context, source.root, source.keys[0]) : null;
186
+ let protectedFold = false;
187
+ visits(context, fn, node => {
188
+ if (node.type !== "CallExpression" || ownFunction(node) !== fn || !canonicalFold(context, node))
189
+ return;
190
+ const reads = readArray(context, node.arguments[0]);
191
+ const projector = unwrap(node.arguments[1]);
192
+ if (!reads || !projector || projector.type !== "ArrowFunctionExpression" || !dependsOn(context, projector.body, fold))
193
+ return;
194
+ const paths = required ?? [[]];
195
+ if (!paths.every(keys => reads.some(read => sameAccess(context, read, source.root, keys))))
196
+ return;
197
+ visits(context, fn, candidate => {
198
+ if (candidate.type === "ReturnStatement" && ownFunction(candidate) === fn && candidate.argument && dependsOn(context, candidate.argument, node))
199
+ protectedFold = true;
200
+ });
201
+ });
202
+ return protectedFold;
203
+ };
@@ -1,4 +1,6 @@
1
1
  import { defineRule } from "./lib/rule.js";
2
+ import { importOf } from "./lib/bindings.js";
3
+ import { projectedReads, slotProtectsFold } from "./lib/slot-read-origin.js";
2
4
  const pathOf = (context) => context.filename.replaceAll("\\", "/");
3
5
  /** A hook that reads data for a view: under `hooks/`, and not the SWR layer that makes the reads (`hooks/swr`). */
4
6
  const isViewHook = (context) => {
@@ -48,7 +50,10 @@ const isHookResult = (context, node, identifier) => {
48
50
  return false;
49
51
  const init = unwrap(declarator.init);
50
52
  const callee = init?.type === "CallExpression" ? unwrap(init.callee) : null;
51
- const name = callee?.type === "Identifier" ? callee.name : callee?.type === "MemberExpression" && callee.property.type === "Identifier" ? callee.property.name : null;
53
+ const imported = callee?.type === "Identifier" ? importOf(context, callee) : callee?.type === "MemberExpression" && callee.object.type === "Identifier" ? importOf(context, callee.object) : null;
54
+ if (imported?.source === "react" && imported.imported !== "useMemo")
55
+ return false;
56
+ const name = imported?.source === "react" ? imported.imported : callee?.type === "Identifier" ? callee.name : callee?.type === "MemberExpression" && callee.property.type === "Identifier" ? callee.property.name : null;
52
57
  return name !== null && /^use[A-Z]/.test(name);
53
58
  });
54
59
  }
@@ -78,12 +83,17 @@ const noSwrDefaultArray = defineRule({
78
83
  if (!fn)
79
84
  return;
80
85
  const text = source.getText(fn);
81
- const escaped = root.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
86
+ const projections = projectedReads(context, root);
87
+ if (projections?.length === 0)
88
+ return;
82
89
  // The read's own status is read in the same hook: `read.isLoading`, or destructured next to `data`.
83
- const readsStatus = new RegExp(`\\b${escaped}\\??\\.(?:isLoading|error|isError)\\b`).test(text)
84
- || new RegExp(`\\{[^}]*\\b(?:isLoading|error)\\b[^}]*\\}\\s*=\\s*${escaped}\\b`).test(text)
85
- || (root.name === "data" && /\bisLoading\b/.test(text));
86
- if (readsStatus)
90
+ const readsStatus = (projections ?? [root]).every(read => {
91
+ const escaped = read.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
92
+ return new RegExp(`\\b${escaped}\\??\\.(?:isLoading|error|isError)\\b`).test(text)
93
+ || new RegExp(`\\{[^}]*\\b(?:isLoading|error)\\b[^}]*\\}\\s*=\\s*${escaped}\\b`).test(text)
94
+ || (read.name === "data" && /\bisLoading\b/.test(text));
95
+ });
96
+ if (readsStatus || slotProtectsFold(context, node, fn))
87
97
  return;
88
98
  context.report({ node, messageId: "fold", data: { read: source.getText(node.left).slice(0, 40) } });
89
99
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/eslint-canon-fe",
3
- "version": "9.9.3",
3
+ "version": "9.9.5",
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",
@@ -38,28 +38,28 @@ pins:
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.10
41
+ version: 3.11.12
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.3
48
+ version: 9.9.5
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.3
55
+ version: 0.21.5
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.3
62
+ version: 0.21.5
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: 4.17.3
109
+ version: 5.0.1
110
110
  group: starci
111
111
  install: registry
112
112
  side: both
@@ -17,7 +17,7 @@ function importBindings(ts, scope, statement) {
17
17
  return;
18
18
  const module = statement.moduleSpecifier.text;
19
19
  if (clause.name)
20
- scope.bindings.set(clause.name.text, { kind: 'component' });
20
+ scope.bindings.set(clause.name.text, { kind: 'component', module, name: 'default' });
21
21
  const named = clause.namedBindings;
22
22
  if (!named)
23
23
  return;
@@ -28,8 +28,9 @@ function importBindings(ts, scope, statement) {
28
28
  if (element.isTypeOnly)
29
29
  continue;
30
30
  const imported = element.propertyName?.text ?? element.name.text;
31
- const kind = TRANSLATION_MODULES.has(module) && TRANSLATION_FACTORIES.has(imported) ? 'translation-factory' : 'component';
32
- scope.bindings.set(element.name.text, { kind });
31
+ const binding = TRANSLATION_MODULES.has(module) && TRANSLATION_FACTORIES.has(imported)
32
+ ? { kind: 'translation-factory' } : { kind: 'component', module, name: imported };
33
+ scope.bindings.set(element.name.text, binding);
33
34
  }
34
35
  }
35
36
  function lexicalBindings(ts, sourceFile) {
@@ -151,12 +152,55 @@ function nestedDataObjects(ts, expression, bindingOf, excluded) {
151
152
  nestedDataObjects(ts, element, bindingOf, excluded);
152
153
  }
153
154
  }
155
+ /** Resolve the canonical status owner through its import binding, including aliases and namespaces. */
156
+ function statusOwner(ts, tag, bindingOf) {
157
+ const binding = ts.isIdentifier(tag) ? bindingOf(tag) : ts.isPropertyAccessExpression(tag) && ts.isIdentifier(tag.expression) ? bindingOf(tag.expression) : null;
158
+ const module = binding?.kind === 'component' || binding?.kind === 'namespace' ? binding.module : null;
159
+ const name = binding?.kind === 'component' ? binding.name : ts.isPropertyAccessExpression(tag) ? tag.name.text : null;
160
+ return name === 'SlotView' && module !== null && /(?:^|\/)components\/composites\/SlotView(?:\/index)?(?:\.[cm]?tsx?)?$/.test(module);
161
+ }
162
+ /** The mandated status wiring breaks clone windows; its render body remains ordinary compared code. */
163
+ function statusRecipe(ts, opening, bindingOf, excluded) {
164
+ if (!statusOwner(ts, opening.tagName, bindingOf))
165
+ return;
166
+ const attributes = opening.attributes.properties;
167
+ const render = attributes.find(attribute => ts.isJsxAttribute(attribute) && ts.isIdentifier(attribute.name) && attribute.name.text === 'render');
168
+ const value = render && ts.isJsxAttribute(render) && render.initializer && ts.isJsxExpression(render.initializer) ? unwrap(ts, render.initializer.expression) : undefined;
169
+ if (!value || !ts.isArrowFunction(value) || value.parameters.length !== 2 || ts.isBlock(value.body))
170
+ return;
171
+ const waiting = value.parameters[1];
172
+ if (!waiting || !ts.isIdentifier(waiting.name) || waiting.initializer || waiting.dotDotDotToken)
173
+ return;
174
+ const waitingBinding = bindingOf(waiting.name);
175
+ let forwardsLoading = false;
176
+ const visit = (node) => {
177
+ if (ts.isJsxAttribute(node) && ts.isIdentifier(node.name) && node.name.text === 'isLoading'
178
+ && node.initializer && ts.isJsxExpression(node.initializer)) {
179
+ const flag = unwrap(ts, node.initializer.expression);
180
+ if (flag && ts.isIdentifier(flag) && waitingBinding && bindingOf(flag) === waitingBinding)
181
+ forwardsLoading = true;
182
+ }
183
+ ts.forEachChild(node, visit);
184
+ };
185
+ visit(value.body);
186
+ if (!forwardsLoading)
187
+ return;
188
+ const wiring = attributes.filter(attribute => ts.isJsxSpreadAttribute(attribute) ? dataMapping(ts, attribute.expression, bindingOf)
189
+ : ts.isIdentifier(attribute.name) && ['slot', 'placeholder', 'labels', 'onRetry'].includes(attribute.name.text)
190
+ && !!attribute.initializer && ts.isJsxExpression(attribute.initializer) && dataMapping(ts, attribute.initializer.expression, bindingOf));
191
+ const hasSlot = wiring.some(attribute => ts.isJsxSpreadAttribute(attribute) || ts.isJsxAttribute(attribute) && ts.isIdentifier(attribute.name) && attribute.name.text === 'slot');
192
+ if (!hasSlot)
193
+ return;
194
+ for (const attribute of wiring)
195
+ excluded.add(attribute);
196
+ }
154
197
  /** Canon data prop objects qualify through an imported component binding, never its chosen name or file path. */
155
198
  export function canonJsxCloneNodes(ts, sourceFile) {
156
199
  const bindingOf = lexicalBindings(ts, sourceFile);
157
200
  const excluded = new Set();
158
201
  const visit = (node) => {
159
202
  if ((ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) && importedComponent(ts, node.tagName, bindingOf)) {
203
+ statusRecipe(ts, node, bindingOf, excluded);
160
204
  for (const attribute of node.attributes.properties) {
161
205
  if (!ts.isJsxAttribute(attribute) || !attribute.initializer || !ts.isJsxExpression(attribute.initializer))
162
206
  continue;
@@ -203,6 +203,9 @@ function signatureParameterStatus(ts, checker, signature, location, parameter, s
203
203
  const actual = parameterSymbol ? checker.getTypeOfSymbolAtLocation(parameterSymbol, location) : null;
204
204
  if (actual && parameter.questionToken && !ts.isUnionTypeNode(parameter.type) && hasAnyFlag(actual.flags, ts.TypeFlags.Union)) {
205
205
  const selected = actual.types.filter((type) => !hasAnyFlag(type.flags, ts.TypeFlags.Undefined));
206
+ // An optional boolean is represented as true | false | undefined, not one Boolean member.
207
+ if (selected.length > 0 && selected.every((type) => hasAnyFlag(type.flags, ts.TypeFlags.BooleanLike)))
208
+ return 'scalar';
206
209
  return selected.length === 1 ? contractTypeStatusFromType(ts, checker, selected[0]) : 'inline';
207
210
  }
208
211
  return actual ? contractTypeStatusFromType(ts, checker, actual) : contractTypeStatus(ts, checker, parameter.type);
@@ -1,4 +1,5 @@
1
1
  import path from 'node:path';
2
+ import { normalizedSymbol } from './ast-walks.mjs';
2
3
  export const keyOf = (ts, property) => (ts.isPropertyAssignment(property) || ts.isShorthandPropertyAssignment(property)) && (ts.isIdentifier(property.name) || ts.isStringLiteralLike(property.name)) ? property.name.text : null;
3
4
  const exported = (ts, statement) => statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword) ?? false;
4
5
  /** The directly defined functions among the declarations of an exported variable statement; other values are not callable. */
@@ -19,17 +20,30 @@ function callableExports(ts, statement) {
19
20
  return statement.name && statement.body ? [statement.name.text] : [];
20
21
  return ts.isVariableStatement(statement) ? callableVariables(ts, statement) : [];
21
22
  }
23
+ /** Named value re-exports count only when the checker resolves an actual function implementation. */
24
+ function callableReexports(ts, checker, statement) {
25
+ if (!ts.isExportDeclaration(statement) || statement.isTypeOnly || !statement.exportClause || !ts.isNamedExports(statement.exportClause))
26
+ return [];
27
+ return statement.exportClause.elements.filter(element => !element.isTypeOnly && (normalizedSymbol(ts, checker, element.name)?.declarations ?? []).some(declaration => {
28
+ if (declaration.getSourceFile().isDeclarationFile)
29
+ return false;
30
+ if (ts.isFunctionDeclaration(declaration))
31
+ return Boolean(declaration.body);
32
+ return ts.isVariableDeclaration(declaration) && Boolean(declaration.initializer
33
+ && (ts.isArrowFunction(declaration.initializer) || ts.isFunctionExpression(declaration.initializer)));
34
+ })).map(element => element.name.text);
35
+ }
22
36
  /**
23
- * The callable subset of a function subject's exports. Constants, enums, objects, types, `export ... from` re-exports and overload
24
- * signatures do not change which functions the spec must provide; a class or a default-export assignment keeps the class-provider
25
- * check (null), as does a module with no callable export.
37
+ * The callable subset of a function subject's exports, including named re-exports resolved to real implementations. Constants,
38
+ * enums, objects, type-only exports and bodyless declarations do not count; a class or a default-export assignment keeps the
39
+ * class-provider check (null), as does a module with no callable export.
26
40
  */
27
- export function functionSubjectExports(ts, sourceFile) {
41
+ export function functionSubjectExports(ts, sourceFile, checker) {
28
42
  const names = [];
29
43
  for (const statement of sourceFile.statements) {
30
44
  if (ts.isClassDeclaration(statement) || ts.isExportAssignment(statement))
31
45
  return null;
32
- for (const name of callableExports(ts, statement))
46
+ for (const name of [...callableExports(ts, statement), ...callableReexports(ts, checker, statement)])
33
47
  if (!names.includes(name))
34
48
  names.push(name);
35
49
  }
@@ -18,7 +18,7 @@ import { checkFunctionSubject, functionSubjectExports, keyOf as literalKey, valu
18
18
  * constructor-deps.mjs). Providers are matched by the exported name of the imported binding (`import { CACHE as C }` is
19
19
  * CACHE), never by text. A provider is a class, or `{ provide: TOKEN, useValue: double }` and nothing else. A sibling
20
20
  * containing only exported functions instead provides its complete real function object and exercises it through module.get;
21
- * the constants, enums, re-exports and overload signatures beside those functions do not count. A value-object subject (an
21
+ * callable named re-exports count, while constants, enums, type-only exports and overload signatures do not. A value-object subject (an
22
22
  * undecorated class whose constructor parameters carry no injection token) is built with `new`, Nest never constructs it.
23
23
  *
24
24
  * Findings: an extra provider, a missing provider, a service that is not the first provider, a dependency whose token cannot
@@ -111,7 +111,7 @@ function checkTestingModule({ ts, kit, rel, serviceRel, serviceFile, imports, ca
111
111
  const list = providerList(ts, call, rel, report);
112
112
  if (!list)
113
113
  return;
114
- const names = functionSubjectExports(ts, serviceFile.sourceFile);
114
+ const names = functionSubjectExports(ts, serviceFile.sourceFile, kit.checkerOf(serviceFile.sourceFile));
115
115
  if (names) {
116
116
  checkFunctionSubject({ ts, rel, serviceRel, imports, call, list, names, report });
117
117
  return;