@starci/eslint-canon-fe 8.2.18 → 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 +15 -0
- package/README.md +19 -1
- package/callback-shape.mjs +344 -0
- package/client-boundary.mjs +1 -1
- package/docs/hooks-folder.md +1 -1
- package/docs/size-and-state-budget.md +6 -6
- package/drawing.mjs +65 -28
- 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 +430 -92
- package/runtime/knowledge/patterns/fe/folder.yaml +4 -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/cat-file.mjs +5 -0
- package/runtime/scripts/api/git/diff.mjs +5 -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 +103 -0
- package/runtime/scripts/hfs/architecture/backend.mjs +22 -10
- package/runtime/scripts/hfs/architecture/background-unowned.mjs +157 -41
- package/runtime/scripts/hfs/architecture/canon-clones.mjs +240 -0
- package/runtime/scripts/hfs/architecture/canon-jsx-clones.mjs +143 -0
- package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +6 -3
- package/runtime/scripts/hfs/architecture/clones.mjs +17 -8
- package/runtime/scripts/hfs/architecture/connection-map.mjs +14 -12
- package/runtime/scripts/hfs/architecture/context-map.mjs +25 -14
- package/runtime/scripts/hfs/architecture/context-owner.mjs +1 -2
- package/runtime/scripts/hfs/architecture/dead-exports-bindings.mjs +149 -0
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +23 -50
- package/runtime/scripts/hfs/architecture/default-deny.mjs +8 -6
- package/runtime/scripts/hfs/architecture/frontend-block-render.mjs +104 -0
- package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +30 -11
- 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/i18n-keys.mjs +16 -3
- package/runtime/scripts/hfs/architecture/machine-ast.mjs +17 -0
- package/runtime/scripts/hfs/architecture/module-per-transport.mjs +49 -25
- 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 +15 -7
- package/runtime/scripts/hfs/architecture/required-files.mjs +2 -1
- package/runtime/scripts/hfs/architecture/source-names.mjs +48 -3
- package/runtime/scripts/hfs/architecture/staged-consumers.mjs +37 -0
- package/runtime/scripts/hfs/architecture/supabase-ast.mjs +214 -17
- package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -6
- 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 +11 -4
- package/runtime/scripts/hfs/architecture/typescript.mjs +1 -8
- package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +9 -13
- 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 +91 -6
- package/runtime/scripts/hfs/path-findings.mjs +32 -2
- 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 +26 -0
- package/runtime/scripts/hfs/slot-imports.mjs +18 -3
- package/runtime/scripts/hfs/slot-required.mjs +13 -0
- package/runtime/scripts/hfs/slot-semantic-problems.mjs +2 -1
- 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 +23 -0
- package/runtime/scripts/lib/ts-ast.mjs +27 -2
- package/served-locale.mjs +80 -9
- package/size-and-state-budget.mjs +11 -8
- package/size-growth.mjs +10 -6
- package/swr-family.mjs +197 -0
- package/translation.mjs +135 -7
- package/transport.mjs +126 -59
|
@@ -174,6 +174,9 @@ rules:
|
|
|
174
174
|
component.tsx. Pure blocks, composites, branches and leaves use index.tsx only and never gain an
|
|
175
175
|
empty twin. classNames.ts is present only when the unit owns class strings. Budgets: component.tsx 300 lines,
|
|
176
176
|
connected index.tsx 200 lines, at most 6 data hooks and 6 useState per connected unit.
|
|
177
|
+
A composition-only feature may render an existing public pure component owner directly instead of creating a
|
|
178
|
+
forwarding component.tsx. The route-state overlay host of FE-BOUNDARY-5 renders components/branches/OverlayHost;
|
|
179
|
+
a connected block still requires its exact private Base twin.
|
|
177
180
|
rationale: |
|
|
178
181
|
Stable basenames make class names and the connected and presentational split discoverable without forwarding
|
|
179
182
|
twins, and budgets keep a unit reviewable.
|
|
@@ -303,6 +306,7 @@ rules:
|
|
|
303
306
|
envelope, the generated wire types (from be/contracts/, through the root codegen) and the axios calls live in
|
|
304
307
|
modules/api; the session lives in modules/auth; mutation feedback in modules/toast; environment reading in
|
|
305
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.
|
|
306
310
|
A helper name defined twice across hook files is a finding.
|
|
307
311
|
rationale: |
|
|
308
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
|
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// cat-file.mjs — `git cat-file <args>`: the type, size or content of repository objects (-e, -p, --batch).
|
|
2
|
+
import { runGit } from './lib.mjs';
|
|
3
|
+
|
|
4
|
+
/** The spawn result {status, stdout, stderr, error} of `git cat-file ...args`; options are runGit's (cwd or dir, config, timeout, maxBuffer, input, encoding, env, git). */
|
|
5
|
+
export const catFile = (args = [], options = {}) => runGit(['cat-file', ...args], options);
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// diff.mjs — `git diff <args>`: the changes between commits, the index and the working tree.
|
|
2
|
+
import { runGit } from './lib.mjs';
|
|
3
|
+
|
|
4
|
+
/** The spawn result {status, stdout, stderr, error} of `git diff ...args`; options are runGit's (cwd or dir, config, timeout, maxBuffer, input, encoding, env, git). */
|
|
5
|
+
export const diff = (args = [], options = {}) => runGit(['diff', ...args], options);
|
|
@@ -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
|
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { allowsFile } from '../allows.mjs';
|
|
3
|
+
import { relativePath, unwrapExpression } from './typescript.mjs';
|
|
4
|
+
import { accessName, sourceLocation } from '../../lib/ts-ast.mjs';
|
|
5
|
+
|
|
6
|
+
const PROCESS_MODULES = new Set(['process', 'node:process']);
|
|
7
|
+
const PROCESS_GLOBAL = new Set(['process']);
|
|
8
|
+
const GLOBAL_HOLDERS = new Set(['globalThis', 'global']);
|
|
9
|
+
const REFLECT_GLOBAL = new Set(['Reflect']);
|
|
10
|
+
|
|
11
|
+
function bindingName(ts, element) {
|
|
12
|
+
const name = element.propertyName ?? element.name;
|
|
13
|
+
if (ts.isIdentifier(name) || ts.isStringLiteralLike(name)) return name.text;
|
|
14
|
+
return ts.isComputedPropertyName(name) && ts.isStringLiteralLike(name.expression) ? name.expression.text : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A global name is not a same-named local variable or parameter. */
|
|
18
|
+
function isGlobalName(ts, checker, node, names) {
|
|
19
|
+
if (!ts.isIdentifier(node) || !names.has(node.text)) return false;
|
|
20
|
+
const declarations = checker?.getSymbolAtLocation(node)?.getDeclarations() ?? [];
|
|
21
|
+
return declarations.every(declaration => declaration.getSourceFile().isDeclarationFile);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function recordProcessBindings(ts, clause, add, report) {
|
|
25
|
+
if (clause?.name) add(clause.name);
|
|
26
|
+
const bindings = clause?.namedBindings;
|
|
27
|
+
if (bindings && ts.isNamespaceImport(bindings)) add(bindings.name);
|
|
28
|
+
if (!bindings || !ts.isNamedImports(bindings)) return;
|
|
29
|
+
for (const element of bindings.elements) {
|
|
30
|
+
const name = (element.propertyName ?? element.name).text;
|
|
31
|
+
if (name === 'env') report(element);
|
|
32
|
+
else if (name === 'default') add(element.name);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function processImportSymbols(ts, checker, sourceFile, report) {
|
|
37
|
+
const imports = new Set();
|
|
38
|
+
const add = name => {
|
|
39
|
+
const symbol = checker.getSymbolAtLocation(name);
|
|
40
|
+
if (symbol) imports.add(symbol);
|
|
41
|
+
};
|
|
42
|
+
for (const statement of sourceFile.statements) {
|
|
43
|
+
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteralLike(statement.moduleSpecifier)
|
|
44
|
+
|| !PROCESS_MODULES.has(statement.moduleSpecifier.text)) continue;
|
|
45
|
+
recordProcessBindings(ts, statement.importClause, add, report);
|
|
46
|
+
}
|
|
47
|
+
return imports;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Process bindings are resolved by symbol, so a nested parameter cannot masquerade as an imported process. */
|
|
51
|
+
function processAccess(ts, checker, sourceFile, report) {
|
|
52
|
+
const imports = processImportSymbols(ts, checker, sourceFile, report);
|
|
53
|
+
const isProcess = expression => {
|
|
54
|
+
const node = unwrapExpression(ts, expression);
|
|
55
|
+
if (!node) return false;
|
|
56
|
+
if (ts.isIdentifier(node)) return imports.has(checker.getSymbolAtLocation(node))
|
|
57
|
+
|| isGlobalName(ts, checker, node, PROCESS_GLOBAL);
|
|
58
|
+
return accessName(ts, node) === 'process'
|
|
59
|
+
&& isGlobalName(ts, checker, node.expression, GLOBAL_HOLDERS);
|
|
60
|
+
};
|
|
61
|
+
return node => {
|
|
62
|
+
if (accessName(ts, node) === 'env' && isProcess(node.expression)) report(node);
|
|
63
|
+
if (ts.isVariableDeclaration(node) && node.initializer && isProcess(node.initializer) && ts.isObjectBindingPattern(node.name)
|
|
64
|
+
&& node.name.elements.some(element => bindingName(ts, element) === 'env')) report(node);
|
|
65
|
+
if (ts.isCallExpression(node) && accessName(ts, node.expression) === 'get'
|
|
66
|
+
&& isGlobalName(ts, checker, node.expression.expression, REFLECT_GLOBAL)
|
|
67
|
+
&& isProcess(node.arguments[0]) && node.arguments[1] && ts.isStringLiteralLike(node.arguments[1]) && node.arguments[1].text === 'env') report(node);
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The declaration, rather than the caller's name, identifies EnvSource readers and configuration helpers. */
|
|
72
|
+
function appEnvironmentCall(config, ts, checker, call) {
|
|
73
|
+
const declaration = checker.getResolvedSignature(call)?.declaration;
|
|
74
|
+
if (!declaration) return false;
|
|
75
|
+
const relative = relativePath(config.root, declaration.getSourceFile().fileName);
|
|
76
|
+
if (ts.isMethodDeclaration(declaration) && ts.isClassDeclaration(declaration.parent) && declaration.parent.name?.text === 'EnvSource') {
|
|
77
|
+
return config.hfs.tierOf(relative) === 'platform' && path.posix.basename(config.hfs.ownerOf(relative)?.root ?? '') === 'config';
|
|
78
|
+
}
|
|
79
|
+
if (!ts.isFunctionDeclaration(declaration) && !ts.isArrowFunction(declaration) && !ts.isFunctionExpression(declaration)) return false;
|
|
80
|
+
return allowsFile(config.hfs, relative)?.entry === '<app>.env.ts'
|
|
81
|
+
|| (path.posix.basename(relative).endsWith('.config.ts') && config.hfs.slotOf(relative) !== null);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Composition groups and options consume parsed options; only the app's matching env role and main read them. */
|
|
85
|
+
export function checkApplicationEnvironment(config, context, sourceFile, fileName, violations) {
|
|
86
|
+
const role = allowsFile(config.hfs, relativePath(config.root, fileName));
|
|
87
|
+
if (role?.allowed && (role.entry === 'main.ts' || role.entry === '<app>.env.ts')) return;
|
|
88
|
+
const { ts } = context;
|
|
89
|
+
const checker = context.checkerFor(fileName);
|
|
90
|
+
const report = node => violations.push({
|
|
91
|
+
ruleId: 'BE_APP_COMPOSITION_ONLY',
|
|
92
|
+
path: relativePath(config.root, fileName),
|
|
93
|
+
...sourceLocation(sourceFile, node),
|
|
94
|
+
message: "Application environment reads live in main.ts or the matching <app>.env.ts. Options files and compose/<area>.ts receive the typed result of read<App>Env(env: EnvSource).",
|
|
95
|
+
});
|
|
96
|
+
const checkProcess = processAccess(ts, checker, sourceFile, report);
|
|
97
|
+
const visit = node => {
|
|
98
|
+
checkProcess(node);
|
|
99
|
+
if (ts.isCallExpression(node) && appEnvironmentCall(config, ts, checker, node)) report(node);
|
|
100
|
+
ts.forEachChild(node, visit);
|
|
101
|
+
};
|
|
102
|
+
visit(sourceFile);
|
|
103
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { isInside } from './config.mjs';
|
|
3
|
-
import { slotAdmitsFile } from '../allows.mjs';
|
|
3
|
+
import { allowsFile, slotAdmitsFile } from '../allows.mjs';
|
|
4
4
|
import { reachableViolation, relativePath } from './typescript.mjs';
|
|
5
|
-
import {
|
|
5
|
+
import { checkApplicationEnvironment } from './backend-app-files.mjs';
|
|
6
|
+
import { accessName, sourceLocation } from '../../lib/ts-ast.mjs';
|
|
6
7
|
import { byCodeUnit } from '../../lib/list.mjs';
|
|
7
8
|
|
|
8
9
|
const FORBIDDEN_APP_ROLE = /(?:^|\.)(?:service|provider|providers|resolver|controller|handler|repository|entity|use-case|command|query|listener|consumer|processor)\.[cm]?[jt]sx?$/i;
|
|
@@ -176,15 +177,10 @@ function requiredSpecifier(ts, call) {
|
|
|
176
177
|
: null;
|
|
177
178
|
}
|
|
178
179
|
|
|
179
|
-
const requiredMemberSelection = (ts, node) => {
|
|
180
|
-
if (ts.isPropertyAccessExpression(node)) return node.name.text;
|
|
181
|
-
return ts.isStringLiteralLike(node.argumentExpression) ? node.argumentExpression.text : null;
|
|
182
|
-
};
|
|
183
|
-
|
|
184
180
|
function requiredMemberEvidence(ts, node, found) {
|
|
185
181
|
if (!(ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) || !ts.isCallExpression(node.expression)) return;
|
|
186
182
|
const specifier = requiredSpecifier(ts, node.expression);
|
|
187
|
-
const selected =
|
|
183
|
+
const selected = accessName(ts, node);
|
|
188
184
|
const member = ts.isPropertyAccessExpression(node) ? node.name : node.argumentExpression;
|
|
189
185
|
if (specifier && TRANSPORT_PACKAGES.test(specifier)) {
|
|
190
186
|
found.push({ node: member, specifier, detail: `${selected ?? 'computed member'} from ${specifier}` });
|
|
@@ -376,11 +372,23 @@ function checkApplicationComposition(config, ts, sourceFile, fileName, app, viol
|
|
|
376
372
|
path: relativePath(config.root, fileName),
|
|
377
373
|
line: 1,
|
|
378
374
|
column: 1,
|
|
379
|
-
message: `Application source ${app.relative} is not a file its app slot requires or allows (main.ts, app.module.ts, <app>.options.ts).`,
|
|
375
|
+
message: `Application source ${app.relative} is not a file its app slot requires or allows (main.ts, app.module.ts, <app>.env.ts, <app>.options.ts, compose/<area>.ts).`,
|
|
380
376
|
});
|
|
381
377
|
}
|
|
382
378
|
}
|
|
383
379
|
|
|
380
|
+
/** The app's environment parser never depends on features, even through a type import or an internal barrel. */
|
|
381
|
+
function checkApplicationEnvEdges(config, context, fileName, violations) {
|
|
382
|
+
const role = allowsFile(config.hfs, relativePath(config.root, fileName));
|
|
383
|
+
if (!role?.allowed || role.entry !== '<app>.env.ts') return;
|
|
384
|
+
const isFeature = target => config.hfs.tierOf(relativePath(config.root, target)) === 'feature';
|
|
385
|
+
for (const edge of context.edges.get(fileName) ?? []) {
|
|
386
|
+
const featureChain = reachableViolation(context.edges, edge, isFeature);
|
|
387
|
+
if (featureChain) violations.push(finding(config, edge, 'BE_APP_COMPOSITION_ONLY',
|
|
388
|
+
'The app environment file holds typed, fail-fast environment parsing only; it cannot depend on features, including through a type import or an internal barrel.', featureChain));
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
384
392
|
function checkApplicationTransport(config, context, sourceFile, fileName, violations) {
|
|
385
393
|
for (const evidence of transportFrameworkEvidence(context.ts, sourceFile, context.checkerFor(fileName))) {
|
|
386
394
|
violations.push({
|
|
@@ -409,7 +417,11 @@ function checkBackendSource(config, context, sourceFiles, roots, sourceFile, vio
|
|
|
409
417
|
const fromFeatures = insideAny(featureRoots, fileName);
|
|
410
418
|
const fromApplication = fromFeatures && insideFeatureLayer(featureRoots, fileName, 'application');
|
|
411
419
|
const app = !fromModules && !fromFeatures ? appSource(config, fileName, nonAppRoots) : null;
|
|
412
|
-
if (app)
|
|
420
|
+
if (app) {
|
|
421
|
+
checkApplicationComposition(config, context.ts, sourceFile, fileName, app, violations);
|
|
422
|
+
checkApplicationEnvEdges(config, context, fileName, violations);
|
|
423
|
+
checkApplicationEnvironment(config, context, sourceFile, fileName, violations);
|
|
424
|
+
}
|
|
413
425
|
if (!fromModules && !fromFeatures) return;
|
|
414
426
|
if (fromApplication) checkApplicationTransport(config, context, sourceFile, fileName, violations);
|
|
415
427
|
if (fromApplication) checkFeatureTransportEdges(config, context, sourceFiles, featureRoots, fileName, violations);
|
|
@@ -5,30 +5,45 @@ import { machineKit } from './machine-ast.mjs';
|
|
|
5
5
|
* R46 `background-unowned` (BE_BACKGROUND_UNOWNED, BE-CONVENTION 1.6 and 1.13). Background work is a transport that only
|
|
6
6
|
* a worker or api app runs (an api app composes the message transport of its own service; a separate worker is opt-in):
|
|
7
7
|
*
|
|
8
|
-
* -
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* consumer: from the processor or consumer files and everything they import, and from the files of the feature that holds them
|
|
15
|
-
* (its handlers are registered with the command bus, not imported by the processor). Mechanisms of platform are exempt.
|
|
8
|
+
* - a processor, consumer, scheduler, saga or event handler is composed by an app of kind `worker` or `api`: its file is
|
|
9
|
+
* reachable, through runtime imports, from an app root declared in hfs.json. Context comes from its file role or framework
|
|
10
|
+
* decorators, never a method name. Infrastructure restrictions on scheduler libraries remain R90 BE_INFRA_OWNER's concern;
|
|
11
|
+
* - methods in those contexts, and methods they call transitively, are reachable from a composed background entry point:
|
|
12
|
+
* its imports and the files of its own feature (command handlers are registered with the bus). Platform mechanisms are
|
|
13
|
+
* exempt. Resolvers and controllers are user entry points and never background, even when called by a background method.
|
|
16
14
|
*
|
|
17
|
-
*
|
|
18
|
-
* not that it does.
|
|
15
|
+
* Classification follows statically resolved calls; ownership follows runtime imports, re-exports and the entry point's feature.
|
|
19
16
|
*/
|
|
20
17
|
export const BACKGROUND_UNOWNED_RULE_IDS = ['BE_BACKGROUND_UNOWNED'];
|
|
21
18
|
|
|
22
19
|
const RULE = 'BE_BACKGROUND_UNOWNED';
|
|
23
|
-
const BACKGROUND_ROLES = new Set(['processor', 'consumer']);
|
|
24
|
-
|
|
25
|
-
const
|
|
20
|
+
const BACKGROUND_ROLES = new Set(['processor', 'consumer', 'scheduler', 'saga', 'event-handler']);
|
|
21
|
+
const USER_ROLES = new Set(['resolver', 'controller']);
|
|
22
|
+
const CLASS_DECORATORS = new Map([
|
|
23
|
+
['@nestjs/bull:Processor', 'processor'], ['@nestjs/bullmq:Processor', 'processor'],
|
|
24
|
+
['@nestjs/cqrs:EventsHandler', 'event-handler'],
|
|
25
|
+
]);
|
|
26
|
+
const METHOD_DECORATORS = new Map([
|
|
27
|
+
['@nestjs/cqrs:Saga', 'saga'], ['@nestjs/event-emitter:OnEvent', 'event-handler'],
|
|
28
|
+
...['Cron', 'Interval', 'Timeout'].map(name => [`@nestjs/schedule:${name}`, 'scheduler']),
|
|
29
|
+
]);
|
|
30
|
+
const USER_DECORATORS = new Map([['@nestjs/graphql:Resolver', 'resolver'], ['@nestjs/common:Controller', 'controller']]);
|
|
26
31
|
|
|
27
32
|
const roleOf = rel => {
|
|
28
33
|
const parts = path.posix.basename(rel).split('.');
|
|
29
|
-
return parts.length >= 3 && parts.at(-1) === 'ts'
|
|
34
|
+
return parts.length >= 3 && parts.at(-1) === 'ts' ? parts.at(-2) : null;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const decoratedRole = (kit, checker, node, roles) => {
|
|
38
|
+
for (const decorator of kit.decorators(node)) {
|
|
39
|
+
const expression = decorator.expression;
|
|
40
|
+
const callee = kit.ts.isCallExpression(expression) ? expression.expression : expression;
|
|
41
|
+
const binding = kit.importBinding(checker, callee);
|
|
42
|
+
const role = binding ? roles.get(`${binding.module}:${binding.name}`) : null;
|
|
43
|
+
if (role) return role;
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
30
46
|
};
|
|
31
|
-
const isBackgroundName = name => BACKGROUND_WORDS.some(word => name === word || (name.startsWith(word) && /[A-Z]/u.test(name[word.length] ?? '')));
|
|
32
47
|
|
|
33
48
|
const adjacencyOf = edges => {
|
|
34
49
|
const adjacency = new Map();
|
|
@@ -60,19 +75,16 @@ const appRootsOf = (apps, graph) => {
|
|
|
60
75
|
return { workers, composed: [...workers, ...services] };
|
|
61
76
|
};
|
|
62
77
|
|
|
63
|
-
const backgroundFilesOf = graph => [...graph.files.values()].filter(file => roleOf(file.rel) && file.tier === 'feature');
|
|
64
|
-
|
|
65
78
|
const reportUncomposedFiles = (background, composed, workerRoots, report) => {
|
|
66
|
-
for (const file of background) {
|
|
67
|
-
if (composed.has(file.rel)) continue;
|
|
68
|
-
|
|
69
|
-
report(file, file.sourceFile, `${file.rel} is a ${role} that no service app composes${workerRoots.length ? '' : ' (hfs.json declares no worker app)'}; a ${role} runs only when the ${role === 'processor' ? 'job' : 'message'} module of its feature is imported by the root module of an app of kind worker or api.`, { role });
|
|
79
|
+
for (const { file, role } of background) {
|
|
80
|
+
if (file.tier === 'platform' || composed.has(file.rel)) continue;
|
|
81
|
+
report(file, file.sourceFile, `${file.rel} is a ${role} that no service app composes${workerRoots.length ? '' : ' (hfs.json declares no worker app)'}; its module must be imported by the root module of an app of kind worker or api.`, { role });
|
|
70
82
|
}
|
|
71
83
|
};
|
|
72
84
|
|
|
73
85
|
const rootsOfRunningFeatures = (running, files) => {
|
|
74
86
|
const roots = new Set();
|
|
75
|
-
for (const file of running) {
|
|
87
|
+
for (const { file } of running) {
|
|
76
88
|
roots.add(file.rel);
|
|
77
89
|
const owner = file.owner?.root;
|
|
78
90
|
if (owner) for (const other of files.values()) if (other.owner?.root === owner) roots.add(other.rel);
|
|
@@ -80,22 +92,125 @@ const rootsOfRunningFeatures = (running, files) => {
|
|
|
80
92
|
return roots;
|
|
81
93
|
};
|
|
82
94
|
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
return
|
|
87
|
-
}
|
|
95
|
+
const callableKey = (kit, node) => `${kit.graphPath(node)}:${node.getStart()}`;
|
|
96
|
+
const callableBody = (ts, declaration) => {
|
|
97
|
+
const node = declaration.initializer ?? declaration;
|
|
98
|
+
return ts.isFunctionLike(node) && node.body ? node : null;
|
|
99
|
+
};
|
|
100
|
+
const methodMember = (ts, node) => {
|
|
101
|
+
if (ts.isMethodDeclaration(node)) return node;
|
|
102
|
+
return ts.isPropertyDeclaration(node.parent) && node.parent.initializer === node ? node.parent : null;
|
|
103
|
+
};
|
|
88
104
|
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
105
|
+
const isUserClass = (kit, checker, node) => USER_ROLES.has(roleOf(kit.graphPath(node) ?? ''))
|
|
106
|
+
|| Boolean(decoratedRole(kit, checker, node, USER_DECORATORS));
|
|
107
|
+
|
|
108
|
+
const seedClass = (scope, file, checker, node, role) => {
|
|
109
|
+
scope.background.set(file.rel, { file, role });
|
|
110
|
+
scope.contextFiles.add(file.rel);
|
|
111
|
+
for (const symbol of checker.getTypeAtLocation(node).getProperties()) {
|
|
112
|
+
for (const declaration of symbol.declarations ?? []) {
|
|
113
|
+
const body = callableBody(scope.kit.ts, declaration);
|
|
114
|
+
if (body) scope.inherited.push(body);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const classContext = (scope, file, checker, node, state) => {
|
|
120
|
+
const user = state.user || isUserClass(scope.kit, checker, node);
|
|
121
|
+
if (!state.nested && user) scope.userFiles.add(file.rel);
|
|
122
|
+
let classRole = null;
|
|
123
|
+
if (!state.nested && !user) {
|
|
124
|
+
const fileRole = BACKGROUND_ROLES.has(state.fileRole) ? state.fileRole : null;
|
|
125
|
+
classRole = decoratedRole(scope.kit, checker, node, CLASS_DECORATORS) ?? fileRole;
|
|
126
|
+
}
|
|
127
|
+
if (classRole) seedClass(scope, file, checker, node, classRole);
|
|
128
|
+
return { ...state, user, classRole };
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const recordCallable = (scope, file, checker, node, state) => {
|
|
132
|
+
const { kit } = scope;
|
|
133
|
+
const member = methodMember(kit.ts, node);
|
|
134
|
+
const canSeed = member && !state.user && !state.nested;
|
|
135
|
+
const role = canSeed ? decoratedRole(kit, checker, member, METHOD_DECORATORS) ?? state.classRole : null;
|
|
136
|
+
const entry = { file, node, checker, user: state.user, member, name: member ? kit.propertyNameText(member.name) : null };
|
|
137
|
+
scope.callables.set(callableKey(kit, node), entry);
|
|
138
|
+
if (role) {
|
|
139
|
+
scope.background.set(file.rel, { file, role });
|
|
140
|
+
scope.contextFiles.add(file.rel);
|
|
141
|
+
scope.seeds.push(entry);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const indexCallables = (scope, file, checker, node, state) => {
|
|
146
|
+
const { ts } = scope.kit;
|
|
147
|
+
if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) state = classContext(scope, file, checker, node, state);
|
|
148
|
+
const callable = ts.isFunctionLike(node);
|
|
149
|
+
if (callable && node.body) recordCallable(scope, file, checker, node, state);
|
|
150
|
+
const childState = { ...state, nested: state.nested || callable };
|
|
151
|
+
ts.forEachChild(node, child => indexCallables(scope, file, checker, child, childState));
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/** Index callable bodies once; a resolver/controller is a barrier to background propagation. */
|
|
155
|
+
const backgroundContextOf = (graph, kit) => {
|
|
156
|
+
const callables = new Map();
|
|
157
|
+
const background = new Map();
|
|
158
|
+
const seeds = [];
|
|
159
|
+
const inherited = [];
|
|
160
|
+
const scope = { kit, callables, background, seeds, inherited, userFiles: new Set(), contextFiles: new Set() };
|
|
95
161
|
for (const file of graph.files.values()) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
162
|
+
const checker = kit.checkerOf(file.sourceFile);
|
|
163
|
+
const fileRole = roleOf(file.rel);
|
|
164
|
+
if (BACKGROUND_ROLES.has(fileRole)) background.set(file.rel, { file, role: fileRole });
|
|
165
|
+
indexCallables(scope, file, checker, file.sourceFile, { fileRole, classRole: null, user: USER_ROLES.has(fileRole), nested: false });
|
|
166
|
+
}
|
|
167
|
+
// Request context overrides a file suffix; mixed files can still hold a separate background class.
|
|
168
|
+
for (const rel of scope.userFiles) if (!scope.contextFiles.has(rel)) background.delete(rel);
|
|
169
|
+
for (const body of inherited) {
|
|
170
|
+
const entry = callables.get(callableKey(kit, body));
|
|
171
|
+
if (entry) seeds.push(entry);
|
|
172
|
+
}
|
|
173
|
+
return { callables, background: [...background.values()], seeds };
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const calledBodies = (kit, checker, call) => {
|
|
177
|
+
const { ts } = kit;
|
|
178
|
+
const expression = call.expression;
|
|
179
|
+
if (ts.isPropertyAccessExpression(expression) || ts.isElementAccessExpression(expression)) {
|
|
180
|
+
const receiver = checker.getNonNullableType(checker.getTypeAtLocation(expression.expression));
|
|
181
|
+
if (receiver.getSymbol()?.declarations?.some(declaration => isUserClass(kit, checker, declaration))) return [];
|
|
182
|
+
}
|
|
183
|
+
const target = ts.isPropertyAccessExpression(expression) ? expression.name : expression;
|
|
184
|
+
const declarations = [...kit.declarationsOf(checker, target)];
|
|
185
|
+
const signature = checker.getResolvedSignature(call)?.declaration;
|
|
186
|
+
if (signature) declarations.push(signature);
|
|
187
|
+
return declarations.map(declaration => callableBody(ts, declaration)).filter(Boolean);
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const checkBackgroundMethods = ({ kit, context, reachable, report }) => {
|
|
191
|
+
const { callables, seeds } = context;
|
|
192
|
+
const seen = new Set();
|
|
193
|
+
const queue = [...seeds];
|
|
194
|
+
let methods = 0;
|
|
195
|
+
while (queue.length) {
|
|
196
|
+
const entry = queue.pop();
|
|
197
|
+
const { file, node, checker, user, member, name } = entry;
|
|
198
|
+
const key = callableKey(kit, node);
|
|
199
|
+
if (user || seen.has(key)) continue;
|
|
200
|
+
seen.add(key);
|
|
201
|
+
if (name && file.tier !== 'platform') {
|
|
202
|
+
methods += 1;
|
|
203
|
+
if (!reachable.has(file.rel)) report(file, member.name, `${name} is background work that no background entry point composed by a worker or api app can reach. Compose the processor, consumer, scheduler, saga or event handler that runs it in a worker or api app.`, { method: name });
|
|
204
|
+
}
|
|
205
|
+
kit.walk(node.body, inner => {
|
|
206
|
+
// Nested bodies run only through a resolved call or when passed as a callback.
|
|
207
|
+
if (kit.ts.isFunctionLike(inner) || kit.ts.isClassDeclaration(inner) || kit.ts.isClassExpression(inner)) return false;
|
|
208
|
+
if (!kit.ts.isCallExpression(inner)) return;
|
|
209
|
+
const callbacks = inner.arguments.filter(argument => kit.ts.isArrowFunction(argument) || kit.ts.isFunctionExpression(argument));
|
|
210
|
+
for (const body of [...calledBodies(kit, checker, inner), ...callbacks]) {
|
|
211
|
+
const called = callables.get(callableKey(kit, body));
|
|
212
|
+
if (called) queue.push(called);
|
|
213
|
+
}
|
|
99
214
|
});
|
|
100
215
|
}
|
|
101
216
|
return methods;
|
|
@@ -104,7 +219,7 @@ const checkBackgroundMethods = ({ graph, kit, ts, reachable, report }) => {
|
|
|
104
219
|
export function checkBackgroundUnowned(input) {
|
|
105
220
|
const { graph } = input;
|
|
106
221
|
const kit = machineKit(input);
|
|
107
|
-
const {
|
|
222
|
+
const { resolver } = kit;
|
|
108
223
|
const violations = [];
|
|
109
224
|
const report = (file, node, message, extra = {}) => violations.push({ ruleId: RULE, path: file.rel, ...kit.at(file.rel, file.sourceFile, node), message, ...extra });
|
|
110
225
|
|
|
@@ -112,13 +227,14 @@ export function checkBackgroundUnowned(input) {
|
|
|
112
227
|
const closure = starts => closureOf(adjacency, starts);
|
|
113
228
|
const { workers: workerRoots, composed: appRoots } = appRootsOf(resolver.repo.apps, graph);
|
|
114
229
|
const composed = closure(appRoots);
|
|
115
|
-
const
|
|
116
|
-
const
|
|
230
|
+
const context = backgroundContextOf(graph, kit);
|
|
231
|
+
const { background } = context;
|
|
232
|
+
const running = background.filter(({ file }) => composed.has(file.rel));
|
|
117
233
|
reportUncomposedFiles(background, composed, workerRoots, report);
|
|
118
234
|
|
|
119
|
-
// Everything a composed
|
|
235
|
+
// Everything a composed background entry point can reach: its imports and its own feature's command handlers.
|
|
120
236
|
const roots = rootsOfRunningFeatures(running, graph.files);
|
|
121
237
|
const reachable = closure([...roots]);
|
|
122
|
-
const methods = checkBackgroundMethods({
|
|
238
|
+
const methods = checkBackgroundMethods({ kit, context, reachable, report });
|
|
123
239
|
return { violations, coverage: { status: 'checked', workers: workerRoots.length, jobsAndConsumers: background.length, composed: running.length, backgroundMethods: methods } };
|
|
124
240
|
}
|