@starci/eslint-canon-fe 8.3.0 → 8.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 +6 -0
- package/callback-shape.mjs +344 -0
- package/client-boundary.mjs +1 -1
- package/docs/hooks-folder.md +1 -1
- package/env-owner.mjs +120 -9
- package/function-budget.mjs +49 -0
- package/hooks-folder.mjs +149 -18
- package/icon.mjs +67 -0
- package/index.mjs +47 -0
- package/lib/own-hook-origin.mjs +53 -0
- package/lib/program-for.mjs +22 -0
- package/lib/pure-owner-helper.mjs +148 -0
- package/lib/redux-hook-origin.mjs +44 -0
- package/next-conventions.mjs +38 -35
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +11 -11
- package/runtime/knowledge/hfs/fe-stack.yaml +22 -1
- 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/served-locale.mjs +80 -9
- package/size-growth.mjs +10 -6
- package/swr-family.mjs +197 -0
- package/translation.mjs +135 -7
- package/transport.mjs +126 -59
|
@@ -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/served-locale.mjs
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*
|
|
15
15
|
* WHAT THEY CANNOT HOLD, stated rather than implied. Neither rule can tell whether the value the link
|
|
16
16
|
* computes is CORRECT - whether it reads the address, the cookie or a constant - because that is
|
|
17
|
-
* inside a function these rules only see the name of. A link that attaches `
|
|
17
|
+
* inside a function these rules only see the name of. A link that attaches `Accept-Language: "en"`
|
|
18
18
|
* unconditionally satisfies both and is wrong. That case is a review question and LOCALE-2 is where
|
|
19
19
|
* it is argued; pretending otherwise would be the rule claiming a guarantee it does not have.
|
|
20
20
|
*
|
|
@@ -73,8 +73,11 @@ const isLocaleLinkFile = (context) => {
|
|
|
73
73
|
*/
|
|
74
74
|
const isLinkImplementationFile = (context) => linkFileName(context) !== null
|
|
75
75
|
|
|
76
|
-
/** The header a request declares its language in
|
|
77
|
-
export const LOCALE_HEADER = "
|
|
76
|
+
/** The header a request declares its language in; HTTP header names are case-insensitive. */
|
|
77
|
+
export const LOCALE_HEADER = "Accept-Language"
|
|
78
|
+
|
|
79
|
+
/** The retired locale header is refused everywhere, including the locale link. */
|
|
80
|
+
const RETIRED_LOCALE_HEADER = "x-locale"
|
|
78
81
|
|
|
79
82
|
/** Callee names that construct the terminal link - the one that actually reaches the network. */
|
|
80
83
|
const TERMINAL_LINK_CALLEES = new Set(["createHttpLink", "HttpLink", "createUploadLink", "BatchHttpLink"])
|
|
@@ -93,16 +96,59 @@ const calleeName = (node) => {
|
|
|
93
96
|
return null
|
|
94
97
|
}
|
|
95
98
|
|
|
96
|
-
/**
|
|
99
|
+
/** Literal and expression-free template spellings identify the same header. */
|
|
100
|
+
const stringOf = (node) => {
|
|
101
|
+
if (node?.type === "Literal" && typeof node.value === "string") return node.value
|
|
102
|
+
if (node?.type === "TemplateLiteral" && node.expressions.length === 0) return node.quasis[0].value.cooked
|
|
103
|
+
return null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Every static property spelling, without treating a computed variable as its identifier's name. */
|
|
107
|
+
const keyOf = (node) => node?.property?.type === "Identifier" && !node.computed ? node.property.name : stringOf(node?.property)
|
|
108
|
+
|
|
109
|
+
/** Every string-ish key a property can carry, so `"Accept-Language"` and `["Accept-Language"]` read the same. */
|
|
97
110
|
const propertyKeyOf = (property) => {
|
|
98
111
|
if (!property || property.type !== "Property") return null
|
|
99
112
|
const key = property.key
|
|
100
113
|
if (!key) return null
|
|
101
114
|
if (key.type === "Identifier" && !property.computed) return key.name
|
|
102
|
-
|
|
115
|
+
return stringOf(key)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Resolve a receiver's binding, so an unrelated local `Headers` or `set` method stays unrelated. */
|
|
119
|
+
const variableOf = (node, sourceCode) => {
|
|
120
|
+
for (let scope = sourceCode.getScope(node); scope; scope = scope.upper) {
|
|
121
|
+
const variable = scope.set.get(node.name)
|
|
122
|
+
if (variable) return variable
|
|
123
|
+
}
|
|
103
124
|
return null
|
|
104
125
|
}
|
|
105
126
|
|
|
127
|
+
/** Global browser constructors and globals may be qualified, but cannot be shadowed. */
|
|
128
|
+
const isNativeName = (node, name, sourceCode) => {
|
|
129
|
+
if (node?.type === "Identifier") return node.name === name && !variableOf(node, sourceCode)?.defs.length
|
|
130
|
+
return node?.type === "MemberExpression" && keyOf(node) === name &&
|
|
131
|
+
["globalThis", "window"].some((global) => isNativeName(node.object, global, sourceCode))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Syntax establishes native Headers values and stable aliases; unknown or reassigned receivers are left alone. */
|
|
135
|
+
const isNativeInstance = (node, name, sourceCode, seen = new Set()) => {
|
|
136
|
+
if (!node || seen.has(node)) return false
|
|
137
|
+
seen.add(node)
|
|
138
|
+
const annotation = node.typeAnnotation?.typeAnnotation ?? node.typeAnnotation
|
|
139
|
+
if (annotation?.type === "TSTypeReference" && isNativeName(annotation.typeName, name, sourceCode)) return true
|
|
140
|
+
if (["TSAsExpression", "TSNonNullExpression", "ChainExpression"].includes(node.type)) return isNativeInstance(node.expression, name, sourceCode, seen)
|
|
141
|
+
if (node.type === "NewExpression") return isNativeName(node.callee, name, sourceCode)
|
|
142
|
+
if (name === "Headers" && node.type === "MemberExpression" && keyOf(node) === "headers") {
|
|
143
|
+
return ["Request", "Response"].some((owner) => isNativeInstance(node.object, owner, sourceCode, new Set(seen)))
|
|
144
|
+
}
|
|
145
|
+
if (node.type !== "Identifier") return false
|
|
146
|
+
const variable = variableOf(node, sourceCode)
|
|
147
|
+
if (variable?.references.some((reference) => reference.isWrite() && !reference.init)) return false
|
|
148
|
+
return (variable?.identifiers ?? []).some((identifier) => isNativeInstance(identifier, name, sourceCode, seen)) ||
|
|
149
|
+
(variable?.defs ?? []).some((definition) => definition.type === "Variable" && isNativeInstance(definition.node.init, name, sourceCode, seen))
|
|
150
|
+
}
|
|
151
|
+
|
|
106
152
|
export const rules = {
|
|
107
153
|
"api-client-attaches-the-locale": {
|
|
108
154
|
meta: {
|
|
@@ -152,15 +198,40 @@ export const rules = {
|
|
|
152
198
|
schema: [],
|
|
153
199
|
messages: {
|
|
154
200
|
elsewhere:
|
|
155
|
-
"Only the locale link may write `
|
|
201
|
+
"Only the locale link may write `Accept-Language`. Setting it here is a second answer to which language this request is in, and the two diverge the first time one of them is updated - usually leaving a single hook correct and the rest of the surface on the server's default. Let the transport attach it.",
|
|
202
|
+
retired:
|
|
203
|
+
"The `x-locale` header is retired. Declare the reader's language with `Accept-Language` in the locale link, the one place that owns it.",
|
|
156
204
|
},
|
|
157
205
|
},
|
|
158
206
|
create(context) {
|
|
159
|
-
|
|
207
|
+
const ownsLocale = isLocaleLinkFile(context)
|
|
208
|
+
const sourceCode = context.sourceCode ?? context.getSourceCode()
|
|
209
|
+
const checkHeader = (node, key) => {
|
|
210
|
+
const header = key?.toLowerCase()
|
|
211
|
+
if (header === RETIRED_LOCALE_HEADER) {
|
|
212
|
+
context.report({ node, messageId: "retired" })
|
|
213
|
+
} else if (header === LOCALE_HEADER.toLowerCase() && !ownsLocale) {
|
|
214
|
+
context.report({ node, messageId: "elsewhere" })
|
|
215
|
+
}
|
|
216
|
+
}
|
|
160
217
|
return {
|
|
161
218
|
Property(node) {
|
|
162
|
-
|
|
163
|
-
|
|
219
|
+
checkHeader(node, propertyKeyOf(node))
|
|
220
|
+
},
|
|
221
|
+
AssignmentExpression(node) {
|
|
222
|
+
if (node.left.type === "MemberExpression") checkHeader(node, keyOf(node.left))
|
|
223
|
+
},
|
|
224
|
+
NewExpression(node) {
|
|
225
|
+
const initial = node.arguments[0]
|
|
226
|
+
if (!isNativeName(node.callee, "Headers", sourceCode) || initial?.type !== "ArrayExpression") return
|
|
227
|
+
for (const entry of initial.elements) {
|
|
228
|
+
if (entry?.type === "ArrayExpression") checkHeader(entry, stringOf(entry.elements[0]))
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
CallExpression(node) {
|
|
232
|
+
const callee = node.callee
|
|
233
|
+
if (callee.type === "MemberExpression" && ["set", "append"].includes(keyOf(callee)) && isNativeInstance(callee.object, "Headers", sourceCode)) {
|
|
234
|
+
checkHeader(node, stringOf(node.arguments[0]))
|
|
164
235
|
}
|
|
165
236
|
},
|
|
166
237
|
}
|
package/size-growth.mjs
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
* A source file has a hard-growth budget of `fileLines.soft` lines while `fileLines.hardGrowth` holds
|
|
5
5
|
* (`ruleParams.fe.fileLines` of the slot manifest, read through the slot view `hfsOf(context).ruleParams`). `file-size-growth` is the ratchet and
|
|
6
6
|
* fails only on a file that must not be this long: a NEW file over the budget, or an existing file over the budget that
|
|
7
|
-
* is longer than its recorded size. The recorded size is the
|
|
8
|
-
* (
|
|
7
|
+
* is longer than its recorded size. The recorded size is the line count at `merge-base HEAD <base>`
|
|
8
|
+
* (STARCI_BASE_REF, origin/main, then main); only staged lint text in a commit hook uses HEAD. An unavailable baseline
|
|
9
|
+
* is reported. A file may stay as large as it is; it may not grow, and it may not be born large. The rule
|
|
9
10
|
* takes no option: the budget is the manifest's, the baseline is git's.
|
|
10
11
|
*
|
|
11
12
|
* There is no baseline file and no allowlist: the record is the repository's own history. A declaration file (`*.d.ts`) carries no behaviour and is not governed. Component,
|
|
@@ -27,7 +28,8 @@ export const fileSizeGrowth = {
|
|
|
27
28
|
schema: [],
|
|
28
29
|
messages: {
|
|
29
30
|
born: "This new file has {{lines}} lines, over the budget of {{max}}. A new file starts within the budget: split it by responsibility.",
|
|
30
|
-
grew: "This file has {{lines}} lines, over the budget of {{max}}, and it was {{recorded}} at the
|
|
31
|
+
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.",
|
|
32
|
+
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.",
|
|
31
33
|
},
|
|
32
34
|
},
|
|
33
35
|
create(context) {
|
|
@@ -36,11 +38,13 @@ export const fileSizeGrowth = {
|
|
|
36
38
|
const max = hardGrowthLines(hfsOf(context).ruleParams.fileLines)
|
|
37
39
|
return {
|
|
38
40
|
"Program:exit"(node) {
|
|
39
|
-
const
|
|
41
|
+
const source = (context.sourceCode || context.getSourceCode()).text
|
|
42
|
+
const lines = lineCount(source)
|
|
40
43
|
if (lines <= max) return
|
|
41
|
-
const recorded = recordedLines(filename)
|
|
44
|
+
const recorded = recordedLines(filename, { source })
|
|
42
45
|
const loc = { line: 1, column: 0 }
|
|
43
|
-
if (recorded ===
|
|
46
|
+
if (recorded === "unavailable") context.report({ node, loc, messageId: "unavailable" })
|
|
47
|
+
else if (recorded === null) context.report({ node, loc, messageId: "born", data: { lines, max } })
|
|
44
48
|
else if (lines > recorded) context.report({ node, loc, messageId: "grew", data: { lines, max, recorded } })
|
|
45
49
|
},
|
|
46
50
|
}
|