@starci/eslint-canon-fe 9.9.2 → 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 +12 -0
- package/dist/lib/slot-read-origin.d.ts +6 -0
- package/dist/lib/slot-read-origin.js +203 -0
- package/dist/skeleton-states.js +16 -6
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +6 -6
- package/runtime/scripts/hfs/architecture/canon-jsx-clones.mjs +47 -3
- package/runtime/scripts/hfs/architecture/contracts.mjs +3 -0
- package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +19 -5
- package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
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
|
+
|
|
13
|
+
## 9.9.3 - 2026-11-02
|
|
14
|
+
|
|
15
|
+
- Changed: bundled canonical pins follow the consolidated Grammar 0.21.3 preservation API cohort; runtime rules are unchanged.
|
|
16
|
+
|
|
5
17
|
## 9.9.2 - 2026-11-02
|
|
6
18
|
|
|
7
19
|
- Changed: bundled pins follow Grammar 0.21.2 with disabled tab items and its coherent canon cohort; existing runtime checks 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
|
+
};
|
package/dist/skeleton-states.js
CHANGED
|
@@ -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
|
|
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
|
|
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 =
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
@@ -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.
|
|
34
|
+
version: 0.21.3
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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:
|
|
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
|
|
32
|
-
|
|
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
|
|
24
|
-
*
|
|
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
|
-
*
|
|
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;
|