@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.
Files changed (68) hide show
  1. package/CHANGELOG.md +78 -2
  2. package/README.md +17 -18
  3. package/brand-values.mjs +1 -3
  4. package/class-names.mjs +9 -9
  5. package/client-boundary.mjs +125 -37
  6. package/comments.mjs +14 -21
  7. package/docs/client-boundary.md +26 -1
  8. package/docs/grammar-boundary.md +33 -0
  9. package/docs/hygiene.md +15 -4
  10. package/docs/lint-escape-hatch.md +3 -1
  11. package/docs/next-conventions.md +26 -0
  12. package/docs/size-growth.md +29 -0
  13. package/docs/spec-quality.md +15 -7
  14. package/docs/status-colors.md +32 -0
  15. package/docs/translation.md +4 -0
  16. package/docs/transport.md +36 -9
  17. package/e2e-shape.mjs +20 -23
  18. package/env-owner.mjs +1 -1
  19. package/file-layout.mjs +105 -103
  20. package/formatting.mjs +2 -4
  21. package/grammar-boundary.mjs +85 -9
  22. package/hooks-folder.mjs +17 -16
  23. package/hygiene.mjs +370 -92
  24. package/icon.mjs +21 -20
  25. package/index.mjs +27 -85
  26. package/landmark.mjs +0 -11
  27. package/lib/bindings.mjs +144 -0
  28. package/lib/config.mjs +79 -0
  29. package/lib/hfs.mjs +48 -0
  30. package/lib/next.mjs +6 -0
  31. package/lib/platform.mjs +66 -0
  32. package/lib/react.mjs +28 -0
  33. package/lib/scope.mjs +119 -19
  34. package/lib/status-tones.generated.mjs +9 -0
  35. package/lib/types.mjs +63 -0
  36. package/lib/why.mjs +41 -11
  37. package/lint-escape-hatch.mjs +17 -15
  38. package/loading.mjs +9 -16
  39. package/naming.mjs +8 -2
  40. package/next-conventions.mjs +109 -25
  41. package/package.json +9 -4
  42. package/props-and-slots.mjs +19 -13
  43. package/runtime/engine/plain-object.mjs +5 -0
  44. package/runtime/engine/runtime-root.mjs +32 -0
  45. package/runtime/engine/yaml.mjs +161 -0
  46. package/runtime/knowledge/hfs/slots.yaml +1072 -0
  47. package/runtime/scripts/lib/git.mjs +53 -0
  48. package/runtime/scripts/lib/glob.mjs +23 -0
  49. package/runtime/scripts/lib/hfs-allows.mjs +42 -0
  50. package/runtime/scripts/lib/hfs-slots.mjs +722 -0
  51. package/runtime/scripts/lib/hfs-view.mjs +68 -0
  52. package/runtime/scripts/lib/next-contract.mjs +14 -0
  53. package/runtime/scripts/lib/path-key.mjs +17 -0
  54. package/runtime/scripts/lib/recorded-lines.mjs +27 -0
  55. package/served-locale.mjs +30 -16
  56. package/shape-slot.mjs +22 -20
  57. package/size-and-state-budget.mjs +41 -23
  58. package/size-growth.mjs +58 -0
  59. package/spec-quality.mjs +95 -16
  60. package/status-colors.mjs +139 -0
  61. package/the-split.mjs +13 -13
  62. package/tokens.mjs +26 -34
  63. package/translation.mjs +52 -19
  64. package/transport.mjs +437 -89
  65. package/type-safety.mjs +8 -17
  66. package/typography.mjs +7 -11
  67. package/vendor-boundary.mjs +5 -27
  68. package/lint-adoption.mjs +0 -79
@@ -12,8 +12,8 @@
12
12
  */
13
13
 
14
14
  import { attribute, attributeValue, calleeName, leadingTextOf, stringOf } from "./lib/ast.mjs"
15
- import { baseName, isSpecFile, stem } from "./lib/scope.mjs"
16
- import { normalizePath } from "./lib/path.mjs"
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 = normalizePath(context.filename || context.getFilename())
38
- const name = baseName(file)
37
+ const file = fileOf(context)
39
38
  if (isSpecFile(file)) return {}
40
- if (stem(file) === "middleware" && /(?:^|\/)src\/middleware\.[cm]?[jt]sx?$/.test(file)) {
41
- return { Program: (node) => context.report({ node, messageId: "file", data: { name } }) }
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 (stem(file) !== "proxy" || !/(?:^|\/)src\/proxy\.[cm]?[jt]s$/.test(file)) return {}
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
- const file = normalizePath(context.filename || context.getFilename())
73
- const match = /\/app\/(?:.*\/)?\[(lang|lng|language|i18n)\]\//.exec(file)
74
- if (!match) return {}
75
- return { Program: (node) => context.report({ node, messageId: "segment", data: { name: match[1] } }) }
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
- const file = normalizePath(context.filename || context.getFilename())
167
- if (isSpecFile(file) || !isPageFile(file)) return {}
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 = (file) =>
216
- /\/modules\/i18n\//.test(file) || /\/app\/global-error\.[cm]?tsx?$/.test(file) || /\/src\/proxy\.[cm]?[jt]s$/.test(file)
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
- const file = normalizePath(context.filename || context.getFilename())
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 one place that may write a route: `modules/routes`. */
296
- const isRoutesModule = (file) => /\/modules\/routes\//.test(file) || /\/modules\/i18n\//.test(file)
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
- const file = normalizePath(context.filename || context.getFilename())
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": "5.1.2",
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": ">=20.9.0"
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
  }
@@ -1,7 +1,8 @@
1
1
  /** Rules for named React props, ordinary children, and local visual ownership. */
2
2
 
3
- const isTestFile = (filename) => /(?:^|[\\/])(?:[^\\/]+\.)?(?:spec|test)\.[cm]?[jt]sx?$/.test(String(filename || ""))
4
- const isComponentSource = (filename) => /(?:^|[\\/])src[\\/]components[\\/]/.test(String(filename || "").replace(/\\/g, "/"))
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 (!isComponentSource(context.filename || context.getFilename()) || isTestFile(context.filename || context.getFilename())) return {}
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 (!isComponentSource(context.filename || context.getFilename()) || isTestFile(context.filename || context.getFilename())) return {}
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
- const filename = context.filename || context.getFilename()
110
- if (isTestFile(filename)) return {}
111
- const bindings = new Set()
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
- ImportDeclaration(node) { if (!/components\//.test(String(node.source?.value || ""))) return; for (const specifier of node.specifiers || []) if (specifier.local?.name) bindings.add(specifier.local.name) },
114
- TSPropertySignature(node) { if (isComponentSource(filename) && ["className", "classNames"].includes(propertyName(node))) context.report({ node, messageId: "declaration", data: { prop: propertyName(node) } }) },
115
- JSXOpeningElement(node) { const component = node.name?.type === "JSXIdentifier" ? node.name.name : null; if (!component || !bindings.has(component)) return; for (const attribute of node.attributes || []) { const prop = attribute.name?.type === "JSXIdentifier" ? attribute.name.name : null; if (["className", "classNames"].includes(prop)) context.report({ node: attribute, messageId: "usage", data: { prop, component } }) } },
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
- const filename = context.filename || context.getFilename()
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 (!String(context.filename || context.getFilename()).replace(/\\/g, "/").includes("/src/")) return {}
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
+ }