modality-ts 0.0.10 → 0.0.12
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/README.md +3 -1
- package/dist/check/diagnostics/bounds.d.ts +2 -1
- package/dist/check/diagnostics/bounds.d.ts.map +1 -1
- package/dist/check/diagnostics/bounds.js +3 -3
- package/dist/check/diagnostics/bounds.js.map +1 -1
- package/dist/check/engine/check-model.d.ts +3 -0
- package/dist/check/engine/check-model.d.ts.map +1 -1
- package/dist/check/engine/check-model.js +408 -43
- package/dist/check/engine/check-model.js.map +1 -1
- package/dist/check/engine/model-api.d.ts.map +1 -1
- package/dist/check/engine/model-api.js +5 -3
- package/dist/check/engine/model-api.js.map +1 -1
- package/dist/check/engine/stabilize.d.ts +2 -1
- package/dist/check/engine/stabilize.d.ts.map +1 -1
- package/dist/check/engine/stabilize.js +31 -9
- package/dist/check/engine/stabilize.js.map +1 -1
- package/dist/check/engine/state-utils.d.ts +3 -2
- package/dist/check/engine/state-utils.d.ts.map +1 -1
- package/dist/check/engine/state-utils.js +20 -7
- package/dist/check/engine/state-utils.js.map +1 -1
- package/dist/check/engine/transitions.d.ts +9 -1
- package/dist/check/engine/transitions.d.ts.map +1 -1
- package/dist/check/engine/transitions.js +32 -5
- package/dist/check/engine/transitions.js.map +1 -1
- package/dist/check/index.d.ts +1 -1
- package/dist/check/index.d.ts.map +1 -1
- package/dist/check/index.js.map +1 -1
- package/dist/check/properties/finalize.d.ts +4 -3
- package/dist/check/properties/finalize.d.ts.map +1 -1
- package/dist/check/properties/finalize.js +45 -9
- package/dist/check/properties/finalize.js.map +1 -1
- package/dist/check/properties/leads-to.d.ts.map +1 -1
- package/dist/check/properties/leads-to.js +4 -3
- package/dist/check/properties/leads-to.js.map +1 -1
- package/dist/check/properties/observe.d.ts +4 -3
- package/dist/check/properties/observe.d.ts.map +1 -1
- package/dist/check/properties/observe.js +5 -5
- package/dist/check/properties/observe.js.map +1 -1
- package/dist/check/properties/reachable-from.d.ts +4 -2
- package/dist/check/properties/reachable-from.d.ts.map +1 -1
- package/dist/check/properties/reachable-from.js +2 -2
- package/dist/check/properties/reachable-from.js.map +1 -1
- package/dist/check/slicing/slice-model.d.ts +1 -0
- package/dist/check/slicing/slice-model.d.ts.map +1 -1
- package/dist/check/slicing/slice-model.js +38 -12
- package/dist/check/slicing/slice-model.js.map +1 -1
- package/dist/check/traces/trace.d.ts +7 -2
- package/dist/check/traces/trace.d.ts.map +1 -1
- package/dist/check/traces/trace.js +9 -4
- package/dist/check/traces/trace.js.map +1 -1
- package/dist/check/types.d.ts +83 -3
- package/dist/check/types.d.ts.map +1 -1
- package/dist/cli/cli.js +110 -13
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/defaults.d.ts +12 -0
- package/dist/cli/defaults.d.ts.map +1 -1
- package/dist/cli/defaults.js +19 -3
- package/dist/cli/defaults.js.map +1 -1
- package/dist/cli/features/check/command.d.ts +6 -0
- package/dist/cli/features/check/command.d.ts.map +1 -1
- package/dist/cli/features/check/command.js +74 -1
- package/dist/cli/features/check/command.js.map +1 -1
- package/dist/cli/features/conform/command.d.ts.map +1 -1
- package/dist/cli/features/conform/command.js +3 -1
- package/dist/cli/features/conform/command.js.map +1 -1
- package/dist/cli/features/export/command.js +12 -4
- package/dist/cli/features/export/command.js.map +1 -1
- package/dist/cli/features/extract/command.d.ts.map +1 -1
- package/dist/cli/features/extract/command.js +302 -92
- package/dist/cli/features/extract/command.js.map +1 -1
- package/dist/cli/features/init/command.js +1 -1
- package/dist/cli/features/init/command.js.map +1 -1
- package/dist/cli/harness/index.d.ts.map +1 -1
- package/dist/cli/harness/index.js +17 -4
- package/dist/cli/harness/index.js.map +1 -1
- package/dist/cli/registry/index.d.ts +4 -4
- package/dist/cli/registry/index.d.ts.map +1 -1
- package/dist/cli/registry/index.js +6 -4
- package/dist/cli/registry/index.js.map +1 -1
- package/dist/core/ir/domains.d.ts +2 -0
- package/dist/core/ir/domains.d.ts.map +1 -1
- package/dist/core/ir/domains.js +71 -0
- package/dist/core/ir/domains.js.map +1 -1
- package/dist/core/ir/validator.d.ts +4 -1
- package/dist/core/ir/validator.d.ts.map +1 -1
- package/dist/core/ir/validator.js +45 -12
- package/dist/core/ir/validator.js.map +1 -1
- package/dist/core/props/index.d.ts.map +1 -1
- package/dist/core/props/index.js.map +1 -1
- package/dist/core/report/types.d.ts +68 -0
- package/dist/core/report/types.d.ts.map +1 -1
- package/dist/extract/engine/pipeline/index.d.ts +3 -1
- package/dist/extract/engine/pipeline/index.d.ts.map +1 -1
- package/dist/extract/engine/pipeline/index.js +10 -23
- package/dist/extract/engine/pipeline/index.js.map +1 -1
- package/dist/extract/engine/spi/index.d.ts +38 -7
- package/dist/extract/engine/spi/index.d.ts.map +1 -1
- package/dist/extract/engine/spi/index.js +1 -1
- package/dist/extract/engine/spi/index.js.map +1 -1
- package/dist/extract/engine/ts/domains.d.ts +1 -1
- package/dist/extract/engine/ts/domains.d.ts.map +1 -1
- package/dist/extract/engine/ts/domains.js +25 -17
- package/dist/extract/engine/ts/domains.js.map +1 -1
- package/dist/extract/engine/ts/react-source-transitions.d.ts +2 -1
- package/dist/extract/engine/ts/react-source-transitions.d.ts.map +1 -1
- package/dist/extract/engine/ts/react-source-transitions.js +17 -7
- package/dist/extract/engine/ts/react-source-transitions.js.map +1 -1
- package/dist/extract/engine/ts/routes.d.ts +2 -2
- package/dist/extract/engine/ts/routes.d.ts.map +1 -1
- package/dist/extract/engine/ts/routes.js +5 -24
- package/dist/extract/engine/ts/routes.js.map +1 -1
- package/dist/extract/engine/ts/static-navigation.d.ts +2 -1
- package/dist/extract/engine/ts/static-navigation.d.ts.map +1 -1
- package/dist/extract/engine/ts/static-navigation.js +30 -20
- package/dist/extract/engine/ts/static-navigation.js.map +1 -1
- package/dist/extract/engine/ts/transition/async.d.ts +2 -10
- package/dist/extract/engine/ts/transition/async.d.ts.map +1 -1
- package/dist/extract/engine/ts/transition/async.js +30 -61
- package/dist/extract/engine/ts/transition/async.js.map +1 -1
- package/dist/extract/engine/ts/transition/effects.d.ts.map +1 -1
- package/dist/extract/engine/ts/transition/effects.js +7 -4
- package/dist/extract/engine/ts/transition/effects.js.map +1 -1
- package/dist/extract/engine/ts/transition/expressions.d.ts.map +1 -1
- package/dist/extract/engine/ts/transition/expressions.js +3 -1
- package/dist/extract/engine/ts/transition/expressions.js.map +1 -1
- package/dist/extract/engine/ts/transition/guards.d.ts.map +1 -1
- package/dist/extract/engine/ts/transition/guards.js +1 -1
- package/dist/extract/engine/ts/transition/guards.js.map +1 -1
- package/dist/extract/engine/ts/transition/handlers.d.ts.map +1 -1
- package/dist/extract/engine/ts/transition/handlers.js +7 -5
- package/dist/extract/engine/ts/transition/handlers.js.map +1 -1
- package/dist/extract/engine/ts/transition/locals.d.ts.map +1 -1
- package/dist/extract/engine/ts/transition/locals.js +2 -1
- package/dist/extract/engine/ts/transition/locals.js.map +1 -1
- package/dist/extract/engine/ts/transition/navigation.d.ts +7 -5
- package/dist/extract/engine/ts/transition/navigation.d.ts.map +1 -1
- package/dist/extract/engine/ts/transition/navigation.js +85 -41
- package/dist/extract/engine/ts/transition/navigation.js.map +1 -1
- package/dist/extract/engine/ts/transition/plugin-calls.d.ts.map +1 -1
- package/dist/extract/engine/ts/transition/plugin-calls.js +1 -1
- package/dist/extract/engine/ts/transition/plugin-calls.js.map +1 -1
- package/dist/extract/engine/ts/transition/timers.d.ts.map +1 -1
- package/dist/extract/engine/ts/transition/timers.js +5 -3
- package/dist/extract/engine/ts/transition/timers.js.map +1 -1
- package/dist/extract/sources/jotai/domains.d.ts +2 -1
- package/dist/extract/sources/jotai/domains.d.ts.map +1 -1
- package/dist/extract/sources/jotai/domains.js +2 -164
- package/dist/extract/sources/jotai/domains.js.map +1 -1
- package/dist/extract/sources/jotai/harness.d.ts.map +1 -1
- package/dist/extract/sources/jotai/harness.js +5 -2
- package/dist/extract/sources/jotai/harness.js.map +1 -1
- package/dist/extract/sources/jotai/transitions.d.ts.map +1 -1
- package/dist/extract/sources/jotai/transitions.js +1 -4
- package/dist/extract/sources/jotai/transitions.js.map +1 -1
- package/dist/extract/sources/router/discover.d.ts +9 -0
- package/dist/extract/sources/router/discover.d.ts.map +1 -0
- package/dist/extract/sources/router/discover.js +135 -0
- package/dist/extract/sources/router/discover.js.map +1 -0
- package/dist/extract/sources/router/index.d.ts +7 -3
- package/dist/extract/sources/router/index.d.ts.map +1 -1
- package/dist/extract/sources/router/index.js +21 -12
- package/dist/extract/sources/router/index.js.map +1 -1
- package/dist/extract/sources/router/navigation.d.ts +3 -4
- package/dist/extract/sources/router/navigation.d.ts.map +1 -1
- package/dist/extract/sources/router/navigation.js +33 -1
- package/dist/extract/sources/router/navigation.js.map +1 -1
- package/dist/extract/sources/router/redirects.d.ts +4 -0
- package/dist/extract/sources/router/redirects.d.ts.map +1 -0
- package/dist/extract/sources/router/redirects.js +37 -0
- package/dist/extract/sources/router/redirects.js.map +1 -0
- package/dist/extract/sources/router/routes.d.ts +2 -2
- package/dist/extract/sources/router/routes.d.ts.map +1 -1
- package/dist/extract/sources/router/routes.js +24 -6
- package/dist/extract/sources/router/routes.js.map +1 -1
- package/dist/extract/sources/shared/react-transition-extract.d.ts +1 -0
- package/dist/extract/sources/shared/react-transition-extract.d.ts.map +1 -1
- package/dist/extract/sources/shared/react-transition-extract.js +1 -0
- package/dist/extract/sources/shared/react-transition-extract.js.map +1 -1
- package/dist/extract/sources/swr/domains.d.ts +3 -2
- package/dist/extract/sources/swr/domains.d.ts.map +1 -1
- package/dist/extract/sources/swr/domains.js +3 -96
- package/dist/extract/sources/swr/domains.js.map +1 -1
- package/dist/extract/sources/swr/harness.d.ts.map +1 -1
- package/dist/extract/sources/swr/harness.js +13 -6
- package/dist/extract/sources/swr/harness.js.map +1 -1
- package/dist/extract/sources/swr/template.js +1 -1
- package/dist/extract/sources/swr/template.js.map +1 -1
- package/dist/extract/sources/swr/transitions.d.ts.map +1 -1
- package/dist/extract/sources/swr/transitions.js.map +1 -1
- package/dist/extract/sources/swr/writes.d.ts.map +1 -1
- package/dist/extract/sources/swr/writes.js +1 -1
- package/dist/extract/sources/swr/writes.js.map +1 -1
- package/dist/extract/sources/use-state/harness.d.ts.map +1 -1
- package/dist/extract/sources/use-state/harness.js +5 -2
- package/dist/extract/sources/use-state/harness.js.map +1 -1
- package/dist/extract/sources/use-state/index.d.ts.map +1 -1
- package/dist/extract/sources/use-state/index.js +1 -166
- package/dist/extract/sources/use-state/index.js.map +1 -1
- package/dist/extract/sources/use-state/transitions.d.ts.map +1 -1
- package/dist/extract/sources/use-state/transitions.js +1 -0
- package/dist/extract/sources/use-state/transitions.js.map +1 -1
- package/dist/extract/sources/use-state/types.d.ts +1 -0
- package/dist/extract/sources/use-state/types.d.ts.map +1 -1
- package/package.json +2 -2
|
@@ -4,20 +4,20 @@ import { dirname, extname, join, parse, resolve } from "node:path";
|
|
|
4
4
|
import { pathToFileURL } from "node:url";
|
|
5
5
|
import * as ts from "typescript";
|
|
6
6
|
import { runExtractionPipeline } from "modality-ts/extract";
|
|
7
|
-
import { canonicalJson, parseModelArtifact, } from "modality-ts/core";
|
|
8
|
-
import { routerSource } from "modality-ts/extract/sources/router";
|
|
7
|
+
import { canonicalJson, collectTokenDomainPaths, domainCardinality, parseModelArtifact, } from "modality-ts/core";
|
|
8
|
+
import { parseReactRouterRoutes, routerSource, } from "modality-ts/extract/sources/router";
|
|
9
9
|
import { emitAppModel } from "../../codegen/model.js";
|
|
10
10
|
import { loadAndApplyOverlay, loadOverlaySpec } from "../../overlay.js";
|
|
11
11
|
import { createBuiltinModalityRegistry } from "../../registry/index.js";
|
|
12
12
|
export async function runExtractCommand(options) {
|
|
13
13
|
const sourcePaths = normalizedSourcePaths(options);
|
|
14
|
-
const
|
|
15
|
-
const config = await loadModalityConfig(options.configPath ?? (await findNearestConfig(
|
|
14
|
+
const projectBase = await loadExtractionProject(sourcePaths);
|
|
15
|
+
const config = await loadModalityConfig(options.configPath ?? (await findNearestConfig(projectBase.configStartDir)));
|
|
16
16
|
const route = options.route ?? config.route ?? "/";
|
|
17
17
|
const appModelPath = options.appModelPath ?? `${dirname(options.modelPath)}/app.model.ts`;
|
|
18
18
|
const packageJsonPath = options.packageJsonPath ??
|
|
19
19
|
config.packageJsonPath ??
|
|
20
|
-
(await findNearestPackageJson(
|
|
20
|
+
(await findNearestPackageJson(projectBase.configStartDir));
|
|
21
21
|
const dependencies = await readPackageDependencies(packageJsonPath);
|
|
22
22
|
const registry = createBuiltinModalityRegistry({
|
|
23
23
|
dependencies,
|
|
@@ -31,6 +31,9 @@ export async function runExtractCommand(options) {
|
|
|
31
31
|
],
|
|
32
32
|
routerPlugin: options.routerPlugin ?? config.routerPlugin,
|
|
33
33
|
});
|
|
34
|
+
const routerAdapter = registry.routerPlugin ?? routerSource();
|
|
35
|
+
const project = await attachRouteInventory(projectBase, routerAdapter);
|
|
36
|
+
const routePatterns = project.inventory.routes.map((node) => node.pattern);
|
|
34
37
|
const effectApis = uniqueStrings([
|
|
35
38
|
...(config.effectApis ?? []),
|
|
36
39
|
...(options.effectApis ?? []),
|
|
@@ -47,27 +50,15 @@ export async function runExtractCommand(options) {
|
|
|
47
50
|
sourceText: project.sourceText,
|
|
48
51
|
fileName: project.entryFile,
|
|
49
52
|
route,
|
|
50
|
-
routePatterns
|
|
53
|
+
routePatterns,
|
|
51
54
|
effectApis,
|
|
52
55
|
sourcePlugins: registry.sourcePlugins,
|
|
53
56
|
routerPlugin: registry.routerPlugin,
|
|
57
|
+
inventory: project.inventory,
|
|
54
58
|
});
|
|
55
59
|
const transitions = [...pipeline.transitions];
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
...project.routes,
|
|
59
|
-
...transitionNavigatedRoutes(transitions),
|
|
60
|
-
]);
|
|
61
|
-
const defaultRouter = routerSource();
|
|
62
|
-
const routeVars = registry.routerPlugin
|
|
63
|
-
? registry.routerPlugin.routeVars(discoveredRoutes, {
|
|
64
|
-
route,
|
|
65
|
-
bounds: { maxHistory: 4 },
|
|
66
|
-
})
|
|
67
|
-
: defaultRouter.routeVars(discoveredRoutes, {
|
|
68
|
-
route,
|
|
69
|
-
bounds: { maxHistory: 4 },
|
|
70
|
-
});
|
|
60
|
+
const lowering = buildLocationLowering(transitions, routerAdapter, project.inventory);
|
|
61
|
+
const routeVars = routerAdapter.locationVars(project.inventory, { route, bounds: { maxHistory: 4 } }, lowering);
|
|
71
62
|
const templateVars = pipeline.templateFragments.flatMap((fragment) => fragment.vars);
|
|
72
63
|
const stateVars = refineAssignedLiteralDomains([...pipeline.stateVars, ...templateVars], transitions);
|
|
73
64
|
const extractedModel = {
|
|
@@ -111,7 +102,7 @@ export async function runExtractCommand(options) {
|
|
|
111
102
|
extractionCaveats,
|
|
112
103
|
},
|
|
113
104
|
};
|
|
114
|
-
const report = createExtractionReport(project.sourceFiles, model, warnings, overlay.ignoredVars, options.now ?? new Date());
|
|
105
|
+
const report = createExtractionReport(project.sourceFiles, model, warnings, overlay.ignoredVars, options.now ?? new Date(), project.inventory);
|
|
115
106
|
await mkdir(dirname(options.modelPath), { recursive: true });
|
|
116
107
|
await writeFile(options.modelPath, `${canonicalJson(model)}\n`, "utf8");
|
|
117
108
|
await mkdir(dirname(appModelPath), { recursive: true });
|
|
@@ -123,11 +114,42 @@ export async function runExtractCommand(options) {
|
|
|
123
114
|
if (options.expectModelPath) {
|
|
124
115
|
await assertMatchesExpectedModel(model, options.expectModelPath);
|
|
125
116
|
}
|
|
117
|
+
const stateSpaceLine = (() => {
|
|
118
|
+
const contributors = report.stateContributors;
|
|
119
|
+
if (!contributors)
|
|
120
|
+
return undefined;
|
|
121
|
+
const { totalBits, topVars } = contributors;
|
|
122
|
+
const top = topVars
|
|
123
|
+
.slice(0, 3)
|
|
124
|
+
.map((v) => `${v.varId}(${v.bits.toFixed(1)})`)
|
|
125
|
+
.join(",");
|
|
126
|
+
return `state-space≈${totalBits.toFixed(1)}bits top:${top}`;
|
|
127
|
+
})();
|
|
128
|
+
const coarseDomainsLine = (() => {
|
|
129
|
+
const entries = report.coarseDomains ?? [];
|
|
130
|
+
if (entries.length === 0)
|
|
131
|
+
return undefined;
|
|
132
|
+
const count = entries.reduce((sum, entry) => sum + entry.paths.length, 0);
|
|
133
|
+
const first = entries[0];
|
|
134
|
+
if (!first)
|
|
135
|
+
return undefined;
|
|
136
|
+
const examplePath = first.paths[0];
|
|
137
|
+
return `coarse-domains=${count} e.g. ${first.varId}[${examplePath ?? ""}]`;
|
|
138
|
+
})();
|
|
139
|
+
const routeCoverageLine = (() => {
|
|
140
|
+
const coverage = report.routeCoverage;
|
|
141
|
+
if (!coverage || coverage.configured === 0)
|
|
142
|
+
return undefined;
|
|
143
|
+
return formatRouteCoverageLine(coverage);
|
|
144
|
+
})();
|
|
126
145
|
return {
|
|
127
146
|
model,
|
|
128
147
|
report,
|
|
129
148
|
lines: [
|
|
130
149
|
`extracted vars=${pipeline.stateVars.length + pipeline.templateFragments.flatMap((fragment) => fragment.vars).length} transitions=${transitions.length}`,
|
|
150
|
+
...(stateSpaceLine ? [stateSpaceLine] : []),
|
|
151
|
+
...(routeCoverageLine ? [routeCoverageLine] : []),
|
|
152
|
+
...(coarseDomainsLine ? [coarseDomainsLine] : []),
|
|
131
153
|
`plugins=${registry.plugins.map((plugin) => `${plugin.kind}:${plugin.id}@${plugin.version}`).join(",") || "none"}`,
|
|
132
154
|
`model=${options.modelPath}`,
|
|
133
155
|
`appModel=${appModelPath}`,
|
|
@@ -159,6 +181,8 @@ async function loadExtractionProject(sourcePaths) {
|
|
|
159
181
|
if (sourcePaths.length > 1)
|
|
160
182
|
return loadMultiFileExtractionProject(sourcePaths);
|
|
161
183
|
const resolved = sourcePaths[0];
|
|
184
|
+
if (!resolved)
|
|
185
|
+
throw new Error("extract requires at least one source path");
|
|
162
186
|
const info = await stat(resolved);
|
|
163
187
|
if (!info.isDirectory()) {
|
|
164
188
|
const source = await readFile(resolved, "utf8");
|
|
@@ -169,7 +193,7 @@ async function loadExtractionProject(sourcePaths) {
|
|
|
169
193
|
sourceText: imported.sources.map((entry) => entry.text).join("\n"),
|
|
170
194
|
sourceFiles: imported.sources.map((entry) => entry.path),
|
|
171
195
|
sources: imported.sources,
|
|
172
|
-
routes: [],
|
|
196
|
+
inventory: { routes: [] },
|
|
173
197
|
effectApis: fetchEffectApis(imported.sources.map((entry) => entry.text).join("\n")),
|
|
174
198
|
configStartDir: dirname(resolved),
|
|
175
199
|
};
|
|
@@ -179,6 +203,7 @@ async function loadExtractionProject(sourcePaths) {
|
|
|
179
203
|
const rootPath = join(resolved, "app", "root.tsx");
|
|
180
204
|
const roots = await existingFiles([rootPath]);
|
|
181
205
|
const entries = [
|
|
206
|
+
{ path: routesPath, text: await readFile(routesPath, "utf8") },
|
|
182
207
|
...(await Promise.all(roots.map(async (path) => ({ path, text: await readFile(path, "utf8") })))),
|
|
183
208
|
...(await Promise.all(routeEntries.map(async (entry) => ({
|
|
184
209
|
path: resolve(dirname(routesPath), entry.file),
|
|
@@ -193,7 +218,7 @@ async function loadExtractionProject(sourcePaths) {
|
|
|
193
218
|
sourceText,
|
|
194
219
|
sourceFiles: imported.sources.map((entry) => entry.path),
|
|
195
220
|
sources: imported.sources,
|
|
196
|
-
routes:
|
|
221
|
+
inventory: { routes: [] },
|
|
197
222
|
effectApis: fetchEffectApis(sourceText),
|
|
198
223
|
configStartDir: resolved,
|
|
199
224
|
};
|
|
@@ -213,7 +238,7 @@ async function loadMultiFileExtractionProject(sourcePaths) {
|
|
|
213
238
|
sourceText,
|
|
214
239
|
sourceFiles: sources.map((entry) => entry.path),
|
|
215
240
|
sources,
|
|
216
|
-
routes:
|
|
241
|
+
inventory: { routes: [] },
|
|
217
242
|
effectApis: uniqueStrings([
|
|
218
243
|
...projects.flatMap((project) => project.effectApis),
|
|
219
244
|
...fetchEffectApis(sourceText),
|
|
@@ -221,12 +246,51 @@ async function loadMultiFileExtractionProject(sourcePaths) {
|
|
|
221
246
|
configStartDir: commonAncestor(projects.map((project) => project.configStartDir)),
|
|
222
247
|
};
|
|
223
248
|
}
|
|
249
|
+
async function attachRouteInventory(project, adapter) {
|
|
250
|
+
const files = [...project.sources];
|
|
251
|
+
const manifestPath = files.find((file) => file.path.endsWith("routes.ts"))?.path ??
|
|
252
|
+
(await findNearestRoutesManifest(project.configStartDir));
|
|
253
|
+
if (manifestPath &&
|
|
254
|
+
!files.some((file) => resolve(file.path) === resolve(manifestPath))) {
|
|
255
|
+
files.push({
|
|
256
|
+
path: manifestPath,
|
|
257
|
+
text: await readFile(manifestPath, "utf8"),
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
const inventory = await adapter.discoverRoutes({
|
|
261
|
+
rootDir: project.configStartDir,
|
|
262
|
+
files,
|
|
263
|
+
readFile: (path) => readFile(path, "utf8"),
|
|
264
|
+
});
|
|
265
|
+
return { ...project, inventory };
|
|
266
|
+
}
|
|
267
|
+
async function findNearestRoutesManifest(startDir) {
|
|
268
|
+
let current = resolve(startDir);
|
|
269
|
+
for (let depth = 0; depth < 8; depth += 1) {
|
|
270
|
+
const candidate = join(current, "app", "routes.ts");
|
|
271
|
+
try {
|
|
272
|
+
const info = await stat(candidate);
|
|
273
|
+
if (info.isFile())
|
|
274
|
+
return candidate;
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
// keep walking upward
|
|
278
|
+
}
|
|
279
|
+
const parent = dirname(current);
|
|
280
|
+
if (parent === current)
|
|
281
|
+
break;
|
|
282
|
+
current = parent;
|
|
283
|
+
}
|
|
284
|
+
return undefined;
|
|
285
|
+
}
|
|
224
286
|
async function sourceWithLocalImports(entries, tsconfig) {
|
|
225
287
|
const seen = new Set();
|
|
226
288
|
const sources = [];
|
|
227
289
|
const queue = [...entries];
|
|
228
290
|
while (queue.length > 0) {
|
|
229
291
|
const next = queue.shift();
|
|
292
|
+
if (!next)
|
|
293
|
+
break;
|
|
230
294
|
const canonical = resolve(next.path);
|
|
231
295
|
if (seen.has(canonical))
|
|
232
296
|
continue;
|
|
@@ -372,33 +436,6 @@ async function existingFiles(paths) {
|
|
|
372
436
|
}
|
|
373
437
|
return found;
|
|
374
438
|
}
|
|
375
|
-
function parseReactRouterRoutes(source) {
|
|
376
|
-
const routes = [];
|
|
377
|
-
const parsed = tsCreateSourceFile(source);
|
|
378
|
-
const visit = (node) => {
|
|
379
|
-
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
|
|
380
|
-
if (node.expression.text === "index" &&
|
|
381
|
-
ts.isStringLiteral(node.arguments[0])) {
|
|
382
|
-
routes.push({ pattern: "/", file: node.arguments[0].text });
|
|
383
|
-
}
|
|
384
|
-
if (node.expression.text === "route" &&
|
|
385
|
-
ts.isStringLiteral(node.arguments[0]) &&
|
|
386
|
-
ts.isStringLiteral(node.arguments[1])) {
|
|
387
|
-
routes.push({
|
|
388
|
-
pattern: reactRouterPathPattern(node.arguments[0].text),
|
|
389
|
-
file: node.arguments[1].text,
|
|
390
|
-
});
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
tsForEachChild(node, visit);
|
|
394
|
-
};
|
|
395
|
-
visit(parsed);
|
|
396
|
-
return routes;
|
|
397
|
-
}
|
|
398
|
-
function reactRouterPathPattern(pattern) {
|
|
399
|
-
const normalized = pattern.startsWith("/") ? pattern : `/${pattern}`;
|
|
400
|
-
return normalized.replace(/\$([A-Za-z0-9_]+)/g, ":$1").replace(/\*$/, "*");
|
|
401
|
-
}
|
|
402
439
|
function sourceHashes(sources) {
|
|
403
440
|
return Object.fromEntries(sources.map((source) => [source.path, sha256(source.text)]));
|
|
404
441
|
}
|
|
@@ -536,7 +573,11 @@ function commonAncestor(paths) {
|
|
|
536
573
|
}
|
|
537
574
|
}
|
|
538
575
|
const prefix = first.slice(0, length).join("/");
|
|
539
|
-
|
|
576
|
+
if (prefix === "") {
|
|
577
|
+
const firstPath = paths[0];
|
|
578
|
+
return firstPath ? parse(firstPath).root : process.cwd();
|
|
579
|
+
}
|
|
580
|
+
return prefix;
|
|
540
581
|
}
|
|
541
582
|
async function findNearestPackageJson(startDir) {
|
|
542
583
|
let dir = startDir;
|
|
@@ -577,21 +618,50 @@ async function assertMatchesExpectedModel(model, expectedModelPath) {
|
|
|
577
618
|
throw new Error(`Extracted model differs from expected snapshot ${expectedModelPath}`);
|
|
578
619
|
}
|
|
579
620
|
}
|
|
580
|
-
function
|
|
621
|
+
function round2(n) {
|
|
622
|
+
return Math.round(n * 100) / 100;
|
|
623
|
+
}
|
|
624
|
+
function buildStateContributors(model, limit = 20) {
|
|
625
|
+
const contributors = model.vars.map((decl) => {
|
|
626
|
+
const cardinality = domainCardinality(decl.domain);
|
|
627
|
+
const bits = cardinality < 1 ? 0 : round2(Math.log2(cardinality));
|
|
628
|
+
const scope = decl.scope.kind === "global" ? "global" : decl.scope.route;
|
|
629
|
+
const origin = typeof decl.origin === "string" ? decl.origin : decl.origin.file;
|
|
630
|
+
return {
|
|
631
|
+
varId: decl.id,
|
|
632
|
+
domainKind: decl.domain.kind,
|
|
633
|
+
bits,
|
|
634
|
+
scope,
|
|
635
|
+
origin,
|
|
636
|
+
};
|
|
637
|
+
});
|
|
638
|
+
const totalBits = round2(contributors.reduce((sum, c) => sum + c.bits, 0));
|
|
639
|
+
const topVars = [...contributors]
|
|
640
|
+
.sort((a, b) => b.bits - a.bits || a.varId.localeCompare(b.varId))
|
|
641
|
+
.slice(0, limit);
|
|
642
|
+
const bySourceMap = new Map();
|
|
643
|
+
for (const c of contributors) {
|
|
644
|
+
bySourceMap.set(c.origin, round2((bySourceMap.get(c.origin) ?? 0) + c.bits));
|
|
645
|
+
}
|
|
646
|
+
const bySource = [...bySourceMap.entries()]
|
|
647
|
+
.map(([source, bits]) => ({ source, bits }))
|
|
648
|
+
.sort((a, b) => b.bits - a.bits || a.source.localeCompare(b.source));
|
|
649
|
+
return { totalBits, topVars, bySource };
|
|
650
|
+
}
|
|
651
|
+
function createExtractionReport(sourceFiles, model, warnings, ignoredVars, now, inventory) {
|
|
581
652
|
const caveats = model.metadata?.extractionCaveats ?? emptyExtractionCaveats();
|
|
653
|
+
const varDomains = new Map(model.vars.map((decl) => [decl.id, decl.domain]));
|
|
582
654
|
const transitionHandlers = model.transitions.map((transition) => ({
|
|
583
655
|
id: transition.id,
|
|
584
656
|
classification: transition.confidence === "manual"
|
|
585
657
|
? "overlay"
|
|
586
658
|
: transition.confidence,
|
|
587
659
|
reasons: transition.confidence === "over-approx"
|
|
588
|
-
? overApproxReasons(transition)
|
|
660
|
+
? overApproxReasons(transition, varDomains)
|
|
589
661
|
: [],
|
|
590
662
|
}));
|
|
591
663
|
const transitionIds = new Set(transitionHandlers.map((handler) => handler.id));
|
|
592
|
-
const unextractableHandlers = warnings
|
|
593
|
-
.map(unextractableHandlerFromWarning)
|
|
594
|
-
.filter((handler) => Boolean(handler))
|
|
664
|
+
const unextractableHandlers = dedupeUnextractableHandlers(warnings)
|
|
595
665
|
.filter((handler) => !transitionIds.has(handler.id))
|
|
596
666
|
.map((handler) => ({
|
|
597
667
|
id: handler.id,
|
|
@@ -602,6 +672,14 @@ function createExtractionReport(sourceFiles, model, warnings, ignoredVars, now)
|
|
|
602
672
|
const exactOrOverlay = handlers.filter((handler) => handler.classification === "exact" ||
|
|
603
673
|
handler.classification === "overlay").length;
|
|
604
674
|
const unextractable = handlers.filter((handler) => handler.classification === "unextractable").length;
|
|
675
|
+
const coarseDomains = model.vars
|
|
676
|
+
.map((decl) => ({
|
|
677
|
+
varId: decl.id,
|
|
678
|
+
paths: collectTokenDomainPaths(decl.domain),
|
|
679
|
+
}))
|
|
680
|
+
.filter((entry) => entry.paths.length > 0)
|
|
681
|
+
.sort((a, b) => a.varId.localeCompare(b.varId));
|
|
682
|
+
const routeCoverage = buildRouteCoverage(inventory, model);
|
|
605
683
|
return {
|
|
606
684
|
schemaVersion: 1,
|
|
607
685
|
kind: "extraction-report",
|
|
@@ -624,6 +702,9 @@ function createExtractionReport(sourceFiles, model, warnings, ignoredVars, now)
|
|
|
624
702
|
? "default-token"
|
|
625
703
|
: "type-derived"),
|
|
626
704
|
})),
|
|
705
|
+
...(coarseDomains.length > 0 ? { coarseDomains } : {}),
|
|
706
|
+
stateContributors: buildStateContributors(model),
|
|
707
|
+
...(routeCoverage ? { routeCoverage } : {}),
|
|
627
708
|
coverage: {
|
|
628
709
|
handlersTotal: handlers.length,
|
|
629
710
|
exactOrOverlay,
|
|
@@ -634,6 +715,106 @@ function createExtractionReport(sourceFiles, model, warnings, ignoredVars, now)
|
|
|
634
715
|
warnings,
|
|
635
716
|
};
|
|
636
717
|
}
|
|
718
|
+
function buildRouteCoverage(inventory, model) {
|
|
719
|
+
if (!inventory || inventory.routes.length === 0)
|
|
720
|
+
return undefined;
|
|
721
|
+
const routeVar = model.vars.find((decl) => decl.id === "sys:route");
|
|
722
|
+
const modeledValues = new Set(routeVar?.domain.kind === "enum" ? routeVar.domain.values : []);
|
|
723
|
+
const routes = inventory.routes
|
|
724
|
+
.map((node) => {
|
|
725
|
+
const modeled = modeledValues.has(node.pattern);
|
|
726
|
+
if (modeled)
|
|
727
|
+
return { pattern: node.pattern, modeled: true };
|
|
728
|
+
let classification;
|
|
729
|
+
let reason;
|
|
730
|
+
if (node.kind === "resource") {
|
|
731
|
+
classification = "api";
|
|
732
|
+
reason = "API/resource route excluded from client state";
|
|
733
|
+
}
|
|
734
|
+
else if (node.redirectTo) {
|
|
735
|
+
classification = "redirect-only";
|
|
736
|
+
reason = "Redirect-only route excluded from client state";
|
|
737
|
+
}
|
|
738
|
+
else if (node.pattern.includes("*")) {
|
|
739
|
+
classification = "unsupported";
|
|
740
|
+
reason = "Splat/wildcard route pattern not modeled";
|
|
741
|
+
}
|
|
742
|
+
else {
|
|
743
|
+
classification = "no-client-state";
|
|
744
|
+
reason = "No client-side state modeled for this route";
|
|
745
|
+
}
|
|
746
|
+
return { pattern: node.pattern, modeled: false, classification, reason };
|
|
747
|
+
})
|
|
748
|
+
.sort((left, right) => left.pattern.localeCompare(right.pattern));
|
|
749
|
+
const modeled = routes.filter((entry) => entry.modeled).length;
|
|
750
|
+
return { configured: inventory.routes.length, modeled, routes };
|
|
751
|
+
}
|
|
752
|
+
function formatRouteCoverageLine(coverage) {
|
|
753
|
+
const omitted = coverage.configured - coverage.modeled;
|
|
754
|
+
const counts = new Map();
|
|
755
|
+
for (const entry of coverage.routes) {
|
|
756
|
+
if (entry.modeled || !entry.classification)
|
|
757
|
+
continue;
|
|
758
|
+
counts.set(entry.classification, (counts.get(entry.classification) ?? 0) + 1);
|
|
759
|
+
}
|
|
760
|
+
const parts = [...counts.entries()]
|
|
761
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
762
|
+
.map(([classification, count]) => `${classification}=${count}`);
|
|
763
|
+
const suffix = parts.length > 0 ? ` [${parts.join(",")}]` : "";
|
|
764
|
+
return `routes configured=${coverage.configured} modeled=${coverage.modeled} omitted=${omitted}${suffix}`;
|
|
765
|
+
}
|
|
766
|
+
function buildLocationLowering(transitions, adapter, inventory) {
|
|
767
|
+
const pushTargets = new Set();
|
|
768
|
+
const pushOrigins = new Set();
|
|
769
|
+
let hasUnboundPush = false;
|
|
770
|
+
for (const transition of transitions) {
|
|
771
|
+
if (transition.id.startsWith("route:"))
|
|
772
|
+
continue;
|
|
773
|
+
const navigations = collectPushReplaceNavigations(transition.effect);
|
|
774
|
+
if (navigations.length === 0)
|
|
775
|
+
continue;
|
|
776
|
+
const component = transition.id.split(".")[0] ?? "";
|
|
777
|
+
const origin = adapter.routeForComponent?.(component, inventory);
|
|
778
|
+
for (const navigation of navigations) {
|
|
779
|
+
if (navigation.to)
|
|
780
|
+
pushTargets.add(navigation.to);
|
|
781
|
+
if (!origin)
|
|
782
|
+
hasUnboundPush = true;
|
|
783
|
+
else
|
|
784
|
+
pushOrigins.add(origin);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
return {
|
|
788
|
+
pushTargets: [...pushTargets].sort(),
|
|
789
|
+
pushOrigins: [...pushOrigins].sort(),
|
|
790
|
+
hasUnboundPush,
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
function collectPushReplaceNavigations(effect) {
|
|
794
|
+
const navigations = [];
|
|
795
|
+
const visit = (current) => {
|
|
796
|
+
if (current.kind === "navigate" &&
|
|
797
|
+
(current.mode === "push" || current.mode === "replace")) {
|
|
798
|
+
const to = current.to?.kind === "lit" && typeof current.to.value === "string"
|
|
799
|
+
? current.to.value
|
|
800
|
+
: undefined;
|
|
801
|
+
navigations.push({
|
|
802
|
+
mode: current.mode,
|
|
803
|
+
...(to !== undefined ? { to } : {}),
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
if (current.kind === "seq") {
|
|
807
|
+
for (const child of current.effects)
|
|
808
|
+
visit(child);
|
|
809
|
+
}
|
|
810
|
+
if (current.kind === "if") {
|
|
811
|
+
visit(current.then);
|
|
812
|
+
visit(current.else);
|
|
813
|
+
}
|
|
814
|
+
};
|
|
815
|
+
visit(effect);
|
|
816
|
+
return navigations;
|
|
817
|
+
}
|
|
637
818
|
function emptyExtractionCaveats() {
|
|
638
819
|
return {
|
|
639
820
|
globalTaints: [],
|
|
@@ -656,10 +837,7 @@ function createExtractionCaveats(warnings) {
|
|
|
656
837
|
.map(unhandledRejectionFromWarning)
|
|
657
838
|
.filter(isCaveat)
|
|
658
839
|
.sort(compareCaveats),
|
|
659
|
-
unextractableHandlers: warnings
|
|
660
|
-
.map(unextractableHandlerFromWarning)
|
|
661
|
-
.filter(isCaveat)
|
|
662
|
-
.sort(compareCaveats),
|
|
840
|
+
unextractableHandlers: dedupeUnextractableHandlers(warnings),
|
|
663
841
|
};
|
|
664
842
|
}
|
|
665
843
|
function globalTaintFromWarning(warning) {
|
|
@@ -723,32 +901,15 @@ function firstSemverMajor(range) {
|
|
|
723
901
|
function pluginProvenance(plugins) {
|
|
724
902
|
return [...plugins.sources, ...(plugins.router ? [plugins.router] : [])].sort((left, right) => left.kind.localeCompare(right.kind) || left.id.localeCompare(right.id));
|
|
725
903
|
}
|
|
726
|
-
function
|
|
727
|
-
const routes = new Set();
|
|
728
|
-
const visit = (effect) => {
|
|
729
|
-
if (effect.kind === "navigate" &&
|
|
730
|
-
effect.to?.kind === "lit" &&
|
|
731
|
-
typeof effect.to.value === "string")
|
|
732
|
-
routes.add(effect.to.value);
|
|
733
|
-
if (effect.kind === "seq") {
|
|
734
|
-
for (const child of effect.effects)
|
|
735
|
-
visit(child);
|
|
736
|
-
}
|
|
737
|
-
if (effect.kind === "if") {
|
|
738
|
-
visit(effect.then);
|
|
739
|
-
visit(effect.else);
|
|
740
|
-
}
|
|
741
|
-
};
|
|
742
|
-
for (const transition of transitions)
|
|
743
|
-
visit(transition.effect);
|
|
744
|
-
return [...routes].sort();
|
|
745
|
-
}
|
|
746
|
-
function overApproxReasons(transition) {
|
|
904
|
+
function overApproxReasons(transition, varDomains = new Map()) {
|
|
747
905
|
const reasons = new Set();
|
|
748
906
|
if (transition.id.endsWith(".escaped"))
|
|
749
907
|
reasons.add("setter escaped to unanalyzed call");
|
|
750
|
-
for (const variable of havocWrites(transition.effect))
|
|
751
|
-
|
|
908
|
+
for (const variable of havocWrites(transition.effect)) {
|
|
909
|
+
const domain = varDomains.get(variable);
|
|
910
|
+
const prefix = domain?.kind === "bool" ? "safe local toggle" : "domain-wide havoc";
|
|
911
|
+
reasons.add(`${prefix}: havoc write to ${variable}`);
|
|
912
|
+
}
|
|
752
913
|
if (reasons.size === 0)
|
|
753
914
|
reasons.add("transition confidence is over-approx");
|
|
754
915
|
return [...reasons].sort();
|
|
@@ -799,17 +960,28 @@ function normalizeId(id) {
|
|
|
799
960
|
function editDistance(left, right) {
|
|
800
961
|
const previous = Array.from({ length: right.length + 1 }, (_, index) => index);
|
|
801
962
|
for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) {
|
|
802
|
-
|
|
963
|
+
const startDiagonal = previous[0];
|
|
964
|
+
if (startDiagonal === undefined)
|
|
965
|
+
break;
|
|
966
|
+
let diagonal = startDiagonal;
|
|
803
967
|
previous[0] = leftIndex;
|
|
804
968
|
for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) {
|
|
805
|
-
const
|
|
806
|
-
const
|
|
969
|
+
const upCell = previous[rightIndex];
|
|
970
|
+
const leftCell = previous[rightIndex - 1];
|
|
971
|
+
if (upCell === undefined || leftCell === undefined)
|
|
972
|
+
break;
|
|
973
|
+
const up = upCell + 1;
|
|
974
|
+
const leftCost = leftCell + 1;
|
|
807
975
|
const subst = diagonal + (left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1);
|
|
808
|
-
|
|
976
|
+
const corner = previous[rightIndex];
|
|
977
|
+
if (corner === undefined)
|
|
978
|
+
break;
|
|
979
|
+
diagonal = corner;
|
|
809
980
|
previous[rightIndex] = Math.min(up, leftCost, subst);
|
|
810
981
|
}
|
|
811
982
|
}
|
|
812
|
-
|
|
983
|
+
const distance = previous[right.length];
|
|
984
|
+
return distance ?? 0;
|
|
813
985
|
}
|
|
814
986
|
function havocWrites(effect) {
|
|
815
987
|
if (effect.kind === "havoc")
|
|
@@ -820,9 +992,47 @@ function havocWrites(effect) {
|
|
|
820
992
|
return [...havocWrites(effect.then), ...havocWrites(effect.else)];
|
|
821
993
|
return [];
|
|
822
994
|
}
|
|
995
|
+
const GENERIC_UNEXTRACTABLE_CATEGORIES = new Set([
|
|
996
|
+
"no-extractable-effect",
|
|
997
|
+
"unextractable",
|
|
998
|
+
]);
|
|
999
|
+
function dedupeUnextractableHandlers(warnings) {
|
|
1000
|
+
const parsed = warnings.map(unextractableHandlerFromWarning).filter((handler) => Boolean(handler));
|
|
1001
|
+
const byId = new Map();
|
|
1002
|
+
for (const handler of parsed) {
|
|
1003
|
+
const existing = byId.get(handler.id);
|
|
1004
|
+
if (!existing) {
|
|
1005
|
+
byId.set(handler.id, handler);
|
|
1006
|
+
continue;
|
|
1007
|
+
}
|
|
1008
|
+
const existingIsGeneric = GENERIC_UNEXTRACTABLE_CATEGORIES.has(existing.category);
|
|
1009
|
+
const incomingIsGeneric = GENERIC_UNEXTRACTABLE_CATEGORIES.has(handler.category);
|
|
1010
|
+
if (existingIsGeneric && !incomingIsGeneric)
|
|
1011
|
+
byId.set(handler.id, handler);
|
|
1012
|
+
}
|
|
1013
|
+
return [...byId.values()]
|
|
1014
|
+
.map(({ id, reason, source }) => source ? { id, reason, source } : { id, reason })
|
|
1015
|
+
.sort(compareCaveats);
|
|
1016
|
+
}
|
|
823
1017
|
function unextractableHandlerFromWarning(warning) {
|
|
824
|
-
const
|
|
825
|
-
|
|
1018
|
+
const rich = /^Unextractable handler (\S+) \[([^\]]+)\] \((.+)\)$/.exec(warning);
|
|
1019
|
+
if (rich) {
|
|
1020
|
+
const id = rich[1];
|
|
1021
|
+
const category = rich[2];
|
|
1022
|
+
const source = rich[3];
|
|
1023
|
+
if (!id || !category || !source)
|
|
1024
|
+
return undefined;
|
|
1025
|
+
return {
|
|
1026
|
+
id,
|
|
1027
|
+
category,
|
|
1028
|
+
reason: `${category} at ${source}`,
|
|
1029
|
+
source,
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
const bare = /^Unextractable handler (\S+)$/.exec(warning);
|
|
1033
|
+
return bare?.[1]
|
|
1034
|
+
? { id: bare[1], category: "unextractable", reason: bare[0] }
|
|
1035
|
+
: undefined;
|
|
826
1036
|
}
|
|
827
1037
|
function pendingVars(effectApis, transitions = [], vars = [], maxPending = 3) {
|
|
828
1038
|
const enqueues = transitions.flatMap((transition) => enqueueOps(transition.effect));
|