@starci/eslint-canon-be 3.3.1 → 3.4.1
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 +15 -0
- package/README.md +2 -2
- package/app-composition.mjs +348 -3
- package/authorization.mjs +68 -2
- package/builders.mjs +2 -2
- package/config-owner.mjs +44 -15
- package/default-deny.mjs +80 -9
- package/edge-hardening.mjs +139 -0
- package/feature-thin.mjs +78 -2
- package/function-budget.mjs +49 -0
- package/health-ready.mjs +539 -0
- package/index.mjs +37 -0
- package/infra-owner.mjs +3 -0
- package/jwt-alg.mjs +45 -0
- package/lib/subject-values.mjs +55 -0
- package/lib/unit-spec.mjs +0 -3
- package/lib/webhook-handoff-inbox.mjs +260 -0
- package/lib/webhook-handoff-intake.mjs +140 -0
- package/lib/why.mjs +13 -0
- package/module-shape.mjs +147 -51
- package/operations.mjs +165 -1
- package/package.json +1 -1
- package/realtime.mjs +59 -22
- package/resilience.mjs +45 -14
- package/runtime/config.example.yaml +7 -6
- package/runtime/engine/config.mjs +10 -18
- package/runtime/engine/removed-vocabulary.mjs +0 -2
- package/runtime/knowledge/hfs/canon-pins.yaml +11 -11
- package/runtime/knowledge/hfs/fe-stack.yaml +22 -1
- package/runtime/knowledge/hfs/rules.yaml +2855 -163
- package/runtime/knowledge/hfs/slots.yaml +333 -62
- package/runtime/knowledge/patterns/fe/folder.yaml +1 -0
- package/runtime/knowledge/sonar-gate.yaml +1 -1
- package/runtime/modules/kernel/removed-vocabulary.yaml +30 -0
- package/runtime/modules/models/runtimes.yaml +5 -7
- package/runtime/packages/hfs/templates/app/package-scripts/package.json +1 -0
- package/runtime/scripts/api/git/lib.mjs +4 -1
- package/runtime/scripts/api/git/merge-base.mjs +9 -0
- package/runtime/scripts/api/git/recorded-lines.mjs +54 -8
- package/runtime/scripts/hfs/architecture/backend-app-files.mjs +1 -8
- package/runtime/scripts/hfs/architecture/backend.mjs +2 -7
- package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +6 -3
- package/runtime/scripts/hfs/architecture/config.mjs +1 -1
- package/runtime/scripts/hfs/architecture/dead-exports-bindings.mjs +149 -0
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +9 -41
- package/runtime/scripts/hfs/architecture/default-deny.mjs +8 -6
- package/runtime/scripts/hfs/architecture/frontend-world.mjs +1 -1
- package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +153 -0
- package/runtime/scripts/hfs/architecture/hfs.mjs +3 -3
- package/runtime/scripts/hfs/architecture/index.mjs +1 -1
- package/runtime/scripts/hfs/architecture/module-per-transport.mjs +28 -13
- package/runtime/scripts/hfs/architecture/next-data-inference.mjs +176 -0
- package/runtime/scripts/hfs/architecture/next-data-symbols.mjs +26 -0
- package/runtime/scripts/hfs/architecture/next-data.mjs +31 -23
- package/runtime/scripts/hfs/architecture/reachability.mjs +14 -0
- package/runtime/scripts/hfs/architecture/register-once.mjs +2 -2
- package/runtime/scripts/hfs/architecture/source-names.mjs +48 -3
- package/runtime/scripts/hfs/architecture/supabase-ast.mjs +211 -11
- package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -1
- package/runtime/scripts/hfs/architecture/supabase.mjs +59 -21
- package/runtime/scripts/hfs/architecture/surface.mjs +2 -1
- package/runtime/scripts/hfs/architecture/tiers.mjs +4 -4
- package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +8 -2
- package/runtime/scripts/hfs/declaration-shape.mjs +39 -3
- package/runtime/scripts/hfs/edition-slots.mjs +2 -2
- package/runtime/scripts/hfs/manifest-shape.mjs +69 -9
- package/runtime/scripts/hfs/param-names.mjs +1 -1
- package/runtime/scripts/hfs/project-rule.mjs +3 -1
- package/runtime/scripts/hfs/rule-catalog.mjs +158 -11
- package/runtime/scripts/hfs/rule-params-shape.mjs +22 -10
- package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
- package/runtime/scripts/hfs/slot-imports.mjs +18 -5
- package/runtime/scripts/hfs/slots.mjs +3 -2
- package/runtime/scripts/hfs/typescript-programs.mjs +0 -8
- package/runtime/scripts/lib/env.mjs +20 -0
- package/runtime/scripts/lib/event-contract.mjs +107 -0
- package/runtime/scripts/lib/git.mjs +1 -1
- package/runtime/scripts/lib/i18n.mjs +0 -2
- package/runtime/scripts/lib/json-clone.mjs +61 -0
- package/runtime/scripts/lib/language.mjs +1 -1
- package/runtime/scripts/lib/list.mjs +4 -0
- package/runtime/scripts/lib/secret-patterns.mjs +60 -5
- package/runtime/scripts/lib/stack-services.mjs +1 -1
- package/runtime/scripts/lib/ts-analysis.mjs +5 -2
- package/runtime/scripts/lib/ts-ast.mjs +27 -2
- package/service-isolation.mjs +3 -0
- package/size-budget.mjs +9 -5
- package/third-party-owner.mjs +149 -0
- package/transport.mjs +11 -29
- package/unit-spec.mjs +270 -84
- package/webhooks.mjs +169 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* R279 BE_THIRD_PARTY_OWNER: dependencies outside the pure-library and framework vocabulary belong to one integration.
|
|
3
|
+
*
|
|
4
|
+
* The SDK inventory comes from the app-root package.json, not a list of SDK names. An unknown dependency is therefore
|
|
5
|
+
* subject to ownership immediately. R90's infrastructure table remains authoritative for its existing libraries;
|
|
6
|
+
* framework packages here supply composition, transport and persistence types that other canon rules already judge.
|
|
7
|
+
* The HFS view decides every source file's tier and owner, including imports in specs and provider support files.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs"
|
|
10
|
+
import { isBuiltin } from "node:module"
|
|
11
|
+
import { dirname, join, resolve } from "node:path"
|
|
12
|
+
import ts from "typescript"
|
|
13
|
+
import { hfsOf, inTestWorld } from "./lib/hfs.mjs"
|
|
14
|
+
import { moduleReferences } from "./lib/import-source.mjs"
|
|
15
|
+
import { isSpecFile } from "./lib/path.mjs"
|
|
16
|
+
|
|
17
|
+
const PURE = new Set([
|
|
18
|
+
"class-transformer", "class-validator", "rxjs", "reflect-metadata", "tslib", "zod", "lodash", "lodash-es",
|
|
19
|
+
"ramda", "decimal.js", "big.js", "bignumber.js", "uuid", "nanoid", "graphql", "graphql-scalars", "graphql-tag",
|
|
20
|
+
"dataloader", "fast-json-stable-stringify", "ajv", "yaml", "js-yaml", "semver",
|
|
21
|
+
])
|
|
22
|
+
const FRAMEWORK = new Set([
|
|
23
|
+
"@nestjs/common", "@nestjs/core", "@nestjs/cqrs", "@nestjs/graphql", "@nestjs/apollo", "@nestjs/platform-express",
|
|
24
|
+
"@nestjs/typeorm", "@nestjs/swagger", "@nestjs/mapped-types", "@nestjs/throttler", "@apollo/server",
|
|
25
|
+
"@as-integrations/express5", "express", "nest-commander", "typeorm",
|
|
26
|
+
])
|
|
27
|
+
const TEST_LIBRARIES = new Set(["@nestjs/testing", "@starci/jest-preset", "@starci/test-world", "@jest/globals", "jest", "jest-mock"])
|
|
28
|
+
const INVENTORIES = new WeakMap()
|
|
29
|
+
const SOURCE = /\.[cm]?[jt]sx?$/
|
|
30
|
+
const GENERATED = new Set(["node_modules", "dist", "coverage", ".git"])
|
|
31
|
+
|
|
32
|
+
/** The package root of a bare module specifier, including a scoped package's subpaths. */
|
|
33
|
+
const packageOf = (specifier) => {
|
|
34
|
+
if (specifier.startsWith(".") || specifier.startsWith("/") || specifier.startsWith("#") || isBuiltin(specifier)) return null
|
|
35
|
+
const parts = specifier.split("/")
|
|
36
|
+
return specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A file that the back-end slot view classifies as executable source or a test. */
|
|
40
|
+
const inSource = (hfs, file) => {
|
|
41
|
+
const tier = hfs.tierOf(file)
|
|
42
|
+
return (tier !== null && tier !== "none") || hfs.slotOf(file)?.startsWith("be.tests.") === true
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Every literal module reference in a source file, including dynamic imports, require and re-exports. */
|
|
46
|
+
const referencesOf = (file, text) => {
|
|
47
|
+
const found = new Set()
|
|
48
|
+
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true)
|
|
49
|
+
const add = (node) => {
|
|
50
|
+
if (node && (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node))) found.add(node.text)
|
|
51
|
+
}
|
|
52
|
+
const visit = (node) => {
|
|
53
|
+
if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) add(node.moduleSpecifier)
|
|
54
|
+
else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) add(node.moduleReference.expression)
|
|
55
|
+
else if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || (ts.isIdentifier(node.expression) && node.expression.text === "require"))) add(node.arguments[0])
|
|
56
|
+
ts.forEachChild(node, visit)
|
|
57
|
+
}
|
|
58
|
+
visit(source)
|
|
59
|
+
return found
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The app's dependency declarations and the SDK imports of all integration owners, read once for this HFS view.
|
|
64
|
+
* No ancestor search: be/ has the app-root package.json immediately above it; standalone fixtures may put it at root.
|
|
65
|
+
*/
|
|
66
|
+
const inventoryOf = (hfs) => {
|
|
67
|
+
if (INVENTORIES.has(hfs)) return INVENTORIES.get(hfs)
|
|
68
|
+
const appManifest = join(dirname(hfs.repoRoot), "package.json")
|
|
69
|
+
const manifest = existsSync(appManifest) ? appManifest : join(hfs.repoRoot, "package.json")
|
|
70
|
+
if (!existsSync(manifest)) throw new Error("third-party-import-owner needs the app-root package.json beside be/ to derive SDK ownership")
|
|
71
|
+
const declaration = JSON.parse(readFileSync(manifest, "utf8"))
|
|
72
|
+
const dependencies = new Map()
|
|
73
|
+
for (const section of ["dependencies", "optionalDependencies", "peerDependencies", "devDependencies"]) {
|
|
74
|
+
for (const [name, version] of Object.entries(declaration[section] ?? {})) {
|
|
75
|
+
if (/^(?:workspace:|file:|link:)/.test(String(version))) continue
|
|
76
|
+
const alias = /^npm:((?:@[^/]+\/)?[^@]+)(?:@|$)/.exec(String(version))
|
|
77
|
+
dependencies.set(name, alias?.[1] ?? name)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const byFile = new Map()
|
|
81
|
+
const walk = (directory) => {
|
|
82
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
83
|
+
if (GENERATED.has(entry.name) || entry.isSymbolicLink()) continue
|
|
84
|
+
const file = join(directory, entry.name)
|
|
85
|
+
if (entry.isDirectory()) walk(file)
|
|
86
|
+
else if (SOURCE.test(entry.name) && hfs.tierOf(file) === "integrations") byFile.set(resolve(file), referencesOf(file, readFileSync(file, "utf8")))
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
walk(hfs.repoRoot)
|
|
90
|
+
const result = { dependencies, byFile }
|
|
91
|
+
INVENTORIES.set(hfs, result)
|
|
92
|
+
return result
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The table already owns this import (including a package subpath); R90 alone judges it. */
|
|
96
|
+
const inInfraTable = (hfs, specifier) => Object.keys(hfs.ruleParams.infraOwners).some((key) => specifier === key || specifier.startsWith(`${key}/`))
|
|
97
|
+
|
|
98
|
+
/** The declared SDK identity, or null for an import that belongs to another existing concern. */
|
|
99
|
+
const sdkOf = (hfs, dependencies, file, specifier) => {
|
|
100
|
+
const name = packageOf(specifier)
|
|
101
|
+
const dependency = dependencies.get(name)
|
|
102
|
+
if (!dependency || inInfraTable(hfs, specifier) || PURE.has(dependency) || FRAMEWORK.has(dependency)) return null
|
|
103
|
+
const isTest = isSpecFile(file) || hfs.slotOf(file)?.startsWith("be.tests.") === true
|
|
104
|
+
if (isTest && TEST_LIBRARIES.has(dependency)) return null
|
|
105
|
+
if (inTestWorld(hfs, file) && (dependency === "testcontainers" || dependency.startsWith("@testcontainers/"))) return null
|
|
106
|
+
return dependency
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Every third-party SDK import stays in a single integrations/<provider> capability. */
|
|
110
|
+
export const thirdPartyImportOwner = {
|
|
111
|
+
meta: {
|
|
112
|
+
type: "problem",
|
|
113
|
+
docs: { description: "BE_THIRD_PARTY_OWNER: a declared third-party SDK is imported only by one integration owner." },
|
|
114
|
+
schema: [],
|
|
115
|
+
messages: {
|
|
116
|
+
foreign: "[BE_THIRD_PARTY_OWNER] `{{name}}` is a third-party dependency from package.json. Import its SDK only inside one `integrations/<provider>` capability and inject that integration's public API here.",
|
|
117
|
+
multiple: "[BE_THIRD_PARTY_OWNER] `{{name}}` is imported by multiple integrations: {{owners}}. Keep its SDK, credentials and protocol mapping in one `integrations/<provider>` owner.",
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
create(context) {
|
|
121
|
+
const hfs = hfsOf(context)
|
|
122
|
+
const file = context.filename
|
|
123
|
+
if (!inSource(hfs, file)) return {}
|
|
124
|
+
let inventory
|
|
125
|
+
return moduleReferences(({ source, value }) => {
|
|
126
|
+
// Relative paths, node builtins and the infrastructure table need no dependency manifest.
|
|
127
|
+
if (packageOf(value) === null || inInfraTable(hfs, value)) return
|
|
128
|
+
inventory ??= inventoryOf(hfs)
|
|
129
|
+
const sdk = sdkOf(hfs, inventory.dependencies, file, value)
|
|
130
|
+
if (sdk === null) return
|
|
131
|
+
const here = hfs.ownerOf(file)
|
|
132
|
+
if (hfs.tierOf(file) !== "integrations" || here === null) {
|
|
133
|
+
context.report({ node: source, messageId: "foreign", data: { name: value } })
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
const owners = new Set([here])
|
|
137
|
+
for (const [other, references] of inventory.byFile) {
|
|
138
|
+
// ESLint's current buffer replaces the on-disk text of this file.
|
|
139
|
+
if (other === resolve(file)) continue
|
|
140
|
+
if ([...references].some((reference) => sdkOf(hfs, inventory.dependencies, other, reference) === sdk)) owners.add(hfs.ownerOf(other))
|
|
141
|
+
}
|
|
142
|
+
if (owners.size > 1) context.report({ node: source, messageId: "multiple", data: { name: sdk, owners: [...owners].sort().map((owner) => `\`${owner}\``).join(", ") } })
|
|
143
|
+
})
|
|
144
|
+
},
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The published rule and its required severity. */
|
|
148
|
+
export const rules = { "third-party-import-owner": thirdPartyImportOwner }
|
|
149
|
+
export const recommended = { "starci-be/third-party-import-owner": "error" }
|
package/transport.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import { hfsOf } from "./lib/hfs.mjs"
|
|
|
15
15
|
import { decoratorArguments, decoratorCallee, importOf, isImportedFrom, moduleReferences } from "./lib/import-source.mjs"
|
|
16
16
|
import { isTransportSlot, KIND_DOOR_SLOTS } from "./lib/transport-slots.mjs"
|
|
17
17
|
import { isPackageType, typeOrigins, typed } from "./lib/types.mjs"
|
|
18
|
+
import { operationRouteEvidence, restExceptionEvidence } from "./operations.mjs"
|
|
18
19
|
|
|
19
20
|
/** The HTTP route decorators of `@nestjs/common`. */
|
|
20
21
|
const HTTP_ROUTES = ["Get", "Post", "Put", "Patch", "Delete", "All", "Options", "Head"]
|
|
@@ -395,47 +396,28 @@ export const noCapabilityImportsFeatures = {
|
|
|
395
396
|
|
|
396
397
|
// -- rest-door-needs-a-reason ------------------------------------------------------------------------------------
|
|
397
398
|
|
|
398
|
-
/**
|
|
399
|
-
const REST_REASONS = new Set(["Health", "AuthHandshake", "SignedWebhook"])
|
|
400
|
-
|
|
401
|
-
/** Nest and Express exports whose presence shows a byte stream or a redirect. */
|
|
402
|
-
const BYTE_EVIDENCE = [
|
|
403
|
-
{ source: "@nestjs/common", names: ["StreamableFile", "Redirect"] },
|
|
404
|
-
{ source: "@nestjs/platform-express", names: ["FileInterceptor", "FilesInterceptor", "AnyFilesInterceptor", "FileFieldsInterceptor"] },
|
|
405
|
-
{ source: "node:fs", names: ["createReadStream"] },
|
|
406
|
-
{ source: "fs", names: ["createReadStream"] },
|
|
407
|
-
]
|
|
408
|
-
|
|
409
|
-
/** True when the class shows one of the four reasons a door is REST: a public reason, a byte stream, or an OAuth redirect. */
|
|
410
|
-
const showsReason = (context, controller) => {
|
|
411
|
-
let shown = false
|
|
412
|
-
walk(controller, (node) => {
|
|
413
|
-
if (shown) return
|
|
414
|
-
if (node.type === "MemberExpression" && !node.computed && node.property.type === "Identifier" && REST_REASONS.has(node.property.name) && node.object.type === "Identifier" && importOf(context, node.object)?.imported === "PublicReason") shown = true
|
|
415
|
-
if (node.type === "Identifier" && !(node.parent?.type === "MemberExpression" && node.parent.property === node && !node.parent.computed)) {
|
|
416
|
-
const found = importOf(context, node)
|
|
417
|
-
if (found && BYTE_EVIDENCE.some((evidence) => evidence.source === found.source && evidence.names.includes(found.imported))) shown = true
|
|
418
|
-
}
|
|
419
|
-
})
|
|
420
|
-
return shown
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
/** A REST door exists only where GraphQL cannot go, and the class has to show which case it is. */
|
|
399
|
+
/** A REST door exists only where GraphQL cannot go, and each route has to show which case it is. */
|
|
424
400
|
export const restDoorNeedsAReason = {
|
|
425
401
|
meta: {
|
|
426
402
|
type: "problem",
|
|
427
|
-
docs: { description: "
|
|
403
|
+
docs: { description: "Every REST route shows its own public reason (Health, AuthHandshake, SignedWebhook), byte transfer, redirect or declared operation table." },
|
|
428
404
|
schema: [],
|
|
429
405
|
messages: {
|
|
430
|
-
unjustified: "This REST
|
|
406
|
+
unjustified: "[BE_REST_DOOR_EVIDENCE] This REST route shows none of the reasons a door may not be GraphQL: a route-level probe, auth handshake or signed webhook (`@Public({ reason: PublicReason.X })`), a byte transfer, an OAuth redirect or a declared public v1 operation table. A plain JSON read belongs in the GraphQL schema, where the client already is.",
|
|
431
407
|
},
|
|
432
408
|
},
|
|
433
409
|
create(context) {
|
|
434
410
|
hfsOf(context)
|
|
411
|
+
const declared = operationRouteEvidence(context)
|
|
435
412
|
return {
|
|
436
413
|
ClassDeclaration(node) {
|
|
437
414
|
const controller = (node.decorators ?? []).find((decorator) => isController(context, decorator))
|
|
438
|
-
if (controller
|
|
415
|
+
if (!controller) return
|
|
416
|
+
for (const method of node.body.body) {
|
|
417
|
+
if (method.type !== "MethodDefinition" || method.kind !== "method" || method.static) continue
|
|
418
|
+
const route = (method.decorators ?? []).find((decorator) => isImportedFrom(context, decoratorCallee(decorator), "@nestjs/common", HTTP_ROUTES))
|
|
419
|
+
if (route && !restExceptionEvidence(context, method) && !declared(method)) context.report({ node: route, messageId: "unjustified" })
|
|
420
|
+
}
|
|
439
421
|
},
|
|
440
422
|
}
|
|
441
423
|
},
|