@starci/eslint-canon-fe 1.4.4 → 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 +8 -2
- package/contract.mjs +5 -3
- package/grammar-boundary.mjs +431 -0
- package/index.mjs +13 -2
- package/package.json +1 -1
- package/the-split.mjs +17 -10
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @starci/eslint-canon-fe
|
|
2
2
|
|
|
3
|
-
**
|
|
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.
|
package/contract.mjs
CHANGED
|
@@ -162,8 +162,10 @@ export const isSourceFile = (filename) => normalizePath(filename).includes("/src
|
|
|
162
162
|
* guess, and a guess in the table is worse than no entry at all, because the next reader takes it
|
|
163
163
|
* for a rule somebody checked.
|
|
164
164
|
*
|
|
165
|
-
* The same is true one level down
|
|
166
|
-
*
|
|
165
|
+
* The same is true one level down, which is why the list has three members rather than one. A
|
|
166
|
+
* highlighted code region is a string of vendor HTML injected into the node; a rendered diagram is
|
|
167
|
+
* an SVG produced from authored source the same way. Neither has slots to name, and neither is a
|
|
168
|
+
* different case from the document that contains them.
|
|
167
169
|
*
|
|
168
170
|
* THIS IS A FOLDER EXEMPTION, exactly like the leaf one above, and it carries the same cost: anyone
|
|
169
171
|
* can escape these rules by filing a component here. What keeps a file out is a question a person
|
|
@@ -172,7 +174,7 @@ export const isSourceFile = (filename) => normalizePath(filename).includes("/src
|
|
|
172
174
|
* a shape somebody chose is what the table is for.
|
|
173
175
|
*/
|
|
174
176
|
export const isAuthoredDocumentBranch = (filename) =>
|
|
175
|
-
/\/src\/components\/branches\/(?:Article|MarkdownCodeBlock)\//.test(normalizePath(filename))
|
|
177
|
+
/\/src\/components\/branches\/(?:Article|MarkdownCodeBlock|MermaidDiagram)\//.test(normalizePath(filename))
|
|
176
178
|
|
|
177
179
|
/** Product source these rules govern: under `src/`, not a test, not a leaf, not the frame. */
|
|
178
180
|
export const isGovernedFile = (filename) => {
|
|
@@ -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
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 (
|
|
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
|
-
|
|
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
|
},
|