@starci/eslint-canon-fe 1.4.5 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @starci/eslint-canon-fe
2
2
 
3
- **58 ESLint rules, from 16 laws, that hold a React front end to one way of being written.**
3
+ **74 ESLint rules, from 18 laws, that hold a React front end to one way of being written.**
4
4
 
5
5
  Not a style pack. These rules enforce *architecture*: which tier a component belongs to, whether
6
6
  structure may be typed as a raw class string, where a vendor primitive may be imported, which file
@@ -55,7 +55,13 @@ A sample, not the list:
55
55
  | `no-resting-twin-component` | A hand-kept skeleton tree that drifts from the thing it stands in for |
56
56
  | `no-inline-lint-config` | A disable comment switching the architecture off for one line |
57
57
  | `require-export-jsdoc` | An exported symbol with no stated reason to exist |
58
- | `no-second-language-in-source` | Copy hardcoded where the translation layer should own it |
58
+ | `no-second-language-in-source` | Copy hardcoded where the translation layer should own it |
59
+ | `no-core-grammar-value-import-outside-adapter` | Core grammar values imported by product code instead of one adapter owner |
60
+ | `surface-branch-requires-contract-render` | A surface used as a JSX children container instead of the contract/render lane |
61
+ | `no-reactnode-escape-slot` | Pre-built ReactNode/JSX escaping a typed component boundary |
62
+ | `runtime-contract-content-requires-component-type` | Runtime props paired with bound content instead of a ComponentType render |
63
+ | `tree-contract-render-identity` | Tree/surface and its render contract claiming different identities |
64
+ | `block-root-is-typed-composition` | A block rooted in an anonymous fragment or structural host element |
59
65
 
60
66
  Every rule names the law that declares it — `ruleOwners` maps rule name to law, so a failing build
61
67
  line leads straight to the document that explains why.
@@ -0,0 +1,431 @@
1
+ /**
2
+ * The executable boundary between product components and `@starci/grammar`.
3
+ *
4
+ * Grammar describes a tree and branch adapters render it. Product blocks compose those typed
5
+ * pieces; they do not import Core values, smuggle pre-built JSX through ReactNode slots, or open a
6
+ * second runtime lane beside ComponentType. These rules keep that boundary mechanical.
7
+ */
8
+
9
+ const normalizePath = (value) => String(value || "").replace(/\\/g, "/")
10
+
11
+ const SURFACES = new Set(["SurfaceCard", "SurfaceFormCard", "SurfaceListCard", "HighlightCard"])
12
+ const TYPED_ROOTS = new Set([...SURFACES, "Tree"])
13
+
14
+ const filenameOf = (context) => normalizePath(context.filename || context.getFilename())
15
+ const isTestFile = (filename) => /(?:^|\/)(?:__tests__\/.*|[^/]+\.(?:test|spec))\.[^/]+$/.test(filename)
16
+ const isBranchOwner = (filename) => filename.includes("/src/components/branches/")
17
+ const isGrammarContract = (filename) => /\/src\/components\/contracts\/grammar\.[^/]+$/.test(filename)
18
+ const isProductSource = (filename) => filename.includes("/src/") && !isTestFile(filename)
19
+
20
+ const jsxName = (node) => node?.name?.type === "JSXIdentifier" ? node.name.name : null
21
+
22
+ const attribute = (node, name) =>
23
+ (node.attributes || []).find(
24
+ (entry) => entry.type === "JSXAttribute" && entry.name?.type === "JSXIdentifier" && entry.name.name === name,
25
+ )
26
+
27
+ const expressionOf = (attributeNode) => {
28
+ const value = attributeNode?.value
29
+ if (!value) return null
30
+ return value.type === "JSXExpressionContainer" ? value.expression : value
31
+ }
32
+
33
+ const unwrapExpression = (node) => {
34
+ let current = node
35
+ while (["TSAsExpression", "TSTypeAssertion", "TSNonNullExpression", "ChainExpression"].includes(current?.type)) {
36
+ current = current.expression
37
+ }
38
+ return current
39
+ }
40
+
41
+ const literalString = (node) => {
42
+ const value = unwrapExpression(node)
43
+ if (value?.type === "Literal" && typeof value.value === "string") return value.value
44
+ if (value?.type === "TemplateLiteral" && value.expressions.length === 0) return value.quasis[0]?.value?.cooked ?? null
45
+ return null
46
+ }
47
+
48
+ const defineContractCall = (node) => {
49
+ const value = unwrapExpression(node)
50
+ return value?.type === "CallExpression" && value.callee?.type === "Identifier" && value.callee.name === "defineContractComponent"
51
+ ? value
52
+ : null
53
+ }
54
+
55
+ const meaningfulChildren = (openingElement) => {
56
+ const children = openingElement.parent?.children || []
57
+ return children.filter((child) => {
58
+ if (child.type === "JSXText") return child.value.trim().length > 0
59
+ if (child.type === "JSXExpressionContainer") return child.expression?.type !== "JSXEmptyExpression"
60
+ return true
61
+ })
62
+ }
63
+
64
+ // -- GRAMMAR-1 -----------------------------------------------------------------------------------
65
+
66
+ /** Product source consumes adapters; only adapter/contract owners may import Core grammar values. */
67
+ export const noCoreGrammarValueImportOutsideAdapter = {
68
+ meta: {
69
+ type: "problem",
70
+ docs: { description: "Core grammar values are imported only by branch adapters and the grammar contract owner." },
71
+ schema: [],
72
+ messages: {
73
+ boundary:
74
+ "`{{source}}` is the Core grammar value lane. Product source must consume a typed branch/composition adapter; only components/branches and components/contracts/grammar may import these values.",
75
+ },
76
+ },
77
+ create(context) {
78
+ const filename = filenameOf(context)
79
+ if (!isProductSource(filename) || isBranchOwner(filename) || isGrammarContract(filename)) return {}
80
+ return {
81
+ ImportDeclaration(node) {
82
+ const source = String(node.source?.value || "")
83
+ if (!/^@starci\/grammar(?:\/|$)/.test(source)) return
84
+ const valueSpecifiers = (node.specifiers || []).filter(
85
+ (specifier) => node.importKind !== "type" && specifier.importKind !== "type",
86
+ )
87
+ if (valueSpecifiers.length > 0) context.report({ node, messageId: "boundary", data: { source } })
88
+ },
89
+ }
90
+ },
91
+ }
92
+
93
+ // -- GRAMMAR-2 -----------------------------------------------------------------------------------
94
+
95
+ /** Surface branches are invoked through their contract/render lane, never as JSX containers. */
96
+ export const surfaceBranchRequiresContractRender = {
97
+ meta: {
98
+ type: "problem",
99
+ docs: { description: "A surface branch requires contract + render and accepts no JSX children." },
100
+ schema: [],
101
+ messages: {
102
+ lane: "`{{component}}` is a grammar branch. Pass both `contract` and `render`; do not use a raw surface prop lane.",
103
+ children:
104
+ "`{{component}}` received pre-built JSX children. Describe the branch with `contract` + `render` so Tree can validate and own the composition.",
105
+ },
106
+ },
107
+ create(context) {
108
+ const filename = filenameOf(context)
109
+ if (!isProductSource(filename) || isBranchOwner(filename)) return {}
110
+ return {
111
+ JSXOpeningElement(node) {
112
+ const component = jsxName(node)
113
+ if (!component || !SURFACES.has(component)) return
114
+ if (!attribute(node, "contract") || !attribute(node, "render")) {
115
+ context.report({ node, messageId: "lane", data: { component } })
116
+ }
117
+ for (const child of meaningfulChildren(node)) {
118
+ context.report({ node: child, messageId: "children", data: { component } })
119
+ }
120
+ },
121
+ }
122
+ },
123
+ }
124
+
125
+ // -- GRAMMAR-3 -----------------------------------------------------------------------------------
126
+
127
+ const propertyName = (node) => {
128
+ if (node?.key?.type === "Identifier") return node.key.name
129
+ if (node?.key?.type === "Literal" && typeof node.key.value === "string") return node.key.value
130
+ return "slot"
131
+ }
132
+
133
+ const forbiddenReactType = (node, seen = new Set()) => {
134
+ if (!node || typeof node !== "object" || seen.has(node)) return null
135
+ seen.add(node)
136
+ if (node.type === "TSTypeReference") {
137
+ const name = node.typeName?.type === "Identifier" ? node.typeName.name : null
138
+ if (name === "ReactNode" || name === "ReactElement") return name
139
+ }
140
+ if (
141
+ node.type === "TSQualifiedName" &&
142
+ node.left?.type === "Identifier" && node.left.name === "JSX" &&
143
+ node.right?.type === "Identifier" && node.right.name === "Element"
144
+ ) return "JSX.Element"
145
+ if (
146
+ node.type === "TSQualifiedName" &&
147
+ node.left?.type === "Identifier" && node.left.name === "React" &&
148
+ node.right?.type === "Identifier" && ["ReactNode", "ReactElement"].includes(node.right.name)
149
+ ) return `React.${node.right.name}`
150
+ for (const [key, value] of Object.entries(node)) {
151
+ if (key === "parent" || key === "loc" || key === "range" || key === "tokens" || key === "comments") continue
152
+ if (Array.isArray(value)) {
153
+ for (const entry of value) {
154
+ const found = forbiddenReactType(entry, seen)
155
+ if (found) return found
156
+ }
157
+ } else {
158
+ const found = forbiddenReactType(value, seen)
159
+ if (found) return found
160
+ }
161
+ }
162
+ return null
163
+ }
164
+
165
+ /** Component APIs cannot carry already-rendered React values across the grammar boundary. */
166
+ export const noReactnodeEscapeSlot = {
167
+ meta: {
168
+ type: "problem",
169
+ docs: { description: "Product component slots carry typed data/components, never ReactNode or JSX.Element." },
170
+ schema: [],
171
+ messages: {
172
+ escape:
173
+ "`{{slot}}` exposes `{{type}}`, an already-built rendering escape hatch. Pass typed data or a ComponentType through the canonical contract/render lane instead.",
174
+ },
175
+ },
176
+ create(context) {
177
+ const filename = filenameOf(context)
178
+ if (!isProductSource(filename) || isBranchOwner(filename) || isGrammarContract(filename)) return {}
179
+ return {
180
+ TSPropertySignature(node) {
181
+ const type = forbiddenReactType(node.typeAnnotation?.typeAnnotation)
182
+ if (type) context.report({ node, messageId: "escape", data: { slot: propertyName(node), type } })
183
+ },
184
+ }
185
+ },
186
+ }
187
+
188
+ const collectDefinitions = () => {
189
+ const definitions = new Map()
190
+ return {
191
+ definitions,
192
+ visitor: {
193
+ VariableDeclarator(node) {
194
+ if (node.id?.type === "Identifier" && node.init) definitions.set(node.id.name, unwrapExpression(node.init))
195
+ },
196
+ },
197
+ }
198
+ }
199
+
200
+ const resolvedDefineCall = (expression, definitions) => {
201
+ const direct = defineContractCall(expression)
202
+ if (direct) return direct
203
+ const value = unwrapExpression(expression)
204
+ return value?.type === "Identifier" ? defineContractCall(definitions.get(value.name)) : null
205
+ }
206
+
207
+ // -- GRAMMAR-4 -----------------------------------------------------------------------------------
208
+
209
+ /** Runtime props require the ComponentType lane of defineContractComponent. */
210
+ export const runtimeContractContentRequiresComponentType = {
211
+ meta: {
212
+ type: "problem",
213
+ docs: { description: "A surface with runtime props renders a named ComponentType contract." },
214
+ schema: [],
215
+ messages: {
216
+ component:
217
+ "This runtime surface binds content instead of a ComponentType. Define a named render component and pass it as the second argument to `defineContractComponent` before sending runtime `props`.",
218
+ },
219
+ },
220
+ create(context) {
221
+ const filename = filenameOf(context)
222
+ if (!isProductSource(filename) || isBranchOwner(filename)) return {}
223
+ const { definitions, visitor } = collectDefinitions()
224
+ const candidates = []
225
+ return {
226
+ ...visitor,
227
+ JSXOpeningElement(node) {
228
+ const component = jsxName(node)
229
+ if (!component || !SURFACES.has(component)) return
230
+ const runtimeInput = component === "SurfaceListCard"
231
+ ? attribute(node, "props")
232
+ : attribute(node, "contentProps")
233
+ if (!runtimeInput) return
234
+ candidates.push({ node, render: expressionOf(attribute(node, "render")) })
235
+ },
236
+ "Program:exit"() {
237
+ for (const candidate of candidates) {
238
+ const call = resolvedDefineCall(candidate.render, definitions)
239
+ if (!call) continue // an imported, already-typed contract is checked by TypeScript
240
+ const content = unwrapExpression(call.arguments[1])
241
+ if (content?.type !== "Identifier") context.report({ node: candidate.node, messageId: "component" })
242
+ }
243
+ },
244
+ }
245
+ },
246
+ }
247
+
248
+ // -- GRAMMAR-5 -----------------------------------------------------------------------------------
249
+
250
+ /** A rendered contract cannot claim a different identity from its Tree/surface host. */
251
+ export const treeContractRenderIdentity = {
252
+ meta: {
253
+ type: "problem",
254
+ docs: { description: "Tree/surface contract identity matches defineContractComponent identity." },
255
+ schema: [],
256
+ messages: {
257
+ mismatch:
258
+ "Host contract `{{host}}` does not match render contract `{{render}}`. One branch has one identity; use the same key in Tree/surface and defineContractComponent.",
259
+ },
260
+ },
261
+ create(context) {
262
+ const filename = filenameOf(context)
263
+ if (!isProductSource(filename)) return {}
264
+ const { definitions, visitor } = collectDefinitions()
265
+ const candidates = []
266
+ return {
267
+ ...visitor,
268
+ JSXOpeningElement(node) {
269
+ const component = jsxName(node)
270
+ if (!component || !TYPED_ROOTS.has(component)) return
271
+ const host = literalString(expressionOf(attribute(node, "contract")))
272
+ const render = expressionOf(attribute(node, "render"))
273
+ if (host && render) candidates.push({ node, host, render })
274
+ },
275
+ "Program:exit"() {
276
+ for (const candidate of candidates) {
277
+ const call = resolvedDefineCall(candidate.render, definitions)
278
+ const render = literalString(call?.arguments?.[0])
279
+ if (render && render !== candidate.host) {
280
+ context.report({ node: candidate.node, messageId: "mismatch", data: { host: candidate.host, render } })
281
+ }
282
+ }
283
+ },
284
+ }
285
+ },
286
+ }
287
+
288
+ // -- GRAMMAR-6 -----------------------------------------------------------------------------------
289
+
290
+ const exportedFunction = (node) => {
291
+ if (node.type === "FunctionDeclaration") return node.parent?.type === "ExportNamedDeclaration" || node.parent?.type === "ExportDefaultDeclaration"
292
+ if (node.type !== "ArrowFunctionExpression" && node.type !== "FunctionExpression") return false
293
+ const declarator = node.parent
294
+ return declarator?.type === "VariableDeclarator" && declarator.parent?.parent?.type === "ExportNamedDeclaration"
295
+ }
296
+
297
+ const returnedValues = (node) => {
298
+ if (node.body?.type !== "BlockStatement") return [node.body]
299
+ return node.body.body.filter((statement) => statement.type === "ReturnStatement").map((statement) => statement.argument)
300
+ }
301
+
302
+ const untypedRoot = (node) => {
303
+ const value = unwrapExpression(node)
304
+ if (!value || value.type === "Literal" && value.value === null) return null
305
+ if (value.type === "ConditionalExpression") return untypedRoot(value.consequent) || untypedRoot(value.alternate)
306
+ if (value.type === "LogicalExpression") return untypedRoot(value.right)
307
+ if (value.type === "JSXFragment") return value
308
+ if (value.type !== "JSXElement") return null
309
+ const name = value.openingElement?.name
310
+ if (name?.type === "JSXIdentifier" && /^[A-Z]/.test(name.name)) return null
311
+ if (name?.type === "JSXMemberExpression") return null
312
+ return value
313
+ }
314
+
315
+ /** Exported block roots are typed compositions, not fragments or structural host elements. */
316
+ export const blockRootIsTypedComposition = {
317
+ meta: {
318
+ type: "problem",
319
+ docs: { description: "An exported block starts at a typed composition boundary." },
320
+ schema: [],
321
+ messages: {
322
+ root:
323
+ "A block cannot own an anonymous fragment or structural host root. Start it with Tree or a named typed composition component whose contract owns the arrangement.",
324
+ },
325
+ },
326
+ create(context) {
327
+ const filename = filenameOf(context)
328
+ if (!isProductSource(filename) || !filename.includes("/components/blocks/")) return {}
329
+ const check = (node) => {
330
+ if (!exportedFunction(node)) return
331
+ for (const value of returnedValues(node)) {
332
+ const root = untypedRoot(value)
333
+ if (root) context.report({ node: root, messageId: "root" })
334
+ }
335
+ }
336
+ return {
337
+ ArrowFunctionExpression: check,
338
+ FunctionExpression: check,
339
+ FunctionDeclaration: check,
340
+ }
341
+ },
342
+ }
343
+
344
+ export const rules = {
345
+ "no-core-grammar-value-import-outside-adapter": noCoreGrammarValueImportOutsideAdapter,
346
+ "surface-branch-requires-contract-render": surfaceBranchRequiresContractRender,
347
+ "no-reactnode-escape-slot": noReactnodeEscapeSlot,
348
+ "runtime-contract-content-requires-component-type": runtimeContractContentRequiresComponentType,
349
+ "tree-contract-render-identity": treeContractRenderIdentity,
350
+ "block-root-is-typed-composition": blockRootIsTypedComposition,
351
+ }
352
+
353
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
354
+
355
+ const severityOf = (setting) => {
356
+ const severity = Array.isArray(setting) ? setting[0] : setting
357
+ if (severity === "error") return 2
358
+ if (severity === "warn") return 1
359
+ if (severity === "off") return 0
360
+ return severity
361
+ }
362
+
363
+ /** Prove every newly created production probe receives every grammar-boundary rule strictly. */
364
+ export const auditEffectiveConfigCoversNewFiles = (probes, expectedRules = Object.keys(rules).map((name) => `starci-fe/${name}`)) => {
365
+ const uncovered = []
366
+ for (const probe of probes || []) {
367
+ const config = probe?.config ?? {}
368
+ const missing = expectedRules.filter((name) => severityOf(config.rules?.[name]) !== 2)
369
+ if (missing.length > 0 || config.linterOptions?.noInlineConfig !== true) {
370
+ uncovered.push({ path: probe?.path ?? "<unknown>", missing, refusesInlineConfig: config.linterOptions?.noInlineConfig === true })
371
+ }
372
+ }
373
+ return { ok: (probes || []).length > 0 && uncovered.length === 0, uncovered }
374
+ }
375
+
376
+ const versionParts = (value) => {
377
+ const match = String(value || "").match(/^(\d+)\.(\d+)\.(\d+)/)
378
+ return match ? match.slice(1).map(Number) : null
379
+ }
380
+
381
+ /** Canon FE V2 is compatible only with the ComponentType/Tree grammar lane introduced in 0.2.0. */
382
+ export const auditGrammarEslintVersionParity = ({ eslintVersion, grammarVersion } = {}) => {
383
+ const eslint = versionParts(eslintVersion)
384
+ const grammar = versionParts(grammarVersion)
385
+ const ok = Boolean(eslint && grammar && eslint[0] === 2 && (grammar[0] > 0 || grammar[1] >= 2))
386
+ return { ok, eslintVersion: eslintVersion ?? null, grammarVersion: grammarVersion ?? null, expected: "canon 2.x + grammar >=0.2.0" }
387
+ }
388
+
389
+ /** README headline counts are executable release metadata, not hand-kept prose. */
390
+ export const auditRuleReadmeCountParity = ({ readme, publishedRules = rules, publishedLaws } = {}) => {
391
+ const match = String(readme || "").match(/\*\*(\d+) ESLint rules, from (\d+) laws/)
392
+ const actualRules = Object.keys(publishedRules || {}).length
393
+ const actualLaws = Number(publishedLaws)
394
+ const declaredRules = match ? Number(match[1]) : null
395
+ const declaredLaws = match ? Number(match[2]) : null
396
+ return {
397
+ ok: declaredRules === actualRules && declaredLaws === actualLaws,
398
+ declaredRules,
399
+ actualRules,
400
+ declaredLaws,
401
+ actualLaws,
402
+ }
403
+ }
404
+
405
+ /** A local Python MCP/Qdrant result is usable only when it proves one clean reference worktree. */
406
+ export const auditReferenceReceiptPreflight = ({ reference, receipt } = {}) => {
407
+ const sourcePath = normalizePath(reference?.path)
408
+ const indexedPath = normalizePath(receipt?.sourcePath)
409
+ const commit = String(reference?.revision || "")
410
+ const revision = String(receipt?.revision || "")
411
+ const revisionMatches = revision.length >= 7 && commit.startsWith(revision)
412
+ const referenceRoot = sourcePath.includes("/.worktrees/references/")
413
+ const sourcePathMatches = referenceRoot && sourcePath === indexedPath
414
+ const clean = receipt?.dirty === false
415
+ const hasGeneration = typeof receipt?.generation === "string" && receipt.generation.length > 0
416
+ return {
417
+ ok: revisionMatches && sourcePathMatches && clean && hasGeneration,
418
+ revisionMatches,
419
+ referenceRoot,
420
+ sourcePathMatches,
421
+ clean,
422
+ hasGeneration,
423
+ }
424
+ }
425
+
426
+ export const audits = {
427
+ "effective-config-covers-new-files": auditEffectiveConfigCoversNewFiles,
428
+ "grammar-eslint-version-parity": auditGrammarEslintVersionParity,
429
+ "rule-readme-count-parity": auditRuleReadmeCountParity,
430
+ "reference-receipt-preflight": auditReferenceReceiptPreflight,
431
+ }
package/index.mjs CHANGED
@@ -14,7 +14,12 @@
14
14
  */
15
15
  import { recommended as commentsRecommended, rules as commentsRules } from "./comments.mjs"
16
16
  import { recommended as contractRecommended, rules as contractRules } from "./contract.mjs"
17
- import { recommended as fileLayoutRecommended, rules as fileLayoutRules } from "./file-layout.mjs"
17
+ import { recommended as fileLayoutRecommended, rules as fileLayoutRules } from "./file-layout.mjs"
18
+ import {
19
+ audits as grammarBoundaryAudits,
20
+ recommended as grammarBoundaryRecommended,
21
+ rules as grammarBoundaryRules,
22
+ } from "./grammar-boundary.mjs"
18
23
  import { recommended as iconRecommended, rules as iconRules } from "./icon.mjs"
19
24
  import { recommended as landmarkRecommended, rules as landmarkRules } from "./landmark.mjs"
20
25
  import { recommended as loadingRecommended, rules as loadingRules } from "./loading.mjs"
@@ -42,7 +47,13 @@ import { recommended as vendorRecommended, rules as vendorRules } from "./vendor
42
47
  const CONTRIBUTIONS = [
43
48
  { law: "comments", rules: commentsRules, recommended: commentsRecommended },
44
49
  { law: "contract", rules: contractRules, recommended: contractRecommended },
45
- { law: "file-layout", rules: fileLayoutRules, recommended: fileLayoutRecommended },
50
+ { law: "file-layout", rules: fileLayoutRules, recommended: fileLayoutRecommended },
51
+ {
52
+ law: "grammar-boundary",
53
+ rules: grammarBoundaryRules,
54
+ recommended: grammarBoundaryRecommended,
55
+ audits: grammarBoundaryAudits,
56
+ },
46
57
  { law: "icon", rules: iconRules, recommended: iconRecommended },
47
58
  { law: "landmark", rules: landmarkRules, recommended: landmarkRecommended },
48
59
  { law: "loading", rules: loadingRules, recommended: loadingRecommended },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/eslint-canon-fe",
3
- "version": "1.4.5",
3
+ "version": "2.0.0",
4
4
  "description": "The StarCi front-end lint canon: every rule its laws publish, gathered as one flat-config plugin.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
package/the-split.mjs CHANGED
@@ -73,8 +73,10 @@ export const connectedBlockHasPresentationalTwin = {
73
73
  "`{{block}}` reads the world, so it must import exact `{{twin}}` from `./component`. A single leaf, one tree, or no local state is not an exception.",
74
74
  bypass:
75
75
  "Connected `{{block}}` renders `<{{rendered}}>` directly. Resolve the world here, then render only `<{{twin}}>`; component.tsx owns the complete presentation.",
76
- unused:
77
- "Connected `{{block}}` imports `{{twin}}` but never renders it. Every connected render path must cross the pure twin.",
76
+ unused:
77
+ "Connected `{{block}}` imports `{{twin}}` but never renders it. Every connected render path must cross the pure twin.",
78
+ called:
79
+ "Connected `{{block}}` calls `{{twin}}(...)` as an ordinary function. Render `<{{twin}} />` so React owns the twin's hook lifecycle and every render keeps one stable component boundary.",
78
80
  },
79
81
  },
80
82
  create(context) {
@@ -82,8 +84,9 @@ export const connectedBlockHasPresentationalTwin = {
82
84
  if (!block) return {}
83
85
 
84
86
  const twin = `${block}Base`
85
- const worldBindings = new Set()
86
- const rendered = []
87
+ const worldBindings = new Set()
88
+ const rendered = []
89
+ const calledTwin = []
87
90
  let importsTwin = false
88
91
  let readsWorld = false
89
92
  let rendersTwin = false
@@ -98,9 +101,10 @@ export const connectedBlockHasPresentationalTwin = {
98
101
  if (imported && local && REACHES_FOR_THE_WORLD.test(imported)) worldBindings.add(local)
99
102
  }
100
103
  },
101
- CallExpression(node) {
102
- if (node.callee?.type !== "Identifier") return
103
- if (worldBindings.has(node.callee.name) || REACHES_FOR_THE_WORLD.test(node.callee.name)) {
104
+ CallExpression(node) {
105
+ if (node.callee?.type !== "Identifier") return
106
+ if (node.callee.name === twin) calledTwin.push(node)
107
+ if (worldBindings.has(node.callee.name) || REACHES_FOR_THE_WORLD.test(node.callee.name)) {
104
108
  readsWorld = true
105
109
  }
106
110
  },
@@ -115,7 +119,7 @@ export const connectedBlockHasPresentationalTwin = {
115
119
  context.report({ node, messageId: "missing", data: { block, twin } })
116
120
  return
117
121
  }
118
- for (const entry of rendered) {
122
+ for (const entry of rendered) {
119
123
  if (entry.name !== twin) {
120
124
  context.report({
121
125
  node: entry.node,
@@ -123,8 +127,11 @@ export const connectedBlockHasPresentationalTwin = {
123
127
  data: { block, rendered: entry.name, twin },
124
128
  })
125
129
  }
126
- }
127
- if (!rendersTwin) context.report({ node, messageId: "unused", data: { block, twin } })
130
+ }
131
+ for (const call of calledTwin) {
132
+ context.report({ node: call, messageId: "called", data: { block, twin } })
133
+ }
134
+ if (!rendersTwin) context.report({ node, messageId: "unused", data: { block, twin } })
128
135
  },
129
136
  }
130
137
  },