@shivaedev/build 0.0.0 → 0.1.1

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 (209) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/LICENSE +21 -0
  3. package/README.md +215 -1
  4. package/dist/BuildFailure.d.ts +9 -0
  5. package/dist/BuildFailure.d.ts.map +1 -0
  6. package/dist/BuildFailure.js +4 -0
  7. package/dist/BuildFailure.js.map +1 -0
  8. package/dist/adapters/appTestConfig.d.ts +4 -0
  9. package/dist/adapters/appTestConfig.d.ts.map +1 -0
  10. package/dist/adapters/appTestConfig.js +17 -0
  11. package/dist/adapters/appTestConfig.js.map +1 -0
  12. package/dist/adapters/appViteConfig.d.ts +26 -0
  13. package/dist/adapters/appViteConfig.d.ts.map +1 -0
  14. package/dist/adapters/appViteConfig.js +18 -0
  15. package/dist/adapters/appViteConfig.js.map +1 -0
  16. package/dist/adapters/bundleCheck.d.ts +3 -0
  17. package/dist/adapters/bundleCheck.d.ts.map +1 -0
  18. package/dist/adapters/bundleCheck.js +53 -0
  19. package/dist/adapters/bundleCheck.js.map +1 -0
  20. package/dist/adapters/copiedCode.d.ts +9 -0
  21. package/dist/adapters/copiedCode.d.ts.map +1 -0
  22. package/dist/adapters/copiedCode.js +84 -0
  23. package/dist/adapters/copiedCode.js.map +1 -0
  24. package/dist/adapters/failuresOf.d.ts +5 -0
  25. package/dist/adapters/failuresOf.d.ts.map +1 -0
  26. package/dist/adapters/failuresOf.js +29 -0
  27. package/dist/adapters/failuresOf.js.map +1 -0
  28. package/dist/adapters/isReadonlyProperty.d.ts +10 -0
  29. package/dist/adapters/isReadonlyProperty.d.ts.map +1 -0
  30. package/dist/adapters/isReadonlyProperty.js +7 -0
  31. package/dist/adapters/isReadonlyProperty.js.map +1 -0
  32. package/dist/adapters/moduleCalls.d.ts +10 -0
  33. package/dist/adapters/moduleCalls.d.ts.map +1 -0
  34. package/dist/adapters/moduleCalls.js +47 -0
  35. package/dist/adapters/moduleCalls.js.map +1 -0
  36. package/dist/adapters/moduleFacts.d.ts +4 -0
  37. package/dist/adapters/moduleFacts.d.ts.map +1 -0
  38. package/dist/adapters/moduleFacts.js +63 -0
  39. package/dist/adapters/moduleFacts.js.map +1 -0
  40. package/dist/adapters/objectForm.d.ts +8 -0
  41. package/dist/adapters/objectForm.d.ts.map +1 -0
  42. package/dist/adapters/objectForm.js +66 -0
  43. package/dist/adapters/objectForm.js.map +1 -0
  44. package/dist/adapters/operationFacts.d.ts +10 -0
  45. package/dist/adapters/operationFacts.d.ts.map +1 -0
  46. package/dist/adapters/operationFacts.js +72 -0
  47. package/dist/adapters/operationFacts.js.map +1 -0
  48. package/dist/adapters/runNode.d.ts +6 -0
  49. package/dist/adapters/runNode.d.ts.map +1 -0
  50. package/dist/adapters/runNode.js +14 -0
  51. package/dist/adapters/runNode.js.map +1 -0
  52. package/dist/adapters/typeReading.d.ts +15 -0
  53. package/dist/adapters/typeReading.d.ts.map +1 -0
  54. package/dist/adapters/typeReading.js +46 -0
  55. package/dist/adapters/typeReading.js.map +1 -0
  56. package/dist/adapters/typeScript.d.ts +15 -0
  57. package/dist/adapters/typeScript.d.ts.map +1 -0
  58. package/dist/adapters/typeScript.js +81 -0
  59. package/dist/adapters/typeScript.js.map +1 -0
  60. package/dist/adapters/unionForm.d.ts +7 -0
  61. package/dist/adapters/unionForm.d.ts.map +1 -0
  62. package/dist/adapters/unionForm.js +78 -0
  63. package/dist/adapters/unionForm.js.map +1 -0
  64. package/dist/adapters/vitePlugin.d.ts +7 -0
  65. package/dist/adapters/vitePlugin.d.ts.map +1 -0
  66. package/dist/adapters/vitePlugin.js +88 -0
  67. package/dist/adapters/vitePlugin.js.map +1 -0
  68. package/dist/adapters/vitePort.d.ts +2 -0
  69. package/dist/adapters/vitePort.d.ts.map +1 -0
  70. package/dist/adapters/vitePort.js +5 -0
  71. package/dist/adapters/vitePort.js.map +1 -0
  72. package/dist/adapters/vitest/setup.d.ts +3 -0
  73. package/dist/adapters/vitest/setup.d.ts.map +1 -0
  74. package/dist/adapters/vitest/setup.js +11 -0
  75. package/dist/adapters/vitest/setup.js.map +1 -0
  76. package/dist/adapters/wireForm.d.ts +5 -0
  77. package/dist/adapters/wireForm.d.ts.map +1 -0
  78. package/dist/adapters/wireForm.js +91 -0
  79. package/dist/adapters/wireForm.js.map +1 -0
  80. package/dist/byText.d.ts +2 -0
  81. package/dist/byText.d.ts.map +1 -0
  82. package/dist/byText.js +8 -0
  83. package/dist/byText.js.map +1 -0
  84. package/dist/cache.d.ts +4 -0
  85. package/dist/cache.d.ts.map +1 -0
  86. package/dist/cache.js +19 -0
  87. package/dist/cache.js.map +1 -0
  88. package/dist/cli.d.ts +3 -0
  89. package/dist/cli.d.ts.map +1 -0
  90. package/dist/cli.js +11 -0
  91. package/dist/cli.js.map +1 -0
  92. package/dist/code.d.ts +11 -0
  93. package/dist/code.d.ts.map +1 -0
  94. package/dist/code.js +11 -0
  95. package/dist/code.js.map +1 -0
  96. package/dist/command.d.ts +6 -0
  97. package/dist/command.d.ts.map +1 -0
  98. package/dist/command.js +28 -0
  99. package/dist/command.js.map +1 -0
  100. package/dist/emit.d.ts +23 -0
  101. package/dist/emit.d.ts.map +1 -0
  102. package/dist/emit.js +69 -0
  103. package/dist/emit.js.map +1 -0
  104. package/dist/facts.d.ts +92 -0
  105. package/dist/facts.d.ts.map +1 -0
  106. package/dist/facts.js +3 -0
  107. package/dist/facts.js.map +1 -0
  108. package/dist/files.d.ts +4 -0
  109. package/dist/files.d.ts.map +1 -0
  110. package/dist/files.js +18 -0
  111. package/dist/files.js.map +1 -0
  112. package/dist/formCode.d.ts +6 -0
  113. package/dist/formCode.d.ts.map +1 -0
  114. package/dist/formCode.js +46 -0
  115. package/dist/formCode.js.map +1 -0
  116. package/dist/generate.d.ts +7 -0
  117. package/dist/generate.d.ts.map +1 -0
  118. package/dist/generate.js +69 -0
  119. package/dist/generate.js.map +1 -0
  120. package/dist/generateRound.d.ts +14 -0
  121. package/dist/generateRound.d.ts.map +1 -0
  122. package/dist/generateRound.js +44 -0
  123. package/dist/generateRound.js.map +1 -0
  124. package/dist/importNames.d.ts +7 -0
  125. package/dist/importNames.d.ts.map +1 -0
  126. package/dist/importNames.js +57 -0
  127. package/dist/importNames.js.map +1 -0
  128. package/dist/inputs.d.ts +15 -0
  129. package/dist/inputs.d.ts.map +1 -0
  130. package/dist/inputs.js +64 -0
  131. package/dist/inputs.js.map +1 -0
  132. package/dist/ownerOf.d.ts +7 -0
  133. package/dist/ownerOf.d.ts.map +1 -0
  134. package/dist/ownerOf.js +30 -0
  135. package/dist/ownerOf.js.map +1 -0
  136. package/dist/packageScope.d.ts +13 -0
  137. package/dist/packageScope.d.ts.map +1 -0
  138. package/dist/packageScope.js +28 -0
  139. package/dist/packageScope.js.map +1 -0
  140. package/dist/plan.d.ts +12 -0
  141. package/dist/plan.d.ts.map +1 -0
  142. package/dist/plan.js +115 -0
  143. package/dist/plan.js.map +1 -0
  144. package/dist/project.d.ts +11 -0
  145. package/dist/project.d.ts.map +1 -0
  146. package/dist/project.js +24 -0
  147. package/dist/project.js.map +1 -0
  148. package/dist/rejections.d.ts +11 -0
  149. package/dist/rejections.d.ts.map +1 -0
  150. package/dist/rejections.js +46 -0
  151. package/dist/rejections.js.map +1 -0
  152. package/dist/report.d.ts +22 -0
  153. package/dist/report.d.ts.map +1 -0
  154. package/dist/report.js +20 -0
  155. package/dist/report.js.map +1 -0
  156. package/dist/serverCode.d.ts +20 -0
  157. package/dist/serverCode.d.ts.map +1 -0
  158. package/dist/serverCode.js +90 -0
  159. package/dist/serverCode.js.map +1 -0
  160. package/dist/settleOrphans.d.ts +5 -0
  161. package/dist/settleOrphans.d.ts.map +1 -0
  162. package/dist/settleOrphans.js +43 -0
  163. package/dist/settleOrphans.js.map +1 -0
  164. package/dist/specifiers.d.ts +4 -0
  165. package/dist/specifiers.d.ts.map +1 -0
  166. package/dist/specifiers.js +52 -0
  167. package/dist/specifiers.js.map +1 -0
  168. package/package.json +82 -5
  169. package/src/BuildFailure.ts +5 -0
  170. package/src/adapters/appTestConfig.ts +18 -0
  171. package/src/adapters/appViteConfig.ts +31 -0
  172. package/src/adapters/bundleCheck.ts +57 -0
  173. package/src/adapters/copiedCode.ts +103 -0
  174. package/src/adapters/failuresOf.ts +31 -0
  175. package/src/adapters/isReadonlyProperty.ts +21 -0
  176. package/src/adapters/moduleCalls.ts +58 -0
  177. package/src/adapters/moduleFacts.ts +66 -0
  178. package/src/adapters/objectForm.ts +74 -0
  179. package/src/adapters/operationFacts.ts +85 -0
  180. package/src/adapters/runNode.ts +15 -0
  181. package/src/adapters/typeReading.ts +63 -0
  182. package/src/adapters/typeScript.ts +101 -0
  183. package/src/adapters/unionForm.ts +88 -0
  184. package/src/adapters/vitePlugin.ts +103 -0
  185. package/src/adapters/vitePort.ts +5 -0
  186. package/src/adapters/vitest/setup.ts +12 -0
  187. package/src/adapters/wireForm.ts +98 -0
  188. package/src/byText.ts +7 -0
  189. package/src/cache.ts +28 -0
  190. package/src/cli.ts +16 -0
  191. package/src/code.ts +23 -0
  192. package/src/command.ts +42 -0
  193. package/src/emit.ts +117 -0
  194. package/src/facts.ts +70 -0
  195. package/src/files.ts +24 -0
  196. package/src/formCode.ts +59 -0
  197. package/src/generate.ts +86 -0
  198. package/src/generateRound.ts +60 -0
  199. package/src/importNames.ts +61 -0
  200. package/src/inputs.ts +82 -0
  201. package/src/ownerOf.ts +40 -0
  202. package/src/packageScope.ts +35 -0
  203. package/src/plan.ts +138 -0
  204. package/src/project.ts +30 -0
  205. package/src/rejections.ts +57 -0
  206. package/src/report.ts +39 -0
  207. package/src/serverCode.ts +139 -0
  208. package/src/settleOrphans.ts +58 -0
  209. package/src/specifiers.ts +55 -0
package/src/cache.ts ADDED
@@ -0,0 +1,28 @@
1
+ import { Effect, Option, Path, Schema } from "effect";
2
+ import { readIfPresent, writeAtomically } from "#files.ts";
3
+
4
+ const CacheRecord = Schema.Struct({ content: Schema.String, key: Schema.String });
5
+
6
+ const CacheJson = Schema.fromJsonString(CacheRecord);
7
+
8
+ const decodeRecord = Schema.decodeUnknownOption(CacheJson);
9
+
10
+ const encodeRecord = Schema.encodeEffect(CacheJson);
11
+
12
+ const recordPath = Effect.fn("Build.recordPath")(function* (root: string, module: string) {
13
+ const path = yield* Path.Path;
14
+ return path.join(root, "node_modules", ".cache", "@shivaedev", "build", `${path.relative(root, module)}.json`);
15
+ });
16
+
17
+ // The output of the last run whose inputs had this key; another key, or an unreadable record, means the module must be derived.
18
+ export const cachedOutput = Effect.fn("Build.cachedOutput")(function* (root: string, module: string, key: string) {
19
+ const text = yield* readIfPresent(yield* recordPath(root, module));
20
+ return Option.filter(
21
+ Option.flatMap(text, (json) => decodeRecord(json)),
22
+ (record) => record.key === key,
23
+ ).pipe(Option.map((record) => record.content));
24
+ });
25
+
26
+ export const recordOutput = Effect.fn("Build.recordOutput")(function* (root: string, module: string, key: string, content: string) {
27
+ yield* writeAtomically(yield* recordPath(root, module), yield* encodeRecord({ content, key }));
28
+ });
package/src/cli.ts ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+ import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
3
+ import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
4
+ import * as NodeServices from "@effect/platform-node/NodeServices";
5
+ import { Cause, Console, Effect } from "effect";
6
+ import { CliError, Command } from "effect/cli";
7
+ import { buildCommand } from "#command.ts";
8
+ import packageInfo from "#package.json" with { type: "json" };
9
+
10
+ const main = Command.run(buildCommand, { version: packageInfo.version }).pipe(
11
+ Effect.tapError((error) => (CliError.isCliError(error) || error._tag === "GenerateFailed" ? Effect.void : Console.error(error.message))),
12
+ Effect.tapCause((cause) => (Cause.hasDies(cause) ? Console.error(Cause.pretty(cause)) : Effect.void)),
13
+ Effect.provide([NodeServices.layer, NodeCrypto.layer]),
14
+ );
15
+
16
+ NodeRuntime.runMain(main, { disableErrorReporting: true });
package/src/code.ts ADDED
@@ -0,0 +1,23 @@
1
+ // An import the generated file needs; the file gives each distinct one a local name and writes the import once.
2
+ export interface ImportRef {
3
+ readonly name: string;
4
+ readonly preferred: string;
5
+ readonly specifier: string;
6
+ readonly typeOnly: boolean;
7
+ }
8
+
9
+ export type Code = readonly (string | ImportRef)[];
10
+
11
+ export const SCHEMA: ImportRef = { name: "Schema", preferred: "Schema", specifier: "effect", typeOnly: false };
12
+
13
+ function isCode(part: string | ImportRef | Code): part is Code {
14
+ return Array.isArray(part);
15
+ }
16
+
17
+ export function code(...parts: readonly (string | ImportRef | Code)[]): Code {
18
+ return parts.flatMap((part) => (isCode(part) ? part : [part]));
19
+ }
20
+
21
+ export function joined(items: readonly Code[], separator: string): Code {
22
+ return items.flatMap((item, index) => (index === 0 ? item : [separator, ...item]));
23
+ }
package/src/command.ts ADDED
@@ -0,0 +1,42 @@
1
+ import { Console, Effect, Path } from "effect";
2
+ import { Command, Flag } from "effect/cli";
3
+ import { generate } from "#generate.ts";
4
+ import type { GenerateReport, ModuleStatus } from "#report.ts";
5
+
6
+ const WORDS: Readonly<Record<ModuleStatus, string>> = {
7
+ current: "current",
8
+ missing: "missing",
9
+ orphaned: "orphaned, its module is gone",
10
+ refused: "refused",
11
+ removed: "removed, its module is gone",
12
+ stale: "stale",
13
+ unchanged: "unchanged, from the cache",
14
+ written: "written",
15
+ };
16
+
17
+ const printReport = Effect.fn("Build.printReport")(function* (report: GenerateReport) {
18
+ for (const { output, status } of report.modules) {
19
+ yield* Console.log(`${output}: ${WORDS[status]}`);
20
+ }
21
+ if (report.modules.length === 0) {
22
+ yield* Console.log("No file of the project calls defineModule.");
23
+ }
24
+ });
25
+
26
+ export const generateCommand = Command.make("generate", { check: Flag.Boolean("check").pipe(Flag.withDefault(false)) }, ({ check }) =>
27
+ Effect.gen(function* () {
28
+ const path = yield* Path.Path;
29
+ const report = yield* generate(path.resolve(), { check }).pipe(
30
+ Effect.tapErrorTag("GenerateFailed", (failed) =>
31
+ printReport(failed.report).pipe(Effect.andThen(Effect.forEach(failed.problems, (problem) => Console.error(problem)))),
32
+ ),
33
+ );
34
+ yield* printReport(report);
35
+ }),
36
+ ).pipe(
37
+ Command.withDescription(
38
+ "Write src/generated/<module>.ts for every defineModule call of the project in the current directory and remove each generated file whose module is gone; --check writes nothing and fails on a missing, stale or orphaned file",
39
+ ),
40
+ );
41
+
42
+ export const buildCommand = Command.make("shivaedev-build").pipe(Command.withSubcommands([generateCommand]));
package/src/emit.ts ADDED
@@ -0,0 +1,117 @@
1
+ import { type Code, code, type ImportRef, joined } from "#code.ts";
2
+ import { importNames } from "#importNames.ts";
3
+
4
+ export interface PlannedOperation {
5
+ readonly kind: "command" | "query";
6
+ readonly members: readonly Code[];
7
+ readonly name: string;
8
+ readonly payload: Code | undefined;
9
+ readonly rejections: readonly (readonly [string, ImportRef])[];
10
+ readonly success: Code;
11
+ }
12
+
13
+ export interface PlannedModule {
14
+ readonly exportName: string;
15
+ readonly keyScope: Code | undefined;
16
+ readonly middleware: Code | undefined;
17
+ readonly module: ImportRef;
18
+ readonly name: string;
19
+ readonly operations: readonly PlannedOperation[];
20
+ readonly source: string;
21
+ readonly subject: Code | undefined;
22
+ }
23
+
24
+ const CLIENT_CONTRACT: ImportRef = {
25
+ name: "clientContract",
26
+ preferred: "clientContract",
27
+ specifier: "@shivaedev/contract/clientContract.ts",
28
+ typeOnly: false,
29
+ };
30
+ const MATCHES: ImportRef = {
31
+ name: "ClientMatchesModule",
32
+ preferred: "ClientMatchesModule",
33
+ specifier: "@shivaedev/contract/clientMatchesModule.ts",
34
+ typeOnly: true,
35
+ };
36
+ const IDENTIFIER = /^[A-Za-z_$][\w$]*$/u;
37
+ const HEADER = /^\/\/ Generated by @shivaedev\/build from (?<source>.+)\. Do not edit or commit this file; run `pnpm generate` to write it again\.$/u;
38
+
39
+ function headerOf(source: string): string {
40
+ return `// Generated by @shivaedev/build from ${source}. Do not edit or commit this file; run \`pnpm generate\` to write it again.`;
41
+ }
42
+
43
+ // The module a file was generated from, read from its first line, so generate tells its own files from others under src/generated.
44
+ export function generatedFrom(text: string): string | undefined {
45
+ return HEADER.exec(text.split("\n", 1)[0] ?? "")?.groups?.source;
46
+ }
47
+
48
+ function keyOf(name: string): string {
49
+ return IDENTIFIER.test(name) ? name : JSON.stringify(name);
50
+ }
51
+
52
+ export function identifierOf(name: string): string {
53
+ const words = name.split(/[^A-Za-z0-9_$]+/u).filter((word) => word !== "");
54
+ const camel = words.map((word, index) => (index === 0 ? word : `${word.charAt(0).toUpperCase()}${word.slice(1)}`)).join("");
55
+ return /^[0-9]/u.test(camel) || camel === "" ? `_${camel}` : camel;
56
+ }
57
+
58
+ function entries(lines: readonly Code[]): Code {
59
+ return code("{\n", ...lines.map((line) => code("\t\t", line, ",\n")), "\t}");
60
+ }
61
+
62
+ function field(name: string, value: Code | undefined): readonly Code[] {
63
+ return value === undefined ? [] : [code(`\t${name}: `, value, ",\n")];
64
+ }
65
+
66
+ export function emit(planned: PlannedModule): string {
67
+ const operations = planned.operations;
68
+ const base = identifierOf(planned.name);
69
+ const wireName = `${base}Wire`;
70
+ const matchName = `${base}MatchModule`;
71
+ const moduleType = { ...planned.module, preferred: `${planned.exportName}Module` };
72
+ function client(kind: PlannedOperation["kind"]) {
73
+ const chosen = operations.filter((operation) => operation.kind === kind);
74
+ return chosen.length === 0
75
+ ? undefined
76
+ : entries(
77
+ chosen.map((operation) =>
78
+ operation.members.length === 0
79
+ ? code(`${keyOf(operation.name)}: {}`)
80
+ : code(`${keyOf(operation.name)}: { `, joined(operation.members, ", "), " }"),
81
+ ),
82
+ );
83
+ }
84
+ const payloads = operations.flatMap((operation) =>
85
+ operation.payload === undefined ? [] : [code(`${keyOf(operation.name)}: `, operation.payload)],
86
+ );
87
+ const contract = code(
88
+ ...field("commands", client("command")),
89
+ ...field("keyScope", planned.keyScope),
90
+ ...field("middleware", planned.middleware),
91
+ `\tname: ${JSON.stringify(planned.name)},\n`,
92
+ ...field("payloads", payloads.length === 0 ? undefined : entries(payloads)),
93
+ ...field("queries", client("query")),
94
+ ...field("subject", planned.subject),
95
+ `\twire: ${wireName},\n`,
96
+ );
97
+ const all = code(
98
+ contract,
99
+ CLIENT_CONTRACT,
100
+ MATCHES,
101
+ moduleType,
102
+ ...operations.flatMap((operation) => [operation.success, ...operation.rejections.map(([, ref]) => ref)]),
103
+ );
104
+ const names = importNames(all, new Set([wireName, matchName, planned.exportName]));
105
+ const wire = operations.map((operation) => {
106
+ const rejections = operation.rejections.map(([tag, ref]) => (names.local(ref) === tag ? tag : `${keyOf(tag)}: ${names.local(ref)}`));
107
+ const listed = rejections.length === 0 ? "{}" : `{ ${rejections.join(", ")} }`;
108
+ return `\t${keyOf(operation.name)}: { rejections: ${listed}, success: ${names.text(operation.success)} },\n`;
109
+ });
110
+ return [
111
+ `${headerOf(planned.source)}\n`,
112
+ ...names.lines.map((line) => `${line}\n`),
113
+ `\nexport const ${wireName} = {\n${wire.join("")}} as const;\n`,
114
+ `\nexport const ${planned.exportName} = ${names.local(CLIENT_CONTRACT)}({\n${names.text(contract)}});\n`,
115
+ `\nexport const ${matchName}: ${names.local(MATCHES)}<typeof ${planned.exportName}, typeof ${names.local(moduleType)}> = true;\n`,
116
+ ].join("");
117
+ }
package/src/facts.ts ADDED
@@ -0,0 +1,70 @@
1
+ // What the type checker says about a module, as plain data: the adapter reads it from a program, the core decides from it.
2
+
3
+ export interface ImportFact {
4
+ readonly local: string;
5
+ readonly name: string;
6
+ readonly specifier: string;
7
+ // The file the import resolves to, so a specifier written in another package can be rewritten for the project.
8
+ readonly target: string | undefined;
9
+ readonly typeOnly: boolean;
10
+ readonly writtenIn: string;
11
+ }
12
+
13
+ export type CodePart = string | ImportFact;
14
+
15
+ export interface DeclaredClass {
16
+ readonly className: string;
17
+ readonly exportName: string | undefined;
18
+ readonly file: string;
19
+ }
20
+
21
+ export type Literal = string | number | boolean;
22
+
23
+ export type Primitive = "BigInt" | "Boolean" | "Date" | "DateTimeUtc" | "Null" | "Number" | "String" | "Undefined" | "Void";
24
+
25
+ export interface Field {
26
+ readonly form: WireForm;
27
+ readonly mutable: boolean;
28
+ readonly name: string;
29
+ readonly optional: boolean;
30
+ }
31
+
32
+ export type WireForm =
33
+ | { readonly form: "primitive"; readonly primitive: Primitive }
34
+ | { readonly form: "literals"; readonly values: readonly Literal[] }
35
+ | { readonly form: "array"; readonly item: WireForm; readonly mutable: boolean }
36
+ | { readonly form: "tuple"; readonly elements: readonly WireForm[]; readonly mutable: boolean }
37
+ | { readonly form: "struct"; readonly fields: readonly Field[] }
38
+ | { readonly form: "record"; readonly value: WireForm; readonly mutable: boolean }
39
+ | { readonly form: "union"; readonly members: readonly WireForm[] }
40
+ | { readonly form: "nullOr"; readonly inner: WireForm }
41
+ | { readonly form: "option"; readonly inner: WireForm }
42
+ | { readonly form: "class"; readonly declared: DeclaredClass };
43
+
44
+ export type Failure =
45
+ | { readonly kind: "retryable" }
46
+ | { readonly kind: "class"; readonly declared: DeclaredClass; readonly schema: boolean; readonly tag: string }
47
+ | { readonly kind: "unexported"; readonly tag: string; readonly text: string }
48
+ | { readonly kind: "untagged"; readonly text: string };
49
+
50
+ export interface OperationFacts {
51
+ readonly failures: readonly Failure[];
52
+ readonly invalidates: readonly CodePart[] | undefined;
53
+ readonly kind: "command" | "query";
54
+ readonly name: string;
55
+ readonly payload: readonly CodePart[] | undefined;
56
+ readonly reads: readonly CodePart[] | undefined;
57
+ readonly readsCollections: readonly CodePart[] | undefined;
58
+ readonly success: WireForm | undefined;
59
+ }
60
+
61
+ export interface ModuleFacts {
62
+ readonly exportName: string;
63
+ readonly file: string;
64
+ readonly keyScope: readonly CodePart[] | undefined;
65
+ readonly middleware: readonly CodePart[] | undefined;
66
+ readonly name: string;
67
+ readonly operations: readonly OperationFacts[];
68
+ readonly refusals: readonly string[];
69
+ readonly subject: readonly CodePart[] | undefined;
70
+ }
package/src/files.ts ADDED
@@ -0,0 +1,24 @@
1
+ import { Effect, FileSystem, Option, Path } from "effect";
2
+
3
+ export const readIfPresent = Effect.fn("Build.readIfPresent")(function* (file: string) {
4
+ const fs = yield* FileSystem.FileSystem;
5
+ return yield* fs.readFileString(file).pipe(
6
+ Effect.map(Option.some),
7
+ Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(Option.none<string>())),
8
+ );
9
+ });
10
+
11
+ // A reader never sees half a file: the text goes to a fresh file beside the target, which then replaces it in one rename. The
12
+ // draft lives in a temporary folder of its own, removed when the write ends however it ends.
13
+ export const writeAtomically = Effect.fn("Build.writeAtomically")(function* (file: string, text: string) {
14
+ const fs = yield* FileSystem.FileSystem;
15
+ const path = yield* Path.Path;
16
+ yield* fs.makeDirectory(path.dirname(file), { recursive: true });
17
+ yield* Effect.scoped(
18
+ Effect.gen(function* () {
19
+ const draft = yield* fs.makeTempFileScoped({ directory: path.dirname(file), prefix: `.${path.basename(file)}.` });
20
+ yield* fs.writeFileString(draft, text);
21
+ yield* fs.rename(draft, file);
22
+ }),
23
+ );
24
+ });
@@ -0,0 +1,59 @@
1
+ import { type Code, code, type ImportRef, joined, SCHEMA } from "#code.ts";
2
+ import type { Field, WireForm } from "#facts.ts";
3
+
4
+ const IDENTIFIER = /^[A-Za-z_$][\w$]*$/u;
5
+
6
+ function schema(member: string, ...inner: readonly (string | ImportRef | Code)[]): Code {
7
+ return inner.length === 0 ? code(SCHEMA, `.${member}`) : code(SCHEMA, `.${member}(`, ...inner, ")");
8
+ }
9
+
10
+ function mutableWhen(mutable: boolean, inner: Code): Code {
11
+ return mutable ? schema("mutable", inner) : inner;
12
+ }
13
+
14
+ function fieldCode(field: Field, classRef: (form: Extract<WireForm, { form: "class" }>) => ImportRef): Code {
15
+ const value = formCode(field.form, classRef);
16
+ const optional = field.optional ? schema("optionalKey", value) : value;
17
+ const key = IDENTIFIER.test(field.name) ? field.name : JSON.stringify(field.name);
18
+ return code(`${key}: `, field.mutable ? schema("mutableKey", optional) : optional);
19
+ }
20
+
21
+ export function formCode(form: WireForm, classRef: (form: Extract<WireForm, { form: "class" }>) => ImportRef): Code {
22
+ function inner(each: WireForm) {
23
+ return formCode(each, classRef);
24
+ }
25
+ switch (form.form) {
26
+ case "primitive":
27
+ return schema(form.primitive);
28
+ case "literals":
29
+ return form.values.length === 1
30
+ ? schema("Literal", JSON.stringify(form.values[0]))
31
+ : schema("Literals", `[${form.values.map((value) => JSON.stringify(value)).join(", ")}]`);
32
+ case "array":
33
+ return mutableWhen(form.mutable, schema("Array", inner(form.item)));
34
+ case "tuple":
35
+ return mutableWhen(form.mutable, schema("Tuple", "[", joined(form.elements.map(inner), ", "), "]"));
36
+ case "struct":
37
+ return form.fields.length === 0
38
+ ? schema("Struct", "{}")
39
+ : schema(
40
+ "Struct",
41
+ "{ ",
42
+ joined(
43
+ form.fields.map((field) => fieldCode(field, classRef)),
44
+ ", ",
45
+ ),
46
+ " }",
47
+ );
48
+ case "record":
49
+ return schema("Record", schema("String"), ", ", form.mutable ? schema("mutableKey", inner(form.value)) : inner(form.value));
50
+ case "union":
51
+ return schema("Union", "[", joined(form.members.map(inner), ", "), "]");
52
+ case "nullOr":
53
+ return schema("NullOr", inner(form.inner));
54
+ case "option":
55
+ return schema("Option", inner(form.inner));
56
+ case "class":
57
+ return code(classRef(form));
58
+ }
59
+ }
@@ -0,0 +1,86 @@
1
+ import { Effect, Option, Path } from "effect";
2
+ import { withHeavyLock } from "@shivaedev/heavy-lock/withHeavyLock.ts";
3
+ import { deriveModules } from "#adapters/typeScript.ts";
4
+ import { byText } from "#byText.ts";
5
+ import { cachedOutput, recordOutput } from "#cache.ts";
6
+ import { emit } from "#emit.ts";
7
+ import { readIfPresent, writeAtomically } from "#files.ts";
8
+ import { inputKey } from "#inputs.ts";
9
+ import { plan } from "#plan.ts";
10
+ import { moduleFiles, openProject } from "#project.ts";
11
+ import { GenerateFailed, type GenerateReport, type ModuleOutcome, problemOf } from "#report.ts";
12
+ import { settleOrphans } from "#settleOrphans.ts";
13
+
14
+ export interface GenerateOptions {
15
+ // Writes nothing and fails when a generated file is missing or differs from what generate would write.
16
+ readonly check: boolean;
17
+ }
18
+
19
+ const settle = Effect.fn("Build.settle")(function* (options: GenerateOptions, output: string, content: string, fromCache: boolean) {
20
+ const existing = yield* readIfPresent(output);
21
+ if (Option.contains(existing, content)) {
22
+ return fromCache ? "unchanged" : "current";
23
+ }
24
+ if (options.check) {
25
+ return Option.isNone(existing) ? "missing" : "stale";
26
+ }
27
+ yield* writeAtomically(output, content);
28
+ return "written";
29
+ });
30
+
31
+ export const generate = Effect.fn("Build.generate")(function* (directory: string, options: GenerateOptions) {
32
+ const path = yield* Path.Path;
33
+ const project = yield* openProject(directory);
34
+ const files = yield* moduleFiles(project);
35
+ const refusals = files.flatMap(({ module, underSource }) =>
36
+ underSource
37
+ ? []
38
+ : [
39
+ `${path.relative(project.root, module)}: defines a module outside src; move it under src, since its generated file goes to src/generated/<its path under src>`,
40
+ ],
41
+ );
42
+ const placed = files.filter(({ underSource }) => underSource);
43
+ const keyed = yield* Effect.forEach(placed, (file) =>
44
+ Effect.gen(function* () {
45
+ const { files: inputs, key } = yield* inputKey(project, file.module);
46
+ return { ...file, cached: yield* cachedOutput(project.root, file.module, key), inputs, key };
47
+ }),
48
+ );
49
+ const pending = keyed.filter(({ cached }) => Option.isNone(cached));
50
+ const facts =
51
+ pending.length === 0
52
+ ? []
53
+ : yield* withHeavyLock(
54
+ deriveModules(
55
+ project.config,
56
+ pending.map(({ module }) => module),
57
+ ),
58
+ { command: "shivaedev-build generate" },
59
+ );
60
+ const outcomes: ModuleOutcome[] = [];
61
+ for (const file of keyed) {
62
+ const relative = { module: path.relative(project.root, file.module), output: path.relative(project.root, file.output) };
63
+ const derived = facts.find((each) => each.file === file.module);
64
+ const planned = derived === undefined ? undefined : yield* plan({ output: file.output, root: project.root }, derived);
65
+ if (planned !== undefined && planned.refusals.length > 0) {
66
+ refusals.push(...planned.refusals);
67
+ outcomes.push({ ...relative, status: "refused" });
68
+ continue;
69
+ }
70
+ const content = Option.getOrElse(file.cached, () => (planned === undefined ? "" : emit(planned.planned)));
71
+ const status = yield* settle(options, file.output, content, Option.isSome(file.cached));
72
+ if (!options.check && Option.isNone(file.cached)) {
73
+ yield* recordOutput(project.root, file.module, file.key, content);
74
+ }
75
+ outcomes.push({ ...relative, status });
76
+ }
77
+ outcomes.push(...(yield* settleOrphans(project, options.check)));
78
+ const inputs = [...new Set(keyed.flatMap((file) => file.inputs))].sort(byText);
79
+ const report: GenerateReport = { inputs, modules: outcomes, programBuilt: pending.length > 0 };
80
+ const lines = outcomes.flatMap((outcome) => problemOf(outcome) ?? []);
81
+ if (refusals.length > 0 || lines.length > 0) {
82
+ const problems = [...refusals, ...lines].map((problem) => problem.replaceAll(`${project.root}${path.sep}`, ""));
83
+ return yield* new GenerateFailed({ problems, report });
84
+ }
85
+ return report;
86
+ });
@@ -0,0 +1,60 @@
1
+ import { Effect, FileSystem, Option, Path, Ref, Semaphore } from "effect";
2
+ import { definesModule } from "#adapters/typeScript.ts";
3
+ import { readIfPresent } from "#files.ts";
4
+ import { generate } from "#generate.ts";
5
+ import type { GenerateReport } from "#report.ts";
6
+
7
+ export interface GenerateRound {
8
+ readonly changed: readonly string[];
9
+ readonly inputs: readonly string[];
10
+ readonly outputs: readonly string[];
11
+ // The generator's message when it refused a module; the refused module's file is left as it was.
12
+ readonly refusal: Option.Option<string>;
13
+ }
14
+
15
+ const roundOf = Effect.fn("Build.roundOf")(function* (root: string, report: GenerateReport, refusal: Option.Option<string>) {
16
+ const path = yield* Path.Path;
17
+ const round: GenerateRound = {
18
+ changed: report.modules.filter(({ status }) => status === "written" || status === "removed").map(({ output }) => path.join(root, output)),
19
+ inputs: report.inputs,
20
+ outputs: report.modules.map(({ output }) => path.join(root, output)),
21
+ refusal,
22
+ };
23
+ return round;
24
+ });
25
+
26
+ export const generateRound = Effect.fn("Build.generateRound")(function* (directory: string) {
27
+ const fs = yield* FileSystem.FileSystem;
28
+ const root = yield* fs.realPath(directory);
29
+ return yield* generate(root, { check: false }).pipe(
30
+ Effect.flatMap((report) => roundOf(root, report, Option.none())),
31
+ Effect.catchTag("GenerateFailed", (failed) => roundOf(root, failed.report, Option.some(failed.problems.join("\n")))),
32
+ );
33
+ });
34
+
35
+ // A dev server's generator: one run at a time, and a changed file starts a run only when some module's last run read it or it
36
+ // is a module file under src that no run has seen yet.
37
+ export const watchedGeneration = Effect.fn("Build.watchedGeneration")(function* (directory: string) {
38
+ const fs = yield* FileSystem.FileSystem;
39
+ const path = yield* Path.Path;
40
+ const root = yield* fs.realPath(directory);
41
+ const sources = `${path.join(root, "src")}${path.sep}`;
42
+ const generated = `${path.join(sources, "generated")}${path.sep}`;
43
+ const inputs = yield* Ref.make<ReadonlySet<string>>(new Set());
44
+ const serial = yield* Semaphore.make(1);
45
+ const run = serial.withPermits(1)(Effect.tap(generateRound(root), (round) => Ref.set(inputs, new Set(round.inputs))));
46
+ const newModule = Effect.fn("Build.newModule")(function* (file: string) {
47
+ if (!(file.startsWith(sources) && (file.endsWith(".ts") || file.endsWith(".tsx"))) || file.startsWith(generated)) {
48
+ return false;
49
+ }
50
+ return Option.exists(yield* readIfPresent(file), (text) => definesModule(file, text));
51
+ });
52
+ const changed = Effect.fn("Build.changed")(function* (file: string) {
53
+ const real = yield* fs.realPath(file).pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(file)));
54
+ const known = (yield* Ref.get(inputs)).has(real);
55
+ return known || (yield* newModule(real)) ? Option.some(yield* run) : Option.none<GenerateRound>();
56
+ });
57
+ return { changed, run };
58
+ });
59
+
60
+ export type WatchedGeneration = Effect.Success<ReturnType<typeof watchedGeneration>>;
@@ -0,0 +1,61 @@
1
+ import { byText } from "#byText.ts";
2
+ import type { Code, ImportRef } from "#code.ts";
3
+
4
+ function keyOf(ref: ImportRef): string {
5
+ return `${ref.specifier}\u0000${ref.name}`;
6
+ }
7
+
8
+ function sorted(values: readonly string[]): readonly string[] {
9
+ return [...values].sort(byText);
10
+ }
11
+
12
+ function importLine(specifier: string, entries: readonly (readonly [ImportRef, string, boolean])[]): readonly string[] {
13
+ const named = entries.filter(([ref]) => ref.name !== "*" && ref.name !== "default");
14
+ const whole = entries.filter(([ref]) => ref.name === "*" || ref.name === "default");
15
+ const allTypes = named.every(([, , typeOnly]) => typeOnly);
16
+ const names = named.map(([ref, local, typeOnly]) => {
17
+ const renamed = ref.name === local ? local : `${ref.name} as ${local}`;
18
+ return typeOnly && !allTypes ? `type ${renamed}` : renamed;
19
+ });
20
+ return [
21
+ ...(named.length === 0 ? [] : [`import ${allTypes ? "type " : ""}{ ${sorted(names).join(", ")} } from "${specifier}";`]),
22
+ ...whole.map(([ref, local, typeOnly]) => `import ${typeOnly ? "type " : ""}${ref.name === "*" ? `* as ${local}` : local} from "${specifier}";`),
23
+ ];
24
+ }
25
+
26
+ // Gives every distinct import one local name, so two classes both called NotFound from different files both fit.
27
+ export function importNames(code: Code, reserved: ReadonlySet<string>) {
28
+ const refs = new Map<string, ImportRef[]>();
29
+ for (const part of code) {
30
+ if (typeof part !== "string") {
31
+ refs.set(keyOf(part), [...(refs.get(keyOf(part)) ?? []), part]);
32
+ }
33
+ }
34
+ const taken = new Set(reserved);
35
+ const locals = new Map<string, string>();
36
+ for (const key of sorted([...refs.keys()])) {
37
+ const preferred = sorted((refs.get(key) ?? []).map((ref) => ref.preferred))[0] ?? "imported";
38
+ let chosen = preferred;
39
+ for (let suffix = 2; taken.has(chosen); suffix += 1) {
40
+ chosen = `${preferred}${suffix}`;
41
+ }
42
+ taken.add(chosen);
43
+ locals.set(key, chosen);
44
+ }
45
+ const bySpecifier = new Map<string, (readonly [ImportRef, string, boolean])[]>();
46
+ for (const [key, uses] of refs) {
47
+ const [first] = uses;
48
+ if (first !== undefined) {
49
+ const entry = [first, locals.get(key) ?? first.preferred, uses.every((use) => use.typeOnly)] as const;
50
+ bySpecifier.set(first.specifier, [...(bySpecifier.get(first.specifier) ?? []), entry]);
51
+ }
52
+ }
53
+ function local(ref: ImportRef) {
54
+ return locals.get(keyOf(ref)) ?? ref.preferred;
55
+ }
56
+ return {
57
+ lines: sorted([...bySpecifier.keys()]).flatMap((specifier) => importLine(specifier, bySpecifier.get(specifier) ?? [])),
58
+ local,
59
+ text: (written: Code) => written.map((part) => (typeof part === "string" ? part : local(part))).join(""),
60
+ };
61
+ }