@starci/eslint-canon-fe 2.0.0 → 3.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.
@@ -1,431 +1,16 @@
1
1
  /**
2
- * The executable boundary between product components and `@starci/grammar`.
2
+ * The boundary between product code and `@starci/grammar`.
3
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.
4
+ * Grammar is a business-neutral HeroUI-backed component package. Product code may compose its
5
+ * components with ordinary React children and typed props; this contribution deliberately does
6
+ * not impose a second rendering protocol or a product-specific adapter lane.
7
7
  */
8
8
 
9
- const normalizePath = (value) => String(value || "").replace(/\\/g, "/")
9
+ /** No product-specific AST restrictions are needed for the neutral Grammar boundary. */
10
+ export const rules = {}
10
11
 
11
- const SURFACES = new Set(["SurfaceCard", "SurfaceFormCard", "SurfaceListCard", "HighlightCard"])
12
- const TYPED_ROOTS = new Set([...SURFACES, "Tree"])
12
+ /** No repository audit is coupled to the neutral Grammar boundary. */
13
+ export const audits = {}
13
14
 
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
- }
15
+ /** The neutral boundary contributes no mandatory rule levels. */
16
+ export const recommended = {}
package/index.mjs CHANGED
@@ -13,7 +13,7 @@
13
13
  * would leave two names for one rule and no way to tell which a message came from.
14
14
  */
15
15
  import { recommended as commentsRecommended, rules as commentsRules } from "./comments.mjs"
16
- import { recommended as contractRecommended, rules as contractRules } from "./contract.mjs"
16
+ import { recommended as classNamesRecommended, rules as classNamesRules } from "./class-names.mjs"
17
17
  import { recommended as fileLayoutRecommended, rules as fileLayoutRules } from "./file-layout.mjs"
18
18
  import {
19
19
  audits as grammarBoundaryAudits,
@@ -46,7 +46,11 @@ import { recommended as vendorRecommended, rules as vendorRules } from "./vendor
46
46
  /** Each law's contribution, kept separate so a duplicate name is detectable rather than silent. */
47
47
  const CONTRIBUTIONS = [
48
48
  { law: "comments", rules: commentsRules, recommended: commentsRecommended },
49
- { law: "contract", rules: contractRules, recommended: contractRecommended },
49
+ {
50
+ law: "class-names",
51
+ rules: classNamesRules,
52
+ recommended: classNamesRecommended,
53
+ },
50
54
  { law: "file-layout", rules: fileLayoutRules, recommended: fileLayoutRecommended },
51
55
  {
52
56
  law: "grammar-boundary",