@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/CHANGELOG.md +43 -0
- package/README.md +82 -7
- package/async-discipline.mjs +83 -0
- package/comments.mjs +7 -55
- package/config-owner.mjs +252 -0
- package/cqrs.mjs +7 -141
- package/data-access.mjs +151 -5
- package/default-deny.mjs +116 -0
- package/error-handling.mjs +207 -0
- package/idempotency.mjs +101 -0
- package/index.mjs +34 -4
- package/input-bounds.mjs +84 -0
- package/lib/ast.mjs +81 -0
- package/lib/config.mjs +49 -0
- package/lib/path.mjs +15 -0
- package/lib/slots.mjs +60 -0
- package/log-safety.mjs +148 -0
- package/module-layering.mjs +87 -105
- package/module-shape.mjs +202 -0
- package/package.json +1 -1
- package/query-safety.mjs +274 -0
- package/resilience.mjs +178 -0
- package/schema-authority.mjs +187 -0
- package/size-budget.mjs +106 -0
- package/spec-quality.mjs +132 -0
- package/suppression.mjs +60 -0
- package/temporal.mjs +71 -0
- package/testing.mjs +31 -19
- package/type-safety.mjs +98 -57
- package/user-copy.mjs +106 -0
- package/exception-identity.mjs +0 -200
- package/exceptions.mjs +0 -262
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
|
+
}
|
package/module-layering.mjs
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The rules that hold `module-layering.mjs`'s law file, `module-layering.md`.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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`
|
|
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"
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
|
88
|
-
|
|
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
|
|
235
|
+
* LAYERING-5 / module-layering.md Law 7, and the shape of an HFS `index.ts` public surface.
|
|
214
236
|
*
|
|
215
|
-
*
|
|
216
|
-
* - a bare-dot or trailing-slash specifier names a directory by construction
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
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
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
414
|
-
*
|
|
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
|
}
|