@craft-ts/dev-tools 0.7.0-beta.13
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/LICENSE +21 -0
- package/README.md +154 -0
- package/generators.json +26 -0
- package/package.json +119 -0
- package/src/bin/craft-architecture-check.d.ts +2 -0
- package/src/bin/craft-architecture-check.js +51 -0
- package/src/bin/craft-architecture-check.js.map +1 -0
- package/src/bin/craft-brand.d.ts +2 -0
- package/src/bin/craft-brand.js +9 -0
- package/src/bin/craft-brand.js.map +1 -0
- package/src/bin/craft-graph.d.ts +2 -0
- package/src/bin/craft-graph.js +72 -0
- package/src/bin/craft-graph.js.map +1 -0
- package/src/bin/craft-migrate-architecture.d.ts +2 -0
- package/src/bin/craft-migrate-architecture.js +64 -0
- package/src/bin/craft-migrate-architecture.js.map +1 -0
- package/src/bin/craft-migrate-components.d.ts +2 -0
- package/src/bin/craft-migrate-components.js +67 -0
- package/src/bin/craft-migrate-components.js.map +1 -0
- package/src/bin/craft-migrate-primitives.d.ts +2 -0
- package/src/bin/craft-migrate-primitives.js +71 -0
- package/src/bin/craft-migrate-primitives.js.map +1 -0
- package/src/bin/craft-migrate-routes.d.ts +2 -0
- package/src/bin/craft-migrate-routes.js +77 -0
- package/src/bin/craft-migrate-routes.js.map +1 -0
- package/src/bin/craft-migrate-services.d.ts +2 -0
- package/src/bin/craft-migrate-services.js +75 -0
- package/src/bin/craft-migrate-services.js.map +1 -0
- package/src/bin/craft-migrate-template.d.ts +2 -0
- package/src/bin/craft-migrate-template.js +68 -0
- package/src/bin/craft-migrate-template.js.map +1 -0
- package/src/bin/craft-migrate.d.ts +2 -0
- package/src/bin/craft-migrate.js +91 -0
- package/src/bin/craft-migrate.js.map +1 -0
- package/src/bin/craft.d.ts +2 -0
- package/src/bin/craft.js +318 -0
- package/src/bin/craft.js.map +1 -0
- package/src/eslint-rules/anchor-has-href.cjs +35 -0
- package/src/eslint-rules/app-start-registry-match.cjs +355 -0
- package/src/eslint-rules/button-has-type.cjs +50 -0
- package/src/eslint-rules/control-has-accessible-name.cjs +56 -0
- package/src/eslint-rules/craft-component-name-match.cjs +8 -0
- package/src/eslint-rules/craft-computed-name-match.cjs +63 -0
- package/src/eslint-rules/craft-css-token-registry.cjs +17 -0
- package/src/eslint-rules/craft-css-var-naming.cjs +18 -0
- package/src/eslint-rules/craft-css-vars-contract.cjs +34 -0
- package/src/eslint-rules/craft-directive-name-match.cjs +8 -0
- package/src/eslint-rules/craft-method-name-match.cjs +8 -0
- package/src/eslint-rules/craft-name-match-utils.cjs +179 -0
- package/src/eslint-rules/craft-primitive-context.cjs +46 -0
- package/src/eslint-rules/craft-signal-source-name-match.cjs +8 -0
- package/src/eslint-rules/craft-source-name-match.cjs +8 -0
- package/src/eslint-rules/craft-styles-scope-safe.cjs +25 -0
- package/src/eslint-rules/css-rule-utils.cjs +109 -0
- package/src/eslint-rules/global-exception-registry-match.cjs +562 -0
- package/src/eslint-rules/heading-has-content.cjs +31 -0
- package/src/eslint-rules/html-helpers.cjs +115 -0
- package/src/eslint-rules/hyperscript-walk.cjs +249 -0
- package/src/eslint-rules/iframe-has-title.cjs +26 -0
- package/src/eslint-rules/img-has-alt.cjs +28 -0
- package/src/eslint-rules/index.cjs +209 -0
- package/src/eslint-rules/index.d.cts +5 -0
- package/src/eslint-rules/label-has-associated-control.cjs +67 -0
- package/src/eslint-rules/no-async-await.cjs +46 -0
- package/src/eslint-rules/no-craft-computed-side-effects.cjs +301 -0
- package/src/eslint-rules/no-craft-use-in-template.cjs +89 -0
- package/src/eslint-rules/no-craft-use.cjs +42 -0
- package/src/eslint-rules/no-direct-temporal-globals.cjs +73 -0
- package/src/eslint-rules/no-effect-in-params.cjs +265 -0
- package/src/eslint-rules/no-effect-outside-loaders.cjs +3 -0
- package/src/eslint-rules/no-ephemeral-template-form-state.cjs +131 -0
- package/src/eslint-rules/no-hardcoded-design-values.cjs +15 -0
- package/src/eslint-rules/no-heading-level-skip.cjs +101 -0
- package/src/eslint-rules/no-imperative-craft-resource-trigger.cjs +291 -0
- package/src/eslint-rules/no-important-in-component-styles.cjs +12 -0
- package/src/eslint-rules/no-injection-token.cjs +173 -0
- package/src/eslint-rules/no-invalid-insertion-pipe.cjs +162 -0
- package/src/eslint-rules/no-manual-route-provider-list.cjs +85 -0
- package/src/eslint-rules/no-noninteractive-element-interactions.cjs +66 -0
- package/src/eslint-rules/no-positive-tabindex.cjs +44 -0
- package/src/eslint-rules/no-redundant-primitive-insertion.cjs +361 -0
- package/src/eslint-rules/no-render-writes.cjs +106 -0
- package/src/eslint-rules/no-throw.cjs +132 -0
- package/src/eslint-rules/no-transition-actions.cjs +133 -0
- package/src/eslint-rules/no-type-assertions-in-template.cjs +211 -0
- package/src/eslint-rules/no-widened-route-provider-context.cjs +39 -0
- package/src/eslint-rules/prefer-browser-boundaries.cjs +92 -0
- package/src/eslint-rules/prefer-craft-http-transport.cjs +103 -0
- package/src/eslint-rules/prefer-craft-template-blocks.cjs +492 -0
- package/src/eslint-rules/prefer-direct-yieldable-callback.cjs +105 -0
- package/src/eslint-rules/prefer-named-html-helpers.cjs +33 -0
- package/src/eslint-rules/prefer-query-method-over-state-trigger.cjs +282 -0
- package/src/eslint-rules/prefer-relative-heading.cjs +43 -0
- package/src/eslint-rules/provide-host-name-match-component.cjs +455 -0
- package/src/eslint-rules/recommended-config.cjs +77 -0
- package/src/eslint-rules/require-assert-exhaustive-route-exceptions.cjs +259 -0
- package/src/eslint-rules/require-cascade-route-di-check.cjs +223 -0
- package/src/eslint-rules/require-child-route-mount-check.cjs +224 -0
- package/src/eslint-rules/require-component-monitoring.cjs +212 -0
- package/src/eslint-rules/require-craft-exception-handler.cjs +142 -0
- package/src/eslint-rules/require-craft-method-for-yieldable-callback.cjs +236 -0
- package/src/eslint-rules/require-craft-resource-trigger-yield.cjs +209 -0
- package/src/eslint-rules/require-effect-adapters.cjs +86 -0
- package/src/eslint-rules/require-exception-component-di-check.cjs +314 -0
- package/src/eslint-rules/require-focus-visible.cjs +36 -0
- package/src/eslint-rules/require-interactive-local-name.cjs +105 -0
- package/src/eslint-rules/require-lazy-load-with-retry.cjs +148 -0
- package/src/eslint-rules/require-outlet-heading-section.cjs +70 -0
- package/src/eslint-rules/require-pending-component-di-check.cjs +490 -0
- package/src/eslint-rules/require-primitive-context.cjs +156 -0
- package/src/eslint-rules/require-primitive-derived-property.cjs +598 -0
- package/src/eslint-rules/require-primitive-generator-unwrap.cjs +274 -0
- package/src/eslint-rules/require-reactive-template-bindings.cjs +305 -0
- package/src/eslint-rules/require-reduced-motion.cjs +38 -0
- package/src/eslint-rules/require-route-heading-outline.cjs +166 -0
- package/src/eslint-rules/require-yieldable-insertion-write.cjs +98 -0
- package/src/eslint-rules/require-yieldable-reactive-read.cjs +102 -0
- package/src/eslint-rules/require-yieldable-template-method.cjs +424 -0
- package/src/eslint-rules/role-has-required-aria.cjs +38 -0
- package/src/eslint-rules/server-function-client-match.cjs +283 -0
- package/src/eslint-rules/target-blank-noopener.cjs +31 -0
- package/src/eslint-rules/template-element-name-unique.cjs +124 -0
- package/src/eslint-rules/valid-aria.cjs +38 -0
- package/src/generators/route/compat.d.ts +3 -0
- package/src/generators/route/compat.js +6 -0
- package/src/generators/route/compat.js.map +1 -0
- package/src/generators/route/generator.d.ts +31 -0
- package/src/generators/route/generator.js +402 -0
- package/src/generators/route/generator.js.map +1 -0
- package/src/generators/route/schema.json +50 -0
- package/src/generators/route/split-schema.json +19 -0
- package/src/index.d.ts +21 -0
- package/src/index.js +23 -0
- package/src/index.js.map +1 -0
- package/src/scripts/angular-brand-codemod.d.ts +139 -0
- package/src/scripts/angular-brand-codemod.js +2318 -0
- package/src/scripts/angular-brand-codemod.js.map +1 -0
- package/src/scripts/angular-brand-codemod.ts +3985 -0
- package/src/scripts/architecture/migrate-architecture.d.ts +14 -0
- package/src/scripts/architecture/migrate-architecture.js +534 -0
- package/src/scripts/architecture/migrate-architecture.js.map +1 -0
- package/src/scripts/architecture/migrate-architecture.ts +653 -0
- package/src/scripts/architecture-graph.d.ts +368 -0
- package/src/scripts/architecture-graph.js +2191 -0
- package/src/scripts/architecture-graph.js.map +1 -0
- package/src/scripts/architecture-graph.ts +3136 -0
- package/src/scripts/components/migrate-components.d.ts +20 -0
- package/src/scripts/components/migrate-components.js +163 -0
- package/src/scripts/components/migrate-components.js.map +1 -0
- package/src/scripts/components/migrate-components.ts +227 -0
- package/src/scripts/components/migration-diagnostic.d.ts +7 -0
- package/src/scripts/components/migration-diagnostic.js +2 -0
- package/src/scripts/components/migration-diagnostic.js.map +1 -0
- package/src/scripts/components/migration-diagnostic.ts +8 -0
- package/src/scripts/create/create-project.d.ts +20 -0
- package/src/scripts/create/create-project.js +783 -0
- package/src/scripts/create/create-project.js.map +1 -0
- package/src/scripts/create/create-project.ts +849 -0
- package/src/scripts/data-flow-graph.d.ts +36 -0
- package/src/scripts/data-flow-graph.js +186 -0
- package/src/scripts/data-flow-graph.js.map +1 -0
- package/src/scripts/data-flow-graph.ts +240 -0
- package/src/scripts/dependency-graph.d.ts +152 -0
- package/src/scripts/dependency-graph.js +4456 -0
- package/src/scripts/dependency-graph.js.map +1 -0
- package/src/scripts/dependency-graph.ts +5908 -0
- package/src/scripts/effect-dependency-graph.d.ts +89 -0
- package/src/scripts/effect-dependency-graph.js +487 -0
- package/src/scripts/effect-dependency-graph.js.map +1 -0
- package/src/scripts/effect-dependency-graph.ts +630 -0
- package/src/scripts/migrate.d.ts +37 -0
- package/src/scripts/migrate.js +98 -0
- package/src/scripts/migrate.js.map +1 -0
- package/src/scripts/migrate.ts +162 -0
- package/src/scripts/migration-workspace.d.ts +2 -0
- package/src/scripts/migration-workspace.js +67 -0
- package/src/scripts/migration-workspace.js.map +1 -0
- package/src/scripts/migration-workspace.ts +93 -0
- package/src/scripts/primitives/migrate-named-primitives.d.ts +50 -0
- package/src/scripts/primitives/migrate-named-primitives.js +487 -0
- package/src/scripts/primitives/migrate-named-primitives.js.map +1 -0
- package/src/scripts/primitives/migrate-named-primitives.ts +627 -0
- package/src/scripts/primitives/migrate-primitive-generators.d.ts +30 -0
- package/src/scripts/primitives/migrate-primitive-generators.js +309 -0
- package/src/scripts/primitives/migrate-primitive-generators.js.map +1 -0
- package/src/scripts/primitives/migrate-primitive-generators.ts +410 -0
- package/src/scripts/primitives/migrate-primitives.d.ts +27 -0
- package/src/scripts/primitives/migrate-primitives.js +427 -0
- package/src/scripts/primitives/migrate-primitives.js.map +1 -0
- package/src/scripts/primitives/migrate-primitives.ts +655 -0
- package/src/scripts/primitives/migrate-yieldable-reactive-reads.d.ts +23 -0
- package/src/scripts/primitives/migrate-yieldable-reactive-reads.js +382 -0
- package/src/scripts/primitives/migrate-yieldable-reactive-reads.js.map +1 -0
- package/src/scripts/primitives/migrate-yieldable-reactive-reads.ts +499 -0
- package/src/scripts/primitives/migration-diagnostic.d.ts +8 -0
- package/src/scripts/primitives/migration-diagnostic.js +2 -0
- package/src/scripts/primitives/migration-diagnostic.js.map +1 -0
- package/src/scripts/primitives/migration-diagnostic.ts +15 -0
- package/src/scripts/routes/migrate-routes.d.ts +34 -0
- package/src/scripts/routes/migrate-routes.js +657 -0
- package/src/scripts/routes/migrate-routes.js.map +1 -0
- package/src/scripts/routes/migrate-routes.ts +895 -0
- package/src/scripts/routes/migration-diagnostic.d.ts +16 -0
- package/src/scripts/routes/migration-diagnostic.js +2 -0
- package/src/scripts/routes/migration-diagnostic.js.map +1 -0
- package/src/scripts/routes/migration-diagnostic.ts +25 -0
- package/src/scripts/routes/route-command.d.ts +75 -0
- package/src/scripts/routes/route-command.js +1169 -0
- package/src/scripts/routes/route-command.js.map +1 -0
- package/src/scripts/routes/route-command.ts +1807 -0
- package/src/scripts/routes/verify-routes.d.ts +60 -0
- package/src/scripts/routes/verify-routes.js +1121 -0
- package/src/scripts/routes/verify-routes.js.map +1 -0
- package/src/scripts/routes/verify-routes.ts +1511 -0
- package/src/scripts/services/config.d.ts +2 -0
- package/src/scripts/services/config.js +38 -0
- package/src/scripts/services/config.js.map +1 -0
- package/src/scripts/services/config.ts +60 -0
- package/src/scripts/services/migrate-services.d.ts +29 -0
- package/src/scripts/services/migrate-services.js +924 -0
- package/src/scripts/services/migrate-services.js.map +1 -0
- package/src/scripts/services/migrate-services.ts +1348 -0
- package/src/scripts/services/migration-diagnostic.d.ts +8 -0
- package/src/scripts/services/migration-diagnostic.js +2 -0
- package/src/scripts/services/migration-diagnostic.js.map +1 -0
- package/src/scripts/services/migration-diagnostic.ts +28 -0
- package/src/scripts/template/migrate-template.d.ts +16 -0
- package/src/scripts/template/migrate-template.js +349 -0
- package/src/scripts/template/migrate-template.js.map +1 -0
- package/src/scripts/template/migrate-template.ts +418 -0
- package/src/template-migration.d.ts +1 -0
- package/src/template-migration.js +2 -0
- package/src/template-migration.js.map +1 -0
- package/src/testing.d.ts +19 -0
- package/src/testing.js +2 -0
- package/src/testing.js.map +1 -0
- package/src/tsconfig.codemod.json +13 -0
|
@@ -0,0 +1,3136 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, relative, resolve } from 'node:path';
|
|
4
|
+
import type {
|
|
5
|
+
DependencyGraph,
|
|
6
|
+
DependencyGraphEdge,
|
|
7
|
+
DependencyGraphEdgeFor,
|
|
8
|
+
DependencyGraphEdgeKind,
|
|
9
|
+
DependencyGraphNode,
|
|
10
|
+
DependencyGraphNodeKind,
|
|
11
|
+
DependencyGraphNodeRegistry,
|
|
12
|
+
DependencyGraphProof,
|
|
13
|
+
} from './dependency-graph.js';
|
|
14
|
+
|
|
15
|
+
export type ArchitectureProvidedOn = {
|
|
16
|
+
kind: DependencyGraphNodeKind;
|
|
17
|
+
name: string;
|
|
18
|
+
file?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type ArchitectureCatalog = {
|
|
22
|
+
version: 1;
|
|
23
|
+
graphHash: string;
|
|
24
|
+
routes: readonly string[];
|
|
25
|
+
services: readonly string[];
|
|
26
|
+
components: readonly string[];
|
|
27
|
+
primitives: readonly string[];
|
|
28
|
+
sources: readonly string[];
|
|
29
|
+
serverFunctionFamilies: readonly string[];
|
|
30
|
+
httpEndpoints: readonly { method: string; url: string }[];
|
|
31
|
+
uniques: readonly string[];
|
|
32
|
+
providers: readonly string[];
|
|
33
|
+
routeProviders: Readonly<Record<string, readonly string[]>>;
|
|
34
|
+
componentProviders: Readonly<Record<string, readonly string[]>>;
|
|
35
|
+
providedOn: Readonly<Record<string, readonly ArchitectureProvidedOn[]>>;
|
|
36
|
+
collisions: {
|
|
37
|
+
services: Readonly<Record<string, readonly string[]>>;
|
|
38
|
+
components: Readonly<Record<string, readonly string[]>>;
|
|
39
|
+
routes: Readonly<Record<string, readonly string[]>>;
|
|
40
|
+
};
|
|
41
|
+
browserBoundaryServices: readonly string[];
|
|
42
|
+
scopes: Readonly<Record<string, string>>;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type ArchitectureNodeView<
|
|
46
|
+
C extends ArchitectureCatalog = ArchitectureCatalog,
|
|
47
|
+
K extends DependencyGraphNodeKind = DependencyGraphNodeKind,
|
|
48
|
+
> = {
|
|
49
|
+
id: string;
|
|
50
|
+
kind: K;
|
|
51
|
+
label: string;
|
|
52
|
+
filePath?: string;
|
|
53
|
+
line?: number;
|
|
54
|
+
details?: DependencyGraphNodeRegistry[K];
|
|
55
|
+
provider(name: CatalogProviders<C>): ArchitectureNodeView<C>;
|
|
56
|
+
providers(): ArchitectureNodeView<C>[];
|
|
57
|
+
outgoing(kind?: DependencyGraphEdgeKind): DependencyGraphEdge[];
|
|
58
|
+
incoming(kind?: DependencyGraphEdgeKind): DependencyGraphEdge[];
|
|
59
|
+
httpEndpoints(): { method: string; url: string }[];
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export type ArchitectureServiceFilter = {
|
|
63
|
+
browserBoundary?: boolean;
|
|
64
|
+
scope?: string;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export type ArchitectureGraphView<
|
|
68
|
+
C extends ArchitectureCatalog = ArchitectureCatalog,
|
|
69
|
+
> = {
|
|
70
|
+
graph: DependencyGraph;
|
|
71
|
+
catalog: C;
|
|
72
|
+
nodes<K extends DependencyGraphNodeKind>(
|
|
73
|
+
kind: K,
|
|
74
|
+
): ArchitectureNodeView<C, K>[];
|
|
75
|
+
edges<K extends DependencyGraphEdgeKind>(
|
|
76
|
+
kind: K,
|
|
77
|
+
): DependencyGraphEdgeFor<K>[];
|
|
78
|
+
pathsBetween(fromId: string, toId: string, maxDepth?: number): ArchitectureGraphPath[];
|
|
79
|
+
proofs(edge: DependencyGraphEdge): DependencyGraphProof[];
|
|
80
|
+
route(path: CatalogRoutes<C>, file?: string): ArchitectureNodeView<C>;
|
|
81
|
+
service(name: CatalogServices<C>, file?: string): ArchitectureNodeView<C>;
|
|
82
|
+
component(name: CatalogComponents<C>, file?: string): ArchitectureNodeView<C>;
|
|
83
|
+
craftMethod(name: string, file?: string): ArchitectureNodeView<C>;
|
|
84
|
+
httpEndpoint(
|
|
85
|
+
method: CatalogHttp<C>['method'],
|
|
86
|
+
url: CatalogHttp<C>['url'],
|
|
87
|
+
): ArchitectureNodeView<C>;
|
|
88
|
+
providedOn(name: CatalogProviders<C>): ArchitectureNodeView<C>[];
|
|
89
|
+
services(filter?: ArchitectureServiceFilter): ArchitectureNodeView<C>[];
|
|
90
|
+
usingHttp(): ArchitectureNodeView<C>[];
|
|
91
|
+
usingTemporal(): ArchitectureNodeView<C>[];
|
|
92
|
+
dependingOnBrowserBoundary(): ArchitectureNodeView<C>[];
|
|
93
|
+
craftMethods(): ArchitectureNodeView<C>[];
|
|
94
|
+
primitives(primitive?: string): ArchitectureNodeView<C>[];
|
|
95
|
+
sources(): ArchitectureNodeView<C>[];
|
|
96
|
+
serverFunctionFamilies(): ArchitectureNodeView<C>[];
|
|
97
|
+
serverFunctionMiddlewares(): ArchitectureNodeView<C>[];
|
|
98
|
+
clientFunctionMiddlewares(): ArchitectureNodeView<C>[];
|
|
99
|
+
handshakes(): ArchitectureNodeView<C>[];
|
|
100
|
+
serverFunctionFamily(id: string): ArchitectureNodeView<C>;
|
|
101
|
+
httpEndpoints(): ArchitectureNodeView<C>[];
|
|
102
|
+
unique(value: CatalogUniques<C>): ArchitectureNodeView<C>;
|
|
103
|
+
uniques(): ArchitectureNodeView<C>[];
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export type ArchitectureGraphPath = {
|
|
107
|
+
readonly nodes: DependencyGraphNode[];
|
|
108
|
+
readonly edges: DependencyGraphEdge[];
|
|
109
|
+
readonly proofs: DependencyGraphProof[];
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export type ExclusiveLinkViolation = {
|
|
113
|
+
from: string;
|
|
114
|
+
to: string;
|
|
115
|
+
kind: DependencyGraphEdgeKind;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export type SensitiveOutputProtectionOptions = {
|
|
119
|
+
readonly categories?: readonly string[];
|
|
120
|
+
readonly allowUnknown?: boolean;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export type SensitiveOutputProtectionViolation = {
|
|
124
|
+
readonly code:
|
|
125
|
+
| 'SENSITIVE_OUTPUT_UNPROTECTED'
|
|
126
|
+
| 'SENSITIVE_OUTPUT_UNKNOWN_PROTECTION';
|
|
127
|
+
readonly classification: string;
|
|
128
|
+
readonly outputId: string;
|
|
129
|
+
readonly message: string;
|
|
130
|
+
readonly path: readonly string[];
|
|
131
|
+
readonly proofs: readonly DependencyGraphProof[];
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
type CatalogRoutes<C extends ArchitectureCatalog> = C['routes'][number];
|
|
135
|
+
type CatalogServices<C extends ArchitectureCatalog> = C['services'][number];
|
|
136
|
+
type CatalogComponents<C extends ArchitectureCatalog> = C['components'][number];
|
|
137
|
+
type CatalogProviders<C extends ArchitectureCatalog> = C['providers'][number];
|
|
138
|
+
type CatalogHttp<C extends ArchitectureCatalog> = C['httpEndpoints'][number];
|
|
139
|
+
type CatalogUniques<C extends ArchitectureCatalog> = C['uniques'][number];
|
|
140
|
+
|
|
141
|
+
const graphByNode = new WeakMap<
|
|
142
|
+
ArchitectureNodeView<ArchitectureCatalog>,
|
|
143
|
+
DependencyGraph
|
|
144
|
+
>();
|
|
145
|
+
|
|
146
|
+
export function graphHash(graph: DependencyGraph): string {
|
|
147
|
+
return createHash('sha256')
|
|
148
|
+
.update(
|
|
149
|
+
JSON.stringify({
|
|
150
|
+
nodes: graph.nodes.map((node) => node.id),
|
|
151
|
+
edges: graph.edges.map((edge) => `${edge.from}:${edge.kind}:${edge.to}`),
|
|
152
|
+
}),
|
|
153
|
+
)
|
|
154
|
+
.digest('hex')
|
|
155
|
+
.slice(0, 16);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function relativeGraphPath(
|
|
159
|
+
graph: DependencyGraph,
|
|
160
|
+
filePath: string | undefined,
|
|
161
|
+
): string | undefined {
|
|
162
|
+
if (!filePath) return undefined;
|
|
163
|
+
return relative(graph.rootDir, filePath).split('\\').join('/');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function buildArchitectureCatalog(
|
|
167
|
+
graph: DependencyGraph,
|
|
168
|
+
): ArchitectureCatalog {
|
|
169
|
+
const nodesById = new Map(graph.nodes.map((node) => [node.id, node]));
|
|
170
|
+
const collisionsFor = (
|
|
171
|
+
kind: DependencyGraphNodeKind,
|
|
172
|
+
names: readonly string[],
|
|
173
|
+
) => {
|
|
174
|
+
const collisions: Record<string, string[]> = {};
|
|
175
|
+
for (const name of names) {
|
|
176
|
+
const matches = graph.nodes.filter((node) =>
|
|
177
|
+
nodeMatches(graph, node, kind, name),
|
|
178
|
+
);
|
|
179
|
+
if (matches.length > 1) {
|
|
180
|
+
collisions[name] = matches
|
|
181
|
+
.map((node) => relativeGraphPath(graph, node.filePath) ?? node.id)
|
|
182
|
+
.sort();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return collisions;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const routes = uniqueSorted(
|
|
189
|
+
graph.nodes.filter((node) => node.kind === 'route').map(routePath),
|
|
190
|
+
);
|
|
191
|
+
const services = uniqueSorted(
|
|
192
|
+
graph.nodes.filter((node) => node.kind === 'service').map((node) => node.label),
|
|
193
|
+
);
|
|
194
|
+
const components = uniqueSorted(
|
|
195
|
+
graph.nodes
|
|
196
|
+
.filter((node) => node.kind === 'component')
|
|
197
|
+
.map((node) => node.label),
|
|
198
|
+
);
|
|
199
|
+
const primitives = uniqueSorted(
|
|
200
|
+
graph.nodes
|
|
201
|
+
.filter((node) => node.kind === 'primitive')
|
|
202
|
+
.map((node) => String(node.details?.['name'] ?? node.label)),
|
|
203
|
+
);
|
|
204
|
+
const sources = uniqueSorted(
|
|
205
|
+
graph.nodes.filter((node) => node.kind === 'source').map((node) => node.label),
|
|
206
|
+
);
|
|
207
|
+
const serverFunctionFamilies = uniqueSorted(
|
|
208
|
+
graph.nodes
|
|
209
|
+
.filter((node) => node.kind === 'server-function-family')
|
|
210
|
+
.map((node) => node.label),
|
|
211
|
+
);
|
|
212
|
+
const httpEndpoints = graph.nodes
|
|
213
|
+
.filter((node) => node.kind === 'http-endpoint')
|
|
214
|
+
.map((node) => ({
|
|
215
|
+
method: String(node.details?.['method'] ?? ''),
|
|
216
|
+
url: String(node.details?.['url'] ?? ''),
|
|
217
|
+
}))
|
|
218
|
+
.sort((left, right) =>
|
|
219
|
+
`${left.method}:${left.url}`.localeCompare(`${right.method}:${right.url}`),
|
|
220
|
+
);
|
|
221
|
+
const uniques = uniqueSorted(
|
|
222
|
+
graph.nodes
|
|
223
|
+
.filter((node) => node.kind === 'unique')
|
|
224
|
+
.map((node) => String(node.details?.['canonical'] ?? node.label)),
|
|
225
|
+
);
|
|
226
|
+
const providedNames = uniqueSorted(
|
|
227
|
+
graph.edges
|
|
228
|
+
.filter((edge) => edge.kind === 'provides')
|
|
229
|
+
.map((edge) => nodesById.get(edge.to)?.label)
|
|
230
|
+
.filter((label): label is string => Boolean(label)),
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
const routeProviders: Record<string, string[]> = {};
|
|
234
|
+
const componentProviders: Record<string, string[]> = {};
|
|
235
|
+
const providedOn: Record<string, ArchitectureProvidedOn[]> = {};
|
|
236
|
+
const scopes: Record<string, string> = {};
|
|
237
|
+
const browserBoundaryServices: string[] = [];
|
|
238
|
+
|
|
239
|
+
for (const node of graph.nodes) {
|
|
240
|
+
if (node.kind === 'service') {
|
|
241
|
+
const scope = node.details?.['scope'];
|
|
242
|
+
if (typeof scope === 'string') scopes[node.label] = scope;
|
|
243
|
+
if (node.details?.['browserBoundary'] === true) {
|
|
244
|
+
browserBoundaryServices.push(node.label);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const provided = providedLabels(graph, nodesById, node.id);
|
|
248
|
+
if (provided.length === 0) continue;
|
|
249
|
+
if (node.kind === 'route') routeProviders[routePath(node)] = uniqueSorted(provided);
|
|
250
|
+
if (node.kind === 'component') {
|
|
251
|
+
componentProviders[node.label] = uniqueSorted(provided);
|
|
252
|
+
}
|
|
253
|
+
for (const name of provided) {
|
|
254
|
+
const entries = providedOn[name] ?? [];
|
|
255
|
+
entries.push({
|
|
256
|
+
kind: node.kind,
|
|
257
|
+
name: node.kind === 'route' ? routePath(node) : node.label,
|
|
258
|
+
file: relativeGraphPath(graph, node.filePath),
|
|
259
|
+
});
|
|
260
|
+
providedOn[name] = entries;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
version: 1,
|
|
266
|
+
graphHash: graphHash(graph),
|
|
267
|
+
routes,
|
|
268
|
+
services,
|
|
269
|
+
components,
|
|
270
|
+
primitives,
|
|
271
|
+
sources,
|
|
272
|
+
serverFunctionFamilies,
|
|
273
|
+
httpEndpoints,
|
|
274
|
+
uniques,
|
|
275
|
+
providers: providedNames,
|
|
276
|
+
routeProviders,
|
|
277
|
+
componentProviders,
|
|
278
|
+
providedOn,
|
|
279
|
+
collisions: {
|
|
280
|
+
services: collisionsFor('service', services),
|
|
281
|
+
components: collisionsFor('component', components),
|
|
282
|
+
routes: collisionsFor('route', routes),
|
|
283
|
+
},
|
|
284
|
+
browserBoundaryServices: uniqueSorted(browserBoundaryServices),
|
|
285
|
+
scopes,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function architectureCatalogToTypeScript(
|
|
290
|
+
catalog: ArchitectureCatalog,
|
|
291
|
+
): string {
|
|
292
|
+
return `export const architectureCatalog = ${JSON.stringify(catalog, null, 2)} as const;\nexport type ArchitectureCatalog = typeof architectureCatalog;\n`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function createArchitectureGraph<
|
|
296
|
+
C extends ArchitectureCatalog = ArchitectureCatalog,
|
|
297
|
+
>(graph: DependencyGraph, catalog?: C): ArchitectureGraphView<C> {
|
|
298
|
+
const nodesById = new Map(graph.nodes.map((node) => [node.id, node]));
|
|
299
|
+
|
|
300
|
+
const wrap = (node: DependencyGraphNode): ArchitectureNodeView<C> => {
|
|
301
|
+
const view: ArchitectureNodeView<C> = {
|
|
302
|
+
id: node.id,
|
|
303
|
+
kind: node.kind,
|
|
304
|
+
label: node.label,
|
|
305
|
+
filePath: node.filePath,
|
|
306
|
+
line: node.line,
|
|
307
|
+
details: node.details,
|
|
308
|
+
provider(name: string) {
|
|
309
|
+
return uniqueNode(
|
|
310
|
+
view.providers().filter((provider) => provider.label === name),
|
|
311
|
+
'provider',
|
|
312
|
+
name,
|
|
313
|
+
);
|
|
314
|
+
},
|
|
315
|
+
providers() {
|
|
316
|
+
return outgoingOf(graph, node.id, 'provides')
|
|
317
|
+
.map((edge) => nodesById.get(edge.to))
|
|
318
|
+
.filter((target): target is DependencyGraphNode => Boolean(target))
|
|
319
|
+
.map(wrap);
|
|
320
|
+
},
|
|
321
|
+
outgoing(kind?: DependencyGraphEdgeKind) {
|
|
322
|
+
return outgoingOf(graph, node.id, kind);
|
|
323
|
+
},
|
|
324
|
+
incoming(kind?: DependencyGraphEdgeKind) {
|
|
325
|
+
return graph.edges.filter(
|
|
326
|
+
(edge) =>
|
|
327
|
+
edge.to === node.id && (kind === undefined || edge.kind === kind),
|
|
328
|
+
);
|
|
329
|
+
},
|
|
330
|
+
httpEndpoints() {
|
|
331
|
+
if (node.kind === 'http-endpoint') {
|
|
332
|
+
return [
|
|
333
|
+
{
|
|
334
|
+
method: String(node.details?.['method'] ?? ''),
|
|
335
|
+
url: String(node.details?.['url'] ?? ''),
|
|
336
|
+
},
|
|
337
|
+
];
|
|
338
|
+
}
|
|
339
|
+
return outgoingOf(graph, node.id, 'calls')
|
|
340
|
+
.map((edge) => nodesById.get(edge.to))
|
|
341
|
+
.filter(
|
|
342
|
+
(target): target is DependencyGraphNode =>
|
|
343
|
+
target?.kind === 'http-endpoint',
|
|
344
|
+
)
|
|
345
|
+
.map((target) => ({
|
|
346
|
+
method: String(target.details?.['method'] ?? ''),
|
|
347
|
+
url: String(target.details?.['url'] ?? ''),
|
|
348
|
+
}));
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
graphByNode.set(view, graph);
|
|
352
|
+
return view;
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
function lookup(
|
|
356
|
+
kind: DependencyGraphNodeKind,
|
|
357
|
+
name: string,
|
|
358
|
+
file?: string,
|
|
359
|
+
predicate?: (node: DependencyGraphNode) => boolean,
|
|
360
|
+
): ArchitectureNodeView {
|
|
361
|
+
const matches = graph.nodes.filter((node) => {
|
|
362
|
+
if (node.kind !== kind) return false;
|
|
363
|
+
if (predicate && !predicate(node)) return false;
|
|
364
|
+
if (!nodeMatches(graph, node, kind, name)) return false;
|
|
365
|
+
return fileMatches(graph, node, file);
|
|
366
|
+
});
|
|
367
|
+
return uniqueNode(matches.map(wrap), kind, name, file);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return {
|
|
371
|
+
graph,
|
|
372
|
+
catalog: (catalog ?? buildArchitectureCatalog(graph)) as C,
|
|
373
|
+
nodes(kind) {
|
|
374
|
+
return graph.nodes
|
|
375
|
+
.filter((node) => node.kind === kind)
|
|
376
|
+
.map(wrap) as ArchitectureNodeView<C, typeof kind>[];
|
|
377
|
+
},
|
|
378
|
+
edges(kind) {
|
|
379
|
+
return graph.edges.filter((edge) => edge.kind === kind) as DependencyGraphEdgeFor<
|
|
380
|
+
typeof kind
|
|
381
|
+
>[];
|
|
382
|
+
},
|
|
383
|
+
pathsBetween(fromId, toId, maxDepth) {
|
|
384
|
+
return dependencyGraphPathsBetween(graph, fromId, toId, maxDepth);
|
|
385
|
+
},
|
|
386
|
+
proofs(edge) {
|
|
387
|
+
return edge.proof ? [edge.proof] : [];
|
|
388
|
+
},
|
|
389
|
+
route(path, file) {
|
|
390
|
+
return lookup('route', path, file);
|
|
391
|
+
},
|
|
392
|
+
service(name, file) {
|
|
393
|
+
return lookup('service', name, file);
|
|
394
|
+
},
|
|
395
|
+
component(name, file) {
|
|
396
|
+
return lookup('component', name, file);
|
|
397
|
+
},
|
|
398
|
+
craftMethod(name, file) {
|
|
399
|
+
return lookup(
|
|
400
|
+
'primitive',
|
|
401
|
+
name,
|
|
402
|
+
file,
|
|
403
|
+
(node) => node.details?.['primitive'] === 'craftMethod',
|
|
404
|
+
);
|
|
405
|
+
},
|
|
406
|
+
httpEndpoint(method, url) {
|
|
407
|
+
const matches = graph.nodes.filter(
|
|
408
|
+
(node) =>
|
|
409
|
+
node.kind === 'http-endpoint' &&
|
|
410
|
+
node.details?.['method'] === method &&
|
|
411
|
+
node.details?.['url'] === url,
|
|
412
|
+
);
|
|
413
|
+
return uniqueNode(matches.map(wrap), 'http-endpoint', `${method} ${url}`);
|
|
414
|
+
},
|
|
415
|
+
providedOn(name) {
|
|
416
|
+
return graph.edges
|
|
417
|
+
.filter((edge) => {
|
|
418
|
+
if (edge.kind !== 'provides') return false;
|
|
419
|
+
return nodesById.get(edge.to)?.label === name;
|
|
420
|
+
})
|
|
421
|
+
.map((edge) => nodesById.get(edge.from))
|
|
422
|
+
.filter((node): node is DependencyGraphNode => Boolean(node))
|
|
423
|
+
.map(wrap);
|
|
424
|
+
},
|
|
425
|
+
services(filter) {
|
|
426
|
+
return graph.nodes
|
|
427
|
+
.filter((node) => node.kind === 'service')
|
|
428
|
+
.filter((node) => {
|
|
429
|
+
if (
|
|
430
|
+
filter?.browserBoundary !== undefined &&
|
|
431
|
+
node.details?.['browserBoundary'] !== filter.browserBoundary
|
|
432
|
+
) {
|
|
433
|
+
return false;
|
|
434
|
+
}
|
|
435
|
+
if (
|
|
436
|
+
filter?.scope !== undefined &&
|
|
437
|
+
node.details?.['scope'] !== filter.scope
|
|
438
|
+
) {
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
return true;
|
|
442
|
+
})
|
|
443
|
+
.map(wrap);
|
|
444
|
+
},
|
|
445
|
+
usingHttp() {
|
|
446
|
+
return graph.nodes
|
|
447
|
+
.filter(
|
|
448
|
+
(node) =>
|
|
449
|
+
node.details?.['craftHttpClient'] === true ||
|
|
450
|
+
outgoingOf(graph, node.id, 'calls').some(
|
|
451
|
+
(edge) => nodesById.get(edge.to)?.kind === 'http-endpoint',
|
|
452
|
+
),
|
|
453
|
+
)
|
|
454
|
+
.map(wrap);
|
|
455
|
+
},
|
|
456
|
+
usingTemporal() {
|
|
457
|
+
return graph.nodes
|
|
458
|
+
.filter((node) => node.details?.['temporal'] === true)
|
|
459
|
+
.map(wrap);
|
|
460
|
+
},
|
|
461
|
+
dependingOnBrowserBoundary() {
|
|
462
|
+
const boundaryIds = new Set(
|
|
463
|
+
graph.nodes
|
|
464
|
+
.filter(
|
|
465
|
+
(node) =>
|
|
466
|
+
node.kind === 'service' &&
|
|
467
|
+
node.details?.['browserBoundary'] === true,
|
|
468
|
+
)
|
|
469
|
+
.map((node) => node.id),
|
|
470
|
+
);
|
|
471
|
+
return graph.nodes
|
|
472
|
+
.filter((node) =>
|
|
473
|
+
outgoingOf(graph, node.id, 'depends-on').some((edge) =>
|
|
474
|
+
boundaryIds.has(edge.to),
|
|
475
|
+
),
|
|
476
|
+
)
|
|
477
|
+
.map(wrap);
|
|
478
|
+
},
|
|
479
|
+
craftMethods() {
|
|
480
|
+
return graph.nodes
|
|
481
|
+
.filter(
|
|
482
|
+
(node) =>
|
|
483
|
+
node.kind === 'primitive' &&
|
|
484
|
+
node.details?.['primitive'] === 'craftMethod',
|
|
485
|
+
)
|
|
486
|
+
.map(wrap);
|
|
487
|
+
},
|
|
488
|
+
primitives(primitive) {
|
|
489
|
+
return graph.nodes
|
|
490
|
+
.filter((node) => {
|
|
491
|
+
if (node.kind !== 'primitive') return false;
|
|
492
|
+
if (primitive === undefined) return true;
|
|
493
|
+
return node.details?.['primitive'] === primitive;
|
|
494
|
+
})
|
|
495
|
+
.map(wrap);
|
|
496
|
+
},
|
|
497
|
+
sources() {
|
|
498
|
+
return graph.nodes.filter((node) => node.kind === 'source').map(wrap);
|
|
499
|
+
},
|
|
500
|
+
serverFunctionFamilies() {
|
|
501
|
+
return graph.nodes
|
|
502
|
+
.filter((node) => node.kind === 'server-function-family')
|
|
503
|
+
.map(wrap);
|
|
504
|
+
},
|
|
505
|
+
serverFunctionMiddlewares() {
|
|
506
|
+
return graph.nodes
|
|
507
|
+
.filter((node) => node.kind === 'server-function-middleware')
|
|
508
|
+
.map(wrap);
|
|
509
|
+
},
|
|
510
|
+
clientFunctionMiddlewares() {
|
|
511
|
+
return graph.nodes
|
|
512
|
+
.filter((node) => node.kind === 'client-function-middleware')
|
|
513
|
+
.map(wrap);
|
|
514
|
+
},
|
|
515
|
+
handshakes() {
|
|
516
|
+
return graph.nodes.filter((node) => node.kind === 'handshake').map(wrap);
|
|
517
|
+
},
|
|
518
|
+
serverFunctionFamily(id) {
|
|
519
|
+
return lookup(
|
|
520
|
+
'server-function-family',
|
|
521
|
+
id,
|
|
522
|
+
undefined,
|
|
523
|
+
(node) =>
|
|
524
|
+
node.label === id || node.details?.['serverFunctionId'] === id,
|
|
525
|
+
);
|
|
526
|
+
},
|
|
527
|
+
httpEndpoints() {
|
|
528
|
+
return graph.nodes
|
|
529
|
+
.filter((node) => node.kind === 'http-endpoint')
|
|
530
|
+
.map(wrap);
|
|
531
|
+
},
|
|
532
|
+
unique(value) {
|
|
533
|
+
return lookup(
|
|
534
|
+
'unique',
|
|
535
|
+
value,
|
|
536
|
+
undefined,
|
|
537
|
+
(node) =>
|
|
538
|
+
node.label === value || node.details?.['canonical'] === value,
|
|
539
|
+
);
|
|
540
|
+
},
|
|
541
|
+
uniques() {
|
|
542
|
+
return graph.nodes.filter((node) => node.kind === 'unique').map(wrap);
|
|
543
|
+
},
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Enumerates short, explainable paths without imposing a domain-specific
|
|
549
|
+
* notion of what a backend node means. Rules can first select typed nodes and
|
|
550
|
+
* then ask this generic primitive for paths between their ids.
|
|
551
|
+
*/
|
|
552
|
+
export function dependencyGraphPathsBetween(
|
|
553
|
+
graph: DependencyGraph,
|
|
554
|
+
fromId: string,
|
|
555
|
+
toId: string,
|
|
556
|
+
maxDepth = 32,
|
|
557
|
+
): ArchitectureGraphPath[] {
|
|
558
|
+
const nodesById = new Map(graph.nodes.map((node) => [node.id, node]));
|
|
559
|
+
if (!nodesById.has(fromId) || !nodesById.has(toId)) return [];
|
|
560
|
+
if (fromId === toId) {
|
|
561
|
+
const node = nodesById.get(fromId);
|
|
562
|
+
return node ? [{ nodes: [node], edges: [], proofs: [] }] : [];
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const outgoing = new Map<string, DependencyGraphEdge[]>();
|
|
566
|
+
for (const edge of graph.edges) {
|
|
567
|
+
const edges = outgoing.get(edge.from) ?? [];
|
|
568
|
+
edges.push(edge);
|
|
569
|
+
outgoing.set(edge.from, edges);
|
|
570
|
+
}
|
|
571
|
+
const paths: ArchitectureGraphPath[] = [];
|
|
572
|
+
const visit = (
|
|
573
|
+
nodeId: string,
|
|
574
|
+
nodePath: DependencyGraphNode[],
|
|
575
|
+
edgePath: DependencyGraphEdge[],
|
|
576
|
+
visited: Set<string>,
|
|
577
|
+
): void => {
|
|
578
|
+
if (edgePath.length >= maxDepth) return;
|
|
579
|
+
for (const edge of outgoing.get(nodeId) ?? []) {
|
|
580
|
+
const target = nodesById.get(edge.to);
|
|
581
|
+
if (!target || visited.has(target.id)) continue;
|
|
582
|
+
const nextNodes = [...nodePath, target];
|
|
583
|
+
const nextEdges = [...edgePath, edge];
|
|
584
|
+
if (target.id === toId) {
|
|
585
|
+
paths.push({
|
|
586
|
+
nodes: nextNodes,
|
|
587
|
+
edges: nextEdges,
|
|
588
|
+
proofs: nextEdges.flatMap((candidate) =>
|
|
589
|
+
candidate.proof ? [candidate.proof] : [],
|
|
590
|
+
),
|
|
591
|
+
});
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
visit(target.id, nextNodes, nextEdges, new Set([...visited, target.id]));
|
|
595
|
+
}
|
|
596
|
+
};
|
|
597
|
+
const start = nodesById.get(fromId);
|
|
598
|
+
if (start) visit(fromId, [start], [], new Set([fromId]));
|
|
599
|
+
return paths;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Checks the first conservative security policy for classified server
|
|
604
|
+
* responses. Capabilities are repository-declared on middleware nodes through
|
|
605
|
+
* `analyzeDependencyGraph({ middlewareCapabilities })`.
|
|
606
|
+
*/
|
|
607
|
+
export function sensitiveOutputProtectionViolations(
|
|
608
|
+
graph: DependencyGraph,
|
|
609
|
+
options: SensitiveOutputProtectionOptions = {},
|
|
610
|
+
): SensitiveOutputProtectionViolation[] {
|
|
611
|
+
const allowed = options.categories ? new Set(options.categories) : undefined;
|
|
612
|
+
const nodesById = new Map(graph.nodes.map((node) => [node.id, node]));
|
|
613
|
+
const violations: SensitiveOutputProtectionViolation[] = [];
|
|
614
|
+
|
|
615
|
+
for (const exposure of graph.edges.filter((edge) => edge.kind === 'exposes-data')) {
|
|
616
|
+
const classification = String(exposure.details?.['classification'] ?? 'unknown');
|
|
617
|
+
if (allowed && !allowed.has(classification)) continue;
|
|
618
|
+
const output = nodesById.get(exposure.to);
|
|
619
|
+
if (!output || output.kind !== 'external-output') continue;
|
|
620
|
+
const serverFunctionId = output.details?.['serverFunctionId'];
|
|
621
|
+
const serverParts = graph.nodes.filter(
|
|
622
|
+
(node) =>
|
|
623
|
+
node.kind === 'server-function-server' &&
|
|
624
|
+
(node.details?.['serverFunctionId'] === serverFunctionId ||
|
|
625
|
+
node.filePath === output.filePath),
|
|
626
|
+
);
|
|
627
|
+
const middleware = serverParts.flatMap((part) =>
|
|
628
|
+
graph.edges
|
|
629
|
+
.filter((edge) => edge.from === part.id && edge.kind === 'depends-on')
|
|
630
|
+
.map((edge) => nodesById.get(edge.to))
|
|
631
|
+
.filter(
|
|
632
|
+
(node): node is DependencyGraphNode =>
|
|
633
|
+
node?.kind === 'server-function-middleware',
|
|
634
|
+
),
|
|
635
|
+
);
|
|
636
|
+
const capable = middleware.some((node) => {
|
|
637
|
+
const protects = node.details?.['protects'];
|
|
638
|
+
return Array.isArray(protects) && protects.includes(classification);
|
|
639
|
+
});
|
|
640
|
+
const unknown = middleware.some(
|
|
641
|
+
(node) => node.details?.['protectionUnknown'] === true,
|
|
642
|
+
);
|
|
643
|
+
if (capable || (unknown && options.allowUnknown)) continue;
|
|
644
|
+
const proofs = exposure.proof ? [exposure.proof] : [];
|
|
645
|
+
const code = unknown
|
|
646
|
+
? 'SENSITIVE_OUTPUT_UNKNOWN_PROTECTION'
|
|
647
|
+
: 'SENSITIVE_OUTPUT_UNPROTECTED';
|
|
648
|
+
violations.push({
|
|
649
|
+
code,
|
|
650
|
+
classification,
|
|
651
|
+
outputId: output.id,
|
|
652
|
+
path: [exposure.from, output.id],
|
|
653
|
+
proofs,
|
|
654
|
+
message: `${code}: ${output.label} exposes ${classification} without a middleware capability protecting ${classification}.`,
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
return violations;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
export function assertSensitiveOutputsProtected(
|
|
661
|
+
graph: DependencyGraph,
|
|
662
|
+
options: SensitiveOutputProtectionOptions = {},
|
|
663
|
+
): void {
|
|
664
|
+
const violations = sensitiveOutputProtectionViolations(graph, options);
|
|
665
|
+
if (violations.length === 0) return;
|
|
666
|
+
throw new Error(violations.map((violation) => violation.message).join('\n'));
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
export function exclusiveLinkViolations(
|
|
670
|
+
left: ArchitectureNodeView<ArchitectureCatalog>,
|
|
671
|
+
right: ArchitectureNodeView<ArchitectureCatalog>,
|
|
672
|
+
): ExclusiveLinkViolation[] {
|
|
673
|
+
const graph = graphOf(left);
|
|
674
|
+
const ownership = assignBranchOwnership(graph, [left.id, right.id]);
|
|
675
|
+
const exclusiveLeft = exclusiveIds(ownership, left.id);
|
|
676
|
+
const exclusiveRight = exclusiveIds(ownership, right.id);
|
|
677
|
+
const violations: ExclusiveLinkViolation[] = [];
|
|
678
|
+
for (const edge of graph.edges) {
|
|
679
|
+
const fromLeft = exclusiveLeft.has(edge.from) && exclusiveRight.has(edge.to);
|
|
680
|
+
const fromRight =
|
|
681
|
+
exclusiveRight.has(edge.from) && exclusiveLeft.has(edge.to);
|
|
682
|
+
if (fromLeft || fromRight) {
|
|
683
|
+
violations.push({ from: edge.from, to: edge.to, kind: edge.kind });
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
return violations;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
export function noExclusiveLink(
|
|
690
|
+
left: ArchitectureNodeView<ArchitectureCatalog>,
|
|
691
|
+
right: ArchitectureNodeView<ArchitectureCatalog>,
|
|
692
|
+
): void {
|
|
693
|
+
const violations = exclusiveLinkViolations(left, right);
|
|
694
|
+
if (violations.length === 0) return;
|
|
695
|
+
const graph = graphOf(left);
|
|
696
|
+
const labels = (id: string) =>
|
|
697
|
+
graph.nodes.find((node) => node.id === id)?.label ?? id;
|
|
698
|
+
throw new Error(
|
|
699
|
+
`Exclusive architecture link between ${left.label} and ${right.label}: ${violations
|
|
700
|
+
.map(
|
|
701
|
+
(violation) =>
|
|
702
|
+
`${labels(violation.from)} -[${violation.kind}]-> ${labels(violation.to)}`,
|
|
703
|
+
)
|
|
704
|
+
.join(', ')}`,
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
export type CraftUniqueViolation = {
|
|
709
|
+
kind: 'duplicate' | 'non-static';
|
|
710
|
+
id: string;
|
|
711
|
+
label: string;
|
|
712
|
+
callSites: readonly { filePath?: string; line?: number; ownerId?: string }[];
|
|
713
|
+
};
|
|
714
|
+
|
|
715
|
+
export function craftUniqueViolations(
|
|
716
|
+
graph: DependencyGraph,
|
|
717
|
+
): CraftUniqueViolation[] {
|
|
718
|
+
return graph.nodes
|
|
719
|
+
.filter((node) => node.kind === 'unique')
|
|
720
|
+
.flatMap((node): CraftUniqueViolation[] => {
|
|
721
|
+
const callSites = Array.isArray(node.details?.['callSites'])
|
|
722
|
+
? (node.details['callSites'] as CraftUniqueViolation['callSites'])
|
|
723
|
+
: [];
|
|
724
|
+
if (node.details?.['static'] === false) {
|
|
725
|
+
return [
|
|
726
|
+
{
|
|
727
|
+
kind: 'non-static' as const,
|
|
728
|
+
id: node.id,
|
|
729
|
+
label: node.label,
|
|
730
|
+
callSites,
|
|
731
|
+
},
|
|
732
|
+
];
|
|
733
|
+
}
|
|
734
|
+
if (callSites.length > 1) {
|
|
735
|
+
return [
|
|
736
|
+
{
|
|
737
|
+
kind: 'duplicate' as const,
|
|
738
|
+
id: node.id,
|
|
739
|
+
label: node.label,
|
|
740
|
+
callSites,
|
|
741
|
+
},
|
|
742
|
+
];
|
|
743
|
+
}
|
|
744
|
+
return [];
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
export function assertCraftUnique(graph: DependencyGraph): void {
|
|
749
|
+
const violations = craftUniqueViolations(graph);
|
|
750
|
+
if (violations.length === 0) return;
|
|
751
|
+
throw new Error(
|
|
752
|
+
violations
|
|
753
|
+
.map((violation) => {
|
|
754
|
+
const sites = violation.callSites
|
|
755
|
+
.map((site) =>
|
|
756
|
+
[site.filePath, site.line].filter(Boolean).join(':'),
|
|
757
|
+
)
|
|
758
|
+
.filter(Boolean)
|
|
759
|
+
.join(', ');
|
|
760
|
+
if (violation.kind === 'non-static') {
|
|
761
|
+
return `Non-static craftUnique argument cannot be verified${sites ? ` (${sites})` : ''}.`;
|
|
762
|
+
}
|
|
763
|
+
return `Duplicate craftUnique ${violation.label} used twice${sites ? ` (${sites})` : ''}.`;
|
|
764
|
+
})
|
|
765
|
+
.join('\n'),
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
export type CraftHandshakeDiagnosticCode =
|
|
770
|
+
| 'CRAFT_HANDSHAKE_MISSING_COUNTERPART'
|
|
771
|
+
| 'CRAFT_HANDSHAKE_NOT_STATIC'
|
|
772
|
+
| 'CRAFT_HANDSHAKE_DUPLICATE_NAME';
|
|
773
|
+
|
|
774
|
+
export type CraftHandshakeViolation = {
|
|
775
|
+
readonly code: CraftHandshakeDiagnosticCode;
|
|
776
|
+
readonly message: string;
|
|
777
|
+
readonly name: string;
|
|
778
|
+
readonly filePath?: string;
|
|
779
|
+
readonly line?: number;
|
|
780
|
+
};
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Vérifie qu'un `craftHandshake(...)` est bien tenu des deux côtés.
|
|
784
|
+
*
|
|
785
|
+
* C'est le pendant exact de `assertCraftUnique`, avec le prédicat inverse :
|
|
786
|
+
* `craftUnique` exige un seul site, un handshake en exige **un de chaque
|
|
787
|
+
* côté**. Le contrôle compare des identifiants, pas des noms de clés devinés
|
|
788
|
+
* dans l'AST : il n'a rien d'heuristique.
|
|
789
|
+
*/
|
|
790
|
+
export function craftHandshakeViolations(
|
|
791
|
+
graph: DependencyGraph,
|
|
792
|
+
): CraftHandshakeViolation[] {
|
|
793
|
+
const handshakes = graph.nodes.filter((node) => node.kind === 'handshake');
|
|
794
|
+
const violations: CraftHandshakeViolation[] = [];
|
|
795
|
+
|
|
796
|
+
for (const node of handshakes) {
|
|
797
|
+
if (node.details?.['static'] === false) {
|
|
798
|
+
violations.push({
|
|
799
|
+
code: 'CRAFT_HANDSHAKE_NOT_STATIC',
|
|
800
|
+
message: `CRAFT_HANDSHAKE_NOT_STATIC: craftHandshake(...) must name a static string literal; "${node.label}" cannot be matched across the boundary otherwise.`,
|
|
801
|
+
name: String(node.label),
|
|
802
|
+
filePath: relativeGraphPath(graph, node.filePath),
|
|
803
|
+
line: node.line,
|
|
804
|
+
});
|
|
805
|
+
continue;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
const server = node.details?.['serverSites'];
|
|
809
|
+
const client = node.details?.['clientSites'];
|
|
810
|
+
const hasServer = Array.isArray(server) && server.length > 0;
|
|
811
|
+
const hasClient = Array.isArray(client) && client.length > 0;
|
|
812
|
+
if (hasServer && hasClient) continue;
|
|
813
|
+
|
|
814
|
+
const missing = hasServer ? 'client' : 'server';
|
|
815
|
+
const reached = hasServer ? server : hasClient ? client : [];
|
|
816
|
+
violations.push({
|
|
817
|
+
code: 'CRAFT_HANDSHAKE_MISSING_COUNTERPART',
|
|
818
|
+
message: `CRAFT_HANDSHAKE_MISSING_COUNTERPART: handshake "${node.label}" has no ${missing} counterpart${
|
|
819
|
+
reached.length ? ` (only ${reached.join(', ')})` : ' (nothing references it)'
|
|
820
|
+
}. A handshake exists to be honoured on both sides; one side alone means the other silently sends, or expects, nothing.`,
|
|
821
|
+
name: String(node.label),
|
|
822
|
+
filePath: relativeGraphPath(graph, node.filePath),
|
|
823
|
+
line: node.line,
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const byName = new Map<string, DependencyGraphNode[]>();
|
|
828
|
+
for (const node of handshakes) {
|
|
829
|
+
const name = node.details?.['handshakeName'];
|
|
830
|
+
if (typeof name !== 'string') continue;
|
|
831
|
+
byName.set(name, [...(byName.get(name) ?? []), node]);
|
|
832
|
+
}
|
|
833
|
+
for (const [name, nodes] of byName) {
|
|
834
|
+
if (nodes.length < 2) continue;
|
|
835
|
+
const node = nodes[0];
|
|
836
|
+
if (!node) continue;
|
|
837
|
+
violations.push({
|
|
838
|
+
code: 'CRAFT_HANDSHAKE_DUPLICATE_NAME',
|
|
839
|
+
message: `CRAFT_HANDSHAKE_DUPLICATE_NAME: handshake "${name}" is declared ${nodes.length} times (${nodes
|
|
840
|
+
.map((candidate) => relativeGraphPath(graph, candidate.filePath) ?? '?')
|
|
841
|
+
.sort()
|
|
842
|
+
.join(', ')}). A handshake is declared once and referenced from both sides; two declarations mean the two sides can agree on a name while sharing nothing.`,
|
|
843
|
+
name,
|
|
844
|
+
filePath: relativeGraphPath(graph, node.filePath),
|
|
845
|
+
line: node.line,
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
return violations;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
export function assertCraftHandshake(graph: DependencyGraph): void {
|
|
853
|
+
const violations = craftHandshakeViolations(graph);
|
|
854
|
+
if (violations.length === 0) return;
|
|
855
|
+
throw new Error(violations.map((violation) => violation.message).join('\n'));
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
export type RouteDiProofViolationKind =
|
|
859
|
+
| 'missing-di-proof'
|
|
860
|
+
| 'unarmed-mapper'
|
|
861
|
+
| 'missing-pending-proof'
|
|
862
|
+
| 'missing-error-proof'
|
|
863
|
+
| 'missing-exception-assert'
|
|
864
|
+
| 'missing-global-error-proof'
|
|
865
|
+
| 'missing-route-load-error-proof';
|
|
866
|
+
|
|
867
|
+
export type RouteDiProofViolation = {
|
|
868
|
+
kind: RouteDiProofViolationKind;
|
|
869
|
+
label: string;
|
|
870
|
+
filePath?: string;
|
|
871
|
+
line?: number;
|
|
872
|
+
};
|
|
873
|
+
|
|
874
|
+
export function routeDiProofViolations(
|
|
875
|
+
graph: DependencyGraph,
|
|
876
|
+
): RouteDiProofViolation[] {
|
|
877
|
+
const nodesById = new Map(graph.nodes.map((node) => [node.id, node]));
|
|
878
|
+
const incomingChecks = new Map<string, DependencyGraphEdge[]>();
|
|
879
|
+
const incomingContains = new Map<string, DependencyGraphEdge[]>();
|
|
880
|
+
for (const edge of graph.edges) {
|
|
881
|
+
if (edge.kind === 'checks') {
|
|
882
|
+
const list = incomingChecks.get(edge.to) ?? [];
|
|
883
|
+
list.push(edge);
|
|
884
|
+
incomingChecks.set(edge.to, list);
|
|
885
|
+
}
|
|
886
|
+
if (edge.kind === 'contains') {
|
|
887
|
+
const list = incomingContains.get(edge.to) ?? [];
|
|
888
|
+
list.push(edge);
|
|
889
|
+
incomingContains.set(edge.to, list);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
const isArmedMapper = (mapperId: string): boolean => {
|
|
894
|
+
return (incomingContains.get(mapperId) ?? []).some(
|
|
895
|
+
(edge) => nodesById.get(edge.from)?.details?.['mechanism'] === 'CanRun',
|
|
896
|
+
);
|
|
897
|
+
};
|
|
898
|
+
|
|
899
|
+
const location = (node: DependencyGraphNode) => ({
|
|
900
|
+
filePath: relativeGraphPath(graph, node.filePath),
|
|
901
|
+
line: node.line,
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
const violations: RouteDiProofViolation[] = [];
|
|
905
|
+
for (const node of graph.nodes) {
|
|
906
|
+
if (node.kind !== 'route') continue;
|
|
907
|
+
const checks = incomingChecks.get(node.id) ?? [];
|
|
908
|
+
const diChecks = checks.filter((edge) => {
|
|
909
|
+
const mechanism = nodesById.get(edge.from)?.details?.['mechanism'];
|
|
910
|
+
return (
|
|
911
|
+
mechanism === 'ValidateCascadeRoutesFile' ||
|
|
912
|
+
mechanism === 'RouteCheckedDI' ||
|
|
913
|
+
mechanism === 'RouteExceptionComponentCheckedDI'
|
|
914
|
+
);
|
|
915
|
+
});
|
|
916
|
+
const componentChecks = diChecks.filter(
|
|
917
|
+
(edge) => edge.details?.['target'] !== 'pending' && edge.details?.['target'] !== 'error',
|
|
918
|
+
);
|
|
919
|
+
if (node.details?.['hasComponent']) {
|
|
920
|
+
if (componentChecks.length === 0) {
|
|
921
|
+
violations.push({
|
|
922
|
+
kind: 'missing-di-proof',
|
|
923
|
+
label: String(node.details['path'] ?? node.label),
|
|
924
|
+
...location(node),
|
|
925
|
+
});
|
|
926
|
+
} else if (!componentChecks.some((edge) => isArmedMapper(edge.from))) {
|
|
927
|
+
violations.push({
|
|
928
|
+
kind: 'unarmed-mapper',
|
|
929
|
+
label: String(node.details['path'] ?? node.label),
|
|
930
|
+
...location(node),
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
if (node.details?.['hasPendingComponent']) {
|
|
935
|
+
const pendingChecks = diChecks.filter(
|
|
936
|
+
(edge) => edge.details?.['target'] === 'pending',
|
|
937
|
+
);
|
|
938
|
+
if (
|
|
939
|
+
pendingChecks.length === 0 ||
|
|
940
|
+
!pendingChecks.some((edge) => isArmedMapper(edge.from))
|
|
941
|
+
) {
|
|
942
|
+
violations.push({
|
|
943
|
+
kind: 'missing-pending-proof',
|
|
944
|
+
label: String(node.details['path'] ?? node.label),
|
|
945
|
+
...location(node),
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
if (node.details?.['hasErrorComponent']) {
|
|
950
|
+
const errorChecks = diChecks.filter(
|
|
951
|
+
(edge) => edge.details?.['target'] === 'error',
|
|
952
|
+
);
|
|
953
|
+
if (
|
|
954
|
+
errorChecks.length === 0 ||
|
|
955
|
+
!errorChecks.some((edge) => isArmedMapper(edge.from))
|
|
956
|
+
) {
|
|
957
|
+
violations.push({
|
|
958
|
+
kind: 'missing-error-proof',
|
|
959
|
+
label: String(node.details['path'] ?? node.label),
|
|
960
|
+
...location(node),
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
const collections = new Map<string, DependencyGraphNode[]>();
|
|
967
|
+
for (const node of graph.nodes) {
|
|
968
|
+
if (node.kind !== 'route') continue;
|
|
969
|
+
const key = `${node.filePath ?? ''}::${String(node.details?.['routesName'] ?? '')}`;
|
|
970
|
+
const list = collections.get(key) ?? [];
|
|
971
|
+
list.push(node);
|
|
972
|
+
collections.set(key, list);
|
|
973
|
+
}
|
|
974
|
+
for (const [, routes] of collections) {
|
|
975
|
+
const representative = routes[0];
|
|
976
|
+
if (!representative) continue;
|
|
977
|
+
const hasAssert = routes.some((route) =>
|
|
978
|
+
(incomingChecks.get(route.id) ?? []).some(
|
|
979
|
+
(edge) =>
|
|
980
|
+
nodesById.get(edge.from)?.details?.['mechanism'] ===
|
|
981
|
+
'assertExhaustiveRouteExceptions',
|
|
982
|
+
),
|
|
983
|
+
);
|
|
984
|
+
if (!hasAssert) {
|
|
985
|
+
violations.push({
|
|
986
|
+
kind: 'missing-exception-assert',
|
|
987
|
+
label: String(representative.details?.['routesName'] ?? representative.label),
|
|
988
|
+
...location(representative),
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
for (const node of graph.nodes) {
|
|
994
|
+
if (node.kind !== 'app-config') continue;
|
|
995
|
+
const checks = incomingChecks.get(node.id) ?? [];
|
|
996
|
+
const armedTarget = (target: string) =>
|
|
997
|
+
checks.some(
|
|
998
|
+
(edge) =>
|
|
999
|
+
edge.details?.['target'] === target &&
|
|
1000
|
+
nodesById.get(edge.from)?.details?.['mechanism'] ===
|
|
1001
|
+
'RouteExceptionComponentCheckedDI' &&
|
|
1002
|
+
isArmedMapper(edge.from),
|
|
1003
|
+
);
|
|
1004
|
+
if (node.details?.['hasGlobalError'] && !armedTarget('global-error')) {
|
|
1005
|
+
violations.push({
|
|
1006
|
+
kind: 'missing-global-error-proof',
|
|
1007
|
+
label: String(node.details['globalErrorComponent'] ?? node.label),
|
|
1008
|
+
...location(node),
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
if (node.details?.['hasRouteLoadError'] && !armedTarget('route-load-error')) {
|
|
1012
|
+
violations.push({
|
|
1013
|
+
kind: 'missing-route-load-error-proof',
|
|
1014
|
+
label: String(node.details['routeLoadErrorComponent'] ?? node.label),
|
|
1015
|
+
...location(node),
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
return violations;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
export function assertRouteDiProofs(graph: DependencyGraph): void {
|
|
1024
|
+
const violations = routeDiProofViolations(graph);
|
|
1025
|
+
if (violations.length === 0) return;
|
|
1026
|
+
throw new Error(
|
|
1027
|
+
violations
|
|
1028
|
+
.map((violation) => {
|
|
1029
|
+
const at = [violation.filePath, violation.line]
|
|
1030
|
+
.filter(Boolean)
|
|
1031
|
+
.join(':');
|
|
1032
|
+
const suffix = at ? ` (${at})` : '';
|
|
1033
|
+
switch (violation.kind) {
|
|
1034
|
+
case 'missing-di-proof':
|
|
1035
|
+
return `Route ${JSON.stringify(violation.label)} is missing its DI proof.${suffix}`;
|
|
1036
|
+
case 'unarmed-mapper':
|
|
1037
|
+
return `Route ${JSON.stringify(violation.label)} has a DI mapper that is not armed with CanRun.${suffix}`;
|
|
1038
|
+
case 'missing-pending-proof':
|
|
1039
|
+
return `Route ${JSON.stringify(violation.label)} is missing its pending-component DI proof.${suffix}`;
|
|
1040
|
+
case 'missing-error-proof':
|
|
1041
|
+
return `Route ${JSON.stringify(violation.label)} is missing its error-component DI proof.${suffix}`;
|
|
1042
|
+
case 'missing-exception-assert':
|
|
1043
|
+
return `Route collection ${violation.label} is missing assertExhaustiveRouteExceptions.${suffix}`;
|
|
1044
|
+
case 'missing-global-error-proof':
|
|
1045
|
+
return `App config is missing an armed DI proof for its global error component ${violation.label}.${suffix}`;
|
|
1046
|
+
case 'missing-route-load-error-proof':
|
|
1047
|
+
return `App config is missing an armed DI proof for its route-load error component ${violation.label}.${suffix}`;
|
|
1048
|
+
}
|
|
1049
|
+
})
|
|
1050
|
+
.join('\n'),
|
|
1051
|
+
);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
export type RouteComponentFileViolation = {
|
|
1055
|
+
kind: 'route-and-component-same-file' | 'components-share-file';
|
|
1056
|
+
route: string;
|
|
1057
|
+
component: string;
|
|
1058
|
+
components: readonly string[];
|
|
1059
|
+
routeFile: string;
|
|
1060
|
+
componentFile: string;
|
|
1061
|
+
};
|
|
1062
|
+
|
|
1063
|
+
export function routeComponentFileViolations(
|
|
1064
|
+
graph: DependencyGraph,
|
|
1065
|
+
): RouteComponentFileViolation[] {
|
|
1066
|
+
const nodesById = new Map(graph.nodes.map((node) => [node.id, node]));
|
|
1067
|
+
const routedComponents = graph.edges.flatMap((edge) => {
|
|
1068
|
+
if (edge.kind !== 'loads') return [];
|
|
1069
|
+
const route = nodesById.get(edge.from);
|
|
1070
|
+
const component = nodesById.get(edge.to);
|
|
1071
|
+
if (
|
|
1072
|
+
!route ||
|
|
1073
|
+
route.kind !== 'route' ||
|
|
1074
|
+
!component ||
|
|
1075
|
+
component.kind !== 'component' ||
|
|
1076
|
+
!route.filePath ||
|
|
1077
|
+
!component.filePath
|
|
1078
|
+
) {
|
|
1079
|
+
return [];
|
|
1080
|
+
}
|
|
1081
|
+
return [
|
|
1082
|
+
{
|
|
1083
|
+
componentId: component.id,
|
|
1084
|
+
route: String(route.details?.['path'] ?? route.label),
|
|
1085
|
+
component: component.label,
|
|
1086
|
+
routeFile: relativeGraphPath(graph, route.filePath) ?? route.filePath,
|
|
1087
|
+
componentFile:
|
|
1088
|
+
relativeGraphPath(graph, component.filePath) ?? component.filePath,
|
|
1089
|
+
},
|
|
1090
|
+
];
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
const routeAndComponentViolations = routedComponents
|
|
1094
|
+
.filter((candidate) => candidate.routeFile === candidate.componentFile)
|
|
1095
|
+
.map(
|
|
1096
|
+
(candidate): RouteComponentFileViolation => ({
|
|
1097
|
+
kind: 'route-and-component-same-file',
|
|
1098
|
+
route: candidate.route,
|
|
1099
|
+
component: candidate.component,
|
|
1100
|
+
components: [candidate.component],
|
|
1101
|
+
routeFile: candidate.routeFile,
|
|
1102
|
+
componentFile: candidate.componentFile,
|
|
1103
|
+
}),
|
|
1104
|
+
);
|
|
1105
|
+
|
|
1106
|
+
const componentsByFile = new Map<string, typeof routedComponents>();
|
|
1107
|
+
for (const candidate of routedComponents) {
|
|
1108
|
+
const candidates = componentsByFile.get(candidate.componentFile) ?? [];
|
|
1109
|
+
candidates.push(candidate);
|
|
1110
|
+
componentsByFile.set(candidate.componentFile, candidates);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
const sharedFileViolations: RouteComponentFileViolation[] = [];
|
|
1114
|
+
for (const candidates of componentsByFile.values()) {
|
|
1115
|
+
const uniqueComponents = [
|
|
1116
|
+
...new Map(candidates.map((candidate) => [candidate.componentId, candidate])).values(),
|
|
1117
|
+
];
|
|
1118
|
+
if (uniqueComponents.length < 2) continue;
|
|
1119
|
+
const first = uniqueComponents[0];
|
|
1120
|
+
if (!first) continue;
|
|
1121
|
+
sharedFileViolations.push({
|
|
1122
|
+
kind: 'components-share-file',
|
|
1123
|
+
route: uniqueComponents
|
|
1124
|
+
.map((candidate) => candidate.route)
|
|
1125
|
+
.join(', '),
|
|
1126
|
+
component: uniqueComponents
|
|
1127
|
+
.map((candidate) => candidate.component)
|
|
1128
|
+
.join(', '),
|
|
1129
|
+
components: uniqueComponents.map((candidate) => candidate.component),
|
|
1130
|
+
routeFile: first.routeFile,
|
|
1131
|
+
componentFile: first.componentFile,
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
return [...routeAndComponentViolations, ...sharedFileViolations];
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
export function assertRouteComponentsInSeparateFiles(
|
|
1139
|
+
graph: DependencyGraph,
|
|
1140
|
+
): void {
|
|
1141
|
+
const violations = routeComponentFileViolations(graph);
|
|
1142
|
+
if (violations.length === 0) return;
|
|
1143
|
+
throw new Error(
|
|
1144
|
+
violations
|
|
1145
|
+
.map(
|
|
1146
|
+
(violation) =>
|
|
1147
|
+
violation.kind === 'components-share-file'
|
|
1148
|
+
? `Routes ${JSON.stringify(violation.route)} load multiple page components (${JSON.stringify(violation.component)}) from the same component file ${violation.componentFile}. Each routed page component must have its own file.`
|
|
1149
|
+
: `Route ${JSON.stringify(violation.route)} loads component ${JSON.stringify(violation.component)} from the same file ${violation.routeFile}. Route definitions and page components must be in separate files.`,
|
|
1150
|
+
)
|
|
1151
|
+
.join('\n'),
|
|
1152
|
+
);
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
export type HttpEndpointUniqueViolation = {
|
|
1156
|
+
id: string;
|
|
1157
|
+
label: string;
|
|
1158
|
+
method: string;
|
|
1159
|
+
url: string;
|
|
1160
|
+
callSites: readonly { ownerId?: string; filePath?: string; line?: number }[];
|
|
1161
|
+
};
|
|
1162
|
+
|
|
1163
|
+
export function httpEndpointUniqueViolations(
|
|
1164
|
+
graph: DependencyGraph,
|
|
1165
|
+
): HttpEndpointUniqueViolation[] {
|
|
1166
|
+
return graph.nodes
|
|
1167
|
+
.filter((node) => node.kind === 'http-endpoint')
|
|
1168
|
+
.flatMap((node): HttpEndpointUniqueViolation[] => {
|
|
1169
|
+
const callSites = Array.isArray(node.details?.['callSites'])
|
|
1170
|
+
? (node.details['callSites'] as HttpEndpointUniqueViolation['callSites'])
|
|
1171
|
+
: [];
|
|
1172
|
+
if (callSites.length <= 1) return [];
|
|
1173
|
+
return [
|
|
1174
|
+
{
|
|
1175
|
+
id: node.id,
|
|
1176
|
+
label: node.label,
|
|
1177
|
+
method: String(node.details?.['method'] ?? ''),
|
|
1178
|
+
url: String(node.details?.['url'] ?? ''),
|
|
1179
|
+
callSites,
|
|
1180
|
+
},
|
|
1181
|
+
];
|
|
1182
|
+
});
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
export function assertHttpEndpointUnique(graph: DependencyGraph): void {
|
|
1186
|
+
const violations = httpEndpointUniqueViolations(graph);
|
|
1187
|
+
if (violations.length === 0) return;
|
|
1188
|
+
throw new Error(
|
|
1189
|
+
violations
|
|
1190
|
+
.map((violation) => {
|
|
1191
|
+
const sites = violation.callSites
|
|
1192
|
+
.map((site) =>
|
|
1193
|
+
[site.filePath, site.line].filter(Boolean).join(':'),
|
|
1194
|
+
)
|
|
1195
|
+
.filter(Boolean)
|
|
1196
|
+
.join(', ');
|
|
1197
|
+
return `Duplicate HTTP ${violation.label} used twice${sites ? ` (${sites})` : ''}.`;
|
|
1198
|
+
})
|
|
1199
|
+
.join('\n'),
|
|
1200
|
+
);
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
export type CraftComputedPureViolation = {
|
|
1204
|
+
kind: 'calls' | 'writes';
|
|
1205
|
+
id: string;
|
|
1206
|
+
label: string;
|
|
1207
|
+
targetId: string;
|
|
1208
|
+
targetLabel: string;
|
|
1209
|
+
filePath?: string;
|
|
1210
|
+
line?: number;
|
|
1211
|
+
};
|
|
1212
|
+
|
|
1213
|
+
export function craftComputedPureViolations(
|
|
1214
|
+
graph: DependencyGraph,
|
|
1215
|
+
): CraftComputedPureViolation[] {
|
|
1216
|
+
const nodesById = new Map(graph.nodes.map((node) => [node.id, node]));
|
|
1217
|
+
return graph.nodes
|
|
1218
|
+
.filter(
|
|
1219
|
+
(node) =>
|
|
1220
|
+
node.kind === 'primitive' &&
|
|
1221
|
+
node.details?.['primitive'] === 'craftComputed',
|
|
1222
|
+
)
|
|
1223
|
+
.flatMap((node) =>
|
|
1224
|
+
graph.edges
|
|
1225
|
+
.filter(
|
|
1226
|
+
(edge) =>
|
|
1227
|
+
edge.from === node.id &&
|
|
1228
|
+
(edge.kind === 'calls' || edge.kind === 'writes'),
|
|
1229
|
+
)
|
|
1230
|
+
.map((edge) => {
|
|
1231
|
+
const target = nodesById.get(edge.to);
|
|
1232
|
+
const name =
|
|
1233
|
+
typeof node.details?.['name'] === 'string'
|
|
1234
|
+
? node.details['name']
|
|
1235
|
+
: node.label.replace(/^craftComputed:/, '');
|
|
1236
|
+
return {
|
|
1237
|
+
kind: edge.kind as 'calls' | 'writes',
|
|
1238
|
+
id: node.id,
|
|
1239
|
+
label: name,
|
|
1240
|
+
targetId: edge.to,
|
|
1241
|
+
targetLabel: target?.label ?? edge.to,
|
|
1242
|
+
filePath: node.filePath,
|
|
1243
|
+
line: node.line,
|
|
1244
|
+
};
|
|
1245
|
+
}),
|
|
1246
|
+
);
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
export function assertCraftComputedPure(graph: DependencyGraph): void {
|
|
1250
|
+
const violations = craftComputedPureViolations(graph);
|
|
1251
|
+
if (violations.length === 0) return;
|
|
1252
|
+
throw new Error(
|
|
1253
|
+
violations
|
|
1254
|
+
.map((violation) => {
|
|
1255
|
+
const site = [violation.filePath, violation.line]
|
|
1256
|
+
.filter(Boolean)
|
|
1257
|
+
.join(':');
|
|
1258
|
+
const action =
|
|
1259
|
+
violation.kind === 'writes' ? 'writes' : 'calls';
|
|
1260
|
+
return `craftComputed ${violation.label} ${action} ${violation.targetLabel}${site ? ` (${site})` : ''}.`;
|
|
1261
|
+
})
|
|
1262
|
+
.join('\n'),
|
|
1263
|
+
);
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
export type DependencyCycle = {
|
|
1267
|
+
ids: readonly string[];
|
|
1268
|
+
labels: readonly string[];
|
|
1269
|
+
};
|
|
1270
|
+
|
|
1271
|
+
export function dependencyCycleViolations(
|
|
1272
|
+
graph: DependencyGraph,
|
|
1273
|
+
): DependencyCycle[] {
|
|
1274
|
+
const nodesById = new Map(graph.nodes.map((node) => [node.id, node]));
|
|
1275
|
+
const adjacency = new Map<string, string[]>();
|
|
1276
|
+
for (const node of graph.nodes) adjacency.set(node.id, []);
|
|
1277
|
+
for (const edge of graph.edges) {
|
|
1278
|
+
if (edge.kind !== 'depends-on') continue;
|
|
1279
|
+
if (!adjacency.has(edge.from)) adjacency.set(edge.from, []);
|
|
1280
|
+
if (!adjacency.has(edge.to)) adjacency.set(edge.to, []);
|
|
1281
|
+
adjacency.get(edge.from)?.push(edge.to);
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
const labelOf = (id: string) => nodesById.get(id)?.label ?? id;
|
|
1285
|
+
return stronglyConnectedComponents(adjacency).flatMap((component) => {
|
|
1286
|
+
const allowed = new Set(component);
|
|
1287
|
+
const cyclic =
|
|
1288
|
+
component.length > 1 ||
|
|
1289
|
+
(adjacency.get(component[0]) ?? []).some((neighbor) =>
|
|
1290
|
+
allowed.has(neighbor),
|
|
1291
|
+
);
|
|
1292
|
+
if (!cyclic) return [];
|
|
1293
|
+
const ids = cyclePath(component, adjacency);
|
|
1294
|
+
return [
|
|
1295
|
+
{
|
|
1296
|
+
ids,
|
|
1297
|
+
labels: ids.map(labelOf),
|
|
1298
|
+
},
|
|
1299
|
+
];
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
export function assertNoDependencyCycles(graph: DependencyGraph): void {
|
|
1304
|
+
const violations = dependencyCycleViolations(graph);
|
|
1305
|
+
if (violations.length === 0) return;
|
|
1306
|
+
throw new Error(
|
|
1307
|
+
violations
|
|
1308
|
+
.map((cycle) => `Dependency cycle: ${cycle.labels.join(' -> ')}.`)
|
|
1309
|
+
.join('\n'),
|
|
1310
|
+
);
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
export type AppConfigRouteCycle = {
|
|
1314
|
+
appConfigFile: string;
|
|
1315
|
+
routeFile: string;
|
|
1316
|
+
routeLabels: readonly string[];
|
|
1317
|
+
};
|
|
1318
|
+
|
|
1319
|
+
/**
|
|
1320
|
+
* App configuration owns route registration. A route module may expose route
|
|
1321
|
+
* metadata to the config, but the two modules must not import each other: the
|
|
1322
|
+
* cycle makes the inferred provider context unstable and invites manual DI
|
|
1323
|
+
* fallbacks.
|
|
1324
|
+
*/
|
|
1325
|
+
export function appConfigRouteCycleViolations(
|
|
1326
|
+
graph: DependencyGraph,
|
|
1327
|
+
): AppConfigRouteCycle[] {
|
|
1328
|
+
const appConfigs = uniqueByFile(
|
|
1329
|
+
graph.nodes.filter((node) => node.kind === 'app-config'),
|
|
1330
|
+
);
|
|
1331
|
+
const routesByFile = new Map<string, DependencyGraphNode[]>();
|
|
1332
|
+
for (const node of graph.nodes) {
|
|
1333
|
+
if (node.kind !== 'route' || !node.filePath) continue;
|
|
1334
|
+
const routes = routesByFile.get(node.filePath) ?? [];
|
|
1335
|
+
routes.push(node);
|
|
1336
|
+
routesByFile.set(node.filePath, routes);
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
const violations: AppConfigRouteCycle[] = [];
|
|
1340
|
+
for (const appConfig of appConfigs) {
|
|
1341
|
+
if (!appConfig.filePath) continue;
|
|
1342
|
+
for (const [routeFile, routeNodes] of routesByFile) {
|
|
1343
|
+
if (
|
|
1344
|
+
!importsFile(appConfig.filePath, routeFile) ||
|
|
1345
|
+
!importsFile(routeFile, appConfig.filePath)
|
|
1346
|
+
) {
|
|
1347
|
+
continue;
|
|
1348
|
+
}
|
|
1349
|
+
violations.push({
|
|
1350
|
+
appConfigFile: appConfig.filePath,
|
|
1351
|
+
routeFile,
|
|
1352
|
+
routeLabels: routeNodes.map(routePath),
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
return violations;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
export function assertNoAppConfigRouteCycles(graph: DependencyGraph): void {
|
|
1360
|
+
const violations = appConfigRouteCycleViolations(graph);
|
|
1361
|
+
if (violations.length === 0) return;
|
|
1362
|
+
throw new Error(
|
|
1363
|
+
violations
|
|
1364
|
+
.map(
|
|
1365
|
+
(violation) =>
|
|
1366
|
+
`App config/routes dependency cycle: ${relativeGraphPath(graph, violation.appConfigFile)} -> ${relativeGraphPath(graph, violation.routeFile)} -> ${relativeGraphPath(graph, violation.appConfigFile)}. Remove the cycle so RouteCheckedDI can infer the application provider context; do not replace it with a manual provider list.`,
|
|
1367
|
+
)
|
|
1368
|
+
.join('\n'),
|
|
1369
|
+
);
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
function uniqueByFile(nodes: readonly DependencyGraphNode[]): DependencyGraphNode[] {
|
|
1373
|
+
const seen = new Set<string>();
|
|
1374
|
+
return nodes.filter((node) => {
|
|
1375
|
+
if (!node.filePath || seen.has(node.filePath)) return false;
|
|
1376
|
+
seen.add(node.filePath);
|
|
1377
|
+
return true;
|
|
1378
|
+
});
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
function importsFile(fromFile: string, targetFile: string): boolean {
|
|
1382
|
+
if (!existsSync(fromFile)) return false;
|
|
1383
|
+
const source = readFileSync(fromFile, 'utf8');
|
|
1384
|
+
const specifiers = source.matchAll(/(?:from\s*|import\s*\(\s*)['"]([^'"]+)['"]/g);
|
|
1385
|
+
for (const match of specifiers) {
|
|
1386
|
+
const specifier = match[1];
|
|
1387
|
+
if (!specifier?.startsWith('.')) continue;
|
|
1388
|
+
if (resolveImport(fromFile, specifier) === targetFile) return true;
|
|
1389
|
+
}
|
|
1390
|
+
return false;
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
function resolveImport(fromFile: string, specifier: string): string | undefined {
|
|
1394
|
+
const base = resolve(dirname(fromFile), specifier);
|
|
1395
|
+
const candidates = [
|
|
1396
|
+
base,
|
|
1397
|
+
`${base}.ts`,
|
|
1398
|
+
`${base}.tsx`,
|
|
1399
|
+
`${base}.mts`,
|
|
1400
|
+
`${base}.js`,
|
|
1401
|
+
`${base}.mjs`,
|
|
1402
|
+
`${base}/index.ts`,
|
|
1403
|
+
`${base}/index.tsx`,
|
|
1404
|
+
];
|
|
1405
|
+
return candidates.find(existsSync);
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
export type PathBoundaryConstraint = {
|
|
1409
|
+
source: string;
|
|
1410
|
+
onlyDependOn?: readonly string[];
|
|
1411
|
+
forbidTarget?: readonly string[];
|
|
1412
|
+
};
|
|
1413
|
+
|
|
1414
|
+
export type PathBoundaryOptions = {
|
|
1415
|
+
edgeKinds?: readonly DependencyGraphEdgeKind[];
|
|
1416
|
+
constraints: readonly PathBoundaryConstraint[];
|
|
1417
|
+
};
|
|
1418
|
+
|
|
1419
|
+
export type PathBoundaryViolation = {
|
|
1420
|
+
fromId: string;
|
|
1421
|
+
toId: string;
|
|
1422
|
+
fromLabel: string;
|
|
1423
|
+
toLabel: string;
|
|
1424
|
+
fromPath: string;
|
|
1425
|
+
toPath: string;
|
|
1426
|
+
edgeKind: DependencyGraphEdgeKind;
|
|
1427
|
+
source: string;
|
|
1428
|
+
reason: 'allowlist' | 'denylist';
|
|
1429
|
+
};
|
|
1430
|
+
|
|
1431
|
+
export function pathBoundaryViolations(
|
|
1432
|
+
graph: DependencyGraph,
|
|
1433
|
+
options: PathBoundaryOptions,
|
|
1434
|
+
): PathBoundaryViolation[] {
|
|
1435
|
+
const edgeKinds = new Set(options.edgeKinds ?? ['depends-on']);
|
|
1436
|
+
const nodesById = new Map(graph.nodes.map((node) => [node.id, node]));
|
|
1437
|
+
const violations: PathBoundaryViolation[] = [];
|
|
1438
|
+
|
|
1439
|
+
for (const edge of graph.edges) {
|
|
1440
|
+
if (!edgeKinds.has(edge.kind)) continue;
|
|
1441
|
+
const from = nodesById.get(edge.from);
|
|
1442
|
+
const to = nodesById.get(edge.to);
|
|
1443
|
+
if (!from || !to) continue;
|
|
1444
|
+
const fromPath = relativeGraphPath(graph, from.filePath);
|
|
1445
|
+
const toPath = relativeGraphPath(graph, to.filePath);
|
|
1446
|
+
if (!fromPath || !toPath) continue;
|
|
1447
|
+
|
|
1448
|
+
for (const constraint of options.constraints) {
|
|
1449
|
+
if (
|
|
1450
|
+
constraint.onlyDependOn === undefined &&
|
|
1451
|
+
constraint.forbidTarget === undefined
|
|
1452
|
+
) {
|
|
1453
|
+
continue;
|
|
1454
|
+
}
|
|
1455
|
+
const captures = matchPathGlob(constraint.source, fromPath);
|
|
1456
|
+
if (!captures) continue;
|
|
1457
|
+
|
|
1458
|
+
const allowed = constraint.onlyDependOn;
|
|
1459
|
+
const forbidden = constraint.forbidTarget;
|
|
1460
|
+
const allowHit =
|
|
1461
|
+
allowed === undefined
|
|
1462
|
+
? true
|
|
1463
|
+
: allowed.some((pattern) =>
|
|
1464
|
+
matchPathGlob(pattern, toPath, captures),
|
|
1465
|
+
);
|
|
1466
|
+
const forbidHit =
|
|
1467
|
+
forbidden !== undefined &&
|
|
1468
|
+
forbidden.some((pattern) => matchPathGlob(pattern, toPath, captures));
|
|
1469
|
+
|
|
1470
|
+
if (allowHit && !forbidHit) continue;
|
|
1471
|
+
violations.push({
|
|
1472
|
+
fromId: from.id,
|
|
1473
|
+
toId: to.id,
|
|
1474
|
+
fromLabel: from.label,
|
|
1475
|
+
toLabel: to.label,
|
|
1476
|
+
fromPath,
|
|
1477
|
+
toPath,
|
|
1478
|
+
edgeKind: edge.kind,
|
|
1479
|
+
source: constraint.source,
|
|
1480
|
+
reason: forbidHit ? 'denylist' : 'allowlist',
|
|
1481
|
+
});
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
return violations;
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
export function assertPathBoundaries(
|
|
1489
|
+
graph: DependencyGraph,
|
|
1490
|
+
options: PathBoundaryOptions,
|
|
1491
|
+
): void {
|
|
1492
|
+
const violations = pathBoundaryViolations(graph, options);
|
|
1493
|
+
if (violations.length === 0) return;
|
|
1494
|
+
throw new Error(
|
|
1495
|
+
violations
|
|
1496
|
+
.map(
|
|
1497
|
+
(violation) =>
|
|
1498
|
+
`Path boundary: ${violation.fromLabel} -[${violation.edgeKind}]-> ${violation.toLabel} (${violation.fromPath} → ${violation.toPath}).`,
|
|
1499
|
+
)
|
|
1500
|
+
.join('\n'),
|
|
1501
|
+
);
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
export type MutationReactOnOptions = {
|
|
1505
|
+
allow?: readonly string[];
|
|
1506
|
+
};
|
|
1507
|
+
|
|
1508
|
+
export type MutationReactOnViolation = {
|
|
1509
|
+
id: string;
|
|
1510
|
+
label: string;
|
|
1511
|
+
filePath?: string;
|
|
1512
|
+
line?: number;
|
|
1513
|
+
};
|
|
1514
|
+
|
|
1515
|
+
export function mutationReactOnViolations(
|
|
1516
|
+
graph: DependencyGraph,
|
|
1517
|
+
options: MutationReactOnOptions = {},
|
|
1518
|
+
): MutationReactOnViolation[] {
|
|
1519
|
+
const nodesById = new Map(graph.nodes.map((node) => [node.id, node]));
|
|
1520
|
+
const allowed = new Set(options.allow ?? []);
|
|
1521
|
+
return graph.nodes.flatMap((node) => {
|
|
1522
|
+
if (node.kind !== 'primitive' || node.details?.['primitive'] !== 'mutation') {
|
|
1523
|
+
return [];
|
|
1524
|
+
}
|
|
1525
|
+
const name = primitiveDisplayName(node);
|
|
1526
|
+
if (allowed.has(name) || allowed.has(node.label)) return [];
|
|
1527
|
+
const reacts = graph.edges.some(
|
|
1528
|
+
(edge) =>
|
|
1529
|
+
edge.from === node.id &&
|
|
1530
|
+
edge.kind === 'triggers' &&
|
|
1531
|
+
edge.details?.['insertion'] === 'react-on-mutation' &&
|
|
1532
|
+
nodesById.get(edge.to)?.details?.['primitive'] === 'query',
|
|
1533
|
+
);
|
|
1534
|
+
if (reacts) return [];
|
|
1535
|
+
return [
|
|
1536
|
+
{
|
|
1537
|
+
id: node.id,
|
|
1538
|
+
label: name,
|
|
1539
|
+
filePath: relativeGraphPath(graph, node.filePath),
|
|
1540
|
+
line: node.line,
|
|
1541
|
+
},
|
|
1542
|
+
];
|
|
1543
|
+
});
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
export function assertMutationHasReactOn(
|
|
1547
|
+
graph: DependencyGraph,
|
|
1548
|
+
options: MutationReactOnOptions = {},
|
|
1549
|
+
): void {
|
|
1550
|
+
const violations = mutationReactOnViolations(graph, options);
|
|
1551
|
+
if (violations.length === 0) return;
|
|
1552
|
+
throw new Error(
|
|
1553
|
+
violations
|
|
1554
|
+
.map((violation) => {
|
|
1555
|
+
const at = [violation.filePath, violation.line]
|
|
1556
|
+
.filter(Boolean)
|
|
1557
|
+
.join(':');
|
|
1558
|
+
return `Mutation ${violation.label} has no query reacting to it${at ? ` (${at})` : ''}.`;
|
|
1559
|
+
})
|
|
1560
|
+
.join('\n'),
|
|
1561
|
+
);
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
export type PersistedUniqueViolation = {
|
|
1565
|
+
id: string;
|
|
1566
|
+
label: string;
|
|
1567
|
+
primitive: string;
|
|
1568
|
+
filePath?: string;
|
|
1569
|
+
line?: number;
|
|
1570
|
+
};
|
|
1571
|
+
|
|
1572
|
+
export function persistedPrimitiveUniqueViolations(
|
|
1573
|
+
graph: DependencyGraph,
|
|
1574
|
+
): PersistedUniqueViolation[] {
|
|
1575
|
+
return graph.nodes.flatMap((node) => {
|
|
1576
|
+
if (node.kind !== 'primitive' || node.details?.['persisted'] !== true) {
|
|
1577
|
+
return [];
|
|
1578
|
+
}
|
|
1579
|
+
if (node.details['persistedUnique'] === true) return [];
|
|
1580
|
+
const primitive =
|
|
1581
|
+
typeof node.details['primitive'] === 'string'
|
|
1582
|
+
? node.details['primitive']
|
|
1583
|
+
: 'primitive';
|
|
1584
|
+
return [
|
|
1585
|
+
{
|
|
1586
|
+
id: node.id,
|
|
1587
|
+
label: primitiveDisplayName(node),
|
|
1588
|
+
primitive,
|
|
1589
|
+
filePath: relativeGraphPath(graph, node.filePath),
|
|
1590
|
+
line: node.line,
|
|
1591
|
+
},
|
|
1592
|
+
];
|
|
1593
|
+
});
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
export function assertPersistedPrimitiveHasUnique(
|
|
1597
|
+
graph: DependencyGraph,
|
|
1598
|
+
): void {
|
|
1599
|
+
const violations = persistedPrimitiveUniqueViolations(graph);
|
|
1600
|
+
if (violations.length === 0) return;
|
|
1601
|
+
throw new Error(
|
|
1602
|
+
violations
|
|
1603
|
+
.map((violation) => {
|
|
1604
|
+
const at = [violation.filePath, violation.line]
|
|
1605
|
+
.filter(Boolean)
|
|
1606
|
+
.join(':');
|
|
1607
|
+
return `Persisted ${violation.primitive} ${violation.label} is missing craftUnique${at ? ` (${at})` : ''}.`;
|
|
1608
|
+
})
|
|
1609
|
+
.join('\n'),
|
|
1610
|
+
);
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
export type InsertSelectUniqueViolation = {
|
|
1614
|
+
key: string;
|
|
1615
|
+
hostId: string;
|
|
1616
|
+
hostLabel: string;
|
|
1617
|
+
callSites: readonly { filePath?: string; line?: number }[];
|
|
1618
|
+
};
|
|
1619
|
+
|
|
1620
|
+
export function insertSelectUniqueViolations(
|
|
1621
|
+
graph: DependencyGraph,
|
|
1622
|
+
): InsertSelectUniqueViolation[] {
|
|
1623
|
+
const nodesById = new Map(graph.nodes.map((node) => [node.id, node]));
|
|
1624
|
+
const grouped = new Map<string, DependencyGraphNode[]>();
|
|
1625
|
+
for (const node of graph.nodes) {
|
|
1626
|
+
if (node.kind !== 'primitive' || node.details?.['primitive'] !== 'insertSelect') {
|
|
1627
|
+
continue;
|
|
1628
|
+
}
|
|
1629
|
+
const parent = graph.edges.find(
|
|
1630
|
+
(edge) => edge.kind === 'contains' && edge.to === node.id,
|
|
1631
|
+
);
|
|
1632
|
+
const hostId = parent?.from ?? node.details?.['ownerId'];
|
|
1633
|
+
if (typeof hostId !== 'string') continue;
|
|
1634
|
+
const key = primitiveDisplayName(node);
|
|
1635
|
+
const groupKey = `${hostId}::${key}`;
|
|
1636
|
+
const list = grouped.get(groupKey) ?? [];
|
|
1637
|
+
list.push(node);
|
|
1638
|
+
grouped.set(groupKey, list);
|
|
1639
|
+
}
|
|
1640
|
+
return [...grouped.entries()].flatMap(([groupKey, nodes]) => {
|
|
1641
|
+
if (nodes.length <= 1) return [];
|
|
1642
|
+
const hostId = groupKey.slice(0, groupKey.indexOf('::'));
|
|
1643
|
+
const key = groupKey.slice(groupKey.indexOf('::') + 2);
|
|
1644
|
+
const host = nodesById.get(hostId);
|
|
1645
|
+
return [
|
|
1646
|
+
{
|
|
1647
|
+
key,
|
|
1648
|
+
hostId,
|
|
1649
|
+
hostLabel: host ? primitiveDisplayName(host) : hostId,
|
|
1650
|
+
callSites: nodes.map((node) => ({
|
|
1651
|
+
filePath: relativeGraphPath(graph, node.filePath),
|
|
1652
|
+
line: node.line,
|
|
1653
|
+
})),
|
|
1654
|
+
},
|
|
1655
|
+
];
|
|
1656
|
+
});
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
export function assertInsertSelectUnique(graph: DependencyGraph): void {
|
|
1660
|
+
const violations = insertSelectUniqueViolations(graph);
|
|
1661
|
+
if (violations.length === 0) return;
|
|
1662
|
+
throw new Error(
|
|
1663
|
+
violations
|
|
1664
|
+
.map((violation) => {
|
|
1665
|
+
const sites = violation.callSites
|
|
1666
|
+
.map((site) => [site.filePath, site.line].filter(Boolean).join(':'))
|
|
1667
|
+
.filter(Boolean)
|
|
1668
|
+
.join(', ');
|
|
1669
|
+
return `Duplicate insertSelect ${violation.key} on ${violation.hostLabel}${sites ? ` (${sites})` : ''}.`;
|
|
1670
|
+
})
|
|
1671
|
+
.join('\n'),
|
|
1672
|
+
);
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
export type CraftEffectNetworkViolation = {
|
|
1676
|
+
kind: 'http' | 'mutation';
|
|
1677
|
+
id: string;
|
|
1678
|
+
label: string;
|
|
1679
|
+
targetId: string;
|
|
1680
|
+
targetLabel: string;
|
|
1681
|
+
filePath?: string;
|
|
1682
|
+
line?: number;
|
|
1683
|
+
};
|
|
1684
|
+
|
|
1685
|
+
export type LoaderRequirementContext = {
|
|
1686
|
+
graph: DependencyGraph;
|
|
1687
|
+
primitive: DependencyGraphNode;
|
|
1688
|
+
target: DependencyGraphNode;
|
|
1689
|
+
edge: DependencyGraphEdge;
|
|
1690
|
+
};
|
|
1691
|
+
|
|
1692
|
+
export type LoaderRequirement = {
|
|
1693
|
+
/** Human-readable name used in the diagnostic. */
|
|
1694
|
+
label: string;
|
|
1695
|
+
/** Returns true when this target satisfies the requirement. */
|
|
1696
|
+
matches: (context: LoaderRequirementContext) => boolean;
|
|
1697
|
+
};
|
|
1698
|
+
|
|
1699
|
+
export type PrimitiveLoaderRequirements = {
|
|
1700
|
+
/** Primitive names to check. Requirements are OR-ed for each primitive. */
|
|
1701
|
+
primitives: readonly string[];
|
|
1702
|
+
requirements: readonly LoaderRequirement[];
|
|
1703
|
+
/** Explicitly exempted primitive labels for intentional local-state examples. */
|
|
1704
|
+
allow?: readonly string[];
|
|
1705
|
+
};
|
|
1706
|
+
|
|
1707
|
+
export type PrimitiveLoaderRequirementViolation = {
|
|
1708
|
+
primitive: string;
|
|
1709
|
+
id: string;
|
|
1710
|
+
label: string;
|
|
1711
|
+
missing: readonly string[];
|
|
1712
|
+
filePath?: string;
|
|
1713
|
+
line?: number;
|
|
1714
|
+
};
|
|
1715
|
+
|
|
1716
|
+
/**
|
|
1717
|
+
* Checks that every selected resource has at least one qualifying dependency
|
|
1718
|
+
* from its `loader`. This is intentionally predicate-based: query/mutation
|
|
1719
|
+
* can use HTTP or server functions, while an Effect application can provide a
|
|
1720
|
+
* rule that recognises Effect services or another domain boundary.
|
|
1721
|
+
*/
|
|
1722
|
+
export function primitiveLoaderRequirementViolations(
|
|
1723
|
+
graph: DependencyGraph,
|
|
1724
|
+
rule: PrimitiveLoaderRequirements,
|
|
1725
|
+
): PrimitiveLoaderRequirementViolation[] {
|
|
1726
|
+
const nodesById = new Map(graph.nodes.map((node) => [node.id, node]));
|
|
1727
|
+
const primitiveNames = new Set(rule.primitives);
|
|
1728
|
+
const allowed = new Set(rule.allow ?? []);
|
|
1729
|
+
return graph.nodes.flatMap((primitive) => {
|
|
1730
|
+
if (
|
|
1731
|
+
primitive.kind !== 'primitive' ||
|
|
1732
|
+
typeof primitive.details?.['primitive'] !== 'string' ||
|
|
1733
|
+
!primitiveNames.has(primitive.details['primitive'])
|
|
1734
|
+
) {
|
|
1735
|
+
return [];
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
const label = primitiveDisplayName(primitive);
|
|
1739
|
+
if (allowed.has(label) || allowed.has(primitive.label)) return [];
|
|
1740
|
+
|
|
1741
|
+
const loaderEdges = graph.edges.filter(
|
|
1742
|
+
(edge) =>
|
|
1743
|
+
edge.from === primitive.id &&
|
|
1744
|
+
edge.details?.['resourceRole'] === 'loader',
|
|
1745
|
+
);
|
|
1746
|
+
const satisfied = loaderEdges.some((edge) => {
|
|
1747
|
+
const target = nodesById.get(edge.to);
|
|
1748
|
+
if (!target) return false;
|
|
1749
|
+
const context = { graph, primitive, target, edge };
|
|
1750
|
+
return rule.requirements.some((requirement) => requirement.matches(context));
|
|
1751
|
+
});
|
|
1752
|
+
if (satisfied) return [];
|
|
1753
|
+
|
|
1754
|
+
return [
|
|
1755
|
+
{
|
|
1756
|
+
primitive: primitive.details['primitive'],
|
|
1757
|
+
id: primitive.id,
|
|
1758
|
+
label,
|
|
1759
|
+
missing: rule.requirements.map((requirement) => requirement.label),
|
|
1760
|
+
filePath: relativeGraphPath(graph, primitive.filePath),
|
|
1761
|
+
line: primitive.line,
|
|
1762
|
+
},
|
|
1763
|
+
];
|
|
1764
|
+
});
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
export function assertPrimitiveLoaderRequirements(
|
|
1768
|
+
graph: DependencyGraph,
|
|
1769
|
+
rule: PrimitiveLoaderRequirements,
|
|
1770
|
+
): void {
|
|
1771
|
+
const violations = primitiveLoaderRequirementViolations(graph, rule);
|
|
1772
|
+
if (violations.length === 0) return;
|
|
1773
|
+
throw new Error(
|
|
1774
|
+
violations
|
|
1775
|
+
.map((violation) => {
|
|
1776
|
+
const at = [violation.filePath, violation.line]
|
|
1777
|
+
.filter(Boolean)
|
|
1778
|
+
.join(':');
|
|
1779
|
+
const requirements = violation.missing.join(' or ') || 'a qualifying dependency';
|
|
1780
|
+
return `${violation.primitive} ${violation.label} loader must depend on ${requirements}${at ? ` (${at})` : ''}.`;
|
|
1781
|
+
})
|
|
1782
|
+
.join('\n'),
|
|
1783
|
+
);
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
const SERVER_STATE_LOADER_REQUIREMENTS: readonly LoaderRequirement[] = [
|
|
1787
|
+
{
|
|
1788
|
+
label: 'CraftHttpClient',
|
|
1789
|
+
matches: ({ graph, target }) =>
|
|
1790
|
+
target.kind === 'http-endpoint' ||
|
|
1791
|
+
reachesServerStateBoundary(graph, target.id),
|
|
1792
|
+
},
|
|
1793
|
+
{
|
|
1794
|
+
label: 'server function',
|
|
1795
|
+
matches: ({ graph, target }) =>
|
|
1796
|
+
target.kind === 'server-function-family' ||
|
|
1797
|
+
reachesServerStateBoundary(graph, target.id),
|
|
1798
|
+
},
|
|
1799
|
+
];
|
|
1800
|
+
|
|
1801
|
+
function reachesServerStateBoundary(
|
|
1802
|
+
graph: DependencyGraph,
|
|
1803
|
+
nodeId: string,
|
|
1804
|
+
visited = new Set<string>(),
|
|
1805
|
+
): boolean {
|
|
1806
|
+
if (visited.has(nodeId)) return false;
|
|
1807
|
+
visited.add(nodeId);
|
|
1808
|
+
const node = graph.nodes.find((candidate) => candidate.id === nodeId);
|
|
1809
|
+
if (
|
|
1810
|
+
node?.kind === 'http-endpoint' ||
|
|
1811
|
+
node?.kind === 'server-function-family'
|
|
1812
|
+
) {
|
|
1813
|
+
return true;
|
|
1814
|
+
}
|
|
1815
|
+
return graph.edges
|
|
1816
|
+
.filter(
|
|
1817
|
+
(edge) =>
|
|
1818
|
+
edge.from === nodeId &&
|
|
1819
|
+
(edge.kind === 'depends-on' || edge.kind === 'calls'),
|
|
1820
|
+
)
|
|
1821
|
+
.some((edge) => reachesServerStateBoundary(graph, edge.to, visited));
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
export function queryMutationServerStateViolations(
|
|
1825
|
+
graph: DependencyGraph,
|
|
1826
|
+
): PrimitiveLoaderRequirementViolation[] {
|
|
1827
|
+
return primitiveLoaderRequirementViolations(graph, {
|
|
1828
|
+
primitives: ['query', 'mutation'],
|
|
1829
|
+
requirements: SERVER_STATE_LOADER_REQUIREMENTS,
|
|
1830
|
+
});
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
export function assertQueryMutationHasServerState(
|
|
1834
|
+
graph: DependencyGraph,
|
|
1835
|
+
options: Pick<PrimitiveLoaderRequirements, 'allow'> = {},
|
|
1836
|
+
): void {
|
|
1837
|
+
assertPrimitiveLoaderRequirements(graph, {
|
|
1838
|
+
primitives: ['query', 'mutation'],
|
|
1839
|
+
requirements: SERVER_STATE_LOADER_REQUIREMENTS,
|
|
1840
|
+
...options,
|
|
1841
|
+
});
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
export function craftEffectNetworkViolations(
|
|
1845
|
+
graph: DependencyGraph,
|
|
1846
|
+
): CraftEffectNetworkViolation[] {
|
|
1847
|
+
const nodesById = new Map(graph.nodes.map((node) => [node.id, node]));
|
|
1848
|
+
return graph.nodes.flatMap((node) => {
|
|
1849
|
+
if (node.kind !== 'primitive' || node.details?.['primitive'] !== 'craftEffect') {
|
|
1850
|
+
return [];
|
|
1851
|
+
}
|
|
1852
|
+
const violations: CraftEffectNetworkViolation[] = [];
|
|
1853
|
+
const location = {
|
|
1854
|
+
filePath: relativeGraphPath(graph, node.filePath),
|
|
1855
|
+
line: node.line,
|
|
1856
|
+
};
|
|
1857
|
+
const push = (
|
|
1858
|
+
kind: 'http' | 'mutation',
|
|
1859
|
+
targetId: string,
|
|
1860
|
+
targetLabel: string,
|
|
1861
|
+
) => {
|
|
1862
|
+
violations.push({
|
|
1863
|
+
kind,
|
|
1864
|
+
id: node.id,
|
|
1865
|
+
label: primitiveDisplayName(node),
|
|
1866
|
+
targetId,
|
|
1867
|
+
targetLabel,
|
|
1868
|
+
...location,
|
|
1869
|
+
});
|
|
1870
|
+
};
|
|
1871
|
+
for (const edge of graph.edges) {
|
|
1872
|
+
if (edge.from !== node.id) continue;
|
|
1873
|
+
if (edge.kind !== 'calls' && edge.kind !== 'depends-on') continue;
|
|
1874
|
+
const target = nodesById.get(edge.to);
|
|
1875
|
+
if (!target) continue;
|
|
1876
|
+
if (target.kind === 'http-endpoint') {
|
|
1877
|
+
push('http', target.id, target.label);
|
|
1878
|
+
continue;
|
|
1879
|
+
}
|
|
1880
|
+
if (isMutationTarget(graph, nodesById, target)) {
|
|
1881
|
+
push('mutation', target.id, primitiveDisplayName(target));
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
if (
|
|
1885
|
+
node.details?.['craftHttpClient'] === true &&
|
|
1886
|
+
!violations.some((violation) => violation.kind === 'http')
|
|
1887
|
+
) {
|
|
1888
|
+
push('http', node.id, 'CraftHttpClient');
|
|
1889
|
+
}
|
|
1890
|
+
return violations;
|
|
1891
|
+
});
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
export function assertCraftEffectNoNetwork(graph: DependencyGraph): void {
|
|
1895
|
+
const violations = craftEffectNetworkViolations(graph);
|
|
1896
|
+
if (violations.length === 0) return;
|
|
1897
|
+
throw new Error(
|
|
1898
|
+
violations
|
|
1899
|
+
.map((violation) => {
|
|
1900
|
+
const at = [violation.filePath, violation.line]
|
|
1901
|
+
.filter(Boolean)
|
|
1902
|
+
.join(':');
|
|
1903
|
+
const action =
|
|
1904
|
+
violation.kind === 'http'
|
|
1905
|
+
? `calls HTTP ${violation.targetLabel}`
|
|
1906
|
+
: `calls mutation ${violation.targetLabel}`;
|
|
1907
|
+
return `craftEffect ${violation.label} ${action}${at ? ` (${at})` : ''}.`;
|
|
1908
|
+
})
|
|
1909
|
+
.join('\n'),
|
|
1910
|
+
);
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
const IMPERATIVE_SYNC_HOSTS = new Set([
|
|
1914
|
+
'state',
|
|
1915
|
+
'query',
|
|
1916
|
+
'mutation',
|
|
1917
|
+
'asyncProcess',
|
|
1918
|
+
]);
|
|
1919
|
+
const IMPERATIVE_SYNC_WRITE_MEMBERS = new Set([
|
|
1920
|
+
'set',
|
|
1921
|
+
'update',
|
|
1922
|
+
'patch',
|
|
1923
|
+
'emit',
|
|
1924
|
+
]);
|
|
1925
|
+
|
|
1926
|
+
export type CraftEffectImperativeSyncKind =
|
|
1927
|
+
| 'state'
|
|
1928
|
+
| 'source'
|
|
1929
|
+
| 'query'
|
|
1930
|
+
| 'mutation'
|
|
1931
|
+
| 'asyncProcess';
|
|
1932
|
+
|
|
1933
|
+
export type CraftEffectImperativeSyncViolation = {
|
|
1934
|
+
kind: CraftEffectImperativeSyncKind;
|
|
1935
|
+
action: 'writes' | 'calls';
|
|
1936
|
+
id: string;
|
|
1937
|
+
label: string;
|
|
1938
|
+
targetId: string;
|
|
1939
|
+
targetLabel: string;
|
|
1940
|
+
filePath?: string;
|
|
1941
|
+
line?: number;
|
|
1942
|
+
};
|
|
1943
|
+
|
|
1944
|
+
export function craftEffectImperativeSyncViolations(
|
|
1945
|
+
graph: DependencyGraph,
|
|
1946
|
+
): CraftEffectImperativeSyncViolation[] {
|
|
1947
|
+
const nodesById = new Map(graph.nodes.map((node) => [node.id, node]));
|
|
1948
|
+
return graph.nodes.flatMap((node) => {
|
|
1949
|
+
if (node.kind !== 'primitive' || node.details?.['primitive'] !== 'craftEffect') {
|
|
1950
|
+
return [];
|
|
1951
|
+
}
|
|
1952
|
+
const location = {
|
|
1953
|
+
filePath: relativeGraphPath(graph, node.filePath),
|
|
1954
|
+
line: node.line,
|
|
1955
|
+
};
|
|
1956
|
+
const violations: CraftEffectImperativeSyncViolation[] = [];
|
|
1957
|
+
for (const edge of graph.edges) {
|
|
1958
|
+
if (edge.from !== node.id) continue;
|
|
1959
|
+
const target = nodesById.get(edge.to);
|
|
1960
|
+
if (!target) continue;
|
|
1961
|
+
const host = imperativeSyncHost(graph, nodesById, target);
|
|
1962
|
+
if (!host) continue;
|
|
1963
|
+
const member = propertyMemberName(target);
|
|
1964
|
+
const writes =
|
|
1965
|
+
edge.kind === 'writes' ||
|
|
1966
|
+
(edge.kind === 'calls' &&
|
|
1967
|
+
member !== undefined &&
|
|
1968
|
+
IMPERATIVE_SYNC_WRITE_MEMBERS.has(member));
|
|
1969
|
+
const calls = edge.kind === 'calls';
|
|
1970
|
+
const functionTrigger =
|
|
1971
|
+
edge.kind === 'depends-on' &&
|
|
1972
|
+
(host.kind === 'mutation' || host.kind === 'asyncProcess') &&
|
|
1973
|
+
target.kind === 'primitive';
|
|
1974
|
+
if (!writes && !calls && !functionTrigger) continue;
|
|
1975
|
+
violations.push({
|
|
1976
|
+
kind: host.kind,
|
|
1977
|
+
action: writes ? 'writes' : 'calls',
|
|
1978
|
+
id: node.id,
|
|
1979
|
+
label: primitiveDisplayName(node),
|
|
1980
|
+
targetId: target.id,
|
|
1981
|
+
targetLabel: host.label,
|
|
1982
|
+
...location,
|
|
1983
|
+
});
|
|
1984
|
+
}
|
|
1985
|
+
return violations;
|
|
1986
|
+
});
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
export function assertCraftEffectNoImperativeSync(
|
|
1990
|
+
graph: DependencyGraph,
|
|
1991
|
+
): void {
|
|
1992
|
+
const violations = craftEffectImperativeSyncViolations(graph);
|
|
1993
|
+
if (violations.length === 0) return;
|
|
1994
|
+
throw new Error(
|
|
1995
|
+
violations
|
|
1996
|
+
.map((violation) => {
|
|
1997
|
+
const at = [violation.filePath, violation.line]
|
|
1998
|
+
.filter(Boolean)
|
|
1999
|
+
.join(':');
|
|
2000
|
+
return `craftEffect ${violation.label} ${violation.action} ${violation.kind} ${violation.targetLabel}${at ? ` (${at})` : ''}. Prefer a state sourced from those signals, or reactive query/mutation params.`;
|
|
2001
|
+
})
|
|
2002
|
+
.join('\n'),
|
|
2003
|
+
);
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
function primitiveDisplayName(node: DependencyGraphNode): string {
|
|
2007
|
+
if (typeof node.details?.['name'] === 'string') return node.details['name'];
|
|
2008
|
+
const separator = node.label.indexOf(':');
|
|
2009
|
+
return separator >= 0 ? node.label.slice(separator + 1) : node.label;
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
function propertyMemberName(node: DependencyGraphNode): string | undefined {
|
|
2013
|
+
if (typeof node.details?.['member'] === 'string') return node.details['member'];
|
|
2014
|
+
if (typeof node.details?.['property'] === 'string') {
|
|
2015
|
+
return node.details['property'];
|
|
2016
|
+
}
|
|
2017
|
+
return undefined;
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
function containingParent(
|
|
2021
|
+
graph: DependencyGraph,
|
|
2022
|
+
nodesById: Map<string, DependencyGraphNode>,
|
|
2023
|
+
target: DependencyGraphNode,
|
|
2024
|
+
): DependencyGraphNode | undefined {
|
|
2025
|
+
const owner = graph.edges.find(
|
|
2026
|
+
(edge) => edge.kind === 'contains' && edge.to === target.id,
|
|
2027
|
+
);
|
|
2028
|
+
return owner ? nodesById.get(owner.from) : undefined;
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
function imperativeSyncHost(
|
|
2032
|
+
graph: DependencyGraph,
|
|
2033
|
+
nodesById: Map<string, DependencyGraphNode>,
|
|
2034
|
+
target: DependencyGraphNode,
|
|
2035
|
+
): { kind: CraftEffectImperativeSyncKind; label: string } | undefined {
|
|
2036
|
+
if (target.kind === 'source') {
|
|
2037
|
+
return { kind: 'source', label: target.label };
|
|
2038
|
+
}
|
|
2039
|
+
if (
|
|
2040
|
+
target.kind === 'primitive' &&
|
|
2041
|
+
typeof target.details?.['primitive'] === 'string' &&
|
|
2042
|
+
IMPERATIVE_SYNC_HOSTS.has(target.details['primitive'])
|
|
2043
|
+
) {
|
|
2044
|
+
return {
|
|
2045
|
+
kind: target.details['primitive'] as CraftEffectImperativeSyncKind,
|
|
2046
|
+
label: primitiveDisplayName(target),
|
|
2047
|
+
};
|
|
2048
|
+
}
|
|
2049
|
+
const parent = containingParent(graph, nodesById, target);
|
|
2050
|
+
if (!parent || parent.id === target.id) return undefined;
|
|
2051
|
+
return imperativeSyncHost(graph, nodesById, parent);
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
function isMutationTarget(
|
|
2055
|
+
graph: DependencyGraph,
|
|
2056
|
+
nodesById: Map<string, DependencyGraphNode>,
|
|
2057
|
+
target: DependencyGraphNode,
|
|
2058
|
+
): boolean {
|
|
2059
|
+
if (target.kind === 'primitive' && target.details?.['primitive'] === 'mutation') {
|
|
2060
|
+
return true;
|
|
2061
|
+
}
|
|
2062
|
+
if (target.kind !== 'property') return false;
|
|
2063
|
+
const owner = graph.edges.find(
|
|
2064
|
+
(edge) => edge.kind === 'contains' && edge.to === target.id,
|
|
2065
|
+
);
|
|
2066
|
+
const parent = owner ? nodesById.get(owner.from) : undefined;
|
|
2067
|
+
return parent?.details?.['primitive'] === 'mutation';
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
export type InteractiveElementNamedViolation = {
|
|
2071
|
+
kind: 'missing' | 'non-static' | 'duplicate';
|
|
2072
|
+
id: string;
|
|
2073
|
+
label: string;
|
|
2074
|
+
tag?: string;
|
|
2075
|
+
callSites: readonly { filePath?: string; line?: number }[];
|
|
2076
|
+
};
|
|
2077
|
+
|
|
2078
|
+
export function interactiveElementNamedViolations(
|
|
2079
|
+
graph: DependencyGraph,
|
|
2080
|
+
): InteractiveElementNamedViolation[] {
|
|
2081
|
+
const violations: InteractiveElementNamedViolation[] = [];
|
|
2082
|
+
const byName = new Map<string, DependencyGraphNode[]>();
|
|
2083
|
+
|
|
2084
|
+
for (const node of graph.nodes) {
|
|
2085
|
+
if (node.kind !== 'template-element') continue;
|
|
2086
|
+
const site = {
|
|
2087
|
+
filePath: relativeGraphPath(graph, node.filePath),
|
|
2088
|
+
line: node.line,
|
|
2089
|
+
};
|
|
2090
|
+
if (node.details?.['static'] === false) {
|
|
2091
|
+
violations.push({
|
|
2092
|
+
kind: 'non-static',
|
|
2093
|
+
id: node.id,
|
|
2094
|
+
label: node.label,
|
|
2095
|
+
tag: String(node.details['tag'] ?? ''),
|
|
2096
|
+
callSites: [site],
|
|
2097
|
+
});
|
|
2098
|
+
continue;
|
|
2099
|
+
}
|
|
2100
|
+
const localName = node.details?.['localName'];
|
|
2101
|
+
if (node.details?.['missing'] || typeof localName !== 'string') {
|
|
2102
|
+
violations.push({
|
|
2103
|
+
kind: 'missing',
|
|
2104
|
+
id: node.id,
|
|
2105
|
+
label: node.label,
|
|
2106
|
+
tag: String(node.details?.['tag'] ?? ''),
|
|
2107
|
+
callSites: [site],
|
|
2108
|
+
});
|
|
2109
|
+
continue;
|
|
2110
|
+
}
|
|
2111
|
+
const list = byName.get(localName) ?? [];
|
|
2112
|
+
list.push(node);
|
|
2113
|
+
byName.set(localName, list);
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
for (const [name, nodes] of byName) {
|
|
2117
|
+
if (nodes.length <= 1) continue;
|
|
2118
|
+
violations.push({
|
|
2119
|
+
kind: 'duplicate',
|
|
2120
|
+
id: nodes[0]?.id ?? name,
|
|
2121
|
+
label: name,
|
|
2122
|
+
callSites: nodes.map((node) => ({
|
|
2123
|
+
filePath: relativeGraphPath(graph, node.filePath),
|
|
2124
|
+
line: node.line,
|
|
2125
|
+
})),
|
|
2126
|
+
});
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
return violations;
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
export function assertInteractiveElementNamed(graph: DependencyGraph): void {
|
|
2133
|
+
const violations = interactiveElementNamedViolations(graph);
|
|
2134
|
+
if (violations.length === 0) return;
|
|
2135
|
+
throw new Error(
|
|
2136
|
+
violations
|
|
2137
|
+
.map((violation) => {
|
|
2138
|
+
const sites = violation.callSites
|
|
2139
|
+
.map((site) => [site.filePath, site.line].filter(Boolean).join(':'))
|
|
2140
|
+
.filter(Boolean)
|
|
2141
|
+
.join(', ');
|
|
2142
|
+
if (violation.kind === 'non-static') {
|
|
2143
|
+
return `Non-static interactive element name cannot be verified${sites ? ` (${sites})` : ''}.`;
|
|
2144
|
+
}
|
|
2145
|
+
if (violation.kind === 'missing') {
|
|
2146
|
+
return `Interactive ${violation.tag} is missing a literal data-craft-name${sites ? ` (${sites})` : ''}.`;
|
|
2147
|
+
}
|
|
2148
|
+
return `Duplicate data-craft-name "${violation.label}" used twice${sites ? ` (${sites})` : ''}.`;
|
|
2149
|
+
})
|
|
2150
|
+
.join('\n'),
|
|
2151
|
+
);
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
export type ServerFunctionArchitectureDiagnosticCode =
|
|
2155
|
+
| 'CRAFT_SERVER_FUNCTION_CLIENT_FAMILY_MISSING'
|
|
2156
|
+
| 'CRAFT_SERVER_FUNCTION_CLIENT_IMPORTS_SERVER'
|
|
2157
|
+
| 'CRAFT_SERVER_FUNCTION_CLIENT_CONTEXT_REQUIRES_CLIENT_EXPOSURE'
|
|
2158
|
+
| 'CRAFT_SERVER_FUNCTION_HANDSHAKE_NOT_ATTACHED'
|
|
2159
|
+
| 'CRAFT_SERVER_FUNCTION_HANDSHAKE_NOT_EXPECTED'
|
|
2160
|
+
| 'CRAFT_SERVER_FUNCTION_CONTRACT_MISMATCH'
|
|
2161
|
+
| 'CRAFT_SERVER_FUNCTION_DUPLICATE_ID'
|
|
2162
|
+
| 'CRAFT_SERVER_FUNCTION_ORPHAN_CLIENT_FACADE'
|
|
2163
|
+
| 'CRAFT_SERVER_FUNCTION_CLIENT_ID_NOT_UNIQUE'
|
|
2164
|
+
| 'CRAFT_SERVER_FUNCTION_CLIENT_ID_NOT_STATIC'
|
|
2165
|
+
| 'CRAFT_SERVER_FUNCTION_CLIENT_ID_MISMATCH'
|
|
2166
|
+
| 'CRAFT_SERVER_FUNCTION_CLIENT_DEFINITION_MISMATCH'
|
|
2167
|
+
| 'CRAFT_SERVER_FUNCTION_NAMING_CONVENTION_MISSING'
|
|
2168
|
+
| 'CRAFT_SERVER_FUNCTION_MIDDLEWARE_NAMING_CONVENTION_MISSING'
|
|
2169
|
+
| 'CRAFT_SERVER_FUNCTION_MIDDLEWARE_DUPLICATE_ID'
|
|
2170
|
+
| 'CRAFT_SERVER_FUNCTION_MIDDLEWARE_CYCLE'
|
|
2171
|
+
| 'CRAFT_SERVER_FUNCTION_MIDDLEWARE_IMPORTED_BY_CLIENT'
|
|
2172
|
+
| 'CRAFT_SERVER_FUNCTION_CLIENT_MIDDLEWARE_NAMING_CONVENTION_MISSING'
|
|
2173
|
+
| 'CRAFT_SERVER_FUNCTION_CLIENT_MIDDLEWARE_DUPLICATE_ID'
|
|
2174
|
+
| 'CRAFT_SERVER_FUNCTION_CLIENT_MIDDLEWARE_CYCLE'
|
|
2175
|
+
| 'CRAFT_SERVER_FUNCTION_CLIENT_MIDDLEWARE_IMPORTED_BY_SERVER'
|
|
2176
|
+
| 'CRAFT_SERVER_FUNCTION_CLIENT_CONTEXT_UNUSED';
|
|
2177
|
+
|
|
2178
|
+
export type ServerFunctionArchitectureViolation = {
|
|
2179
|
+
readonly code: ServerFunctionArchitectureDiagnosticCode;
|
|
2180
|
+
readonly message: string;
|
|
2181
|
+
readonly family: string;
|
|
2182
|
+
readonly filePath?: string;
|
|
2183
|
+
readonly line?: number;
|
|
2184
|
+
};
|
|
2185
|
+
|
|
2186
|
+
/**
|
|
2187
|
+
* Verifies the source-level server-function family contract. The rule is
|
|
2188
|
+
* intentionally independent of a bundler: a production build must fail while
|
|
2189
|
+
* the offending server import is still visible in the TypeScript graph.
|
|
2190
|
+
*/
|
|
2191
|
+
export function serverFunctionArchitectureViolations(
|
|
2192
|
+
graph: DependencyGraph,
|
|
2193
|
+
): ServerFunctionArchitectureViolation[] {
|
|
2194
|
+
const parts = graph.nodes.filter((node) =>
|
|
2195
|
+
node.kind === 'server-function-contract' ||
|
|
2196
|
+
node.kind === 'server-function-client' ||
|
|
2197
|
+
node.kind === 'server-function-server',
|
|
2198
|
+
);
|
|
2199
|
+
const families = new Map<string, DependencyGraphNode[]>();
|
|
2200
|
+
for (const node of parts) {
|
|
2201
|
+
const family = String(node.details?.['family'] ?? node.label);
|
|
2202
|
+
const entries = families.get(family) ?? [];
|
|
2203
|
+
entries.push(node);
|
|
2204
|
+
families.set(family, entries);
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
const violations: ServerFunctionArchitectureViolation[] = [];
|
|
2208
|
+
const add = (
|
|
2209
|
+
code: ServerFunctionArchitectureDiagnosticCode,
|
|
2210
|
+
message: string,
|
|
2211
|
+
node: DependencyGraphNode,
|
|
2212
|
+
): void => {
|
|
2213
|
+
violations.push({
|
|
2214
|
+
code,
|
|
2215
|
+
message: `${code}: ${message}`,
|
|
2216
|
+
family: String(node.details?.['family'] ?? node.label),
|
|
2217
|
+
filePath: relativeGraphPath(graph, node.filePath),
|
|
2218
|
+
line: node.line,
|
|
2219
|
+
});
|
|
2220
|
+
};
|
|
2221
|
+
|
|
2222
|
+
for (const node of graph.nodes.filter((node) => node.kind === 'server-function-misnamed')) {
|
|
2223
|
+
violations.push({
|
|
2224
|
+
code: 'CRAFT_SERVER_FUNCTION_NAMING_CONVENTION_MISSING',
|
|
2225
|
+
message:
|
|
2226
|
+
'CRAFT_SERVER_FUNCTION_NAMING_CONVENTION_MISSING: serverFunction(...) is defined outside a "*.fn-serveur.ts" file, so the architecture graph cannot verify its client facade, contract, or id wiring. Move it into a "*.fn-serveur.ts" file.',
|
|
2227
|
+
family: String(node.details?.['serverFunctionId'] ?? node.label),
|
|
2228
|
+
filePath: relativeGraphPath(graph, node.filePath),
|
|
2229
|
+
line: node.line,
|
|
2230
|
+
});
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
violations.push(...serverFunctionMiddlewareViolations(graph));
|
|
2234
|
+
violations.push(...clientFunctionMiddlewareViolations(graph));
|
|
2235
|
+
|
|
2236
|
+
const idFamilies = new Map<string, Set<string>>();
|
|
2237
|
+
for (const node of parts) {
|
|
2238
|
+
const id = node.details?.['serverFunctionId'];
|
|
2239
|
+
if (typeof id !== 'string') continue;
|
|
2240
|
+
const family = String(node.details?.['family'] ?? node.label);
|
|
2241
|
+
const entries = idFamilies.get(id) ?? new Set<string>();
|
|
2242
|
+
entries.add(family);
|
|
2243
|
+
idFamilies.set(id, entries);
|
|
2244
|
+
}
|
|
2245
|
+
for (const [id, familyNames] of idFamilies) {
|
|
2246
|
+
if (familyNames.size < 2) continue;
|
|
2247
|
+
const node = parts.find((candidate) => candidate.details?.['serverFunctionId'] === id);
|
|
2248
|
+
if (node) {
|
|
2249
|
+
add(
|
|
2250
|
+
'CRAFT_SERVER_FUNCTION_DUPLICATE_ID',
|
|
2251
|
+
`server function id "${id}" is declared by multiple families (${[...familyNames].sort().join(', ')}).`,
|
|
2252
|
+
node,
|
|
2253
|
+
);
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
for (const [, familyParts] of families) {
|
|
2258
|
+
const contract = familyParts.find((node) => node.kind === 'server-function-contract');
|
|
2259
|
+
const client = familyParts.find((node) => node.kind === 'server-function-client');
|
|
2260
|
+
const server = familyParts.find((node) => node.kind === 'server-function-server');
|
|
2261
|
+
const familyNode = graph.nodes.find(
|
|
2262
|
+
(node) =>
|
|
2263
|
+
node.kind === 'server-function-family' &&
|
|
2264
|
+
node.details?.['family'] === familyParts[0]?.details?.['family'],
|
|
2265
|
+
);
|
|
2266
|
+
const anchor = contract ?? client ?? server ?? familyNode;
|
|
2267
|
+
if (!anchor) continue;
|
|
2268
|
+
const exposure = contract?.details?.['exposure'];
|
|
2269
|
+
const id =
|
|
2270
|
+
contract?.details?.['serverFunctionId'] ??
|
|
2271
|
+
server?.details?.['serverFunctionId'] ??
|
|
2272
|
+
client?.details?.['serverFunctionId'];
|
|
2273
|
+
const hasClientExposure = exposure === 'client';
|
|
2274
|
+
|
|
2275
|
+
if (hasClientExposure && (!client || !server)) {
|
|
2276
|
+
add(
|
|
2277
|
+
'CRAFT_SERVER_FUNCTION_CLIENT_FAMILY_MISSING',
|
|
2278
|
+
`client-exposed family must contain a client facade and a server implementation (found client=${Boolean(client)}, server=${Boolean(server)}).`,
|
|
2279
|
+
anchor,
|
|
2280
|
+
);
|
|
2281
|
+
}
|
|
2282
|
+
if (client && !server) {
|
|
2283
|
+
add(
|
|
2284
|
+
'CRAFT_SERVER_FUNCTION_ORPHAN_CLIENT_FACADE',
|
|
2285
|
+
'client facade has no corresponding server implementation.',
|
|
2286
|
+
client,
|
|
2287
|
+
);
|
|
2288
|
+
}
|
|
2289
|
+
if (client && !contract && !server) {
|
|
2290
|
+
add(
|
|
2291
|
+
'CRAFT_SERVER_FUNCTION_ORPHAN_CLIENT_FACADE',
|
|
2292
|
+
'client facade has no corresponding server implementation.',
|
|
2293
|
+
client,
|
|
2294
|
+
);
|
|
2295
|
+
}
|
|
2296
|
+
if (
|
|
2297
|
+
server?.details?.['declaresClientContext'] === true &&
|
|
2298
|
+
exposure !== 'client'
|
|
2299
|
+
) {
|
|
2300
|
+
add(
|
|
2301
|
+
'CRAFT_SERVER_FUNCTION_CLIENT_CONTEXT_REQUIRES_CLIENT_EXPOSURE',
|
|
2302
|
+
'a declared client context requires a contract with exposure="client": no browser can fill it otherwise.',
|
|
2303
|
+
server,
|
|
2304
|
+
);
|
|
2305
|
+
}
|
|
2306
|
+
for (const violation of familyHandshakeViolations(graph, server, client)) {
|
|
2307
|
+
add(violation.code, violation.reason, violation.node);
|
|
2308
|
+
}
|
|
2309
|
+
if (contract && exposure === 'server' && client) {
|
|
2310
|
+
add(
|
|
2311
|
+
'CRAFT_SERVER_FUNCTION_CONTRACT_MISMATCH',
|
|
2312
|
+
'server-only contract cannot have a client facade.',
|
|
2313
|
+
contract,
|
|
2314
|
+
);
|
|
2315
|
+
}
|
|
2316
|
+
if (contract && server && id !== undefined && server.details?.['serverFunctionId'] !== id) {
|
|
2317
|
+
add(
|
|
2318
|
+
'CRAFT_SERVER_FUNCTION_CONTRACT_MISMATCH',
|
|
2319
|
+
`server implementation id does not match contract id "${id}".`,
|
|
2320
|
+
server,
|
|
2321
|
+
);
|
|
2322
|
+
}
|
|
2323
|
+
if (client && contract && client.details?.['contractFamily'] !== client.details?.['family']) {
|
|
2324
|
+
add(
|
|
2325
|
+
'CRAFT_SERVER_FUNCTION_CONTRACT_MISMATCH',
|
|
2326
|
+
'client facade does not use the contract from its own family.',
|
|
2327
|
+
client,
|
|
2328
|
+
);
|
|
2329
|
+
}
|
|
2330
|
+
if (
|
|
2331
|
+
server &&
|
|
2332
|
+
contract &&
|
|
2333
|
+
(server.details?.['contractFamily'] !== server.details?.['family'] ||
|
|
2334
|
+
(exposure === 'client' && server.details?.['contractFamily'] === undefined))
|
|
2335
|
+
) {
|
|
2336
|
+
add(
|
|
2337
|
+
'CRAFT_SERVER_FUNCTION_CONTRACT_MISMATCH',
|
|
2338
|
+
'server implementation does not use the contract from its own family.',
|
|
2339
|
+
server,
|
|
2340
|
+
);
|
|
2341
|
+
}
|
|
2342
|
+
if (contract && !contract.details?.['serverFunctionId']) {
|
|
2343
|
+
add(
|
|
2344
|
+
'CRAFT_SERVER_FUNCTION_CONTRACT_MISMATCH',
|
|
2345
|
+
'contract must declare a stable non-empty id.',
|
|
2346
|
+
contract,
|
|
2347
|
+
);
|
|
2348
|
+
}
|
|
2349
|
+
if (client && contract && client.details?.['contractFamily'] === undefined) {
|
|
2350
|
+
add(
|
|
2351
|
+
'CRAFT_SERVER_FUNCTION_CONTRACT_MISMATCH',
|
|
2352
|
+
'client facade must use the contract from its own family.',
|
|
2353
|
+
client,
|
|
2354
|
+
);
|
|
2355
|
+
}
|
|
2356
|
+
if (client && !contract && server) {
|
|
2357
|
+
if (client.details?.['contractFamily'] === undefined) {
|
|
2358
|
+
add(
|
|
2359
|
+
'CRAFT_SERVER_FUNCTION_CONTRACT_MISMATCH',
|
|
2360
|
+
'client facade must import the server function type from its own family.',
|
|
2361
|
+
client,
|
|
2362
|
+
);
|
|
2363
|
+
}
|
|
2364
|
+
if (
|
|
2365
|
+
id !== undefined &&
|
|
2366
|
+
server.details?.['serverFunctionId'] !== id
|
|
2367
|
+
) {
|
|
2368
|
+
add(
|
|
2369
|
+
'CRAFT_SERVER_FUNCTION_CONTRACT_MISMATCH',
|
|
2370
|
+
`client facade id does not match server implementation id "${server.details?.['serverFunctionId'] ?? ''}".`,
|
|
2371
|
+
client,
|
|
2372
|
+
);
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
if (client && client.details?.['usesCraftUnique'] !== true) {
|
|
2376
|
+
add(
|
|
2377
|
+
'CRAFT_SERVER_FUNCTION_CLIENT_ID_NOT_UNIQUE',
|
|
2378
|
+
'client facade id must be wrapped in craftUnique(...).',
|
|
2379
|
+
client,
|
|
2380
|
+
);
|
|
2381
|
+
}
|
|
2382
|
+
if (client && client.details?.['clientIdentityStatic'] === false) {
|
|
2383
|
+
add(
|
|
2384
|
+
'CRAFT_SERVER_FUNCTION_CLIENT_ID_NOT_STATIC',
|
|
2385
|
+
'client facade craftUnique(...) id must be a static string literal.',
|
|
2386
|
+
client,
|
|
2387
|
+
);
|
|
2388
|
+
}
|
|
2389
|
+
if (
|
|
2390
|
+
client &&
|
|
2391
|
+
client.details?.['clientDefinitionFile'] !== undefined &&
|
|
2392
|
+
client.details?.['clientDefinitionFile'] !==
|
|
2393
|
+
`${String(client.details?.['family'])}.fn-serveur.ts`
|
|
2394
|
+
) {
|
|
2395
|
+
add(
|
|
2396
|
+
'CRAFT_SERVER_FUNCTION_CLIENT_DEFINITION_MISMATCH',
|
|
2397
|
+
'client facade type definition does not belong to its server-function family.',
|
|
2398
|
+
client,
|
|
2399
|
+
);
|
|
2400
|
+
}
|
|
2401
|
+
if (
|
|
2402
|
+
client &&
|
|
2403
|
+
server &&
|
|
2404
|
+
client.details?.['serverFunctionId'] !== undefined &&
|
|
2405
|
+
server.details?.['serverFunctionId'] !== undefined &&
|
|
2406
|
+
client.details?.['serverFunctionId'] !==
|
|
2407
|
+
server.details?.['serverFunctionId']
|
|
2408
|
+
) {
|
|
2409
|
+
add(
|
|
2410
|
+
'CRAFT_SERVER_FUNCTION_CLIENT_ID_MISMATCH',
|
|
2411
|
+
`client facade id does not match server implementation id "${String(server.details?.['serverFunctionId'])}".`,
|
|
2412
|
+
client,
|
|
2413
|
+
);
|
|
2414
|
+
}
|
|
2415
|
+
if (client?.details?.['runtimeServerImports']) {
|
|
2416
|
+
add(
|
|
2417
|
+
'CRAFT_SERVER_FUNCTION_CLIENT_IMPORTS_SERVER',
|
|
2418
|
+
'client facade has a runtime import of *.fn-serveur.ts.',
|
|
2419
|
+
client,
|
|
2420
|
+
);
|
|
2421
|
+
}
|
|
2422
|
+
if (server?.details?.['runtimeClientImports']) {
|
|
2423
|
+
add(
|
|
2424
|
+
'CRAFT_SERVER_FUNCTION_CONTRACT_MISMATCH',
|
|
2425
|
+
'server implementation has a runtime import of *.fn-client.ts.',
|
|
2426
|
+
server,
|
|
2427
|
+
);
|
|
2428
|
+
}
|
|
2429
|
+
if (contract?.details?.['importsServerOnly']) {
|
|
2430
|
+
add(
|
|
2431
|
+
'CRAFT_SERVER_FUNCTION_CONTRACT_MISMATCH',
|
|
2432
|
+
'shared contract imports a server-only module.',
|
|
2433
|
+
contract,
|
|
2434
|
+
);
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
return violations;
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2440
|
+
/**
|
|
2441
|
+
* Vérifie qu'un handshake exigé par la chaîne **serveur** d'une famille est
|
|
2442
|
+
* bien rempli par la chaîne **client** de la *même* famille, et l'inverse.
|
|
2443
|
+
*
|
|
2444
|
+
* `assertCraftHandshake` répond à la question globale — « ce nom a-t-il un
|
|
2445
|
+
* pendant quelque part ? ». Celle-ci répond à la seule qui compte à l'appel :
|
|
2446
|
+
* cette server function-là reçoit-elle ce qu'elle attend ? Un handshake tenu à
|
|
2447
|
+
* l'autre bout du dépôt, mais pas attaché sur cette façade, laisserait la
|
|
2448
|
+
* fonction échouer à l'exécution.
|
|
2449
|
+
*/
|
|
2450
|
+
function familyHandshakeViolations(
|
|
2451
|
+
graph: DependencyGraph,
|
|
2452
|
+
server: DependencyGraphNode | undefined,
|
|
2453
|
+
client: DependencyGraphNode | undefined,
|
|
2454
|
+
): {
|
|
2455
|
+
code: ServerFunctionArchitectureDiagnosticCode;
|
|
2456
|
+
reason: string;
|
|
2457
|
+
node: DependencyGraphNode;
|
|
2458
|
+
}[] {
|
|
2459
|
+
if (!server || !client) return [];
|
|
2460
|
+
|
|
2461
|
+
const serverFiles = reachableFiles(graph, server, 'middleware-uses');
|
|
2462
|
+
const clientFiles = new Set([
|
|
2463
|
+
...reachableFiles(graph, client, 'client-middleware-attached'),
|
|
2464
|
+
...reachableFiles(graph, client, 'client-middleware-uses'),
|
|
2465
|
+
]);
|
|
2466
|
+
// Les dépendances d'un middleware client attaché comptent aussi : la chaîne
|
|
2467
|
+
// est aplatie à l'exécution, elle doit l'être ici.
|
|
2468
|
+
for (const file of [...clientFiles]) {
|
|
2469
|
+
for (const node of graph.nodes) {
|
|
2470
|
+
if (node.kind !== 'client-function-middleware') continue;
|
|
2471
|
+
if (relativeGraphPath(graph, node.filePath) !== file) continue;
|
|
2472
|
+
for (const reached of reachableFiles(graph, node, 'client-middleware-uses')) {
|
|
2473
|
+
clientFiles.add(reached);
|
|
2474
|
+
}
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
const violations: {
|
|
2479
|
+
code: ServerFunctionArchitectureDiagnosticCode;
|
|
2480
|
+
reason: string;
|
|
2481
|
+
node: DependencyGraphNode;
|
|
2482
|
+
}[] = [];
|
|
2483
|
+
|
|
2484
|
+
for (const handshake of graph.nodes.filter((node) => node.kind === 'handshake')) {
|
|
2485
|
+
const name = handshake.details?.['handshakeName'];
|
|
2486
|
+
if (typeof name !== 'string') continue;
|
|
2487
|
+
const declaredBy = sitesOf(handshake, 'serverSites');
|
|
2488
|
+
const filledBy = sitesOf(handshake, 'clientSites');
|
|
2489
|
+
const expected = declaredBy.some((file) => serverFiles.has(file));
|
|
2490
|
+
const attached = filledBy.some((file) => clientFiles.has(file));
|
|
2491
|
+
|
|
2492
|
+
if (expected && !attached) {
|
|
2493
|
+
violations.push({
|
|
2494
|
+
code: 'CRAFT_SERVER_FUNCTION_HANDSHAKE_NOT_ATTACHED',
|
|
2495
|
+
reason: `server chain expects handshake "${name}", but the client facade attaches nothing that fills it. Add its client middleware with .pipe(craftClientMiddleware(...)).`,
|
|
2496
|
+
node: client,
|
|
2497
|
+
});
|
|
2498
|
+
} else if (attached && !expected) {
|
|
2499
|
+
violations.push({
|
|
2500
|
+
code: 'CRAFT_SERVER_FUNCTION_HANDSHAKE_NOT_EXPECTED',
|
|
2501
|
+
reason: `client facade attaches handshake "${name}", but no middleware of the server chain expects it. The value would travel and be dropped.`,
|
|
2502
|
+
node: client,
|
|
2503
|
+
});
|
|
2504
|
+
}
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
return violations;
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
function sitesOf(node: DependencyGraphNode, key: string): string[] {
|
|
2511
|
+
const sites = node.details?.[key];
|
|
2512
|
+
return Array.isArray(sites) ? sites.map((site) => String(site)) : [];
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
/** Fichiers atteints depuis un nœud en suivant une frontière d'arêtes. */
|
|
2516
|
+
function reachableFiles(
|
|
2517
|
+
graph: DependencyGraph,
|
|
2518
|
+
from: DependencyGraphNode,
|
|
2519
|
+
boundary: string,
|
|
2520
|
+
): Set<string> {
|
|
2521
|
+
const byId = new Map(graph.nodes.map((node) => [node.id, node]));
|
|
2522
|
+
const files = new Set<string>();
|
|
2523
|
+
const seen = new Set<string>();
|
|
2524
|
+
const visit = (nodeId: string): void => {
|
|
2525
|
+
if (seen.has(nodeId)) return;
|
|
2526
|
+
seen.add(nodeId);
|
|
2527
|
+
const node = byId.get(nodeId);
|
|
2528
|
+
const file = node && relativeGraphPath(graph, node.filePath);
|
|
2529
|
+
if (file) files.add(file);
|
|
2530
|
+
for (const edge of graph.edges) {
|
|
2531
|
+
if (edge.from !== nodeId) continue;
|
|
2532
|
+
if (edge.details?.['boundary'] !== boundary) continue;
|
|
2533
|
+
visit(edge.to);
|
|
2534
|
+
}
|
|
2535
|
+
};
|
|
2536
|
+
visit(from.id);
|
|
2537
|
+
return files;
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
/**
|
|
2541
|
+
* Middleware rules. A middleware carries authorization and request context, so
|
|
2542
|
+
* an ambiguous identity or a browser-reachable implementation is a boundary
|
|
2543
|
+
* defect, not a style issue.
|
|
2544
|
+
*/
|
|
2545
|
+
function serverFunctionMiddlewareViolations(
|
|
2546
|
+
graph: DependencyGraph,
|
|
2547
|
+
): ServerFunctionArchitectureViolation[] {
|
|
2548
|
+
const violations: ServerFunctionArchitectureViolation[] = [];
|
|
2549
|
+
const middlewares = graph.nodes.filter(
|
|
2550
|
+
(node) => node.kind === 'server-function-middleware',
|
|
2551
|
+
);
|
|
2552
|
+
|
|
2553
|
+
for (const node of graph.nodes.filter(
|
|
2554
|
+
(node) => node.kind === 'server-function-middleware-misnamed',
|
|
2555
|
+
)) {
|
|
2556
|
+
violations.push({
|
|
2557
|
+
code: 'CRAFT_SERVER_FUNCTION_MIDDLEWARE_NAMING_CONVENTION_MISSING',
|
|
2558
|
+
message:
|
|
2559
|
+
'CRAFT_SERVER_FUNCTION_MIDDLEWARE_NAMING_CONVENTION_MISSING: craftMiddleware(...) is defined outside a "*.mw-serveur.ts" file, so the architecture graph cannot keep it away from the client bundle. Move it into a "*.mw-serveur.ts" file.',
|
|
2560
|
+
family: String(node.details?.['middlewareId'] ?? node.label),
|
|
2561
|
+
filePath: relativeGraphPath(graph, node.filePath),
|
|
2562
|
+
line: node.line,
|
|
2563
|
+
});
|
|
2564
|
+
}
|
|
2565
|
+
|
|
2566
|
+
const byId = new Map<string, DependencyGraphNode[]>();
|
|
2567
|
+
for (const node of middlewares) {
|
|
2568
|
+
const id = node.details?.['middlewareId'];
|
|
2569
|
+
if (typeof id !== 'string') continue;
|
|
2570
|
+
byId.set(id, [...(byId.get(id) ?? []), node]);
|
|
2571
|
+
}
|
|
2572
|
+
for (const [id, nodes] of byId) {
|
|
2573
|
+
if (nodes.length < 2) continue;
|
|
2574
|
+
const node = nodes[0];
|
|
2575
|
+
if (!node) continue;
|
|
2576
|
+
violations.push({
|
|
2577
|
+
code: 'CRAFT_SERVER_FUNCTION_MIDDLEWARE_DUPLICATE_ID',
|
|
2578
|
+
message: `CRAFT_SERVER_FUNCTION_MIDDLEWARE_DUPLICATE_ID: middleware id "${id}" is declared ${nodes.length} times (${nodes
|
|
2579
|
+
.map((candidate) => relativeGraphPath(graph, candidate.filePath) ?? '?')
|
|
2580
|
+
.sort()
|
|
2581
|
+
.join(', ')}). The registry deduplicates by id, so two implementations sharing one id would type-check and run wrong.`,
|
|
2582
|
+
family: id,
|
|
2583
|
+
filePath: relativeGraphPath(graph, node.filePath),
|
|
2584
|
+
line: node.line,
|
|
2585
|
+
});
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
for (const cycle of middlewareCycles(graph, middlewares)) {
|
|
2589
|
+
const node = middlewares.find((candidate) => candidate.id === cycle[0]);
|
|
2590
|
+
if (!node) continue;
|
|
2591
|
+
violations.push({
|
|
2592
|
+
code: 'CRAFT_SERVER_FUNCTION_MIDDLEWARE_CYCLE',
|
|
2593
|
+
message: `CRAFT_SERVER_FUNCTION_MIDDLEWARE_CYCLE: middleware .use(...) dependencies form a cycle (${cycle
|
|
2594
|
+
.map((id) => middlewareLabel(middlewares, id))
|
|
2595
|
+
.join(' -> ')}).`,
|
|
2596
|
+
family: middlewareLabel(middlewares, cycle[0] ?? ''),
|
|
2597
|
+
filePath: relativeGraphPath(graph, node.filePath),
|
|
2598
|
+
line: node.line,
|
|
2599
|
+
});
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2602
|
+
for (const node of graph.nodes.filter(
|
|
2603
|
+
(node) => node.kind === 'server-function-client',
|
|
2604
|
+
)) {
|
|
2605
|
+
const imported = node.details?.['runtimeMiddlewareImports'];
|
|
2606
|
+
if (!Array.isArray(imported) || imported.length === 0) continue;
|
|
2607
|
+
violations.push({
|
|
2608
|
+
code: 'CRAFT_SERVER_FUNCTION_MIDDLEWARE_IMPORTED_BY_CLIENT',
|
|
2609
|
+
message: `CRAFT_SERVER_FUNCTION_MIDDLEWARE_IMPORTED_BY_CLIENT: client facade imports server middleware at runtime (${imported.join(', ')}). Import only the server function type from the client side.`,
|
|
2610
|
+
family: String(node.details?.['family'] ?? node.label),
|
|
2611
|
+
filePath: relativeGraphPath(graph, node.filePath),
|
|
2612
|
+
line: node.line,
|
|
2613
|
+
});
|
|
2614
|
+
}
|
|
2615
|
+
|
|
2616
|
+
return violations;
|
|
2617
|
+
}
|
|
2618
|
+
|
|
2619
|
+
/**
|
|
2620
|
+
* Règles miroir côté client. Un middleware client publie une **déclaration**,
|
|
2621
|
+
* pas une donnée de confiance ; l'enjeu de frontière est donc l'inverse de
|
|
2622
|
+
* celui du serveur : il ne doit jamais être tiré dans un module serveur, où sa
|
|
2623
|
+
* présence laisserait croire que la valeur vient de la session.
|
|
2624
|
+
*/
|
|
2625
|
+
function clientFunctionMiddlewareViolations(
|
|
2626
|
+
graph: DependencyGraph,
|
|
2627
|
+
): ServerFunctionArchitectureViolation[] {
|
|
2628
|
+
const violations: ServerFunctionArchitectureViolation[] = [];
|
|
2629
|
+
const middlewares = graph.nodes.filter(
|
|
2630
|
+
(node) => node.kind === 'client-function-middleware',
|
|
2631
|
+
);
|
|
2632
|
+
|
|
2633
|
+
for (const node of graph.nodes.filter(
|
|
2634
|
+
(node) => node.kind === 'client-function-middleware-misnamed',
|
|
2635
|
+
)) {
|
|
2636
|
+
violations.push({
|
|
2637
|
+
code: 'CRAFT_SERVER_FUNCTION_CLIENT_MIDDLEWARE_NAMING_CONVENTION_MISSING',
|
|
2638
|
+
message:
|
|
2639
|
+
'CRAFT_SERVER_FUNCTION_CLIENT_MIDDLEWARE_NAMING_CONVENTION_MISSING: craftMiddleware(...).client(...) is defined outside a "*.mw-client.ts" or "*.fn-client.ts" file, so the architecture graph cannot tell it apart from a server middleware. Move it next to the facade that uses it, or into a shared "*.mw-client.ts" file.',
|
|
2640
|
+
family: String(node.details?.['middlewareId'] ?? node.label),
|
|
2641
|
+
filePath: relativeGraphPath(graph, node.filePath),
|
|
2642
|
+
line: node.line,
|
|
2643
|
+
});
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
const byId = new Map<string, DependencyGraphNode[]>();
|
|
2647
|
+
for (const node of middlewares) {
|
|
2648
|
+
const id = node.details?.['middlewareId'];
|
|
2649
|
+
if (typeof id !== 'string') continue;
|
|
2650
|
+
byId.set(id, [...(byId.get(id) ?? []), node]);
|
|
2651
|
+
}
|
|
2652
|
+
for (const [id, nodes] of byId) {
|
|
2653
|
+
if (nodes.length < 2) continue;
|
|
2654
|
+
const node = nodes[0];
|
|
2655
|
+
if (!node) continue;
|
|
2656
|
+
violations.push({
|
|
2657
|
+
code: 'CRAFT_SERVER_FUNCTION_CLIENT_MIDDLEWARE_DUPLICATE_ID',
|
|
2658
|
+
message: `CRAFT_SERVER_FUNCTION_CLIENT_MIDDLEWARE_DUPLICATE_ID: client middleware id "${id}" is declared ${nodes.length} times (${nodes
|
|
2659
|
+
.map((candidate) => relativeGraphPath(graph, candidate.filePath) ?? '?')
|
|
2660
|
+
.sort()
|
|
2661
|
+
.join(', ')}). The chain deduplicates by id, so two implementations sharing one id would type-check and run wrong.`,
|
|
2662
|
+
family: id,
|
|
2663
|
+
filePath: relativeGraphPath(graph, node.filePath),
|
|
2664
|
+
line: node.line,
|
|
2665
|
+
});
|
|
2666
|
+
}
|
|
2667
|
+
|
|
2668
|
+
for (const cycle of middlewareCycles(
|
|
2669
|
+
graph,
|
|
2670
|
+
middlewares,
|
|
2671
|
+
'client-middleware-uses',
|
|
2672
|
+
)) {
|
|
2673
|
+
const node = middlewares.find((candidate) => candidate.id === cycle[0]);
|
|
2674
|
+
if (!node) continue;
|
|
2675
|
+
violations.push({
|
|
2676
|
+
code: 'CRAFT_SERVER_FUNCTION_CLIENT_MIDDLEWARE_CYCLE',
|
|
2677
|
+
message: `CRAFT_SERVER_FUNCTION_CLIENT_MIDDLEWARE_CYCLE: client middleware .use(...) dependencies form a cycle (${cycle
|
|
2678
|
+
.map((id) => middlewareLabel(middlewares, id))
|
|
2679
|
+
.join(' -> ')}).`,
|
|
2680
|
+
family: middlewareLabel(middlewares, cycle[0] ?? ''),
|
|
2681
|
+
filePath: relativeGraphPath(graph, node.filePath),
|
|
2682
|
+
line: node.line,
|
|
2683
|
+
});
|
|
2684
|
+
}
|
|
2685
|
+
|
|
2686
|
+
for (const node of graph.nodes.filter(
|
|
2687
|
+
(node) =>
|
|
2688
|
+
node.kind === 'server-function-server' ||
|
|
2689
|
+
node.kind === 'server-function-middleware',
|
|
2690
|
+
)) {
|
|
2691
|
+
const imported = node.details?.['runtimeClientMiddlewareImports'];
|
|
2692
|
+
if (!Array.isArray(imported) || imported.length === 0) continue;
|
|
2693
|
+
violations.push({
|
|
2694
|
+
code: 'CRAFT_SERVER_FUNCTION_CLIENT_MIDDLEWARE_IMPORTED_BY_SERVER',
|
|
2695
|
+
message: `CRAFT_SERVER_FUNCTION_CLIENT_MIDDLEWARE_IMPORTED_BY_SERVER: a server module imports client middleware at runtime (${imported.join(', ')}). A client middleware only declares what the browser sends; the server must revalidate it through its own schema, never run it.`,
|
|
2696
|
+
family: String(
|
|
2697
|
+
node.details?.['middlewareId'] ?? node.details?.['family'] ?? node.label,
|
|
2698
|
+
),
|
|
2699
|
+
filePath: relativeGraphPath(graph, node.filePath),
|
|
2700
|
+
line: node.line,
|
|
2701
|
+
});
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2704
|
+
for (const node of middlewares) {
|
|
2705
|
+
const unused = node.details?.['unusedClientContextKeys'];
|
|
2706
|
+
if (!Array.isArray(unused) || unused.length === 0) continue;
|
|
2707
|
+
violations.push({
|
|
2708
|
+
code: 'CRAFT_SERVER_FUNCTION_CLIENT_CONTEXT_UNUSED',
|
|
2709
|
+
message: `CRAFT_SERVER_FUNCTION_CLIENT_CONTEXT_UNUSED: client middleware "${node.details?.['middlewareId'] ?? node.label}" publishes ${unused
|
|
2710
|
+
.map((key) => `"${String(key)}"`)
|
|
2711
|
+
.join(', ')}, and no server module reads any of them through context.clientContext. This is a heuristic — a read behind an alias is invisible to it — but a context nobody reads is usually a leftover.`,
|
|
2712
|
+
family: String(node.details?.['middlewareId'] ?? node.label),
|
|
2713
|
+
filePath: relativeGraphPath(graph, node.filePath),
|
|
2714
|
+
line: node.line,
|
|
2715
|
+
});
|
|
2716
|
+
}
|
|
2717
|
+
|
|
2718
|
+
return violations;
|
|
2719
|
+
}
|
|
2720
|
+
|
|
2721
|
+
function middlewareLabel(
|
|
2722
|
+
middlewares: readonly DependencyGraphNode[],
|
|
2723
|
+
nodeId: string,
|
|
2724
|
+
): string {
|
|
2725
|
+
const node = middlewares.find((candidate) => candidate.id === nodeId);
|
|
2726
|
+
return String(node?.details?.['middlewareId'] ?? node?.label ?? nodeId);
|
|
2727
|
+
}
|
|
2728
|
+
|
|
2729
|
+
function middlewareCycles(
|
|
2730
|
+
graph: DependencyGraph,
|
|
2731
|
+
middlewares: readonly DependencyGraphNode[],
|
|
2732
|
+
boundary = 'middleware-uses',
|
|
2733
|
+
): string[][] {
|
|
2734
|
+
const known = new Set(middlewares.map((node) => node.id));
|
|
2735
|
+
const targets = new Map<string, string[]>();
|
|
2736
|
+
for (const edge of graph.edges) {
|
|
2737
|
+
if (edge.details?.['boundary'] !== boundary) continue;
|
|
2738
|
+
if (!known.has(edge.from) || !known.has(edge.to)) continue;
|
|
2739
|
+
targets.set(edge.from, [...(targets.get(edge.from) ?? []), edge.to]);
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2742
|
+
const cycles: string[][] = [];
|
|
2743
|
+
const seen = new Set<string>();
|
|
2744
|
+
const stack: string[] = [];
|
|
2745
|
+
const onStack = new Set<string>();
|
|
2746
|
+
|
|
2747
|
+
const visit = (nodeId: string): void => {
|
|
2748
|
+
if (onStack.has(nodeId)) {
|
|
2749
|
+
cycles.push([...stack.slice(stack.indexOf(nodeId)), nodeId]);
|
|
2750
|
+
return;
|
|
2751
|
+
}
|
|
2752
|
+
if (seen.has(nodeId)) return;
|
|
2753
|
+
seen.add(nodeId);
|
|
2754
|
+
stack.push(nodeId);
|
|
2755
|
+
onStack.add(nodeId);
|
|
2756
|
+
for (const target of targets.get(nodeId) ?? []) visit(target);
|
|
2757
|
+
stack.pop();
|
|
2758
|
+
onStack.delete(nodeId);
|
|
2759
|
+
};
|
|
2760
|
+
|
|
2761
|
+
for (const node of middlewares) visit(node.id);
|
|
2762
|
+
return cycles;
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2765
|
+
export function assertServerFunctionArchitecture(graph: DependencyGraph): void {
|
|
2766
|
+
const violations = serverFunctionArchitectureViolations(graph);
|
|
2767
|
+
if (violations.length === 0) return;
|
|
2768
|
+
throw new Error(violations.map((violation) => violation.message).join('\n'));
|
|
2769
|
+
}
|
|
2770
|
+
|
|
2771
|
+
export type ArchitectureCheckTarget = 'development' | 'production';
|
|
2772
|
+
|
|
2773
|
+
export function assertArchitecture(
|
|
2774
|
+
graph: DependencyGraph,
|
|
2775
|
+
options: { readonly target?: ArchitectureCheckTarget } = {},
|
|
2776
|
+
): void {
|
|
2777
|
+
const target = options.target ?? 'development';
|
|
2778
|
+
// Keep the target explicit even while development and production share the
|
|
2779
|
+
// source-level rules. Production callers use this function as the mandatory
|
|
2780
|
+
// gate immediately before the production build.
|
|
2781
|
+
if (target !== 'development' && target !== 'production') {
|
|
2782
|
+
throw new Error(`Unknown architecture check target "${target}".`);
|
|
2783
|
+
}
|
|
2784
|
+
assertDeclarativeArchitecture(graph);
|
|
2785
|
+
}
|
|
2786
|
+
|
|
2787
|
+
export function assertDeclarativeArchitecture(
|
|
2788
|
+
graph: DependencyGraph,
|
|
2789
|
+
options: MutationReactOnOptions = {},
|
|
2790
|
+
): void {
|
|
2791
|
+
const messages: string[] = [];
|
|
2792
|
+
for (const assert of [
|
|
2793
|
+
assertCraftUnique,
|
|
2794
|
+
assertHttpEndpointUnique,
|
|
2795
|
+
assertCraftComputedPure,
|
|
2796
|
+
assertNoDependencyCycles,
|
|
2797
|
+
assertServerFunctionArchitecture,
|
|
2798
|
+
]) {
|
|
2799
|
+
try {
|
|
2800
|
+
assert(graph);
|
|
2801
|
+
} catch (error) {
|
|
2802
|
+
messages.push(error instanceof Error ? error.message : String(error));
|
|
2803
|
+
}
|
|
2804
|
+
}
|
|
2805
|
+
try {
|
|
2806
|
+
assertMutationHasReactOn(graph, options);
|
|
2807
|
+
} catch (error) {
|
|
2808
|
+
messages.push(error instanceof Error ? error.message : String(error));
|
|
2809
|
+
}
|
|
2810
|
+
if (messages.length === 0) return;
|
|
2811
|
+
throw new Error(messages.join('\n'));
|
|
2812
|
+
}
|
|
2813
|
+
|
|
2814
|
+
function escapeRegex(value: string): string {
|
|
2815
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
2816
|
+
}
|
|
2817
|
+
|
|
2818
|
+
function globToRegExp(
|
|
2819
|
+
pattern: string,
|
|
2820
|
+
captures: Readonly<Record<string, string>> = {},
|
|
2821
|
+
): RegExp {
|
|
2822
|
+
let source = '^';
|
|
2823
|
+
let i = 0;
|
|
2824
|
+
while (i < pattern.length) {
|
|
2825
|
+
if (pattern.startsWith('**/', i)) {
|
|
2826
|
+
source += '(?:.*/)?';
|
|
2827
|
+
i += 3;
|
|
2828
|
+
continue;
|
|
2829
|
+
}
|
|
2830
|
+
if (pattern.startsWith('**', i)) {
|
|
2831
|
+
source += '.*';
|
|
2832
|
+
i += 2;
|
|
2833
|
+
continue;
|
|
2834
|
+
}
|
|
2835
|
+
if (pattern[i] === '*') {
|
|
2836
|
+
source += '[^/]*';
|
|
2837
|
+
i += 1;
|
|
2838
|
+
continue;
|
|
2839
|
+
}
|
|
2840
|
+
if (pattern[i] === ':') {
|
|
2841
|
+
const nameMatch = /^:([A-Za-z_][A-Za-z0-9_]*)/.exec(pattern.slice(i));
|
|
2842
|
+
if (!nameMatch) {
|
|
2843
|
+
source += ':';
|
|
2844
|
+
i += 1;
|
|
2845
|
+
continue;
|
|
2846
|
+
}
|
|
2847
|
+
const name = nameMatch[1] ?? '';
|
|
2848
|
+
if (name in captures) {
|
|
2849
|
+
source += escapeRegex(captures[name] ?? '');
|
|
2850
|
+
} else {
|
|
2851
|
+
source += `(?<${name}>[^/]+)`;
|
|
2852
|
+
}
|
|
2853
|
+
i += nameMatch[0].length;
|
|
2854
|
+
continue;
|
|
2855
|
+
}
|
|
2856
|
+
source += escapeRegex(pattern[i] ?? '');
|
|
2857
|
+
i += 1;
|
|
2858
|
+
}
|
|
2859
|
+
source += '$';
|
|
2860
|
+
return new RegExp(source);
|
|
2861
|
+
}
|
|
2862
|
+
|
|
2863
|
+
function matchPathGlob(
|
|
2864
|
+
pattern: string,
|
|
2865
|
+
path: string,
|
|
2866
|
+
captures: Readonly<Record<string, string>> = {},
|
|
2867
|
+
): Record<string, string> | null {
|
|
2868
|
+
const match = globToRegExp(pattern, captures).exec(path);
|
|
2869
|
+
if (!match) return null;
|
|
2870
|
+
return { ...captures, ...(match.groups ?? {}) };
|
|
2871
|
+
}
|
|
2872
|
+
|
|
2873
|
+
function stronglyConnectedComponents(
|
|
2874
|
+
adjacency: Map<string, string[]>,
|
|
2875
|
+
): string[][] {
|
|
2876
|
+
let index = 0;
|
|
2877
|
+
const indices = new Map<string, number>();
|
|
2878
|
+
const lowlink = new Map<string, number>();
|
|
2879
|
+
const stack: string[] = [];
|
|
2880
|
+
const onStack = new Set<string>();
|
|
2881
|
+
const components: string[][] = [];
|
|
2882
|
+
|
|
2883
|
+
const connect = (id: string): void => {
|
|
2884
|
+
indices.set(id, index);
|
|
2885
|
+
lowlink.set(id, index);
|
|
2886
|
+
index += 1;
|
|
2887
|
+
stack.push(id);
|
|
2888
|
+
onStack.add(id);
|
|
2889
|
+
|
|
2890
|
+
for (const neighbor of adjacency.get(id) ?? []) {
|
|
2891
|
+
if (!indices.has(neighbor)) {
|
|
2892
|
+
connect(neighbor);
|
|
2893
|
+
lowlink.set(
|
|
2894
|
+
id,
|
|
2895
|
+
Math.min(lowlink.get(id) ?? 0, lowlink.get(neighbor) ?? 0),
|
|
2896
|
+
);
|
|
2897
|
+
} else if (onStack.has(neighbor)) {
|
|
2898
|
+
lowlink.set(
|
|
2899
|
+
id,
|
|
2900
|
+
Math.min(lowlink.get(id) ?? 0, indices.get(neighbor) ?? 0),
|
|
2901
|
+
);
|
|
2902
|
+
}
|
|
2903
|
+
}
|
|
2904
|
+
|
|
2905
|
+
if (lowlink.get(id) !== indices.get(id)) return;
|
|
2906
|
+
const component: string[] = [];
|
|
2907
|
+
let node: string;
|
|
2908
|
+
do {
|
|
2909
|
+
node = stack.pop() ?? id;
|
|
2910
|
+
onStack.delete(node);
|
|
2911
|
+
component.push(node);
|
|
2912
|
+
} while (node !== id);
|
|
2913
|
+
components.push(component);
|
|
2914
|
+
};
|
|
2915
|
+
|
|
2916
|
+
for (const id of adjacency.keys()) {
|
|
2917
|
+
if (!indices.has(id)) connect(id);
|
|
2918
|
+
}
|
|
2919
|
+
return components;
|
|
2920
|
+
}
|
|
2921
|
+
|
|
2922
|
+
function cyclePath(
|
|
2923
|
+
component: string[],
|
|
2924
|
+
adjacency: Map<string, string[]>,
|
|
2925
|
+
): string[] {
|
|
2926
|
+
const allowed = new Set(component);
|
|
2927
|
+
if (component.length === 1) {
|
|
2928
|
+
const id = component[0];
|
|
2929
|
+
return (adjacency.get(id) ?? []).includes(id) ? [id, id] : [id];
|
|
2930
|
+
}
|
|
2931
|
+
|
|
2932
|
+
const start = component[0];
|
|
2933
|
+
const path: string[] = [];
|
|
2934
|
+
const visit = (id: string): boolean => {
|
|
2935
|
+
path.push(id);
|
|
2936
|
+
for (const next of adjacency.get(id) ?? []) {
|
|
2937
|
+
if (!allowed.has(next)) continue;
|
|
2938
|
+
if (next === start && path.length > 1) {
|
|
2939
|
+
path.push(start);
|
|
2940
|
+
return true;
|
|
2941
|
+
}
|
|
2942
|
+
if (!path.includes(next) && visit(next)) return true;
|
|
2943
|
+
}
|
|
2944
|
+
path.pop();
|
|
2945
|
+
return false;
|
|
2946
|
+
};
|
|
2947
|
+
return visit(start) ? path : component;
|
|
2948
|
+
}
|
|
2949
|
+
|
|
2950
|
+
function graphOf(
|
|
2951
|
+
node: ArchitectureNodeView<ArchitectureCatalog>,
|
|
2952
|
+
): DependencyGraph {
|
|
2953
|
+
const graph = graphByNode.get(node);
|
|
2954
|
+
if (!graph) {
|
|
2955
|
+
throw new Error('Architecture nodes must come from createArchitectureGraph().');
|
|
2956
|
+
}
|
|
2957
|
+
return graph;
|
|
2958
|
+
}
|
|
2959
|
+
|
|
2960
|
+
function providedLabels(
|
|
2961
|
+
graph: DependencyGraph,
|
|
2962
|
+
nodesById: Map<string, DependencyGraphNode>,
|
|
2963
|
+
ownerId: string,
|
|
2964
|
+
): string[] {
|
|
2965
|
+
return uniqueSorted(
|
|
2966
|
+
graph.edges
|
|
2967
|
+
.filter((edge) => edge.kind === 'provides' && edge.from === ownerId)
|
|
2968
|
+
.map((edge) => nodesById.get(edge.to)?.label)
|
|
2969
|
+
.filter((label): label is string => Boolean(label)),
|
|
2970
|
+
);
|
|
2971
|
+
}
|
|
2972
|
+
|
|
2973
|
+
function outgoingOf(
|
|
2974
|
+
graph: DependencyGraph,
|
|
2975
|
+
id: string,
|
|
2976
|
+
kind?: DependencyGraphEdgeKind,
|
|
2977
|
+
): DependencyGraphEdge[] {
|
|
2978
|
+
return graph.edges.filter(
|
|
2979
|
+
(edge) => edge.from === id && (kind === undefined || edge.kind === kind),
|
|
2980
|
+
);
|
|
2981
|
+
}
|
|
2982
|
+
|
|
2983
|
+
function assignBranchOwnership(
|
|
2984
|
+
graph: DependencyGraph,
|
|
2985
|
+
rootIds: readonly string[],
|
|
2986
|
+
): Map<string, string | 'shared'> {
|
|
2987
|
+
const provideTargets = new Set(
|
|
2988
|
+
graph.edges
|
|
2989
|
+
.filter((edge) => edge.kind === 'provides')
|
|
2990
|
+
.map((edge) => edge.to),
|
|
2991
|
+
);
|
|
2992
|
+
const providedByRoot = new Map<string, Set<string>>();
|
|
2993
|
+
for (const rootId of rootIds) {
|
|
2994
|
+
providedByRoot.set(
|
|
2995
|
+
rootId,
|
|
2996
|
+
new Set(
|
|
2997
|
+
graph.edges
|
|
2998
|
+
.filter((edge) => edge.kind === 'provides' && edge.from === rootId)
|
|
2999
|
+
.map((edge) => edge.to),
|
|
3000
|
+
),
|
|
3001
|
+
);
|
|
3002
|
+
}
|
|
3003
|
+
|
|
3004
|
+
const ownership = new Map<string, string | 'shared'>();
|
|
3005
|
+
type WaveItem = { id: string; owner: string };
|
|
3006
|
+
let wave: WaveItem[] = [];
|
|
3007
|
+
for (const rootId of rootIds) {
|
|
3008
|
+
ownership.set(rootId, rootId);
|
|
3009
|
+
wave.push({ id: rootId, owner: rootId });
|
|
3010
|
+
for (const provided of providedByRoot.get(rootId) ?? []) {
|
|
3011
|
+
const current = ownership.get(provided);
|
|
3012
|
+
if (current === undefined) {
|
|
3013
|
+
ownership.set(provided, rootId);
|
|
3014
|
+
wave.push({ id: provided, owner: rootId });
|
|
3015
|
+
} else if (current !== rootId) {
|
|
3016
|
+
ownership.set(provided, 'shared');
|
|
3017
|
+
}
|
|
3018
|
+
}
|
|
3019
|
+
}
|
|
3020
|
+
|
|
3021
|
+
const outgoing = new Map<string, string[]>();
|
|
3022
|
+
for (const edge of graph.edges) {
|
|
3023
|
+
const list = outgoing.get(edge.from) ?? [];
|
|
3024
|
+
list.push(edge.to);
|
|
3025
|
+
outgoing.set(edge.from, list);
|
|
3026
|
+
}
|
|
3027
|
+
|
|
3028
|
+
while (wave.length > 0) {
|
|
3029
|
+
const next: WaveItem[] = [];
|
|
3030
|
+
const claimedThisWave = new Map<string, string>();
|
|
3031
|
+
for (const item of wave) {
|
|
3032
|
+
if (ownership.get(item.id) === 'shared') continue;
|
|
3033
|
+
for (const neighbor of outgoing.get(item.id) ?? []) {
|
|
3034
|
+
if (
|
|
3035
|
+
provideTargets.has(neighbor) &&
|
|
3036
|
+
!providedByRoot.get(item.owner)?.has(neighbor)
|
|
3037
|
+
) {
|
|
3038
|
+
continue;
|
|
3039
|
+
}
|
|
3040
|
+
const existing = ownership.get(neighbor);
|
|
3041
|
+
if (existing === 'shared') continue;
|
|
3042
|
+
if (existing !== undefined && existing !== item.owner) {
|
|
3043
|
+
if (claimedThisWave.has(neighbor)) {
|
|
3044
|
+
ownership.set(neighbor, 'shared');
|
|
3045
|
+
}
|
|
3046
|
+
continue;
|
|
3047
|
+
}
|
|
3048
|
+
if (existing === undefined) {
|
|
3049
|
+
claimedThisWave.set(neighbor, item.owner);
|
|
3050
|
+
ownership.set(neighbor, item.owner);
|
|
3051
|
+
next.push({ id: neighbor, owner: item.owner });
|
|
3052
|
+
}
|
|
3053
|
+
}
|
|
3054
|
+
}
|
|
3055
|
+
wave = next;
|
|
3056
|
+
}
|
|
3057
|
+
|
|
3058
|
+
return ownership;
|
|
3059
|
+
}
|
|
3060
|
+
|
|
3061
|
+
function exclusiveIds(
|
|
3062
|
+
ownership: Map<string, string | 'shared'>,
|
|
3063
|
+
rootId: string,
|
|
3064
|
+
): Set<string> {
|
|
3065
|
+
return new Set(
|
|
3066
|
+
[...ownership.entries()]
|
|
3067
|
+
.filter(([, owner]) => owner === rootId)
|
|
3068
|
+
.map(([id]) => id),
|
|
3069
|
+
);
|
|
3070
|
+
}
|
|
3071
|
+
|
|
3072
|
+
function nodeMatches(
|
|
3073
|
+
_graph: DependencyGraph,
|
|
3074
|
+
node: DependencyGraphNode,
|
|
3075
|
+
kind: DependencyGraphNodeKind,
|
|
3076
|
+
name: string,
|
|
3077
|
+
): boolean {
|
|
3078
|
+
if (kind === 'route') return routePath(node) === name;
|
|
3079
|
+
if (kind === 'unique') {
|
|
3080
|
+
return (
|
|
3081
|
+
node.label === name || node.details?.['canonical'] === name
|
|
3082
|
+
);
|
|
3083
|
+
}
|
|
3084
|
+
if (kind === 'primitive') {
|
|
3085
|
+
return (
|
|
3086
|
+
node.details?.['name'] === name ||
|
|
3087
|
+
node.label === name ||
|
|
3088
|
+
node.label === `craftMethod:${name}` ||
|
|
3089
|
+
node.label.endsWith(`:${name}`)
|
|
3090
|
+
);
|
|
3091
|
+
}
|
|
3092
|
+
return node.label === name;
|
|
3093
|
+
}
|
|
3094
|
+
|
|
3095
|
+
function fileMatches(
|
|
3096
|
+
graph: DependencyGraph,
|
|
3097
|
+
node: DependencyGraphNode,
|
|
3098
|
+
file?: string,
|
|
3099
|
+
): boolean {
|
|
3100
|
+
if (!file) return true;
|
|
3101
|
+
const relativePath = relativeGraphPath(graph, node.filePath);
|
|
3102
|
+
if (!relativePath) return false;
|
|
3103
|
+
return relativePath === file || relativePath.endsWith(file);
|
|
3104
|
+
}
|
|
3105
|
+
|
|
3106
|
+
function routePath(node: DependencyGraphNode): string {
|
|
3107
|
+
const path = node.details?.['path'];
|
|
3108
|
+
return typeof path === 'string' ? path : node.label;
|
|
3109
|
+
}
|
|
3110
|
+
|
|
3111
|
+
function uniqueSorted(values: readonly string[]): string[] {
|
|
3112
|
+
return [...new Set(values)].sort();
|
|
3113
|
+
}
|
|
3114
|
+
|
|
3115
|
+
function uniqueNode<C extends ArchitectureCatalog>(
|
|
3116
|
+
matches: ArchitectureNodeView<C>[],
|
|
3117
|
+
kind: string,
|
|
3118
|
+
name: string,
|
|
3119
|
+
file?: string,
|
|
3120
|
+
): ArchitectureNodeView<C> {
|
|
3121
|
+
if (matches.length === 1) return matches[0];
|
|
3122
|
+
if (matches.length === 0) {
|
|
3123
|
+
throw new Error(
|
|
3124
|
+
file
|
|
3125
|
+
? `Unknown ${kind} '${name}' in '${file}'.`
|
|
3126
|
+
: `Unknown ${kind} '${name}'.`,
|
|
3127
|
+
);
|
|
3128
|
+
}
|
|
3129
|
+
const files = matches
|
|
3130
|
+
.map((node) => node.filePath ?? node.id)
|
|
3131
|
+
.sort()
|
|
3132
|
+
.join(', ');
|
|
3133
|
+
throw new Error(
|
|
3134
|
+
`Ambiguous ${kind} '${name}'. Disambiguate with file: ${files}`,
|
|
3135
|
+
);
|
|
3136
|
+
}
|