@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
|
@@ -1,18 +1,14 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
1
|
import path from 'node:path';
|
|
3
|
-
import {
|
|
4
|
-
import { readJsonFile as readJson } from '../../lib/json.mjs';
|
|
2
|
+
import { isInside } from './config.mjs';
|
|
5
3
|
import { frameworkPinnedRootFiles } from './framework-pinned.mjs';
|
|
6
|
-
import {
|
|
7
|
-
import { normalizedSymbolValue
|
|
4
|
+
import { reachableViolation, relativePath } from './typescript.mjs';
|
|
5
|
+
import { normalizedSymbolValue } from './ast-walks.mjs';
|
|
8
6
|
import { sourceLocation } from '../../lib/ts-ast.mjs';
|
|
9
|
-
|
|
7
|
+
import { checkWorldRenderBoundaries } from './frontend-world.mjs';
|
|
8
|
+
import { checkGrammar } from './frontend-grammar.mjs';
|
|
9
|
+
import { checkRoute } from './frontend-routing.mjs';
|
|
10
10
|
const FEATURE_TIERS = new Set(['pages', 'layouts', 'overlays']);
|
|
11
11
|
const COMPONENT_TIERS = new Set(['blocks', 'composites', 'branches', 'leaves']);
|
|
12
|
-
const LOWER_COMPONENT_TIERS = new Set(['composites', 'branches', 'leaves']);
|
|
13
|
-
const WORLD_NAVIGATION_CALLS = new Set(['useParams', 'usePathname', 'useRouter', 'useSearchParams', 'useSelectedLayoutSegment', 'useSelectedLayoutSegments']);
|
|
14
|
-
const WORLD_INTL_CALLS = new Set(['useLocale', 'useMessages', 'useNow', 'useTimeZone', 'useTranslations']);
|
|
15
|
-
const WORLD_SWR_CALLS = new Set(['default', 'useSWR', 'useSWRConfig', 'useSWRImmutable', 'useSWRMutation']);
|
|
16
12
|
|
|
17
13
|
function absolute(root, relative) {
|
|
18
14
|
return path.resolve(root, ...relative.split('/'));
|
|
@@ -54,279 +50,8 @@ function violation(config, sourceFile, node, ruleId, message, extra = {}) {
|
|
|
54
50
|
};
|
|
55
51
|
}
|
|
56
52
|
|
|
57
|
-
function grammarExport(manifest, packageName, specifier) {
|
|
58
|
-
const key = specifier === packageName ? '.' : `.${specifier.slice(packageName.length)}`;
|
|
59
|
-
const value = manifest?.exports?.[key];
|
|
60
|
-
const targets = exportTargetStrings(value);
|
|
61
|
-
return targets.length > 0 && targets.every(target => target.startsWith('./') && !target.includes('\\') && !target.split('/').includes('..'));
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function grammarExportTargets(manifest, packageRoot, packageName, specifier) {
|
|
65
|
-
const key = specifier === packageName ? '.' : `.${specifier.slice(packageName.length)}`;
|
|
66
|
-
return exportTargetStrings(manifest?.exports?.[key]).filter(target => target.startsWith('./') && !target.includes('\\') && !target.split('/').includes('..'))
|
|
67
|
-
.map(target => canonical(path.resolve(packageRoot, target)));
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function literalModules(ts, sourceFile, checker) {
|
|
71
|
-
const modules = [];
|
|
72
|
-
const visit = node => {
|
|
73
|
-
if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteralLike(node.moduleSpecifier)) {
|
|
74
|
-
modules.push({ node: node.moduleSpecifier, specifier: node.moduleSpecifier.text });
|
|
75
|
-
} else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)
|
|
76
|
-
&& node.moduleReference.expression && ts.isStringLiteralLike(node.moduleReference.expression)) {
|
|
77
|
-
modules.push({ node: node.moduleReference.expression, specifier: node.moduleReference.expression.text });
|
|
78
|
-
} else if (ts.isCallExpression(node) && node.arguments.length > 0 && ts.isStringLiteralLike(node.arguments[0])
|
|
79
|
-
&& (node.expression.kind === ts.SyntaxKind.ImportKeyword || isUnshadowedCommonJsRequire(ts, checker, node.expression))) {
|
|
80
|
-
modules.push({ node: node.arguments[0], specifier: node.arguments[0].text });
|
|
81
|
-
}
|
|
82
|
-
ts.forEachChild(node, visit);
|
|
83
|
-
};
|
|
84
|
-
visit(sourceFile);
|
|
85
|
-
return modules;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function cssImports(content) {
|
|
89
|
-
const imports = [];
|
|
90
|
-
const withoutComments = content.replace(/\/\*[\s\S]*?\*\//g, match => match.replace(/[^\r\n]/g, ' '));
|
|
91
|
-
const pattern = /@import\s+(?:url\(\s*(?:(["'])(.*?)\1|([^\s)]+))\s*\)|(["'])(.*?)\4)/gi;
|
|
92
|
-
for (const match of withoutComments.matchAll(pattern)) {
|
|
93
|
-
imports.push({ specifier: match[2] ?? match[3] ?? match[5], index: match.index });
|
|
94
|
-
}
|
|
95
|
-
return imports;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function checkGrammar(config, context) {
|
|
99
|
-
const grammar = config.frontend.grammar;
|
|
100
|
-
if (!grammar) return [];
|
|
101
|
-
const violations = [];
|
|
102
|
-
const local = context.workspaces.find(workspace => workspace.name === grammar.package);
|
|
103
|
-
// Node resolution per consumer: in an npm-workspaces monorepo a consumer whose range differs from the
|
|
104
|
-
// hoisted copy gets its own apps/<app>/node_modules/<package> (one consumer: apps/app on 0.5.0 beside a hoisted
|
|
105
|
-
// 0.4.11). Every consumer is judged against the copy it actually resolves, never the hoisted one alone.
|
|
106
|
-
// An app installs once, at its root (config.packageRoot): a side folder has no node_modules of its own.
|
|
107
|
-
const installRoot = config.packageRoot ?? config.root;
|
|
108
|
-
const installedFrom = (from) => {
|
|
109
|
-
for (let dir = from; isInside(installRoot, dir); dir = path.dirname(dir)) {
|
|
110
|
-
const candidate = path.join(dir, 'node_modules', ...grammar.package.split('/'));
|
|
111
|
-
if (fs.existsSync(path.join(candidate, 'package.json'))) return candidate;
|
|
112
|
-
if (dir === installRoot || path.dirname(dir) === dir) break;
|
|
113
|
-
}
|
|
114
|
-
return path.join(installRoot, 'node_modules', ...grammar.package.split('/'));
|
|
115
|
-
};
|
|
116
|
-
const consumers = grammar.consumerManifests.map(relative => {
|
|
117
|
-
const root = path.dirname(path.join(config.root, ...relative.split('/')));
|
|
118
|
-
const packageRoot = local?.root ?? installedFrom(root);
|
|
119
|
-
return { relative, root, packageRoot, packageManifest: readJson(path.join(packageRoot, 'package.json')),
|
|
120
|
-
manifest: readJson(path.join(config.root, ...relative.split('/'))) ?? {} };
|
|
121
|
-
});
|
|
122
|
-
const installs = [...new Map(consumers.map(consumer => [consumer.packageRoot, consumer])).values()];
|
|
123
|
-
const packageRoot = installs[0]?.packageRoot ?? local?.root ?? path.join(installRoot, 'node_modules', ...grammar.package.split('/'));
|
|
124
|
-
const manifestFile = path.join(packageRoot, 'package.json');
|
|
125
|
-
const manifest = readJson(manifestFile);
|
|
126
|
-
const ownerOf = (fileName) => consumers.filter(consumer => isInside(consumer.root, fileName))
|
|
127
|
-
.sort((a, b) => b.root.length - a.root.length)[0] ?? null;
|
|
128
|
-
const contractProblems = [];
|
|
129
|
-
const styleBypasses = [];
|
|
130
|
-
for (const install of installs) {
|
|
131
|
-
const where = installs.length > 1 ? ` (as ${install.relative} resolves it, ${relativePath(config.root, install.packageRoot)})` : '';
|
|
132
|
-
if (install.packageManifest?.name !== grammar.package) contractProblems.push(`installed package ${grammar.package} is unavailable or has a different name${where}`);
|
|
133
|
-
if (!grammarExport(install.packageManifest, grammar.package, grammar.entry)) contractProblems.push(`${grammar.entry} is not a safe declared package export${where}`);
|
|
134
|
-
if (!grammarExport(install.packageManifest, grammar.package, grammar.styleEntry)) contractProblems.push(`${grammar.styleEntry} is not a safe declared style export${where}`);
|
|
135
|
-
const actualPeers = Object.keys(install.packageManifest?.peerDependencies ?? {}).sort();
|
|
136
|
-
const selectedPeers = [...grammar.peers].sort();
|
|
137
|
-
if (JSON.stringify(actualPeers) !== JSON.stringify(selectedPeers)) {
|
|
138
|
-
contractProblems.push(`${grammar.package} peerDependencies must exactly match the selected peers ${selectedPeers.join(', ')}${where}`);
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
for (const consumer of consumers) if (!Object.hasOwn(consumer.manifest.dependencies ?? {}, grammar.package)) {
|
|
142
|
-
contractProblems.push(`${consumer.relative} does not declare ${grammar.package} as a runtime dependency`);
|
|
143
|
-
}
|
|
144
|
-
for (const peer of grammar.peers) {
|
|
145
|
-
for (const consumer of consumers) if (!Object.hasOwn(consumer.manifest.dependencies ?? {}, peer) && !Object.hasOwn(consumer.manifest.peerDependencies ?? {}, peer)) {
|
|
146
|
-
contractProblems.push(`${consumer.relative} does not declare Grammar peer ${peer}`);
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
for (const source of grammar.styleSources) {
|
|
150
|
-
const content = fs.readFileSync(path.join(config.root, ...source.split('/')), 'utf8');
|
|
151
|
-
const grammarStyles = cssImports(content).filter(item => item.specifier === grammar.package || item.specifier.startsWith(`${grammar.package}/`));
|
|
152
|
-
if (!grammarStyles.some(item => item.specifier === grammar.styleEntry)) contractProblems.push(`${source} does not import ${grammar.styleEntry}`);
|
|
153
|
-
for (const imported of grammarStyles) if (imported.specifier !== grammar.styleEntry) {
|
|
154
|
-
contractProblems.push(`${source} imports Grammar style ${imported.specifier} outside the selected style entry`);
|
|
155
|
-
styleBypasses.push({ ruleId: 'ARCH_GRAMMAR_EXPORT_BYPASS', path: source,
|
|
156
|
-
line: content.slice(0, imported.index).split(/\r?\n/).length, column: 1, package: grammar.package, specifier: imported.specifier,
|
|
157
|
-
message: `Style source must use the selected Grammar style entry ${grammar.styleEntry}; ${imported.specifier} is outside that contract.` });
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
if (contractProblems.length) violations.push({
|
|
161
|
-
ruleId: 'ARCH_GRAMMAR_CONTRACT_INVALID',
|
|
162
|
-
path: fs.existsSync(manifestFile) && isInside(config.root, manifestFile) ? relativePath(config.root, manifestFile) : 'package.json',
|
|
163
|
-
line: 1,
|
|
164
|
-
column: 1,
|
|
165
|
-
package: grammar.package,
|
|
166
|
-
message: `Grammar contract is invalid: ${contractProblems.join('; ')}.`,
|
|
167
|
-
});
|
|
168
|
-
const allowed = new Set([grammar.entry, grammar.styleEntry]);
|
|
169
|
-
violations.push(...styleBypasses);
|
|
170
|
-
for (const sourceFile of context.files) {
|
|
171
|
-
if (installs.some(install => isInside(install.packageRoot, sourceFile.fileName))) continue;
|
|
172
|
-
const owner = ownerOf(sourceFile.fileName);
|
|
173
|
-
if (!owner) continue;
|
|
174
|
-
const edgesByStart = new Map((context.edges.get(path.resolve(sourceFile.fileName)) ?? []).map(edge => [edge.node.getStart(sourceFile), edge]));
|
|
175
|
-
for (const reference of literalModules(context.ts, sourceFile, context.checkerFor(sourceFile.fileName))) {
|
|
176
|
-
if (reference.specifier !== grammar.package && !reference.specifier.startsWith(`${grammar.package}/`)) continue;
|
|
177
|
-
if (allowed.has(reference.specifier)) {
|
|
178
|
-
const edge = edgesByStart.get(reference.node.getStart(sourceFile));
|
|
179
|
-
const expected = grammarExportTargets(owner.packageManifest, owner.packageRoot, grammar.package, reference.specifier);
|
|
180
|
-
if (!edge || expected.includes(canonical(edge.to))) continue;
|
|
181
|
-
}
|
|
182
|
-
violations.push(violation(config, sourceFile, reference.node, 'ARCH_GRAMMAR_EXPORT_BYPASS',
|
|
183
|
-
`Product source must import the selected Grammar code entry ${grammar.entry} or style entry ${grammar.styleEntry}; ${reference.specifier} is outside that contract.`,
|
|
184
|
-
{ specifier: reference.specifier, package: grammar.package }));
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
return violations;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function hasClientDirective(ts, sourceFile) {
|
|
191
|
-
return sourceFile.statements.find(statement => ts.isExpressionStatement(statement)
|
|
192
|
-
&& ts.isStringLiteral(statement.expression) && statement.expression.text === 'use client');
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
function importsForSource(context, fileName) {
|
|
196
|
-
return context.edges.get(path.resolve(fileName)) ?? [];
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
function importBindings(ts, sourceFile, edges) {
|
|
200
|
-
const byStart = new Map(edges.map(edge => [edge.node.getStart(sourceFile), edge]));
|
|
201
|
-
const bindings = new Map();
|
|
202
|
-
for (const statement of sourceFile.statements) {
|
|
203
|
-
if (!ts.isImportDeclaration(statement) || !statement.importClause || !ts.isStringLiteralLike(statement.moduleSpecifier)) continue;
|
|
204
|
-
const edge = byStart.get(statement.moduleSpecifier.getStart(sourceFile));
|
|
205
|
-
if (!edge) continue;
|
|
206
|
-
const clause = statement.importClause;
|
|
207
|
-
if (clause.name && !clause.isTypeOnly) bindings.set(clause.name.text, edge);
|
|
208
|
-
if (clause.namedBindings && ts.isNamespaceImport(clause.namedBindings)) bindings.set(clause.namedBindings.name.text, edge);
|
|
209
|
-
if (clause.namedBindings && ts.isNamedImports(clause.namedBindings)) {
|
|
210
|
-
for (const element of clause.namedBindings.elements) if (!element.isTypeOnly && !clause.isTypeOnly) bindings.set(element.name.text, edge);
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
return bindings;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function importedNames(ts, sourceFile, moduleName) {
|
|
217
|
-
const names = new Map();
|
|
218
|
-
for (const statement of sourceFile.statements) {
|
|
219
|
-
if (!ts.isImportDeclaration(statement) || statement.moduleSpecifier.text !== moduleName || !statement.importClause || statement.importClause.isTypeOnly) continue;
|
|
220
|
-
const named = statement.importClause.namedBindings;
|
|
221
|
-
if (!named || !ts.isNamedImports(named)) continue;
|
|
222
|
-
for (const element of named.elements) if (!element.isTypeOnly) names.set(element.name.text, element.propertyName?.text ?? element.name.text);
|
|
223
|
-
}
|
|
224
|
-
return names;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
function jsxRootName(ts, tagName) {
|
|
228
|
-
if (ts.isIdentifier(tagName)) return tagName.text;
|
|
229
|
-
if (ts.isPropertyAccessExpression(tagName)) {
|
|
230
|
-
let current = tagName;
|
|
231
|
-
while (ts.isPropertyAccessExpression(current)) current = current.expression;
|
|
232
|
-
return ts.isIdentifier(current) ? current.text : null;
|
|
233
|
-
}
|
|
234
|
-
return null;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
function routeFunction(ts, sourceFile) {
|
|
238
|
-
const variables = new Map();
|
|
239
|
-
const functions = new Map();
|
|
240
|
-
let directDefault = null;
|
|
241
|
-
let exportNode = null;
|
|
242
|
-
for (const statement of sourceFile.statements) {
|
|
243
|
-
if (ts.isVariableStatement(statement)) {
|
|
244
|
-
for (const declaration of statement.declarationList.declarations) {
|
|
245
|
-
if (ts.isIdentifier(declaration.name) && declaration.initializer
|
|
246
|
-
&& (ts.isArrowFunction(declaration.initializer) || ts.isFunctionExpression(declaration.initializer))) variables.set(declaration.name.text, declaration.initializer);
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
if (ts.isFunctionDeclaration(statement) && statement.name) functions.set(statement.name.text, statement);
|
|
250
|
-
if (ts.isFunctionDeclaration(statement) && statement.modifiers?.some(item => item.kind === ts.SyntaxKind.DefaultKeyword)) directDefault = statement;
|
|
251
|
-
if (ts.isExportAssignment(statement) && !statement.isExportEquals) exportNode = statement;
|
|
252
|
-
}
|
|
253
|
-
if (directDefault) return { fn: directDefault, exportNode: directDefault };
|
|
254
|
-
if (!exportNode || !ts.isIdentifier(exportNode.expression)) return { fn: null, exportNode };
|
|
255
|
-
return { fn: variables.get(exportNode.expression.text) ?? functions.get(exportNode.expression.text) ?? null, exportNode };
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
function routePageRoots(context, sourceFile, featureRoots) {
|
|
259
|
-
const workspace = context.workspaceOf(sourceFile.fileName);
|
|
260
|
-
if (!workspace) return featureRoots.map(root => path.join(root, 'pages'));
|
|
261
|
-
const local = featureRoots.filter(root => context.workspaceOf(root) === workspace);
|
|
262
|
-
return (local.length ? local : featureRoots).map(root => path.join(root, 'pages'));
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
function routingTermination(ts, statement, navigation) {
|
|
266
|
-
if (ts.isThrowStatement(statement)) return true;
|
|
267
|
-
if (ts.isBlock(statement)) return statement.statements.length > 0 && routingTermination(ts, statement.statements.at(-1), navigation);
|
|
268
|
-
const expression = ts.isExpressionStatement(statement) ? statement.expression : (ts.isReturnStatement(statement) ? statement.expression : null);
|
|
269
|
-
return Boolean(expression && ts.isCallExpression(expression) && ts.isIdentifier(expression.expression)
|
|
270
|
-
&& ['redirect', 'notFound'].includes(navigation.get(expression.expression.text)));
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
function routingGuard(ts, node, navigation) {
|
|
274
|
-
return ts.isIfStatement(node) && !node.elseStatement && routingTermination(ts, node.thenStatement, navigation);
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
function checkRoute(config, context, sourceFile, roots) {
|
|
278
|
-
const { ts } = context;
|
|
279
|
-
const violations = [];
|
|
280
|
-
const edges = importsForSource(context, sourceFile.fileName);
|
|
281
|
-
const bindings = importBindings(ts, sourceFile, edges);
|
|
282
|
-
const route = routeFunction(ts, sourceFile);
|
|
283
|
-
if (!route.exportNode || !route.fn) {
|
|
284
|
-
violations.push({ ruleId: 'FE_ROUTE_DEFAULT_EXPORT', path: relativePath(config.root, sourceFile.fileName), line: 1, column: 1,
|
|
285
|
-
message: 'A page.tsx must default-export one local route function.' });
|
|
286
|
-
return violations;
|
|
287
|
-
}
|
|
288
|
-
const jsx = [];
|
|
289
|
-
const navigation = importedNames(ts, sourceFile, 'next/navigation');
|
|
290
|
-
const adapterCalls = [];
|
|
291
|
-
const decisions = [];
|
|
292
|
-
const visit = (node, insideNavigationArgument = false) => {
|
|
293
|
-
if (ts.isJsxSelfClosingElement(node) || ts.isJsxElement(node)) jsx.push(node);
|
|
294
|
-
if (ts.isIfStatement(node) || ts.isSwitchStatement(node) || (ts.isConditionalExpression(node) && !insideNavigationArgument)) decisions.push(node);
|
|
295
|
-
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && ['redirect', 'notFound'].includes(navigation.get(node.expression.text))) {
|
|
296
|
-
adapterCalls.push(node);
|
|
297
|
-
for (const argument of node.arguments) visit(argument, true);
|
|
298
|
-
return;
|
|
299
|
-
}
|
|
300
|
-
ts.forEachChild(node, child => visit(child, insideNavigationArgument));
|
|
301
|
-
};
|
|
302
|
-
visit(route.fn);
|
|
303
|
-
const pages = routePageRoots(context, sourceFile, roots.features);
|
|
304
|
-
const mountedPages = jsx.filter(node => {
|
|
305
|
-
const opening = ts.isJsxElement(node) ? node.openingElement : node;
|
|
306
|
-
const local = jsxRootName(ts, opening.tagName);
|
|
307
|
-
const edge = local ? bindings.get(local) : null;
|
|
308
|
-
return edge && Boolean(reachableViolation(context.edges, edge, target => insideAny(pages, target), { follow: candidate => candidate.reexport && candidate.runtime }));
|
|
309
|
-
});
|
|
310
|
-
const navigationOnly = jsx.length === 0 && adapterCalls.length === 1;
|
|
311
|
-
if (!navigationOnly && (mountedPages.length !== 1 || jsx.length !== 1)) {
|
|
312
|
-
violations.push(violation(config, sourceFile, route.fn, 'FE_ROUTE_ONE_PAGE', 'A page.tsx route must mount exactly one pages-tier component, or be a zero-JSX redirect/notFound adapter.', { ts }));
|
|
313
|
-
}
|
|
314
|
-
if (!navigationOnly) for (const decision of decisions) if (!routingGuard(ts, decision, navigation)) {
|
|
315
|
-
violations.push(violation(config, sourceFile, decision, 'FE_ROUTE_DRAWING_DECISION', 'Visual route files may use terminal redirect/notFound guards but cannot select or omit visual composition.', { ts }));
|
|
316
|
-
}
|
|
317
|
-
const clientDirective = hasClientDirective(ts, sourceFile);
|
|
318
|
-
if (clientDirective) violations.push(violation(config, sourceFile, clientDirective, 'FE_ROUTE_CLIENT_BOUNDARY', 'A page.tsx route adapter cannot own the client boundary.', { ts }));
|
|
319
|
-
const forbiddenRouteHooks = new Set(['useRouter', 'usePathname', 'useSearchParams', 'useParams', 'useSelectedLayoutSegment', 'useSelectedLayoutSegments']);
|
|
320
|
-
for (const [local, imported] of navigation) if (forbiddenRouteHooks.has(imported)) {
|
|
321
|
-
const identifier = sourceFile.statements.flatMap(statement => ts.isImportDeclaration(statement) && statement.importClause?.namedBindings && ts.isNamedImports(statement.importClause.namedBindings)
|
|
322
|
-
? statement.importClause.namedBindings.elements.filter(item => item.name.text === local).map(item => item.name) : [])[0];
|
|
323
|
-
if (identifier) violations.push(violation(config, sourceFile, identifier, 'FE_ROUTE_CLIENT_HOOK', `Server route adapter imports ${imported}.`, { ts }));
|
|
324
|
-
}
|
|
325
|
-
return violations;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
53
|
function roleEntry(relative) {
|
|
329
|
-
const parts = String(relative).
|
|
54
|
+
const parts = String(relative).replaceAll('\\', '/').split('/');
|
|
330
55
|
return parts.length === 1 && /^index\.[cm]?[jt]sx?$/i.test(parts[0]);
|
|
331
56
|
}
|
|
332
57
|
|
|
@@ -364,11 +89,9 @@ function checkFrontendSourceLayout(config, sourceFile, roots) {
|
|
|
364
89
|
if (located.role === 'components') valid = COMPONENT_TIERS.has(first) || roleEntry(located.relative);
|
|
365
90
|
if (located.role === 'hooks') valid = located.relative.includes('/') || roleEntry(located.relative);
|
|
366
91
|
return valid ? [] : [{ ruleId: 'FE_SOURCE_LAYOUT_INVALID', path: relativePath(config.root, sourceFile.fileName), line: 1, column: 1,
|
|
367
|
-
message:
|
|
368
|
-
|
|
369
|
-
: located.role
|
|
370
|
-
? 'Frontend components contain only blocks, composites, branches and leaves plus an optional root public entry.'
|
|
371
|
-
: 'Every authored custom hook is grouped below a domain folder under hooks; only the root public entry may sit directly under hooks.' }];
|
|
92
|
+
message: ({ features: 'Frontend features contain only pages, layouts and overlays plus an optional root public entry.',
|
|
93
|
+
components: 'Frontend components contain only blocks, composites, branches and leaves plus an optional root public entry.',
|
|
94
|
+
hooks: 'Every authored custom hook is grouped below a domain folder under hooks; only the root public entry may sit directly under hooks.' })[located.role] }];
|
|
372
95
|
}
|
|
373
96
|
|
|
374
97
|
function declarationName(ts, declaration) {
|
|
@@ -379,572 +102,131 @@ function declarationName(ts, declaration) {
|
|
|
379
102
|
return null;
|
|
380
103
|
}
|
|
381
104
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
const checker =
|
|
386
|
-
const
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
const
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
105
|
+
const HOOK_NAME = /^use[A-Z0-9]/;
|
|
106
|
+
|
|
107
|
+
function reportCustomHook(state, symbol, node) {
|
|
108
|
+
const { ts, checker, seen, violations, config, sourceFile } = state;
|
|
109
|
+
const identity = normalizedSymbolValue(ts, checker, symbol) ?? symbol;
|
|
110
|
+
if (!identity || seen.has(identity) || checker.getTypeOfSymbolAtLocation(identity, node).getCallSignatures().length === 0) return;
|
|
111
|
+
seen.add(identity);
|
|
112
|
+
violations.push(violation(config, sourceFile, node, 'FE_CUSTOM_HOOK_LOCATION',
|
|
113
|
+
'Every authored custom useX hook declaration belongs under the configured hooks root; calling built-in React hooks inside a visual is still valid.', { ts }));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function inspectHookDeclaration(state, declaration) {
|
|
117
|
+
const name = declarationName(state.ts, declaration);
|
|
118
|
+
if (!name || !HOOK_NAME.test(name.text)) return;
|
|
119
|
+
const symbol = state.checker.getSymbolAtLocation(name);
|
|
120
|
+
reportCustomHook(state, symbol, name);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function visitHookDeclarations(state, node) {
|
|
124
|
+
const { ts } = state;
|
|
125
|
+
if (ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node) || ts.isVariableDeclaration(node)) inspectHookDeclaration(state, node);
|
|
126
|
+
ts.forEachChild(node, child => visitHookDeclarations(state, child));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function reportExportedHooks(state) {
|
|
130
|
+
const { ts, checker, sourceFile } = state;
|
|
406
131
|
const module = checker.getSymbolAtLocation(sourceFile);
|
|
407
132
|
for (const exposed of module ? checker.getExportsOfModule(module) : []) {
|
|
408
|
-
if (
|
|
133
|
+
if (!HOOK_NAME.test(exposed.name)) continue;
|
|
409
134
|
const identity = normalizedSymbolValue(ts, checker, exposed);
|
|
410
|
-
for (const declaration of identity?.getDeclarations?.() ?? [])
|
|
411
|
-
|
|
412
|
-
|
|
135
|
+
for (const declaration of identity?.getDeclarations?.() ?? []) {
|
|
136
|
+
if (declaration.getSourceFile() !== sourceFile) continue;
|
|
137
|
+
reportCustomHook(state, identity, declarationName(ts, declaration) ?? declaration);
|
|
413
138
|
}
|
|
414
139
|
}
|
|
415
|
-
return violations;
|
|
416
140
|
}
|
|
417
141
|
|
|
418
|
-
function
|
|
142
|
+
function checkCustomHookLocations(config, context, sourceFile, roots) {
|
|
143
|
+
if (insideAny(roots.hooks, sourceFile.fileName)) return [];
|
|
419
144
|
const { ts } = context;
|
|
420
|
-
const
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
for (const edge of importsForSource(context, fileName)) {
|
|
425
|
-
if (!edge.runtime) continue;
|
|
426
|
-
const configuredHookBarrel = insideAny(roots.hooks, edge.to) && /^index\.[cm]?[jt]sx?$/i.test(path.basename(edge.to));
|
|
427
|
-
const hookChain = configuredHookBarrel ? null
|
|
428
|
-
: reachableViolation(context.edges, edge, target => insideAny(roots.hooks, target), { follow: candidate => candidate.reexport && candidate.runtime });
|
|
429
|
-
if (hookChain) {
|
|
430
|
-
violations.push({ ruleId: 'FE_COMPONENT_DEEP_HOOK_IMPORT', path: relativePath(config.root, fileName), line: edge.line, column: edge.column,
|
|
431
|
-
specifier: edge.specifier, resolvedPath: relativePath(config.root, hookChain.at(-1)), dependencyChain: hookChain.map(item => relativePath(config.root, item)),
|
|
432
|
-
message: 'Components must enter data hooks through the owning hooks barrel.' });
|
|
433
|
-
}
|
|
434
|
-
if (pure) {
|
|
435
|
-
const chain = reachableViolation(context.edges, edge, target => insideAny(roots.hooks, target) || insideAny(roots.transport, target),
|
|
436
|
-
{ follow: candidate => candidate.reexport && candidate.runtime });
|
|
437
|
-
if (chain) violations.push({ ruleId: 'FE_PURE_REACHES_DATA', path: relativePath(config.root, fileName), line: edge.line, column: edge.column,
|
|
438
|
-
specifier: edge.specifier, resolvedPath: relativePath(config.root, chain.at(-1)), dependencyChain: chain.map(item => relativePath(config.root, item)),
|
|
439
|
-
message: 'Pure component.tsx cannot reach hooks or API transport through an alias, relative import, package, or barrel.' });
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
if (pure) {
|
|
443
|
-
const worldHooks = new Set(['createContext', 'useContext', 'useSyncExternalStore']);
|
|
444
|
-
const reactNamespaces = new Set();
|
|
445
|
-
for (const statement of sourceFile.statements) {
|
|
446
|
-
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteralLike(statement.moduleSpecifier) || statement.importClause?.isTypeOnly) continue;
|
|
447
|
-
const source = statement.moduleSpecifier.text;
|
|
448
|
-
const named = statement.importClause?.namedBindings;
|
|
449
|
-
if (source === 'react' && named && ts.isNamedImports(named)) {
|
|
450
|
-
for (const element of named.elements) {
|
|
451
|
-
const imported = element.propertyName?.text ?? element.name.text;
|
|
452
|
-
if (!element.isTypeOnly && worldHooks.has(imported)) violations.push(violation(config, sourceFile, element, 'FE_PURE_WORLD_HOOK', `Pure component.tsx imports ${imported}; its connected index.tsx owns application/world state.`, { ts }));
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
if (source === 'react' && statement.importClause?.name) reactNamespaces.add(statement.importClause.name.text);
|
|
456
|
-
if (source === 'react' && named && ts.isNamespaceImport(named)) reactNamespaces.add(named.name.text);
|
|
457
|
-
if ((source === 'next/navigation' || source === 'next-intl') && statement.importClause) {
|
|
458
|
-
violations.push(violation(config, sourceFile, statement, 'FE_PURE_WORLD_IMPORT', `Pure component.tsx imports ${source}; its connected index.tsx owns router and locale context.`, { ts }));
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
const visitWorldHooks = node => {
|
|
462
|
-
if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression) && reactNamespaces.has(node.expression.text)
|
|
463
|
-
&& worldHooks.has(node.name.text)) violations.push(violation(config, sourceFile, node, 'FE_PURE_WORLD_HOOK', `Pure component.tsx accesses ${node.expression.text}.${node.name.text}; its connected index.tsx owns application/world state.`, { ts }));
|
|
464
|
-
ts.forEachChild(node, visitWorldHooks);
|
|
465
|
-
};
|
|
466
|
-
visitWorldHooks(sourceFile);
|
|
467
|
-
}
|
|
468
|
-
return violations;
|
|
145
|
+
const state = { ts, config, sourceFile, checker: context.checkerFor(sourceFile.fileName), violations: [], seen: new Set() };
|
|
146
|
+
visitHookDeclarations(state, sourceFile);
|
|
147
|
+
reportExportedHooks(state);
|
|
148
|
+
return state.violations;
|
|
469
149
|
}
|
|
470
150
|
|
|
151
|
+
function importsForSource(context, fileName) {
|
|
152
|
+
return context.edges.get(path.resolve(fileName)) ?? [];
|
|
153
|
+
}
|
|
471
154
|
|
|
472
|
-
function
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
if (
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
155
|
+
function inspectComponentEdge(config, context, roots, fileName, pure, edge, violations) {
|
|
156
|
+
if (!edge.runtime) return;
|
|
157
|
+
const configuredHookBarrel = insideAny(roots.hooks, edge.to) && /^index\.[cm]?[jt]sx?$/i.test(path.basename(edge.to));
|
|
158
|
+
const hookChain = configuredHookBarrel ? null
|
|
159
|
+
: reachableViolation(context.edges, edge, target => insideAny(roots.hooks, target), { follow: candidate => candidate.reexport && candidate.runtime });
|
|
160
|
+
if (hookChain) {
|
|
161
|
+
violations.push({ ruleId: 'FE_COMPONENT_DEEP_HOOK_IMPORT', path: relativePath(config.root, fileName), line: edge.line, column: edge.column,
|
|
162
|
+
specifier: edge.specifier, resolvedPath: relativePath(config.root, hookChain.at(-1)), dependencyChain: hookChain.map(item => relativePath(config.root, item)),
|
|
163
|
+
message: 'Components must enter data hooks through the owning hooks barrel.' });
|
|
481
164
|
}
|
|
482
|
-
|
|
165
|
+
if (pure) inspectPureDataReachability(config, context, roots, fileName, edge, violations);
|
|
483
166
|
}
|
|
484
167
|
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
168
|
+
function inspectPureDataReachability(config, context, roots, fileName, edge, violations) {
|
|
169
|
+
const chain = reachableViolation(context.edges, edge, target => insideAny(roots.hooks, target) || insideAny(roots.transport, target),
|
|
170
|
+
{ follow: candidate => candidate.reexport && candidate.runtime });
|
|
171
|
+
if (chain) violations.push({ ruleId: 'FE_PURE_REACHES_DATA', path: relativePath(config.root, fileName), line: edge.line, column: edge.column,
|
|
172
|
+
specifier: edge.specifier, resolvedPath: relativePath(config.root, chain.at(-1)), dependencyChain: chain.map(item => relativePath(config.root, item)),
|
|
173
|
+
message: 'Pure component.tsx cannot reach hooks or API transport through an alias, relative import, package, or barrel.' });
|
|
174
|
+
}
|
|
488
175
|
|
|
489
|
-
function
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
176
|
+
function reportWorldHookImports(config, sourceFile, named, state, violation) {
|
|
177
|
+
const { ts, worldHooks, violations } = state;
|
|
178
|
+
for (const element of named.elements) {
|
|
179
|
+
const imported = element.propertyName?.text ?? element.name.text;
|
|
180
|
+
if (!element.isTypeOnly && worldHooks.has(imported)) violations.push(violation(config, sourceFile, element, 'FE_PURE_WORLD_HOOK', `Pure component.tsx imports ${imported}; its connected index.tsx owns application/world state.`, { ts }));
|
|
181
|
+
}
|
|
495
182
|
}
|
|
496
183
|
|
|
497
|
-
function
|
|
498
|
-
|
|
499
|
-
|
|
184
|
+
function recordReactNamespaces(ts, clause, named, reactNamespaces) {
|
|
185
|
+
if (clause?.name) reactNamespaces.add(clause.name.text);
|
|
186
|
+
if (named && ts.isNamespaceImport(named)) reactNamespaces.add(named.name.text);
|
|
500
187
|
}
|
|
501
188
|
|
|
502
|
-
function
|
|
503
|
-
const
|
|
504
|
-
if (ts.
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
189
|
+
function inspectPureImport(config, sourceFile, statement, state, violation) {
|
|
190
|
+
const { ts, reactNamespaces, violations } = state;
|
|
191
|
+
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteralLike(statement.moduleSpecifier) || statement.importClause?.isTypeOnly) return;
|
|
192
|
+
const source = statement.moduleSpecifier.text;
|
|
193
|
+
const named = statement.importClause?.namedBindings;
|
|
194
|
+
if (source === 'react') {
|
|
195
|
+
if (named && ts.isNamedImports(named)) reportWorldHookImports(config, sourceFile, named, state, violation);
|
|
196
|
+
recordReactNamespaces(ts, statement.importClause, named, reactNamespaces);
|
|
197
|
+
}
|
|
198
|
+
if ((source === 'next/navigation' || source === 'next-intl') && statement.importClause) {
|
|
199
|
+
violations.push(violation(config, sourceFile, statement, 'FE_PURE_WORLD_IMPORT', `Pure component.tsx imports ${source}; its connected index.tsx owns router and locale context.`, { ts }));
|
|
511
200
|
}
|
|
512
|
-
if (!ts.isPropertyAccessExpression(expression) || expression.name.text !== 'createElement') return false;
|
|
513
|
-
const symbol = checker.getSymbolAtLocation(expression.expression);
|
|
514
|
-
return (symbol?.declarations ?? []).some(declaration => {
|
|
515
|
-
const parent = importDeclarationFor(ts, declaration);
|
|
516
|
-
return (ts.isNamespaceImport(declaration) || ts.isImportClause(declaration))
|
|
517
|
-
&& parent && ts.isStringLiteralLike(parent.moduleSpecifier) && parent.moduleSpecifier.text === 'react';
|
|
518
|
-
});
|
|
519
201
|
}
|
|
520
202
|
|
|
521
|
-
function
|
|
522
|
-
|
|
523
|
-
if (ts.isPropertyAccessExpression(
|
|
524
|
-
|
|
203
|
+
function inspectPureWorldHookAccess(config, sourceFile, node, state, violation) {
|
|
204
|
+
const { ts, worldHooks, reactNamespaces, violations } = state;
|
|
205
|
+
if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression) && reactNamespaces.has(node.expression.text)
|
|
206
|
+
&& worldHooks.has(node.name.text)) {
|
|
207
|
+
violations.push(violation(config, sourceFile, node, 'FE_PURE_WORLD_HOOK', `Pure component.tsx accesses ${node.expression.text}.${node.name.text}; its connected index.tsx owns application/world state.`, { ts }));
|
|
208
|
+
}
|
|
209
|
+
ts.forEachChild(node, child => inspectPureWorldHookAccess(config, sourceFile, child, state, violation));
|
|
525
210
|
}
|
|
526
211
|
|
|
527
|
-
function
|
|
528
|
-
const
|
|
529
|
-
|
|
530
|
-
|
|
212
|
+
function inspectPureModule(config, context, sourceFile, violations, violation) {
|
|
213
|
+
const ts = context.ts;
|
|
214
|
+
const state = { ts, worldHooks: new Set(['createContext', 'useContext', 'useSyncExternalStore']), reactNamespaces: new Set(), violations };
|
|
215
|
+
for (const statement of sourceFile.statements) inspectPureImport(config, sourceFile, statement, state, violation);
|
|
216
|
+
inspectPureWorldHookAccess(config, sourceFile, sourceFile, state, violation);
|
|
531
217
|
}
|
|
532
218
|
|
|
533
|
-
function
|
|
534
|
-
const { ts } = context;
|
|
219
|
+
function checkPureAndData(config, context, sourceFile, roots) {
|
|
535
220
|
const violations = [];
|
|
536
|
-
const
|
|
537
|
-
|
|
538
|
-
const
|
|
539
|
-
const
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
const intrinsicUiHookFile = (fileName, visiting = new Set()) => {
|
|
543
|
-
const resolved = path.resolve(fileName);
|
|
544
|
-
if (intrinsicUiHookCache.has(resolved)) return intrinsicUiHookCache.get(resolved);
|
|
545
|
-
const hookRoot = rootFor(roots.hooks, resolved);
|
|
546
|
-
if (!hookRoot || relativePath(hookRoot, resolved).split('/')[0] !== 'ui' || visiting.has(resolved)) return false;
|
|
547
|
-
const sourceFile = context.files.find(source => path.resolve(source.fileName) === resolved);
|
|
548
|
-
if (!sourceFile) return false;
|
|
549
|
-
intrinsicUiHookCache.set(resolved, false);
|
|
550
|
-
const nextVisiting = new Set(visiting).add(resolved);
|
|
551
|
-
const checker = context.checkerFor(resolved);
|
|
552
|
-
const edges = importsForSource(context, resolved);
|
|
553
|
-
let intrinsic = true;
|
|
554
|
-
for (const edge of edges.filter(candidate => candidate.runtime)) {
|
|
555
|
-
const forbidden = reachableViolation(context.edges, edge, target => insideAny(roots.transport, target)
|
|
556
|
-
|| insideAny(roots.modules, target) || insideAny(roots.features, target) || insideAny(roots.routes, target)
|
|
557
|
-
|| (insideAny(roots.hooks, target) && relativePath(rootFor(roots.hooks, target), target).split('/')[0] !== 'ui'),
|
|
558
|
-
{ follow: candidate => candidate.runtime });
|
|
559
|
-
if (forbidden || (insideAny(roots.hooks, edge.to) && !intrinsicUiHookFile(edge.to, nextVisiting))) {
|
|
560
|
-
intrinsic = false;
|
|
561
|
-
break;
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
for (const statement of sourceFile.statements) {
|
|
565
|
-
if (!intrinsic) break;
|
|
566
|
-
if (!ts.isImportDeclaration(statement) || !statement.importClause || statement.importClause.isTypeOnly
|
|
567
|
-
|| !ts.isStringLiteralLike(statement.moduleSpecifier)) continue;
|
|
568
|
-
const specifier = statement.moduleSpecifier.text;
|
|
569
|
-
const bindings = [];
|
|
570
|
-
if (statement.importClause.name) bindings.push('default');
|
|
571
|
-
const named = statement.importClause.namedBindings;
|
|
572
|
-
if (named && ts.isNamedImports(named)) for (const element of named.elements) if (!element.isTypeOnly) bindings.push(element.propertyName?.text ?? element.name.text);
|
|
573
|
-
if (named && ts.isNamespaceImport(named) && ['next/navigation', 'next-intl', 'swr', 'swr/immutable', 'swr/mutation', 'next-auth/react'].includes(specifier)) { intrinsic = false; break; }
|
|
574
|
-
if (bindings.some(imported => knownWorldImport(specifier, imported))) { intrinsic = false; break; }
|
|
575
|
-
}
|
|
576
|
-
const visit = node => {
|
|
577
|
-
if (!intrinsic) return;
|
|
578
|
-
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'fetch'
|
|
579
|
-
&& !checker.getSymbolAtLocation(node.expression)) { intrinsic = false; return; }
|
|
580
|
-
ts.forEachChild(node, visit);
|
|
581
|
-
};
|
|
582
|
-
visit(sourceFile);
|
|
583
|
-
intrinsicUiHookCache.set(resolved, intrinsic);
|
|
584
|
-
return intrinsic;
|
|
585
|
-
};
|
|
586
|
-
|
|
587
|
-
const intrinsicUiHookSymbol = (symbol, checker) => {
|
|
588
|
-
const identity = normalizedSymbolValue(ts, checker, symbol);
|
|
589
|
-
const declarations = identity?.getDeclarations?.() ?? [];
|
|
590
|
-
return declarations.length > 0 && declarations.every(declaration => intrinsicUiHookFile(declaration.getSourceFile().fileName));
|
|
591
|
-
};
|
|
592
|
-
|
|
593
|
-
const importInfo = sourceFile => {
|
|
594
|
-
const key = path.resolve(sourceFile.fileName);
|
|
595
|
-
if (worldInfoCache.has(key)) return worldInfoCache.get(key);
|
|
596
|
-
const checker = context.checkerFor(sourceFile.fileName);
|
|
597
|
-
const edges = importsForSource(context, sourceFile.fileName);
|
|
598
|
-
const byStart = new Map(edges.map(edge => [edge.node.getStart(sourceFile), edge]));
|
|
599
|
-
const symbols = new Set();
|
|
600
|
-
const namespaces = new Map();
|
|
601
|
-
for (const statement of sourceFile.statements) {
|
|
602
|
-
if (!ts.isImportDeclaration(statement) || !statement.importClause || statement.importClause.isTypeOnly
|
|
603
|
-
|| !ts.isStringLiteralLike(statement.moduleSpecifier)) continue;
|
|
604
|
-
const specifier = statement.moduleSpecifier.text;
|
|
605
|
-
const edge = byStart.get(statement.moduleSpecifier.getStart(sourceFile));
|
|
606
|
-
const worldEdge = edge && Boolean(reachableViolation(context.edges, edge,
|
|
607
|
-
target => insideAny(roots.hooks, target) || insideAny(roots.transport, target),
|
|
608
|
-
{ follow: candidate => candidate.reexport && candidate.runtime }));
|
|
609
|
-
const clause = statement.importClause;
|
|
610
|
-
if (clause.name) {
|
|
611
|
-
const symbol = checker.getSymbolAtLocation(clause.name);
|
|
612
|
-
if (symbol && (worldEdge || knownWorldImport(specifier, 'default'))) symbols.add(symbol);
|
|
613
|
-
}
|
|
614
|
-
const named = clause.namedBindings;
|
|
615
|
-
if (named && ts.isNamedImports(named)) for (const element of named.elements) {
|
|
616
|
-
if (element.isTypeOnly) continue;
|
|
617
|
-
const imported = element.propertyName?.text ?? element.name.text;
|
|
618
|
-
const symbol = checker.getSymbolAtLocation(element.name);
|
|
619
|
-
if (symbol && (worldEdge || knownWorldImport(specifier, imported))) symbols.add(symbol);
|
|
620
|
-
}
|
|
621
|
-
if (named && ts.isNamespaceImport(named)) {
|
|
622
|
-
const symbol = checker.getSymbolAtLocation(named.name);
|
|
623
|
-
if (symbol) namespaces.set(symbol, { specifier, worldEdge });
|
|
624
|
-
}
|
|
625
|
-
}
|
|
626
|
-
const value = { checker, symbols, namespaces };
|
|
627
|
-
worldInfoCache.set(key, value);
|
|
628
|
-
return value;
|
|
629
|
-
};
|
|
630
|
-
|
|
631
|
-
const expressionFunctions = (expression, checker, seen = new Set()) => {
|
|
632
|
-
expression = unwrapExpression(ts, expression);
|
|
633
|
-
if (!expression) return [];
|
|
634
|
-
if (ts.isArrowFunction(expression) || ts.isFunctionExpression(expression) || ts.isMethodDeclaration(expression)
|
|
635
|
-
|| ts.isFunctionDeclaration(expression)) return [expression];
|
|
636
|
-
if (ts.isConditionalExpression(expression)) {
|
|
637
|
-
return [...expressionFunctions(expression.whenTrue, checker, seen), ...expressionFunctions(expression.whenFalse, checker, seen)];
|
|
638
|
-
}
|
|
639
|
-
if (ts.isCallExpression(expression)) {
|
|
640
|
-
const selected = unwrapExpression(ts, expression.expression);
|
|
641
|
-
const name = ts.isIdentifier(selected) ? selected.text
|
|
642
|
-
: ts.isPropertyAccessExpression(selected) ? selected.name.text : null;
|
|
643
|
-
if (['forwardRef', 'memo'].includes(name) && expression.arguments[0]) return expressionFunctions(expression.arguments[0], checker, seen);
|
|
644
|
-
}
|
|
645
|
-
const symbol = normalizedSymbolValue(ts, checker, selectedSymbol(ts, checker, expression));
|
|
646
|
-
if (!symbol || seen.has(symbol)) return [];
|
|
647
|
-
const nextSeen = new Set(seen).add(symbol);
|
|
648
|
-
const functions = [];
|
|
649
|
-
for (const declaration of symbol.getDeclarations?.() ?? []) {
|
|
650
|
-
if (ts.isFunctionDeclaration(declaration) || ts.isMethodDeclaration(declaration)) functions.push(declaration);
|
|
651
|
-
else if (ts.isVariableDeclaration(declaration) && declaration.initializer) {
|
|
652
|
-
functions.push(...expressionFunctions(declaration.initializer, checker, nextSeen));
|
|
653
|
-
} else if (ts.isClassDeclaration(declaration)) {
|
|
654
|
-
functions.push(...declaration.members.filter(member => ts.isMethodDeclaration(member)
|
|
655
|
-
&& member.name && (ts.isIdentifier(member.name) || ts.isStringLiteralLike(member.name)) && member.name.text === 'render'));
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
return [...new Set(functions.filter(fn => fn.body))];
|
|
659
|
-
};
|
|
660
|
-
|
|
661
|
-
const symbolIsWorld = (raw, sourceFile, propertyName = null) => {
|
|
662
|
-
if (!raw) return false;
|
|
663
|
-
const info = importInfo(sourceFile);
|
|
664
|
-
if (intrinsicUiHookSymbol(raw, info.checker)) return false;
|
|
665
|
-
if (info.symbols.has(raw)) return true;
|
|
666
|
-
for (const [namespace, declaration] of info.namespaces) if (namespace === raw) {
|
|
667
|
-
return declaration.worldEdge || knownWorldImport(declaration.specifier, propertyName ?? '');
|
|
668
|
-
}
|
|
669
|
-
const identity = normalizedSymbolValue(ts, info.checker, raw);
|
|
670
|
-
return (identity?.getDeclarations?.() ?? []).some(declaration => {
|
|
671
|
-
const fileName = declaration.getSourceFile().fileName;
|
|
672
|
-
return insideAny(roots.hooks, fileName) || insideAny(roots.transport, fileName);
|
|
673
|
-
});
|
|
674
|
-
};
|
|
675
|
-
|
|
676
|
-
const namespaceIsWorld = (raw, sourceFile, propertyName) => {
|
|
677
|
-
const info = importInfo(sourceFile);
|
|
678
|
-
const declaration = info.namespaces.get(raw);
|
|
679
|
-
return Boolean(declaration && (declaration.worldEdge || knownWorldImport(declaration.specifier, propertyName ?? '')));
|
|
680
|
-
};
|
|
681
|
-
|
|
682
|
-
const calleeIsWorld = (input, checker, sourceFile, seen = new Set()) => {
|
|
683
|
-
const expression = unwrapExpression(ts, input);
|
|
684
|
-
if (ts.isIdentifier(expression) && symbolIsWorld(checker.getSymbolAtLocation(expression), sourceFile)) return true;
|
|
685
|
-
if ((ts.isPropertyAccessExpression(expression) || ts.isElementAccessExpression(expression))) {
|
|
686
|
-
const property = ts.isPropertyAccessExpression(expression) ? expression.name.text
|
|
687
|
-
: ts.isStringLiteralLike(expression.argumentExpression) ? expression.argumentExpression.text : null;
|
|
688
|
-
const namespace = checker.getSymbolAtLocation(expression.expression);
|
|
689
|
-
if (intrinsicUiHookSymbol(selectedSymbol(ts, checker, expression), checker)) return false;
|
|
690
|
-
if (namespaceIsWorld(namespace, sourceFile, property)) return true;
|
|
691
|
-
if (symbolIsWorld(selectedSymbol(ts, checker, expression), sourceFile)) return true;
|
|
692
|
-
}
|
|
693
|
-
const symbol = normalizedSymbolValue(ts, checker, selectedSymbol(ts, checker, expression));
|
|
694
|
-
if (!symbol || seen.has(symbol)) return false;
|
|
695
|
-
const nextSeen = new Set(seen).add(symbol);
|
|
696
|
-
const declarations = symbol.getDeclarations?.() ?? [];
|
|
697
|
-
for (const declaration of declarations) {
|
|
698
|
-
if (ts.isVariableDeclaration(declaration) && declaration.initializer) {
|
|
699
|
-
if (calleeIsWorld(declaration.initializer, checker, declaration.getSourceFile(), nextSeen)) return true;
|
|
700
|
-
}
|
|
701
|
-
}
|
|
702
|
-
return expressionFunctions(expression, checker).some(fn => functionUsesWorld(fn, checker, nextSeen));
|
|
703
|
-
};
|
|
704
|
-
|
|
705
|
-
const callIsWorld = (call, checker, sourceFile, seen) => calleeIsWorld(call.expression, checker, sourceFile, seen);
|
|
706
|
-
|
|
707
|
-
const functionUsesWorld = (fn, checker, seen = new Set()) => {
|
|
708
|
-
if (functionWorldCache.has(fn)) return functionWorldCache.get(fn);
|
|
709
|
-
functionWorldCache.set(fn, false);
|
|
710
|
-
let found = false;
|
|
711
|
-
const sourceFile = fn.getSourceFile();
|
|
712
|
-
const visit = node => {
|
|
713
|
-
if (found) return;
|
|
714
|
-
if (ts.isCallExpression(node) && callIsWorld(node, checker, sourceFile, seen)) { found = true; return; }
|
|
715
|
-
ts.forEachChild(node, visit);
|
|
716
|
-
};
|
|
717
|
-
visit(fn.body ?? fn);
|
|
718
|
-
functionWorldCache.set(fn, found);
|
|
719
|
-
return found;
|
|
720
|
-
};
|
|
721
|
-
|
|
722
|
-
const expressionHasRender = (expression, checker, seen = new Set()) => {
|
|
723
|
-
expression = unwrapExpression(ts, expression);
|
|
724
|
-
if (!expression) return false;
|
|
725
|
-
if (ts.isJsxElement(expression) || ts.isJsxSelfClosingElement(expression) || ts.isJsxFragment(expression)) return true;
|
|
726
|
-
if (ts.isCallExpression(expression) && isReactCreateElement(ts, checker, expression)) return true;
|
|
727
|
-
if (ts.isConditionalExpression(expression)) return expressionHasRender(expression.whenTrue, checker, seen)
|
|
728
|
-
|| expressionHasRender(expression.whenFalse, checker, seen);
|
|
729
|
-
if (ts.isBinaryExpression(expression) && [ts.SyntaxKind.AmpersandAmpersandToken, ts.SyntaxKind.BarBarToken,
|
|
730
|
-
ts.SyntaxKind.QuestionQuestionToken].includes(expression.operatorToken.kind)) {
|
|
731
|
-
return expressionHasRender(expression.left, checker, seen) || expressionHasRender(expression.right, checker, seen);
|
|
732
|
-
}
|
|
733
|
-
if (ts.isCallExpression(expression) && (ts.isPropertyAccessExpression(expression.expression)
|
|
734
|
-
|| ts.isElementAccessExpression(expression.expression))) {
|
|
735
|
-
const name = ts.isPropertyAccessExpression(expression.expression) ? expression.expression.name.text
|
|
736
|
-
: ts.isStringLiteralLike(expression.expression.argumentExpression) ? expression.expression.argumentExpression.text : null;
|
|
737
|
-
if (name === 'map' && expression.arguments[0]) return expressionFunctions(expression.arguments[0], checker)
|
|
738
|
-
.some(fn => functionHasRender(fn, checker, seen));
|
|
739
|
-
}
|
|
740
|
-
const symbol = normalizedSymbolValue(ts, checker, selectedSymbol(ts, checker, ts.isCallExpression(expression) ? expression.expression : expression));
|
|
741
|
-
if (!symbol || seen.has(symbol)) return false;
|
|
742
|
-
const nextSeen = new Set(seen).add(symbol);
|
|
743
|
-
if (ts.isIdentifier(expression)) for (const declaration of symbol.getDeclarations?.() ?? []) {
|
|
744
|
-
if (ts.isVariableDeclaration(declaration) && declaration.initializer && expressionHasRender(declaration.initializer, checker, nextSeen)) return true;
|
|
745
|
-
}
|
|
746
|
-
if (ts.isCallExpression(expression)) return expressionFunctions(expression.expression, checker, seen)
|
|
747
|
-
.some(fn => functionHasRender(fn, checker, nextSeen));
|
|
748
|
-
return false;
|
|
749
|
-
};
|
|
750
|
-
|
|
751
|
-
const functionHasRender = (fn, checker, seen = new Set()) => {
|
|
752
|
-
if (renderOutputCache.has(fn)) return renderOutputCache.get(fn);
|
|
753
|
-
renderOutputCache.set(fn, false);
|
|
754
|
-
const found = returnedExpressions(ts, fn).some(expression => expressionHasRender(expression, checker, seen));
|
|
755
|
-
renderOutputCache.set(fn, found);
|
|
756
|
-
return found;
|
|
757
|
-
};
|
|
758
|
-
|
|
759
|
-
const capturesOuterFunction = (fn, checker) => {
|
|
760
|
-
const outers = [];
|
|
761
|
-
for (let parent = fn.parent; parent; parent = parent.parent) if (ts.isFunctionLike(parent)) outers.push(parent);
|
|
762
|
-
if (!outers.length) return false;
|
|
763
|
-
let captured = false;
|
|
764
|
-
const visit = node => {
|
|
765
|
-
if (captured) return;
|
|
766
|
-
if (ts.isIdentifier(node)) {
|
|
767
|
-
const symbol = checker.getSymbolAtLocation(node);
|
|
768
|
-
for (const declaration of symbol?.getDeclarations?.() ?? []) {
|
|
769
|
-
if (declaration.pos >= fn.pos && declaration.end <= fn.end) continue;
|
|
770
|
-
if (outers.some(outer => declaration.pos >= outer.pos && declaration.end <= outer.end)) { captured = true; return; }
|
|
771
|
-
}
|
|
772
|
-
}
|
|
773
|
-
ts.forEachChild(node, visit);
|
|
774
|
-
};
|
|
775
|
-
visit(fn.body ?? fn);
|
|
776
|
-
return captured;
|
|
777
|
-
};
|
|
778
|
-
|
|
779
|
-
const pureRenderTarget = (expression, checker, expectedFile = null) => {
|
|
780
|
-
const functions = expressionFunctions(expression, checker);
|
|
781
|
-
return functions.length > 0 && functions.every(fn => sourceSet.has(path.resolve(fn.getSourceFile().fileName))
|
|
782
|
-
&& (insideAny(roots.components, fn.getSourceFile().fileName) || insideAny(roots.features, fn.getSourceFile().fileName))
|
|
783
|
-
&& (!expectedFile || path.resolve(fn.getSourceFile().fileName) === expectedFile)
|
|
784
|
-
&& functionHasRender(fn, checker) && !functionUsesWorld(fn, checker) && !capturesOuterFunction(fn, checker));
|
|
785
|
-
};
|
|
786
|
-
|
|
787
|
-
const expressionSuppliesRender = (expression, checker) => expressionHasRender(expression, checker)
|
|
788
|
-
|| expressionFunctions(expression, checker).some(fn => functionHasRender(fn, checker));
|
|
789
|
-
|
|
790
|
-
const routedLayoutChild = (opening, child, checker) => {
|
|
791
|
-
if (!ts.isJsxExpression(child) || !child.expression) return false;
|
|
792
|
-
const source = opening.getSourceFile().fileName;
|
|
793
|
-
if (!/^apps\/[^/]+\/src\/features\/layouts\/[^/]+\/index\.tsx$/.test(relativePath(config.root, source))) return false;
|
|
794
|
-
const sibling = path.resolve(path.dirname(source), 'component.tsx');
|
|
795
|
-
if (!pureRenderTarget(opening.tagName, checker, sibling)) return false;
|
|
796
|
-
const value = unwrapExpression(ts, child.expression);
|
|
797
|
-
if (!ts.isPropertyAccessExpression(value) || !['content', 'children'].includes(value.name.text)) return false;
|
|
798
|
-
const receiver = unwrapExpression(ts, value.expression);
|
|
799
|
-
if (!ts.isIdentifier(receiver)) return false;
|
|
800
|
-
const symbol = checker.getSymbolAtLocation(receiver);
|
|
801
|
-
return (symbol?.getDeclarations?.() ?? []).some(declaration => ts.isParameter(declaration));
|
|
802
|
-
};
|
|
803
|
-
|
|
804
|
-
const renderBoundary = (expression, checker, seen = new Set(), allowEmpty = false, expectedFile = null) => {
|
|
805
|
-
expression = unwrapExpression(ts, expression);
|
|
806
|
-
if (!expression) return allowEmpty;
|
|
807
|
-
if (expression.kind === ts.SyntaxKind.NullKeyword || expression.kind === ts.SyntaxKind.FalseKeyword) return allowEmpty;
|
|
808
|
-
if (ts.isConditionalExpression(expression)) return renderBoundary(expression.whenTrue, checker, seen, allowEmpty, expectedFile)
|
|
809
|
-
&& renderBoundary(expression.whenFalse, checker, seen, allowEmpty, expectedFile);
|
|
810
|
-
if (ts.isBinaryExpression(expression) && expression.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) {
|
|
811
|
-
return renderBoundary(expression.right, checker, seen, true, expectedFile);
|
|
812
|
-
}
|
|
813
|
-
if (ts.isArrayLiteralExpression(expression)) return expression.elements.length > 0
|
|
814
|
-
&& expression.elements.every(item => renderBoundary(item, checker, seen, allowEmpty, expectedFile));
|
|
815
|
-
if (ts.isIdentifier(expression)) {
|
|
816
|
-
const symbol = normalizedSymbolValue(ts, checker, checker.getSymbolAtLocation(expression));
|
|
817
|
-
if (!symbol || seen.has(symbol)) return false;
|
|
818
|
-
const nextSeen = new Set(seen).add(symbol);
|
|
819
|
-
const values = (symbol.getDeclarations?.() ?? []).flatMap(declaration => ts.isVariableDeclaration(declaration) && declaration.initializer
|
|
820
|
-
? [declaration.initializer] : []);
|
|
821
|
-
if (values.length) return values.every(value => renderBoundary(value, checker, nextSeen, allowEmpty, expectedFile));
|
|
822
|
-
}
|
|
823
|
-
if (ts.isCallExpression(expression)) {
|
|
824
|
-
if (isReactCreateElement(ts, checker, expression)) {
|
|
825
|
-
const target = expression.arguments[0] ? unwrapExpression(ts, expression.arguments[0]) : null;
|
|
826
|
-
if (!target || ts.isStringLiteralLike(target)) return false;
|
|
827
|
-
const pureTarget = pureRenderTarget(target, checker, expectedFile) && !wrapperTag(ts, target);
|
|
828
|
-
const wrapper = wrapperTag(ts, target);
|
|
829
|
-
if (!pureTarget && !wrapper) return false;
|
|
830
|
-
const props = expression.arguments[1] ? unwrapExpression(ts, expression.arguments[1]) : null;
|
|
831
|
-
let hasBoundary = pureTarget;
|
|
832
|
-
if (props && props.kind !== ts.SyntaxKind.NullKeyword) {
|
|
833
|
-
if (!ts.isObjectLiteralExpression(props) || props.properties.some(property => ts.isSpreadAssignment(property))) return false;
|
|
834
|
-
for (const property of props.properties) {
|
|
835
|
-
if (!ts.isPropertyAssignment(property)) continue;
|
|
836
|
-
const value = unwrapExpression(ts, property.initializer);
|
|
837
|
-
if (!expressionSuppliesRender(value, checker)) continue;
|
|
838
|
-
if (!pureRenderTarget(value, checker, expectedFile) && !renderBoundary(value, checker, seen, true, expectedFile)) return false;
|
|
839
|
-
const name = property.name && (ts.isIdentifier(property.name) || ts.isStringLiteralLike(property.name)) ? property.name.text : null;
|
|
840
|
-
if (['children', 'component', 'content', 'render', 'view'].includes(name)) hasBoundary = true;
|
|
841
|
-
}
|
|
842
|
-
}
|
|
843
|
-
const children = expression.arguments.slice(2);
|
|
844
|
-
if (children.length) {
|
|
845
|
-
if (!children.every(child => renderBoundary(child, checker, seen, false, expectedFile))) return false;
|
|
846
|
-
hasBoundary = true;
|
|
847
|
-
}
|
|
848
|
-
return hasBoundary;
|
|
849
|
-
}
|
|
850
|
-
if ((ts.isPropertyAccessExpression(expression.expression) || ts.isElementAccessExpression(expression.expression))) {
|
|
851
|
-
const name = ts.isPropertyAccessExpression(expression.expression) ? expression.expression.name.text
|
|
852
|
-
: ts.isStringLiteralLike(expression.expression.argumentExpression) ? expression.expression.argumentExpression.text : null;
|
|
853
|
-
if (name === 'map' && expression.arguments[0]) {
|
|
854
|
-
const renderers = expressionFunctions(expression.arguments[0], checker);
|
|
855
|
-
return renderers.length > 0 && renderers.every(fn => !functionUsesWorld(fn, checker)
|
|
856
|
-
&& returnedExpressions(ts, fn).length > 0
|
|
857
|
-
&& returnedExpressions(ts, fn).every(value => renderBoundary(value, checker, seen, true, expectedFile)));
|
|
858
|
-
}
|
|
859
|
-
}
|
|
860
|
-
return pureRenderTarget(expression.expression, checker, expectedFile);
|
|
861
|
-
}
|
|
862
|
-
if (ts.isJsxFragment(expression)) {
|
|
863
|
-
const children = expression.children.filter(child => !ts.isJsxText(child) || child.text.trim());
|
|
864
|
-
return children.length > 0 && children.every(child => ts.isJsxExpression(child)
|
|
865
|
-
? renderBoundary(child.expression, checker, seen, false, expectedFile)
|
|
866
|
-
: ts.isJsxText(child) ? false : renderBoundary(child, checker, seen, false, expectedFile));
|
|
867
|
-
}
|
|
868
|
-
if (ts.isJsxElement(expression) || ts.isJsxSelfClosingElement(expression)) {
|
|
869
|
-
const opening = ts.isJsxElement(expression) ? expression.openingElement : expression;
|
|
870
|
-
const pureTarget = pureRenderTarget(opening.tagName, checker, expectedFile) && !wrapperTag(ts, opening.tagName);
|
|
871
|
-
const wrapper = wrapperTag(ts, opening.tagName);
|
|
872
|
-
if (!pureTarget && !wrapper) return false;
|
|
873
|
-
const children = ts.isJsxElement(expression)
|
|
874
|
-
? expression.children.filter(child => !ts.isJsxText(child) || child.text.trim()) : [];
|
|
875
|
-
let hasBoundary = pureTarget;
|
|
876
|
-
if (children.length) {
|
|
877
|
-
// A layout may hand its one opaque router-supplied child to its pure sibling.
|
|
878
|
-
// Extra drawing still has to satisfy the ordinary resolved render boundary.
|
|
879
|
-
const routedChild = pureTarget && children.length === 1 && routedLayoutChild(opening, children[0], checker);
|
|
880
|
-
if (!routedChild && !children.every(child => ts.isJsxExpression(child)
|
|
881
|
-
? renderBoundary(child.expression, checker, seen, false, expectedFile)
|
|
882
|
-
: ts.isJsxText(child) ? false : renderBoundary(child, checker, seen, false, expectedFile))) return false;
|
|
883
|
-
hasBoundary = true;
|
|
884
|
-
}
|
|
885
|
-
for (const attribute of opening.attributes.properties) {
|
|
886
|
-
if (!ts.isJsxAttribute(attribute) || !attribute.initializer) continue;
|
|
887
|
-
const name = attribute.name.text;
|
|
888
|
-
if (['fallback', 'errorElement'].includes(name)) {
|
|
889
|
-
if (ts.isStringLiteral(attribute.initializer)) return false;
|
|
890
|
-
if (ts.isJsxExpression(attribute.initializer)
|
|
891
|
-
&& !renderBoundary(attribute.initializer.expression, checker, seen, true, expectedFile)) return false;
|
|
892
|
-
}
|
|
893
|
-
if (ts.isJsxExpression(attribute.initializer) && attribute.initializer.expression
|
|
894
|
-
&& expressionSuppliesRender(attribute.initializer.expression, checker)) {
|
|
895
|
-
if (!pureRenderTarget(attribute.initializer.expression, checker, expectedFile)
|
|
896
|
-
&& !renderBoundary(attribute.initializer.expression, checker, seen, true, expectedFile)) return false;
|
|
897
|
-
if (['component', 'content', 'render', 'view'].includes(name)) hasBoundary = true;
|
|
898
|
-
}
|
|
899
|
-
}
|
|
900
|
-
return hasBoundary;
|
|
901
|
-
}
|
|
902
|
-
return false;
|
|
903
|
-
};
|
|
904
|
-
|
|
905
|
-
for (const sourceFile of context.files) {
|
|
906
|
-
if ((!insideAny(roots.components, sourceFile.fileName) && !insideAny(roots.features, sourceFile.fileName))
|
|
907
|
-
|| !/\.[cm]?tsx$/i.test(sourceFile.fileName)) continue;
|
|
908
|
-
const checker = context.checkerFor(sourceFile.fileName);
|
|
909
|
-
const tier = tierOf(roots.components, sourceFile.fileName);
|
|
910
|
-
const functions = [];
|
|
911
|
-
const collect = node => {
|
|
912
|
-
if (ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node) || ts.isArrowFunction(node) || ts.isFunctionExpression(node)) functions.push(node);
|
|
913
|
-
ts.forEachChild(node, collect);
|
|
914
|
-
};
|
|
915
|
-
collect(sourceFile);
|
|
916
|
-
for (const fn of functions) {
|
|
917
|
-
const name = declarationName(ts, fn);
|
|
918
|
-
if (tier?.tier === 'blocks' && name && /^use[A-Z0-9]/.test(name.text) && functionUsesWorld(fn, checker)) {
|
|
919
|
-
violations.push(violation(config, sourceFile, name, 'FE_BLOCK_PRODUCT_HOOK_DEFINITION',
|
|
920
|
-
'A block may call a product hook from hooks but cannot define product-world or data lifecycle under the block.', { ts }));
|
|
921
|
-
}
|
|
922
|
-
}
|
|
923
|
-
for (const fn of functions) {
|
|
924
|
-
if (!functionHasRender(fn, checker) || !functionUsesWorld(fn, checker)) continue;
|
|
925
|
-
const returned = returnedExpressions(ts, fn);
|
|
926
|
-
if (!returned.length || returned.some(expression => !renderBoundary(expression, checker, new Set(), true))) {
|
|
927
|
-
violations.push(violation(config, sourceFile, fn, 'FE_WORLD_OWNER_RENDER_BOUNDARY',
|
|
928
|
-
'A visual owner that reads product/world state must hand every render path to a statically resolved pure render function or component; intrinsic-only interaction does not select this rule.', { ts }));
|
|
929
|
-
}
|
|
930
|
-
if (tier && LOWER_COMPONENT_TIERS.has(tier.tier)) {
|
|
931
|
-
violations.push(violation(config, sourceFile, fn, 'FE_COMPONENT_WORLD_OWNERSHIP',
|
|
932
|
-
`${tier.tier} may own intrinsic browser interaction but cannot own product-world, navigation, locale, session or data lifecycle.`, { ts }));
|
|
933
|
-
}
|
|
934
|
-
if (tier?.tier === 'blocks') {
|
|
935
|
-
const expected = path.resolve(path.dirname(sourceFile.fileName), 'component.tsx');
|
|
936
|
-
const index = path.basename(sourceFile.fileName).toLowerCase() === 'index.tsx';
|
|
937
|
-
if (!index || !sourceSet.has(expected) || !returned.length
|
|
938
|
-
|| returned.some(expression => !renderBoundary(expression, checker, new Set(), true, expected))) {
|
|
939
|
-
violations.push(violation(config, sourceFile, fn, 'FE_CONNECTED_BLOCK_RENDER_PAIR',
|
|
940
|
-
'A connected blocks/index.tsx must hand every nonempty render path to a resolved pure export from its sibling component.tsx; pure blocks and lower tiers need no twin.', { ts }));
|
|
941
|
-
}
|
|
942
|
-
}
|
|
943
|
-
}
|
|
944
|
-
}
|
|
221
|
+
const fileName = path.resolve(sourceFile.fileName);
|
|
222
|
+
if (!insideAny(roots.components, fileName)) return violations;
|
|
223
|
+
const pure = path.basename(fileName).toLowerCase() === 'component.tsx';
|
|
224
|
+
for (const edge of importsForSource(context, fileName)) inspectComponentEdge(config, context, roots, fileName, pure, edge, violations);
|
|
225
|
+
if (pure) inspectPureModule(config, context, sourceFile, violations, violation);
|
|
945
226
|
return violations;
|
|
946
227
|
}
|
|
947
228
|
|
|
229
|
+
|
|
948
230
|
/** Enforce route adapters, UI responsibility tiers, pure/connected, and transport boundaries. */
|
|
949
231
|
export function checkFrontend(config, context) {
|
|
950
232
|
const roots = {
|
|
@@ -957,15 +239,13 @@ export function checkFrontend(config, context) {
|
|
|
957
239
|
};
|
|
958
240
|
roots.sourceRoots = [...new Set(roots.routes.map(root => path.dirname(root)))];
|
|
959
241
|
roots.pinnedRootFiles = frameworkPinnedRootFiles();
|
|
960
|
-
const violations = checkGrammar(config, context);
|
|
242
|
+
const violations = checkGrammar(config, context, violation);
|
|
961
243
|
for (const sourceFile of context.files) {
|
|
962
244
|
if (insideAny(roots.routes, sourceFile.fileName) && path.basename(sourceFile.fileName).toLowerCase() === 'page.tsx') {
|
|
963
|
-
violations.push(...checkRoute(config, context, sourceFile, roots));
|
|
245
|
+
violations.push(...checkRoute(config, context, sourceFile, roots, { violation, insideAny, importsForSource }));
|
|
964
246
|
}
|
|
965
|
-
violations.push(...checkFrontendSourceLayout(config, sourceFile, roots));
|
|
966
|
-
violations.push(...checkCustomHookLocations(config, context, sourceFile, roots));
|
|
967
|
-
violations.push(...checkPureAndData(config, context, sourceFile, roots));
|
|
247
|
+
violations.push(...checkFrontendSourceLayout(config, sourceFile, roots), ...checkCustomHookLocations(config, context, sourceFile, roots), ...checkPureAndData(config, context, sourceFile, roots));
|
|
968
248
|
}
|
|
969
|
-
violations.push(...checkWorldRenderBoundaries(config, context, roots));
|
|
249
|
+
violations.push(...checkWorldRenderBoundaries(config, context, roots, { insideAny, rootFor, tierOf, violation, declarationName, importsForSource }));
|
|
970
250
|
return violations;
|
|
971
251
|
}
|