@starci/hfs 4.0.10 → 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/emit/operations.mjs +1 -1
- package/emit/type-schema.mjs +2 -1
- package/lint/run.mjs +3 -2
- package/package.json +1 -1
- 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 +221 -239
- 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 +35 -31
- package/runtime/engine/sonar-config.mjs +21 -0
- package/runtime/engine/temp-root.mjs +32 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +14 -14
- package/runtime/knowledge/hfs/peer-integrations.yaml +2 -3
- package/runtime/knowledge/hfs/rules.yaml +98 -65
- 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 -41
- package/runtime/modules/kernel/removed-vocabulary.yaml +168 -0
- package/runtime/modules/models/registry.yaml +25 -359
- package/runtime/modules/models/runtimes.yaml +92 -256
- 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/make-temp-dir.mjs +11 -0
- package/runtime/scripts/api/fs/safe-remove.mjs +52 -35
- package/runtime/scripts/api/git/lib.mjs +34 -1
- package/runtime/scripts/api/process/resolve-real-tool.mjs +14 -8
- package/runtime/scripts/api/process/run-program.mjs +2 -1
- package/runtime/scripts/api/sops/lib.mjs +198 -120
- 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 +2 -1
- 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 +59 -48
- 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/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/path-key.mjs +38 -5
- 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 +95 -48
- 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
- package/runtime/engine/admission.mjs +0 -308
- package/runtime/engine/canonical-json.mjs +0 -10
- package/runtime/engine/db/blob.mjs +0 -315
- package/runtime/engine/db/ledger-paths.mjs +0 -83
- package/runtime/engine/db/ledger.mjs +0 -1118
- package/runtime/engine/db/machine-connection.mjs +0 -135
- package/runtime/engine/db/machine-schema.mjs +0 -84
- package/runtime/engine/db/machine.mjs +0 -1367
- package/runtime/engine/db/migrations/machine/0001-init.sql +0 -924
- package/runtime/engine/db/migrations/runtime/0001-init.sql +0 -1108
- package/runtime/engine/db/provider-reservations.mjs +0 -101
- package/runtime/engine/digest.mjs +0 -16
- package/runtime/engine/refuse.mjs +0 -11
- package/runtime/modules/ops/_labels.yaml +0 -53
- package/runtime/scripts/api/node/lib.mjs +0 -14
- package/runtime/scripts/api/node/spawn-node.mjs +0 -6
- package/runtime/scripts/api/process/lib.mjs +0 -111
- package/runtime/scripts/api/process/owned-process.mjs +0 -78
- package/runtime/scripts/api/process/stop-owned-process.mjs +0 -9
- package/runtime/scripts/connectors/lib.mjs +0 -488
- package/runtime/scripts/lib/clip.mjs +0 -19
- package/runtime/scripts/lib/display-names.mjs +0 -259
- package/runtime/scripts/lib/example-refs.mjs +0 -158
- package/runtime/scripts/lib/json-schema.mjs +0 -52
- package/runtime/scripts/lib/process-identity.mjs +0 -6
- package/runtime/scripts/lib/read-yaml.mjs +0 -18
- package/runtime/scripts/lib/redact.mjs +0 -161
- package/runtime/scripts/lib/sleep.mjs +0 -7
- package/runtime/scripts/lib/source-phrases.mjs +0 -34
- package/runtime/scripts/lib/sqlite.mjs +0 -21
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { normalizedSymbolValue } from './ast-walks.mjs';
|
|
3
|
+
import { relativePath, unwrapExpression } from './typescript.mjs';
|
|
4
|
+
function importDeclarationFor(ts, node) {
|
|
5
|
+
for (let current = node; current; current = current.parent) if (ts.isImportDeclaration(current)) return current;
|
|
6
|
+
return null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function isReactCreateElement(ts, checker, call) {
|
|
10
|
+
const expression = unwrapExpression(ts, call.expression);
|
|
11
|
+
if (ts.isIdentifier(expression)) {
|
|
12
|
+
const symbol = checker.getSymbolAtLocation(expression);
|
|
13
|
+
return (symbol?.declarations ?? []).some(declaration => {
|
|
14
|
+
const imported = ts.isImportSpecifier(declaration) ? declaration.propertyName?.text ?? declaration.name.text : null;
|
|
15
|
+
const parent = importDeclarationFor(ts, declaration);
|
|
16
|
+
return imported === 'createElement' && parent && ts.isStringLiteralLike(parent.moduleSpecifier) && parent.moduleSpecifier.text === 'react';
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
if (!ts.isPropertyAccessExpression(expression) || expression.name.text !== 'createElement') return false;
|
|
20
|
+
const symbol = checker.getSymbolAtLocation(expression.expression);
|
|
21
|
+
return (symbol?.declarations ?? []).some(declaration => {
|
|
22
|
+
const parent = importDeclarationFor(ts, declaration);
|
|
23
|
+
return (ts.isNamespaceImport(declaration) || ts.isImportClause(declaration))
|
|
24
|
+
&& parent && ts.isStringLiteralLike(parent.moduleSpecifier) && parent.moduleSpecifier.text === 'react';
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function jsxTagName(ts, tagName) {
|
|
29
|
+
if (ts.isIdentifier(tagName)) return tagName.text;
|
|
30
|
+
if (ts.isPropertyAccessExpression(tagName)) return tagName.name.text;
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function wrapperTag(ts, tagName) {
|
|
35
|
+
const name = jsxTagName(ts, tagName);
|
|
36
|
+
return name === 'Suspense' || name === 'SWRConfig' || name === 'Provider'
|
|
37
|
+
|| Boolean(name && (name.endsWith('Provider') || name.endsWith('ErrorBoundary')));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class WorldRenderAnalysis {
|
|
41
|
+
constructor(world, helpers) {
|
|
42
|
+
this.world = world;
|
|
43
|
+
this.helpers = helpers;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
expressionHasRender(expression, checker, seen = new Set()) {
|
|
47
|
+
expression = unwrapExpression(this.world.ts, expression);
|
|
48
|
+
if (!expression) return false;
|
|
49
|
+
if (this.world.ts.isJsxElement(expression) || this.world.ts.isJsxSelfClosingElement(expression) || this.world.ts.isJsxFragment(expression)) return true;
|
|
50
|
+
if (this.world.ts.isCallExpression(expression) && isReactCreateElement(this.world.ts, checker, expression)) return true;
|
|
51
|
+
if (this.world.ts.isConditionalExpression(expression)) return this.conditionalExpressionHasRender(expression, checker, seen);
|
|
52
|
+
const logicalRender = this.logicalExpressionHasRender(expression, checker, seen);
|
|
53
|
+
if (logicalRender !== null) return logicalRender;
|
|
54
|
+
if (this.world.ts.isCallExpression(expression)) {
|
|
55
|
+
const mapped = this.mappedExpressionHasRender(expression, checker, seen);
|
|
56
|
+
if (mapped !== null) return mapped;
|
|
57
|
+
}
|
|
58
|
+
return this.referencedExpressionHasRender(expression, checker, seen);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
conditionalExpressionHasRender(expression, checker, seen) {
|
|
62
|
+
return this.expressionHasRender(expression.whenTrue, checker, seen)
|
|
63
|
+
|| this.expressionHasRender(expression.whenFalse, checker, seen);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
logicalExpressionHasRender(expression, checker, seen) {
|
|
67
|
+
const ts = this.world.ts;
|
|
68
|
+
if (!ts.isBinaryExpression(expression) || ![ts.SyntaxKind.AmpersandAmpersandToken, ts.SyntaxKind.BarBarToken,
|
|
69
|
+
ts.SyntaxKind.QuestionQuestionToken].includes(expression.operatorToken.kind)) return null;
|
|
70
|
+
return this.expressionHasRender(expression.left, checker, seen) || this.expressionHasRender(expression.right, checker, seen);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
mappedExpressionHasRender(expression, checker, seen) {
|
|
74
|
+
const ts = this.world.ts;
|
|
75
|
+
if (!ts.isPropertyAccessExpression(expression.expression) && !ts.isElementAccessExpression(expression.expression)) return null;
|
|
76
|
+
let name = ts.isPropertyAccessExpression(expression.expression) ? expression.expression.name.text : null;
|
|
77
|
+
if (name === null && ts.isStringLiteralLike(expression.expression.argumentExpression)) name = expression.expression.argumentExpression.text;
|
|
78
|
+
if (name !== 'map' || !expression.arguments[0]) return null;
|
|
79
|
+
return this.world.expressionFunctions(expression.arguments[0], checker).some(fn => this.functionHasRender(fn, checker, seen));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
referencedExpressionHasRender(expression, checker, seen) {
|
|
83
|
+
const ts = this.world.ts;
|
|
84
|
+
const selected = ts.isCallExpression(expression) ? expression.expression : expression;
|
|
85
|
+
const symbol = normalizedSymbolValue(ts, checker, this.helpers.selectedSymbol(ts, checker, selected));
|
|
86
|
+
if (!symbol || seen.has(symbol)) return false;
|
|
87
|
+
const nextSeen = new Set(seen).add(symbol);
|
|
88
|
+
if (ts.isIdentifier(expression) && this.identifierInitializerHasRender(symbol, checker, nextSeen)) return true;
|
|
89
|
+
if (ts.isCallExpression(expression)) return this.world.expressionFunctions(expression.expression, checker, seen)
|
|
90
|
+
.some(fn => this.functionHasRender(fn, checker, nextSeen));
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
identifierInitializerHasRender(symbol, checker, seen) {
|
|
95
|
+
for (const declaration of symbol.getDeclarations?.() ?? []) {
|
|
96
|
+
if (this.world.ts.isVariableDeclaration(declaration) && declaration.initializer
|
|
97
|
+
&& this.expressionHasRender(declaration.initializer, checker, seen)) return true;
|
|
98
|
+
}
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
functionHasRender(fn, checker, seen = new Set()) {
|
|
103
|
+
if (this.world.renderOutputCache.has(fn)) return this.world.renderOutputCache.get(fn);
|
|
104
|
+
this.world.renderOutputCache.set(fn, false);
|
|
105
|
+
const found = this.helpers.returnedExpressions(this.world.ts, fn).some(expression => this.expressionHasRender(expression, checker, seen));
|
|
106
|
+
this.world.renderOutputCache.set(fn, found);
|
|
107
|
+
return found;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
capturesOuterFunction(fn, checker) {
|
|
111
|
+
const outers = [];
|
|
112
|
+
for (let parent = fn.parent; parent; parent = parent.parent) if (this.world.ts.isFunctionLike(parent)) outers.push(parent);
|
|
113
|
+
if (!outers.length) return false;
|
|
114
|
+
const capture = { found: false };
|
|
115
|
+
this.visitOuterReferences(fn.body ?? fn, fn, checker, outers, capture);
|
|
116
|
+
return capture.found;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
visitOuterReferences(node, fn, checker, outers, capture) {
|
|
120
|
+
if (capture.found) return;
|
|
121
|
+
if (this.referencesOuterFunction(node, fn, checker, outers)) {
|
|
122
|
+
capture.found = true;
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
this.world.ts.forEachChild(node, child => this.visitOuterReferences(child, fn, checker, outers, capture));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
referencesOuterFunction(node, fn, checker, outers) {
|
|
129
|
+
if (!this.world.ts.isIdentifier(node)) return false;
|
|
130
|
+
const symbol = checker.getSymbolAtLocation(node);
|
|
131
|
+
for (const declaration of symbol?.getDeclarations?.() ?? []) {
|
|
132
|
+
if (declaration.pos >= fn.pos && declaration.end <= fn.end) continue;
|
|
133
|
+
if (outers.some(outer => declaration.pos >= outer.pos && declaration.end <= outer.end)) return true;
|
|
134
|
+
}
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
pureRenderTarget(expression, checker, expectedFile = null) {
|
|
139
|
+
const functions = this.world.expressionFunctions(expression, checker);
|
|
140
|
+
return functions.length > 0 && functions.every(fn => this.world.sourceSet.has(path.resolve(fn.getSourceFile().fileName))
|
|
141
|
+
&& (this.world.insideAny(this.world.roots.components, fn.getSourceFile().fileName) || this.world.insideAny(this.world.roots.features, fn.getSourceFile().fileName))
|
|
142
|
+
&& (!expectedFile || path.resolve(fn.getSourceFile().fileName) === expectedFile)
|
|
143
|
+
&& this.functionHasRender(fn, checker) && !this.world.functionUsesWorld(fn, checker) && !this.capturesOuterFunction(fn, checker));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
expressionSuppliesRender(expression, checker) {
|
|
147
|
+
return this.expressionHasRender(expression, checker)
|
|
148
|
+
|| this.world.expressionFunctions(expression, checker).some(fn => this.functionHasRender(fn, checker));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
routedLayoutChild(opening, child, checker) {
|
|
152
|
+
const ts = this.world.ts;
|
|
153
|
+
if (!ts.isJsxExpression(child) || !child.expression) return false;
|
|
154
|
+
const source = opening.getSourceFile().fileName;
|
|
155
|
+
if (!/^apps\/[^/]+\/src\/features\/layouts\/[^/]+\/index\.tsx$/.test(relativePath(this.world.config.root, source))) return false;
|
|
156
|
+
const sibling = path.resolve(path.dirname(source), 'component.tsx');
|
|
157
|
+
if (!this.pureRenderTarget(opening.tagName, checker, sibling)) return false;
|
|
158
|
+
const value = unwrapExpression(ts, child.expression);
|
|
159
|
+
if (!ts.isPropertyAccessExpression(value) || !['content', 'children'].includes(value.name.text)) return false;
|
|
160
|
+
const receiver = unwrapExpression(ts, value.expression);
|
|
161
|
+
if (!ts.isIdentifier(receiver)) return false;
|
|
162
|
+
const symbol = checker.getSymbolAtLocation(receiver);
|
|
163
|
+
return (symbol?.getDeclarations?.() ?? []).some(declaration => ts.isParameter(declaration));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
renderBoundary(expression, checker, seen = new Set(), allowEmpty = false, expectedFile = null) {
|
|
167
|
+
const ts = this.world.ts;
|
|
168
|
+
expression = unwrapExpression(ts, expression);
|
|
169
|
+
if (!expression) return allowEmpty;
|
|
170
|
+
if (expression.kind === ts.SyntaxKind.NullKeyword || expression.kind === ts.SyntaxKind.FalseKeyword) return allowEmpty;
|
|
171
|
+
if (ts.isConditionalExpression(expression)) return this.conditionalRenderBoundary(expression, checker, seen, allowEmpty, expectedFile);
|
|
172
|
+
if (ts.isBinaryExpression(expression) && expression.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) {
|
|
173
|
+
return this.renderBoundary(expression.right, checker, seen, true, expectedFile);
|
|
174
|
+
}
|
|
175
|
+
if (ts.isArrayLiteralExpression(expression)) return this.arrayRenderBoundary(expression, checker, seen, allowEmpty, expectedFile);
|
|
176
|
+
if (ts.isIdentifier(expression)) return this.identifierRenderBoundary(expression, checker, seen, allowEmpty, expectedFile);
|
|
177
|
+
if (ts.isCallExpression(expression)) return this.callRenderBoundary(expression, checker, seen, expectedFile);
|
|
178
|
+
if (ts.isJsxFragment(expression)) return this.fragmentRenderBoundary(expression, checker, seen, expectedFile);
|
|
179
|
+
if (ts.isJsxElement(expression) || ts.isJsxSelfClosingElement(expression)) return this.jsxRenderBoundary(expression, checker, seen, expectedFile);
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
conditionalRenderBoundary(expression, checker, seen, allowEmpty, expectedFile) {
|
|
184
|
+
return this.renderBoundary(expression.whenTrue, checker, seen, allowEmpty, expectedFile)
|
|
185
|
+
&& this.renderBoundary(expression.whenFalse, checker, seen, allowEmpty, expectedFile);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
arrayRenderBoundary(expression, checker, seen, allowEmpty, expectedFile) {
|
|
189
|
+
return expression.elements.length > 0
|
|
190
|
+
&& expression.elements.every(item => this.renderBoundary(item, checker, seen, allowEmpty, expectedFile));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
identifierRenderBoundary(expression, checker, seen, allowEmpty, expectedFile) {
|
|
194
|
+
const symbol = normalizedSymbolValue(this.world.ts, checker, checker.getSymbolAtLocation(expression));
|
|
195
|
+
if (!symbol || seen.has(symbol)) return false;
|
|
196
|
+
const nextSeen = new Set(seen).add(symbol);
|
|
197
|
+
const values = (symbol.getDeclarations?.() ?? []).flatMap(declaration => this.world.ts.isVariableDeclaration(declaration) && declaration.initializer
|
|
198
|
+
? [declaration.initializer] : []);
|
|
199
|
+
if (values.length) return values.every(value => this.renderBoundary(value, checker, nextSeen, allowEmpty, expectedFile));
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
callRenderBoundary(expression, checker, seen, expectedFile) {
|
|
204
|
+
if (isReactCreateElement(this.world.ts, checker, expression)) return this.createElementBoundary(expression, checker, seen, expectedFile);
|
|
205
|
+
const mapped = this.mappedCallBoundary(expression, checker, seen, expectedFile);
|
|
206
|
+
if (mapped !== null) return mapped;
|
|
207
|
+
return this.pureRenderTarget(expression.expression, checker, expectedFile);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
createElementBoundary(expression, checker, seen, expectedFile) {
|
|
211
|
+
const ts = this.world.ts;
|
|
212
|
+
const target = expression.arguments[0] ? unwrapExpression(ts, expression.arguments[0]) : null;
|
|
213
|
+
if (!target || ts.isStringLiteralLike(target)) return false;
|
|
214
|
+
const pureTarget = this.pureRenderTarget(target, checker, expectedFile) && !wrapperTag(ts, target);
|
|
215
|
+
const wrapper = wrapperTag(ts, target);
|
|
216
|
+
if (!pureTarget && !wrapper) return false;
|
|
217
|
+
const boundary = { hasBoundary: pureTarget };
|
|
218
|
+
const props = expression.arguments[1] ? unwrapExpression(ts, expression.arguments[1]) : null;
|
|
219
|
+
if (!this.createElementPropsBoundary(props, checker, seen, expectedFile, boundary)) return false;
|
|
220
|
+
const children = expression.arguments.slice(2);
|
|
221
|
+
if (children.length) {
|
|
222
|
+
if (!children.every(child => this.renderBoundary(child, checker, seen, false, expectedFile))) return false;
|
|
223
|
+
boundary.hasBoundary = true;
|
|
224
|
+
}
|
|
225
|
+
return boundary.hasBoundary;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
createElementPropsBoundary(props, checker, seen, expectedFile, boundary) {
|
|
229
|
+
const ts = this.world.ts;
|
|
230
|
+
if (!props || props.kind === ts.SyntaxKind.NullKeyword) return true;
|
|
231
|
+
if (!ts.isObjectLiteralExpression(props) || props.properties.some(property => ts.isSpreadAssignment(property))) return false;
|
|
232
|
+
return props.properties.every(property => this.propsPropertyBoundary(property, checker, seen, expectedFile, boundary));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
propsPropertyBoundary(property, checker, seen, expectedFile, boundary) {
|
|
236
|
+
const ts = this.world.ts;
|
|
237
|
+
if (!ts.isPropertyAssignment(property)) return true;
|
|
238
|
+
const value = unwrapExpression(ts, property.initializer);
|
|
239
|
+
if (!this.expressionSuppliesRender(value, checker)) return true;
|
|
240
|
+
if (!this.pureRenderTarget(value, checker, expectedFile) && !this.renderBoundary(value, checker, seen, true, expectedFile)) return false;
|
|
241
|
+
const name = property.name && (ts.isIdentifier(property.name) || ts.isStringLiteralLike(property.name)) ? property.name.text : null;
|
|
242
|
+
if (['children', 'component', 'content', 'render', 'view'].includes(name)) boundary.hasBoundary = true;
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
mappedCallBoundary(expression, checker, seen, expectedFile) {
|
|
247
|
+
const ts = this.world.ts;
|
|
248
|
+
if (!ts.isPropertyAccessExpression(expression.expression) && !ts.isElementAccessExpression(expression.expression)) return null;
|
|
249
|
+
let name = ts.isPropertyAccessExpression(expression.expression) ? expression.expression.name.text : null;
|
|
250
|
+
if (name === null && ts.isStringLiteralLike(expression.expression.argumentExpression)) name = expression.expression.argumentExpression.text;
|
|
251
|
+
if (name !== 'map' || !expression.arguments[0]) return null;
|
|
252
|
+
const renderers = this.world.expressionFunctions(expression.arguments[0], checker);
|
|
253
|
+
return renderers.length > 0 && renderers.every(fn => !this.world.functionUsesWorld(fn, checker)
|
|
254
|
+
&& this.helpers.returnedExpressions(ts, fn).length > 0
|
|
255
|
+
&& this.helpers.returnedExpressions(ts, fn).every(value => this.renderBoundary(value, checker, seen, true, expectedFile)));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
fragmentRenderBoundary(expression, checker, seen, expectedFile) {
|
|
259
|
+
const ts = this.world.ts;
|
|
260
|
+
const children = expression.children.filter(child => !ts.isJsxText(child) || child.text.trim());
|
|
261
|
+
return children.length > 0 && children.every(child => !ts.isJsxText(child)
|
|
262
|
+
&& this.renderBoundary(ts.isJsxExpression(child) ? child.expression : child, checker, seen, false, expectedFile));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
jsxRenderBoundary(expression, checker, seen, expectedFile) {
|
|
266
|
+
const ts = this.world.ts;
|
|
267
|
+
const opening = ts.isJsxElement(expression) ? expression.openingElement : expression;
|
|
268
|
+
const pureTarget = this.pureRenderTarget(opening.tagName, checker, expectedFile) && !wrapperTag(ts, opening.tagName);
|
|
269
|
+
const wrapper = wrapperTag(ts, opening.tagName);
|
|
270
|
+
if (!pureTarget && !wrapper) return false;
|
|
271
|
+
const boundary = { hasBoundary: pureTarget };
|
|
272
|
+
if (!this.jsxChildrenBoundary(expression, opening, checker, seen, expectedFile, pureTarget, boundary)) return false;
|
|
273
|
+
if (!this.jsxAttributesBoundary(opening, checker, seen, expectedFile, boundary)) return false;
|
|
274
|
+
return boundary.hasBoundary;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
jsxChildrenBoundary(expression, opening, checker, seen, expectedFile, pureTarget, boundary) {
|
|
278
|
+
const ts = this.world.ts;
|
|
279
|
+
const children = ts.isJsxElement(expression)
|
|
280
|
+
? expression.children.filter(child => !ts.isJsxText(child) || child.text.trim()) : [];
|
|
281
|
+
if (!children.length) return true;
|
|
282
|
+
const routedChild = pureTarget && children.length === 1 && this.routedLayoutChild(opening, children[0], checker);
|
|
283
|
+
if (!routedChild && !children.every(child => !ts.isJsxText(child)
|
|
284
|
+
&& this.renderBoundary(ts.isJsxExpression(child) ? child.expression : child, checker, seen, false, expectedFile))) return false;
|
|
285
|
+
boundary.hasBoundary = true;
|
|
286
|
+
return true;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
jsxAttributesBoundary(opening, checker, seen, expectedFile, boundary) {
|
|
290
|
+
const ts = this.world.ts;
|
|
291
|
+
return opening.attributes.properties.every(attribute => !ts.isJsxAttribute(attribute) || !attribute.initializer
|
|
292
|
+
|| this.jsxAttributeBoundary(attribute, checker, seen, expectedFile, boundary));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
jsxAttributeBoundary(attribute, checker, seen, expectedFile, boundary) {
|
|
296
|
+
const ts = this.world.ts;
|
|
297
|
+
const name = attribute.name.text;
|
|
298
|
+
if (['fallback', 'errorElement'].includes(name)) {
|
|
299
|
+
if (ts.isStringLiteral(attribute.initializer)) return false;
|
|
300
|
+
if (ts.isJsxExpression(attribute.initializer)
|
|
301
|
+
&& !this.renderBoundary(attribute.initializer.expression, checker, seen, true, expectedFile)) return false;
|
|
302
|
+
}
|
|
303
|
+
if (ts.isJsxExpression(attribute.initializer) && attribute.initializer.expression
|
|
304
|
+
&& this.expressionSuppliesRender(attribute.initializer.expression, checker)) {
|
|
305
|
+
if (!this.pureRenderTarget(attribute.initializer.expression, checker, expectedFile)
|
|
306
|
+
&& !this.renderBoundary(attribute.initializer.expression, checker, seen, true, expectedFile)) return false;
|
|
307
|
+
if (['component', 'content', 'render', 'view'].includes(name)) boundary.hasBoundary = true;
|
|
308
|
+
}
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { normalizedSymbolValue, returnedExpressions as sharedReturnedExpressions } from './ast-walks.mjs';
|
|
3
|
+
import { reachableViolation, relativePath, unwrapExpression } from './typescript.mjs';
|
|
4
|
+
import { WorldRenderAnalysis } from './frontend-world-render.mjs';
|
|
5
|
+
const WORLD_NAVIGATION_CALLS = new Set(['useParams', 'usePathname', 'useRouter', 'useSearchParams', 'useSelectedLayoutSegment', 'useSelectedLayoutSegments']);
|
|
6
|
+
const WORLD_INTL_CALLS = new Set(['useLocale', 'useMessages', 'useNow', 'useTimeZone', 'useTranslations']);
|
|
7
|
+
const WORLD_SWR_CALLS = new Set(['default', 'useSWR', 'useSWRConfig', 'useSWRImmutable', 'useSWRMutation']);
|
|
8
|
+
const LOWER_COMPONENT_TIERS = new Set(['composites', 'branches', 'leaves']);
|
|
9
|
+
function selectedSymbol(ts, checker, expression) {
|
|
10
|
+
const selected = unwrapExpression(ts, expression);
|
|
11
|
+
if (!selected) return null;
|
|
12
|
+
if (ts.isIdentifier(selected)) return checker.getSymbolAtLocation(selected) ?? null;
|
|
13
|
+
if (ts.isPropertyAccessExpression(selected)) return checker.getSymbolAtLocation(selected.name) ?? null;
|
|
14
|
+
if (ts.isElementAccessExpression(selected) && ts.isStringLiteralLike(selected.argumentExpression)) {
|
|
15
|
+
return checker.getSymbolAtLocation(selected.argumentExpression)
|
|
16
|
+
?? checker.getTypeAtLocation(selected.expression).getProperty(selected.argumentExpression.text)
|
|
17
|
+
?? null;
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/* The wider boundary set: `ts.isFunctionLike` (includes call signatures' declarations) rather than the
|
|
23
|
+
* four default callable kinds. */
|
|
24
|
+
const returnedExpressions = (ts, declaration) => sharedReturnedExpressions(ts, declaration, { functionLike: ts.isFunctionLike });
|
|
25
|
+
|
|
26
|
+
function knownWorldImport(specifier, imported) {
|
|
27
|
+
if (specifier === 'next-intl') return WORLD_INTL_CALLS.has(imported);
|
|
28
|
+
if (specifier === 'next/navigation' || /(?:^|\/)i18n\/navigation$/.test(specifier)) return WORLD_NAVIGATION_CALLS.has(imported);
|
|
29
|
+
if (specifier === 'swr' || specifier === 'swr/immutable' || specifier === 'swr/mutation') return WORLD_SWR_CALLS.has(imported);
|
|
30
|
+
if (specifier === 'next-auth/react') return imported === 'useSession';
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
class WorldAnalysis {
|
|
35
|
+
constructor(config, context, roots, helpers) {
|
|
36
|
+
this.config = config;
|
|
37
|
+
this.context = context;
|
|
38
|
+
this.roots = roots;
|
|
39
|
+
this.ts = context.ts;
|
|
40
|
+
Object.assign(this, helpers);
|
|
41
|
+
this.violations = [];
|
|
42
|
+
this.sourceSet = new Set(context.files.map(source => path.resolve(source.fileName)));
|
|
43
|
+
this.worldInfoCache = new Map();
|
|
44
|
+
this.functionWorldCache = new Map();
|
|
45
|
+
this.renderOutputCache = new Map();
|
|
46
|
+
this.intrinsicUiHookCache = new Map();
|
|
47
|
+
this.renderer = new WorldRenderAnalysis(this, { selectedSymbol, returnedExpressions });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
forbiddenIntrinsicEdge(edge, nextVisiting) {
|
|
51
|
+
const forbidden = reachableViolation(this.context.edges, edge, target => this.insideAny(this.roots.transport, target)
|
|
52
|
+
|| this.insideAny(this.roots.modules, target) || this.insideAny(this.roots.features, target) || this.insideAny(this.roots.routes, target)
|
|
53
|
+
|| (this.insideAny(this.roots.hooks, target) && relativePath(this.rootFor(this.roots.hooks, target), target).split('/')[0] !== 'ui'),
|
|
54
|
+
{ follow: candidate => candidate.runtime });
|
|
55
|
+
return Boolean(forbidden || (this.insideAny(this.roots.hooks, edge.to) && !this.intrinsicUiHookFile(edge.to, nextVisiting)));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
forbiddenWorldImport(statement) {
|
|
59
|
+
if (!this.ts.isImportDeclaration(statement) || !statement.importClause || statement.importClause.isTypeOnly
|
|
60
|
+
|| !this.ts.isStringLiteralLike(statement.moduleSpecifier)) return false;
|
|
61
|
+
const specifier = statement.moduleSpecifier.text;
|
|
62
|
+
const bindings = [];
|
|
63
|
+
if (statement.importClause.name) bindings.push('default');
|
|
64
|
+
const named = statement.importClause.namedBindings;
|
|
65
|
+
if (named && this.ts.isNamespaceImport(named)
|
|
66
|
+
&& ['next/navigation', 'next-intl', 'swr', 'swr/immutable', 'swr/mutation', 'next-auth/react'].includes(specifier)) return true;
|
|
67
|
+
if (named && this.ts.isNamedImports(named)) {
|
|
68
|
+
for (const element of named.elements) if (!element.isTypeOnly) bindings.push(element.propertyName?.text ?? element.name.text);
|
|
69
|
+
}
|
|
70
|
+
return bindings.some(imported => knownWorldImport(specifier, imported));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
containsUnshadowedFetch(sourceFile, checker) {
|
|
74
|
+
let found = false;
|
|
75
|
+
const visit = node => {
|
|
76
|
+
if (!found && this.ts.isCallExpression(node) && this.ts.isIdentifier(node.expression) && node.expression.text === 'fetch'
|
|
77
|
+
&& !checker.getSymbolAtLocation(node.expression)) found = true;
|
|
78
|
+
if (!found) this.ts.forEachChild(node, visit);
|
|
79
|
+
};
|
|
80
|
+
visit(sourceFile);
|
|
81
|
+
return found;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
intrinsicUiHookFile(fileName, visiting = new Set()) {
|
|
85
|
+
const resolved = path.resolve(fileName);
|
|
86
|
+
if (this.intrinsicUiHookCache.has(resolved)) return this.intrinsicUiHookCache.get(resolved);
|
|
87
|
+
const hookRoot = this.rootFor(this.roots.hooks, resolved);
|
|
88
|
+
if (!hookRoot || relativePath(hookRoot, resolved).split('/')[0] !== 'ui' || visiting.has(resolved)) return false;
|
|
89
|
+
const sourceFile = this.context.files.find(source => path.resolve(source.fileName) === resolved);
|
|
90
|
+
if (!sourceFile) return false;
|
|
91
|
+
this.intrinsicUiHookCache.set(resolved, false);
|
|
92
|
+
const nextVisiting = new Set(visiting).add(resolved);
|
|
93
|
+
const checker = this.context.checkerFor(resolved);
|
|
94
|
+
const edges = this.importsForSource(this.context, resolved);
|
|
95
|
+
let intrinsic = true;
|
|
96
|
+
for (const edge of edges.filter(candidate => candidate.runtime)) {
|
|
97
|
+
if (this.forbiddenIntrinsicEdge(edge, nextVisiting)) {
|
|
98
|
+
intrinsic = false;
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
for (const statement of sourceFile.statements) {
|
|
103
|
+
if (!intrinsic) break;
|
|
104
|
+
if (this.forbiddenWorldImport(statement)) { intrinsic = false; break; }
|
|
105
|
+
}
|
|
106
|
+
if (intrinsic && this.containsUnshadowedFetch(sourceFile, checker)) intrinsic = false;
|
|
107
|
+
this.intrinsicUiHookCache.set(resolved, intrinsic);
|
|
108
|
+
return intrinsic;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
intrinsicUiHookSymbol(symbol, checker) {
|
|
112
|
+
const identity = normalizedSymbolValue(this.ts, checker, symbol);
|
|
113
|
+
const declarations = identity?.getDeclarations?.() ?? [];
|
|
114
|
+
return declarations.length > 0 && declarations.every(declaration => this.intrinsicUiHookFile(declaration.getSourceFile().fileName));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
worldEdgeOf(edge) {
|
|
118
|
+
return edge && Boolean(reachableViolation(this.context.edges, edge,
|
|
119
|
+
target => this.insideAny(this.roots.hooks, target) || this.insideAny(this.roots.transport, target),
|
|
120
|
+
{ follow: candidate => candidate.reexport && candidate.runtime }));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
collectNamedWorldImports(checker, named, specifier, worldEdge, symbols) {
|
|
124
|
+
for (const element of named.elements) {
|
|
125
|
+
if (element.isTypeOnly) continue;
|
|
126
|
+
const imported = element.propertyName?.text ?? element.name.text;
|
|
127
|
+
const symbol = checker.getSymbolAtLocation(element.name);
|
|
128
|
+
if (symbol && (worldEdge || knownWorldImport(specifier, imported))) symbols.add(symbol);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
inspectWorldImportStatement(sourceFile, checker, byStart, symbols, namespaces, statement) {
|
|
133
|
+
if (!this.ts.isImportDeclaration(statement) || !statement.importClause || statement.importClause.isTypeOnly
|
|
134
|
+
|| !this.ts.isStringLiteralLike(statement.moduleSpecifier)) return;
|
|
135
|
+
const specifier = statement.moduleSpecifier.text;
|
|
136
|
+
const worldEdge = this.worldEdgeOf(byStart.get(statement.moduleSpecifier.getStart(sourceFile)));
|
|
137
|
+
const clause = statement.importClause;
|
|
138
|
+
if (clause.name) {
|
|
139
|
+
const symbol = checker.getSymbolAtLocation(clause.name);
|
|
140
|
+
if (symbol && (worldEdge || knownWorldImport(specifier, 'default'))) symbols.add(symbol);
|
|
141
|
+
}
|
|
142
|
+
const named = clause.namedBindings;
|
|
143
|
+
if (named && this.ts.isNamedImports(named)) this.collectNamedWorldImports(checker, named, specifier, worldEdge, symbols);
|
|
144
|
+
if (named && this.ts.isNamespaceImport(named)) {
|
|
145
|
+
const symbol = checker.getSymbolAtLocation(named.name);
|
|
146
|
+
if (symbol) namespaces.set(symbol, { specifier, worldEdge });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
importInfo(sourceFile) {
|
|
151
|
+
const key = path.resolve(sourceFile.fileName);
|
|
152
|
+
if (this.worldInfoCache.has(key)) return this.worldInfoCache.get(key);
|
|
153
|
+
const checker = this.context.checkerFor(sourceFile.fileName);
|
|
154
|
+
const edges = this.importsForSource(this.context, sourceFile.fileName);
|
|
155
|
+
const byStart = new Map(edges.map(edge => [edge.node.getStart(sourceFile), edge]));
|
|
156
|
+
const symbols = new Set();
|
|
157
|
+
const namespaces = new Map();
|
|
158
|
+
for (const statement of sourceFile.statements) this.inspectWorldImportStatement(sourceFile, checker, byStart, symbols, namespaces, statement);
|
|
159
|
+
const value = { checker, symbols, namespaces };
|
|
160
|
+
this.worldInfoCache.set(key, value);
|
|
161
|
+
return value;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
collectExpressionFunctions(declaration, checker, seen, functions) {
|
|
165
|
+
if (this.ts.isFunctionDeclaration(declaration) || this.ts.isMethodDeclaration(declaration)) functions.push(declaration);
|
|
166
|
+
else if (this.ts.isVariableDeclaration(declaration) && declaration.initializer) {
|
|
167
|
+
functions.push(...this.expressionFunctions(declaration.initializer, checker, seen));
|
|
168
|
+
} else if (this.ts.isClassDeclaration(declaration)) {
|
|
169
|
+
functions.push(...declaration.members.filter(member => this.ts.isMethodDeclaration(member)
|
|
170
|
+
&& member.name && (this.ts.isIdentifier(member.name) || this.ts.isStringLiteralLike(member.name)) && member.name.text === 'render'));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
isComponentWrapperCall(call) {
|
|
175
|
+
const selected = unwrapExpression(this.ts, call.expression);
|
|
176
|
+
const name = (this.ts.isIdentifier(selected) && selected.text) || (this.ts.isPropertyAccessExpression(selected) && selected.name.text) || null;
|
|
177
|
+
return ['forwardRef', 'memo'].includes(name);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
expressionFunctions(expression, checker, seen = new Set()) {
|
|
181
|
+
expression = unwrapExpression(this.ts, expression);
|
|
182
|
+
if (!expression) return [];
|
|
183
|
+
if (this.ts.isArrowFunction(expression) || this.ts.isFunctionExpression(expression) || this.ts.isMethodDeclaration(expression)
|
|
184
|
+
|| this.ts.isFunctionDeclaration(expression)) return [expression];
|
|
185
|
+
if (this.ts.isConditionalExpression(expression)) {
|
|
186
|
+
return [...this.expressionFunctions(expression.whenTrue, checker, seen), ...this.expressionFunctions(expression.whenFalse, checker, seen)];
|
|
187
|
+
}
|
|
188
|
+
if (this.ts.isCallExpression(expression) && this.isComponentWrapperCall(expression) && expression.arguments[0]) {
|
|
189
|
+
return this.expressionFunctions(expression.arguments[0], checker, seen);
|
|
190
|
+
}
|
|
191
|
+
const symbol = normalizedSymbolValue(this.ts, checker, selectedSymbol(this.ts, checker, expression));
|
|
192
|
+
if (!symbol || seen.has(symbol)) return [];
|
|
193
|
+
const nextSeen = new Set(seen).add(symbol);
|
|
194
|
+
const functions = [];
|
|
195
|
+
for (const declaration of symbol.getDeclarations?.() ?? []) this.collectExpressionFunctions(declaration, checker, nextSeen, functions);
|
|
196
|
+
return [...new Set(functions.filter(fn => fn.body))];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
symbolIsWorld(raw, sourceFile, propertyName = null) {
|
|
200
|
+
if (!raw) return false;
|
|
201
|
+
const info = this.importInfo(sourceFile);
|
|
202
|
+
if (this.intrinsicUiHookSymbol(raw, info.checker)) return false;
|
|
203
|
+
if (info.symbols.has(raw)) return true;
|
|
204
|
+
for (const [namespace, declaration] of info.namespaces) if (namespace === raw) {
|
|
205
|
+
return declaration.worldEdge || knownWorldImport(declaration.specifier, propertyName ?? '');
|
|
206
|
+
}
|
|
207
|
+
const identity = normalizedSymbolValue(this.ts, info.checker, raw);
|
|
208
|
+
return (identity?.getDeclarations?.() ?? []).some(declaration => {
|
|
209
|
+
const fileName = declaration.getSourceFile().fileName;
|
|
210
|
+
return this.insideAny(this.roots.hooks, fileName) || this.insideAny(this.roots.transport, fileName);
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
namespaceIsWorld(raw, sourceFile, propertyName) {
|
|
215
|
+
const info = this.importInfo(sourceFile);
|
|
216
|
+
const declaration = info.namespaces.get(raw);
|
|
217
|
+
return Boolean(declaration && (declaration.worldEdge || knownWorldImport(declaration.specifier, propertyName ?? '')));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
worldMemberCallee(expression, checker, sourceFile) {
|
|
221
|
+
let property = this.ts.isPropertyAccessExpression(expression) ? expression.name.text : null;
|
|
222
|
+
if (property === null && this.ts.isStringLiteralLike(expression.argumentExpression)) property = expression.argumentExpression.text;
|
|
223
|
+
const namespace = checker.getSymbolAtLocation(expression.expression);
|
|
224
|
+
if (this.intrinsicUiHookSymbol(selectedSymbol(this.ts, checker, expression), checker)) return false;
|
|
225
|
+
if (this.namespaceIsWorld(namespace, sourceFile, property)) return true;
|
|
226
|
+
return this.symbolIsWorld(selectedSymbol(this.ts, checker, expression), sourceFile);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
worldVariableInitializer(symbol, checker, nextSeen) {
|
|
230
|
+
for (const declaration of symbol.getDeclarations?.() ?? []) {
|
|
231
|
+
if (this.ts.isVariableDeclaration(declaration) && declaration.initializer
|
|
232
|
+
&& this.calleeIsWorld(declaration.initializer, checker, declaration.getSourceFile(), nextSeen)) return true;
|
|
233
|
+
}
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
calleeIsWorld(input, checker, sourceFile, seen = new Set()) {
|
|
238
|
+
const expression = unwrapExpression(this.ts, input);
|
|
239
|
+
if (this.ts.isIdentifier(expression) && this.symbolIsWorld(checker.getSymbolAtLocation(expression), sourceFile)) return true;
|
|
240
|
+
if (this.ts.isPropertyAccessExpression(expression) || this.ts.isElementAccessExpression(expression))
|
|
241
|
+
if (this.worldMemberCallee(expression, checker, sourceFile)) return true;
|
|
242
|
+
const symbol = normalizedSymbolValue(this.ts, checker, selectedSymbol(this.ts, checker, expression));
|
|
243
|
+
if (!symbol || seen.has(symbol)) return false;
|
|
244
|
+
const nextSeen = new Set(seen).add(symbol);
|
|
245
|
+
if (this.worldVariableInitializer(symbol, checker, nextSeen)) return true;
|
|
246
|
+
return this.expressionFunctions(expression, checker).some(fn => this.functionUsesWorld(fn, checker, nextSeen));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
callIsWorld(call, checker, sourceFile, seen) { return this.calleeIsWorld(call.expression, checker, sourceFile, seen); }
|
|
250
|
+
|
|
251
|
+
functionUsesWorld(fn, checker, seen = new Set()) {
|
|
252
|
+
if (this.functionWorldCache.has(fn)) return this.functionWorldCache.get(fn);
|
|
253
|
+
this.functionWorldCache.set(fn, false);
|
|
254
|
+
let found = false;
|
|
255
|
+
const sourceFile = fn.getSourceFile();
|
|
256
|
+
const visit = node => {
|
|
257
|
+
if (found) return;
|
|
258
|
+
if (this.ts.isCallExpression(node) && this.callIsWorld(node, checker, sourceFile, seen)) { found = true; return; }
|
|
259
|
+
this.ts.forEachChild(node, visit);
|
|
260
|
+
};
|
|
261
|
+
visit(fn.body ?? fn);
|
|
262
|
+
this.functionWorldCache.set(fn, found);
|
|
263
|
+
return found;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
collectFunctionNodes(node, functions) {
|
|
267
|
+
if (this.ts.isFunctionDeclaration(node) || this.ts.isMethodDeclaration(node) || this.ts.isArrowFunction(node) || this.ts.isFunctionExpression(node)) functions.push(node);
|
|
268
|
+
this.ts.forEachChild(node, child => this.collectFunctionNodes(child, functions));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
inspectBlockProductHooks(sourceFile, checker, tier, functions) {
|
|
272
|
+
for (const fn of functions) {
|
|
273
|
+
const name = this.declarationName(this.ts, fn);
|
|
274
|
+
if (tier?.tier === 'blocks' && name && /^use[A-Z0-9]/.test(name.text) && this.functionUsesWorld(fn, checker)) {
|
|
275
|
+
this.violations.push(this.violation(this.config, sourceFile, name, 'FE_BLOCK_PRODUCT_HOOK_DEFINITION',
|
|
276
|
+
'A block may call a product hook from hooks but cannot define product-world or data lifecycle under the block.', { ts: this.ts }));
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
inspectWorldOwner(sourceFile, checker, tier, fn) {
|
|
282
|
+
if (!this.renderer.functionHasRender(fn, checker) || !this.functionUsesWorld(fn, checker)) return;
|
|
283
|
+
const returned = returnedExpressions(this.ts, fn);
|
|
284
|
+
if (!returned.length || returned.some(expression => !this.renderer.renderBoundary(expression, checker, new Set(), true))) {
|
|
285
|
+
this.violations.push(this.violation(this.config, sourceFile, fn, 'FE_WORLD_OWNER_RENDER_BOUNDARY',
|
|
286
|
+
'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: this.ts }));
|
|
287
|
+
}
|
|
288
|
+
if (tier && LOWER_COMPONENT_TIERS.has(tier.tier)) {
|
|
289
|
+
this.violations.push(this.violation(this.config, sourceFile, fn, 'FE_COMPONENT_WORLD_OWNERSHIP',
|
|
290
|
+
`${tier.tier} may own intrinsic browser interaction but cannot own product-world, navigation, locale, session or data lifecycle.`, { ts: this.ts }));
|
|
291
|
+
}
|
|
292
|
+
if (tier?.tier === 'blocks') this.inspectConnectedBlock(sourceFile, checker, fn, returned);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
inspectConnectedBlock(sourceFile, checker, fn, returned) {
|
|
296
|
+
const expected = path.resolve(path.dirname(sourceFile.fileName), 'component.tsx');
|
|
297
|
+
const index = path.basename(sourceFile.fileName).toLowerCase() === 'index.tsx';
|
|
298
|
+
if (!index || !this.sourceSet.has(expected) || !returned.length
|
|
299
|
+
|| returned.some(expression => !this.renderer.renderBoundary(expression, checker, new Set(), true, expected))) {
|
|
300
|
+
this.violations.push(this.violation(this.config, sourceFile, fn, 'FE_CONNECTED_BLOCK_RENDER_PAIR',
|
|
301
|
+
'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: this.ts }));
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
inspectWorldOwners(sourceFile, checker, tier, functions) {
|
|
306
|
+
for (const fn of functions) this.inspectWorldOwner(sourceFile, checker, tier, fn);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
inspectSourceFile(sourceFile) {
|
|
310
|
+
if ((!this.insideAny(this.roots.components, sourceFile.fileName) && !this.insideAny(this.roots.features, sourceFile.fileName))
|
|
311
|
+
|| !/\.[cm]?tsx$/i.test(sourceFile.fileName)) return;
|
|
312
|
+
const checker = this.context.checkerFor(sourceFile.fileName);
|
|
313
|
+
const tier = this.tierOf(this.roots.components, sourceFile.fileName);
|
|
314
|
+
const functions = [];
|
|
315
|
+
this.collectFunctionNodes(sourceFile, functions);
|
|
316
|
+
this.inspectBlockProductHooks(sourceFile, checker, tier, functions);
|
|
317
|
+
this.inspectWorldOwners(sourceFile, checker, tier, functions);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
check() {
|
|
321
|
+
for (const sourceFile of this.context.files) this.inspectSourceFile(sourceFile);
|
|
322
|
+
return this.violations;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function checkWorldRenderBoundaries(config, context, roots, helpers) {
|
|
327
|
+
return new WorldAnalysis(config, context, roots, helpers).check();
|
|
328
|
+
}
|