@starci/hfs 4.0.9 → 4.1.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 +1 -1
- package/LICENSE +21 -0
- package/emit/operations.mjs +1 -1
- package/emit/type-schema.mjs +2 -1
- package/lint/run.mjs +82 -8
- package/package.json +3 -2
- package/report/order.mjs +2 -0
- package/runtime/config.example.yaml +187 -0
- package/runtime/engine/by-code-unit.mjs +12 -0
- package/runtime/engine/config.mjs +247 -273
- package/runtime/engine/invalid-config.mjs +5 -5
- package/runtime/engine/model-config.mjs +90 -0
- package/runtime/engine/orca-config.mjs +3 -1
- package/runtime/engine/removed-vocabulary.mjs +28 -0
- package/runtime/engine/resources-config.mjs +52 -0
- package/runtime/engine/runtime-root.mjs +17 -0
- package/runtime/engine/secrets.mjs +134 -0
- package/runtime/engine/sonar-config.mjs +21 -0
- package/runtime/engine/temp-root.mjs +32 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +18 -18
- package/runtime/knowledge/hfs/peer-integrations.yaml +2 -3
- package/runtime/knowledge/hfs/rules.yaml +102 -69
- package/runtime/knowledge/hfs/slots.yaml +11 -14
- package/runtime/knowledge/patterns/be/api.yaml +7 -24
- package/runtime/knowledge/patterns/be/cli.yaml +3 -12
- package/runtime/knowledge/patterns/be/realtime.yaml +1 -14
- package/runtime/knowledge/patterns/be/webhooks.yaml +0 -19
- package/runtime/modules/kernel/failure-codes.yaml +1 -1
- package/runtime/modules/kernel/removed-vocabulary.yaml +168 -0
- package/runtime/modules/models/registry.yaml +25 -397
- package/runtime/modules/models/runtimes.yaml +92 -262
- package/runtime/modules/models/tiers.yaml +65 -0
- package/runtime/scripts/api/fs/claim-file.mjs +30 -0
- package/runtime/scripts/api/fs/ensure-temp-root.mjs +27 -0
- package/runtime/scripts/api/fs/forbidden-root.mjs +2 -1
- package/runtime/scripts/api/fs/lib.mjs +6 -0
- package/runtime/scripts/api/fs/make-temp-dir.mjs +11 -0
- package/runtime/scripts/api/fs/safe-remove.mjs +52 -35
- package/runtime/scripts/api/git/lib.mjs +36 -1
- package/runtime/scripts/api/process/resolve-real-tool.mjs +52 -0
- package/runtime/scripts/api/process/run-program.mjs +8 -0
- package/runtime/scripts/api/sops/decrypt.mjs +9 -16
- package/runtime/scripts/api/sops/lib.mjs +309 -11
- package/runtime/scripts/api/sops/seal.mjs +8 -4
- package/runtime/scripts/hfs/allows.mjs +3 -3
- package/runtime/scripts/hfs/architecture/ast-walks.mjs +63 -50
- package/runtime/scripts/hfs/architecture/automatic-gates.mjs +96 -0
- package/runtime/scripts/hfs/architecture/backend.mjs +261 -175
- package/runtime/scripts/hfs/architecture/background-unowned.mjs +69 -34
- package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +55 -54
- package/runtime/scripts/hfs/architecture/clones.mjs +179 -106
- package/runtime/scripts/hfs/architecture/config-unread.mjs +5 -22
- package/runtime/scripts/hfs/architecture/config.mjs +95 -80
- package/runtime/scripts/hfs/architecture/connection-map.mjs +233 -141
- package/runtime/scripts/hfs/architecture/constructor-deps.mjs +15 -9
- package/runtime/scripts/hfs/architecture/context-coupling.mjs +76 -52
- package/runtime/scripts/hfs/architecture/context-map.mjs +215 -141
- package/runtime/scripts/hfs/architecture/context-owner.mjs +39 -30
- package/runtime/scripts/hfs/architecture/context-platform-tables.mjs +31 -17
- package/runtime/scripts/hfs/architecture/context-transaction.mjs +38 -25
- package/runtime/scripts/hfs/architecture/contract-fixture-guard.mjs +86 -40
- package/runtime/scripts/hfs/architecture/contracts-readonly.mjs +342 -0
- package/runtime/scripts/hfs/architecture/contracts.mjs +263 -455
- package/runtime/scripts/hfs/architecture/cross-app-duplicate.mjs +43 -34
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +201 -154
- package/runtime/scripts/hfs/architecture/default-deny.mjs +118 -93
- package/runtime/scripts/hfs/architecture/doc-language.mjs +2 -1
- package/runtime/scripts/hfs/architecture/entrypoint.mjs +45 -30
- package/runtime/scripts/hfs/architecture/error-codes.mjs +19 -12
- package/runtime/scripts/hfs/architecture/error-masked.mjs +39 -26
- package/runtime/scripts/hfs/architecture/fe-slot-allows.mjs +3 -2
- package/runtime/scripts/hfs/architecture/feature-shape.mjs +26 -20
- package/runtime/scripts/hfs/architecture/framework-pinned.mjs +1 -1
- package/runtime/scripts/hfs/architecture/frontend-grammar.mjs +190 -0
- package/runtime/scripts/hfs/architecture/frontend-routing.mjs +186 -0
- package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +311 -0
- package/runtime/scripts/hfs/architecture/frontend-world.mjs +328 -0
- package/runtime/scripts/hfs/architecture/frontend.mjs +109 -829
- package/runtime/scripts/hfs/architecture/hfs-graph.mjs +14 -2
- package/runtime/scripts/hfs/architecture/hfs.mjs +120 -288
- package/runtime/scripts/hfs/architecture/hooks-are-hooks.mjs +46 -26
- package/runtime/scripts/hfs/architecture/i18n-keys.mjs +114 -76
- package/runtime/scripts/hfs/architecture/index.mjs +158 -102
- package/runtime/scripts/hfs/architecture/injection-token-exported.mjs +33 -28
- package/runtime/scripts/hfs/architecture/machine-ast.mjs +180 -144
- package/runtime/scripts/hfs/architecture/managed-scripts.mjs +8 -2
- package/runtime/scripts/hfs/architecture/module-per-transport.mjs +150 -101
- package/runtime/scripts/hfs/architecture/next-data-contract.mjs +51 -43
- package/runtime/scripts/hfs/architecture/next-data-key.mjs +338 -0
- package/runtime/scripts/hfs/architecture/next-data.mjs +179 -427
- package/runtime/scripts/hfs/architecture/owners.mjs +27 -13
- package/runtime/scripts/hfs/architecture/package-shape.mjs +51 -23
- package/runtime/scripts/hfs/architecture/presentation.mjs +165 -0
- package/runtime/scripts/hfs/architecture/reachability.mjs +127 -61
- package/runtime/scripts/hfs/architecture/register-once.mjs +112 -63
- package/runtime/scripts/hfs/architecture/registration.mjs +187 -110
- package/runtime/scripts/hfs/architecture/required-files.mjs +100 -68
- package/runtime/scripts/hfs/architecture/route-files-thin.mjs +83 -58
- package/runtime/scripts/hfs/architecture/schema-owner.mjs +182 -108
- package/runtime/scripts/hfs/architecture/source-names-shape.mjs +318 -0
- package/runtime/scripts/hfs/architecture/source-names.mjs +83 -248
- package/runtime/scripts/hfs/architecture/sql-owner.mjs +175 -118
- package/runtime/scripts/hfs/architecture/sql-returning.mjs +39 -28
- package/runtime/scripts/hfs/architecture/sql-tokens.mjs +302 -172
- package/runtime/scripts/hfs/architecture/supabase-ast.mjs +1 -1
- package/runtime/scripts/hfs/architecture/supabase-be.mjs +55 -45
- package/runtime/scripts/hfs/architecture/supabase-results.mjs +182 -0
- package/runtime/scripts/hfs/architecture/supabase-tables.mjs +20 -12
- package/runtime/scripts/hfs/architecture/supabase.mjs +191 -263
- package/runtime/scripts/hfs/architecture/symbols.mjs +175 -99
- package/runtime/scripts/hfs/architecture/test-world-files.mjs +82 -41
- package/runtime/scripts/hfs/architecture/tiers.mjs +120 -69
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +84 -57
- package/runtime/scripts/hfs/architecture/type-context.mjs +282 -0
- package/runtime/scripts/hfs/architecture/typescript.mjs +116 -276
- package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +84 -62
- package/runtime/scripts/hfs/architecture.mjs +3 -4
- package/runtime/scripts/hfs/check.mjs +61 -70
- package/runtime/scripts/hfs/coverage-scope.mjs +84 -38
- package/runtime/scripts/hfs/declaration-shape.mjs +77 -36
- package/runtime/scripts/hfs/declaration-slots.mjs +1 -1
- package/runtime/scripts/hfs/edition-slots.mjs +5 -3
- package/runtime/scripts/hfs/linear-text.mjs +30 -0
- package/runtime/scripts/hfs/manifest-shape.mjs +164 -63
- package/runtime/scripts/hfs/path-findings.mjs +58 -38
- package/runtime/scripts/hfs/pin-findings.mjs +31 -0
- package/runtime/scripts/hfs/repo-identity.mjs +5 -2
- package/runtime/scripts/hfs/rule-catalog.mjs +194 -0
- package/runtime/scripts/hfs/rule-params-shape.mjs +33 -27
- package/runtime/scripts/hfs/rules/cli.mjs +5 -1
- package/runtime/scripts/hfs/rules/contract-compat.mjs +40 -22
- package/runtime/scripts/hfs/rules/contract.mjs +35 -27
- package/runtime/scripts/hfs/rules/database-config.mjs +57 -32
- package/runtime/scripts/hfs/rules/database-migrations.mjs +52 -31
- package/runtime/scripts/hfs/rules/database-plpgsql.mjs +106 -91
- package/runtime/scripts/hfs/rules/database-sql.mjs +254 -173
- package/runtime/scripts/hfs/rules/database.mjs +36 -23
- package/runtime/scripts/hfs/rules/deps.mjs +64 -32
- package/runtime/scripts/hfs/rules/docker.mjs +113 -65
- package/runtime/scripts/hfs/rules/edition.mjs +118 -111
- package/runtime/scripts/hfs/rules/event-bus.mjs +91 -45
- package/runtime/scripts/hfs/rules/fe-contract-documents.mjs +17 -16
- package/runtime/scripts/hfs/rules/fe-no-tests.mjs +36 -16
- package/runtime/scripts/hfs/rules/frontend-tree.mjs +4 -3
- package/runtime/scripts/hfs/rules/integration-specs.mjs +75 -41
- package/runtime/scripts/hfs/rules/kinds.mjs +59 -25
- package/runtime/scripts/hfs/rules/lint-suppression.mjs +9 -3
- package/runtime/scripts/hfs/rules/monorepo.mjs +82 -45
- package/runtime/scripts/hfs/rules/peer-integrations.mjs +24 -3
- package/runtime/scripts/hfs/rules/pipeline.mjs +58 -9
- package/runtime/scripts/hfs/rules/proof-commands.mjs +14 -8
- package/runtime/scripts/hfs/rules/repo-local-checks.mjs +11 -6
- package/runtime/scripts/hfs/rules/saga.mjs +128 -61
- package/runtime/scripts/hfs/rules/secrets.mjs +4 -3
- package/runtime/scripts/hfs/rules/services.mjs +34 -16
- package/runtime/scripts/hfs/rules/stacks.mjs +21 -14
- package/runtime/scripts/hfs/rules/supabase-secrets.mjs +32 -23
- package/runtime/scripts/hfs/rules/test-topology.mjs +25 -11
- package/runtime/scripts/hfs/secret.mjs +90 -45
- package/runtime/scripts/hfs/slot-app-view.mjs +102 -0
- package/runtime/scripts/hfs/slot-classify.mjs +65 -0
- package/runtime/scripts/hfs/slot-errors.mjs +11 -0
- package/runtime/scripts/hfs/slot-imports.mjs +76 -0
- package/runtime/scripts/hfs/slot-manifest-shape.mjs +73 -0
- package/runtime/scripts/hfs/slot-match.mjs +153 -0
- package/runtime/scripts/hfs/slot-path.mjs +8 -0
- package/runtime/scripts/hfs/slot-required.mjs +76 -0
- package/runtime/scripts/hfs/slot-semantic-problems.mjs +148 -0
- package/runtime/scripts/hfs/slot-side-problems.mjs +28 -0
- package/runtime/scripts/hfs/slots.mjs +72 -693
- package/runtime/scripts/hfs/sql/pg-parse.mjs +5 -2
- package/runtime/scripts/hfs/trailing-slashes.mjs +8 -0
- package/runtime/scripts/hfs/tree.mjs +19 -14
- package/runtime/scripts/hfs/typescript-programs.mjs +6 -4
- package/runtime/scripts/hfs/view.mjs +1 -1
- package/runtime/scripts/lib/dockerfile.mjs +56 -33
- package/runtime/scripts/lib/event-contract.mjs +17 -13
- package/runtime/scripts/lib/fs-kind.mjs +14 -4
- package/runtime/scripts/lib/git.mjs +2 -2
- package/runtime/scripts/lib/graphql-contract.mjs +158 -322
- package/runtime/scripts/lib/graphql-sdl.mjs +262 -0
- package/runtime/scripts/lib/i18n.mjs +3 -2
- package/runtime/scripts/lib/in-order.mjs +71 -0
- package/runtime/scripts/lib/language.mjs +3 -3
- package/runtime/scripts/lib/list.mjs +8 -1
- package/runtime/scripts/lib/mutation-fence.mjs +15 -0
- package/runtime/scripts/lib/path-key.mjs +42 -9
- package/runtime/scripts/lib/pid-alive.mjs +7 -0
- package/runtime/scripts/lib/regex.mjs +1 -1
- package/runtime/scripts/lib/same-text.mjs +1 -1
- package/runtime/scripts/lib/secret-patterns.mjs +6 -2
- package/runtime/scripts/lib/sleep-sync.mjs +2 -2
- package/runtime/scripts/lib/sops-envelope.mjs +103 -2
- package/runtime/scripts/lib/stack-services.mjs +1 -2
- package/runtime/scripts/lib/ts-ast.mjs +9 -0
- package/runtime/scripts/lib/walk.mjs +6 -1
- package/scaffold/add-cli-lite.mjs +2 -1
- package/scaffold/add-table.mjs +2 -2
- package/scaffold/add.mjs +3 -2
- package/scaffold/app.mjs +4 -2
- package/scaffold/edition-gate.mjs +25 -25
- package/scaffold/lite-exports.mjs +2 -1
- package/scaffold/service.mjs +5 -4
- package/sync/hygiene.mjs +15 -9
- package/sync/index.mjs +7 -3
- package/templates/app/ci-workflows/github/workflows/ci.yml +4 -4
- package/templates/app/ci-workflows/github/workflows/e2e.yml +3 -3
- package/templates/app/ci-workflows/github/workflows/images.yml +3 -3
- package/templates/app/ci-workflows/sonar-steps.yml +2 -2
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +6 -6
- package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +3 -3
- package/templates/app/ci-workflows-lite/github/workflows/images.yml +3 -3
- package/templates/app/starciwork.gitignore +1 -1
- package/templates/be/image/api/Dockerfile +1 -1
- package/templates/be/image/cli/Dockerfile +1 -1
- package/templates/be/image/worker/Dockerfile +1 -1
- package/templates/be/patterns/cli/group.cli.spec.ts.tpl +1 -1
- package/templates/be/patterns/cli/group.cli.ts.tpl +2 -2
- package/templates/be/patterns/event-bus/platform/event-runner.service.spec.ts.tpl +13 -0
- package/templates/be/patterns/event-bus/platform/event-runner.service.ts.tpl +7 -2
- package/templates/be/patterns/event-bus/platform/event.policy.ts.tpl +4 -1
- package/templates/be/patterns/event-bus/platform/kafka-event-transport.client.ts.tpl +2 -1
- package/templates/be/patterns/outbox/platform/outbox-relay.policy.ts.tpl +24 -9
- package/templates/be/patterns/queues/platform/queue-relay.service.ts.tpl +3 -2
- package/templates/be/patterns/queues/platform/queue-worker.service.ts.tpl +8 -5
- package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.spec.ts +1 -1
- package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.ts +2 -2
- package/templates/be/skeleton/src/features/cli/seed/seed.cli.spec.ts +1 -1
- package/templates/be/skeleton/src/features/cli/seed/seed.cli.ts +2 -2
- package/templates/be/skeleton/src/modules/platform/database/migrate-connections.client.ts +3 -2
- package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.ts +5 -4
- package/templates/be/skeleton/src/modules/platform/primitives/index.ts +1 -0
- package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.spec.ts +47 -0
- package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.ts +16 -0
- package/templates/be/skeleton-lite/apps/api/Dockerfile +1 -0
- package/templates/be/skeleton-lite/src/modules/platform/primitives/index.ts +2 -0
- package/templates/fe/image/next/Dockerfile +2 -1
- package/templates/fe/skeleton/apps/app/src/app/[locale]/page.tsx +2 -2
- package/templates/fe/skeleton/apps/landing/src/app/[locale]/page.tsx +2 -2
- package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +1 -2
- package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/layout.tsx +1 -2
- package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/page.tsx +2 -2
- package/templates/fe/skeleton-lite/apps/web/src/components/blocks/SignInForm/index.tsx +7 -1
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/write-sign-out.ts +1 -1
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/validation/validation.mapper.ts +10 -2
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +10 -3
- package/upgrade/index.mjs +9 -2
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { readTextFile } from '../../lib/read-text.mjs';
|
|
2
|
+
|
|
3
|
+
// Owner ruling 2026-09-29 (layout 2026-09-30): integration, e2e and contract run MANUALLY only. No hook, default typecheck,
|
|
4
|
+
// coverage run or automatic CI trigger may include those trees or run those projects. Linting the e2e files is not running them: ESLint reads them
|
|
5
|
+
// as syntax in the one repository-wide lint run (the factory's e2e block), so no `lint:e2e` command exists to judge.
|
|
6
|
+
const E2E_COMMAND = new RegExp([
|
|
7
|
+
String.raw`\btest:(?:e2e|integration|contract)\b|`,
|
|
8
|
+
String.raw`\btypecheck:tests\b|`,
|
|
9
|
+
String.raw`--selectProjects\s+(?:e2e|integration|contract)\b|`,
|
|
10
|
+
String.raw`src\/tests\/(?:world|integration|e2e|contract)\b|`,
|
|
11
|
+
String.raw`jest[^\n|&;]*(?:e2e|integration|contract)`,
|
|
12
|
+
].join(''), 'u');
|
|
13
|
+
const UNIT_RUN_SCRIPTS = ['test', 'test:unit', 'test:ci', 'test:affected', 'test:coverage', 'test:cov'];
|
|
14
|
+
|
|
15
|
+
const runsE2e = text => E2E_COMMAND.test(String(text).replace(/--ignore-pattern[= ]+(?:"[^"]*"|'[^']*'|\S+)/gu, ''));
|
|
16
|
+
const withoutComments = text => text.split('\n').filter(line => !/^\s*#/u.test(line)).join('\n');
|
|
17
|
+
|
|
18
|
+
function inspectHooks(state) {
|
|
19
|
+
for (const hook of ['.husky/pre-commit', '.husky/pre-push']) {
|
|
20
|
+
const text = readTextFile(state.root, hook);
|
|
21
|
+
if (text === null) continue;
|
|
22
|
+
const body = withoutComments(text);
|
|
23
|
+
if (runsE2e(body)) state.finding(state.rule, hook, `${hook} runs integration, e2e or contract. They are manual only; hooks run unit, lint and typecheck.`);
|
|
24
|
+
for (const match of body.matchAll(/npm\s+run\s+([\w:.-]+)/gu)) state.pending.push(match[1]);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function inspectLintStaged(state) {
|
|
29
|
+
const lintStaged = state.pkg?.['lint-staged'];
|
|
30
|
+
if (lintStaged && runsE2e(Object.values(lintStaged).flat().join('\n')))
|
|
31
|
+
state.finding(state.rule, 'package.json', 'lint-staged runs an integration, e2e or contract command. They are manual only.');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function inspectHookScripts(state) {
|
|
35
|
+
for (const name of state.pending) {
|
|
36
|
+
if (state.called.has(name)) continue;
|
|
37
|
+
state.called.add(name);
|
|
38
|
+
const command = state.scripts[name];
|
|
39
|
+
if (typeof command !== 'string') continue;
|
|
40
|
+
if (runsE2e(command)) state.finding(state.rule, 'package.json', `Script ${name} is run by a husky hook and touches integration, e2e or contract. They are manual only.`);
|
|
41
|
+
for (const match of command.matchAll(/npm\s+run\s+([\w:.-]+)/gu)) state.pending.push(match[1]);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function inspectUnitGates(state) {
|
|
46
|
+
if (!state.tree.hasFile(state.jestConfig)) return;
|
|
47
|
+
for (const name of UNIT_RUN_SCRIPTS) {
|
|
48
|
+
const command = state.scripts[name];
|
|
49
|
+
if (typeof command === 'string' && /\bjest\b/u.test(command) && !/--selectProjects\s+unit\b/u.test(command))
|
|
50
|
+
state.finding(state.rule, 'package.json', `Script ${name} runs jest without --selectProjects unit and would run the integration, e2e or contract project.`);
|
|
51
|
+
}
|
|
52
|
+
const jestText = readTextFile(state.root, state.jestConfig) ?? '';
|
|
53
|
+
if (/collectCoverageFrom/u.test(jestText) && !/!src\/tests\/(?:\*\*|e2e)/u.test(jestText))
|
|
54
|
+
state.finding(state.rule, state.jestConfig, 'collectCoverageFrom must exclude src/tests/** so no integration, e2e or contract file counts toward coverage.');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function inspectWorkflows(state) {
|
|
58
|
+
for (const file of state.tree.files().filter(entry => /^\.github\/workflows\/[^/]+\.ya?ml$/u.test(entry))) {
|
|
59
|
+
const text = readTextFile(state.root, file);
|
|
60
|
+
if (text === null) continue;
|
|
61
|
+
const trigger = /^on:.*(?:\n(?:[ \t].*)?$)*/mu.exec(text)?.[0] ?? '';
|
|
62
|
+
if (!/\b(?:push|pull_request)\b/u.test(trigger)) continue;
|
|
63
|
+
if (runsE2e(withoutComments(text)))
|
|
64
|
+
state.finding(state.rule, file, `${file} runs e2e on push or pull_request. Move the e2e job to its own workflow with on: workflow_dispatch only.`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Check hooks, transitive hook scripts, unit scripts, coverage and automatic workflows. */
|
|
69
|
+
export function e2eInAutomaticGate({ root, tree, jestConfig, finding }) {
|
|
70
|
+
const rule = 'HFS_E2E_IN_AUTOMATIC_GATE';
|
|
71
|
+
let pkg = null;
|
|
72
|
+
try { pkg = JSON.parse(readTextFile(root, 'package.json') ?? ''); } catch { /* the package checks own invalid JSON */ }
|
|
73
|
+
const state = { root, tree, jestConfig, finding, rule, pkg, scripts: pkg?.scripts ?? {}, pending: [], called: new Set() };
|
|
74
|
+
inspectHooks(state);
|
|
75
|
+
inspectLintStaged(state);
|
|
76
|
+
inspectHookScripts(state);
|
|
77
|
+
inspectUnitGates(state);
|
|
78
|
+
inspectWorkflows(state);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The be side's default tsconfig excludes the world, integration, e2e and contract trees (they are checked by src/tests/tsconfig.json). */
|
|
82
|
+
export function testTreesOutOfDefaultProgram({ root, tree, finding }) {
|
|
83
|
+
const rule = 'HFS_E2E_IN_AUTOMATIC_GATE';
|
|
84
|
+
const tsconfigText = readTextFile(root, 'tsconfig.json');
|
|
85
|
+
let tsconfig = null;
|
|
86
|
+
try { tsconfig = tsconfigText === null ? null : JSON.parse(tsconfigText); } catch { /* the typecheck itself owns parsing */ }
|
|
87
|
+
if (tsconfig) {
|
|
88
|
+
const excludedText = JSON.stringify(tsconfig.exclude ?? []);
|
|
89
|
+
const excludesTestTrees = ['world', 'integration', 'e2e', 'contract'].every(tree => excludedText.includes(`src/tests/${tree}`));
|
|
90
|
+
const defaultAll = tsconfig.include === undefined && tsconfig.files === undefined;
|
|
91
|
+
const files = tree.files();
|
|
92
|
+
if (files.some(file => /^src\/tests\/(?:world|integration|e2e|contract)\/.+\.[cm]?tsx?$/u.test(file)) && !excludesTestTrees &&
|
|
93
|
+
(defaultAll || JSON.stringify(tsconfig.include ?? []).includes('src')))
|
|
94
|
+
finding(rule, 'tsconfig.json', 'The default tsconfig includes src/tests/{world,integration,e2e,contract}/**. Exclude those trees and check them with src/tests/tsconfig.json (typecheck:tests).');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -3,11 +3,21 @@ import { isInside } from './config.mjs';
|
|
|
3
3
|
import { slotAdmitsFile } from '../allows.mjs';
|
|
4
4
|
import { reachableViolation, relativePath } from './typescript.mjs';
|
|
5
5
|
import { sourceLocation } from '../../lib/ts-ast.mjs';
|
|
6
|
+
import { byCodeUnit } from '../../lib/list.mjs';
|
|
6
7
|
|
|
7
8
|
const FORBIDDEN_APP_ROLE = /(?:^|\.)(?:service|provider|providers|resolver|controller|handler|repository|entity|use-case|command|query|listener|consumer|processor)\.[cm]?[jt]sx?$/i;
|
|
8
9
|
const FORBIDDEN_DECLARATION = /(?:Service|Provider|Resolver|Controller|Handler|Repository|Entity|UseCase|Command|Query|Listener|Consumer|Processor)$/;
|
|
9
10
|
const FORBIDDEN_DECORATORS = new Set(['Controller', 'Resolver', 'Injectable', 'Processor', 'WebSocketGateway']);
|
|
10
|
-
const TRANSPORT_PACKAGES =
|
|
11
|
+
const TRANSPORT_PACKAGES = new RegExp(`^(?:${[
|
|
12
|
+
'@nestjs/(?:graphql|microservices|platform-[^/]+|websockets)(?:/|$)',
|
|
13
|
+
'@apollo/',
|
|
14
|
+
'apollo-server(?:/|$)',
|
|
15
|
+
'express(?:/|$)',
|
|
16
|
+
'fastify(?:/|$)',
|
|
17
|
+
'graphql(?:/|$)',
|
|
18
|
+
'class-validator(?:/|$)',
|
|
19
|
+
'class-transformer(?:/|$)',
|
|
20
|
+
].join('|')})`);
|
|
11
21
|
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']);
|
|
12
22
|
|
|
13
23
|
function absolute(root, relative) {
|
|
@@ -72,111 +82,142 @@ function roleEvidence(ts, sourceFile) {
|
|
|
72
82
|
return found;
|
|
73
83
|
}
|
|
74
84
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
: ts.isStringLiteralLike(element.propertyName)
|
|
85
|
-
? element.propertyName.text
|
|
86
|
-
: null;
|
|
85
|
+
// The destructured binding elements naming a transport export (or a computed binding, which cannot be
|
|
86
|
+
// proved to exclude one): `const { Controller, [x]: y } = ...`. `rest` elements are always opaque.
|
|
87
|
+
function destructuredTransportBindings(ts, elements, detail) {
|
|
88
|
+
return elements.flatMap(element => {
|
|
89
|
+
let selected = null;
|
|
90
|
+
if (!element.dotDotDotToken) {
|
|
91
|
+
const imported = element.propertyName ?? element.name;
|
|
92
|
+
if (ts.isIdentifier(imported) || ts.isStringLiteralLike(imported)) selected = imported.text;
|
|
93
|
+
}
|
|
87
94
|
return selected === null || NEST_COMMON_TRANSPORT.has(selected) ? [{ node: element, detail: selected ?? detail }] : [];
|
|
88
95
|
});
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const namespaceMemberUsages = (ts, node, isAlias) => {
|
|
99
|
+
if (ts.isPropertyAccessExpression(node) && isAlias(node.expression)
|
|
100
|
+
&& NEST_COMMON_TRANSPORT.has(node.name.text)) return [{ node: node.name, detail: node.name.text }];
|
|
101
|
+
if (ts.isElementAccessExpression(node) && isAlias(node.expression)) {
|
|
102
|
+
const selected = ts.isStringLiteralLike(node.argumentExpression) ? node.argumentExpression.text : null;
|
|
103
|
+
if (selected === null || NEST_COMMON_TRANSPORT.has(selected)) return [{ node: node.argumentExpression, detail: selected ?? 'computed @nestjs/common namespace access' }];
|
|
104
|
+
}
|
|
105
|
+
return [];
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const namespaceEscapeUsages = (ts, node, isAlias) => {
|
|
109
|
+
if (!ts.isVariableDeclaration(node) || !isAlias(node.initializer)) return [];
|
|
110
|
+
if (!ts.isObjectBindingPattern(node.name)) return [{ node: node.name, detail: 'escaped @nestjs/common namespace access' }];
|
|
111
|
+
return destructuredTransportBindings(ts, node.name.elements, 'computed @nestjs/common namespace destructuring');
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
function namespaceTransportUsages(ts, sourceFile, checker, binding) {
|
|
115
|
+
const usages = [];
|
|
116
|
+
const alias = binding.text;
|
|
117
|
+
const bindingSymbol = checker?.getSymbolAtLocation(binding);
|
|
118
|
+
const isAlias = node => ts.isIdentifier(node) && node.text === alias
|
|
119
|
+
&& (!bindingSymbol || checker?.getSymbolAtLocation(node) === bindingSymbol);
|
|
120
|
+
const visit = node => {
|
|
121
|
+
usages.push(...namespaceMemberUsages(ts, node, isAlias), ...namespaceEscapeUsages(ts, node, isAlias));
|
|
122
|
+
ts.forEachChild(node, visit);
|
|
113
123
|
};
|
|
124
|
+
visit(sourceFile);
|
|
125
|
+
return usages;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function importSpecifierOf(ts, statement) {
|
|
129
|
+
const importEqualsSpecifier = ts.isImportEqualsDeclaration(statement)
|
|
130
|
+
&& ts.isExternalModuleReference(statement.moduleReference)
|
|
131
|
+
&& statement.moduleReference.expression
|
|
132
|
+
&& ts.isStringLiteralLike(statement.moduleReference.expression)
|
|
133
|
+
? statement.moduleReference.expression
|
|
134
|
+
: null;
|
|
135
|
+
let importSpecifier = importEqualsSpecifier;
|
|
136
|
+
if (ts.isImportDeclaration(statement) && ts.isStringLiteralLike(statement.moduleSpecifier)) importSpecifier = statement.moduleSpecifier;
|
|
137
|
+
return importSpecifier;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function addCommonImportEvidence(ts, statement, importSpecifier, sourceFile, checker, found) {
|
|
141
|
+
if (ts.isImportEqualsDeclaration(statement)) {
|
|
142
|
+
found.push(...namespaceTransportUsages(ts, sourceFile, checker, statement.name).map(item => ({ ...item, specifier: importSpecifier.text })));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (!statement.importClause) return;
|
|
146
|
+
const bindings = statement.importClause.namedBindings;
|
|
147
|
+
if (bindings && ts.isNamespaceImport(bindings)) {
|
|
148
|
+
found.push(...namespaceTransportUsages(ts, sourceFile, checker, bindings.name).map(item => ({ ...item, specifier: importSpecifier.text })));
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (!bindings || !ts.isNamedImports(bindings)) return;
|
|
152
|
+
for (const element of bindings.elements) {
|
|
153
|
+
const imported = element.propertyName?.text ?? element.name.text;
|
|
154
|
+
if (NEST_COMMON_TRANSPORT.has(imported)) found.push({ node: element, specifier: importSpecifier.text, detail: imported });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function importTransportEvidence(ts, sourceFile, checker, found) {
|
|
114
159
|
for (const statement of sourceFile.statements) {
|
|
115
|
-
const
|
|
116
|
-
&& ts.isExternalModuleReference(statement.moduleReference)
|
|
117
|
-
&& statement.moduleReference.expression
|
|
118
|
-
&& ts.isStringLiteralLike(statement.moduleReference.expression)
|
|
119
|
-
? statement.moduleReference.expression
|
|
120
|
-
: null;
|
|
121
|
-
const importSpecifier = ts.isImportDeclaration(statement) && ts.isStringLiteralLike(statement.moduleSpecifier)
|
|
122
|
-
? statement.moduleSpecifier
|
|
123
|
-
: importEqualsSpecifier;
|
|
160
|
+
const importSpecifier = importSpecifierOf(ts, statement);
|
|
124
161
|
if (!importSpecifier) continue;
|
|
125
162
|
const specifier = importSpecifier.text;
|
|
126
163
|
if (TRANSPORT_PACKAGES.test(specifier)) {
|
|
127
164
|
found.push({ node: importSpecifier, specifier, detail: specifier });
|
|
128
165
|
continue;
|
|
129
166
|
}
|
|
130
|
-
if (specifier
|
|
131
|
-
if (ts.isImportEqualsDeclaration(statement)) {
|
|
132
|
-
found.push(...namespaceUsages(statement.name).map(item => ({ ...item, specifier })));
|
|
133
|
-
continue;
|
|
134
|
-
}
|
|
135
|
-
if (!statement.importClause) continue;
|
|
136
|
-
const bindings = statement.importClause.namedBindings;
|
|
137
|
-
if (bindings && ts.isNamespaceImport(bindings)) {
|
|
138
|
-
found.push(...namespaceUsages(bindings.name).map(item => ({ ...item, specifier })));
|
|
139
|
-
continue;
|
|
140
|
-
}
|
|
141
|
-
if (!bindings || !ts.isNamedImports(bindings)) continue;
|
|
142
|
-
for (const element of bindings.elements) {
|
|
143
|
-
const imported = element.propertyName?.text ?? element.name.text;
|
|
144
|
-
if (NEST_COMMON_TRANSPORT.has(imported)) found.push({ node: element, specifier, detail: imported });
|
|
145
|
-
}
|
|
167
|
+
if (specifier === '@nestjs/common') addCommonImportEvidence(ts, statement, importSpecifier, sourceFile, checker, found);
|
|
146
168
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function requiredSpecifier(ts, call) {
|
|
172
|
+
return ts.isCallExpression(call)
|
|
173
|
+
&& ts.isIdentifier(call.expression) && call.expression.text === 'require'
|
|
174
|
+
&& call.arguments.length === 1 && ts.isStringLiteralLike(call.arguments[0])
|
|
175
|
+
? call.arguments[0].text
|
|
176
|
+
: null;
|
|
177
|
+
}
|
|
178
|
+
|
|
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
|
+
function requiredMemberEvidence(ts, node, found) {
|
|
185
|
+
if (!(ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) || !ts.isCallExpression(node.expression)) return;
|
|
186
|
+
const specifier = requiredSpecifier(ts, node.expression);
|
|
187
|
+
const selected = requiredMemberSelection(ts, node);
|
|
188
|
+
const member = ts.isPropertyAccessExpression(node) ? node.name : node.argumentExpression;
|
|
189
|
+
if (specifier && TRANSPORT_PACKAGES.test(specifier)) {
|
|
190
|
+
found.push({ node: member, specifier, detail: `${selected ?? 'computed member'} from ${specifier}` });
|
|
191
|
+
} else if (specifier === '@nestjs/common' && (selected === null || NEST_COMMON_TRANSPORT.has(selected))) {
|
|
192
|
+
found.push({ node: member, specifier, detail: selected ?? 'computed @nestjs/common require access' });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function requiredVariableEvidence(ts, node, sourceFile, checker, found) {
|
|
197
|
+
if (!ts.isVariableDeclaration(node) || !node.initializer || !ts.isCallExpression(node.initializer)
|
|
198
|
+
|| !requiredSpecifier(ts, node.initializer)) return;
|
|
199
|
+
const specifier = requiredSpecifier(ts, node.initializer);
|
|
200
|
+
if (TRANSPORT_PACKAGES.test(specifier)) {
|
|
201
|
+
found.push({ node: node.initializer.arguments[0], specifier, detail: specifier });
|
|
202
|
+
} else if (specifier === '@nestjs/common') {
|
|
203
|
+
if (ts.isIdentifier(node.name)) {
|
|
204
|
+
found.push(...namespaceTransportUsages(ts, sourceFile, checker, node.name).map(item => ({ ...item, specifier })));
|
|
205
|
+
} else if (ts.isObjectBindingPattern(node.name)) {
|
|
206
|
+
found.push(...destructuredTransportBindings(ts, node.name.elements, 'computed @nestjs/common require destructuring').map(item => ({ ...item, specifier })));
|
|
176
207
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function requiredTransportEvidence(ts, node, sourceFile, checker, found) {
|
|
212
|
+
requiredMemberEvidence(ts, node, found);
|
|
213
|
+
requiredVariableEvidence(ts, node, sourceFile, checker, found);
|
|
214
|
+
ts.forEachChild(node, child => requiredTransportEvidence(ts, child, sourceFile, checker, found));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function transportFrameworkEvidence(ts, sourceFile, checker) {
|
|
218
|
+
const found = [];
|
|
219
|
+
importTransportEvidence(ts, sourceFile, checker, found);
|
|
220
|
+
requiredTransportEvidence(ts, sourceFile, sourceFile, checker, found);
|
|
180
221
|
return found;
|
|
181
222
|
}
|
|
182
223
|
|
|
@@ -207,7 +248,7 @@ function selectedReexportSurface(ts, declaration, selected) {
|
|
|
207
248
|
return next.size ? next : undefined;
|
|
208
249
|
}
|
|
209
250
|
|
|
210
|
-
function
|
|
251
|
+
function importedTransportBindings(ts, sourceFile) {
|
|
211
252
|
const imported = new Map();
|
|
212
253
|
for (const statement of sourceFile.statements) {
|
|
213
254
|
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteralLike(statement.moduleSpecifier) || !statement.importClause) continue;
|
|
@@ -217,60 +258,92 @@ function externalTransportReexport(ts, sourceFile, selected) {
|
|
|
217
258
|
for (const element of bindings.elements) imported.set(element.name.text, { specifier, imported: element.propertyName?.text ?? element.name.text });
|
|
218
259
|
} else if (bindings && ts.isNamespaceImport(bindings)) imported.set(bindings.name.text, { specifier, imported: null });
|
|
219
260
|
}
|
|
261
|
+
return imported;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const unnamedReexport = (statement, specifier, selected) => {
|
|
265
|
+
if (specifier !== '@nestjs/common' || selected === null || [...selected].some(name => NEST_COMMON_TRANSPORT.has(name))) {
|
|
266
|
+
return { node: statement.moduleSpecifier, specifier, detail: selected === null ? `unbounded ${specifier} re-export` : `re-exported ${[...selected].join(', ')}` };
|
|
267
|
+
}
|
|
268
|
+
return null;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const namedReexport = (clause, specifier, selected) => {
|
|
272
|
+
for (const element of clause.elements) {
|
|
273
|
+
if (selected !== null && !selected.has(element.name.text)) continue;
|
|
274
|
+
const original = element.propertyName?.text ?? element.name.text;
|
|
275
|
+
if (specifier !== '@nestjs/common' || NEST_COMMON_TRANSPORT.has(original)) return { node: element, specifier, detail: `${element.name.text} re-exported from ${specifier}` };
|
|
276
|
+
}
|
|
277
|
+
return null;
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
function directTransportReexport(ts, statement, specifier, selected) {
|
|
281
|
+
const clause = statement.exportClause;
|
|
282
|
+
if (!clause) return unnamedReexport(statement, specifier, selected);
|
|
283
|
+
if (ts.isNamespaceExport(clause)) {
|
|
284
|
+
return selected === null || selected.has(clause.name.text) ? { node: clause.name, specifier, detail: `namespace re-export from ${specifier}` } : null;
|
|
285
|
+
}
|
|
286
|
+
return ts.isNamedExports(clause) ? namedReexport(clause, specifier, selected) : null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** True for an import binding of a transport package, or of a transport symbol of @nestjs/common. */
|
|
290
|
+
const isTransportBinding = binding => (binding.specifier === '@nestjs/common'
|
|
291
|
+
? binding.imported !== null && NEST_COMMON_TRANSPORT.has(binding.imported)
|
|
292
|
+
: TRANSPORT_PACKAGES.test(binding.specifier));
|
|
293
|
+
|
|
294
|
+
function localTransportReexport(ts, statement, selected, imported) {
|
|
295
|
+
const clause = statement.exportClause;
|
|
296
|
+
if (!clause || !ts.isNamedExports(clause)) return null;
|
|
297
|
+
for (const element of clause.elements) {
|
|
298
|
+
if (selected !== null && !selected.has(element.name.text)) continue;
|
|
299
|
+
const binding = imported.get(element.propertyName?.text ?? element.name.text);
|
|
300
|
+
if (!binding || !isTransportBinding(binding)) continue;
|
|
301
|
+
return { node: element, specifier: binding.specifier, detail: `${element.name.text} re-exported from ${binding.specifier}` };
|
|
302
|
+
}
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const statementTransportReexport = (ts, statement, selected, imported) => {
|
|
307
|
+
const specifier = statement.moduleSpecifier && ts.isStringLiteralLike(statement.moduleSpecifier) ? statement.moduleSpecifier.text : null;
|
|
308
|
+
if (specifier && (specifier === '@nestjs/common' || TRANSPORT_PACKAGES.test(specifier))) return directTransportReexport(ts, statement, specifier, selected);
|
|
309
|
+
return specifier ? null : localTransportReexport(ts, statement, selected, imported);
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
function externalTransportReexport(ts, sourceFile, selected) {
|
|
313
|
+
const imported = importedTransportBindings(ts, sourceFile);
|
|
220
314
|
for (const statement of sourceFile.statements) {
|
|
221
315
|
if (!ts.isExportDeclaration(statement)) continue;
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
if (specifier && (specifier === '@nestjs/common' || TRANSPORT_PACKAGES.test(specifier))) {
|
|
225
|
-
if (!clause) {
|
|
226
|
-
if (specifier !== '@nestjs/common' || selected === null || [...selected].some(name => NEST_COMMON_TRANSPORT.has(name))) {
|
|
227
|
-
return { node: statement.moduleSpecifier, specifier, detail: selected === null ? `unbounded ${specifier} re-export` : `re-exported ${[...selected].join(', ')}` };
|
|
228
|
-
}
|
|
229
|
-
continue;
|
|
230
|
-
}
|
|
231
|
-
if (ts.isNamespaceExport(clause)) {
|
|
232
|
-
if (selected === null || selected.has(clause.name.text)) return { node: clause.name, specifier, detail: `namespace re-export from ${specifier}` };
|
|
233
|
-
continue;
|
|
234
|
-
}
|
|
235
|
-
if (!ts.isNamedExports(clause)) continue;
|
|
236
|
-
for (const element of clause.elements) {
|
|
237
|
-
if (selected !== null && !selected.has(element.name.text)) continue;
|
|
238
|
-
const original = element.propertyName?.text ?? element.name.text;
|
|
239
|
-
if (specifier !== '@nestjs/common' || NEST_COMMON_TRANSPORT.has(original)) return { node: element, specifier, detail: `${element.name.text} re-exported from ${specifier}` };
|
|
240
|
-
}
|
|
241
|
-
continue;
|
|
242
|
-
}
|
|
243
|
-
if (specifier || !clause || !ts.isNamedExports(clause)) continue;
|
|
244
|
-
for (const element of clause.elements) {
|
|
245
|
-
if (selected !== null && !selected.has(element.name.text)) continue;
|
|
246
|
-
const local = element.propertyName?.text ?? element.name.text;
|
|
247
|
-
const binding = imported.get(local);
|
|
248
|
-
if (!binding) continue;
|
|
249
|
-
if (binding.specifier !== '@nestjs/common' && !TRANSPORT_PACKAGES.test(binding.specifier)) continue;
|
|
250
|
-
if (binding.specifier === '@nestjs/common' && (binding.imported === null || !NEST_COMMON_TRANSPORT.has(binding.imported))) continue;
|
|
251
|
-
return { node: element, specifier: binding.specifier, detail: `${element.name.text} re-exported from ${binding.specifier}` };
|
|
252
|
-
}
|
|
316
|
+
const evidence = statementTransportReexport(ts, statement, selected, imported);
|
|
317
|
+
if (evidence) return evidence;
|
|
253
318
|
}
|
|
254
319
|
return null;
|
|
255
320
|
}
|
|
256
321
|
|
|
322
|
+
function reexportQueueKey(current) {
|
|
323
|
+
return `${current.file}\0${current.selected === null ? '*' : [...current.selected].sort(byCodeUnit).join(',')}`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function addReexportEdges(ts, context, current, queue) {
|
|
327
|
+
for (const edge of context.edges.get(current.file) ?? []) {
|
|
328
|
+
if (!edge.reexport) continue;
|
|
329
|
+
const selected = selectedReexportSurface(ts, edge.declaration, current.selected);
|
|
330
|
+
if (selected !== undefined) queue.push({ file: edge.to, selected, chain: [...current.chain, edge.to] });
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
257
334
|
function reexportedTransportEvidence(ts, context, sourceFiles, firstEdge) {
|
|
258
335
|
const queue = [{ file: firstEdge.to, selected: importedSurface(ts, firstEdge.declaration), chain: [firstEdge.from, firstEdge.to] }];
|
|
259
336
|
const visited = new Set();
|
|
260
337
|
while (queue.length) {
|
|
261
338
|
const current = queue.shift();
|
|
262
|
-
const key =
|
|
339
|
+
const key = reexportQueueKey(current);
|
|
263
340
|
if (visited.has(key)) continue;
|
|
264
341
|
visited.add(key);
|
|
265
342
|
const sourceFile = sourceFiles.get(path.resolve(current.file));
|
|
266
343
|
if (!sourceFile) continue;
|
|
267
344
|
const evidence = externalTransportReexport(ts, sourceFile, current.selected);
|
|
268
345
|
if (evidence) return { evidence, chain: current.chain };
|
|
269
|
-
|
|
270
|
-
if (!edge.reexport) continue;
|
|
271
|
-
const selected = selectedReexportSurface(ts, edge.declaration, current.selected);
|
|
272
|
-
if (selected !== undefined) queue.push({ file: edge.to, selected, chain: [...current.chain, edge.to] });
|
|
273
|
-
}
|
|
346
|
+
addReexportEdges(ts, context, current, queue);
|
|
274
347
|
}
|
|
275
348
|
return null;
|
|
276
349
|
}
|
|
@@ -288,56 +361,69 @@ function finding(config, edge, ruleId, message, chain) {
|
|
|
288
361
|
};
|
|
289
362
|
}
|
|
290
363
|
|
|
364
|
+
function checkApplicationComposition(config, ts, sourceFile, fileName, app, violations) {
|
|
365
|
+
const evidence = roleEvidence(ts, sourceFile);
|
|
366
|
+
if (evidence) {
|
|
367
|
+
violations.push({
|
|
368
|
+
ruleId: 'BE_APP_BUSINESS_ROLE',
|
|
369
|
+
path: relativePath(config.root, fileName),
|
|
370
|
+
...sourceLocation(sourceFile, evidence.node),
|
|
371
|
+
message: `Application composition source contains measurable business/provider role ${evidence.detail}. Move that role under src/features or src/modules.`,
|
|
372
|
+
});
|
|
373
|
+
} else if (slotAdmitsFile(config.hfs, relativePath(config.root, fileName)) !== true) {
|
|
374
|
+
violations.push({
|
|
375
|
+
ruleId: FORBIDDEN_APP_ROLE.test(path.posix.basename(app.relative)) ? 'BE_APP_BUSINESS_ROLE' : 'BE_APP_COMPOSITION_ONLY',
|
|
376
|
+
path: relativePath(config.root, fileName),
|
|
377
|
+
line: 1,
|
|
378
|
+
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).`,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function checkApplicationTransport(config, context, sourceFile, fileName, violations) {
|
|
385
|
+
for (const evidence of transportFrameworkEvidence(context.ts, sourceFile, context.checkerFor(fileName))) {
|
|
386
|
+
violations.push({
|
|
387
|
+
ruleId: 'BE_APPLICATION_TRANSPORT_FRAMEWORK',
|
|
388
|
+
path: relativePath(config.root, fileName),
|
|
389
|
+
...sourceLocation(sourceFile, evidence.node),
|
|
390
|
+
specifier: evidence.specifier,
|
|
391
|
+
message: `Feature application code imports transport framework surface ${evidence.detail}. Keep protocol decorators, request/response types, validation DTOs, and generated transport types under transport/.`,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function checkFeatureTransportEdges(config, context, sourceFiles, featureRoots, fileName, violations) {
|
|
397
|
+
for (const edge of context.edges.get(fileName) ?? []) {
|
|
398
|
+
const reexported = reexportedTransportEvidence(context.ts, context, sourceFiles, edge);
|
|
399
|
+
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));
|
|
400
|
+
const transportChain = reachableViolation(context.edges, edge, target => insideFeatureLayer(featureRoots, target, 'transport'));
|
|
401
|
+
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));
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function checkBackendSource(config, context, sourceFiles, roots, sourceFile, violations) {
|
|
406
|
+
const { moduleRoots, featureRoots, nonAppRoots } = roots;
|
|
407
|
+
const fileName = path.resolve(sourceFile.fileName);
|
|
408
|
+
const fromModules = insideAny(moduleRoots, fileName);
|
|
409
|
+
const fromFeatures = insideAny(featureRoots, fileName);
|
|
410
|
+
const fromApplication = fromFeatures && insideFeatureLayer(featureRoots, fileName, 'application');
|
|
411
|
+
const app = !fromModules && !fromFeatures ? appSource(config, fileName, nonAppRoots) : null;
|
|
412
|
+
if (app) checkApplicationComposition(config, context.ts, sourceFile, fileName, app, violations);
|
|
413
|
+
if (!fromModules && !fromFeatures) return;
|
|
414
|
+
if (fromApplication) checkApplicationTransport(config, context, sourceFile, fileName, violations);
|
|
415
|
+
if (fromApplication) checkFeatureTransportEdges(config, context, sourceFiles, featureRoots, fileName, violations);
|
|
416
|
+
}
|
|
417
|
+
|
|
291
418
|
/** Enforce backend direction and keep executable apps as measurable composition roots. */
|
|
292
419
|
export function checkBackend(config, context) {
|
|
293
420
|
const violations = [];
|
|
294
421
|
const moduleRoots = roots(config.root, config.backend.modules);
|
|
295
422
|
const featureRoots = roots(config.root, config.backend.features);
|
|
296
|
-
const
|
|
423
|
+
const sourceRoots = { moduleRoots, featureRoots, nonAppRoots: [...moduleRoots, ...featureRoots] };
|
|
297
424
|
const sourceFiles = new Map(context.files.map(file => [path.resolve(file.fileName), file]));
|
|
298
425
|
for (const sourceFile of context.files) {
|
|
299
|
-
|
|
300
|
-
const fromModules = insideAny(moduleRoots, fileName);
|
|
301
|
-
const fromFeatures = insideAny(featureRoots, fileName);
|
|
302
|
-
const fromApplication = fromFeatures && insideFeatureLayer(featureRoots, fileName, 'application');
|
|
303
|
-
const app = !fromModules && !fromFeatures ? appSource(config, fileName, nonAppRoots) : null;
|
|
304
|
-
if (app) {
|
|
305
|
-
const evidence = roleEvidence(context.ts, sourceFile);
|
|
306
|
-
if (evidence) {
|
|
307
|
-
violations.push({
|
|
308
|
-
ruleId: 'BE_APP_BUSINESS_ROLE',
|
|
309
|
-
path: relativePath(config.root, fileName),
|
|
310
|
-
...sourceLocation(sourceFile, evidence.node),
|
|
311
|
-
message: `Application composition source contains measurable business/provider role ${evidence.detail}. Move that role under src/features or src/modules.`,
|
|
312
|
-
});
|
|
313
|
-
} else if (slotAdmitsFile(config.hfs, relativePath(config.root, fileName)) !== true) {
|
|
314
|
-
violations.push({
|
|
315
|
-
ruleId: FORBIDDEN_APP_ROLE.test(path.posix.basename(app.relative)) ? 'BE_APP_BUSINESS_ROLE' : 'BE_APP_COMPOSITION_ONLY',
|
|
316
|
-
path: relativePath(config.root, fileName),
|
|
317
|
-
line: 1,
|
|
318
|
-
column: 1,
|
|
319
|
-
message: `Application source ${app.relative} is not a file its app slot requires or allows (main.ts, app.module.ts, <app>.options.ts).`,
|
|
320
|
-
});
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
if (!fromModules && !fromFeatures) continue;
|
|
324
|
-
if (fromApplication) {
|
|
325
|
-
for (const evidence of transportFrameworkEvidence(context.ts, sourceFile, context.checkerFor(fileName))) violations.push({
|
|
326
|
-
ruleId: 'BE_APPLICATION_TRANSPORT_FRAMEWORK',
|
|
327
|
-
path: relativePath(config.root, fileName),
|
|
328
|
-
...sourceLocation(sourceFile, evidence.node),
|
|
329
|
-
specifier: evidence.specifier,
|
|
330
|
-
message: `Feature application code imports transport framework surface ${evidence.detail}. Keep protocol decorators, request/response types, validation DTOs, and generated transport types under transport/.`,
|
|
331
|
-
});
|
|
332
|
-
}
|
|
333
|
-
for (const edge of context.edges.get(fileName) ?? []) {
|
|
334
|
-
if (fromApplication) {
|
|
335
|
-
const reexported = reexportedTransportEvidence(context.ts, context, sourceFiles, edge);
|
|
336
|
-
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));
|
|
337
|
-
const transportChain = reachableViolation(context.edges, edge, target => insideFeatureLayer(featureRoots, target, 'transport'));
|
|
338
|
-
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));
|
|
339
|
-
}
|
|
340
|
-
}
|
|
426
|
+
checkBackendSource(config, context, sourceFiles, sourceRoots, sourceFile, violations);
|
|
341
427
|
}
|
|
342
428
|
return violations;
|
|
343
429
|
}
|