@starci/eslint-canon-be 1.2.2 → 1.7.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/lib/slots.mjs ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The adapter between the HFS slot manifest (`knowledge/hfs/slots.yaml`) and the lint rules that need a
3
+ * number or a list the manifest owns: which platform capabilities may be `@Global()`, how long a source
4
+ * file may be, how many names a public `index.ts` may export.
5
+ *
6
+ * The manifest states these in `ruleParams.be` (the same block `ruleParams(manifest, "be")` of
7
+ * `scripts/lib/hfs-slots.mjs` returns) and in the `budget.indexExports` of a `be.*` slot. This file reads only those
8
+ * keys, and there is no other source: a manifest that does not state them, or that cannot be read, stops the lint
9
+ * run with an error naming the file, because the slot check owns the finding and a rule must not guess a number.
10
+ *
11
+ * - `ruleParams.be.globalModules` directories `src/modules/platform/<capability>/` allowed to declare `@Global()`;
12
+ * the rule compares the capability `platform/<capability>`
13
+ * - `ruleParams.be.fileLines` `{ soft, hardGrowth }`: `soft` is the line budget; with `hardGrowth: true` a file
14
+ * over it may not be born or grow (`file-size-growth`)
15
+ * - the `budget.indexExports` of the first `be.*` slot that states one, the public-surface width
16
+ *
17
+ * The manifest is found through `STARCI_HFS_SLOTS`, else at `<runtime>/knowledge/hfs/slots.yaml` above this package.
18
+ */
19
+ import { existsSync, readFileSync } from "node:fs"
20
+ import { dirname, join } from "node:path"
21
+ import { fileURLToPath } from "node:url"
22
+
23
+ const HERE = dirname(fileURLToPath(import.meta.url))
24
+ const RUNTIME_ROOT = join(HERE, "..", "..", "..", "..")
25
+
26
+ const manifestFile = () => process.env.STARCI_HFS_SLOTS || join(RUNTIME_ROOT, "knowledge", "hfs", "slots.yaml")
27
+
28
+ const toCapability = (dir) => String(dir).replace(/^src\/modules\//, "").replace(/\/+$/, "")
29
+
30
+ /**
31
+ * Reads the rule parameters out of a parsed manifest.
32
+ *
33
+ * @param {object} manifest - The parsed `slots.yaml`.
34
+ * @returns {{ globalModules: readonly string[], fileLines: { soft: number, hardGrowth: number }, indexExports: number }} The parameters; `fileLines.hardGrowth` is the line count above which a file may not grow.
35
+ */
36
+ export const paramsFromManifest = (manifest) => {
37
+ const be = manifest?.ruleParams?.be
38
+ if (!Array.isArray(be?.globalModules) || !be.globalModules.every((name) => typeof name === "string")) throw new Error("slots.yaml states no ruleParams.be.globalModules")
39
+ if (!Number.isInteger(be.fileLines?.soft) || typeof be.fileLines?.hardGrowth !== "boolean") throw new Error("slots.yaml states no ruleParams.be.fileLines {soft, hardGrowth}")
40
+ const indexSlot = Array.isArray(manifest.slots)
41
+ ? manifest.slots.find((slot) => String(slot?.id ?? "").startsWith("be.") && Number.isInteger(slot?.budget?.indexExports))
42
+ : undefined
43
+ if (indexSlot === undefined) throw new Error("slots.yaml states no budget.indexExports on a be.* slot")
44
+ const { soft, hardGrowth } = be.fileLines
45
+ return {
46
+ globalModules: be.globalModules.map(toCapability),
47
+ fileLines: { soft, hardGrowth: hardGrowth ? soft : Number.POSITIVE_INFINITY },
48
+ indexExports: indexSlot.budget.indexExports,
49
+ }
50
+ }
51
+
52
+ const load = async () => {
53
+ const file = manifestFile()
54
+ if (!existsSync(file)) throw new Error(`HFS slot manifest not found at ${file}`)
55
+ const { parseYaml } = await import(new URL("../../../../engine/yaml.mjs", import.meta.url))
56
+ return paramsFromManifest(parseYaml(readFileSync(file, "utf8")))
57
+ }
58
+
59
+ /** The parameters, resolved once per process. */
60
+ export const hfsParams = await load()
package/log-safety.mjs ADDED
@@ -0,0 +1,148 @@
1
+ /**
2
+ * The rule that keeps a secret or a person out of a log line.
3
+ *
4
+ * `no-secret-in-log` (R71 `BE_LOG_SECRET`) reads the arguments of a logger call and refuses a value whose name
5
+ * says it is a credential (`password`, `secret`, `apiKey`, `accessToken`, `authorization`, `cookie`, `otp`) or
6
+ * a personal identifier (`email`, `phone`, `fullName`, `idCard`, `birthDate`). A log line outlives the request,
7
+ * is copied to a vendor, and is read by people who never had the credential or the consent. Log an id, a count,
8
+ * or a masked form instead.
9
+ *
10
+ * The check is on names, because a parser sees names. A name that says it is a measurement of a credential
11
+ * (`tokenCount`, `tokenType`, `secretName`) is not the credential, so those endings are exempt.
12
+ */
13
+ import { wordsOf } from "./lib/ast.mjs"
14
+ import { isDeclarationFile, isTestLane } from "./lib/path.mjs"
15
+
16
+ const LOG_METHODS = new Set(["log", "error", "warn", "info", "debug", "verbose", "fatal", "trace"])
17
+
18
+ /** A receiver is a logger when its last name says so. */
19
+ const isLoggerReceiver = (node) => {
20
+ const name = node.type === "Identifier"
21
+ ? node.name
22
+ : node.type === "MemberExpression" && !node.computed && node.property.type === "Identifier"
23
+ ? node.property.name
24
+ : null
25
+ return name !== null && /^(?:logger|log|winstonService|loggingService|appLogger)$/i.test(name)
26
+ }
27
+
28
+ const SECRET_WORDS = new Set(["password", "passwd", "secret", "secrets", "apikey", "authorization", "bearer", "otp", "cookie", "cookies", "credential", "credentials", "jwt", "passphrase"])
29
+ const SECRET_PAIRS = [["api", "key"], ["private", "key"], ["access", "key"], ["secret", "key"], ["signing", "key"], ["client", "secret"]]
30
+ const PII_WORDS = new Set(["email", "emails", "phone", "phonenumber", "fullname", "idcard", "cccd", "birthdate", "dob"])
31
+ /** A name that measures or classifies a credential is not the credential. */
32
+ const MEASURE_WORDS = new Set(["count", "counts", "id", "ids", "type", "types", "name", "names", "ttl", "expiry", "expires", "expiresat", "limit", "usage", "used", "budget", "max", "min", "input", "output", "total", "length", "kind", "hash", "hashed", "masked", "present", "provided", "configured", "exists", "has", "is", "reason", "status", "prefix", "suffix", "version", "rotated", "at", "summary", "source", "from", "fixture", "seed"])
33
+
34
+ /** Whether a name spells a credential, given its words. */
35
+ const isSecretName = (words) => {
36
+ if (words.some((word) => MEASURE_WORDS.has(word))) return false
37
+ if (words.some((word) => SECRET_WORDS.has(word))) return true
38
+ if (words.includes("token") || words.includes("tokens")) return true
39
+ return SECRET_PAIRS.some(([a, b]) => words.some((word, index) => word === a && words[index + 1] === b))
40
+ }
41
+
42
+ const isPiiName = (words) => !words.some((word) => MEASURE_WORDS.has(word)) && words.some((word) => PII_WORDS.has(word))
43
+
44
+ /** A call that hides its argument: the value shown is a mask, a hash, a flag or a count. */
45
+ const isHidingCall = (node) =>
46
+ node.type === "CallExpression"
47
+ && node.callee.type === "Identifier"
48
+ && /^(?:mask|redact|hash|fingerprint|last4|truncate|Boolean|Number|String)$|^(?:mask|redact|hash|fingerprint)/i.test(node.callee.name)
49
+
50
+ /** The names an argument reads: identifiers, member properties and object keys, not nested function bodies. */
51
+ const namesIn = (node, found = []) => {
52
+ if (!node || typeof node.type !== "string") return found
53
+ switch (node.type) {
54
+ case "Identifier":
55
+ found.push({ name: node.name, node })
56
+ return found
57
+ case "MemberExpression":
58
+ // only the last name says what the value IS: `credential.fromEmail` is an address, `payload.token` is a token
59
+ if (!node.computed && node.property.type === "Identifier") found.push({ name: node.property.name, node: node.property })
60
+ else namesIn(node.property, found)
61
+ return found
62
+ case "Property":
63
+ if (isHidingCall(node.value)) return found
64
+ if (!node.computed && node.key.type === "Identifier") found.push({ name: node.key.name, node: node.key })
65
+ namesIn(node.value, found)
66
+ return found
67
+ case "ObjectExpression":
68
+ node.properties.forEach((property) => namesIn(property, found))
69
+ return found
70
+ case "ArrayExpression":
71
+ node.elements.forEach((element) => namesIn(element, found))
72
+ return found
73
+ case "TemplateLiteral":
74
+ node.expressions.forEach((expression) => namesIn(expression, found))
75
+ return found
76
+ case "BinaryExpression":
77
+ case "LogicalExpression":
78
+ namesIn(node.left, found)
79
+ namesIn(node.right, found)
80
+ return found
81
+ case "ConditionalExpression":
82
+ namesIn(node.consequent, found)
83
+ namesIn(node.alternate, found)
84
+ return found
85
+ case "SpreadElement":
86
+ return namesIn(node.argument, found)
87
+ case "TSAsExpression":
88
+ case "TSNonNullExpression":
89
+ return namesIn(node.expression, found)
90
+ case "CallExpression":
91
+ // `mask(token)` and `redact(token)` are the sanctioned way to show a credential; any other call reads its arguments
92
+ if (isHidingCall(node)) return found
93
+ node.arguments.forEach((argument) => namesIn(argument, found))
94
+ return found
95
+ default:
96
+ return found
97
+ }
98
+ }
99
+
100
+ /** A logger call names no credential and no personal identifier. */
101
+ export const noSecretInLog = {
102
+ meta: {
103
+ type: "problem",
104
+ docs: { description: "Arguments of a logger call carry no credential and no personal identifier by name." },
105
+ schema: [],
106
+ messages: {
107
+ secret:
108
+ "`{{name}}` reads as a credential and is passed to a logger. Log lines outlive the request and are copied to vendors. Log whether it was present, its length, or a masked form (`mask(...)`), never the value.",
109
+ pii:
110
+ "`{{name}}` reads as a personal identifier and is passed to a logger. Log the owning id instead, or a masked form (`mask(...)`), never the raw value.",
111
+ },
112
+ },
113
+ create(context) {
114
+ const filename = context.filename || context.getFilename()
115
+ if (isTestLane(filename) || isDeclarationFile(filename)) return {}
116
+ return {
117
+ CallExpression(node) {
118
+ const { callee } = node
119
+ if (callee.type !== "MemberExpression" || callee.computed || callee.property.type !== "Identifier") return
120
+ if (!LOG_METHODS.has(callee.property.name) || !isLoggerReceiver(callee.object)) return
121
+ const reported = new Set()
122
+ for (const argument of node.arguments) {
123
+ for (const { name, node: at } of namesIn(argument)) {
124
+ const words = wordsOf(name)
125
+ if (reported.has(name)) continue
126
+ if (isSecretName(words)) {
127
+ reported.add(name)
128
+ context.report({ node: at, messageId: "secret", data: { name } })
129
+ } else if (isPiiName(words)) {
130
+ reported.add(name)
131
+ context.report({ node: at, messageId: "pii", data: { name } })
132
+ }
133
+ }
134
+ }
135
+ },
136
+ }
137
+ },
138
+ }
139
+
140
+ /** The rules this law contributes to the plugin. */
141
+ export const rules = {
142
+ "no-secret-in-log": noSecretInLog,
143
+ }
144
+
145
+ /** Starts at error: no baseline exists, and the repositories' fix lanes clear the debt. */
146
+ export const recommended = {
147
+ "starci-be/no-secret-in-log": "error",
148
+ }
@@ -1,14 +1,17 @@
1
1
  /**
2
2
  * The rules that hold `module-layering.mjs`'s law file, `module-layering.md`.
3
3
  *
4
- * Five rules, all reading import specifiers, export specifiers, decorators or a filename against
4
+ * Four rules, all reading import specifiers, export specifiers, decorators or a filename against
5
5
  * the path of the file being linted. They are cheap and exact -- none of them touches disk -- which
6
6
  * is the whole reason they can be `error`:
7
- * - `must-deep-module-import` / `no-self-module-alias` hold LAYERING-1 and LAYERING-2.
8
- * - `no-folder-reexport` holds LAYERING-5 / Law 7 (no file re-exports a folder).
9
- * - `no-self-global-module` holds the decidable slice of LAYERING-4 / Law 6 (a capability may not
10
- * declare itself `@Global()`; see that rule's own doc comment for what is deliberately still
11
- * left undecided).
7
+ * - `must-deep-module-import` / `no-self-module-alias` hold LAYERING-1 and LAYERING-2. Under HFS the
8
+ * first is the cross-owner public-entry rule: an aliased import names the owner and stops there
9
+ * (`@modules/domain/plan`, `@features/plan`), so it lands on the owner's `index.ts`. A path into a
10
+ * file of another owner, or an alias root or tier with no owner, is refused.
11
+ * - `no-folder-reexport` holds LAYERING-5 / Law 7 and the width of an HFS `index.ts`: no folder
12
+ * re-export, no `export *`, no `*_STORE` token, no whole `types/` folder, and at most the manifest's
13
+ * `indexExports` names.
14
+ * - `@Global()` is not decided here: `global-module-allowlist` (module-shape.mjs) reads the allowlist.
12
15
  * - `no-relative-capability-escape` holds Law 8 (a relative import may not walk out of its own
13
16
  * capability -- crossing a boundary always goes through the declared public alias).
14
17
  *
@@ -28,14 +31,14 @@
28
31
  */
29
32
 
30
33
  import { normalizePath } from "./lib/path.mjs"
34
+ import { hfsParams } from "./lib/slots.mjs"
31
35
 
32
36
  /**
33
37
  * Category folders that hold capabilities rather than being one.
34
38
  *
35
- * Under these the capability name is the second segment. `lib` remains recognized while older
36
- * sources transition to the HFS tiers; the architecture check rejects it as a new tier.
39
+ * Under these the capability name is the second segment. They are the three HFS tiers; `lib` is not one.
37
40
  */
38
- const META_ROOTS = new Set(["domain", "platform", "integrations", "lib"])
41
+ const META_ROOTS = new Set(["domain", "platform", "integrations"])
39
42
 
40
43
  /** The aliases a capability is reachable through. */
41
44
  const ALIASES = [
@@ -61,18 +64,32 @@ const ALIASES = [
61
64
 
62
65
  // -- LAYERING-1 ------------------------------------------------------------------------------------
63
66
 
64
- /** An import names a capability's explicit public entry or a file, never a root or tier. */
67
+ /**
68
+ * An aliased import lands on an owner's public entry: the alias and the owner, nothing deeper.
69
+ *
70
+ * HFS gives every owner (a feature, a domain or platform capability, an integration) one `index.ts` that
71
+ * lists what it offers. An import from another owner therefore names the owner and stops
72
+ * (`@modules/domain/plan`, `@features/plan`); `@modules/domain/plan/plan.service` reaches past that entry
73
+ * into a file the owner did not offer, and `@modules/domain` or `@modules` names no owner at all. The
74
+ * rule's published name is older than the standard - it once required the opposite, a path INTO a file -
75
+ * and is kept because the name is what a build log and a manifest cite; the behavior is HFS's.
76
+ *
77
+ * Reaching into one's own owner through its alias is `no-self-module-alias`'s finding, not this one's.
78
+ */
65
79
  export const mustDeepModuleImport = {
66
80
  meta: {
67
81
  type: "problem",
68
- docs: { description: "Import a capability public entry or a file, never an alias root or tier." },
82
+ docs: { description: "Import another owner through its public entry: the alias and the owner, never a path into it." },
69
83
  schema: [],
70
84
  messages: {
71
85
  barrel:
72
- "`{{specifier}}` stops before a capability public entry or file. Import an explicit capability index.ts API or the declaring file.",
86
+ "`{{specifier}}` stops before an owner. Import an owner's public entry, for example `@modules/domain/plan` or `@features/plan`.",
87
+ deep:
88
+ "`{{specifier}}` reaches into a file of another owner. Import the owner's public entry (its `index.ts`) and export what you need from there.",
73
89
  },
74
90
  },
75
91
  create(context) {
92
+ const self = selfAliases(context.filename || context.getFilename())
76
93
  const check = (node, specifier) => {
77
94
  if (typeof specifier !== "string") return
78
95
  const alias = ALIASES.find((candidate) => specifier.startsWith(candidate.prefix))
@@ -84,12 +101,17 @@ export const mustDeepModuleImport = {
84
101
  return
85
102
  }
86
103
  const parts = rest.split("/")
87
- const barrelDepth = alias.metaAware && META_ROOTS.has(parts[0])
88
- ? 2
89
- : 1
90
- if (parts.length < barrelDepth || (!alias.publicEntry && parts.length === barrelDepth)) {
104
+ const ownerDepth = alias.metaAware && META_ROOTS.has(parts[0]) ? 2 : 1
105
+ if (parts.length < ownerDepth || (!alias.publicEntry && parts.length === ownerDepth)) {
91
106
  context.report({ node, messageId: "barrel", data: { specifier } })
107
+ return
92
108
  }
109
+ if (!alias.publicEntry || parts.length === ownerDepth) return
110
+ const entryFile = parts.length === ownerDepth + 1 && parts[ownerDepth] === "index"
111
+ if (entryFile) return
112
+ // reaching into one's own owner through its alias is no-self-module-alias's finding
113
+ if (self && self.prefix === alias.prefix && self.keys.some((key) => rest === key || rest.startsWith(`${key}/`))) return
114
+ context.report({ node, messageId: "deep", data: { specifier } })
93
115
  }
94
116
  return {
95
117
  ImportDeclaration(node) {
@@ -210,44 +232,59 @@ const isBareFolderSpecifier = (specifier) =>
210
232
  specifier === "." || specifier === "./" || specifier === ".." || specifier === "../" || specifier.endsWith("/")
211
233
 
212
234
  /**
213
- * LAYERING-5 / module-layering.md Law 7: no file re-exports a folder.
235
+ * LAYERING-5 / module-layering.md Law 7, and the shape of an HFS `index.ts` public surface.
214
236
  *
215
- * Two certain signs, neither needing a disk lookup:
216
- * - a bare-dot or trailing-slash specifier names a directory by construction -- no file path ever
217
- * looks like that, aliased or relative, so there is nothing to guess;
218
- * - an export-star in an `index.*` public entry hides the capability's API rather than listing
219
- * its exports explicitly, even when other named exports appear beside it.
237
+ * The public entry of an owner is a short, explicit list. Certain signs, none needing a disk lookup:
238
+ * - a bare-dot or trailing-slash specifier names a directory by construction, in any file;
239
+ * - in an `index.*` file: `export *` hides the surface instead of listing it; a specifier ending in
240
+ * `/types` or `/types/index` exports a whole types folder; an exported name ending in `_STORE` hands out
241
+ * a storage token; and more than `maxExports` (the `indexExports` budget of the slot manifest) names is not
242
+ * a public surface but the inside of the owner.
220
243
  *
221
- * What this deliberately does NOT flag: a single bridging re-export naming a real file
222
- * (`export { AiInvokeService } from '@modules/ai/ai-invoke.service'`), which LAYERING-1's own
223
- * common-business-situations list keeps legitimate, and a relative barrel that names a real
224
- * multi-segment path with no file extension (`export * from './services'`) -- telling that apart
225
- * from a real file with an implicit extension needs the filesystem, which no rule in this canon
226
- * touches; see the module doc comment for why that gap is left to LAYERING-1's alias-only reach
227
- * plus this filename check rather than guessed at.
244
+ * What this accepts, because HFS wants it: `export { PlanModule } from "./plan.module"`,
245
+ * `export type { PlanSummary } from "./plan.contracts"`, and `export { entities, migrations } from
246
+ * "./persistence"` - explicit names from named files. Whether a specifier such as `./persistence` is a
247
+ * file or a folder needs the file system, which no rule in this canon touches.
228
248
  */
229
249
  export const noFolderReexport = {
230
250
  meta: {
231
251
  type: "problem",
232
252
  docs: {
233
253
  description:
234
- "LAYERING-5 / HFS: no file re-exports a folder, and public index files list named exports.",
254
+ "LAYERING-5 / HFS: no file re-exports a folder, and an index file lists a bounded set of named exports.",
235
255
  },
236
- schema: [],
256
+ schema: [
257
+ {
258
+ type: "object",
259
+ properties: { maxExports: { type: "integer", minimum: 1 } },
260
+ additionalProperties: false,
261
+ },
262
+ ],
237
263
  messages: {
238
264
  bareSpecifier:
239
- "`{{specifier}}` names a directory, not a file. LAYERING-5: a capability's public surface is the files call sites actually import -- re-exporting a whole folder makes that surface a list nobody reads instead. Export the specific file.",
265
+ "`{{specifier}}` names a directory, not a file. LAYERING-5: a capability's public surface is the files call sites actually import - re-exporting a whole folder makes that surface a list nobody reads instead. Export the specific file.",
240
266
  indexBarrel:
241
267
  "`{{name}}` is a public entry and must list explicit named exports. Replace export-star with the symbols this capability exposes.",
268
+ typesFolder:
269
+ "`{{specifier}}` exports a whole types folder from a public entry. List the contract types other owners need, by name.",
270
+ storeToken:
271
+ "`{{name}}` is a storage token. A public entry offers behavior and contracts, not the token that opens the owner's store.",
272
+ tooWide:
273
+ "This public entry exports {{count}} names, above the budget of {{max}}. An entry that wide is the inside of the owner: split the owner, or export fewer, narrower contracts.",
242
274
  },
243
275
  },
244
276
  create(context) {
245
277
  const filename = normalizePath(context.filename || context.getFilename())
278
+ const isIndex = INDEX_FILE_RE.test(filename)
279
+ const max = context.options[0]?.maxExports ?? hfsParams.indexExports
280
+ let count = 0
246
281
 
247
282
  const checkSpecifier = (node, specifier) => {
248
283
  if (typeof specifier !== "string") return
249
284
  if (isBareFolderSpecifier(specifier)) {
250
285
  context.report({ node, messageId: "bareSpecifier", data: { specifier } })
286
+ } else if (isIndex && /(?:^|\/)types(?:\/index)?$/.test(specifier)) {
287
+ context.report({ node, messageId: "typesFolder", data: { specifier } })
251
288
  }
252
289
  }
253
290
 
@@ -257,10 +294,23 @@ export const noFolderReexport = {
257
294
  },
258
295
  ExportNamedDeclaration(node) {
259
296
  if (node.source) checkSpecifier(node.source, node.source.value)
297
+ if (!isIndex) return
298
+ const declared = []
299
+ if (node.declaration) {
300
+ if (node.declaration.id) declared.push(node.declaration.id)
301
+ for (const declarator of node.declaration.declarations ?? []) if (declarator.id.type === "Identifier") declared.push(declarator.id)
302
+ }
303
+ for (const specifier of node.specifiers) declared.push(specifier.exported)
304
+ count += declared.length
305
+ for (const identifier of declared) {
306
+ const name = identifier.name ?? identifier.value
307
+ if (typeof name === "string" && /_STORE$/.test(name)) context.report({ node: identifier, messageId: "storeToken", data: { name } })
308
+ }
260
309
  },
261
310
  ExportAllDeclaration(node) {
262
311
  if (node.source) checkSpecifier(node.source, node.source.value)
263
- if (INDEX_FILE_RE.test(filename) && !isBareFolderSpecifier(node.source?.value)) {
312
+ if (isIndex) count += 1
313
+ if (isIndex && !isBareFolderSpecifier(node.source?.value)) {
264
314
  context.report({
265
315
  node,
266
316
  messageId: "indexBarrel",
@@ -268,70 +318,8 @@ export const noFolderReexport = {
268
318
  })
269
319
  }
270
320
  },
271
- }
272
- },
273
- }
274
-
275
- // -- LAYERING-4 / Law 6 (partial) ----------------------------------------------------------------
276
-
277
- /** Files that build their own throwaway test graph rather than wiring a production capability. */
278
- const TEST_FILE_RE = /\.(spec|test)\.(ts|tsx|js|jsx|mjs|cjs)$/
279
-
280
- /**
281
- * LAYERING-4 / module-layering.md Law 6, the one slice of it decidable without whole-application
282
- * knowledge: a capability declaring `@Global()` on itself.
283
- *
284
- * The rest of Law 6 -- which capabilities exist at all, and what starts before what -- has no rule
285
- * here, and the omission is deliberate, not an oversight. Both need knowledge a single file does
286
- * not have: "which capabilities exist" is a fact about the whole application graph, and "what
287
- * starts first" is recorded today as an English comment (`apps/core/src/main.ts`'s side-effect
288
- * import), which a rule can only pattern-match on wording and would be guessing at meaning, not
289
- * reading syntax.
290
- *
291
- * `isGlobal` passed as a plain, caller-supplied OPTION (`.register({ isGlobal: false })`,
292
- * `.setExtras({ isGlobal: false }, (definition, extras) => ({ ...definition, global: extras.isGlobal
293
- * }))`) is deliberately OUT of scope too, and measuring the reference repository is why: it passes
294
- * that literal option at every level of nesting, including a capability's own top module wiring its
295
- * own nested children (`CoreModule.register({ isGlobal: true })` inside `features/api/api.module.ts`,
296
- * which is nesting, not the sideways declaration Law 6 refuses). Telling "a nested child" from "a
297
- * foreign capability" needs the module graph -- the exact reason LAYERING-3 stays a documented code
298
- * rather than an enforced one, and the same reason applies here. `@Global()` carries none of that
299
- * ambiguity: it declares the module global unconditionally, for every caller, which is precisely
300
- * the fact Law 6 says only a composition root gets to state.
301
- */
302
- export const noSelfGlobalModule = {
303
- meta: {
304
- type: "problem",
305
- docs: {
306
- description:
307
- "LAYERING-4 / module-layering.md Law 6: which capabilities are global is stated only at the composition root -- a capability's own file may not declare itself `@Global()`.",
308
- },
309
- schema: [],
310
- messages: {
311
- global:
312
- "`@Global()` declares this module global from inside its own capability. LAYERING-4: whether a capability is global is a fact about the APPLICATION, decided per-application at the composition root (`AiModule.register({ isGlobal: true })` in `apps/*/src/app.module.ts`), not a fact a capability gets to state about itself. Remove the decorator and pass `isGlobal` through as a caller-supplied option instead.",
313
- },
314
- },
315
- create(context) {
316
- const filename = normalizePath(context.filename || context.getFilename())
317
- if (TEST_FILE_RE.test(filename)) return {}
318
- const self = selfAliases(filename)
319
- if (!self || self.prefix === "@tests/") return {}
320
-
321
- return {
322
- Decorator(node) {
323
- const expr = node.expression
324
- const name =
325
- expr.type === "CallExpression"
326
- ? expr.callee.type === "Identifier"
327
- ? expr.callee.name
328
- : null
329
- : expr.type === "Identifier"
330
- ? expr.name
331
- : null
332
- if (name === "Global") {
333
- context.report({ node, messageId: "global" })
334
- }
321
+ "Program:exit"(node) {
322
+ if (isIndex && count > max) context.report({ node, messageId: "tooWide", data: { count, max } })
335
323
  },
336
324
  }
337
325
  },
@@ -403,24 +391,18 @@ export const rules = {
403
391
  "must-deep-module-import": mustDeepModuleImport,
404
392
  "no-self-module-alias": noSelfModuleAlias,
405
393
  "no-folder-reexport": noFolderReexport,
406
- "no-self-global-module": noSelfGlobalModule,
407
394
  "no-relative-capability-escape": noRelativeCapabilityEscape,
408
395
  }
409
396
 
410
397
  /**
411
398
  * The level this law asks for, as the plugin's own opinion.
412
399
  *
413
- * All five measured at zero debt in the reference repository -- the three added alongside
414
- * `no-folder-reexport`, `no-self-global-module` and `no-relative-capability-escape` fired zero
415
- * times across the repository's ~4700 `src/**` TypeScript files, verified against a harness that
416
- * DOES fire on synthetic violations of each (so the zero is measured, not a broken rule going
417
- * quiet). A repository adopting any of these five into an existing tree will not be at zero: land
418
- * above zero at `warn` with the count, exactly as the burn-down procedure says.
400
+ * All four are `error`. HFS keeps no baseline: a repository adopts them after its migration lane has
401
+ * moved its imports onto owner entries, and `starciBeConfig` states them at `error` with none switched off.
419
402
  */
420
403
  export const recommended = {
421
404
  "starci-be/must-deep-module-import": "error",
422
405
  "starci-be/no-self-module-alias": "error",
423
406
  "starci-be/no-folder-reexport": "error",
424
- "starci-be/no-self-global-module": "error",
425
407
  "starci-be/no-relative-capability-escape": "error",
426
408
  }