@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
|
@@ -306,6 +306,7 @@ rules:
|
|
|
306
306
|
envelope, the generated wire types (from be/contracts/, through the root codegen) and the axios calls live in
|
|
307
307
|
modules/api; the session lives in modules/auth; mutation feedback in modules/toast; environment reading in
|
|
308
308
|
modules/config; every href builder in modules/routes; locale routing in modules/i18n; colour values in modules/brand.
|
|
309
|
+
Only hooks/swr imports the API transport operations; feature owners and other hooks consume those SWR hooks.
|
|
309
310
|
A helper name defined twice across hook files is a finding.
|
|
310
311
|
rationale: |
|
|
311
312
|
A rule that says what a hooks folder is also says where everything else goes, so nobody copies a helper sixteen
|
|
@@ -82,7 +82,7 @@ overall:
|
|
|
82
82
|
rule: typescript:S3776
|
|
83
83
|
via: overall.issues
|
|
84
84
|
# Lite is the same quality policy, coverage included: a lite back end keeps full's unit run and its 100 per file on the measured
|
|
85
|
-
# logic files (owner ruling 2026-10-12, R212 case-1). Readers select this block from hfs.json edition; the gate keeps its own
|
|
85
|
+
# logic files (owner ruling 2026-10-12, R212 case-1 (owner:lite-production-swarm), with the Work boundary amended by ruling:R212/case-4). Readers select this block from hfs.json edition; the gate keeps its own
|
|
86
86
|
# name so a lite project's server gate stays a separate object, and every threshold below equals full's.
|
|
87
87
|
lite:
|
|
88
88
|
gate:
|
|
@@ -351,7 +351,7 @@ allocation:
|
|
|
351
351
|
# process keeps the lock regardless of age.
|
|
352
352
|
housekeeping:
|
|
353
353
|
tmpMaxAgeMs: 172800000
|
|
354
|
-
tmpPrefixes: [policy-, rights-, l4-, starci, starci-w2, evidence, sup-k, work-v3, scoped-lint, si-, w1-, nivo-, orca-spec,
|
|
354
|
+
tmpPrefixes: [coverage-generated-wire-, work-validate-lite-, example-refs-lite-, example-lite-, coverage-inventory-, policy-, rights-, l4-, starci, starci-w2, evidence, sup-k, work-v3, scoped-lint, si-, w1-, nivo-, orca-spec,
|
|
355
355
|
owner-bias-cli-, admission-runtime-, agent-launch-, starci-provider-budget-, starci-provider-schema-,
|
|
356
356
|
codex-, deps-, direction-part-, draw-layer-, footprint-, grammar-pin-, guard-, literal-argv-, module-yaml-,
|
|
357
357
|
doc-language-, guidance-, hk-, hfs-, install-link-, kernel-check-, kguard-, sup-orca, land-, op-manifest-, path-key-, pathspec-, push-scan-spec-, rename-over-, sbl-, serve-ask-, sup-chat, sup-repo, sup-src, sup-wd,
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"test:e2e": "npm run typecheck:tests && cd be && jest --selectProjects e2e",
|
|
11
11
|
"test:contract": "npm run typecheck:tests && cd be && jest --selectProjects contract",
|
|
12
12
|
"test:stack": "starci app stack",
|
|
13
|
+
{{supabaseScripts}}
|
|
13
14
|
"contract:emit": "starci app emit"
|
|
14
15
|
}
|
|
15
16
|
}
|
|
@@ -75,3 +75,6 @@ export const gitExecutable = (options = null) => {
|
|
|
75
75
|
if (!options) cachedGit = found;
|
|
76
76
|
return found;
|
|
77
77
|
};
|
|
78
|
+
|
|
79
|
+
/** The output ceiling of the whole-diff and `cat-file --batch` calls `test affected` batches (a release diff is tens of megabytes). */
|
|
80
|
+
export const BATCH_MAX_BUFFER = 1024 * 1024 * 1024;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// merge-base.mjs — `git merge-base <a> <b>`: the best common ancestor of two commits. Whether one commit is in another
|
|
2
|
+
// is is-ancestor.mjs.
|
|
3
|
+
import { gitRunner } from './lib.mjs';
|
|
4
|
+
|
|
5
|
+
/** The best common ancestor of commits `a` and `b` (a sha), or null when they share none or a ref does not resolve. */
|
|
6
|
+
export const mergeBase = (cwd, a, b, { git = null } = {}) => {
|
|
7
|
+
const r = gitRunner(git)(['merge-base', a, b], { cwd });
|
|
8
|
+
return r.ok && r.stdout ? r.stdout : null;
|
|
9
|
+
};
|
|
@@ -1,19 +1,65 @@
|
|
|
1
1
|
// recorded-lines.mjs - the line count a file had at a git revision (git show <ref>:<file>): the recorded size a
|
|
2
2
|
// size-growth rule compares the working file with.
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
4
5
|
import { runGit } from './lib.mjs';
|
|
6
|
+
import { mergeBase } from './merge-base.mjs';
|
|
7
|
+
import { withoutGitLocalEnv } from '../../lib/git.mjs';
|
|
5
8
|
import { lineCount } from '../../lib/line-count.mjs';
|
|
9
|
+
import { readEnv } from '../../lib/env.mjs';
|
|
10
|
+
import { folded } from '../../lib/event-contract.mjs';
|
|
11
|
+
|
|
12
|
+
const ok = (result) => !result.error && result.status === 0;
|
|
13
|
+
const pathKey = (value) => process.platform === 'win32' ? value.toLowerCase() : value;
|
|
14
|
+
|
|
15
|
+
/** A commit hook exports an index; HEAD applies only to a staged file whose lint text is that index's text. */
|
|
16
|
+
function lintingIndex(dir, name, source) {
|
|
17
|
+
const indexFile = readEnv('GIT_INDEX_FILE');
|
|
18
|
+
if (!indexFile || typeof source !== 'string') return false;
|
|
19
|
+
const options = { dir, env: withoutGitLocalEnv(process.env), maxBuffer: 64 * 1024 * 1024 };
|
|
20
|
+
const index = runGit(['rev-parse', '--path-format=absolute', '--git-path', 'index'], options);
|
|
21
|
+
const root = runGit(['rev-parse', '--show-toplevel'], options);
|
|
22
|
+
if (!ok(index) || !ok(root)) return false;
|
|
23
|
+
// The hook's relative index path is relative to the repository root even after xargs changes to be/ or fe/.
|
|
24
|
+
const hookIndex = path.resolve(root.stdout.trim(), indexFile);
|
|
25
|
+
if (pathKey(path.dirname(hookIndex)) !== pathKey(path.dirname(path.resolve(index.stdout.trim())))) return false;
|
|
26
|
+
const stagedOptions = { ...options, env: { ...options.env, GIT_INDEX_FILE: hookIndex } };
|
|
27
|
+
const changed = runGit(['diff', '--cached', '--relative', '--name-only', '-z', '--', `./${name}`], stagedOptions);
|
|
28
|
+
if (!ok(changed) || !changed.stdout.split('\0').includes(name)) return false;
|
|
29
|
+
const staged = runGit(['show', `:./${name}`], stagedOptions);
|
|
30
|
+
return ok(staged) && folded(staged.stdout) === folded(source);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function recordedRef(dir, name, source) {
|
|
34
|
+
if (lintingIndex(dir, name, source)) return 'HEAD';
|
|
35
|
+
const base = readEnv('STARCI_BASE_REF');
|
|
36
|
+
for (const ref of base ? [base] : ['origin/main', 'main']) {
|
|
37
|
+
const sha = mergeBase(dir, 'HEAD', ref);
|
|
38
|
+
if (sha) return sha;
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
6
42
|
|
|
7
43
|
/**
|
|
8
|
-
* The line count
|
|
44
|
+
* The line count at the branch's merge base, null for a confirmed new file, or `unavailable` for unreadable history.
|
|
45
|
+
* Only lint text matching a changed file in a commit hook's index is compared with HEAD. Explicit revision strings
|
|
46
|
+
* remain supported for callers that request a particular revision rather than the lint baseline.
|
|
9
47
|
*
|
|
10
48
|
* @param {string} filename - Absolute path of the file.
|
|
11
|
-
* @param {string}
|
|
12
|
-
* @returns {number | null} The recorded line count.
|
|
49
|
+
* @param {object | string} options - `{ source }` from ESLint, or an explicit revision.
|
|
50
|
+
* @returns {number | null | 'unavailable'} The recorded line count or the reason it cannot be compared.
|
|
13
51
|
*/
|
|
14
|
-
export const recordedLines = (filename,
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
52
|
+
export const recordedLines = (filename, options = {}) => {
|
|
53
|
+
const absolute = path.resolve(filename);
|
|
54
|
+
let dir = path.dirname(absolute);
|
|
55
|
+
while (!existsSync(dir) && path.dirname(dir) !== dir) dir = path.dirname(dir);
|
|
56
|
+
const name = path.relative(dir, absolute).split(path.sep).join('/');
|
|
57
|
+
const ref = typeof options === 'string' ? options : recordedRef(dir, name, options.source);
|
|
58
|
+
if (!ref) return 'unavailable';
|
|
59
|
+
const gitOptions = { dir, maxBuffer: 64 * 1024 * 1024 };
|
|
60
|
+
const files = runGit(['ls-tree', '--name-only', '-z', ref, '--', `./${name}`], gitOptions);
|
|
61
|
+
if (!ok(files)) return 'unavailable';
|
|
62
|
+
if (!files.stdout.split('\0').includes(name)) return null;
|
|
63
|
+
const recorded = runGit(['show', `${ref}:./${name}`], gitOptions);
|
|
64
|
+
return ok(recorded) ? lineCount(recorded.stdout) : 'unavailable';
|
|
19
65
|
};
|
|
@@ -1,20 +1,13 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { allowsFile } from '../allows.mjs';
|
|
3
3
|
import { relativePath, unwrapExpression } from './typescript.mjs';
|
|
4
|
-
import { sourceLocation } from '../../lib/ts-ast.mjs';
|
|
4
|
+
import { accessName, sourceLocation } from '../../lib/ts-ast.mjs';
|
|
5
5
|
|
|
6
6
|
const PROCESS_MODULES = new Set(['process', 'node:process']);
|
|
7
7
|
const PROCESS_GLOBAL = new Set(['process']);
|
|
8
8
|
const GLOBAL_HOLDERS = new Set(['globalThis', 'global']);
|
|
9
9
|
const REFLECT_GLOBAL = new Set(['Reflect']);
|
|
10
10
|
|
|
11
|
-
/** A static property name, including `object['env']` and `object[`env`]`. */
|
|
12
|
-
export function accessName(ts, node) {
|
|
13
|
-
if (ts.isPropertyAccessExpression(node)) return node.name.text;
|
|
14
|
-
if (ts.isElementAccessExpression(node) && ts.isStringLiteralLike(node.argumentExpression)) return node.argumentExpression.text;
|
|
15
|
-
return null;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
11
|
function bindingName(ts, element) {
|
|
19
12
|
const name = element.propertyName ?? element.name;
|
|
20
13
|
if (ts.isIdentifier(name) || ts.isStringLiteralLike(name)) return name.text;
|
|
@@ -3,7 +3,7 @@ import { isInside } from './config.mjs';
|
|
|
3
3
|
import { allowsFile, slotAdmitsFile } from '../allows.mjs';
|
|
4
4
|
import { reachableViolation, relativePath } from './typescript.mjs';
|
|
5
5
|
import { checkApplicationEnvironment } from './backend-app-files.mjs';
|
|
6
|
-
import { sourceLocation } from '../../lib/ts-ast.mjs';
|
|
6
|
+
import { accessName, sourceLocation } from '../../lib/ts-ast.mjs';
|
|
7
7
|
import { byCodeUnit } from '../../lib/list.mjs';
|
|
8
8
|
|
|
9
9
|
const FORBIDDEN_APP_ROLE = /(?:^|\.)(?:service|provider|providers|resolver|controller|handler|repository|entity|use-case|command|query|listener|consumer|processor)\.[cm]?[jt]sx?$/i;
|
|
@@ -177,15 +177,10 @@ function requiredSpecifier(ts, call) {
|
|
|
177
177
|
: null;
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
-
const requiredMemberSelection = (ts, node) => {
|
|
181
|
-
if (ts.isPropertyAccessExpression(node)) return node.name.text;
|
|
182
|
-
return ts.isStringLiteralLike(node.argumentExpression) ? node.argumentExpression.text : null;
|
|
183
|
-
};
|
|
184
|
-
|
|
185
180
|
function requiredMemberEvidence(ts, node, found) {
|
|
186
181
|
if (!(ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) || !ts.isCallExpression(node.expression)) return;
|
|
187
182
|
const specifier = requiredSpecifier(ts, node.expression);
|
|
188
|
-
const selected =
|
|
183
|
+
const selected = accessName(ts, node);
|
|
189
184
|
const member = ts.isPropertyAccessExpression(node) ? node.name : node.argumentExpression;
|
|
190
185
|
if (specifier && TRANSPORT_PACKAGES.test(specifier)) {
|
|
191
186
|
found.push({ node: member, specifier, detail: `${selected ?? 'computed member'} from ${specifier}` });
|
|
@@ -4,7 +4,7 @@ import { sourceLocation } from '../../lib/ts-ast.mjs';
|
|
|
4
4
|
/**
|
|
5
5
|
* R55 `client-reaches-server` (FE_CLIENT_REACHES_SERVER), the repository half of the eslint rule `client-no-server-import`,
|
|
6
6
|
* which sees one hop from a file that carries the directive. The machine walks the resolved import graph from every module
|
|
7
|
-
* whose
|
|
7
|
+
* whose directive prologue contains `"use client"`, over runtime edges only (a type-only import vanishes at build), and
|
|
8
8
|
* reports any reachable module (the entry itself included) that imports a server-only surface:
|
|
9
9
|
*
|
|
10
10
|
* - `server-only`, `next/headers`, `next/server`, `next-intl/server`;
|
|
@@ -22,8 +22,11 @@ const NODE_BUILTINS = new Set(builtinModules.map(name => name.replace(/^node:/u,
|
|
|
22
22
|
|
|
23
23
|
const isServerSpecifier = specifier => SERVER_MODULES.has(specifier) || specifier.startsWith('node:') || NODE_BUILTINS.has(specifier.split('/')[0]);
|
|
24
24
|
const isClientEntry = (ts, file) => {
|
|
25
|
-
const
|
|
26
|
-
|
|
25
|
+
for (const statement of file.sourceFile.statements) {
|
|
26
|
+
if (!ts.isExpressionStatement(statement) || !ts.isStringLiteral(statement.expression)) return false;
|
|
27
|
+
if (statement.expression.text === 'use client') return true;
|
|
28
|
+
}
|
|
29
|
+
return false;
|
|
27
30
|
};
|
|
28
31
|
|
|
29
32
|
const reachesBundle = (ts, declaration) => {
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Import and namespace consumption analysis shared by dead-export edges.
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/** Disk/staged test consumers have no production checker; bind their local names without resolving dependencies. */
|
|
5
|
+
const namespaceChecker = (context, sourceFile, namespace) => {
|
|
6
|
+
if (context.namespaceCheckers.has(sourceFile)) return context.namespaceCheckers.get(sourceFile);
|
|
7
|
+
const { ts } = context;
|
|
8
|
+
let checker = context.checkerFor?.(sourceFile.fileName);
|
|
9
|
+
if (!checker?.getSymbolAtLocation(namespace.name)) {
|
|
10
|
+
const options = { noResolve: true, noLib: true, types: [], allowJs: true, target: ts.ScriptTarget.Latest };
|
|
11
|
+
const host = ts.createCompilerHost(options);
|
|
12
|
+
const filename = path.resolve(sourceFile.fileName);
|
|
13
|
+
host.getSourceFile = file => path.resolve(file) === filename ? sourceFile : undefined;
|
|
14
|
+
host.fileExists = file => path.resolve(file) === filename;
|
|
15
|
+
host.readFile = file => path.resolve(file) === filename ? sourceFile.text : undefined;
|
|
16
|
+
checker = ts.createProgram([filename], options, host).getTypeChecker();
|
|
17
|
+
}
|
|
18
|
+
context.namespaceCheckers.set(sourceFile, checker);
|
|
19
|
+
return checker;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const transparentExpression = (ts, node) => node && (ts.isParenthesizedExpression(node) || ts.isAsExpression(node)
|
|
23
|
+
|| ts.isTypeAssertionExpression(node) || ts.isNonNullExpression(node) || ts.isSatisfiesExpression(node));
|
|
24
|
+
|
|
25
|
+
const literalMember = (ts, node) => {
|
|
26
|
+
while (transparentExpression(ts, node)) node = node.expression;
|
|
27
|
+
return node && (ts.isStringLiteralLike(node) || ts.isNumericLiteral(node)) ? node.text : null;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const destructuredMember = (ts, name) => {
|
|
31
|
+
if (ts.isIdentifier(name)) return name.text;
|
|
32
|
+
if (ts.isComputedPropertyName(name)) return literalMember(ts, name.expression);
|
|
33
|
+
return literalMember(ts, name);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** A destructuring alias consumes the property's name, while a rest/computed binding may consume every export. */
|
|
37
|
+
const destructuredBindings = (ts, pattern) => {
|
|
38
|
+
const names = [];
|
|
39
|
+
const elements = ts.isObjectBindingPattern(pattern) ? pattern.elements : pattern.properties;
|
|
40
|
+
for (const element of elements) {
|
|
41
|
+
if (element.dotDotDotToken || ts.isSpreadAssignment(element)) return { all: true, pairs: [] };
|
|
42
|
+
const name = destructuredMember(ts, element.propertyName ?? element.name);
|
|
43
|
+
if (name === null) return { all: true, pairs: [] };
|
|
44
|
+
names.push(name);
|
|
45
|
+
}
|
|
46
|
+
return { all: false, pairs: names.map(imported => ({ imported, exported: null })) };
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const namespaceReference = (ts, checker, symbol, node) => {
|
|
50
|
+
if (checker.getSymbolAtLocation(node) === symbol) return true;
|
|
51
|
+
const parent = node.parent;
|
|
52
|
+
if (ts.isShorthandPropertyAssignment(parent) && parent.name === node) return checker.getShorthandAssignmentValueSymbol(parent) === symbol;
|
|
53
|
+
return ts.isExportSpecifier(parent) && node === (parent.propertyName ?? parent.name)
|
|
54
|
+
&& checker.getExportSpecifierLocalTargetSymbol(parent) === symbol;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const unwrappedNamespace = (ts, node) => {
|
|
58
|
+
while (transparentExpression(ts, node.parent)) node = node.parent;
|
|
59
|
+
return node;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const indexedNamespaceMember = (ts, node) => {
|
|
63
|
+
if (!ts.isTypeQueryNode(node.parent) || node.parent.exprName !== node) return null;
|
|
64
|
+
let query = node.parent;
|
|
65
|
+
while (ts.isParenthesizedTypeNode(query.parent)) query = query.parent;
|
|
66
|
+
const indexed = query.parent;
|
|
67
|
+
return ts.isIndexedAccessTypeNode(indexed) && indexed.objectType === query && ts.isLiteralTypeNode(indexed.indexType)
|
|
68
|
+
? literalMember(ts, indexed.indexType.literal) : null;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** The first member is the entry export even in a chain such as namespace.Enum.Member or namespace.Type.Inner. */
|
|
72
|
+
const namespaceUse = (ts, reference) => {
|
|
73
|
+
const node = unwrappedNamespace(ts, reference);
|
|
74
|
+
const parent = node.parent;
|
|
75
|
+
let name = null;
|
|
76
|
+
if (ts.isPropertyAccessExpression(parent) && parent.expression === node) name = parent.name.text;
|
|
77
|
+
else if (ts.isQualifiedName(parent) && parent.left === node) name = parent.right.text;
|
|
78
|
+
else if (ts.isElementAccessExpression(parent) && parent.expression === node) name = literalMember(ts, parent.argumentExpression);
|
|
79
|
+
else if (ts.isVariableDeclaration(parent) && parent.initializer === node && ts.isObjectBindingPattern(parent.name)) return destructuredBindings(ts, parent.name);
|
|
80
|
+
else if (ts.isBinaryExpression(parent) && parent.right === node && parent.operatorToken.kind === ts.SyntaxKind.EqualsToken
|
|
81
|
+
&& ts.isObjectLiteralExpression(parent.left)) return destructuredBindings(ts, parent.left);
|
|
82
|
+
else name = indexedNamespaceMember(ts, node);
|
|
83
|
+
return name === null ? { all: true, pairs: [] } : { all: false, pairs: [{ imported: name, exported: null }] };
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const namespaceBindings = (context, declaration, namespace) => {
|
|
87
|
+
if (context.namespaceUses.has(namespace)) return context.namespaceUses.get(namespace);
|
|
88
|
+
const { ts } = context;
|
|
89
|
+
const sourceFile = declaration.getSourceFile();
|
|
90
|
+
const checker = namespaceChecker(context, sourceFile, namespace);
|
|
91
|
+
const symbol = checker.getSymbolAtLocation(namespace.name);
|
|
92
|
+
const result = { all: false, pairs: [] };
|
|
93
|
+
const visit = node => {
|
|
94
|
+
if (ts.isJSDocSeeTag(node) || ts.isJSDocLink(node) || ts.isJSDocLinkCode(node) || ts.isJSDocLinkPlain(node)) return;
|
|
95
|
+
if (ts.isIdentifier(node) && node !== namespace.name && namespaceReference(ts, checker, symbol, node)) {
|
|
96
|
+
const used = namespaceUse(ts, node);
|
|
97
|
+
result.all ||= used.all;
|
|
98
|
+
result.pairs.push(...used.pairs);
|
|
99
|
+
}
|
|
100
|
+
if (!result.all) {
|
|
101
|
+
ts.forEachChild(node, visit);
|
|
102
|
+
for (const doc of node.jsDoc ?? []) visit(doc);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
// An unavailable binding must never turn an unknown consumer into a false unused-export finding.
|
|
106
|
+
if (symbol) visit(sourceFile);
|
|
107
|
+
else result.all = true;
|
|
108
|
+
context.namespaceUses.set(namespace, result);
|
|
109
|
+
return result;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const importBindings = (context, declaration) => {
|
|
113
|
+
const { ts } = context;
|
|
114
|
+
const clause = declaration.importClause;
|
|
115
|
+
if (!clause) return { all: false, pairs: [] }; // side-effect import: uses nothing
|
|
116
|
+
const pairs = [];
|
|
117
|
+
if (clause.name) pairs.push({ imported: 'default', exported: null });
|
|
118
|
+
const named = clause.namedBindings;
|
|
119
|
+
if (named && named.kind === ts.SyntaxKind.NamespaceImport) {
|
|
120
|
+
const namespace = namespaceBindings(context, declaration, named);
|
|
121
|
+
return { all: namespace.all, pairs: [...pairs, ...namespace.pairs] };
|
|
122
|
+
}
|
|
123
|
+
if (named) for (const element of named.elements) pairs.push({ imported: (element.propertyName ?? element.name).text, exported: null });
|
|
124
|
+
return { all: false, pairs };
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const exportBindings = (ts, declaration) => {
|
|
128
|
+
const clause = declaration.exportClause;
|
|
129
|
+
if (!clause || clause.kind === ts.SyntaxKind.NamespaceExport) return { all: true, pairs: [] };
|
|
130
|
+
return { all: false, pairs: clause.elements.map(element => ({ imported: (element.propertyName ?? element.name).text, exported: element.name.text })) };
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const importTypeBindings = (ts, declaration) => {
|
|
134
|
+
let qualifier = declaration.qualifier;
|
|
135
|
+
if (!qualifier) return { all: true, pairs: [] };
|
|
136
|
+
while (qualifier.kind === ts.SyntaxKind.QualifiedName) qualifier = qualifier.left;
|
|
137
|
+
return { all: false, pairs: [{ imported: qualifier.text, exported: null }] };
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/** Names an import/export declaration takes from its target: {all} or {pairs: [{imported, exported}]}. */
|
|
141
|
+
export function bindingsOf(context, declaration) {
|
|
142
|
+
const { ts } = context;
|
|
143
|
+
if (!declaration) return { all: true, pairs: [] };
|
|
144
|
+
if (declaration.kind === ts.SyntaxKind.ImportDeclaration) return importBindings(context, declaration);
|
|
145
|
+
if (declaration.kind === ts.SyntaxKind.ExportDeclaration) return exportBindings(ts, declaration);
|
|
146
|
+
if (declaration.kind === ts.SyntaxKind.ImportTypeNode) return importTypeBindings(ts, declaration);
|
|
147
|
+
// ImportEqualsDeclaration, dynamic import(), require(): the whole module is taken.
|
|
148
|
+
return { all: true, pairs: [] };
|
|
149
|
+
}
|
|
@@ -17,8 +17,9 @@
|
|
|
17
17
|
* and contributes no name here.
|
|
18
18
|
*
|
|
19
19
|
* A name is used when a production file outside the owner imports it from the entry (type-only imports count). A
|
|
20
|
-
* namespace import
|
|
21
|
-
*
|
|
20
|
+
* namespace import uses its statically accessed members; a computed read or an escape of the namespace uses every name.
|
|
21
|
+
* `import x = require`, a dynamic import()/require(), an `export *` or `export * as` of the entry make every name used.
|
|
22
|
+
* A file outside the owner that re-exports the name (`export { n as m } from`) uses it when `m` is itself
|
|
22
23
|
* used by the importers of that file; a re-exporting file nobody imports is a framework entry (a route file) and uses it.
|
|
23
24
|
* Specs and tests are not in the graph, so an export used only by a spec is dead, on purpose, with these exceptions (unit test
|
|
24
25
|
* standard): the unit spec of every unit role (ruleParams.be.unitRoles: `<name>.service.spec.ts`, `<name>.cli.spec.ts`) and of every logic role
|
|
@@ -39,6 +40,7 @@ import path from 'node:path';
|
|
|
39
40
|
import { treeOf } from './required-files.mjs';
|
|
40
41
|
import { globExpression } from '../../lib/glob.mjs';
|
|
41
42
|
import { stagedConsumerSources } from './staged-consumers.mjs';
|
|
43
|
+
import { bindingsOf } from './dead-exports-bindings.mjs';
|
|
42
44
|
|
|
43
45
|
export const DEAD_EXPORT_RULE_IDS = ['HFS_UNUSED_EXPORT', 'HFS_UNUSED_FILE'];
|
|
44
46
|
|
|
@@ -70,40 +72,6 @@ function extraEntries(graph, entry) {
|
|
|
70
72
|
return names.flatMap(name => (name.includes('*') ? matches(name) : [`${found.root}/${name}`].filter(rel => graph.files.has(rel))));
|
|
71
73
|
}
|
|
72
74
|
|
|
73
|
-
const importBindings = (ts, declaration) => {
|
|
74
|
-
const clause = declaration.importClause;
|
|
75
|
-
if (!clause) return { all: false, pairs: [] }; // side-effect import: uses nothing
|
|
76
|
-
const pairs = [];
|
|
77
|
-
if (clause.name) pairs.push({ imported: 'default', exported: null });
|
|
78
|
-
const named = clause.namedBindings;
|
|
79
|
-
if (named && named.kind === ts.SyntaxKind.NamespaceImport) return { all: true, pairs: [] };
|
|
80
|
-
if (named) for (const element of named.elements) pairs.push({ imported: (element.propertyName ?? element.name).text, exported: null });
|
|
81
|
-
return { all: false, pairs };
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
const exportBindings = (ts, declaration) => {
|
|
85
|
-
const clause = declaration.exportClause;
|
|
86
|
-
if (!clause || clause.kind === ts.SyntaxKind.NamespaceExport) return { all: true, pairs: [] };
|
|
87
|
-
return { all: false, pairs: clause.elements.map(element => ({ imported: (element.propertyName ?? element.name).text, exported: element.name.text })) };
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
const importTypeBindings = (ts, declaration) => {
|
|
91
|
-
let qualifier = declaration.qualifier;
|
|
92
|
-
if (!qualifier) return { all: true, pairs: [] };
|
|
93
|
-
while (qualifier.kind === ts.SyntaxKind.QualifiedName) qualifier = qualifier.left;
|
|
94
|
-
return { all: false, pairs: [{ imported: qualifier.text, exported: null }] };
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
/** Names an import/export declaration takes from its target: {all} or {pairs: [{imported, exported}]}. */
|
|
98
|
-
function bindingsOf(ts, declaration) {
|
|
99
|
-
if (!declaration) return { all: true, pairs: [] };
|
|
100
|
-
if (declaration.kind === ts.SyntaxKind.ImportDeclaration) return importBindings(ts, declaration);
|
|
101
|
-
if (declaration.kind === ts.SyntaxKind.ExportDeclaration) return exportBindings(ts, declaration);
|
|
102
|
-
if (declaration.kind === ts.SyntaxKind.ImportTypeNode) return importTypeBindings(ts, declaration);
|
|
103
|
-
// ImportEqualsDeclaration, dynamic import(), require(): the whole module is taken.
|
|
104
|
-
return { all: true, pairs: [] };
|
|
105
|
-
}
|
|
106
|
-
|
|
107
75
|
const addExportName = (found, sourceFile, name, node) => {
|
|
108
76
|
if (found.has(name)) return;
|
|
109
77
|
found.set(name, sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1);
|
|
@@ -312,7 +280,7 @@ const consumedNames = (context, file, ownerKey, depth, visiting) => {
|
|
|
312
280
|
const result = { all: false, names: new Set() };
|
|
313
281
|
for (const edge of context.edgesTo.get(file) ?? []) {
|
|
314
282
|
if (ownerKey && unitOfFile(context.graph, edge.from) === ownerKey) continue;
|
|
315
|
-
const bindings = bindingsOf(context
|
|
283
|
+
const bindings = bindingsOf(context, edge.edge.declaration);
|
|
316
284
|
if (bindings.all) { result.all = true; continue; }
|
|
317
285
|
for (const pair of bindings.pairs) consumeReexportedName(context, result, edge, pair, depth, visiting);
|
|
318
286
|
}
|
|
@@ -321,10 +289,10 @@ const consumedNames = (context, file, ownerKey, depth, visiting) => {
|
|
|
321
289
|
|
|
322
290
|
/** Adds to `used` the entry exports reached by a consumer outside the owner that imports a file the entry re-exports by name. */
|
|
323
291
|
const addReexportedUses = (context, key, publicEntry, used) => {
|
|
324
|
-
const {
|
|
292
|
+
const { graph } = context;
|
|
325
293
|
for (const edge of graph.edges) {
|
|
326
294
|
if (edge.from !== publicEntry || !edge.reexport || graph.unit(edge.to) !== key) continue;
|
|
327
|
-
const reexport = bindingsOf(
|
|
295
|
+
const reexport = bindingsOf(context, edge.edge.declaration);
|
|
328
296
|
const inner = consumedNames(context, edge.to, key, 0, new Set([publicEntry, edge.to]));
|
|
329
297
|
if (reexport.all) continue;
|
|
330
298
|
for (const pair of reexport.pairs) if (inner.all || inner.names.has(pair.imported)) used.names.add(pair.exported);
|
|
@@ -342,7 +310,7 @@ const unusedExportViolation = (publicEntry, name, line, owner) => ({
|
|
|
342
310
|
const addSiblingEntryUses = (context, publicEntry, declared, used) => {
|
|
343
311
|
for (const edge of context.edgesTo.get(publicEntry) ?? []) {
|
|
344
312
|
if (edge.reexport || edge.from === publicEntry || !declared.has(edge.from)) continue;
|
|
345
|
-
const bindings = bindingsOf(context
|
|
313
|
+
const bindings = bindingsOf(context, edge.edge.declaration);
|
|
346
314
|
if (bindings.all) used.all = true;
|
|
347
315
|
for (const pair of bindings.pairs) used.names.add(pair.imported);
|
|
348
316
|
}
|
|
@@ -382,7 +350,7 @@ const ownerExportFindings = (context, config) => {
|
|
|
382
350
|
|
|
383
351
|
export function checkDeadExports({ context, graph, config }) {
|
|
384
352
|
const edgesTo = edgeIndex([...graph.edges, ...testConsumerEdges({ context, graph, config })]);
|
|
385
|
-
const analysis = { ...context, graph, edgesTo };
|
|
353
|
+
const analysis = { ...context, graph, edgesTo, namespaceCheckers: new WeakMap(), namespaceUses: new WeakMap() };
|
|
386
354
|
const exports = ownerExportFindings(analysis, config);
|
|
387
355
|
const files = deadFiles(graph, config);
|
|
388
356
|
const dead = exports.violations.length;
|
|
@@ -2,22 +2,23 @@ import { machineKit } from './machine-ast.mjs';
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* R41 `default-deny-app-guard` (BE_DEFAULT_DENY). Every api app root provides its `APP_GUARD` entries in the order
|
|
5
|
-
* throttler -> CSRF origin guard -> AuthGuard, each exactly once, so no request reaches a handler before it was
|
|
6
|
-
* rate-limited, origin-checked and
|
|
5
|
+
* throttler -> CSRF origin guard -> CredentialGuard -> AuthGuard, each exactly once, so no request reaches a handler before it was
|
|
6
|
+
* rate-limited, origin-checked, credential-authenticated and admitted. A guard is classified by structure, never by name or folder alone
|
|
7
7
|
* (BE-CONVENTION 1.7 names the roles, not a library):
|
|
8
8
|
* - throttler: a class of @nestjs/throttler or one that extends it, or a guard that reads through a Reflector the
|
|
9
9
|
* per-door metadata (the `@RateLimit(tier)` decorator) that a SetMetadata call of its own capability writes;
|
|
10
10
|
* - CSRF origin guard: a guard declared in platform/http-security that reads the request origin or referer header;
|
|
11
|
+
* - CredentialGuard: the `CredentialGuard` class declared in domain/identity;
|
|
11
12
|
* - AuthGuard: the `AuthGuard` class declared in domain/identity.
|
|
12
13
|
* Other guards are permitted between them; a missing, repeated or reordered one of the three is a finding.
|
|
13
14
|
*/
|
|
14
15
|
export const DEFAULT_DENY_RULE_IDS = ['BE_DEFAULT_DENY'];
|
|
15
16
|
|
|
16
17
|
const RULE = 'BE_DEFAULT_DENY';
|
|
17
|
-
const ORDER = ['throttler', 'csrf', 'auth'];
|
|
18
|
+
const ORDER = ['throttler', 'csrf', 'credential', 'auth'];
|
|
18
19
|
const ORIGIN_HEADERS = new Set(['origin', 'referer', 'referrer']);
|
|
19
20
|
const METADATA_READERS = new Set(['get', 'getAll', 'getAllAndMerge', 'getAllAndOverride']);
|
|
20
|
-
const LABEL = { throttler: 'the throttler guard', csrf: 'the CSRF origin guard of platform/http-security', auth: 'AuthGuard of domain/identity' };
|
|
21
|
+
const LABEL = { throttler: 'the throttler guard', csrf: 'the CSRF origin guard of platform/http-security', credential: 'CredentialGuard of domain/identity', auth: 'AuthGuard of domain/identity' };
|
|
21
22
|
|
|
22
23
|
const keySymbol = (kit, checker, node) => (node ? kit.aliased(checker, kit.symbolAt(checker, node)) ?? null : null);
|
|
23
24
|
|
|
@@ -98,6 +99,7 @@ const classifyDeclaration = (kit, graph, keyCache, declaration) => {
|
|
|
98
99
|
if (readsDoorMetadata(kit, graph, keyCache, declaration, owner)) return 'throttler';
|
|
99
100
|
}
|
|
100
101
|
if (owner?.tier === 'domain' && owner.name === 'identity' && ts.isClassDeclaration(declaration) && declaration.name?.text === 'AuthGuard') return 'auth';
|
|
102
|
+
if (owner?.tier === 'domain' && owner.name === 'identity' && ts.isClassDeclaration(declaration) && declaration.name?.text === 'CredentialGuard') return 'credential';
|
|
101
103
|
return null;
|
|
102
104
|
};
|
|
103
105
|
|
|
@@ -117,7 +119,7 @@ const roleMessages = (app, roles, anchor) => {
|
|
|
117
119
|
const out = [];
|
|
118
120
|
for (const role of ORDER) {
|
|
119
121
|
const entries = roles.filter(item => item.role === role);
|
|
120
|
-
if (entries.length === 0) out.push({ node: anchor, message: `App ${app.name} does not provide ${LABEL[role]} as an APP_GUARD; an api app is denied by default: throttler, then CSRF origin guard, then AuthGuard.` });
|
|
122
|
+
if (entries.length === 0) out.push({ node: anchor, message: `App ${app.name} does not provide ${LABEL[role]} as an APP_GUARD; an api app is denied by default: throttler, then CSRF origin guard, then CredentialGuard, then AuthGuard.` });
|
|
121
123
|
else if (entries.length > 1) out.push({ node: entries[1].guard.node, message: `App ${app.name} provides ${LABEL[role]} more than once as an APP_GUARD.` });
|
|
122
124
|
}
|
|
123
125
|
return out;
|
|
@@ -128,7 +130,7 @@ const orderMessages = (app, roles) => {
|
|
|
128
130
|
if (!indexes.every(index => index >= 0)) return [];
|
|
129
131
|
const out = [];
|
|
130
132
|
for (let i = 1; i < indexes.length; i += 1) {
|
|
131
|
-
if (indexes[i] < indexes[i - 1]) out.push({ node: roles[indexes[i]].guard.node, message: `App ${app.name} provides ${LABEL[ORDER[i]]} before ${LABEL[ORDER[i - 1]]}; the APP_GUARD order is throttler, CSRF origin guard, AuthGuard.` });
|
|
133
|
+
if (indexes[i] < indexes[i - 1]) out.push({ node: roles[indexes[i]].guard.node, message: `App ${app.name} provides ${LABEL[ORDER[i]]} before ${LABEL[ORDER[i - 1]]}; the APP_GUARD order is throttler, CSRF origin guard, CredentialGuard, AuthGuard.` });
|
|
132
134
|
}
|
|
133
135
|
return out;
|
|
134
136
|
};
|
|
@@ -74,7 +74,7 @@ class WorldAnalysis {
|
|
|
74
74
|
let found = false;
|
|
75
75
|
const visit = node => {
|
|
76
76
|
if (!found && this.ts.isCallExpression(node) && this.ts.isIdentifier(node.expression) && node.expression.text === 'fetch'
|
|
77
|
-
&&
|
|
77
|
+
&& (checker.getSymbolAtLocation(node.expression)?.getDeclarations() ?? []).every(declaration => declaration.getSourceFile().isDeclarationFile)) found = true;
|
|
78
78
|
if (!found) this.ts.forEachChild(node, visit);
|
|
79
79
|
};
|
|
80
80
|
visit(sourceFile);
|