@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.
- package/CHANGELOG.md +10 -0
- package/README.md +2 -2
- package/boundary-schema.mjs +32 -4
- package/idempotency.mjs +3 -0
- package/index.mjs +2 -0
- package/infra-owner.mjs +4 -3
- package/lib/client-idempotency.mjs +403 -0
- package/lib/persistence.mjs +1 -1
- package/lib/schema-values.mjs +90 -0
- package/lib/unit-spec.mjs +6 -0
- package/operations.mjs +197 -32
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +65 -9
- package/runtime/knowledge/hfs/fe-stack.yaml +23 -5
- package/runtime/knowledge/hfs/rules.yaml +168 -47
- package/runtime/knowledge/hfs/slots.yaml +110 -52
- package/runtime/knowledge/sonar-gate.yaml +1 -1
- package/runtime/modules/kernel/removed-vocabulary.yaml +62 -0
- package/runtime/modules/models/registry.yaml +3 -1
- package/runtime/modules/models/runtimes.yaml +2 -0
- package/runtime/scripts/api/git/ls-tree.mjs +5 -0
- package/runtime/scripts/api/git/show.mjs +6 -0
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +10 -10
- package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +1 -1
- package/runtime/scripts/hfs/architecture/operation-contract-roots.mjs +53 -0
- package/runtime/scripts/hfs/architecture/reachability.mjs +1 -1
- package/runtime/scripts/hfs/architecture/sql-owner.mjs +17 -6
- package/runtime/scripts/hfs/architecture/sql-token-forms.mjs +105 -0
- package/runtime/scripts/hfs/architecture/sql-tokens.mjs +38 -52
- package/runtime/scripts/hfs/architecture/supabase-keys.mjs +261 -0
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +49 -3
- package/runtime/scripts/hfs/declaration-shape.mjs +4 -4
- package/runtime/scripts/hfs/declaration-slots.mjs +12 -3
- package/runtime/scripts/hfs/manifest-shape.mjs +10 -6
- package/runtime/scripts/hfs/rule-params-shape.mjs +16 -6
- package/runtime/scripts/hfs/rules/database-constants.mjs +26 -0
- package/runtime/scripts/hfs/rules/database-migrations.mjs +489 -0
- package/runtime/scripts/hfs/slot-side-problems.mjs +2 -2
- package/runtime/scripts/hfs/slots.mjs +1 -0
- package/runtime/scripts/hfs/view.mjs +2 -1
- package/runtime/scripts/lib/in-order.mjs +71 -0
- package/runtime/scripts/lib/language.mjs +94 -1
- package/runtime/scripts/lib/package-at.mjs +62 -0
- package/runtime/scripts/lib/same-text.mjs +5 -0
- package/runtime/scripts/lib/ts-analysis.mjs +10 -0
- package/suppression.mjs +1 -1
- package/unit-spec.mjs +66 -6
- 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
|
-
|
|
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:
|
|
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
|
|
32
|
-
import {
|
|
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
|
-
|
|
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 =
|
|
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" }
|