@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.
Files changed (91) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +2 -2
  3. package/app-composition.mjs +348 -3
  4. package/authorization.mjs +68 -2
  5. package/builders.mjs +2 -2
  6. package/config-owner.mjs +44 -15
  7. package/default-deny.mjs +80 -9
  8. package/edge-hardening.mjs +139 -0
  9. package/feature-thin.mjs +78 -2
  10. package/function-budget.mjs +49 -0
  11. package/health-ready.mjs +539 -0
  12. package/index.mjs +37 -0
  13. package/infra-owner.mjs +3 -0
  14. package/jwt-alg.mjs +45 -0
  15. package/lib/subject-values.mjs +55 -0
  16. package/lib/unit-spec.mjs +0 -3
  17. package/lib/webhook-handoff-inbox.mjs +260 -0
  18. package/lib/webhook-handoff-intake.mjs +140 -0
  19. package/lib/why.mjs +13 -0
  20. package/module-shape.mjs +147 -51
  21. package/operations.mjs +165 -1
  22. package/package.json +1 -1
  23. package/realtime.mjs +59 -22
  24. package/resilience.mjs +45 -14
  25. package/runtime/config.example.yaml +7 -6
  26. package/runtime/engine/config.mjs +10 -18
  27. package/runtime/engine/removed-vocabulary.mjs +0 -2
  28. package/runtime/knowledge/hfs/canon-pins.yaml +11 -11
  29. package/runtime/knowledge/hfs/fe-stack.yaml +22 -1
  30. package/runtime/knowledge/hfs/rules.yaml +2855 -163
  31. package/runtime/knowledge/hfs/slots.yaml +333 -62
  32. package/runtime/knowledge/patterns/fe/folder.yaml +1 -0
  33. package/runtime/knowledge/sonar-gate.yaml +1 -1
  34. package/runtime/modules/kernel/removed-vocabulary.yaml +30 -0
  35. package/runtime/modules/models/runtimes.yaml +5 -7
  36. package/runtime/packages/hfs/templates/app/package-scripts/package.json +1 -0
  37. package/runtime/scripts/api/git/lib.mjs +4 -1
  38. package/runtime/scripts/api/git/merge-base.mjs +9 -0
  39. package/runtime/scripts/api/git/recorded-lines.mjs +54 -8
  40. package/runtime/scripts/hfs/architecture/backend-app-files.mjs +1 -8
  41. package/runtime/scripts/hfs/architecture/backend.mjs +2 -7
  42. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +6 -3
  43. package/runtime/scripts/hfs/architecture/config.mjs +1 -1
  44. package/runtime/scripts/hfs/architecture/dead-exports-bindings.mjs +149 -0
  45. package/runtime/scripts/hfs/architecture/dead-exports.mjs +9 -41
  46. package/runtime/scripts/hfs/architecture/default-deny.mjs +8 -6
  47. package/runtime/scripts/hfs/architecture/frontend-world.mjs +1 -1
  48. package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +153 -0
  49. package/runtime/scripts/hfs/architecture/hfs.mjs +3 -3
  50. package/runtime/scripts/hfs/architecture/index.mjs +1 -1
  51. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +28 -13
  52. package/runtime/scripts/hfs/architecture/next-data-inference.mjs +176 -0
  53. package/runtime/scripts/hfs/architecture/next-data-symbols.mjs +26 -0
  54. package/runtime/scripts/hfs/architecture/next-data.mjs +31 -23
  55. package/runtime/scripts/hfs/architecture/reachability.mjs +14 -0
  56. package/runtime/scripts/hfs/architecture/register-once.mjs +2 -2
  57. package/runtime/scripts/hfs/architecture/source-names.mjs +48 -3
  58. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +211 -11
  59. package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -1
  60. package/runtime/scripts/hfs/architecture/supabase.mjs +59 -21
  61. package/runtime/scripts/hfs/architecture/surface.mjs +2 -1
  62. package/runtime/scripts/hfs/architecture/tiers.mjs +4 -4
  63. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +8 -2
  64. package/runtime/scripts/hfs/declaration-shape.mjs +39 -3
  65. package/runtime/scripts/hfs/edition-slots.mjs +2 -2
  66. package/runtime/scripts/hfs/manifest-shape.mjs +69 -9
  67. package/runtime/scripts/hfs/param-names.mjs +1 -1
  68. package/runtime/scripts/hfs/project-rule.mjs +3 -1
  69. package/runtime/scripts/hfs/rule-catalog.mjs +158 -11
  70. package/runtime/scripts/hfs/rule-params-shape.mjs +22 -10
  71. package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
  72. package/runtime/scripts/hfs/slot-imports.mjs +18 -5
  73. package/runtime/scripts/hfs/slots.mjs +3 -2
  74. package/runtime/scripts/hfs/typescript-programs.mjs +0 -8
  75. package/runtime/scripts/lib/env.mjs +20 -0
  76. package/runtime/scripts/lib/event-contract.mjs +107 -0
  77. package/runtime/scripts/lib/git.mjs +1 -1
  78. package/runtime/scripts/lib/i18n.mjs +0 -2
  79. package/runtime/scripts/lib/json-clone.mjs +61 -0
  80. package/runtime/scripts/lib/language.mjs +1 -1
  81. package/runtime/scripts/lib/list.mjs +4 -0
  82. package/runtime/scripts/lib/secret-patterns.mjs +60 -5
  83. package/runtime/scripts/lib/stack-services.mjs +1 -1
  84. package/runtime/scripts/lib/ts-analysis.mjs +5 -2
  85. package/runtime/scripts/lib/ts-ast.mjs +27 -2
  86. package/service-isolation.mjs +3 -0
  87. package/size-budget.mjs +9 -5
  88. package/third-party-owner.mjs +149 -0
  89. package/transport.mjs +11 -29
  90. package/unit-spec.mjs +270 -84
  91. 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
- /** The `PublicReason` members that justify a REST door: a probe, a handshake and a signed webhook. */
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: "A `@Controller` shows a public reason (Health, AuthHandshake, SignedWebhook) or a byte stream or redirect." },
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 door shows none of the reasons a door may not be GraphQL: a probe, an auth handshake (OAuth redirect) or a signed webhook (`@Public({ reason: PublicReason.X })`), or a byte stream. A plain JSON read belongs in the GraphQL schema, where the client already is.",
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 && !showsReason(context, node)) context.report({ node: controller, messageId: "unjustified" })
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
  },