@starci/eslint-canon-be 3.3.0 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +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/knowledge/hfs/canon-pins.yaml +11 -11
- package/runtime/knowledge/hfs/fe-stack.yaml +22 -1
- package/runtime/knowledge/hfs/rules.yaml +3401 -152
- package/runtime/knowledge/hfs/slots.yaml +344 -62
- package/runtime/knowledge/patterns/fe/folder.yaml +1 -0
- package/runtime/knowledge/sonar-gate.yaml +1 -1
- package/runtime/modules/models/runtimes.yaml +1 -1
- package/runtime/packages/hfs/templates/app/package-scripts/package.json +1 -0
- package/runtime/scripts/api/git/lib.mjs +3 -0
- 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/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/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 +3 -3
- 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 +67 -7
- package/runtime/scripts/hfs/project-rule.mjs +3 -1
- package/runtime/scripts/hfs/rule-catalog.mjs +156 -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 +1 -0
- package/runtime/scripts/lib/env.mjs +20 -0
- package/runtime/scripts/lib/event-contract.mjs +107 -0
- package/runtime/scripts/lib/json-clone.mjs +61 -0
- 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,20 @@
|
|
|
1
|
+
// env.mjs - the one place the runtime reads a named environment variable (smell S8-01/S8-02/S11-04; scripts/checks/check-env.mjs).
|
|
2
|
+
// modules/schemas/env.yaml catalogues every variable with its purpose and kind. Production code reads a variable here
|
|
3
|
+
// (readEnv) or takes an injected `env` parameter; it never reads `process.env.NAME` itself. The test runner is observed
|
|
4
|
+
// in exactly one place, isSpecRun: code that must not touch the host's real state during a spec asks it.
|
|
5
|
+
|
|
6
|
+
/** The value of the environment variable `name` in `env` (default: the process environment), or undefined. */
|
|
7
|
+
export const readEnv = (name, env = process.env) => env[name];
|
|
8
|
+
|
|
9
|
+
/** True when `env` (default: the process environment) belongs to a spec run (`node --test` marks its children with NODE_TEST_CONTEXT). */
|
|
10
|
+
export const isSpecRun = (env = process.env) => Boolean(env.NODE_TEST_CONTEXT);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The environment for a child that is itself a test runner: `env` without the mark of an enclosing spec run. A `node --test` that inherits NODE_TEST_CONTEXT believes it is a
|
|
14
|
+
* subtest of that run: it reports to its parent and exits 0 whatever its tests did, so a verb that runs specs as a child (starci test affected, the land gate, the release cut, the
|
|
15
|
+
* suite) would pass red specs from inside a test process. The runner marks the spec files it starts itself, so isSpecRun stays true inside them.
|
|
16
|
+
*/
|
|
17
|
+
export function withoutTestRunner(env = process.env) {
|
|
18
|
+
const { NODE_TEST_CONTEXT: _enclosing, ...rest } = env;
|
|
19
|
+
return rest;
|
|
20
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// event-contract.mjs - the async contract between the services of one product (R166 HFS_EVENT_CONTRACT).
|
|
2
|
+
// The one source of the contract is the typed event classes of a service, `be/src/modules/events/<service>/<event>.event.ts`:
|
|
3
|
+
// export interface OrderPlacedPayload { readonly orderId: string; readonly totalMinorUnits: number }
|
|
4
|
+
// export class OrderPlacedEvent extends BaseEvent {
|
|
5
|
+
// static readonly eventName = "order.placed"; static readonly version = 1
|
|
6
|
+
// static readonly compensates = "order.placed" // only an event that reports the failure of a step
|
|
7
|
+
// static create(payload: OrderPlacedPayload): OrderPlacedEvent { ... }
|
|
8
|
+
// }
|
|
9
|
+
// `starci app emit` writes `be/contracts/<service>/events.json` from them (name, version, compensates, and the payload fields of the
|
|
10
|
+
// interface `create` takes); `starci app check` compares the committed file with the same reader. Nothing is executed: both walk the TypeScript
|
|
11
|
+
// syntax tree. The functions take the `ts` module the caller loaded (the repository's own, else the runtime's); this file imports only the code-unit comparator.
|
|
12
|
+
import { byCodeUnit } from './list.mjs';
|
|
13
|
+
|
|
14
|
+
const EVENT_CONTRACT_SCHEMA = 'starci/event-contract@1';
|
|
15
|
+
const FIELD_TYPE = /^(?:string|number|boolean|string\[\]|number\[\])\??$/;
|
|
16
|
+
|
|
17
|
+
const hasStatic = (ts, member) => member.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.StaticKeyword) === true;
|
|
18
|
+
|
|
19
|
+
/** The literal of a `static readonly <name> = <literal>` member: `{ text }` for a string, `{ number }` for an integer, or null. */
|
|
20
|
+
function staticLiteral(ts, member) {
|
|
21
|
+
if (!ts.isPropertyDeclaration(member) || !hasStatic(ts, member) || !ts.isIdentifier(member.name) || member.initializer === undefined) return null;
|
|
22
|
+
let value = member.initializer;
|
|
23
|
+
while (ts.isAsExpression(value) || ts.isParenthesizedExpression(value)) value = value.expression;
|
|
24
|
+
if (ts.isStringLiteralLike(value)) return { name: member.name.text, text: value.text };
|
|
25
|
+
if (ts.isNumericLiteral(value) && Number.isInteger(Number(value.text))) return { name: member.name.text, number: Number(value.text) };
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The text of a type node when it is one of the wire field types (`string`, `number`, `boolean`, `string[]`, `number[]`), else null. */
|
|
30
|
+
function fieldType(ts, node) {
|
|
31
|
+
if (node.kind === ts.SyntaxKind.StringKeyword) return 'string';
|
|
32
|
+
if (node.kind === ts.SyntaxKind.NumberKeyword) return 'number';
|
|
33
|
+
if (node.kind === ts.SyntaxKind.BooleanKeyword) return 'boolean';
|
|
34
|
+
if (ts.isArrayTypeNode(node)) {
|
|
35
|
+
const element = fieldType(ts, node.elementType);
|
|
36
|
+
return element === 'string' || element === 'number' ? `${element}[]` : null;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function appendPayloadMember(ts, member, typeName, problems, where, payload) {
|
|
42
|
+
if (!ts.isPropertySignature(member) || !ts.isIdentifier(member.name) || member.type === undefined) {
|
|
43
|
+
problems.push(`${where}: the payload ${typeName} holds a member that is not \`name: type\``);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const type = fieldType(ts, member.type);
|
|
47
|
+
let written = null;
|
|
48
|
+
if (type !== null) written = `${type}${member.questionToken ? '?' : ''}`;
|
|
49
|
+
if (written === null || !FIELD_TYPE.test(written)) problems.push(`${where}: payload field ${member.name.text} must be string, number, boolean, string[] or number[]`);
|
|
50
|
+
else payload[member.name.text] = written;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The payload fields of the interface or type literal named `typeName` in the file: `{ <field>: <type> }`, optional fields with a `?`. */
|
|
54
|
+
function payloadOf(ts, sourceFile, typeName, problems, where) {
|
|
55
|
+
let members = null;
|
|
56
|
+
for (const statement of sourceFile.statements) {
|
|
57
|
+
if (ts.isInterfaceDeclaration(statement) && statement.name.text === typeName) members = statement.members;
|
|
58
|
+
if (ts.isTypeAliasDeclaration(statement) && statement.name.text === typeName && ts.isTypeLiteralNode(statement.type)) members = statement.type.members;
|
|
59
|
+
}
|
|
60
|
+
if (members === null) {
|
|
61
|
+
problems.push(`${where}: the payload type ${typeName} is not an interface or type literal of the file`);
|
|
62
|
+
return {};
|
|
63
|
+
}
|
|
64
|
+
const payload = {};
|
|
65
|
+
for (const member of members) appendPayloadMember(ts, member, typeName, problems, where, payload);
|
|
66
|
+
return payload;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The event classes of one source text: `{ events: [{ className, name, version, compensates, payload }], problems: [text] }`. A class is an event
|
|
71
|
+
* when it declares `static readonly eventName`; `version` is required, `create(payload: <Interface>)` names the payload.
|
|
72
|
+
*/
|
|
73
|
+
export function readEventClasses(ts, text, file = 'event.ts') {
|
|
74
|
+
const sourceFile = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
75
|
+
const events = [];
|
|
76
|
+
const problems = [];
|
|
77
|
+
for (const statement of sourceFile.statements) {
|
|
78
|
+
if (!ts.isClassDeclaration(statement) || statement.name === undefined) continue;
|
|
79
|
+
const literals = statement.members.map((member) => staticLiteral(ts, member)).filter((literal) => literal !== null);
|
|
80
|
+
const name = literals.find((literal) => literal.name === 'eventName')?.text;
|
|
81
|
+
if (name === undefined) continue;
|
|
82
|
+
const className = statement.name.text;
|
|
83
|
+
const version = literals.find((literal) => literal.name === 'version')?.number;
|
|
84
|
+
const compensates = literals.find((literal) => literal.name === 'compensates')?.text;
|
|
85
|
+
if (version === undefined || version < 1) problems.push(`${className} needs \`static readonly version\` as a positive integer`);
|
|
86
|
+
const create = statement.members.find((member) => ts.isMethodDeclaration(member) && hasStatic(ts, member) && ts.isIdentifier(member.name) && member.name.text === 'create');
|
|
87
|
+
const parameter = create?.parameters[0]?.type;
|
|
88
|
+
if (parameter === undefined || !ts.isTypeReferenceNode(parameter) || !ts.isIdentifier(parameter.typeName)) {
|
|
89
|
+
problems.push(`${className} needs \`static create(payload: <Interface>)\` naming its payload interface`);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
events.push({ className, name, version: version ?? 0, ...(compensates === undefined ? {} : { compensates }), payload: payloadOf(ts, sourceFile, parameter.typeName.text, problems, className) });
|
|
93
|
+
}
|
|
94
|
+
return { events, problems };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The sorted object: keys in code-point order, recursively (what the snapshot prints). */
|
|
98
|
+
const sorted = (value) => (value !== null && typeof value === 'object' && !Array.isArray(value) ? Object.fromEntries(Object.keys(value).sort(byCodeUnit).map((key) => [key, sorted(value[key])])) : value);
|
|
99
|
+
|
|
100
|
+
/** The text of the snapshot of a service from its event classes: 2-space JSON, sorted keys, one final newline. */
|
|
101
|
+
export function snapshotText(service, events) {
|
|
102
|
+
const table = Object.fromEntries(events.map(({ name, version, compensates, payload }) => [name, { version, payload, ...(compensates === undefined ? {} : { compensates }) }]));
|
|
103
|
+
return `${JSON.stringify(sorted({ schema: EVENT_CONTRACT_SCHEMA, service, events: table }), null, 2)}\n`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The snapshot's text with line endings folded, for comparing a committed file to a fresh one. */
|
|
107
|
+
export const folded = (text) => text.replaceAll('\r\n', '\n');
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// json-clone.mjs — a deep copy that keeps exactly what survives a JSON text and nothing else: the plain-data
|
|
2
|
+
// copy of a value, detached from every reference, frozen state and class of the original. `structuredClone` keeps
|
|
3
|
+
// what JSON drops (undefined members, Dates, Maps, sparse holes) and refuses what JSON accepts (functions,
|
|
4
|
+
// `toJSON` objects), so it is not a substitute; this walks the value once under the JSON.stringify rules.
|
|
5
|
+
|
|
6
|
+
/** The boxed primitives JSON.stringify unwraps: recognised by the internal slot a valueOf call reads, as JSON does. */
|
|
7
|
+
const BOXED = [
|
|
8
|
+
{ slot: Number.prototype.valueOf, read: Number },
|
|
9
|
+
{ slot: String.prototype.valueOf, read: String },
|
|
10
|
+
{ slot: Boolean.prototype.valueOf, read: (box) => Boolean.prototype.valueOf.call(box) },
|
|
11
|
+
{ slot: BigInt.prototype.valueOf, read: (box) => BigInt.prototype.valueOf.call(box) },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
/** Whether `value` carries the internal slot `slot` reads (a valueOf of that boxed type does not throw on it). */
|
|
15
|
+
const hasSlot = (slot, value) => {
|
|
16
|
+
try { slot.call(value); return true; } catch { return false; }
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** What JSON.stringify takes of `holder[key]`: the toJSON result when there is one, a boxed primitive unwrapped. */
|
|
20
|
+
const serialized = (holder, key) => {
|
|
21
|
+
let value = holder[key];
|
|
22
|
+
if (value !== null && (typeof value === 'object' || typeof value === 'bigint') && typeof value.toJSON === 'function') value = value.toJSON(key);
|
|
23
|
+
if (value === null || typeof value !== 'object') return value;
|
|
24
|
+
const box = BOXED.find((candidate) => hasSlot(candidate.slot, value));
|
|
25
|
+
return box ? box.read(value) : value;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** A member JSON leaves out of an object (and writes as null in an array). */
|
|
29
|
+
const isAbsent = (value) => value === undefined || typeof value === 'function' || typeof value === 'symbol';
|
|
30
|
+
|
|
31
|
+
const copyValue = (value, ancestors) => {
|
|
32
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
|
|
33
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value + 0 : null;
|
|
34
|
+
if (typeof value === 'bigint') throw new TypeError('Do not know how to serialize a BigInt');
|
|
35
|
+
if (ancestors.includes(value)) throw new TypeError('Converting circular structure to JSON');
|
|
36
|
+
const inside = [...ancestors, value];
|
|
37
|
+
if (Array.isArray(value)) {
|
|
38
|
+
return Array.from({ length: value.length }, (_, index) => {
|
|
39
|
+
const item = serialized(value, String(index));
|
|
40
|
+
return isAbsent(item) ? null : copyValue(item, inside);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
const entries = [];
|
|
44
|
+
for (const name of Object.keys(value)) {
|
|
45
|
+
const member = serialized(value, name);
|
|
46
|
+
if (!isAbsent(member)) entries.push([name, copyValue(member, inside)]);
|
|
47
|
+
}
|
|
48
|
+
return Object.fromEntries(entries);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* `value` copied as JSON data: members that are undefined, functions or symbols are dropped (an array item becomes
|
|
53
|
+
* null), non-finite numbers become null and -0 becomes 0, `toJSON` results replace their objects (a Date becomes its ISO string), boxed
|
|
54
|
+
* primitives unwrap, and a cycle or a BigInt throws a TypeError. A value JSON has no text for (undefined, a function,
|
|
55
|
+
* a symbol) throws a SyntaxError, as parsing the missing text does.
|
|
56
|
+
*/
|
|
57
|
+
export function jsonClone(value) {
|
|
58
|
+
const top = serialized({ '': value }, '');
|
|
59
|
+
if (isAbsent(top)) throw new SyntaxError('"undefined" is not valid JSON');
|
|
60
|
+
return copyValue(top, []);
|
|
61
|
+
}
|
|
@@ -17,6 +17,10 @@ export const objectList = (v) => (Array.isArray(v) ? v : []).filter((item) => it
|
|
|
17
17
|
export const stringList = (v, { blank = true } = {}) =>
|
|
18
18
|
Array.isArray(v) && v.every((s) => typeof s === 'string' && s.length > 0 && (blank || s.trim().length > 0));
|
|
19
19
|
|
|
20
|
+
/** A non-empty list of distinct values, all accepted by the supplied predicate. */
|
|
21
|
+
export const nonemptyUniqueList = (values, accepts) => Array.isArray(values) && values.length > 0
|
|
22
|
+
&& values.every((entry) => accepts(entry)) && new Set(values).size === values.length;
|
|
23
|
+
|
|
20
24
|
/** The items of a `sep`-separated list: trimmed, empties dropped; `dedupe` keeps first occurrences only. */
|
|
21
25
|
export const splitList = (v, { sep = ',', dedupe = false } = {}) => {
|
|
22
26
|
const items = String(v ?? '').split(sep).map((s) => s.trim()).filter(Boolean);
|
|
@@ -12,6 +12,57 @@ export const FORBIDDEN_FILES = [
|
|
|
12
12
|
{ name: 'starcistacks-secret-plaintext', test: (f) => /(^|\/)\.starcistacks\/[^/]+\/secrets\//i.test(f) && !/(\.enc|\/\.gitkeep|\/KEYS\.md)$/i.test(f) },
|
|
13
13
|
{ name: 'credentials-json', test: (f) => /(^|\/)(credentials|service-account|client_secret)[^/]*\.json$/i.test(f) },
|
|
14
14
|
];
|
|
15
|
+
// `word` in any case (name checks are case-aware by construction, not by flag).
|
|
16
|
+
const fold = (word) => word.replace(/[a-z]/gi, (c) => `[${c.toLowerCase()}${c.toUpperCase()}]`);
|
|
17
|
+
|
|
18
|
+
// A name that holds secret material: the keyword is an identifier segment, so DB_PASSWORD, dbPassword,
|
|
19
|
+
// OPENAI_API_KEY, api_secret and accessToken are candidates while `secondary`, `tokenize`, `monkey`, `secrets.x`
|
|
20
|
+
// and `$secret` are not. A segment starts at the name edge, after `_`/`-`/`.`, or at a camel hump.
|
|
21
|
+
const SECRET_WORD = [
|
|
22
|
+
'password', 'passwd', 'passphrase', 'secret', 'credentials?', 'token',
|
|
23
|
+
'api[_-]?key', 'access[_-]?token', 'auth[_-]?token', 'refresh[_-]?token', 'id[_-]?token', 'session[_-]?token',
|
|
24
|
+
'client[_-]?secret', 'api[_-]?secret', 'session[_-]?secret', 'jwt[_-]?secret',
|
|
25
|
+
'private[_-]?key', 'secret[_-]?key', 'signing[_-]?key', 'encryption[_-]?key', 'master[_-]?key',
|
|
26
|
+
'service[_-]?role[_-]?key', 'service[_-]?key', 'session[_-]?key',
|
|
27
|
+
].map((word) => fold(word)).join('|');
|
|
28
|
+
|
|
29
|
+
// A name whose last segment is configuration, not material: SECRET_FILE and *_PATH point at the materialized
|
|
30
|
+
// secret file (the materializer's own form), PASSWORD_MIN_LENGTH is a number, *_REF/_ID a reference.
|
|
31
|
+
const BENIGN_WORD = [
|
|
32
|
+
'file', 'path', 'dir', 'url', 'uri', 'endpoint', 'host', 'refs?', 'names?', 'ids?', 'arn', 'urn',
|
|
33
|
+
'length', 'min', 'max', 'size', 'count', 'budget', 'limit', 'regex', 'pattern', 'policy',
|
|
34
|
+
'algorithm', 'scheme', 'expiry', 'expires', 'ttl', 'required', 'enabled', 'disabled', 'type', 'version',
|
|
35
|
+
].map((word) => fold(word)).join('|');
|
|
36
|
+
|
|
37
|
+
const SEGMENT_BEFORE = String.raw`(?:(?<![A-Za-z0-9_$])|(?<=[_\-.])|(?<=[a-z])(?=[A-Z]))`;
|
|
38
|
+
const NAME_REST = '[A-Za-z0-9_$.-]*?';
|
|
39
|
+
const SECRET_NAME = [
|
|
40
|
+
'(?<![A-Za-z0-9_$])',
|
|
41
|
+
String.raw`(?!${NAME_REST}${SEGMENT_BEFORE}(?:${BENIGN_WORD})(?=\s*[:=]))`,
|
|
42
|
+
String.raw`(?=${NAME_REST}${SEGMENT_BEFORE}(?:${SECRET_WORD})(?=[_\-.]|[A-Z]|[^A-Za-z0-9_$]|$))`,
|
|
43
|
+
String.raw`[A-Za-z_$][A-Za-z0-9_$.-]*\s*[:=]\s*`,
|
|
44
|
+
].join('');
|
|
45
|
+
|
|
46
|
+
// `password = "hunter2hunter2"` — the quoted literal. `env(NAME)` is a reference, never a secret.
|
|
47
|
+
const ASSIGNED_QUOTED = new RegExp(
|
|
48
|
+
SECRET_NAME + String.raw`['"\`](?!env\([A-Z_][A-Z0-9_]*\)['"\`])([^'"\`\n\s$<{]{12,})['"\`]`,
|
|
49
|
+
);
|
|
50
|
+
// `DB_PASSWORD=hunter2hunter2` — the compose list-item and dotenv form; the dotenv value has no `:` so
|
|
51
|
+
// `password: secret:db/password` and `token: ref:vault/api` stay references, and the end boundary keeps
|
|
52
|
+
// calls and member/index references such as `secret = credentialsSource.current` out of the literals.
|
|
53
|
+
const ASSIGNED_UNQUOTED = new RegExp(
|
|
54
|
+
SECRET_NAME + String.raw`(?!['"\`]|env\()([A-Za-z0-9_+=-]{12,})(?![A-Za-z0-9_+=.(\[-])`,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const NAME_WORD = (word, tail) => new RegExp(`^${NAME_REST}${SEGMENT_BEFORE}(?:${word})${tail}`);
|
|
58
|
+
const SECRET_NAME_TEST = NAME_WORD(SECRET_WORD, String.raw`(?:[_\-.]|[A-Z]|[^A-Za-z0-9_$]|$)`);
|
|
59
|
+
const BENIGN_TAIL_TEST = NAME_WORD(BENIGN_WORD, '$');
|
|
60
|
+
|
|
61
|
+
/** True when a bare variable `name` holds secret material as an identifier segment (DB_PASSWORD, dbPassword,
|
|
62
|
+
* SUPABASE_SERVICE_ROLE_KEY) and does not end in a config suffix (PASSWORD_MIN_LENGTH, SECRET_FILE,
|
|
63
|
+
* tokenBudget). The same name judgement the `assigned-secret` patterns make, for callers that read keys. */
|
|
64
|
+
export const secretNameLike = (name) => SECRET_NAME_TEST.test(name) && !BENIGN_TAIL_TEST.test(name);
|
|
65
|
+
|
|
15
66
|
export const SECRET_PATTERNS = [
|
|
16
67
|
{ name: 'private-key-block', re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/ },
|
|
17
68
|
{ name: 'aws-access-key', re: /\bAKIA[0-9A-Z]{16}\b/ },
|
|
@@ -23,17 +74,20 @@ export const SECRET_PATTERNS = [
|
|
|
23
74
|
{ name: 'telegram-bot-token', re: /\b\d{8,10}:AA[0-9A-Za-z_-]{33}\b/ },
|
|
24
75
|
{ name: 'stripe-secret', re: /\b(?:sk|rk)_live_[0-9A-Za-z]{20,}\b/ },
|
|
25
76
|
{ name: 'jwt', re: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/ },
|
|
77
|
+
{ name: 'age-secret-key', re: /\bAGE-SECRET-KEY-1[0-9A-Z]{20,}\b/ },
|
|
78
|
+
{ name: 'password-connection-string', re: /\b(?:postgres(?:ql)?|mysql|mariadb|mongodb(?:\+srv)?|redis):\/\/[^:\s/@]+:[^@\s/${}]{8,}@/iu },
|
|
26
79
|
// A value written `env(NAME)` (the Supabase config.toml reference form) names a variable, not a secret.
|
|
27
80
|
// A keyword-assigned value that names itself a stand-in (a test stub's `accessToken: "fixture-..."`) is no
|
|
28
81
|
// candidate; only this heuristic takes the exemption, never a provider-shaped token above.
|
|
29
82
|
// A spec file's keyword-assigned values are test inputs (a spec's sign-in
|
|
30
83
|
// form password and a mocked accessToken once refused a push): the heuristic skips spec files too.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
String.raw`\b\s*[:=]\s*['"](?!env\([A-Z_][A-Z0-9_]*\)['"])([^'"\s$<{]{12,})`,
|
|
34
|
-
`['"]`,
|
|
35
|
-
].join(''), 'i'), placeholder: /fixture|stub|fake|dummy|placeholder|example|sample|changeme|redacted|mock/i,
|
|
84
|
+
// A SCREAMING_SNAKE quoted value in source is a public code (an enum member whose value is an upper-case code), not a credential.
|
|
85
|
+
{ name: 'assigned-secret', re: ASSIGNED_QUOTED, sourceCode: /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/, placeholder: /fixture|stub|fake|dummy|placeholder|example|sample|changeme|redacted|mock/i,
|
|
36
86
|
skipFile: /\.(?:spec|test|e2e-spec)\.[cm]?[jt]sx?$/ },
|
|
87
|
+
// An unquoted value in TypeScript or JavaScript source is an identifier or a type (`token: InjectionToken`,
|
|
88
|
+
// `MODULE_OPTIONS_TOKEN: DATABASE_OPTIONS_TOKEN`), never a literal; the unquoted form is the dotenv, compose and toml shape.
|
|
89
|
+
{ name: 'assigned-secret', re: ASSIGNED_UNQUOTED, placeholder: /fixture|stub|fake|dummy|placeholder|example|sample|changeme|redacted|mock/i,
|
|
90
|
+
skipFile: /\.(?:spec|test|e2e-spec)\.[cm]?[jt]sx?$|\.[cm]?[jt]sx?$/ },
|
|
37
91
|
];
|
|
38
92
|
|
|
39
93
|
|
|
@@ -43,6 +97,7 @@ export function secretHits(file, text) {
|
|
|
43
97
|
for (const rule of SECRET_PATTERNS) {
|
|
44
98
|
if (rule.skipFile?.test(file ?? '')) continue;
|
|
45
99
|
const hit = rule.re.exec(text);
|
|
100
|
+
if (hit && rule.sourceCode?.test(hit[1] ?? '') && /\.[cm]?[jt]sx?$/.test(file ?? '')) continue;
|
|
46
101
|
if (hit && !rule.placeholder?.test(hit[1] ?? '')) names.push(rule.name);
|
|
47
102
|
}
|
|
48
103
|
return names;
|
|
@@ -58,7 +58,7 @@ const KNOWN_IMAGES = Object.freeze({
|
|
|
58
58
|
export const STATEFUL_KINDS = Object.freeze(new Set(['database', 'cache', 'identity', 'storage', 'mail', 'queue', 'search']));
|
|
59
59
|
|
|
60
60
|
/** The repository name of an image reference: `quay.io/keycloak/keycloak:26.0` -> `keycloak`. */
|
|
61
|
-
const repositoryOf = image => {
|
|
61
|
+
export const repositoryOf = image => {
|
|
62
62
|
const withoutDigest = image.split('@')[0];
|
|
63
63
|
const lastSegment = withoutDigest.split('/').at(-1) ?? withoutDigest;
|
|
64
64
|
return lastSegment.split(':')[0];
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** The expression under the wrappers selected by an AST check's contract. */
|
|
2
|
-
export function unwrapExpression(ts, expression, { awaitExpressions = false, typeAssertions = true, satisfiesExpressions = true } = {}) {
|
|
2
|
+
export function unwrapExpression(ts, expression, { awaitExpressions = false, typeAssertions = true, nonNullExpressions = true, satisfiesExpressions = true } = {}) {
|
|
3
3
|
while (expression && (ts.isParenthesizedExpression(expression) || ts.isAsExpression(expression)
|
|
4
|
-
|| (typeAssertions && ts.isTypeAssertionExpression(expression)) || ts.isNonNullExpression(expression)
|
|
4
|
+
|| (typeAssertions && ts.isTypeAssertionExpression(expression)) || (nonNullExpressions && ts.isNonNullExpression(expression))
|
|
5
5
|
|| (satisfiesExpressions && (ts.isSatisfiesExpression?.(expression) ?? false))
|
|
6
6
|
|| (awaitExpressions && ts.isAwaitExpression(expression)))) expression = expression.expression;
|
|
7
7
|
return expression;
|
|
@@ -18,3 +18,6 @@ export function importsOf(ts, sourceFile, { moduleKey = 'module', stringLiterals
|
|
|
18
18
|
}
|
|
19
19
|
return imports;
|
|
20
20
|
}
|
|
21
|
+
|
|
22
|
+
/** Catalog values unwrap parentheses, `as`, and `satisfies`, preserving assertions and non-null nodes. */
|
|
23
|
+
export const unwrapCatalogExpression = (ts, node) => unwrapExpression(ts, node, { typeAssertions: false, nonNullExpressions: false });
|
|
@@ -11,8 +11,18 @@ export const hasAnyFlag = (flags, mask) => ((flags ?? 0) & mask) !== 0;
|
|
|
11
11
|
/** Whether the variable `declaration` belongs to a `const` list: the TypeScript node-flag test, an explicit comparison of the masked flags. */
|
|
12
12
|
export const isConstVariable = (ts, declaration) => (ts.getCombinedNodeFlags(declaration.parent) & ts.NodeFlags.Const) !== 0;
|
|
13
13
|
|
|
14
|
-
/**
|
|
15
|
-
export
|
|
14
|
+
/** Text of a string-literal-like node or an enabled identifier/numeric name; null for other expressions. */
|
|
15
|
+
export function nameText(ts, node, { identifiers = true, numbers = false } = {}) {
|
|
16
|
+
if (!node) return null;
|
|
17
|
+
if (ts.isStringLiteralLike(node) || (identifiers && ts.isIdentifier(node)) || (numbers && ts.isNumericLiteral(node))) return node.text;
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A statically selected property name, including string-literal element access; null for computed selections. */
|
|
22
|
+
export function accessName(ts, node) {
|
|
23
|
+
if (ts.isPropertyAccessExpression(node)) return node.name.text;
|
|
24
|
+
return ts.isElementAccessExpression(node) ? literalText(ts, node.argumentExpression) : null;
|
|
25
|
+
}
|
|
16
26
|
|
|
17
27
|
/** The text of a string-literal-like `node` (a string literal or a no-substitution template), or null. */
|
|
18
28
|
export const literalText = (ts, node) => (node && ts.isStringLiteralLike(node) ? node.text : null);
|
|
@@ -42,3 +52,18 @@ export function propertyText(ts, text, { file = 'source.ts', key, declaration =
|
|
|
42
52
|
visit(source);
|
|
43
53
|
return value;
|
|
44
54
|
}
|
|
55
|
+
|
|
56
|
+
/** Initializers of every identifier-named variable, grouped in traversal order across the source's scopes. */
|
|
57
|
+
export function variableInitializerGroups(ts, source) {
|
|
58
|
+
const groups = new Map();
|
|
59
|
+
const collect = (node) => {
|
|
60
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
|
|
61
|
+
const initializers = groups.get(node.name.text) ?? [];
|
|
62
|
+
initializers.push(node.initializer);
|
|
63
|
+
groups.set(node.name.text, initializers);
|
|
64
|
+
}
|
|
65
|
+
ts.forEachChild(node, collect);
|
|
66
|
+
};
|
|
67
|
+
collect(source);
|
|
68
|
+
return groups;
|
|
69
|
+
}
|
package/service-isolation.mjs
CHANGED
|
@@ -19,6 +19,9 @@ const isRelative = (specifier) => specifier === "." || specifier === ".." || spe
|
|
|
19
19
|
|
|
20
20
|
/** Calls `check(node, specifier)` for every import and re-export that names a module. */
|
|
21
21
|
const onSpecifiers = (check) => ({
|
|
22
|
+
ImportExpression(node) {
|
|
23
|
+
check(node.source, node.source.value)
|
|
24
|
+
},
|
|
22
25
|
ImportDeclaration(node) {
|
|
23
26
|
check(node.source, node.source.value)
|
|
24
27
|
},
|
package/size-budget.mjs
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* A source file has a hard-growth budget of `fileLines.hardGrowth` lines (`ruleParams.be.fileLines` of the
|
|
5
5
|
* slot manifest, read through `settings.starci.hfs`, see `lib/hfs.mjs`). `file-size-growth` is the ratchet and fails only on a file that must not
|
|
6
6
|
* be this long: a NEW file over the budget, or an existing file over the budget that is longer than its recorded size.
|
|
7
|
-
* The recorded size is the file's line count at
|
|
7
|
+
* The recorded size is the file's line count at `merge-base HEAD <base>` (STARCI_BASE_REF, origin/main, then main).
|
|
8
|
+
* Only staged lint text in a commit hook is compared with HEAD. An unavailable baseline is reported. A file may stay as large
|
|
8
9
|
* as it is; it may not grow, and it may not be born large. The rule takes no option: the budget is the manifest's, the
|
|
9
10
|
* baseline is git's.
|
|
10
11
|
*
|
|
@@ -33,7 +34,8 @@ export const fileSizeGrowth = {
|
|
|
33
34
|
schema: [],
|
|
34
35
|
messages: {
|
|
35
36
|
born: "This new file has {{lines}} lines, over the budget of {{max}}. A new file starts within the budget: split it by responsibility.",
|
|
36
|
-
grew: "This file has {{lines}} lines, over the budget of {{max}}, and it was {{recorded}} at the
|
|
37
|
+
grew: "This file has {{lines}} lines, over the budget of {{max}}, and it was {{recorded}} at the recorded revision. A file over the budget may not grow: move the new code into its own file.",
|
|
38
|
+
unavailable: "The recorded size of this over-budget file is unavailable. Make STARCI_BASE_REF, origin/main or main and its merge-base history available before linting.",
|
|
37
39
|
},
|
|
38
40
|
},
|
|
39
41
|
create(context) {
|
|
@@ -43,11 +45,13 @@ export const fileSizeGrowth = {
|
|
|
43
45
|
const max = hardGrowthLines(hfs.ruleParams.fileLines)
|
|
44
46
|
return {
|
|
45
47
|
"Program:exit"(node) {
|
|
46
|
-
const
|
|
48
|
+
const source = (context.sourceCode || context.getSourceCode()).text
|
|
49
|
+
const lines = lineCount(source)
|
|
47
50
|
if (lines <= max) return
|
|
48
|
-
const recorded = recordedLines(filename)
|
|
51
|
+
const recorded = recordedLines(filename, { source })
|
|
49
52
|
const loc = { line: 1, column: 0 }
|
|
50
|
-
if (recorded ===
|
|
53
|
+
if (recorded === "unavailable") context.report({ node, loc, messageId: "unavailable" })
|
|
54
|
+
else if (recorded === null) context.report({ node, loc, messageId: "born", data: { lines, max } })
|
|
51
55
|
else if (lines > recorded) context.report({ node, loc, messageId: "grew", data: { lines, max, recorded } })
|
|
52
56
|
},
|
|
53
57
|
}
|
|
@@ -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
|
},
|