@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.
Files changed (69) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/callback-shape.mjs +344 -0
  3. package/client-boundary.mjs +1 -1
  4. package/docs/hooks-folder.md +1 -1
  5. package/env-owner.mjs +120 -9
  6. package/function-budget.mjs +49 -0
  7. package/hooks-folder.mjs +149 -18
  8. package/icon.mjs +67 -0
  9. package/index.mjs +47 -0
  10. package/lib/own-hook-origin.mjs +53 -0
  11. package/lib/program-for.mjs +22 -0
  12. package/lib/pure-owner-helper.mjs +148 -0
  13. package/lib/redux-hook-origin.mjs +44 -0
  14. package/next-conventions.mjs +38 -35
  15. package/package.json +1 -1
  16. package/runtime/knowledge/hfs/canon-pins.yaml +11 -11
  17. package/runtime/knowledge/hfs/fe-stack.yaml +22 -1
  18. package/runtime/knowledge/hfs/slots.yaml +344 -62
  19. package/runtime/knowledge/patterns/fe/folder.yaml +1 -0
  20. package/runtime/knowledge/sonar-gate.yaml +1 -1
  21. package/runtime/modules/models/runtimes.yaml +1 -1
  22. package/runtime/packages/hfs/templates/app/package-scripts/package.json +1 -0
  23. package/runtime/scripts/api/git/lib.mjs +3 -0
  24. package/runtime/scripts/api/git/merge-base.mjs +9 -0
  25. package/runtime/scripts/api/git/recorded-lines.mjs +54 -8
  26. package/runtime/scripts/hfs/architecture/backend-app-files.mjs +1 -8
  27. package/runtime/scripts/hfs/architecture/backend.mjs +2 -7
  28. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +6 -3
  29. package/runtime/scripts/hfs/architecture/dead-exports-bindings.mjs +149 -0
  30. package/runtime/scripts/hfs/architecture/dead-exports.mjs +9 -41
  31. package/runtime/scripts/hfs/architecture/default-deny.mjs +8 -6
  32. package/runtime/scripts/hfs/architecture/frontend-world.mjs +1 -1
  33. package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +153 -0
  34. package/runtime/scripts/hfs/architecture/hfs.mjs +3 -3
  35. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +28 -13
  36. package/runtime/scripts/hfs/architecture/next-data-inference.mjs +176 -0
  37. package/runtime/scripts/hfs/architecture/next-data-symbols.mjs +26 -0
  38. package/runtime/scripts/hfs/architecture/next-data.mjs +31 -23
  39. package/runtime/scripts/hfs/architecture/reachability.mjs +14 -0
  40. package/runtime/scripts/hfs/architecture/register-once.mjs +2 -2
  41. package/runtime/scripts/hfs/architecture/source-names.mjs +48 -3
  42. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +211 -11
  43. package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -1
  44. package/runtime/scripts/hfs/architecture/supabase.mjs +59 -21
  45. package/runtime/scripts/hfs/architecture/surface.mjs +2 -1
  46. package/runtime/scripts/hfs/architecture/tiers.mjs +3 -3
  47. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +8 -2
  48. package/runtime/scripts/hfs/declaration-shape.mjs +39 -3
  49. package/runtime/scripts/hfs/edition-slots.mjs +2 -2
  50. package/runtime/scripts/hfs/manifest-shape.mjs +67 -7
  51. package/runtime/scripts/hfs/project-rule.mjs +3 -1
  52. package/runtime/scripts/hfs/rule-catalog.mjs +156 -11
  53. package/runtime/scripts/hfs/rule-params-shape.mjs +22 -10
  54. package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
  55. package/runtime/scripts/hfs/slot-imports.mjs +18 -5
  56. package/runtime/scripts/hfs/slots.mjs +1 -0
  57. package/runtime/scripts/lib/env.mjs +20 -0
  58. package/runtime/scripts/lib/event-contract.mjs +107 -0
  59. package/runtime/scripts/lib/json-clone.mjs +61 -0
  60. package/runtime/scripts/lib/list.mjs +4 -0
  61. package/runtime/scripts/lib/secret-patterns.mjs +60 -5
  62. package/runtime/scripts/lib/stack-services.mjs +1 -1
  63. package/runtime/scripts/lib/ts-analysis.mjs +5 -2
  64. package/runtime/scripts/lib/ts-ast.mjs +27 -2
  65. package/served-locale.mjs +80 -9
  66. package/size-growth.mjs +10 -6
  67. package/swr-family.mjs +197 -0
  68. package/translation.mjs +135 -7
  69. 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 of a file at a git revision, or null when the file did not exist there or git is absent.
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} ref - The revision, `HEAD` by default (the parent of the commit being made).
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, ref = 'HEAD') => {
15
- const r = runGit(['show', `${ref}:./${path.basename(filename)}`], { dir: path.dirname(filename), maxBuffer: 64 * 1024 * 1024 });
16
- // no repository, no such revision, or a new file: all mean there is no recorded size to compare with
17
- if (r.error || r.status !== 0) return null;
18
- return lineCount(r.stdout);
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 = requiredMemberSelection(ts, node);
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 first statement is the `"use client"` directive, over runtime edges only (a type-only import vanishes at build), and
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 first = file.sourceFile.statements[0];
26
- return Boolean(first) && ts.isExpressionStatement(first) && ts.isStringLiteral(first.expression) && first.expression.text === 'use client';
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, `import x = require`, a dynamic import()/require(), an `export *` or `export * as` of the entry make
21
- * every name used. A file outside the owner that re-exports the name (`export { n as m } from`) uses it when `m` is itself
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.ts, edge.edge.declaration);
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 { ts, graph } = context;
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(ts, edge.edge.declaration);
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.ts, edge.edge.declaration);
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 authenticated. A guard is classified by structure, never by name or folder alone
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
- && !checker.getSymbolAtLocation(node.expression)) found = true;
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);