@starci/eslint-canon-fe 7.0.0 → 7.1.1
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 +10 -0
- package/docs/transport.md +4 -4
- package/index.mjs +2 -0
- package/lib/why.mjs +3 -3
- package/package.json +1 -1
- package/project-graph.mjs +23 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +170 -0
- package/runtime/knowledge/patterns/fe/folder.yaml +309 -0
- package/runtime/knowledge/sonar-gate.yaml +86 -0
- package/runtime/packages/hfs/templates/be/package-scripts/package.json +19 -0
- package/runtime/packages/hfs/templates/fe/package-scripts/package.json +13 -0
- package/runtime/scripts/checks/architecture/backend.mjs +350 -0
- package/runtime/scripts/checks/architecture/background-unowned.mjs +107 -0
- package/runtime/scripts/checks/architecture/client-reaches-server.mjs +94 -0
- package/runtime/scripts/checks/architecture/clones.mjs +200 -0
- package/runtime/scripts/checks/architecture/config-unread.mjs +35 -0
- package/runtime/scripts/checks/architecture/config.mjs +310 -0
- package/runtime/scripts/checks/architecture/connection-map.mjs +208 -0
- package/runtime/scripts/checks/architecture/constructor-deps.mjs +100 -0
- package/runtime/scripts/checks/architecture/contract-fixture-guard.mjs +128 -0
- package/runtime/scripts/checks/architecture/contracts.mjs +792 -0
- package/runtime/scripts/checks/architecture/cross-app-duplicate.mjs +73 -0
- package/runtime/scripts/checks/architecture/dead-exports.mjs +265 -0
- package/runtime/scripts/checks/architecture/default-deny.mjs +129 -0
- package/runtime/scripts/checks/architecture/doc-language.mjs +39 -0
- package/runtime/scripts/checks/architecture/entrypoint.mjs +57 -0
- package/runtime/scripts/checks/architecture/error-codes.mjs +45 -0
- package/runtime/scripts/checks/architecture/error-masked.mjs +60 -0
- package/runtime/scripts/checks/architecture/fe-slot-allows.mjs +38 -0
- package/runtime/scripts/checks/architecture/feature-shape.mjs +49 -0
- package/runtime/scripts/checks/architecture/framework-pinned.mjs +83 -0
- package/runtime/scripts/checks/architecture/frontend.mjs +995 -0
- package/runtime/scripts/checks/architecture/hfs-graph.mjs +61 -0
- package/runtime/scripts/checks/architecture/hfs.mjs +521 -0
- package/runtime/scripts/checks/architecture/hooks-are-hooks.mjs +88 -0
- package/runtime/scripts/checks/architecture/i18n-keys.mjs +146 -0
- package/runtime/scripts/checks/architecture/index.mjs +321 -0
- package/runtime/scripts/checks/architecture/injection-token-exported.mjs +62 -0
- package/runtime/scripts/checks/architecture/machine-ast.mjs +138 -0
- package/runtime/scripts/checks/architecture/module-per-transport.mjs +130 -0
- package/runtime/scripts/checks/architecture/next-data.mjs +775 -0
- package/runtime/scripts/checks/architecture/owners.mjs +95 -0
- package/runtime/scripts/checks/architecture/package-shape.mjs +63 -0
- package/runtime/scripts/checks/architecture/reachability.mjs +233 -0
- package/runtime/scripts/checks/architecture/register-once.mjs +141 -0
- package/runtime/scripts/checks/architecture/registration.mjs +319 -0
- package/runtime/scripts/checks/architecture/required-files.mjs +172 -0
- package/runtime/scripts/checks/architecture/route-files-thin.mjs +97 -0
- package/runtime/scripts/checks/architecture/schema-owner.mjs +261 -0
- package/runtime/scripts/checks/architecture/source-names.mjs +607 -0
- package/runtime/scripts/checks/architecture/sql-owner.mjs +142 -0
- package/runtime/scripts/checks/architecture/sql-tokens.mjs +327 -0
- package/runtime/scripts/checks/architecture/surface.mjs +91 -0
- package/runtime/scripts/checks/architecture/symbols.mjs +193 -0
- package/runtime/scripts/checks/architecture/test-world-files.mjs +163 -0
- package/runtime/scripts/checks/architecture/tiers.mjs +130 -0
- package/runtime/scripts/checks/architecture/transport-owner.mjs +112 -0
- package/runtime/scripts/checks/architecture/typescript.mjs +573 -0
- package/runtime/scripts/checks/architecture/unit-spec-providers.mjs +122 -0
- package/runtime/scripts/checks/common.mjs +44 -0
- package/runtime/scripts/checks/typescript-programs.mjs +82 -0
- package/runtime/scripts/lib/hfs-path-findings.mjs +84 -0
- package/runtime/scripts/lib/hfs-rules/fe-no-tests.mjs +47 -0
- package/runtime/scripts/lib/hfs-rules/read.mjs +28 -0
- package/runtime/scripts/lib/hfs-rules/secrets.mjs +54 -0
- package/runtime/scripts/lib/hfs-rules/spec-placement.mjs +29 -0
- package/runtime/scripts/lib/json.mjs +22 -0
- package/runtime/scripts/lib/project-graph.mjs +0 -0
- package/runtime/scripts/lib/project-rule.mjs +39 -0
- package/runtime/scripts/lib/repo-identity.mjs +50 -0
- package/runtime/scripts/lib/secret-patterns.mjs +44 -0
- package/runtime/scripts/lib/stack-services.mjs +217 -0
- package/runtime/scripts/lib/test-secrets.mjs +120 -0
- package/transport.mjs +9 -9
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
schema: starci/sonar-gate@1
|
|
2
|
+
id: sonar-gate
|
|
3
|
+
title: The Sonar quality gate every product is held to
|
|
4
|
+
purpose: |
|
|
5
|
+
The one place the Sonar thresholds live. A product repository never restates them: its
|
|
6
|
+
.starcistacks/application-stacks.yaml `services.sonar.qualityGate` names the gate below, and
|
|
7
|
+
scripts/checks/sonar-local.mjs (1) makes the local SonarQube's gate of that name carry exactly these
|
|
8
|
+
conditions and selects it for the project, and (2) judges an op's slice - the lines it changed - against
|
|
9
|
+
the same numbers. Code-writing ops cannot settle done while the slice is red
|
|
10
|
+
(scripts/kernel/sonar-settle.mjs). Changing a number here changes every repository on the next scan.
|
|
11
|
+
The gate has two parts: `newCode` (the recent work: duplication, blocker/critical issues, hotspots) and
|
|
12
|
+
`overall` (the whole code: no imported HFS, ESLint or stylelint finding, no duplicated-lines excess, no cognitive-
|
|
13
|
+
complexity issue). The one Sonar mechanism of the HFS canon is packages/hfs/README.md section "Sonar".
|
|
14
|
+
gate:
|
|
15
|
+
name: starci-new-code
|
|
16
|
+
# A project with no new-code baseline judges the whole project as new code (nivo inc-f92febebbb64); a fixed
|
|
17
|
+
# window keeps "new code" the recent work on main, so the server gate is passable on a healthy main.
|
|
18
|
+
newCodePeriod: {type: NUMBER_OF_DAYS, value: 30}
|
|
19
|
+
newCode:
|
|
20
|
+
# Sonar holds NO coverage condition: unit coverage is the runner's job (jest per-file 100 on `*.service.ts`), never an lcov import.
|
|
21
|
+
# SonarQube's ignoreSmallChanges: fewer changed lines than this are not held to duplication.
|
|
22
|
+
ignoreBelowChangedLines: 20
|
|
23
|
+
duplication:
|
|
24
|
+
metric: new_duplicated_lines_density
|
|
25
|
+
maxPercent: 3
|
|
26
|
+
issues:
|
|
27
|
+
# An open issue of these severities on a changed line fails the slice; lesser ones are listed, never blocking.
|
|
28
|
+
blockingSeverities: [BLOCKER, CRITICAL]
|
|
29
|
+
metrics: {BLOCKER: new_blocker_violations, CRITICAL: new_critical_violations}
|
|
30
|
+
max: 0
|
|
31
|
+
hotspots:
|
|
32
|
+
metric: new_security_hotspots_reviewed
|
|
33
|
+
minReviewedPercent: 100
|
|
34
|
+
unreviewedMax: 0
|
|
35
|
+
# The whole code, not only the new code. Every finding of the HFS canon reaches Sonar through ONE entry, `hfs lint`
|
|
36
|
+
# (packages/hfs, contract changes hfs-sonar-import and hfs-lint-entry), which writes ONE file, reports/lint.sonar.json
|
|
37
|
+
# (`npm run lint -- --sonar reports/lint.sonar.json`), named by sonar.externalIssuesReportPaths; the gate holds the open-issue count of
|
|
38
|
+
# the whole project at zero. The file carries three engines:
|
|
39
|
+
# starci-hfs the repository findings of `hfs check` (managed files, tree, pins, CI, contracts; the rule id is the finding code)
|
|
40
|
+
# eslint ESLint's json over the repository (the BE and FE canon plugins alike, per-file and project-graph rules)
|
|
41
|
+
# stylelint stylelint's json over the style glob (front ends)
|
|
42
|
+
# The engines share ONE placement rule (a finding on a file Sonar does not index is filed on the first source file); Sonar's own
|
|
43
|
+
# ESLint import (sonar.eslint.reportPaths) is not used because it drops such issues.
|
|
44
|
+
# A SonarQube gate condition cannot filter issues by engine, so the condition is the project's open issues (`violations`,
|
|
45
|
+
# overall code): the three engines above are the imports it counts, and every native Sonar issue counts with them. That is
|
|
46
|
+
# stricter than the engines alone and it is intended: a project that meets this gate has no open issue of any origin.
|
|
47
|
+
overall:
|
|
48
|
+
issues:
|
|
49
|
+
metric: violations
|
|
50
|
+
max: 0
|
|
51
|
+
engines: [starci-hfs, eslint, stylelint]
|
|
52
|
+
# HFS_DUPLICATE_CODE (R21): duplicated lines density of the whole project, the Sonar enforcer of the machine's clone check.
|
|
53
|
+
# SonarJS runs its own token-based detection (sonar.cpd.* minimums are not read for TypeScript), so ruleParams.<profile>.duplicateBlock
|
|
54
|
+
# is enforced by the machine and imported as starci-hfs issues, and this density is the Sonar-side backstop.
|
|
55
|
+
duplication:
|
|
56
|
+
metric: duplicated_lines_density
|
|
57
|
+
maxPercent: 3
|
|
58
|
+
# HFS_SIZE_GROWTH (R20): the cognitive complexity of a function is the Sonar rule S3776 (typescript:S3776, Sonar way, default
|
|
59
|
+
# threshold); an open S3776 issue is an open issue of `overall.issues`, so the zero-issue condition is its gate.
|
|
60
|
+
cognitiveComplexity:
|
|
61
|
+
rule: typescript:S3776
|
|
62
|
+
via: overall.issues
|
|
63
|
+
# The Sonar enforcers of the rule catalog (knowledge/hfs/rules.yaml): the failure code each condition above is the gate of.
|
|
64
|
+
enforces:
|
|
65
|
+
- {rule: R20, code: HFS_SIZE_GROWTH, enforcer: cognitive-complexity, condition: cognitiveComplexity}
|
|
66
|
+
- {rule: R21, code: HFS_DUPLICATE_CODE, enforcer: duplicated-lines-density, condition: duplication}
|
|
67
|
+
- {rule: R21, code: HFS_DUPLICATE_SYMBOL, enforcer: duplicated-lines-density, condition: duplication}
|
|
68
|
+
# Ops that write product code: each runs sonar-local on its own change and cannot settle done while the gate is red.
|
|
69
|
+
enforcedOps: [backend.implement, interface.implement, code.refactor]
|
|
70
|
+
reasoning: |
|
|
71
|
+
Chosen against the four projects the local server holds on 2026-09-29 (whole-project duplicated lines
|
|
72
|
+
1.6 / 2.1 / 1.7 / 0.0 percent), so new code that meets them is at or below what main already carries.
|
|
73
|
+
no coverage condition: the unit project fails below its per-file 100 threshold on `*.service.ts`, so Sonar never
|
|
74
|
+
imports an lcov report and never judges coverage.
|
|
75
|
+
duplication 3: the Sonar way value; the worst project today is 2.1, so 3 rejects a real copy-paste block and
|
|
76
|
+
passes ordinary work.
|
|
77
|
+
blocker/critical 0: the two severities that are defects, not style. main carries old debt of both kinds
|
|
78
|
+
(nivo-backend 3 blocker, 137 critical), so the SLICE verdict reads only changed lines and never main's backlog;
|
|
79
|
+
major and lower are reported on the summary and never fail a slice.
|
|
80
|
+
overall issues 0 and overall duplication 3: the server gate is also the owner's definition of done for the HFS canon:
|
|
81
|
+
a repository that carries any open ESLint, stylelint or HFS finding is not green, whatever the age of the line. The
|
|
82
|
+
op slice keeps judging changed lines (an op cannot fix a repository's whole backlog); the dashboard and CI show the
|
|
83
|
+
whole-project condition. Adopting a repository therefore means clearing its imported findings, which `hfs check`,
|
|
84
|
+
eslint and stylelint already demand at land.
|
|
85
|
+
hotspots 0 unreviewed: a hotspot is a question the author must answer; on changed lines it is answered in the
|
|
86
|
+
same slice.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"scripts": {
|
|
3
|
+
"build": "tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json",
|
|
4
|
+
{{appScripts}}
|
|
5
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
6
|
+
"typecheck:tests": "tsc -p src/tests/tsconfig.json",
|
|
7
|
+
"lint": "hfs lint",
|
|
8
|
+
"lint:fix": "hfs lint --fix",
|
|
9
|
+
"format": "prettier --write .",
|
|
10
|
+
"format:check": "prettier --check .",
|
|
11
|
+
"test": "jest --selectProjects unit --coverage",
|
|
12
|
+
"test:affected": "jest --selectProjects unit --passWithNoTests",
|
|
13
|
+
"test:integration": "npm run typecheck:tests && jest --selectProjects integration",
|
|
14
|
+
"test:e2e": "npm run typecheck:tests && jest --selectProjects e2e",
|
|
15
|
+
"test:contract": "npm run typecheck:tests && jest --selectProjects contract",
|
|
16
|
+
"test:stack": "starci-test-stack",
|
|
17
|
+
"contract:emit": "hfs emit-contracts"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"scripts": {
|
|
3
|
+
"prepare": "husky",
|
|
4
|
+
{{appScripts}}
|
|
5
|
+
"codegen": "npm run codegen --workspaces --if-present",
|
|
6
|
+
"build": "npm run build --workspaces --if-present",
|
|
7
|
+
"typecheck": "npm run typecheck --workspaces --if-present",
|
|
8
|
+
"lint": "npm run codegen --silent && hfs lint --stylelint \"{{styleGlob}}\"",
|
|
9
|
+
"lint:fix": "npm run codegen --silent && hfs lint --fix --stylelint \"{{styleGlob}}\"",
|
|
10
|
+
"format": "prettier --write .",
|
|
11
|
+
"format:check": "prettier --check ."
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { isInside } from './config.mjs';
|
|
3
|
+
import { slotAdmitsFile } from '../../lib/hfs-allows.mjs';
|
|
4
|
+
import { reachableViolation, relativePath, sourceLocation } from './typescript.mjs';
|
|
5
|
+
|
|
6
|
+
const FORBIDDEN_APP_ROLE = /(?:^|\.)(?:service|provider|providers|resolver|controller|handler|repository|entity|use-case|command|query|listener|consumer|processor)\.[cm]?[jt]sx?$/i;
|
|
7
|
+
const FORBIDDEN_DECLARATION = /(?:Service|Provider|Resolver|Controller|Handler|Repository|Entity|UseCase|Command|Query|Listener|Consumer|Processor)$/;
|
|
8
|
+
const FORBIDDEN_DECORATORS = new Set(['Controller', 'Resolver', 'Injectable', 'Processor', 'WebSocketGateway']);
|
|
9
|
+
const TRANSPORT_PACKAGES = /^(?:@nestjs\/(?:graphql|microservices|platform-[^/]+|websockets)(?:\/|$)|@apollo\/|apollo-server(?:\/|$)|express(?:\/|$)|fastify(?:\/|$)|graphql(?:\/|$)|class-validator(?:\/|$)|class-transformer(?:\/|$))/;
|
|
10
|
+
const NEST_COMMON_TRANSPORT = new Set(['Controller', 'Get', 'Post', 'Put', 'Patch', 'Delete', 'Options', 'Head', 'Body', 'Param', 'Query', 'Req', 'Request', 'Res', 'Response', 'Headers', 'Header', 'HttpCode', 'Redirect', 'Render', 'Sse', 'UploadedFile', 'UploadedFiles', 'UseGuards', 'UseInterceptors', 'UsePipes']);
|
|
11
|
+
|
|
12
|
+
function absolute(root, relative) {
|
|
13
|
+
return path.resolve(root, ...relative.split('/'));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function roots(root, relatives) {
|
|
17
|
+
return relatives.map(relative => absolute(root, relative));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function insideAny(candidates, fileName) {
|
|
21
|
+
return candidates.some(root => isInside(root, fileName));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function insideFeatureLayer(featureRoots, fileName, layer) {
|
|
25
|
+
return featureRoots.some(root => isInside(root, fileName)
|
|
26
|
+
&& relativePath(root, fileName).split('/').some(segment => segment.toLowerCase() === layer));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function appSource(config, fileName, excludedRoots = []) {
|
|
30
|
+
if (excludedRoots.some(excluded => isInside(excluded, fileName))) return null;
|
|
31
|
+
for (const appRoot of roots(config.root, config.backend.apps)) {
|
|
32
|
+
if (!isInside(appRoot, fileName)) continue;
|
|
33
|
+
const parts = relativePath(appRoot, fileName).split('/');
|
|
34
|
+
const sourceIndex = parts.indexOf('src');
|
|
35
|
+
if (sourceIndex >= 0) return { appRoot, relative: parts.slice(sourceIndex + 1).join('/') };
|
|
36
|
+
// An app root may name its src/ directly (apps/<app>/src), but the repository src/ itself is
|
|
37
|
+
// never an app root: HFS composition lives under apps/<app> only.
|
|
38
|
+
const appRootRelative = relativePath(config.root, appRoot).split('/');
|
|
39
|
+
if (path.basename(appRoot).toLowerCase() === 'src' && appRootRelative[0] === 'apps' && appRootRelative.length > 1) {
|
|
40
|
+
return { appRoot, relative: parts.join('/') };
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function decoratorName(ts, decorator) {
|
|
48
|
+
const expression = decorator.expression;
|
|
49
|
+
const called = ts.isCallExpression(expression) ? expression.expression : expression;
|
|
50
|
+
if (ts.isIdentifier(called)) return called.text;
|
|
51
|
+
return ts.isPropertyAccessExpression(called) ? called.name.text : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function roleEvidence(ts, sourceFile) {
|
|
55
|
+
let found = null;
|
|
56
|
+
const visit = node => {
|
|
57
|
+
if (found) return;
|
|
58
|
+
const decorators = ts.canHaveDecorators?.(node) ? ts.getDecorators(node) ?? [] : node.decorators ?? [];
|
|
59
|
+
const forbidden = decorators.find(item => FORBIDDEN_DECORATORS.has(decoratorName(ts, item)));
|
|
60
|
+
if (forbidden) {
|
|
61
|
+
found = { node: forbidden, detail: `@${decoratorName(ts, forbidden)}` };
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if ((ts.isClassDeclaration(node) || ts.isFunctionDeclaration(node)) && node.name && FORBIDDEN_DECLARATION.test(node.name.text)) {
|
|
65
|
+
found = { node: node.name, detail: node.name.text };
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
ts.forEachChild(node, visit);
|
|
69
|
+
};
|
|
70
|
+
visit(sourceFile);
|
|
71
|
+
return found;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function transportFrameworkEvidence(ts, sourceFile, checker) {
|
|
75
|
+
const found = [];
|
|
76
|
+
const namespaceUsages = binding => {
|
|
77
|
+
const usages = [];
|
|
78
|
+
const alias = binding.text;
|
|
79
|
+
const bindingSymbol = checker?.getSymbolAtLocation(binding);
|
|
80
|
+
const isAlias = node => ts.isIdentifier(node) && node.text === alias
|
|
81
|
+
&& (!bindingSymbol || checker?.getSymbolAtLocation(node) === bindingSymbol);
|
|
82
|
+
const visit = node => {
|
|
83
|
+
if (ts.isPropertyAccessExpression(node) && isAlias(node.expression)
|
|
84
|
+
&& NEST_COMMON_TRANSPORT.has(node.name.text)) usages.push({ node: node.name, detail: node.name.text });
|
|
85
|
+
if (ts.isElementAccessExpression(node) && isAlias(node.expression)) {
|
|
86
|
+
const selected = ts.isStringLiteralLike(node.argumentExpression) ? node.argumentExpression.text : null;
|
|
87
|
+
if (selected === null || NEST_COMMON_TRANSPORT.has(selected)) usages.push({ node: node.argumentExpression, detail: selected ?? 'computed @nestjs/common namespace access' });
|
|
88
|
+
}
|
|
89
|
+
if (ts.isVariableDeclaration(node) && isAlias(node.initializer)) {
|
|
90
|
+
if (!ts.isObjectBindingPattern(node.name)) {
|
|
91
|
+
usages.push({ node: node.name, detail: 'escaped @nestjs/common namespace access' });
|
|
92
|
+
} else {
|
|
93
|
+
for (const element of node.name.elements) {
|
|
94
|
+
const selected = element.dotDotDotToken
|
|
95
|
+
? null
|
|
96
|
+
: ts.isIdentifier(element.propertyName ?? element.name)
|
|
97
|
+
? (element.propertyName ?? element.name).text
|
|
98
|
+
: ts.isStringLiteralLike(element.propertyName)
|
|
99
|
+
? element.propertyName.text
|
|
100
|
+
: null;
|
|
101
|
+
if (selected === null || NEST_COMMON_TRANSPORT.has(selected)) {
|
|
102
|
+
usages.push({ node: element, detail: selected ?? 'computed @nestjs/common namespace destructuring' });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
ts.forEachChild(node, visit);
|
|
108
|
+
};
|
|
109
|
+
visit(sourceFile);
|
|
110
|
+
return usages;
|
|
111
|
+
};
|
|
112
|
+
for (const statement of sourceFile.statements) {
|
|
113
|
+
const importEqualsSpecifier = ts.isImportEqualsDeclaration(statement)
|
|
114
|
+
&& ts.isExternalModuleReference(statement.moduleReference)
|
|
115
|
+
&& statement.moduleReference.expression
|
|
116
|
+
&& ts.isStringLiteralLike(statement.moduleReference.expression)
|
|
117
|
+
? statement.moduleReference.expression
|
|
118
|
+
: null;
|
|
119
|
+
const importSpecifier = ts.isImportDeclaration(statement) && ts.isStringLiteralLike(statement.moduleSpecifier)
|
|
120
|
+
? statement.moduleSpecifier
|
|
121
|
+
: importEqualsSpecifier;
|
|
122
|
+
if (!importSpecifier) continue;
|
|
123
|
+
const specifier = importSpecifier.text;
|
|
124
|
+
if (TRANSPORT_PACKAGES.test(specifier)) {
|
|
125
|
+
found.push({ node: importSpecifier, specifier, detail: specifier });
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (specifier !== '@nestjs/common') continue;
|
|
129
|
+
if (ts.isImportEqualsDeclaration(statement)) {
|
|
130
|
+
found.push(...namespaceUsages(statement.name).map(item => ({ ...item, specifier })));
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (!statement.importClause) continue;
|
|
134
|
+
const bindings = statement.importClause.namedBindings;
|
|
135
|
+
if (bindings && ts.isNamespaceImport(bindings)) {
|
|
136
|
+
found.push(...namespaceUsages(bindings.name).map(item => ({ ...item, specifier })));
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (!bindings || !ts.isNamedImports(bindings)) continue;
|
|
140
|
+
for (const element of bindings.elements) {
|
|
141
|
+
const imported = element.propertyName?.text ?? element.name.text;
|
|
142
|
+
if (NEST_COMMON_TRANSPORT.has(imported)) found.push({ node: element, specifier, detail: imported });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const visitRequire = node => {
|
|
146
|
+
const requiredSpecifier = call => ts.isCallExpression(call)
|
|
147
|
+
&& ts.isIdentifier(call.expression) && call.expression.text === 'require'
|
|
148
|
+
&& call.arguments.length === 1 && ts.isStringLiteralLike(call.arguments[0])
|
|
149
|
+
? call.arguments[0].text
|
|
150
|
+
: null;
|
|
151
|
+
if ((ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) && ts.isCallExpression(node.expression)) {
|
|
152
|
+
const specifier = requiredSpecifier(node.expression);
|
|
153
|
+
const selected = ts.isPropertyAccessExpression(node)
|
|
154
|
+
? node.name.text
|
|
155
|
+
: ts.isStringLiteralLike(node.argumentExpression) ? node.argumentExpression.text : null;
|
|
156
|
+
if (specifier && TRANSPORT_PACKAGES.test(specifier)) {
|
|
157
|
+
found.push({ node: ts.isPropertyAccessExpression(node) ? node.name : node.argumentExpression, specifier, detail: `${selected ?? 'computed member'} from ${specifier}` });
|
|
158
|
+
} else if (specifier === '@nestjs/common' && (selected === null || NEST_COMMON_TRANSPORT.has(selected))) {
|
|
159
|
+
found.push({ node: ts.isPropertyAccessExpression(node) ? node.name : node.argumentExpression, specifier, detail: selected ?? 'computed @nestjs/common require access' });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (ts.isVariableDeclaration(node) && node.initializer && ts.isCallExpression(node.initializer)
|
|
163
|
+
&& requiredSpecifier(node.initializer)) {
|
|
164
|
+
const specifier = requiredSpecifier(node.initializer);
|
|
165
|
+
if (TRANSPORT_PACKAGES.test(specifier)) {
|
|
166
|
+
found.push({ node: node.initializer.arguments[0], specifier, detail: specifier });
|
|
167
|
+
} else if (specifier === '@nestjs/common') {
|
|
168
|
+
if (ts.isIdentifier(node.name)) {
|
|
169
|
+
found.push(...namespaceUsages(node.name).map(item => ({ ...item, specifier })));
|
|
170
|
+
} else if (ts.isObjectBindingPattern(node.name)) {
|
|
171
|
+
for (const element of node.name.elements) {
|
|
172
|
+
const selected = element.dotDotDotToken
|
|
173
|
+
? null
|
|
174
|
+
: ts.isIdentifier(element.propertyName ?? element.name)
|
|
175
|
+
? (element.propertyName ?? element.name).text
|
|
176
|
+
: ts.isStringLiteralLike(element.propertyName)
|
|
177
|
+
? element.propertyName.text
|
|
178
|
+
: null;
|
|
179
|
+
if (selected === null || NEST_COMMON_TRANSPORT.has(selected)) found.push({ node: element, specifier, detail: selected ?? 'computed @nestjs/common require destructuring' });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
ts.forEachChild(node, visitRequire);
|
|
185
|
+
};
|
|
186
|
+
visitRequire(sourceFile);
|
|
187
|
+
return found;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function importedSurface(ts, declaration) {
|
|
191
|
+
if (!ts.isImportDeclaration(declaration)) return null;
|
|
192
|
+
const clause = declaration.importClause;
|
|
193
|
+
if (!clause) return null;
|
|
194
|
+
const names = new Set(clause.name ? ['default'] : []);
|
|
195
|
+
const bindings = clause.namedBindings;
|
|
196
|
+
if (bindings && ts.isNamespaceImport(bindings)) return null;
|
|
197
|
+
if (bindings && ts.isNamedImports(bindings)) {
|
|
198
|
+
for (const element of bindings.elements) names.add(element.propertyName?.text ?? element.name.text);
|
|
199
|
+
}
|
|
200
|
+
return names;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function selectedReexportSurface(ts, declaration, selected) {
|
|
204
|
+
if (!ts.isExportDeclaration(declaration)) return undefined;
|
|
205
|
+
if (!declaration.exportClause) return selected;
|
|
206
|
+
if (ts.isNamespaceExport(declaration.exportClause)) {
|
|
207
|
+
return selected === null || selected.has(declaration.exportClause.name.text) ? null : undefined;
|
|
208
|
+
}
|
|
209
|
+
if (!ts.isNamedExports(declaration.exportClause)) return undefined;
|
|
210
|
+
const next = new Set();
|
|
211
|
+
for (const element of declaration.exportClause.elements) {
|
|
212
|
+
if (selected === null || selected.has(element.name.text)) next.add(element.propertyName?.text ?? element.name.text);
|
|
213
|
+
}
|
|
214
|
+
return next.size ? next : undefined;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function externalTransportReexport(ts, sourceFile, selected) {
|
|
218
|
+
const imported = new Map();
|
|
219
|
+
for (const statement of sourceFile.statements) {
|
|
220
|
+
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteralLike(statement.moduleSpecifier) || !statement.importClause) continue;
|
|
221
|
+
const specifier = statement.moduleSpecifier.text;
|
|
222
|
+
const bindings = statement.importClause.namedBindings;
|
|
223
|
+
if (bindings && ts.isNamedImports(bindings)) {
|
|
224
|
+
for (const element of bindings.elements) imported.set(element.name.text, { specifier, imported: element.propertyName?.text ?? element.name.text });
|
|
225
|
+
} else if (bindings && ts.isNamespaceImport(bindings)) imported.set(bindings.name.text, { specifier, imported: null });
|
|
226
|
+
}
|
|
227
|
+
for (const statement of sourceFile.statements) {
|
|
228
|
+
if (!ts.isExportDeclaration(statement)) continue;
|
|
229
|
+
const clause = statement.exportClause;
|
|
230
|
+
const specifier = ts.isStringLiteralLike(statement.moduleSpecifier) ? statement.moduleSpecifier.text : null;
|
|
231
|
+
if (specifier && (specifier === '@nestjs/common' || TRANSPORT_PACKAGES.test(specifier))) {
|
|
232
|
+
if (!clause) {
|
|
233
|
+
if (specifier !== '@nestjs/common' || selected === null || [...selected].some(name => NEST_COMMON_TRANSPORT.has(name))) {
|
|
234
|
+
return { node: statement.moduleSpecifier, specifier, detail: selected === null ? `unbounded ${specifier} re-export` : `re-exported ${[...selected].join(', ')}` };
|
|
235
|
+
}
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (ts.isNamespaceExport(clause)) {
|
|
239
|
+
if (selected === null || selected.has(clause.name.text)) return { node: clause.name, specifier, detail: `namespace re-export from ${specifier}` };
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (!ts.isNamedExports(clause)) continue;
|
|
243
|
+
for (const element of clause.elements) {
|
|
244
|
+
if (selected !== null && !selected.has(element.name.text)) continue;
|
|
245
|
+
const original = element.propertyName?.text ?? element.name.text;
|
|
246
|
+
if (specifier !== '@nestjs/common' || NEST_COMMON_TRANSPORT.has(original)) return { node: element, specifier, detail: `${element.name.text} re-exported from ${specifier}` };
|
|
247
|
+
}
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (specifier || !clause || !ts.isNamedExports(clause)) continue;
|
|
251
|
+
for (const element of clause.elements) {
|
|
252
|
+
if (selected !== null && !selected.has(element.name.text)) continue;
|
|
253
|
+
const local = element.propertyName?.text ?? element.name.text;
|
|
254
|
+
const binding = imported.get(local);
|
|
255
|
+
if (!binding) continue;
|
|
256
|
+
if (binding.specifier !== '@nestjs/common' && !TRANSPORT_PACKAGES.test(binding.specifier)) continue;
|
|
257
|
+
if (binding.specifier === '@nestjs/common' && (binding.imported === null || !NEST_COMMON_TRANSPORT.has(binding.imported))) continue;
|
|
258
|
+
return { node: element, specifier: binding.specifier, detail: `${element.name.text} re-exported from ${binding.specifier}` };
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function reexportedTransportEvidence(ts, context, sourceFiles, firstEdge) {
|
|
265
|
+
const queue = [{ file: firstEdge.to, selected: importedSurface(ts, firstEdge.declaration), chain: [firstEdge.from, firstEdge.to] }];
|
|
266
|
+
const visited = new Set();
|
|
267
|
+
while (queue.length) {
|
|
268
|
+
const current = queue.shift();
|
|
269
|
+
const key = `${current.file}\0${current.selected === null ? '*' : [...current.selected].sort().join(',')}`;
|
|
270
|
+
if (visited.has(key)) continue;
|
|
271
|
+
visited.add(key);
|
|
272
|
+
const sourceFile = sourceFiles.get(path.resolve(current.file));
|
|
273
|
+
if (!sourceFile) continue;
|
|
274
|
+
const evidence = externalTransportReexport(ts, sourceFile, current.selected);
|
|
275
|
+
if (evidence) return { evidence, chain: current.chain };
|
|
276
|
+
for (const edge of context.edges.get(current.file) ?? []) {
|
|
277
|
+
if (!edge.reexport) continue;
|
|
278
|
+
const selected = selectedReexportSurface(ts, edge.declaration, current.selected);
|
|
279
|
+
if (selected !== undefined) queue.push({ file: edge.to, selected, chain: [...current.chain, edge.to] });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function finding(config, edge, ruleId, message, chain) {
|
|
286
|
+
return {
|
|
287
|
+
ruleId,
|
|
288
|
+
path: relativePath(config.root, edge.from),
|
|
289
|
+
line: edge.line,
|
|
290
|
+
column: edge.column,
|
|
291
|
+
specifier: edge.specifier,
|
|
292
|
+
resolvedPath: relativePath(config.root, chain.at(-1)),
|
|
293
|
+
dependencyChain: chain.map(file => relativePath(config.root, file)),
|
|
294
|
+
message,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Enforce backend direction and keep executable apps as measurable composition roots. */
|
|
299
|
+
export function checkBackend(config, context) {
|
|
300
|
+
const violations = [];
|
|
301
|
+
const moduleRoots = roots(config.root, config.backend.modules);
|
|
302
|
+
const featureRoots = roots(config.root, config.backend.features);
|
|
303
|
+
const nonAppRoots = [...moduleRoots, ...featureRoots];
|
|
304
|
+
const sourceFiles = new Map(context.files.map(file => [path.resolve(file.fileName), file]));
|
|
305
|
+
for (const sourceFile of context.files) {
|
|
306
|
+
const fileName = path.resolve(sourceFile.fileName);
|
|
307
|
+
const fromModules = insideAny(moduleRoots, fileName);
|
|
308
|
+
const fromFeatures = insideAny(featureRoots, fileName);
|
|
309
|
+
const fromApplication = fromFeatures && insideFeatureLayer(featureRoots, fileName, 'application');
|
|
310
|
+
const app = !fromModules && !fromFeatures ? appSource(config, fileName, nonAppRoots) : null;
|
|
311
|
+
if (app) {
|
|
312
|
+
const evidence = roleEvidence(context.ts, sourceFile);
|
|
313
|
+
if (evidence) {
|
|
314
|
+
violations.push({
|
|
315
|
+
ruleId: 'BE_APP_BUSINESS_ROLE',
|
|
316
|
+
path: relativePath(config.root, fileName),
|
|
317
|
+
...sourceLocation(sourceFile, evidence.node),
|
|
318
|
+
message: `Application composition source contains measurable business/provider role ${evidence.detail}. Move that role under src/features or src/modules.`,
|
|
319
|
+
});
|
|
320
|
+
} else if (slotAdmitsFile(config.hfs, relativePath(config.root, fileName)) !== true) {
|
|
321
|
+
violations.push({
|
|
322
|
+
ruleId: FORBIDDEN_APP_ROLE.test(path.posix.basename(app.relative)) ? 'BE_APP_BUSINESS_ROLE' : 'BE_APP_COMPOSITION_ONLY',
|
|
323
|
+
path: relativePath(config.root, fileName),
|
|
324
|
+
line: 1,
|
|
325
|
+
column: 1,
|
|
326
|
+
message: `Application source ${app.relative} is not a file its app slot requires or allows (main.ts, app.module.ts, <app>.options.ts).`,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (!fromModules && !fromFeatures) continue;
|
|
331
|
+
if (fromApplication) {
|
|
332
|
+
for (const evidence of transportFrameworkEvidence(context.ts, sourceFile, context.checkerFor(fileName))) violations.push({
|
|
333
|
+
ruleId: 'BE_APPLICATION_TRANSPORT_FRAMEWORK',
|
|
334
|
+
path: relativePath(config.root, fileName),
|
|
335
|
+
...sourceLocation(sourceFile, evidence.node),
|
|
336
|
+
specifier: evidence.specifier,
|
|
337
|
+
message: `Feature application code imports transport framework surface ${evidence.detail}. Keep protocol decorators, request/response types, validation DTOs, and generated transport types under transport/.`,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
for (const edge of context.edges.get(fileName) ?? []) {
|
|
341
|
+
if (fromApplication) {
|
|
342
|
+
const reexported = reexportedTransportEvidence(context.ts, context, sourceFiles, edge);
|
|
343
|
+
if (reexported) violations.push(finding(config, edge, 'BE_APPLICATION_TRANSPORT_FRAMEWORK', `Feature application code imports protocol surface ${reexported.evidence.detail} through an internal re-export. Keep protocol decorators and types under transport/.`, reexported.chain));
|
|
344
|
+
const transportChain = reachableViolation(context.edges, edge, target => insideFeatureLayer(featureRoots, target, 'transport'));
|
|
345
|
+
if (transportChain) violations.push(finding(config, edge, 'BE_APPLICATION_IMPORTS_TRANSPORT', 'Feature application code cannot depend on transport adapters or DTOs, including through a type import or barrel.', transportChain));
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return violations;
|
|
350
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { machineKit } from './machine-ast.mjs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* R46 `background-unowned` (BE_BACKGROUND_UNOWNED, BE-CONVENTION 1.6 and 1.13). Background work is a transport that only
|
|
6
|
+
* a worker app runs:
|
|
7
|
+
*
|
|
8
|
+
* - every job (`*.job.ts`) and consumer (`*.consumer.ts`) is composed by an app of kind `worker`: the file is reachable,
|
|
9
|
+
* through runtime imports, from the root module of some worker app declared in hfs.json; one no worker reaches never runs;
|
|
10
|
+
* - no scheduler outside `platform/scheduling`: a `@Cron`, `@Interval` or `@Timeout` decorator of `@nestjs/schedule` and a
|
|
11
|
+
* `setInterval` call are refused everywhere else (platform/scheduling owns the lease, the fencing and the overlap guard);
|
|
12
|
+
* - a method whose name is one of the words the law names (`sweep`, `deliver`, `reconcile`, `retry`, alone or as the
|
|
13
|
+
* first word of a camelCase name) in a domain, integrations or feature class is reachable from a composed job or
|
|
14
|
+
* consumer: from the job 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 job). Mechanisms of platform are exempt.
|
|
16
|
+
*
|
|
17
|
+
* Reachability is static (imports, re-exports and the feature of the job); it proves a job or consumer could call the method,
|
|
18
|
+
* not that it does.
|
|
19
|
+
*/
|
|
20
|
+
export const BACKGROUND_UNOWNED_RULE_IDS = ['BE_BACKGROUND_UNOWNED'];
|
|
21
|
+
|
|
22
|
+
const RULE = 'BE_BACKGROUND_UNOWNED';
|
|
23
|
+
const BACKGROUND_ROLES = ['job', 'consumer'];
|
|
24
|
+
/** The words the law names for background methods (knowledge/hfs/README.md 5.7). */
|
|
25
|
+
const BACKGROUND_WORDS = ['sweep', 'deliver', 'reconcile', 'retry'];
|
|
26
|
+
const SCHEDULER_DECORATORS = ['Cron', 'Interval', 'Timeout'];
|
|
27
|
+
const SCHEDULER_PACKAGE = '@nestjs/schedule';
|
|
28
|
+
const SCHEDULING_CAPABILITY = 'scheduling';
|
|
29
|
+
|
|
30
|
+
const roleOf = rel => {
|
|
31
|
+
const parts = path.posix.basename(rel).split('.');
|
|
32
|
+
return parts.length >= 3 && parts.at(-1) === 'ts' && BACKGROUND_ROLES.includes(parts.at(-2)) ? parts.at(-2) : null;
|
|
33
|
+
};
|
|
34
|
+
const isBackgroundName = name => BACKGROUND_WORDS.some(word => name === word || (name.startsWith(word) && /[A-Z]/u.test(name[word.length] ?? '')));
|
|
35
|
+
|
|
36
|
+
export function checkBackgroundUnowned(input) {
|
|
37
|
+
const { graph } = input;
|
|
38
|
+
const kit = machineKit(input);
|
|
39
|
+
const { ts, resolver } = kit;
|
|
40
|
+
const violations = [];
|
|
41
|
+
const report = (file, node, message, extra = {}) => violations.push({ ruleId: RULE, path: file.rel, ...kit.at(file.rel, file.sourceFile, node), message, ...extra });
|
|
42
|
+
|
|
43
|
+
const adjacency = new Map();
|
|
44
|
+
for (const edge of graph.edges) if (edge.runtime) {
|
|
45
|
+
if (!adjacency.has(edge.from)) adjacency.set(edge.from, []);
|
|
46
|
+
adjacency.get(edge.from).push(edge.to);
|
|
47
|
+
}
|
|
48
|
+
const closure = starts => {
|
|
49
|
+
const seen = new Set(starts);
|
|
50
|
+
const queue = [...starts];
|
|
51
|
+
while (queue.length) for (const next of adjacency.get(queue.shift()) ?? []) if (!seen.has(next)) { seen.add(next); queue.push(next); }
|
|
52
|
+
return seen;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const workerRoots = resolver.repo.apps.filter(app => app.kind === 'worker').map(app => `apps/${app.name}/src/app.module.ts`).filter(rel => graph.files.has(rel));
|
|
56
|
+
const composed = closure(workerRoots);
|
|
57
|
+
const background = [...graph.files.values()].filter(file => roleOf(file.rel) && file.tier === 'feature');
|
|
58
|
+
const running = background.filter(file => composed.has(file.rel));
|
|
59
|
+
for (const file of background) {
|
|
60
|
+
if (composed.has(file.rel)) continue;
|
|
61
|
+
const role = roleOf(file.rel);
|
|
62
|
+
report(file, file.sourceFile, `${file.rel} is a ${role} that no worker app composes${workerRoots.length ? '' : ' (hfs.json declares no worker app)'}; a ${role} runs only when the ${role === 'job' ? 'schedule' : 'message'} module of its feature is imported by the root module of an app of kind worker.`, { role });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Everything a composed job or consumer can reach: its imports, and the files of its own feature (command handlers).
|
|
66
|
+
const roots = new Set();
|
|
67
|
+
for (const file of running) {
|
|
68
|
+
roots.add(file.rel);
|
|
69
|
+
const owner = file.owner?.root;
|
|
70
|
+
if (owner) for (const other of graph.files.values()) if (other.owner?.root === owner) roots.add(other.rel);
|
|
71
|
+
}
|
|
72
|
+
const reachable = closure([...roots]);
|
|
73
|
+
|
|
74
|
+
let methods = 0;
|
|
75
|
+
for (const file of graph.files.values()) {
|
|
76
|
+
const checker = kit.checkerOf(file.sourceFile);
|
|
77
|
+
const inScheduling = file.owner?.slot === 'be.platform' && path.posix.basename(file.owner.root) === SCHEDULING_CAPABILITY;
|
|
78
|
+
const mechanism = file.tier === 'platform';
|
|
79
|
+
kit.walk(file.sourceFile, node => {
|
|
80
|
+
if (!inScheduling && ts.isDecorator(node)) {
|
|
81
|
+
const call = ts.isCallExpression(node.expression) ? node.expression.expression : node.expression;
|
|
82
|
+
const binding = kit.importBinding(checker, call);
|
|
83
|
+
if (binding && binding.module === SCHEDULER_PACKAGE && SCHEDULER_DECORATORS.includes(binding.name)) {
|
|
84
|
+
report(file, node, `@${binding.name} schedules work inside a class outside platform/scheduling. Write a job in transport/schedule/<job>.job.ts that dispatches one command or query, and let platform/scheduling and a worker app run it.`, { scheduler: binding.name });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (!inScheduling && ts.isCallExpression(node)) {
|
|
88
|
+
const callee = node.expression;
|
|
89
|
+
const bare = ts.isIdentifier(callee) && callee.text === 'setInterval' && kit.declarationsOf(checker, callee).every(declaration => declaration.getSourceFile().isDeclarationFile);
|
|
90
|
+
const member = ts.isPropertyAccessExpression(callee) && callee.name.text === 'setInterval' && ts.isIdentifier(callee.expression) && ['globalThis', 'window', 'global'].includes(callee.expression.text);
|
|
91
|
+
const global = bare || member;
|
|
92
|
+
if (global) report(file, node, 'setInterval starts a scheduler outside platform/scheduling. Write a job in transport/schedule/<job>.job.ts and let platform/scheduling and a worker app run it.', { scheduler: 'setInterval' });
|
|
93
|
+
}
|
|
94
|
+
if (!mechanism && ts.isClassDeclaration(node)) {
|
|
95
|
+
for (const member of node.members) {
|
|
96
|
+
if (!ts.isMethodDeclaration(member) || !member.name) continue;
|
|
97
|
+
const name = kit.propertyNameText(member.name);
|
|
98
|
+
if (!name || !isBackgroundName(name)) continue;
|
|
99
|
+
methods += 1;
|
|
100
|
+
if (!reachable.has(file.rel)) report(file, member.name, `${name} is background work (a ${BACKGROUND_WORDS.join(', ')} method) that no job or consumer composed by a worker app can reach. Add a job in transport/schedule or a consumer in transport/message of a feature that runs it, and compose that transport module in a worker app.`, { method: name });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return true;
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return { violations, coverage: { status: 'checked', workers: workerRoots.length, jobsAndConsumers: background.length, composed: running.length, backgroundMethods: methods } };
|
|
107
|
+
}
|