@starci/eslint-canon-fe 5.1.2 → 6.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/CHANGELOG.md +78 -2
- package/README.md +17 -18
- package/brand-values.mjs +1 -3
- package/class-names.mjs +9 -9
- package/client-boundary.mjs +125 -37
- package/comments.mjs +14 -21
- package/docs/client-boundary.md +26 -1
- package/docs/grammar-boundary.md +33 -0
- package/docs/hygiene.md +15 -4
- package/docs/lint-escape-hatch.md +3 -1
- package/docs/next-conventions.md +26 -0
- package/docs/size-growth.md +29 -0
- package/docs/spec-quality.md +15 -7
- package/docs/status-colors.md +32 -0
- package/docs/translation.md +4 -0
- package/docs/transport.md +36 -9
- package/e2e-shape.mjs +20 -23
- package/env-owner.mjs +1 -1
- package/file-layout.mjs +105 -103
- package/formatting.mjs +2 -4
- package/grammar-boundary.mjs +85 -9
- package/hooks-folder.mjs +17 -16
- package/hygiene.mjs +370 -92
- package/icon.mjs +21 -20
- package/index.mjs +27 -85
- package/landmark.mjs +0 -11
- package/lib/bindings.mjs +144 -0
- package/lib/config.mjs +79 -0
- package/lib/hfs.mjs +48 -0
- package/lib/next.mjs +6 -0
- package/lib/platform.mjs +66 -0
- package/lib/react.mjs +28 -0
- package/lib/scope.mjs +119 -19
- package/lib/status-tones.generated.mjs +9 -0
- package/lib/types.mjs +63 -0
- package/lib/why.mjs +41 -11
- package/lint-escape-hatch.mjs +17 -15
- package/loading.mjs +9 -16
- package/naming.mjs +8 -2
- package/next-conventions.mjs +109 -25
- package/package.json +9 -4
- package/props-and-slots.mjs +19 -13
- package/runtime/engine/plain-object.mjs +5 -0
- package/runtime/engine/runtime-root.mjs +32 -0
- package/runtime/engine/yaml.mjs +161 -0
- package/runtime/knowledge/hfs/slots.yaml +1072 -0
- package/runtime/scripts/lib/git.mjs +53 -0
- package/runtime/scripts/lib/glob.mjs +23 -0
- package/runtime/scripts/lib/hfs-allows.mjs +42 -0
- package/runtime/scripts/lib/hfs-slots.mjs +722 -0
- package/runtime/scripts/lib/hfs-view.mjs +68 -0
- package/runtime/scripts/lib/next-contract.mjs +14 -0
- package/runtime/scripts/lib/path-key.mjs +17 -0
- package/runtime/scripts/lib/recorded-lines.mjs +27 -0
- package/served-locale.mjs +30 -16
- package/shape-slot.mjs +22 -20
- package/size-and-state-budget.mjs +41 -23
- package/size-growth.mjs +58 -0
- package/spec-quality.mjs +95 -16
- package/status-colors.mjs +139 -0
- package/the-split.mjs +13 -13
- package/tokens.mjs +26 -34
- package/translation.mjs +52 -19
- package/transport.mjs +437 -89
- package/type-safety.mjs +8 -17
- package/typography.mjs +7 -11
- package/vendor-boundary.mjs +5 -27
- package/lint-adoption.mjs +0 -79
package/next-conventions.mjs
CHANGED
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { attribute, attributeValue, calleeName, leadingTextOf, stringOf } from "./lib/ast.mjs"
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
15
|
+
import { hfsOf } from "./lib/hfs.mjs"
|
|
16
|
+
import { baseName, classOf, fileOf, inSlot, isSpecFile, roleOfFile, slotOfFile, stem } from "./lib/scope.mjs"
|
|
17
17
|
|
|
18
18
|
/** Another i18n stack: a second place that decides which language a reader sees. */
|
|
19
19
|
const OTHER_I18N = /^(?:react-i18next|i18next|next-i18next|react-intl|next-translate|@lingui\/.+|typesafe-i18n|rosetta)$/
|
|
@@ -34,13 +34,13 @@ export const noMiddlewareFile = {
|
|
|
34
34
|
},
|
|
35
35
|
},
|
|
36
36
|
create(context) {
|
|
37
|
-
const file =
|
|
38
|
-
const name = baseName(file)
|
|
37
|
+
const file = fileOf(context)
|
|
39
38
|
if (isSpecFile(file)) return {}
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
// The retired name has a slot of its own (`fe.source-root-retired`, forbidden); the interceptor is the `proxy` role of `fe.source-root-pinned`.
|
|
40
|
+
if (inSlot(context, "fe.source-root-retired")) {
|
|
41
|
+
return { Program: (node) => context.report({ node, messageId: "file", data: { name: baseName(file) } }) }
|
|
42
42
|
}
|
|
43
|
-
if (
|
|
43
|
+
if (!inSlot(context, "fe.source-root-pinned") || roleOfFile(context) !== "proxy") return {}
|
|
44
44
|
const check = (node) => {
|
|
45
45
|
const names = []
|
|
46
46
|
const declaration = node.declaration
|
|
@@ -69,10 +69,14 @@ export const localeSegmentIsLocale = {
|
|
|
69
69
|
},
|
|
70
70
|
},
|
|
71
71
|
create(context) {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
72
|
+
// A route folder is a segment of slot `fe.route`; the locale one is `[locale]`, never another spelling.
|
|
73
|
+
if (!inSlot(context, "fe.route")) return {}
|
|
74
|
+
const hfs = hfsOf(context)
|
|
75
|
+
const file = fileOf(context)
|
|
76
|
+
const below = hfs.relative(file).slice(classOf(context).root.length + 1).split("/")
|
|
77
|
+
const name = below.slice(0, -1).map((segment) => /^\[(lang|lng|language|i18n)\]$/.exec(segment)).find(Boolean)?.[1]
|
|
78
|
+
if (!name) return {}
|
|
79
|
+
return { Program: (node) => context.report({ node, messageId: "segment", data: { name } }) }
|
|
76
80
|
},
|
|
77
81
|
}
|
|
78
82
|
|
|
@@ -133,9 +137,6 @@ export const htmlLangFromLocale = {
|
|
|
133
137
|
|
|
134
138
|
// -- FE-NEXT-5 -------------------------------------------------------------------------------------
|
|
135
139
|
|
|
136
|
-
/** True for a route page: a `page.tsx` anywhere under `app`. */
|
|
137
|
-
const isPageFile = (file) => /\/app\/(?:.*\/)?page\.[cm]?tsx?$/.test(file)
|
|
138
|
-
|
|
139
140
|
/** The names a module exports, from declarations and specifiers. */
|
|
140
141
|
const exportedNames = (program) => {
|
|
141
142
|
const names = new Set()
|
|
@@ -163,8 +164,8 @@ export const pageExportsMetadata = {
|
|
|
163
164
|
},
|
|
164
165
|
},
|
|
165
166
|
create(context) {
|
|
166
|
-
|
|
167
|
-
if (
|
|
167
|
+
// A page is the file the slot gives the role `page` (spec files carry no role).
|
|
168
|
+
if (!inSlot(context, "fe.route") || roleOfFile(context) !== "page") return {}
|
|
168
169
|
return {
|
|
169
170
|
"Program:exit"(program) {
|
|
170
171
|
const names = exportedNames(program)
|
|
@@ -211,9 +212,11 @@ export const noNullSuspenseFallback = {
|
|
|
211
212
|
|
|
212
213
|
// -- FE-NEXT-7 -------------------------------------------------------------------------------------
|
|
213
214
|
|
|
214
|
-
/** Files that legitimately sit outside the locale provider or own the navigation module. */
|
|
215
|
-
const isNavigationOwner = (
|
|
216
|
-
|
|
215
|
+
/** Files that legitimately sit outside the locale provider or own the navigation module: the i18n module, the root error boundary (role `global-error`), the proxy (role `proxy`; `instrumentation` files are not exempt). */
|
|
216
|
+
const isNavigationOwner = (context) =>
|
|
217
|
+
inSlot(context, "fe.modules.i18n") ||
|
|
218
|
+
(inSlot(context, "fe.source-root-pinned") && roleOfFile(context) === "proxy") ||
|
|
219
|
+
(inSlot(context, "fe.route") && roleOfFile(context) === "global-error")
|
|
217
220
|
|
|
218
221
|
/** The router helpers that are locale-blind in `next/navigation`. */
|
|
219
222
|
const LOCALE_BLIND = new Set(["useRouter", "usePathname", "redirect", "permanentRedirect"])
|
|
@@ -232,8 +235,7 @@ export const navigationFromIntl = {
|
|
|
232
235
|
},
|
|
233
236
|
},
|
|
234
237
|
create(context) {
|
|
235
|
-
|
|
236
|
-
if (isSpecFile(file) || isNavigationOwner(file)) return {}
|
|
238
|
+
if (isSpecFile(fileOf(context)) || isNavigationOwner(context)) return {}
|
|
237
239
|
return {
|
|
238
240
|
ImportDeclaration(node) {
|
|
239
241
|
if (node.importKind === "type") return
|
|
@@ -292,8 +294,8 @@ const isNavigation = (name) =>
|
|
|
292
294
|
/** True for `/x`, `/x/y`; false for `//host` and for `/` alone, which every app owns. */
|
|
293
295
|
const isInternalPath = (text) => text.startsWith("/") && !text.startsWith("//") && text.length > 1
|
|
294
296
|
|
|
295
|
-
/** The
|
|
296
|
-
const isRoutesModule = (
|
|
297
|
+
/** The places that may write a route: the routes module (`fe.modules.routes`) and the i18n module (`fe.modules.i18n`). */
|
|
298
|
+
const isRoutesModule = (context) => inSlot(context, "fe.modules.routes", "fe.modules.i18n")
|
|
297
299
|
|
|
298
300
|
/** No route written by hand outside `modules/routes`. */
|
|
299
301
|
export const noHardcodedRoute = {
|
|
@@ -307,8 +309,7 @@ export const noHardcodedRoute = {
|
|
|
307
309
|
},
|
|
308
310
|
},
|
|
309
311
|
create(context) {
|
|
310
|
-
|
|
311
|
-
if (isSpecFile(file) || isRoutesModule(file)) return {}
|
|
312
|
+
if (isSpecFile(fileOf(context)) || isRoutesModule(context)) return {}
|
|
312
313
|
const source = context.sourceCode ?? context.getSourceCode()
|
|
313
314
|
const report = (node, text) => context.report({ node, messageId: "route", data: { path: text } })
|
|
314
315
|
return {
|
|
@@ -329,6 +330,88 @@ export const noHardcodedRoute = {
|
|
|
329
330
|
},
|
|
330
331
|
}
|
|
331
332
|
|
|
333
|
+
// -- FE-NEXT-10 -----------------------------------------------------------------------------------
|
|
334
|
+
|
|
335
|
+
/** The next-intl stack factories: module specifier -> the export that builds one layer of the stack (`default` for a default export). */
|
|
336
|
+
const STACK_FACTORIES = new Map([
|
|
337
|
+
["next-intl/routing", new Set(["defineRouting"])],
|
|
338
|
+
["next-intl/navigation", new Set(["createNavigation"])],
|
|
339
|
+
["next-intl/server", new Set(["getRequestConfig"])],
|
|
340
|
+
["next-intl/middleware", new Set(["default"])],
|
|
341
|
+
])
|
|
342
|
+
|
|
343
|
+
/** The variable a name binds to, walking up from the scope of a node. */
|
|
344
|
+
const bindingOf = (context, identifier) => {
|
|
345
|
+
let scope = (context.sourceCode ?? context.getSourceCode()).getScope(identifier)
|
|
346
|
+
while (scope) {
|
|
347
|
+
const variable = scope.set.get(identifier.name)
|
|
348
|
+
if (variable) return variable.defs[0] ?? null
|
|
349
|
+
scope = scope.upper
|
|
350
|
+
}
|
|
351
|
+
return null
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** The name a stack factory has where the call reads it: "defineRouting" (next-intl/routing), else null. */
|
|
355
|
+
const stackFactoryOf = (context, callee) => {
|
|
356
|
+
if (callee.type === "Identifier") {
|
|
357
|
+
const definition = bindingOf(context, callee)
|
|
358
|
+
if (definition?.type !== "ImportBinding") return null
|
|
359
|
+
const source = String(definition.parent.source.value)
|
|
360
|
+
const specifier = definition.node
|
|
361
|
+
const exported = specifier.type === "ImportSpecifier" ? specifier.imported.name ?? specifier.imported.value : specifier.type === "ImportDefaultSpecifier" ? "default" : null
|
|
362
|
+
return exported !== null && STACK_FACTORIES.get(source)?.has(exported) ? { source, name: exported === "default" ? "createMiddleware" : exported } : null
|
|
363
|
+
}
|
|
364
|
+
if (callee.type === "MemberExpression" && !callee.computed && callee.object.type === "Identifier" && callee.property.type === "Identifier") {
|
|
365
|
+
const definition = bindingOf(context, callee.object)
|
|
366
|
+
if (definition?.type !== "ImportBinding" || definition.node.type !== "ImportNamespaceSpecifier") return null
|
|
367
|
+
const source = String(definition.parent.source.value)
|
|
368
|
+
return STACK_FACTORIES.get(source)?.has(callee.property.name) ? { source, name: callee.property.name } : null
|
|
369
|
+
}
|
|
370
|
+
return null
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* The next-intl stack is written once per repository.
|
|
375
|
+
*
|
|
376
|
+
* `defineRouting` (`next-intl/routing`), `createNavigation` (`next-intl/navigation`), `getRequestConfig` (`next-intl/server`) and
|
|
377
|
+
* `createMiddleware` (`next-intl/middleware`), resolved by the import that binds the called name (a renamed import or a namespace
|
|
378
|
+
* member counts), are called only in a file of the i18n package (slot `fe.package.i18n`, `createAppI18n`), or in the `fe.modules.i18n`
|
|
379
|
+
* slot of a repository that declares exactly one app. In a multi-app repository an app's `modules/i18n` imports the package
|
|
380
|
+
* factory; three byte-identical `request.ts` files, one per app, are three stacks that drift.
|
|
381
|
+
*
|
|
382
|
+
* It does not replace `no-second-i18n-stack`: that rule refuses another i18n LIBRARY at its import, this one refuses a second copy of
|
|
383
|
+
* next-intl's own stack. Different evidence, different remedy.
|
|
384
|
+
*/
|
|
385
|
+
export const i18nStackInOneModule = {
|
|
386
|
+
meta: {
|
|
387
|
+
type: "problem",
|
|
388
|
+
docs: { description: "next-intl's routing, navigation, request config and middleware factories are called only in the i18n package (or the only app's `modules/i18n`)." },
|
|
389
|
+
schema: [],
|
|
390
|
+
messages: {
|
|
391
|
+
stack:
|
|
392
|
+
"`{{name}}` builds a layer of the next-intl stack here. The stack is written once per repository, {{where}}; a copy in an app is a second stack that drifts from the first (three apps once carried byte-identical `request.ts` files). Import what the shared factory returns instead of calling `{{name}}`.",
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
create(context) {
|
|
396
|
+
if (isSpecFile(context.filename || context.getFilename())) return {}
|
|
397
|
+
const hfs = hfsOf(context)
|
|
398
|
+
const slot = slotOfFile(context)
|
|
399
|
+
const single = hfs.apps.length === 1
|
|
400
|
+
if (slot === "fe.package.i18n" || (slot === "fe.modules.i18n" && single)) return {}
|
|
401
|
+
const proxy = single && slot === "fe.source-root-pinned" && roleOfFile(context) === "proxy"
|
|
402
|
+
const where = single ? "in the app's `modules/i18n`" : "in the i18n package (`packages/<family>-i18n`, exported as `createAppI18n`) that every app's `modules/i18n` calls"
|
|
403
|
+
return {
|
|
404
|
+
CallExpression(node) {
|
|
405
|
+
const factory = stackFactoryOf(context, node.callee)
|
|
406
|
+
// In a one-app repository the proxy (role `proxy`) mounts the locale middleware built from the one routing; a shared repository
|
|
407
|
+
// builds it in the i18n package and the proxy re-exports it.
|
|
408
|
+
if (factory?.name === "createMiddleware" && proxy) return
|
|
409
|
+
if (factory) context.report({ node, messageId: "stack", data: { name: factory.name, where } })
|
|
410
|
+
},
|
|
411
|
+
}
|
|
412
|
+
},
|
|
413
|
+
}
|
|
414
|
+
|
|
332
415
|
/** The rules this law contributes to the plugin. */
|
|
333
416
|
export const rules = {
|
|
334
417
|
"no-middleware-file": noMiddlewareFile,
|
|
@@ -340,6 +423,7 @@ export const rules = {
|
|
|
340
423
|
"navigation-from-intl": navigationFromIntl,
|
|
341
424
|
"no-native-anchor": noNativeAnchor,
|
|
342
425
|
"no-hardcoded-route": noHardcodedRoute,
|
|
426
|
+
"i18n-stack-in-one-module": i18nStackInOneModule,
|
|
343
427
|
}
|
|
344
428
|
|
|
345
429
|
/** Every rule is an error. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@starci/eslint-canon-fe",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "6.0.0",
|
|
4
4
|
"description": "The StarCi front-end lint canon: every rule its laws publish, gathered as one flat-config plugin and attached by one factory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"files": [
|
|
13
13
|
"*.mjs",
|
|
14
14
|
"lib/*.mjs",
|
|
15
|
+
"runtime/**",
|
|
15
16
|
"docs/*.md",
|
|
16
17
|
"*.ts",
|
|
17
18
|
"!*.test.mjs",
|
|
@@ -19,11 +20,12 @@
|
|
|
19
20
|
"README.md"
|
|
20
21
|
],
|
|
21
22
|
"engines": {
|
|
22
|
-
"node": ">=
|
|
23
|
+
"node": ">=22.13.0"
|
|
23
24
|
},
|
|
24
25
|
"peerDependencies": {
|
|
25
26
|
"eslint": ">=9",
|
|
26
|
-
"eslint-plugin-react-hooks": ">=7"
|
|
27
|
+
"eslint-plugin-react-hooks": ">=7",
|
|
28
|
+
"typescript": ">=5.9"
|
|
27
29
|
},
|
|
28
30
|
"publishConfig": {
|
|
29
31
|
"access": "public"
|
|
@@ -36,5 +38,8 @@
|
|
|
36
38
|
"eslintplugin",
|
|
37
39
|
"starci",
|
|
38
40
|
"canon"
|
|
39
|
-
]
|
|
41
|
+
],
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@typescript-eslint/parser": "^8.70.0"
|
|
44
|
+
}
|
|
40
45
|
}
|
package/props-and-slots.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/** Rules for named React props, ordinary children, and local visual ownership. */
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
import { declarationsOf, fileOf as declarationFile } from "./lib/types.mjs"
|
|
4
|
+
import { fileOf, isComponentFile, isComponentPath, isProductSource, isSpecFile, kindOfFile } from "./lib/scope.mjs"
|
|
5
|
+
|
|
5
6
|
const propertyName = (node) => {
|
|
6
7
|
const key = node?.key ?? node?.property
|
|
7
8
|
if (!key) return null
|
|
@@ -69,7 +70,7 @@ export const publicComponentSignature = {
|
|
|
69
70
|
const actual = type?.type === "TSTypeReference" && type.typeName?.type === "Identifier" ? type.typeName.name : null
|
|
70
71
|
if (!actual || !expected.includes(actual)) context.report({ node: params[0], messageId: "type", data: { name, expected: expected.join(" or ") } })
|
|
71
72
|
}
|
|
72
|
-
if (!
|
|
73
|
+
if (!isComponentFile(context) || isSpecFile(fileOf(context))) return {}
|
|
73
74
|
return {
|
|
74
75
|
VariableDeclarator(node) {
|
|
75
76
|
const name = componentNameOf(node)
|
|
@@ -92,7 +93,7 @@ export const noPerPartClassNameProp = {
|
|
|
92
93
|
messages: { perPart: "Keep internal part styling owned by the component." },
|
|
93
94
|
},
|
|
94
95
|
create(context) {
|
|
95
|
-
if (!
|
|
96
|
+
if (!isComponentFile(context) || isSpecFile(fileOf(context))) return {}
|
|
96
97
|
return { TSPropertySignature(node) { const name = propertyName(node); if (name && name !== "className" && /^[a-z][A-Za-z0-9]*ClassName$/.test(name)) context.report({ node, messageId: "perPart" }) } }
|
|
97
98
|
},
|
|
98
99
|
}
|
|
@@ -106,13 +107,19 @@ export const noPublicClassNameProp = {
|
|
|
106
107
|
messages: { declaration: "Component props must not expose {{prop}}.", usage: "Do not pass {{prop}} to house component {{component}}." },
|
|
107
108
|
},
|
|
108
109
|
create(context) {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
110
|
+
if (isSpecFile(fileOf(context))) return {}
|
|
111
|
+
const declared = isComponentFile(context)
|
|
112
|
+
/** A house component: the identifier resolves (through its import) to a file of a component layer. */
|
|
113
|
+
const isHouseComponent = (identifier) => declarationsOf(context, identifier).some((declaration) => isComponentPath(context, declarationFile(declaration)))
|
|
112
114
|
return {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
115
|
+
TSPropertySignature(node) { if (declared && ["className", "classNames"].includes(propertyName(node))) context.report({ node, messageId: "declaration", data: { prop: propertyName(node) } }) },
|
|
116
|
+
JSXOpeningElement(node) {
|
|
117
|
+
const identifier = node.name?.type === "JSXIdentifier" ? node.name : null
|
|
118
|
+
if (!identifier) return
|
|
119
|
+
const styled = (node.attributes || []).filter((attribute) => ["className", "classNames"].includes(attribute.name?.type === "JSXIdentifier" ? attribute.name.name : null))
|
|
120
|
+
if (styled.length === 0 || !isHouseComponent(identifier)) return
|
|
121
|
+
for (const attribute of styled) context.report({ node: attribute, messageId: "usage", data: { prop: attribute.name.name, component: identifier.name } })
|
|
122
|
+
},
|
|
116
123
|
}
|
|
117
124
|
},
|
|
118
125
|
}
|
|
@@ -121,8 +128,7 @@ export const noPublicClassNameProp = {
|
|
|
121
128
|
export const noPublicFrameCssProps = {
|
|
122
129
|
meta: { type: "problem", docs: { description: "Non-leaf component props do not expose CSS-shaped frame decisions." }, schema: [], messages: { css: "Move {{prop}} into component-owned layout behavior." } },
|
|
123
130
|
create(context) {
|
|
124
|
-
|
|
125
|
-
if (isTestFile(filename) || !isComponentSource(filename) || /[\\/]leaves[\\/]/.test(String(filename).replace(/\\/g, "/"))) return {}
|
|
131
|
+
if (isSpecFile(fileOf(context)) || !isComponentFile(context) || kindOfFile(context) === "leaves") return {}
|
|
126
132
|
const cssProps = new Set(["gap", "padding", "align", "justify", "className", "classNames", "style", "inline", "nested"])
|
|
127
133
|
return { TSPropertySignature(node) { const name = propertyName(node); if (cssProps.has(name)) context.report({ node, messageId: "css", data: { prop: name } }) } }
|
|
128
134
|
},
|
|
@@ -132,7 +138,7 @@ export const noPublicFrameCssProps = {
|
|
|
132
138
|
export const noCssDoorTypeLaundering = {
|
|
133
139
|
meta: { type: "problem", docs: { description: "Omit, Pick, and Exclude cannot hide styling props." }, schema: [], messages: { utility: "Remove {{prop}} from the owning public type instead of hiding it with {{utility}}." } },
|
|
134
140
|
create(context) {
|
|
135
|
-
if (!
|
|
141
|
+
if (!isProductSource(context)) return {}
|
|
136
142
|
return { TSTypeReference(node) { const utility = node.typeName?.type === "Identifier" ? node.typeName.name : null; const params = node.typeArguments?.params || node.typeParameters?.params || []; if (!utility || !["Omit", "Pick", "Exclude"].includes(utility)) return; const keys = params[1]?.type === "TSLiteralType" ? [params[1].literal?.value] : params[1]?.types?.map((item) => item.literal?.value) || []; const prop = keys.find((key) => ["className", "classNames", "style"].includes(key)); if (prop) context.report({ node, messageId: "utility", data: { utility, prop } }) } }
|
|
137
143
|
},
|
|
138
144
|
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// plain-object.mjs — the one "is this a non-array object" predicate the runtime asks with.
|
|
2
|
+
// A leaf beside digest.mjs: schema walkers, config readers and JSON consumers all mean the
|
|
3
|
+
// same thing by "a mapping" - own object, never null, never an array - so they share it here.
|
|
4
|
+
/** True for a non-null, non-array object. Does not check the prototype (a JSON.parse result). */
|
|
5
|
+
export const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {fileURLToPath} from 'node:url';
|
|
4
|
+
import {parseYaml} from './yaml.mjs';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Runtime root helper. The runtime reads this source tree directly. `skillRoot` is the
|
|
8
|
+
* directory containing `package.json` (this module's parent) — the checkout, or the copy of it
|
|
9
|
+
* an install placed on the host.
|
|
10
|
+
*
|
|
11
|
+
* Every workflow, op, schema, knowledge and model contract read at runtime is an authored YAML
|
|
12
|
+
* file (or an authored JSON one where a document is stored that way). `readModuleJson` resolves a
|
|
13
|
+
* path under `skillRoot` and parses the file by its extension.
|
|
14
|
+
*/
|
|
15
|
+
const moduleRoot = path.dirname(fileURLToPath(new URL('../package.json', import.meta.url)));
|
|
16
|
+
|
|
17
|
+
/** The runtime root: this source tree (or an immutable sealed payload of it). */
|
|
18
|
+
export const skillRoot = moduleRoot;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Read a runtime contract document. `parts` are path segments under `skillRoot`
|
|
22
|
+
* (e.g. readModuleJson('modules', 'models', 'kinds.yaml')).
|
|
23
|
+
*/
|
|
24
|
+
export function readModuleJson(...parts) {
|
|
25
|
+
const rel = parts.join('/');
|
|
26
|
+
const file = path.join(skillRoot, rel);
|
|
27
|
+
if (!(file.startsWith(skillRoot) && fs.existsSync(file) && fs.statSync(file).isFile()))
|
|
28
|
+
throw new Error(`Required contract not found for ${rel} under ${skillRoot}`);
|
|
29
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
30
|
+
if (/\.ya?ml$/i.test(file)) return parseYaml(text);
|
|
31
|
+
return JSON.parse(text);
|
|
32
|
+
}
|