@shivaedev/build 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/LICENSE +21 -0
- package/README.md +215 -1
- package/dist/BuildFailure.d.ts +9 -0
- package/dist/BuildFailure.d.ts.map +1 -0
- package/dist/BuildFailure.js +4 -0
- package/dist/BuildFailure.js.map +1 -0
- package/dist/adapters/appTestConfig.d.ts +4 -0
- package/dist/adapters/appTestConfig.d.ts.map +1 -0
- package/dist/adapters/appTestConfig.js +17 -0
- package/dist/adapters/appTestConfig.js.map +1 -0
- package/dist/adapters/appViteConfig.d.ts +26 -0
- package/dist/adapters/appViteConfig.d.ts.map +1 -0
- package/dist/adapters/appViteConfig.js +18 -0
- package/dist/adapters/appViteConfig.js.map +1 -0
- package/dist/adapters/bundleCheck.d.ts +3 -0
- package/dist/adapters/bundleCheck.d.ts.map +1 -0
- package/dist/adapters/bundleCheck.js +53 -0
- package/dist/adapters/bundleCheck.js.map +1 -0
- package/dist/adapters/copiedCode.d.ts +9 -0
- package/dist/adapters/copiedCode.d.ts.map +1 -0
- package/dist/adapters/copiedCode.js +84 -0
- package/dist/adapters/copiedCode.js.map +1 -0
- package/dist/adapters/failuresOf.d.ts +5 -0
- package/dist/adapters/failuresOf.d.ts.map +1 -0
- package/dist/adapters/failuresOf.js +29 -0
- package/dist/adapters/failuresOf.js.map +1 -0
- package/dist/adapters/isReadonlyProperty.d.ts +10 -0
- package/dist/adapters/isReadonlyProperty.d.ts.map +1 -0
- package/dist/adapters/isReadonlyProperty.js +7 -0
- package/dist/adapters/isReadonlyProperty.js.map +1 -0
- package/dist/adapters/moduleCalls.d.ts +10 -0
- package/dist/adapters/moduleCalls.d.ts.map +1 -0
- package/dist/adapters/moduleCalls.js +47 -0
- package/dist/adapters/moduleCalls.js.map +1 -0
- package/dist/adapters/moduleFacts.d.ts +4 -0
- package/dist/adapters/moduleFacts.d.ts.map +1 -0
- package/dist/adapters/moduleFacts.js +63 -0
- package/dist/adapters/moduleFacts.js.map +1 -0
- package/dist/adapters/objectForm.d.ts +8 -0
- package/dist/adapters/objectForm.d.ts.map +1 -0
- package/dist/adapters/objectForm.js +66 -0
- package/dist/adapters/objectForm.js.map +1 -0
- package/dist/adapters/operationFacts.d.ts +10 -0
- package/dist/adapters/operationFacts.d.ts.map +1 -0
- package/dist/adapters/operationFacts.js +72 -0
- package/dist/adapters/operationFacts.js.map +1 -0
- package/dist/adapters/runNode.d.ts +6 -0
- package/dist/adapters/runNode.d.ts.map +1 -0
- package/dist/adapters/runNode.js +14 -0
- package/dist/adapters/runNode.js.map +1 -0
- package/dist/adapters/typeReading.d.ts +15 -0
- package/dist/adapters/typeReading.d.ts.map +1 -0
- package/dist/adapters/typeReading.js +46 -0
- package/dist/adapters/typeReading.js.map +1 -0
- package/dist/adapters/typeScript.d.ts +15 -0
- package/dist/adapters/typeScript.d.ts.map +1 -0
- package/dist/adapters/typeScript.js +81 -0
- package/dist/adapters/typeScript.js.map +1 -0
- package/dist/adapters/unionForm.d.ts +7 -0
- package/dist/adapters/unionForm.d.ts.map +1 -0
- package/dist/adapters/unionForm.js +78 -0
- package/dist/adapters/unionForm.js.map +1 -0
- package/dist/adapters/vitePlugin.d.ts +7 -0
- package/dist/adapters/vitePlugin.d.ts.map +1 -0
- package/dist/adapters/vitePlugin.js +88 -0
- package/dist/adapters/vitePlugin.js.map +1 -0
- package/dist/adapters/vitePort.d.ts +2 -0
- package/dist/adapters/vitePort.d.ts.map +1 -0
- package/dist/adapters/vitePort.js +5 -0
- package/dist/adapters/vitePort.js.map +1 -0
- package/dist/adapters/vitest/setup.d.ts +3 -0
- package/dist/adapters/vitest/setup.d.ts.map +1 -0
- package/dist/adapters/vitest/setup.js +11 -0
- package/dist/adapters/vitest/setup.js.map +1 -0
- package/dist/adapters/wireForm.d.ts +5 -0
- package/dist/adapters/wireForm.d.ts.map +1 -0
- package/dist/adapters/wireForm.js +91 -0
- package/dist/adapters/wireForm.js.map +1 -0
- package/dist/byText.d.ts +2 -0
- package/dist/byText.d.ts.map +1 -0
- package/dist/byText.js +8 -0
- package/dist/byText.js.map +1 -0
- package/dist/cache.d.ts +4 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +19 -0
- package/dist/cache.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +11 -0
- package/dist/cli.js.map +1 -0
- package/dist/code.d.ts +11 -0
- package/dist/code.d.ts.map +1 -0
- package/dist/code.js +11 -0
- package/dist/code.js.map +1 -0
- package/dist/command.d.ts +6 -0
- package/dist/command.d.ts.map +1 -0
- package/dist/command.js +28 -0
- package/dist/command.js.map +1 -0
- package/dist/emit.d.ts +23 -0
- package/dist/emit.d.ts.map +1 -0
- package/dist/emit.js +69 -0
- package/dist/emit.js.map +1 -0
- package/dist/facts.d.ts +92 -0
- package/dist/facts.d.ts.map +1 -0
- package/dist/facts.js +3 -0
- package/dist/facts.js.map +1 -0
- package/dist/files.d.ts +4 -0
- package/dist/files.d.ts.map +1 -0
- package/dist/files.js +15 -0
- package/dist/files.js.map +1 -0
- package/dist/formCode.d.ts +6 -0
- package/dist/formCode.d.ts.map +1 -0
- package/dist/formCode.js +46 -0
- package/dist/formCode.js.map +1 -0
- package/dist/generate.d.ts +7 -0
- package/dist/generate.d.ts.map +1 -0
- package/dist/generate.js +69 -0
- package/dist/generate.js.map +1 -0
- package/dist/generateRound.d.ts +14 -0
- package/dist/generateRound.d.ts.map +1 -0
- package/dist/generateRound.js +44 -0
- package/dist/generateRound.js.map +1 -0
- package/dist/importNames.d.ts +7 -0
- package/dist/importNames.d.ts.map +1 -0
- package/dist/importNames.js +57 -0
- package/dist/importNames.js.map +1 -0
- package/dist/inputs.d.ts +15 -0
- package/dist/inputs.d.ts.map +1 -0
- package/dist/inputs.js +64 -0
- package/dist/inputs.js.map +1 -0
- package/dist/ownerOf.d.ts +7 -0
- package/dist/ownerOf.d.ts.map +1 -0
- package/dist/ownerOf.js +30 -0
- package/dist/ownerOf.js.map +1 -0
- package/dist/packageScope.d.ts +13 -0
- package/dist/packageScope.d.ts.map +1 -0
- package/dist/packageScope.js +28 -0
- package/dist/packageScope.js.map +1 -0
- package/dist/plan.d.ts +12 -0
- package/dist/plan.d.ts.map +1 -0
- package/dist/plan.js +115 -0
- package/dist/plan.js.map +1 -0
- package/dist/project.d.ts +11 -0
- package/dist/project.d.ts.map +1 -0
- package/dist/project.js +24 -0
- package/dist/project.js.map +1 -0
- package/dist/rejections.d.ts +11 -0
- package/dist/rejections.d.ts.map +1 -0
- package/dist/rejections.js +46 -0
- package/dist/rejections.js.map +1 -0
- package/dist/report.d.ts +22 -0
- package/dist/report.d.ts.map +1 -0
- package/dist/report.js +20 -0
- package/dist/report.js.map +1 -0
- package/dist/serverCode.d.ts +20 -0
- package/dist/serverCode.d.ts.map +1 -0
- package/dist/serverCode.js +90 -0
- package/dist/serverCode.js.map +1 -0
- package/dist/settleOrphans.d.ts +5 -0
- package/dist/settleOrphans.d.ts.map +1 -0
- package/dist/settleOrphans.js +43 -0
- package/dist/settleOrphans.js.map +1 -0
- package/dist/specifiers.d.ts +4 -0
- package/dist/specifiers.d.ts.map +1 -0
- package/dist/specifiers.js +52 -0
- package/dist/specifiers.js.map +1 -0
- package/package.json +82 -5
- package/src/BuildFailure.ts +5 -0
- package/src/adapters/appTestConfig.ts +18 -0
- package/src/adapters/appViteConfig.ts +31 -0
- package/src/adapters/bundleCheck.ts +57 -0
- package/src/adapters/copiedCode.ts +103 -0
- package/src/adapters/failuresOf.ts +31 -0
- package/src/adapters/isReadonlyProperty.ts +21 -0
- package/src/adapters/moduleCalls.ts +58 -0
- package/src/adapters/moduleFacts.ts +66 -0
- package/src/adapters/objectForm.ts +74 -0
- package/src/adapters/operationFacts.ts +85 -0
- package/src/adapters/runNode.ts +15 -0
- package/src/adapters/typeReading.ts +63 -0
- package/src/adapters/typeScript.ts +101 -0
- package/src/adapters/unionForm.ts +88 -0
- package/src/adapters/vitePlugin.ts +103 -0
- package/src/adapters/vitePort.ts +5 -0
- package/src/adapters/vitest/setup.ts +12 -0
- package/src/adapters/wireForm.ts +98 -0
- package/src/byText.ts +7 -0
- package/src/cache.ts +28 -0
- package/src/cli.ts +16 -0
- package/src/code.ts +23 -0
- package/src/command.ts +42 -0
- package/src/emit.ts +117 -0
- package/src/facts.ts +70 -0
- package/src/files.ts +19 -0
- package/src/formCode.ts +59 -0
- package/src/generate.ts +86 -0
- package/src/generateRound.ts +60 -0
- package/src/importNames.ts +61 -0
- package/src/inputs.ts +82 -0
- package/src/ownerOf.ts +40 -0
- package/src/packageScope.ts +35 -0
- package/src/plan.ts +138 -0
- package/src/project.ts +30 -0
- package/src/rejections.ts +57 -0
- package/src/report.ts +39 -0
- package/src/serverCode.ts +139 -0
- package/src/settleOrphans.ts +58 -0
- package/src/specifiers.ts +55 -0
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,19 @@
|
|
|
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.
|
|
12
|
+
export const writeAtomically = Effect.fn("Build.writeAtomically")(function* (file: string, text: string) {
|
|
13
|
+
const fs = yield* FileSystem.FileSystem;
|
|
14
|
+
const path = yield* Path.Path;
|
|
15
|
+
yield* fs.makeDirectory(path.dirname(file), { recursive: true });
|
|
16
|
+
const draft = yield* fs.makeTempFile({ directory: path.dirname(file), prefix: `.${path.basename(file)}.` });
|
|
17
|
+
yield* fs.writeFileString(draft, text);
|
|
18
|
+
yield* fs.rename(draft, file);
|
|
19
|
+
});
|
package/src/formCode.ts
ADDED
|
@@ -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
|
+
}
|
package/src/generate.ts
ADDED
|
@@ -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
|
+
}
|
package/src/inputs.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { Effect, FileSystem, Option, Path } from "effect";
|
|
2
|
+
import { Digest } from "effect/crypto";
|
|
3
|
+
import { type ProjectConfig, resolvedImports } from "#adapters/typeScript.ts";
|
|
4
|
+
import { byText } from "#byText.ts";
|
|
5
|
+
import packageInfo from "#package.json" with { type: "json" };
|
|
6
|
+
|
|
7
|
+
const LOCKFILES = ["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lock"];
|
|
8
|
+
|
|
9
|
+
export interface Project {
|
|
10
|
+
readonly config: ProjectConfig;
|
|
11
|
+
readonly generated: string;
|
|
12
|
+
readonly generator: string;
|
|
13
|
+
readonly root: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const lockfileOf = Effect.fn("Build.lockfileOf")(function* (root: string) {
|
|
17
|
+
const fs = yield* FileSystem.FileSystem;
|
|
18
|
+
const path = yield* Path.Path;
|
|
19
|
+
for (let directory = root; ; directory = path.dirname(directory)) {
|
|
20
|
+
for (const name of LOCKFILES) {
|
|
21
|
+
if (yield* fs.exists(path.join(directory, name))) {
|
|
22
|
+
return Option.some(path.join(directory, name));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (path.dirname(directory) === directory) {
|
|
26
|
+
return Option.none<string>();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// The generator's own code, so a changed generator rewrites every file even before its version changes.
|
|
32
|
+
export const generatorFingerprint = Effect.fn("Build.generatorFingerprint")(function* () {
|
|
33
|
+
const fs = yield* FileSystem.FileSystem;
|
|
34
|
+
const path = yield* Path.Path;
|
|
35
|
+
const digest = yield* Digest.Digest;
|
|
36
|
+
const directory = yield* path.fromFileUrl(new URL(".", import.meta.url));
|
|
37
|
+
const files = (yield* fs.readDirectory(directory, { recursive: true })).sort();
|
|
38
|
+
const texts = yield* Effect.forEach(files, (file) =>
|
|
39
|
+
Effect.gen(function* () {
|
|
40
|
+
const full = path.join(directory, file);
|
|
41
|
+
const info = yield* fs.stat(full);
|
|
42
|
+
return info.type === "File" ? `${file}\u0000${yield* fs.readFileString(full)}` : "";
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
const version = `@shivaedev/build ${packageInfo.version}`;
|
|
46
|
+
return yield* digest.digestHex(Digest.sha256, new TextEncoder().encode([version, ...texts].join("\u0001")));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Installed packages are covered by the lockfile and the generator's output by its own inputs, so the walk stops at both.
|
|
50
|
+
const importGraph = Effect.fn("Build.importGraph")(function* (project: Project, module: string) {
|
|
51
|
+
const fs = yield* FileSystem.FileSystem;
|
|
52
|
+
const seen = new Map<string, string>();
|
|
53
|
+
const pending = [module];
|
|
54
|
+
for (let file = pending.pop(); file !== undefined; file = pending.pop()) {
|
|
55
|
+
if (!seen.has(file)) {
|
|
56
|
+
const text = yield* fs.readFileString(file);
|
|
57
|
+
seen.set(file, text);
|
|
58
|
+
const imports = yield* resolvedImports(project.config, file, text);
|
|
59
|
+
pending.push(...imports.filter((imported) => !(imported.includes("/node_modules/") || imported.startsWith(project.generated))));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return seen;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
export const inputKey = Effect.fn("Build.inputKey")(function* (project: Project, module: string) {
|
|
66
|
+
const fs = yield* FileSystem.FileSystem;
|
|
67
|
+
const path = yield* Path.Path;
|
|
68
|
+
const digest = yield* Digest.Digest;
|
|
69
|
+
const graph = yield* importGraph(project, module);
|
|
70
|
+
const lockfile = yield* lockfileOf(project.root);
|
|
71
|
+
const chain = yield* Effect.forEach([...project.config.chain, ...project.config.declarations], (file) =>
|
|
72
|
+
Effect.map(fs.readFileString(file), (text) => [file, text] as const),
|
|
73
|
+
);
|
|
74
|
+
const lock = Option.isSome(lockfile) ? [[lockfile.value, yield* fs.readFileString(lockfile.value)] as const] : [];
|
|
75
|
+
const inputs = [...graph, ...chain, ...lock];
|
|
76
|
+
const entries = inputs.map(([file, text]) => `${path.relative(project.root, file)}\u0000${text}`).sort(byText);
|
|
77
|
+
const key = yield* digest.digestHex(
|
|
78
|
+
Digest.sha256,
|
|
79
|
+
new TextEncoder().encode([project.generator, `typescript ${project.config.typeScriptVersion}`, ...entries].join("\u0001")),
|
|
80
|
+
);
|
|
81
|
+
return { files: inputs.map(([file]) => file), key };
|
|
82
|
+
});
|
package/src/ownerOf.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Effect, Option, Path, Schema } from "effect";
|
|
2
|
+
import { readIfPresent } from "#files.ts";
|
|
3
|
+
|
|
4
|
+
// The package a module belongs to: the nearest package.json above it that has a name.
|
|
5
|
+
export interface Owner {
|
|
6
|
+
readonly directory: string;
|
|
7
|
+
readonly name: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const Manifest = Schema.fromJsonString(Schema.Struct({ name: Schema.optionalKey(Schema.String) }));
|
|
11
|
+
|
|
12
|
+
const decodeManifest = Schema.decodeUnknownOption(Manifest);
|
|
13
|
+
|
|
14
|
+
export const ownerOf = Effect.fn("Build.ownerOf")(function* (id: string, owners: Map<string, Option.Option<Owner>>) {
|
|
15
|
+
const path = yield* Path.Path;
|
|
16
|
+
if (!path.isAbsolute(id)) {
|
|
17
|
+
return Option.none<Owner>();
|
|
18
|
+
}
|
|
19
|
+
const visited: string[] = [];
|
|
20
|
+
for (let directory = path.dirname(id); ; directory = path.dirname(directory)) {
|
|
21
|
+
const known = owners.get(directory);
|
|
22
|
+
if (known !== undefined) {
|
|
23
|
+
for (const each of visited) {
|
|
24
|
+
owners.set(each, known);
|
|
25
|
+
}
|
|
26
|
+
return known;
|
|
27
|
+
}
|
|
28
|
+
visited.push(directory);
|
|
29
|
+
const name = Option.flatMap(yield* readIfPresent(path.join(directory, "package.json")), (text) =>
|
|
30
|
+
Option.flatMap(decodeManifest(text), (manifest) => Option.fromUndefinedOr(manifest.name)),
|
|
31
|
+
);
|
|
32
|
+
if (Option.isSome(name) || path.dirname(directory) === directory) {
|
|
33
|
+
const found = Option.map(name, (each): Owner => ({ directory, name: each }));
|
|
34
|
+
for (const each of visited) {
|
|
35
|
+
owners.set(each, found);
|
|
36
|
+
}
|
|
37
|
+
return found;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
});
|