@starci/eslint-canon-be 3.4.39 → 3.4.41

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 (48) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +2 -2
  3. package/boundary-schema.mjs +32 -4
  4. package/idempotency.mjs +3 -0
  5. package/index.mjs +2 -0
  6. package/infra-owner.mjs +4 -3
  7. package/lib/client-idempotency.mjs +403 -0
  8. package/lib/persistence.mjs +1 -1
  9. package/lib/schema-values.mjs +90 -0
  10. package/lib/unit-spec.mjs +6 -0
  11. package/operations.mjs +197 -32
  12. package/package.json +1 -1
  13. package/runtime/knowledge/hfs/canon-pins.yaml +65 -9
  14. package/runtime/knowledge/hfs/fe-stack.yaml +23 -5
  15. package/runtime/knowledge/hfs/rules.yaml +168 -47
  16. package/runtime/knowledge/hfs/slots.yaml +110 -52
  17. package/runtime/knowledge/sonar-gate.yaml +1 -1
  18. package/runtime/modules/kernel/removed-vocabulary.yaml +62 -0
  19. package/runtime/modules/models/registry.yaml +3 -1
  20. package/runtime/modules/models/runtimes.yaml +2 -0
  21. package/runtime/scripts/api/git/ls-tree.mjs +5 -0
  22. package/runtime/scripts/api/git/show.mjs +6 -0
  23. package/runtime/scripts/hfs/architecture/dead-exports.mjs +10 -10
  24. package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +1 -1
  25. package/runtime/scripts/hfs/architecture/operation-contract-roots.mjs +53 -0
  26. package/runtime/scripts/hfs/architecture/reachability.mjs +1 -1
  27. package/runtime/scripts/hfs/architecture/sql-owner.mjs +17 -6
  28. package/runtime/scripts/hfs/architecture/sql-token-forms.mjs +105 -0
  29. package/runtime/scripts/hfs/architecture/sql-tokens.mjs +38 -52
  30. package/runtime/scripts/hfs/architecture/supabase-keys.mjs +261 -0
  31. package/runtime/scripts/hfs/architecture/transport-owner.mjs +49 -3
  32. package/runtime/scripts/hfs/declaration-shape.mjs +4 -4
  33. package/runtime/scripts/hfs/declaration-slots.mjs +12 -3
  34. package/runtime/scripts/hfs/manifest-shape.mjs +10 -6
  35. package/runtime/scripts/hfs/rule-params-shape.mjs +16 -6
  36. package/runtime/scripts/hfs/rules/database-constants.mjs +26 -0
  37. package/runtime/scripts/hfs/rules/database-migrations.mjs +489 -0
  38. package/runtime/scripts/hfs/slot-side-problems.mjs +2 -2
  39. package/runtime/scripts/hfs/slots.mjs +1 -0
  40. package/runtime/scripts/hfs/view.mjs +2 -1
  41. package/runtime/scripts/lib/in-order.mjs +71 -0
  42. package/runtime/scripts/lib/language.mjs +94 -1
  43. package/runtime/scripts/lib/package-at.mjs +62 -0
  44. package/runtime/scripts/lib/same-text.mjs +5 -0
  45. package/runtime/scripts/lib/ts-analysis.mjs +10 -0
  46. package/suppression.mjs +1 -1
  47. package/unit-spec.mjs +66 -6
  48. package/uploads.mjs +112 -0
@@ -15,7 +15,7 @@ export const SECOND_LANGUAGE_LETTER = /[À-ÃÈ-ÊÌÍÒ-ÕÙÚÝà-ãè-êìí
15
15
  export const hasSecondLanguage = (text) => typeof text === 'string' && SECOND_LANGUAGE_LETTER.test(text.normalize('NFC'));
16
16
 
17
17
  /** Every line of `text` with a Vietnamese letter: `[{ line, column }]`, both 1-based. */
18
- export function secondLanguageHits(text) {
18
+ function secondLanguageHits(text) {
19
19
  const hits = [];
20
20
  String(text).split(/\r?\n/).forEach((raw, index) => {
21
21
  const match = SECOND_LANGUAGE_LETTER.exec(raw.normalize('NFC'));
@@ -24,6 +24,99 @@ export function secondLanguageHits(text) {
24
24
  return hits;
25
25
  }
26
26
 
27
+ /** The named bindings of an import from the backend message catalog contract. */
28
+ function messageCatalogBindings(ts, statement) {
29
+ if (!ts.isImportDeclaration(statement) || statement.moduleSpecifier.text !== '@modules/platform/i18n') return null;
30
+ return statement.importClause?.namedBindings ?? null;
31
+ }
32
+
33
+ /** The local type names imported from the backend message catalog contract. */
34
+ function messageBundleImports(ts, source) {
35
+ const named = new Set();
36
+ const namespaces = new Set();
37
+ for (const statement of source.statements) {
38
+ const bindings = messageCatalogBindings(ts, statement);
39
+ if (!bindings) continue;
40
+ if (ts.isNamespaceImport(bindings)) namespaces.add(bindings.name.text);
41
+ for (const entry of ts.isNamedImports(bindings) ? bindings.elements : []) {
42
+ if ((entry.propertyName ?? entry.name).text === 'MessageBundle') named.add(entry.name.text);
43
+ }
44
+ }
45
+ return { named, namespaces };
46
+ }
47
+
48
+ /** A declaration that introduces a same-named local type or namespace. */
49
+ function declaresTypeName(ts, statement, name) {
50
+ if (statement.name?.text !== name) return false;
51
+ return [ts.isTypeAliasDeclaration, ts.isInterfaceDeclaration, ts.isClassDeclaration, ts.isEnumDeclaration, ts.isModuleDeclaration]
52
+ .some(predicate => predicate(statement));
53
+ }
54
+
55
+ /** A type reference bound to the imported MessageBundle contract, including import aliases. */
56
+ function isMessageBundleType(ts, type, imports) {
57
+ if (!type || !ts.isTypeReferenceNode(type)) return false;
58
+ const name = type.typeName;
59
+ if (ts.isIdentifier(name)) return imports.named.has(name.text) && !shadowsImportedType(ts, type, name.text);
60
+ return ts.isQualifiedName(name) && ts.isIdentifier(name.left)
61
+ && imports.namespaces.has(name.left.text) && name.right.text === 'MessageBundle' && !shadowsImportedType(ts, type, name.left.text);
62
+ }
63
+
64
+ /** A local declaration or generic type parameter takes precedence over a same-named import. */
65
+ function shadowsImportedType(ts, node, name) {
66
+ for (let scope = node.parent; scope; scope = scope.parent) {
67
+ if (scope.typeParameters?.some(parameter => parameter.name.text === name)) return true;
68
+ if (scope.statements?.some(statement => declaresTypeName(ts, statement, name))) return true;
69
+ }
70
+ return false;
71
+ }
72
+
73
+ /** The literal object supplied by a variable typed with the imported MessageBundle contract. */
74
+ function messageCatalogObject(ts, node, imports) {
75
+ if (!ts.isVariableDeclaration(node) || !node.initializer) return null;
76
+ let value = node.initializer;
77
+ const type = ts.isSatisfiesExpression(value) ? value.type : node.type;
78
+ if (ts.isSatisfiesExpression(value)) value = value.expression;
79
+ return isMessageBundleType(ts, type, imports) && ts.isObjectLiteralExpression(value) ? value : null;
80
+ }
81
+
82
+ /** Literal string values in the Vietnamese locale of one message catalog. */
83
+ function vietnameseValueRanges(ts, catalog, source) {
84
+ const ranges = [];
85
+ for (const locale of catalog.properties) {
86
+ if (!ts.isPropertyAssignment(locale) || locale.name.text !== 'vi' || !ts.isObjectLiteralExpression(locale.initializer)) continue;
87
+ for (const message of locale.initializer.properties) {
88
+ if (ts.isPropertyAssignment(message) && ts.isStringLiteralLike(message.initializer))
89
+ ranges.push({ start: message.initializer.getStart(source), end: message.initializer.end });
90
+ }
91
+ }
92
+ return ranges;
93
+ }
94
+
95
+ /** Literal Vietnamese message values of typed catalogs; keys, comments, English values and expressions remain source. */
96
+ function messageBundleValueRanges(ts, source) {
97
+ const imports = messageBundleImports(ts, source);
98
+ const ranges = [];
99
+ const visit = (node) => {
100
+ const catalog = messageCatalogObject(ts, node, imports);
101
+ if (catalog) ranges.push(...vietnameseValueRanges(ts, catalog, source));
102
+ ts.forEachChild(node, visit);
103
+ };
104
+ visit(source);
105
+ return ranges;
106
+ }
107
+
108
+ /** Source-language hits, treating only literal vi values of typed backend MessageBundle objects as translated product data. */
109
+ export function sourceLanguageHits(rel, text, ts) {
110
+ if (!hasSecondLanguage(text)) return [];
111
+ if (!ts || !/\.[cm]?tsx?$/.test(rel)) return secondLanguageHits(text);
112
+ const source = ts.createSourceFile(rel, text, ts.ScriptTarget.Latest, true);
113
+ let sourceText = text;
114
+ for (const range of messageBundleValueRanges(ts, source).sort((left, right) => right.start - left.start)) {
115
+ sourceText = sourceText.slice(0, range.start) + sourceText.slice(range.start, range.end).replace(/[^\r\n]/g, ' ') + sourceText.slice(range.end);
116
+ }
117
+ return secondLanguageHits(sourceText);
118
+ }
119
+
27
120
  /**
28
121
  * The Vietnamese fields of one failure-code entry: the operator text the owner mandated for the failure-code catalog
29
122
  * (scripts/checks/check-failure-codes.mjs types the entry with exactly these). causes_vi is the list companion of the three scalar fields.
@@ -0,0 +1,62 @@
1
+ // scripts/lib/package-at.mjs — a package the runtime does not ship (playwright, esbuild, tailwindcss) comes from the
2
+ // project that owns it: node resolution from that project's directory, walking up its node_modules.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { createRequire } from 'node:module';
6
+ import { skillRoot } from '../../engine/runtime-root.mjs';
7
+
8
+ /** The package.json of `name` as resolved from `dir`, or null. */
9
+ export const packageAt = (dir, name) => {
10
+ try { return createRequire(path.join(dir, 'package.json')).resolve(`${name}/package.json`); } catch { return null; }
11
+ };
12
+
13
+ /** This runtime's own `starci` bin (packages/cli is the only package that declares one). */
14
+ const RUNTIME_STARCI_BIN = path.join(skillRoot, 'packages', 'cli', 'bin', 'starci.mjs');
15
+
16
+ /** The `starci` bin of the @starci/cli that `dir` installs (a product runs its own pinned CLI), else this runtime's own. */
17
+ function starciBin(dir) {
18
+ const packageFile = packageAt(dir, '@starci/cli');
19
+ if (packageFile) {
20
+ try {
21
+ const { bin } = JSON.parse(fs.readFileSync(packageFile, 'utf8'));
22
+ const rel = typeof bin === 'string' ? bin : bin?.starci;
23
+ const file = rel ? path.join(path.dirname(packageFile), rel) : null;
24
+ if (file && fs.existsSync(file)) return file;
25
+ } catch { /* an unreadable install falls back to the runtime's own CLI */ }
26
+ }
27
+ return RUNTIME_STARCI_BIN;
28
+ }
29
+
30
+ /** The first of `names` resolvable from the first of `dirs` that has one: {name, root, packageFile, version} or null. */
31
+ export function findPackage(dirs, names) {
32
+ for (const dir of dirs.filter(Boolean)) {
33
+ for (const name of names) {
34
+ const packageFile = packageAt(dir, name);
35
+ if (!packageFile) continue;
36
+ const require = createRequire(packageFile);
37
+ return { name, root: path.dirname(packageFile), packageFile, version: require(packageFile).version };
38
+ }
39
+ }
40
+ return null;
41
+ }
42
+
43
+ /** The CommonJS entry of a package found by findPackage. */
44
+ export const requirePackage = (found) => createRequire(found.packageFile)(found.name);
45
+
46
+ /** The TypeScript compiler resolvable from the first of `dirs` (an app root, then the caller's own), or null. */
47
+ export const loadTypescript = (...dirs) => {
48
+ const located = findPackage(dirs, ['typescript']);
49
+ return located ? requirePackage(located) : null;
50
+ };
51
+
52
+ /**
53
+ * The app's command line and the app implementation behind it: `bin` the `starci` bin of the app's installed @starci/cli, else
54
+ * this runtime's packages/cli/bin/starci.mjs (starciBin); `dir` the @starci/hfs that answers
55
+ * `starci app` - the app's own install, else the one beside its @starci/cli, else this runtime's packages/hfs - whose runtime
56
+ * slice the gate's base lint (scripts/gates/gate.mjs) and the READ digest (scripts/gates/read-digest.mjs) load in-process.
57
+ */
58
+ export function hfsEntry(root) {
59
+ const cli = packageAt(root, '@starci/cli');
60
+ const manifest = packageAt(root, '@starci/hfs') ?? (cli && packageAt(path.dirname(cli), '@starci/hfs'));
61
+ return { dir: manifest ? path.dirname(manifest) : path.join(skillRoot, 'packages', 'hfs'), bin: starciBin(root) };
62
+ }
@@ -0,0 +1,5 @@
1
+ // same-text.mjs - text equality that ignores the line-ending form and a trailing blank tail (a git runner may trim stdout, a work tree may
2
+ // carry CRLF): the one comparison the database rules use for a committed file against its base or against a regenerated one.
3
+
4
+ /** True when both texts exist and are equal once CRLF is folded to LF and trailing whitespace at the end is dropped. */
5
+ export const sameText = (a, b) => a !== null && b !== null && a.replaceAll('\r\n', '\n').trimEnd() === b.replaceAll('\r\n', '\n').trimEnd();
@@ -25,3 +25,13 @@ export function importsOf(ts, sourceFile, { moduleKey = 'module', stringLiterals
25
25
 
26
26
  /** Catalog values unwrap parentheses, `as`, and `satisfies`, preserving assertions and non-null nodes. */
27
27
  export const unwrapCatalogExpression = (ts, node) => unwrapExpression(ts, node, { typeAssertions: false, nonNullExpressions: false });
28
+
29
+ /** Bind this file in isolation: the compiler resolves lexical names, but imports and library files are never loaded. */
30
+ export function lexicalChecker(ts, source) {
31
+ const options = { noLib: true, noResolve: true, allowJs: true };
32
+ const host = ts.createCompilerHost(options);
33
+ host.getSourceFile = (file) => file === source.fileName ? source : undefined;
34
+ host.fileExists = () => false;
35
+ host.readFile = () => undefined;
36
+ return ts.createProgram([source.fileName], options, host).getTypeChecker();
37
+ }
package/suppression.mjs CHANGED
@@ -8,7 +8,7 @@
8
8
  * `eslint-env`, and a `/* global x *\/` declaration;
9
9
  * - TypeScript: `@ts-ignore`, `@ts-expect-error` and `@ts-nocheck`, wherever they sit in a comment;
10
10
  * - coverage: `istanbul ignore`, `c8 ignore`, `v8 ignore`;
11
- * - Sonar: `NOSONAR` and `sonar-disable`;
11
+ * - Sonar: its no-sonar comment and `sonar-disable`;
12
12
  * - Prettier: `prettier-ignore`.
13
13
  *
14
14
  * The factory also sets `noInlineConfig: true`, so ESLint itself ignores such a comment; this rule is what turns the
package/unit-spec.mjs CHANGED
@@ -5,7 +5,9 @@
5
5
  * and takes every double from the one shared kit, `@starci/jest-preset`:
6
6
  *
7
7
  * - `spec-builds-with-testing-module`: the spec calls `Test.createTestingModule(...)` from `@nestjs/testing`, awaits
8
- * `.compile()` and reads the subject with `.get(...)` on the resulting `TestingModule`.
8
+ * `.compile()` and reads the subject with `.get(...)` on the resulting `TestingModule`. A spec of a value role
9
+ * (`ruleParams.be.valueRoles`) is exempt only while its sibling is a real boundary schema (an owner tier, zod values and types
10
+ * only, `schema-file-values-only`): its subject is a declared value, parsed directly.
9
11
  * - `spec-no-new-subject`: the subject (the class of the sibling `<name>.<role>.ts`) is never built with `new`.
10
12
  * - `spec-module-definition-only-providers`: the module definition has a `providers` list and nothing else: no `imports` key
11
13
  * and no `override*` call (`overrideProvider`, `overrideGuard`, `overrideModule`, `overrideFilter`, `overridePipe`,
@@ -28,9 +30,13 @@
28
30
  import { hfsOf } from "./lib/hfs.mjs"
29
31
  import { keyName, walk } from "./lib/ast.mjs"
30
32
  import { importOf, isImportedFrom } from "./lib/import-source.mjs"
31
- import { isPackageType, typeOrigins } from "./lib/types.mjs"
32
- import { baseOf, isServiceSpecFile, isUnitSpecFile, unitRoleOfSpec } from "./lib/unit-spec.mjs"
33
+ import ts from "typescript"
34
+ import { normalizePath } from "./lib/path.mjs"
35
+ import { isPackageType, typeOrigins, typed } from "./lib/types.mjs"
36
+ import { baseOf, isServiceSpecFile, isUnitSpecFile, isValueRoleSpec, unitRoleOfSpec } from "./lib/unit-spec.mjs"
33
37
  import { isRealSubjectValue, valueObjectsOfSibling } from "./lib/subject-values.mjs"
38
+ import { ownerNameOf } from "./lib/ports.mjs"
39
+ import { siblingIsValueSchema } from "./lib/schema-values.mjs"
34
40
 
35
41
  const TESTING_PACKAGE = "@nestjs/testing"
36
42
 
@@ -77,7 +83,12 @@ export const specBuildsWithTestingModule = {
77
83
  },
78
84
  },
79
85
  create(context) {
80
- if (!isFormSpecFile(hfsOf(context), context.filename || context.getFilename())) return {}
86
+ const filename = context.filename || context.getFilename()
87
+ // a value role (a zod schema) declares a value Nest never constructs: its spec parses it and builds no module, but only when the
88
+ // sibling really is one (a boundary schema of an owner tier holding zod values and types, nothing else); any other file keeps the rule
89
+ const hfs = hfsOf(context)
90
+ if (!isFormSpecFile(hfs, filename)) return {}
91
+ if (isValueRoleSpec(hfs, filename) && siblingIsValueSchema(hfs, filename)) return {}
81
92
  // a value-object subject (undecorated, no constructor injection token) is built with `new`: Nest never constructs it
82
93
  const valueObjectSpec = valueObjectsOfSibling(context).length > 0
83
94
  let created = null
@@ -385,12 +396,36 @@ const COMPILED = new WeakMap()
385
396
  const tableOf = (specDoubles) => {
386
397
  let compiled = COMPILED.get(specDoubles)
387
398
  if (!compiled) {
388
- compiled = { kit: specDoubles.kit, entries: specDoubles.doubles.map((entry) => ({ ...entry, pattern: new RegExp(entry.token) })), fallback: specDoubles.fallback }
399
+ compiled = { kit: specDoubles.kit, entries: specDoubles.doubles.map((entry) => ({ ...entry, pattern: new RegExp(entry.token), from: entry.from === undefined ? undefined : new RegExp(entry.from) })), fallback: specDoubles.fallback }
389
400
  COMPILED.set(specDoubles, compiled)
390
401
  }
391
402
  return compiled
392
403
  }
393
404
 
405
+ /** The file and exported name a token identifier is declared in, after following every import and re-export, or null when it cannot be resolved. */
406
+ const declarationOf = (context, token) => {
407
+ const { checker, toTs } = typed(context)
408
+ const symbol = checker.getSymbolAtLocation(toTs(token))
409
+ const target = symbol?.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol
410
+ const declaration = target?.getDeclarations?.()?.[0]
411
+ return declaration === undefined ? null : { file: normalizePath(declaration.getSourceFile().fileName), name: target.getName() }
412
+ }
413
+
414
+ /**
415
+ * Whether a table entry applies to a provider token. The spelling picks the entry; when the entry names where its port is declared
416
+ * (`from`, a pattern over the declaring file), the declaration decides, never the import written in the spec: a token declared in
417
+ * another module that merely ends alike (`CORRELATION_IDS` of logging against `IDS` of ids) is a different port, a token the
418
+ * owner declares stays that port however it is re-exported or renamed (`IDS as SYSTEM_SOURCE`, a fixture re-export). A token
419
+ * that cannot be resolved, or is declared in the spec itself, is judged by its spelling alone.
420
+ */
421
+ const entryApplies = (context, entry, spelled, token) => {
422
+ if (entry.from === undefined || token?.type !== "Identifier") return entry.pattern.test(spelled)
423
+ const declared = declarationOf(context, token)
424
+ if (declared === null || declared.file === normalizePath(context.filename || context.getFilename())) return entry.pattern.test(spelled)
425
+ if (!entry.from.test(declared.file)) return !declared.file.includes("/src/modules/") && entry.pattern.test(spelled)
426
+ return entry.pattern.test(spelled) || entry.pattern.test(screamingSnake(declared.name))
427
+ }
428
+
394
429
  /** A const identifier's initializer, or null when its value cannot be followed. */
395
430
  const constantInitializer = (context, value) => {
396
431
  for (let scope = context.sourceCode.getScope(value); scope; scope = scope.upper) {
@@ -403,6 +438,31 @@ const constantInitializer = (context, value) => {
403
438
  return null
404
439
  }
405
440
 
441
+ /** An imported token retains its declared identity through const aliases and assertion wrappers. */
442
+ const originsOfToken = (context, token) => {
443
+ let value = peel(token)
444
+ const seen = new Set()
445
+ while (value?.type === "Identifier" && !seen.has(value)) {
446
+ seen.add(value)
447
+ const initial = peel(constantInitializer(context, value))
448
+ if (!initial || !["Identifier", "MemberExpression"].includes(initial.type)) break
449
+ value = initial
450
+ }
451
+ return typeOrigins(context, value)
452
+ }
453
+
454
+ /** Inbox is the platform port; a service or producer whose name contains Inbox remains an ordinary collaborator. */
455
+ const providerDoubleEntry = (context, hfs, table, token, normalised) => {
456
+ const named = (name, from) => table.entries.find((entry) => entryApplies(context, entry, screamingSnake(name), from)) ?? table.fallback
457
+ const lexical = named(normalised, token)
458
+ const inbox = named("INBOX")
459
+ const origins = originsOfToken(context, token)
460
+ const platform = origins.filter((origin) => origin.module === null && hfs.tierOf(origin.file) === "platform")
461
+ if (platform.some((origin) => origin.name === "INBOX" && ownerNameOf(hfs, origin.file) === "inbox")) return inbox
462
+ if (lexical !== inbox || origins.length === 0) return lexical
463
+ return platform.map((origin) => named(origin.name)).find((entry) => entry !== inbox) ?? table.fallback
464
+ }
465
+
406
466
  /** A value's form and the double it names: `{ form, name }`, or null when it is not a form the table knows. */
407
467
  const formOf = (context, node, depth = 0) => {
408
468
  const value = peel(node)
@@ -489,7 +549,7 @@ export const specInfraDoubleFromKit = {
489
549
  const spelled = tokenName(provide?.value)
490
550
  if (spelled === null) return
491
551
  const normalised = screamingSnake(spelled)
492
- const entry = table.entries.find((candidate) => candidate.pattern.test(normalised)) ?? table.fallback
552
+ const entry = providerDoubleEntry(context, hfs, table, provide.value, normalised)
493
553
  const found = formOf(context, node.value)
494
554
  const want = describe(entry)
495
555
  const data = { token: spelled, want, kit: table.kit }
package/uploads.mjs ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * The mechanically recognisable buffering surfaces of BE-TRANSPORT-1. Browser uploads use storage grants; the API
3
+ * exchanges metadata. An actual byte-stream route remains a separate transport case, so `StreamableFile` stays legal.
4
+ * This rule does not prove a storage policy's principal, key, expiry or byte bound; the integration's behavior specs do.
5
+ */
6
+ import { keyName, staticText } from "./lib/ast.mjs"
7
+ import { importOf, moduleReferences } from "./lib/import-source.mjs"
8
+ import { resolveVariable } from "./lib/transactions.mjs"
9
+
10
+ /** Libraries that implement API-side multipart uploads instead of a storage grant. */
11
+ const UPLOAD_PACKAGES = [
12
+ "multer",
13
+ "express-fileupload",
14
+ "graphql-upload",
15
+ "graphql-upload-minimal",
16
+ "@fastify/multipart",
17
+ "fastify-multipart",
18
+ "busboy",
19
+ "formidable",
20
+ "multiparty",
21
+ "connect-multiparty",
22
+ ]
23
+
24
+ /** Selected Nest exports consume multipart files; other exports of these packages remain usable. */
25
+ const NEST_UPLOADS = {
26
+ "@nestjs/common": ["UploadedFile", "UploadedFiles"],
27
+ "@nestjs/platform-express": ["MulterModule", "FileInterceptor", "FilesInterceptor", "AnyFilesInterceptor", "FileFieldsInterceptor"],
28
+ }
29
+
30
+ /** The statement block in which this expression is evaluated. */
31
+ const statementBlock = (expression) => {
32
+ let statement = expression
33
+ while (statement.parent && !["Program", "BlockStatement"].includes(statement.parent.type)) statement = statement.parent
34
+ return statement.parent
35
+ }
36
+
37
+ /**
38
+ * The namespace source at one read, following completed standalone writes in that statement block.
39
+ * Branches, destructuring assignments and closure execution require dataflow beyond this rule's supported alias shape.
40
+ */
41
+ const namespaceSource = (context, expression, seen = new Set()) => {
42
+ if (!expression || seen.has(expression)) return null
43
+ seen.add(expression)
44
+ if (expression?.type === "Identifier") {
45
+ const found = importOf(context, expression)
46
+ if (found?.imported === "*") return found.source
47
+ const variable = resolveVariable(context.sourceCode, expression)
48
+ if (!variable) return null
49
+ const definition = variable.defs[0]
50
+ if (definition?.type !== "Variable" || definition.node.type !== "VariableDeclarator" || definition.node.id.type !== "Identifier") return null
51
+ const writes = variable.references.filter((reference) => reference.isWrite() && !reference.init
52
+ && reference.writeExpr?.range[1] <= expression.range[0])
53
+ .sort((left, right) => right.identifier.range[0] - left.identifier.range[0])
54
+ const write = writes[0]
55
+ if (!write) return namespaceSource(context, definition.node.init, seen)
56
+ const assignment = write.identifier.parent
57
+ if (assignment.type !== "AssignmentExpression" || assignment.operator !== "=" || assignment.left !== write.identifier
58
+ || assignment.parent.type !== "ExpressionStatement" || assignment.parent.parent !== statementBlock(expression)) return null
59
+ return namespaceSource(context, write.writeExpr, seen)
60
+ }
61
+ if (expression?.type === "AwaitExpression") return namespaceSource(context, expression.argument, seen)
62
+ const source = expression?.type === "ImportExpression" ? expression.source
63
+ : expression?.type === "CallExpression" && expression.callee.type === "Identifier" && expression.callee.name === "require" ? expression.arguments[0] : null
64
+ return staticText(source)
65
+ }
66
+
67
+ /** Multipart buffers are never the API's upload contract. */
68
+ export const noBufferedUpload = {
69
+ meta: {
70
+ type: "problem",
71
+ docs: { description: "Browser file uploads use presigned storage grants, without Nest/Multer or GraphQL multipart buffering in the API." },
72
+ schema: [],
73
+ messages: {
74
+ buffered: "[BE_UPLOAD_BUFFERED] `{{name}}` accepts multipart files in the API. Return an authenticated, short-lived presigned PUT scoped to the principal and exact object key, signed for the exact content type and length, verify the stored object with a head call after the upload, and send the file directly to storage. An actual streaming REST route needs its own BE-TRANSPORT-1 evidence.",
75
+ },
76
+ },
77
+ create(context) {
78
+ const report = (node, name) => context.report({ node, messageId: "buffered", data: { name } })
79
+ const selected = (source, name) => typeof source === "string" && Object.entries(NEST_UPLOADS)
80
+ .some(([pkg, names]) => (source === pkg || source.startsWith(`${pkg}/`)) && names.includes(name))
81
+ return {
82
+ ...moduleReferences(({ source, value, names }) => {
83
+ if (UPLOAD_PACKAGES.some((pkg) => value === pkg || value.startsWith(`${pkg}/`))) {
84
+ report(source, value)
85
+ return
86
+ }
87
+ const forbidden = names.filter((name) => selected(value, name))
88
+ if (forbidden.length > 0) report(source, forbidden.join(", "))
89
+ }),
90
+ MemberExpression(node) {
91
+ const source = namespaceSource(context, node.object)
92
+ const name = node.computed ? staticText(node.property) : keyName(node.property)
93
+ if (selected(source, name)) report(node, name)
94
+ },
95
+ VariableDeclarator(node) {
96
+ if (node.id.type !== "ObjectPattern") return
97
+ const source = namespaceSource(context, node.init)
98
+ for (const property of node.id.properties) {
99
+ if (property.type !== "Property") continue
100
+ const name = property.computed ? staticText(property.key) : keyName(property.key)
101
+ if (selected(source, name)) report(property, name)
102
+ }
103
+ },
104
+ }
105
+ },
106
+ }
107
+
108
+ /** The transport buffering rule's published name. */
109
+ export const rules = { "no-buffered-upload": noBufferedUpload }
110
+
111
+ /** Upload buffering is an error in every backend. */
112
+ export const recommended = { "starci-be/no-buffered-upload": "error" }