@valbuild/cli 0.97.3 → 0.97.4

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 (32) hide show
  1. package/cli/dist/valbuild-cli-cli.cjs.dev.js +1471 -118
  2. package/cli/dist/valbuild-cli-cli.cjs.prod.js +1471 -118
  3. package/cli/dist/valbuild-cli-cli.esm.js +1469 -118
  4. package/package.json +6 -4
  5. package/src/__fixtures__/basic/val.config.ts +2 -2
  6. package/src/__fixtures__/basic/val.modules.ts +16 -0
  7. package/src/__fixtures__/debug-snapshot/.val/patches/11111111-1111-4111-8111-111111111111/patch.json +19 -0
  8. package/src/__fixtures__/debug-snapshot/.val/patches/22222222-2222-4222-8222-222222222222/patch.json +20 -0
  9. package/src/__fixtures__/debug-snapshot/.val/patches/head/patch.json +20 -0
  10. package/src/__fixtures__/debug-snapshot/content/projects.val.ts +23 -0
  11. package/src/__fixtures__/debug-snapshot/content/summary.ts +6 -0
  12. package/src/__fixtures__/debug-snapshot/content/tags.val.ts +10 -0
  13. package/src/__fixtures__/debug-snapshot/content/unrelated.val.ts +5 -0
  14. package/src/__fixtures__/debug-snapshot/tsconfig.json +12 -0
  15. package/src/__fixtures__/debug-snapshot/val.config.ts +5 -0
  16. package/src/__fixtures__/debug-snapshot/val.modules.ts +8 -0
  17. package/src/cli.ts +89 -2
  18. package/src/debug/context.ts +173 -0
  19. package/src/debug/importGraph.ts +126 -0
  20. package/src/debug/moduleClosure.ts +167 -0
  21. package/src/debug/report.ts +80 -0
  22. package/src/debug/snapshot.ts +497 -0
  23. package/src/debug/snapshotRoundTrip.test.ts +95 -0
  24. package/src/debug.test.ts +107 -0
  25. package/src/debug.ts +120 -0
  26. package/src/deleteUnappliablePatches.ts +139 -0
  27. package/src/listUnusedFiles.ts +16 -4
  28. package/src/runValidation.test.ts +6 -6
  29. package/src/runValidation.ts +40 -15
  30. package/src/utils/evalValConfigFile.ts +13 -5
  31. package/src/utils/sourcePathToFileLocation.ts +184 -0
  32. package/src/validate.ts +415 -154
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@valbuild/cli",
3
3
  "private": false,
4
- "version": "0.97.3",
4
+ "version": "0.97.4",
5
5
  "description": "Val CLI tools",
6
6
  "repository": {
7
7
  "type": "git",
@@ -19,16 +19,18 @@
19
19
  },
20
20
  "dependencies": {
21
21
  "chalk": "^5.6.2",
22
+ "chokidar": "^5.0.0",
22
23
  "cors": "^2.8.5",
23
24
  "fast-glob": "^3.3.3",
25
+ "jszip": "^3.10.1",
24
26
  "meow": "^9.0.0",
25
27
  "open": "^9.1.0",
26
28
  "picocolors": "^1.1.1",
27
29
  "zod": "^4.3.5",
28
- "@valbuild/core": "0.97.1",
30
+ "@valbuild/core": "0.97.4",
29
31
  "@valbuild/eslint-plugin": "0.93.0",
30
- "@valbuild/server": "0.97.3",
31
- "@valbuild/shared": "0.97.1"
32
+ "@valbuild/server": "0.97.4",
33
+ "@valbuild/shared": "0.97.4"
32
34
  },
33
35
  "peerDependencies": {
34
36
  "prettier": "*",
@@ -1,5 +1,5 @@
1
1
  import { initVal } from "@valbuild/core";
2
2
 
3
- const { s, c } = initVal();
3
+ const { s, c, config } = initVal();
4
4
 
5
- export { s, c };
5
+ export { s, c, config };
@@ -0,0 +1,16 @@
1
+ import { modules } from "@valbuild/core";
2
+ import { config } from "./val.config";
3
+
4
+ export default modules(config, [
5
+ { def: () => import("./content/basic-valid.val") },
6
+ { def: () => import("./content/basic-errors.val") },
7
+ { def: () => import("./content/basic-files.val") },
8
+ { def: () => import("./content/basic-image.val") },
9
+ { def: () => import("./content/basic-image-from-gallery.val") },
10
+ { def: () => import("./content/basic-image-from-galleries.val") },
11
+ { def: () => import("./content/basic-gallery.val") },
12
+ { def: () => import("./content/basic-gallery-2.val") },
13
+ { def: () => import("./content/basic-gallery-fail-on-non-unique-dir.val") },
14
+ { def: () => import("./content/basic-gallery-missing-tracked.val") },
15
+ { def: () => import("./content/basic-gallery-wrong-metadata.val") },
16
+ ]);
@@ -0,0 +1,19 @@
1
+ {
2
+ "patch": [
3
+ {
4
+ "op": "remove",
5
+ "path": ["BBL", "summary", "services", "2"]
6
+ }
7
+ ],
8
+ "patchId": "22222222-2222-4222-8222-222222222222",
9
+ "parentRef": {
10
+ "type": "patch",
11
+ "patchId": "11111111-1111-4111-8111-111111111111"
12
+ },
13
+ "path": "/content/projects.val.ts",
14
+ "authorId": "author-b",
15
+ "sessionId": null,
16
+ "baseSha": "0000000000000000000000000000000000000000000000000000000000000000",
17
+ "coreVersion": null,
18
+ "createdAt": "2026-08-11T09:00:00.000Z"
19
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "patch": [
3
+ {
4
+ "op": "replace",
5
+ "path": ["BBL", "summary", "services", "2"],
6
+ "value": "Development U"
7
+ }
8
+ ],
9
+ "patchId": "33333333-3333-4333-8333-333333333333",
10
+ "parentRef": {
11
+ "type": "patch",
12
+ "patchId": "22222222-2222-4222-8222-222222222222"
13
+ },
14
+ "path": "/content/projects.val.ts",
15
+ "authorId": "author-a",
16
+ "sessionId": null,
17
+ "baseSha": "0000000000000000000000000000000000000000000000000000000000000000",
18
+ "coreVersion": null,
19
+ "createdAt": "2026-08-11T09:00:05.000Z"
20
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "patch": [
3
+ {
4
+ "op": "replace",
5
+ "path": ["BBL", "title"],
6
+ "value": "BBL Housing"
7
+ }
8
+ ],
9
+ "patchId": "11111111-1111-4111-8111-111111111111",
10
+ "parentRef": {
11
+ "type": "head",
12
+ "headBaseSha": "0000000000000000000000000000000000000000000000000000000000000000"
13
+ },
14
+ "path": "/content/projects.val.ts",
15
+ "authorId": "author-a",
16
+ "sessionId": null,
17
+ "baseSha": "0000000000000000000000000000000000000000000000000000000000000000",
18
+ "coreVersion": null,
19
+ "createdAt": "2026-08-10T12:00:00.000Z"
20
+ }
@@ -0,0 +1,23 @@
1
+ import { c, s } from "../val.config";
2
+ import tagsVal from "./tags.val";
3
+ import { summarySchema } from "./summary";
4
+
5
+ export default c.define(
6
+ "/content/projects.val.ts",
7
+ s.record(
8
+ s.object({
9
+ title: s.string(),
10
+ tag: s.keyOf(tagsVal),
11
+ summary: summarySchema,
12
+ }),
13
+ ),
14
+ {
15
+ BBL: {
16
+ title: "BBL",
17
+ tag: "design",
18
+ summary: {
19
+ services: ["Strategy", "Design", "Development"],
20
+ },
21
+ },
22
+ },
23
+ );
@@ -0,0 +1,6 @@
1
+ import { s } from "../val.config";
2
+
3
+ // A shared schema fragment: a snapshot that omits this file cannot be evaluated.
4
+ export const summarySchema = s.object({
5
+ services: s.array(s.string()),
6
+ });
@@ -0,0 +1,10 @@
1
+ import { c, s } from "../val.config";
2
+
3
+ export default c.define(
4
+ "/content/tags.val.ts",
5
+ s.record(s.object({ label: s.string() })),
6
+ {
7
+ design: { label: "Design" },
8
+ tech: { label: "Tech" },
9
+ },
10
+ );
@@ -0,0 +1,5 @@
1
+ import { c, s } from "../val.config";
2
+
3
+ // Nothing references this module and no patch touches it, so a debug snapshot
4
+ // must NOT include it.
5
+ export default c.define("/content/unrelated.val.ts", s.string(), "unrelated");
@@ -0,0 +1,12 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2017",
4
+ "allowJs": true,
5
+ "skipLibCheck": true,
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "module": "esnext",
9
+ "moduleResolution": "node"
10
+ },
11
+ "exclude": ["node_modules"]
12
+ }
@@ -0,0 +1,5 @@
1
+ import { initVal } from "@valbuild/core";
2
+
3
+ const { s, c, config } = initVal({});
4
+
5
+ export { s, c, config };
@@ -0,0 +1,8 @@
1
+ import { modules } from "@valbuild/core";
2
+ import { config } from "./val.config";
3
+
4
+ export default modules(config, [
5
+ { def: () => import("./content/projects.val") },
6
+ { def: () => import("./content/tags.val") },
7
+ { def: () => import("./content/unrelated.val") },
8
+ ]);
package/src/cli.ts CHANGED
@@ -6,6 +6,8 @@ import { getVersions } from "./getVersions";
6
6
  import { connect } from "./connect";
7
7
  import chalk from "chalk";
8
8
  import { login } from "./login";
9
+ import { debug } from "./debug";
10
+ import { deleteUnappliablePatches } from "./deleteUnappliablePatches";
9
11
 
10
12
  async function main(): Promise<void> {
11
13
  const { input, flags, showHelp } = meow(
@@ -22,12 +24,15 @@ async function main(): Promise<void> {
22
24
  files
23
25
  connect
24
26
  versions
25
-
27
+ debug
28
+ delete-unappliable-patches
29
+
26
30
  Command: validate
27
31
  Description: val-idate val modules
28
32
  Options:
29
33
  --root [root], -r [root] Set project root directory (default process.cwd())
30
34
  --fix [fix] Attempt to fix validation errors
35
+ --watch, -w Re-validate on changes to val.config, val.modules and *.val files
31
36
 
32
37
 
33
38
  Command: login
@@ -43,10 +48,38 @@ async function main(): Promise<void> {
43
48
 
44
49
  Command: list-unused-files
45
50
  Description: EXPERIMENTAL.
46
- List files that are in public/val but not in use by any Val module.
51
+ List files that are in the configured files directory (files.directory, default public/val) but not in use by any Val module.
47
52
  This is useful for cleaning up unused files.
48
53
  Options:
49
54
  --root [root], -r [root] Set project root directory (default process.cwd())
55
+
56
+ Command: debug
57
+ Description: Create a self-contained snapshot (the pending patches plus the modules they
58
+ touch or reference) that Val developers can replay to reproduce validation and save
59
+ errors. Read-only.
60
+ Options:
61
+ --root [root], -r [root] Set project root directory (default process.cwd())
62
+ --out [file] Output zip (default ./val-debug-<branch>-<commit>-<timestamp>.zip)
63
+ --remote Read the patches from the hosted project instead of <root>/.val
64
+ (requires "val login"; implied by VAL_API_KEY)
65
+ --commit [sha] Commit to read module sources at (default VAL_GIT_COMMIT, else git HEAD)
66
+ --branch [name] Branch (default VAL_GIT_BRANCH, else the current git branch)
67
+ --include-files Also download the binary files the patches reference
68
+ --verbose List every pending patch, not just the failing ones
69
+
70
+ Command: delete-unappliable-patches
71
+ Description: Delete the pending patches that cannot be applied. This is what unblocks a
72
+ publish failing with "Failed to create commit". Capture a "val debug" snapshot first:
73
+ deleting discards the changes those patches contain.
74
+ Options:
75
+ --root [root], -r [root] Set project root directory (default process.cwd())
76
+ --remote Operate on the hosted project instead of <root>/.val
77
+ (requires "val login"; implied by VAL_API_KEY)
78
+ --commit [sha] Commit to read module sources at (default VAL_GIT_COMMIT, else git HEAD)
79
+ --branch [name] Branch (default VAL_GIT_BRANCH, else the current git branch)
80
+ --dry-run Only list what would be deleted
81
+ --yes Do not ask for confirmation
82
+ --verbose List every pending patch, not just the failing ones
50
83
  `,
51
84
  {
52
85
  flags: {
@@ -62,12 +95,40 @@ async function main(): Promise<void> {
62
95
  fix: {
63
96
  type: "boolean",
64
97
  },
98
+ watch: {
99
+ type: "boolean",
100
+ alias: "w",
101
+ },
65
102
  noEslint: {
66
103
  type: "boolean",
67
104
  },
68
105
  managedDir: {
69
106
  type: "string",
70
107
  },
108
+ out: {
109
+ type: "string",
110
+ },
111
+ commit: {
112
+ type: "string",
113
+ },
114
+ branch: {
115
+ type: "string",
116
+ },
117
+ includeFiles: {
118
+ type: "boolean",
119
+ },
120
+ remote: {
121
+ type: "boolean",
122
+ },
123
+ dryRun: {
124
+ type: "boolean",
125
+ },
126
+ yes: {
127
+ type: "boolean",
128
+ },
129
+ verbose: {
130
+ type: "boolean",
131
+ },
71
132
  },
72
133
  hardRejection: false,
73
134
  },
@@ -94,6 +155,26 @@ async function main(): Promise<void> {
94
155
  });
95
156
  case "versions":
96
157
  return versions();
158
+ case "debug":
159
+ return debug({
160
+ root: flags.root,
161
+ out: flags.out,
162
+ commit: flags.commit,
163
+ branch: flags.branch,
164
+ remote: flags.remote,
165
+ includeFiles: flags.includeFiles,
166
+ verbose: flags.verbose,
167
+ });
168
+ case "delete-unappliable-patches":
169
+ return deleteUnappliablePatches({
170
+ root: flags.root,
171
+ commit: flags.commit,
172
+ branch: flags.branch,
173
+ remote: flags.remote,
174
+ dryRun: flags.dryRun,
175
+ yes: flags.yes,
176
+ verbose: flags.verbose,
177
+ });
97
178
  case "login":
98
179
  return login({
99
180
  root: flags.root,
@@ -112,9 +193,15 @@ async function main(): Promise<void> {
112
193
  if (flags.managedDir) {
113
194
  return error(`Command "validate" does not support --managedDir flag`);
114
195
  }
196
+ if (flags.watch && flags.fix) {
197
+ return error(
198
+ `Command "validate" does not support --watch together with --fix`,
199
+ );
200
+ }
115
201
  return validate({
116
202
  root: flags.root,
117
203
  fix: flags.fix,
204
+ watch: flags.watch,
118
205
  });
119
206
  default:
120
207
  return error(`Unknown command "${input.join(" ")}"`);
@@ -0,0 +1,173 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { DEFAULT_CONTENT_HOST, ValConfig, ValModules } from "@valbuild/core";
4
+ import {
5
+ getPersonalAccessTokenPath,
6
+ loadValModules,
7
+ parsePersonalAccessTokenFile,
8
+ safeReadGit,
9
+ ValOpsFS,
10
+ ValOpsHttp,
11
+ } from "@valbuild/server";
12
+ import { evalValConfigFile } from "../utils/evalValConfigFile";
13
+
14
+ /**
15
+ * Everything the debug commands need in order to talk to the same ops the
16
+ * running app talks to.
17
+ *
18
+ * Mirrors how the app itself decides between fs and http mode in
19
+ * `initHandlerOptions` (packages/server/src/ValRouter.ts): a project name plus
20
+ * credentials means the patches live in the hosted content service, otherwise
21
+ * they are on disk under `<root>/.val`.
22
+ */
23
+ export type DebugContext = {
24
+ projectRoot: string;
25
+ valModules: ValModules;
26
+ config: ValConfig;
27
+ mode: "fs" | "http";
28
+ project: string | null;
29
+ branch: string | null;
30
+ commit: string | null;
31
+ /** How we authenticated, for the snapshot manifest. Never the secret itself. */
32
+ authKind: "pat" | "api-key" | "none";
33
+ contentUrl: string;
34
+ filesDirectory: string;
35
+ serverOps: ValOpsFS | ValOpsHttp;
36
+ };
37
+
38
+ export class DebugContextError extends Error {}
39
+
40
+ export async function createDebugContext(options: {
41
+ root?: string;
42
+ commit?: string;
43
+ branch?: string;
44
+ /**
45
+ * Read patches from the hosted content service rather than `<root>/.val`.
46
+ *
47
+ * Has to be explicit: whether a project's patches live remotely depends on
48
+ * how the *deployed* app is configured (proxy mode), which a local checkout
49
+ * cannot know. A `val login` token says nothing about it either - it is a user
50
+ * credential, not a signal. We default to fs mode and let VAL_API_KEY (the
51
+ * same thing that puts the app in proxy mode) opt in automatically.
52
+ */
53
+ remote?: boolean;
54
+ }): Promise<DebugContext> {
55
+ const projectRoot = options.root ? path.resolve(options.root) : process.cwd();
56
+ if (!fs.existsSync(projectRoot)) {
57
+ throw new DebugContextError(`Project root does not exist: ${projectRoot}`);
58
+ }
59
+ const config =
60
+ (await evalValConfigFile(projectRoot, "val.config.ts")) ||
61
+ (await evalValConfigFile(projectRoot, "val.config.js"));
62
+ if (!config) {
63
+ throw new DebugContextError(
64
+ `Could not find val.config.ts nor val.config.js in: ${projectRoot}`,
65
+ );
66
+ }
67
+ const valModules = loadValModules(projectRoot);
68
+ const contentUrl = process.env.VAL_CONTENT_URL || DEFAULT_CONTENT_HOST;
69
+ const filesDirectory = config.files?.directory ?? "/public/val";
70
+ const project = config.project || process.env.VAL_PROJECT || null;
71
+ const git = await safeReadGit(projectRoot);
72
+ const branch =
73
+ options.branch ||
74
+ config.gitBranch ||
75
+ process.env.VAL_GIT_BRANCH ||
76
+ git.branch ||
77
+ null;
78
+ const commit =
79
+ options.commit ||
80
+ config.gitCommit ||
81
+ process.env.VAL_GIT_COMMIT ||
82
+ git.commit ||
83
+ null;
84
+
85
+ const wantsRemote = options.remote || !!process.env.VAL_API_KEY;
86
+ const auth = wantsRemote ? readAuth(projectRoot) : null;
87
+ if (wantsRemote) {
88
+ if (!project) {
89
+ throw new DebugContextError(
90
+ "Cannot read remote patches: no project is configured.\n" +
91
+ "Set 'project' in val.config, or the VAL_PROJECT env var.",
92
+ );
93
+ }
94
+ if (!auth) {
95
+ throw new DebugContextError(
96
+ "Cannot read remote patches: you are not logged in.\n\n\tnpx val login\n\n" +
97
+ "(or set the VAL_API_KEY env var)",
98
+ );
99
+ }
100
+ if (!branch) {
101
+ throw new DebugContextError(
102
+ "Could not determine the branch. Pass --branch, or set VAL_GIT_BRANCH.",
103
+ );
104
+ }
105
+ if (!commit) {
106
+ throw new DebugContextError(
107
+ "Could not determine the commit. Pass --commit, or set VAL_GIT_COMMIT.\n" +
108
+ "This must be the commit the app was deployed from: it is the commit the module sources are read at.",
109
+ );
110
+ }
111
+ return {
112
+ projectRoot,
113
+ valModules,
114
+ config,
115
+ mode: "http",
116
+ project,
117
+ branch,
118
+ commit,
119
+ authKind: "pat" in auth ? "pat" : "api-key",
120
+ contentUrl,
121
+ filesDirectory,
122
+ serverOps: new ValOpsHttp(
123
+ contentUrl,
124
+ project,
125
+ commit,
126
+ branch,
127
+ auth,
128
+ valModules,
129
+ { root: config.root, config },
130
+ ),
131
+ };
132
+ }
133
+ return {
134
+ projectRoot,
135
+ valModules,
136
+ config,
137
+ mode: "fs",
138
+ project,
139
+ branch,
140
+ commit,
141
+ authKind: "none",
142
+ contentUrl,
143
+ filesDirectory,
144
+ serverOps: new ValOpsFS(contentUrl, projectRoot, valModules, { config }),
145
+ };
146
+ }
147
+
148
+ /**
149
+ * The personal access token written by `val login`, falling back to the api key
150
+ * the app itself uses. Reading the pat file is the same dance as
151
+ * `resolveRemoteFile` in runValidation.ts.
152
+ */
153
+ function readAuth(
154
+ projectRoot: string,
155
+ ): { pat: string } | { apiKey: string } | null {
156
+ const patFile = getPersonalAccessTokenPath(projectRoot);
157
+ if (fs.existsSync(patFile)) {
158
+ const contents = fs.readFileSync(patFile, "utf-8");
159
+ const parsed = parsePersonalAccessTokenFile(contents);
160
+ if (parsed.success) {
161
+ return { pat: parsed.data.pat };
162
+ }
163
+ throw new DebugContextError(
164
+ `Could not parse the personal access token at ${patFile}: ${parsed.error}.\n` +
165
+ `Log in again:\n\n\tnpx val login`,
166
+ );
167
+ }
168
+ const apiKey = process.env.VAL_API_KEY;
169
+ if (apiKey) {
170
+ return { apiKey };
171
+ }
172
+ return null;
173
+ }
@@ -0,0 +1,126 @@
1
+ import path from "path";
2
+ import ts from "typescript";
3
+
4
+ const RESOLVE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".cjs", ".mjs"];
5
+
6
+ export type ImportGraphResult = {
7
+ /** Project-relative path (leading "/") -> file contents. */
8
+ files: Record<string, string>;
9
+ /**
10
+ * Specifiers we could not resolve to a project file: bare packages (fine,
11
+ * they come from node_modules) and tsconfig path aliases (NOT fine - the
12
+ * snapshot will not evaluate). Recorded so the manifest can say so out loud
13
+ * rather than shipping a bundle that mysteriously fails to load.
14
+ */
15
+ unresolved: { from: string; specifier: string }[];
16
+ };
17
+
18
+ /**
19
+ * Walks the relative-import graph of `entryFiles` and reads every project file
20
+ * it reaches, using `readFile` so the contents come from the same revision as
21
+ * the modules themselves (the deployed commit in http mode).
22
+ *
23
+ * `loadValModules` evaluates val.modules.ts and everything it imports in a vm,
24
+ * so a snapshot that is missing an imported file (a shared schema fragment, or
25
+ * val.config itself) cannot be replayed at all.
26
+ */
27
+ export async function collectImportedProjectFiles(
28
+ entryFiles: { path: string; contents: string }[],
29
+ readFile: (projectRelativePath: string) => Promise<string | null>,
30
+ ): Promise<ImportGraphResult> {
31
+ const files: Record<string, string> = {};
32
+ const unresolved: { from: string; specifier: string }[] = [];
33
+ const seen = new Set<string>();
34
+ const queue: { path: string; contents: string }[] = [];
35
+
36
+ for (const entry of entryFiles) {
37
+ seen.add(entry.path);
38
+ queue.push(entry);
39
+ }
40
+
41
+ while (queue.length > 0) {
42
+ const current = queue.shift();
43
+ if (current === undefined) {
44
+ continue;
45
+ }
46
+ const dir = path.posix.dirname(current.path);
47
+ for (const specifier of readImportSpecifiers(
48
+ current.path,
49
+ current.contents,
50
+ )) {
51
+ if (!specifier.startsWith(".")) {
52
+ unresolved.push({ from: current.path, specifier });
53
+ continue;
54
+ }
55
+ const base = path.posix.resolve(dir, specifier);
56
+ const resolved = await resolveProjectFile(base, readFile);
57
+ if (!resolved) {
58
+ unresolved.push({ from: current.path, specifier });
59
+ continue;
60
+ }
61
+ if (seen.has(resolved.path)) {
62
+ continue;
63
+ }
64
+ seen.add(resolved.path);
65
+ files[resolved.path] = resolved.contents;
66
+ queue.push(resolved);
67
+ }
68
+ }
69
+
70
+ return { files, unresolved };
71
+ }
72
+
73
+ async function resolveProjectFile(
74
+ base: string,
75
+ readFile: (projectRelativePath: string) => Promise<string | null>,
76
+ ): Promise<{ path: string; contents: string } | null> {
77
+ const candidates = [
78
+ base,
79
+ ...RESOLVE_EXTENSIONS.map((ext) => base + ext),
80
+ ...RESOLVE_EXTENSIONS.map((ext) => path.posix.join(base, "index" + ext)),
81
+ ];
82
+ for (const candidate of candidates) {
83
+ const contents = await readFile(candidate);
84
+ if (contents !== null) {
85
+ return { path: candidate, contents };
86
+ }
87
+ }
88
+ return null;
89
+ }
90
+
91
+ /**
92
+ * Every module specifier in the file: static imports/exports and the dynamic
93
+ * `import()` calls val.modules.ts is built out of.
94
+ */
95
+ function readImportSpecifiers(filePath: string, contents: string): string[] {
96
+ const sourceFile = ts.createSourceFile(
97
+ filePath,
98
+ contents,
99
+ ts.ScriptTarget.ES2020,
100
+ true,
101
+ );
102
+ const specifiers: string[] = [];
103
+ const visit = (node: ts.Node) => {
104
+ if (
105
+ (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) &&
106
+ node.moduleSpecifier &&
107
+ ts.isStringLiteral(node.moduleSpecifier)
108
+ ) {
109
+ specifiers.push(node.moduleSpecifier.text);
110
+ } else if (
111
+ ts.isCallExpression(node) &&
112
+ (node.expression.kind === ts.SyntaxKind.ImportKeyword ||
113
+ (ts.isIdentifier(node.expression) &&
114
+ node.expression.text === "require")) &&
115
+ node.arguments.length > 0
116
+ ) {
117
+ const arg = node.arguments[0];
118
+ if (ts.isStringLiteral(arg)) {
119
+ specifiers.push(arg.text);
120
+ }
121
+ }
122
+ ts.forEachChild(node, visit);
123
+ };
124
+ visit(sourceFile);
125
+ return specifiers;
126
+ }