modality-ts 0.0.10 → 0.0.11

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.
Files changed (200) hide show
  1. package/README.md +3 -1
  2. package/dist/check/diagnostics/bounds.d.ts +2 -1
  3. package/dist/check/diagnostics/bounds.d.ts.map +1 -1
  4. package/dist/check/diagnostics/bounds.js +3 -3
  5. package/dist/check/diagnostics/bounds.js.map +1 -1
  6. package/dist/check/engine/check-model.d.ts +3 -0
  7. package/dist/check/engine/check-model.d.ts.map +1 -1
  8. package/dist/check/engine/check-model.js +408 -43
  9. package/dist/check/engine/check-model.js.map +1 -1
  10. package/dist/check/engine/model-api.d.ts.map +1 -1
  11. package/dist/check/engine/model-api.js +5 -3
  12. package/dist/check/engine/model-api.js.map +1 -1
  13. package/dist/check/engine/stabilize.d.ts +2 -1
  14. package/dist/check/engine/stabilize.d.ts.map +1 -1
  15. package/dist/check/engine/stabilize.js +31 -9
  16. package/dist/check/engine/stabilize.js.map +1 -1
  17. package/dist/check/engine/state-utils.d.ts +3 -2
  18. package/dist/check/engine/state-utils.d.ts.map +1 -1
  19. package/dist/check/engine/state-utils.js +20 -7
  20. package/dist/check/engine/state-utils.js.map +1 -1
  21. package/dist/check/engine/transitions.d.ts +9 -1
  22. package/dist/check/engine/transitions.d.ts.map +1 -1
  23. package/dist/check/engine/transitions.js +32 -5
  24. package/dist/check/engine/transitions.js.map +1 -1
  25. package/dist/check/index.d.ts +1 -1
  26. package/dist/check/index.d.ts.map +1 -1
  27. package/dist/check/index.js.map +1 -1
  28. package/dist/check/properties/finalize.d.ts +4 -3
  29. package/dist/check/properties/finalize.d.ts.map +1 -1
  30. package/dist/check/properties/finalize.js +45 -9
  31. package/dist/check/properties/finalize.js.map +1 -1
  32. package/dist/check/properties/leads-to.d.ts.map +1 -1
  33. package/dist/check/properties/leads-to.js +4 -3
  34. package/dist/check/properties/leads-to.js.map +1 -1
  35. package/dist/check/properties/observe.d.ts +4 -3
  36. package/dist/check/properties/observe.d.ts.map +1 -1
  37. package/dist/check/properties/observe.js +5 -5
  38. package/dist/check/properties/observe.js.map +1 -1
  39. package/dist/check/properties/reachable-from.d.ts +4 -2
  40. package/dist/check/properties/reachable-from.d.ts.map +1 -1
  41. package/dist/check/properties/reachable-from.js +2 -2
  42. package/dist/check/properties/reachable-from.js.map +1 -1
  43. package/dist/check/slicing/slice-model.d.ts +1 -0
  44. package/dist/check/slicing/slice-model.d.ts.map +1 -1
  45. package/dist/check/slicing/slice-model.js +38 -12
  46. package/dist/check/slicing/slice-model.js.map +1 -1
  47. package/dist/check/traces/trace.d.ts +7 -2
  48. package/dist/check/traces/trace.d.ts.map +1 -1
  49. package/dist/check/traces/trace.js +9 -4
  50. package/dist/check/traces/trace.js.map +1 -1
  51. package/dist/check/types.d.ts +83 -3
  52. package/dist/check/types.d.ts.map +1 -1
  53. package/dist/cli/cli.js +76 -4
  54. package/dist/cli/cli.js.map +1 -1
  55. package/dist/cli/features/check/command.d.ts +6 -0
  56. package/dist/cli/features/check/command.d.ts.map +1 -1
  57. package/dist/cli/features/check/command.js +74 -1
  58. package/dist/cli/features/check/command.js.map +1 -1
  59. package/dist/cli/features/conform/command.d.ts.map +1 -1
  60. package/dist/cli/features/conform/command.js +3 -1
  61. package/dist/cli/features/conform/command.js.map +1 -1
  62. package/dist/cli/features/export/command.js +12 -4
  63. package/dist/cli/features/export/command.js.map +1 -1
  64. package/dist/cli/features/extract/command.d.ts.map +1 -1
  65. package/dist/cli/features/extract/command.js +302 -92
  66. package/dist/cli/features/extract/command.js.map +1 -1
  67. package/dist/cli/features/init/command.js +1 -1
  68. package/dist/cli/features/init/command.js.map +1 -1
  69. package/dist/cli/harness/index.d.ts.map +1 -1
  70. package/dist/cli/harness/index.js +17 -4
  71. package/dist/cli/harness/index.js.map +1 -1
  72. package/dist/cli/registry/index.d.ts +4 -4
  73. package/dist/cli/registry/index.d.ts.map +1 -1
  74. package/dist/cli/registry/index.js +6 -4
  75. package/dist/cli/registry/index.js.map +1 -1
  76. package/dist/core/ir/domains.d.ts +2 -0
  77. package/dist/core/ir/domains.d.ts.map +1 -1
  78. package/dist/core/ir/domains.js +71 -0
  79. package/dist/core/ir/domains.js.map +1 -1
  80. package/dist/core/ir/validator.d.ts +4 -1
  81. package/dist/core/ir/validator.d.ts.map +1 -1
  82. package/dist/core/ir/validator.js +45 -12
  83. package/dist/core/ir/validator.js.map +1 -1
  84. package/dist/core/props/index.d.ts.map +1 -1
  85. package/dist/core/props/index.js.map +1 -1
  86. package/dist/core/report/types.d.ts +68 -0
  87. package/dist/core/report/types.d.ts.map +1 -1
  88. package/dist/extract/engine/pipeline/index.d.ts +3 -1
  89. package/dist/extract/engine/pipeline/index.d.ts.map +1 -1
  90. package/dist/extract/engine/pipeline/index.js +10 -23
  91. package/dist/extract/engine/pipeline/index.js.map +1 -1
  92. package/dist/extract/engine/spi/index.d.ts +38 -7
  93. package/dist/extract/engine/spi/index.d.ts.map +1 -1
  94. package/dist/extract/engine/spi/index.js +1 -1
  95. package/dist/extract/engine/spi/index.js.map +1 -1
  96. package/dist/extract/engine/ts/domains.d.ts +1 -1
  97. package/dist/extract/engine/ts/domains.d.ts.map +1 -1
  98. package/dist/extract/engine/ts/domains.js +25 -17
  99. package/dist/extract/engine/ts/domains.js.map +1 -1
  100. package/dist/extract/engine/ts/react-source-transitions.d.ts +2 -1
  101. package/dist/extract/engine/ts/react-source-transitions.d.ts.map +1 -1
  102. package/dist/extract/engine/ts/react-source-transitions.js +17 -7
  103. package/dist/extract/engine/ts/react-source-transitions.js.map +1 -1
  104. package/dist/extract/engine/ts/routes.d.ts +2 -2
  105. package/dist/extract/engine/ts/routes.d.ts.map +1 -1
  106. package/dist/extract/engine/ts/routes.js +5 -24
  107. package/dist/extract/engine/ts/routes.js.map +1 -1
  108. package/dist/extract/engine/ts/static-navigation.d.ts +2 -1
  109. package/dist/extract/engine/ts/static-navigation.d.ts.map +1 -1
  110. package/dist/extract/engine/ts/static-navigation.js +30 -20
  111. package/dist/extract/engine/ts/static-navigation.js.map +1 -1
  112. package/dist/extract/engine/ts/transition/async.d.ts +2 -10
  113. package/dist/extract/engine/ts/transition/async.d.ts.map +1 -1
  114. package/dist/extract/engine/ts/transition/async.js +30 -61
  115. package/dist/extract/engine/ts/transition/async.js.map +1 -1
  116. package/dist/extract/engine/ts/transition/effects.d.ts.map +1 -1
  117. package/dist/extract/engine/ts/transition/effects.js +7 -4
  118. package/dist/extract/engine/ts/transition/effects.js.map +1 -1
  119. package/dist/extract/engine/ts/transition/expressions.d.ts.map +1 -1
  120. package/dist/extract/engine/ts/transition/expressions.js +3 -1
  121. package/dist/extract/engine/ts/transition/expressions.js.map +1 -1
  122. package/dist/extract/engine/ts/transition/guards.d.ts.map +1 -1
  123. package/dist/extract/engine/ts/transition/guards.js +1 -1
  124. package/dist/extract/engine/ts/transition/guards.js.map +1 -1
  125. package/dist/extract/engine/ts/transition/handlers.d.ts.map +1 -1
  126. package/dist/extract/engine/ts/transition/handlers.js +7 -5
  127. package/dist/extract/engine/ts/transition/handlers.js.map +1 -1
  128. package/dist/extract/engine/ts/transition/locals.d.ts.map +1 -1
  129. package/dist/extract/engine/ts/transition/locals.js +2 -1
  130. package/dist/extract/engine/ts/transition/locals.js.map +1 -1
  131. package/dist/extract/engine/ts/transition/navigation.d.ts +7 -5
  132. package/dist/extract/engine/ts/transition/navigation.d.ts.map +1 -1
  133. package/dist/extract/engine/ts/transition/navigation.js +85 -41
  134. package/dist/extract/engine/ts/transition/navigation.js.map +1 -1
  135. package/dist/extract/engine/ts/transition/plugin-calls.d.ts.map +1 -1
  136. package/dist/extract/engine/ts/transition/plugin-calls.js +1 -1
  137. package/dist/extract/engine/ts/transition/plugin-calls.js.map +1 -1
  138. package/dist/extract/engine/ts/transition/timers.d.ts.map +1 -1
  139. package/dist/extract/engine/ts/transition/timers.js +5 -3
  140. package/dist/extract/engine/ts/transition/timers.js.map +1 -1
  141. package/dist/extract/sources/jotai/domains.d.ts +2 -1
  142. package/dist/extract/sources/jotai/domains.d.ts.map +1 -1
  143. package/dist/extract/sources/jotai/domains.js +2 -164
  144. package/dist/extract/sources/jotai/domains.js.map +1 -1
  145. package/dist/extract/sources/jotai/harness.d.ts.map +1 -1
  146. package/dist/extract/sources/jotai/harness.js +5 -2
  147. package/dist/extract/sources/jotai/harness.js.map +1 -1
  148. package/dist/extract/sources/jotai/transitions.d.ts.map +1 -1
  149. package/dist/extract/sources/jotai/transitions.js +1 -4
  150. package/dist/extract/sources/jotai/transitions.js.map +1 -1
  151. package/dist/extract/sources/router/discover.d.ts +9 -0
  152. package/dist/extract/sources/router/discover.d.ts.map +1 -0
  153. package/dist/extract/sources/router/discover.js +135 -0
  154. package/dist/extract/sources/router/discover.js.map +1 -0
  155. package/dist/extract/sources/router/index.d.ts +7 -3
  156. package/dist/extract/sources/router/index.d.ts.map +1 -1
  157. package/dist/extract/sources/router/index.js +21 -12
  158. package/dist/extract/sources/router/index.js.map +1 -1
  159. package/dist/extract/sources/router/navigation.d.ts +3 -4
  160. package/dist/extract/sources/router/navigation.d.ts.map +1 -1
  161. package/dist/extract/sources/router/navigation.js +33 -1
  162. package/dist/extract/sources/router/navigation.js.map +1 -1
  163. package/dist/extract/sources/router/redirects.d.ts +4 -0
  164. package/dist/extract/sources/router/redirects.d.ts.map +1 -0
  165. package/dist/extract/sources/router/redirects.js +37 -0
  166. package/dist/extract/sources/router/redirects.js.map +1 -0
  167. package/dist/extract/sources/router/routes.d.ts +2 -2
  168. package/dist/extract/sources/router/routes.d.ts.map +1 -1
  169. package/dist/extract/sources/router/routes.js +24 -6
  170. package/dist/extract/sources/router/routes.js.map +1 -1
  171. package/dist/extract/sources/shared/react-transition-extract.d.ts +1 -0
  172. package/dist/extract/sources/shared/react-transition-extract.d.ts.map +1 -1
  173. package/dist/extract/sources/shared/react-transition-extract.js +1 -0
  174. package/dist/extract/sources/shared/react-transition-extract.js.map +1 -1
  175. package/dist/extract/sources/swr/domains.d.ts +3 -2
  176. package/dist/extract/sources/swr/domains.d.ts.map +1 -1
  177. package/dist/extract/sources/swr/domains.js +3 -96
  178. package/dist/extract/sources/swr/domains.js.map +1 -1
  179. package/dist/extract/sources/swr/harness.d.ts.map +1 -1
  180. package/dist/extract/sources/swr/harness.js +13 -6
  181. package/dist/extract/sources/swr/harness.js.map +1 -1
  182. package/dist/extract/sources/swr/template.js +1 -1
  183. package/dist/extract/sources/swr/template.js.map +1 -1
  184. package/dist/extract/sources/swr/transitions.d.ts.map +1 -1
  185. package/dist/extract/sources/swr/transitions.js.map +1 -1
  186. package/dist/extract/sources/swr/writes.d.ts.map +1 -1
  187. package/dist/extract/sources/swr/writes.js +1 -1
  188. package/dist/extract/sources/swr/writes.js.map +1 -1
  189. package/dist/extract/sources/use-state/harness.d.ts.map +1 -1
  190. package/dist/extract/sources/use-state/harness.js +5 -2
  191. package/dist/extract/sources/use-state/harness.js.map +1 -1
  192. package/dist/extract/sources/use-state/index.d.ts.map +1 -1
  193. package/dist/extract/sources/use-state/index.js +1 -166
  194. package/dist/extract/sources/use-state/index.js.map +1 -1
  195. package/dist/extract/sources/use-state/transitions.d.ts.map +1 -1
  196. package/dist/extract/sources/use-state/transitions.js +1 -0
  197. package/dist/extract/sources/use-state/transitions.js.map +1 -1
  198. package/dist/extract/sources/use-state/types.d.ts +1 -0
  199. package/dist/extract/sources/use-state/types.d.ts.map +1 -1
  200. 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 project = await loadExtractionProject(sourcePaths);
15
- const config = await loadModalityConfig(options.configPath ?? (await findNearestConfig(project.configStartDir)));
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(project.configStartDir));
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: project.routes,
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 discoveredRoutes = uniqueStrings([
57
- route,
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: uniqueStrings(routeEntries.map((entry) => entry.pattern)),
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: uniqueStrings(projects.flatMap((project) => project.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
- return prefix === "" ? parse(paths[0]).root : prefix;
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 createExtractionReport(sourceFiles, model, warnings, ignoredVars, now) {
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 transitionNavigatedRoutes(transitions) {
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
- reasons.add(`havoc write to ${variable}`);
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
- let diagonal = previous[0];
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 up = previous[rightIndex] + 1;
806
- const leftCost = previous[rightIndex - 1] + 1;
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
- diagonal = previous[rightIndex];
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
- return previous[right.length];
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 match = /^Unextractable handler (.+)$/.exec(warning);
825
- return match?.[1] ? { id: match[1], reason: warning } : undefined;
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));