@llm4ts/shell 2.33.0 → 2.34.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/flows/lib/port.js +23 -2
- package/flows/port-files.js +10 -3
- package/flows/port-guide.js +245 -0
- package/flows/port-ledger.js +180 -0
- package/flows/port-tests.js +177 -0
- package/kits/port/README.md +11 -6
- package/kits/port/packs/scala-ts/pack.md +36 -0
- package/kits/port/packs/scala-ts/patterns/pitfalls-scala-ts.md +21 -0
- package/kits/port/packs/scala-ts/prompts/porting.md +64 -0
- package/kits/port/packs/scala-ts/reviewers/effect-fidelity.md +13 -0
- package/kits/port/packs/zig-rust/pack.md +17 -0
- package/package.json +4 -4
package/flows/lib/port.js
CHANGED
|
@@ -8,7 +8,7 @@ import { autonomyContract } from "@llm4ts/flow/AutonomyContract";
|
|
|
8
8
|
import { cap } from "@llm4ts/flow/Context";
|
|
9
9
|
import { FlowAborted } from "@llm4ts/flow/FlowError";
|
|
10
10
|
import { loadPatternCards } from "@llm4ts/flow/Patterns";
|
|
11
|
-
import { PortEntry, linesOf, portStatusInstruction, targetPathOf } from "@llm4ts/flow/Port";
|
|
11
|
+
import { PortEntry, linesOf, parseLedger, portStatusInstruction, targetPathOf } from "@llm4ts/flow/Port";
|
|
12
12
|
import { matchingFiles } from "@llm4ts/flow/SpecChecks";
|
|
13
13
|
/** Where the port flows keep their state, under the target repository. */
|
|
14
14
|
export const portStateDir = (workDir) => join(workDir, ".llm4ts", "port");
|
|
@@ -73,9 +73,16 @@ export const implementerSystem = (porting) => [
|
|
|
73
73
|
"",
|
|
74
74
|
implementerDenials
|
|
75
75
|
].join("\n");
|
|
76
|
-
export const implementerPrompt = (entry, sourceText, porting, sourceChars) => [
|
|
76
|
+
export const implementerPrompt = (entry, sourceText, porting, sourceChars, ledgerRows) => [
|
|
77
77
|
`Port \`${entry.source}\` (${entry.loc} lines). Write the ported file at: ${entry.target}`,
|
|
78
78
|
"The draft need not compile; it must capture the logic faithfully.",
|
|
79
|
+
...(ledgerRows === undefined
|
|
80
|
+
? []
|
|
81
|
+
: [
|
|
82
|
+
"",
|
|
83
|
+
"Precomputed classifications for this file (cross-file analysis; trust them over local guessing):",
|
|
84
|
+
ledgerRows
|
|
85
|
+
]),
|
|
79
86
|
"",
|
|
80
87
|
portStatusInstruction(porting.comment),
|
|
81
88
|
"",
|
|
@@ -111,6 +118,20 @@ export const compileFixerSystem = (porting) => [
|
|
|
111
118
|
"# Porting rulebook",
|
|
112
119
|
porting.rulebook
|
|
113
120
|
].join("\n");
|
|
121
|
+
/** The ledger the pack's `port-ledger` writes, beside the specs; `undefined` when none. */
|
|
122
|
+
export const ledgerPath = (porting) => `${porting.pack.specsDir}/ledger.tsv`;
|
|
123
|
+
export const readLedgerRows = (files, workDir, porting) => Effect.map(files.read(join(workDir, ledgerPath(porting))), (text) => text === undefined ? [] : parseLedger(text));
|
|
124
|
+
/** A native engineer's brief for the trial port: no rulebook, idiomatic target code. */
|
|
125
|
+
export const nativeImplementerSystem = (porting) => [
|
|
126
|
+
autonomyContract("minimal"),
|
|
127
|
+
"",
|
|
128
|
+
`You are a senior engineer in the target language of pack ${porting.pack.name}. Port the`,
|
|
129
|
+
"source file the way you would write it natively and idiomatically, with no rulebook: your",
|
|
130
|
+
"draft is compared with a rules-following draft to find what the rulebook forgot. Read",
|
|
131
|
+
"exactly one source file; do not run a build or any git command; write only the target file.",
|
|
132
|
+
"",
|
|
133
|
+
implementerDenials
|
|
134
|
+
].join("\n");
|
|
114
135
|
export const portEnv = (environment) => {
|
|
115
136
|
const int = (raw, fallback, min = 1) => {
|
|
116
137
|
const value = Number.parseInt(raw ?? "", 10);
|
package/flows/port-files.js
CHANGED
|
@@ -17,11 +17,11 @@ import * as Effect from "effect/Effect";
|
|
|
17
17
|
import { ApprovedMarker, requireApproval } from "@llm4ts/flow/Approval";
|
|
18
18
|
import { makeChat } from "@llm4ts/flow/Chat";
|
|
19
19
|
import { Info } from "@llm4ts/flow/FlowEvents";
|
|
20
|
-
import { asAddedFileDiff, batchesOf, portStatusIn, renderPilotReport } from "@llm4ts/flow/Port";
|
|
20
|
+
import { asAddedFileDiff, batchesOf, ledgerRowsFor, portStatusIn, renderPilotReport } from "@llm4ts/flow/Port";
|
|
21
21
|
import { adversarialReviewer, reviewAndFixLoop } from "@llm4ts/flow/Review";
|
|
22
22
|
import { runQueue } from "@llm4ts/flow/WorkQueue";
|
|
23
23
|
import { asReadOnly, coderFromEnv, makeNodeWorkspace, nodePlainFileStore, openPack, resolveFlowInput, runFlowMain, runNode, stage } from "@llm4ts/runner";
|
|
24
|
-
import { asPortingPack, implementerPrompt, implementerSystem, portEnv, portFixerSystem, portManifest, portStateDir } from "./lib/port.js";
|
|
24
|
+
import { asPortingPack, implementerPrompt, implementerSystem, ledgerPath, portEnv, readLedgerRows, portFixerSystem, portManifest, portStateDir } from "./lib/port.js";
|
|
25
25
|
const renderReport = (outcomes, total, pending) => {
|
|
26
26
|
const done = outcomes.filter((outcome) => outcome.status === "done");
|
|
27
27
|
const failed = outcomes.filter((outcome) => outcome.status === "failed");
|
|
@@ -85,6 +85,13 @@ const program = Effect.gen(function* () {
|
|
|
85
85
|
yield* requireApproval(files, pilotPath);
|
|
86
86
|
}
|
|
87
87
|
const items = knobs.pilot > 0 ? pending.slice(0, knobs.pilot) : pending;
|
|
88
|
+
// A ledger written by port-ledger rides into every implementer prompt.
|
|
89
|
+
const ledger = yield* readLedgerRows(files, input.workDir, porting);
|
|
90
|
+
if (ledger.length > 0) {
|
|
91
|
+
yield* events.publish(Info.make({
|
|
92
|
+
message: `port: ${ledger.length} ledger row(s) from ${ledgerPath(porting)}`
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
88
95
|
const batches = batchesOf(items, { files: knobs.batch });
|
|
89
96
|
const started = yield* Clock.currentTimeMillis;
|
|
90
97
|
const outcomes = [];
|
|
@@ -108,7 +115,7 @@ const program = Effect.gen(function* () {
|
|
|
108
115
|
agent: "coder",
|
|
109
116
|
stall: { repeats: 5 }
|
|
110
117
|
});
|
|
111
|
-
yield* chat.ask(implementerPrompt(entry, source, porting, knobs.sourceChars));
|
|
118
|
+
yield* chat.ask(implementerPrompt(entry, source, porting, knobs.sourceChars, ledgerRowsFor(ledger, entry.source)));
|
|
112
119
|
const targetPath = join(input.workDir, entry.target);
|
|
113
120
|
if ((yield* files.read(targetPath)) === undefined) {
|
|
114
121
|
return { note: `nothing written at ${entry.target}` };
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// Audit the porting rulebook before it ports anything (ADR 0028, the Bun
|
|
2
|
+
// port's porting-md audit): dimension auditors read the rulebook against a
|
|
3
|
+
// few sample sources and propose rules; three refuters vote each finding
|
|
4
|
+
// down or let it stand; a trial port of the samples by the rules and again
|
|
5
|
+
// natively shows what the rulebook forgot. The result is a patch to
|
|
6
|
+
// `prompts/porting.md` behind an approval; the next run applies it.
|
|
7
|
+
//
|
|
8
|
+
// LLM4TS_PACK=zig-rust llm4ts run port-guide --repo ~/src/bun
|
|
9
|
+
//
|
|
10
|
+
// Knobs: LLM4TS_PORT_GUIDE_SAMPLE (3 files), LLM4TS_PORT_GUIDE_TRIAL (on|off).
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import * as Effect from "effect/Effect";
|
|
13
|
+
import * as Schema from "effect/Schema";
|
|
14
|
+
import { ApprovedMarker } from "@llm4ts/flow/Approval";
|
|
15
|
+
import { makeChat } from "@llm4ts/flow/Chat";
|
|
16
|
+
import { cap } from "@llm4ts/flow/Context";
|
|
17
|
+
import { Info } from "@llm4ts/flow/FlowEvents";
|
|
18
|
+
import { GuideFinding, defaultAuditDimensions, renderGuideAudit, standsAfterRefutes } from "@llm4ts/flow/Port";
|
|
19
|
+
import { AppliedMarkerPrefix, applyRuleEdit, retroApprovalOf } from "@llm4ts/flow/Retro";
|
|
20
|
+
import { asReadOnly, coderFromEnv, makeNodeWorkspace, nodePlainFileStore, openPack, resolveFlowInput, runFlowMain, runNode, stage } from "@llm4ts/runner";
|
|
21
|
+
import { structuredAndPublish } from "@llm4ts/flow/Flow";
|
|
22
|
+
import { asPortingPack, implementerPrompt, implementerSystem, nativeImplementerSystem, portEnv, portManifest, portStateDir } from "./lib/port.js";
|
|
23
|
+
class Findings extends Schema.Class("Findings")({
|
|
24
|
+
findings: Schema.Array(GuideFinding)
|
|
25
|
+
}) {
|
|
26
|
+
}
|
|
27
|
+
const findingItems = {
|
|
28
|
+
type: "array",
|
|
29
|
+
items: {
|
|
30
|
+
type: "object",
|
|
31
|
+
properties: {
|
|
32
|
+
dimension: { type: "string" },
|
|
33
|
+
finding: { type: "string" },
|
|
34
|
+
evidence: { type: "string" },
|
|
35
|
+
proposedRule: { type: "string" }
|
|
36
|
+
},
|
|
37
|
+
required: ["dimension", "finding", "evidence", "proposedRule"]
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
const findingsJson = {
|
|
41
|
+
type: "object",
|
|
42
|
+
properties: { findings: findingItems },
|
|
43
|
+
required: ["findings"]
|
|
44
|
+
};
|
|
45
|
+
class Refute extends Schema.Class("Refute")({
|
|
46
|
+
holds: Schema.Boolean,
|
|
47
|
+
why: Schema.String
|
|
48
|
+
}) {
|
|
49
|
+
}
|
|
50
|
+
const refuteJson = {
|
|
51
|
+
type: "object",
|
|
52
|
+
properties: { holds: { type: "boolean" }, why: { type: "string" } },
|
|
53
|
+
required: ["holds", "why"]
|
|
54
|
+
};
|
|
55
|
+
class Trial extends Schema.Class("Trial")({
|
|
56
|
+
differences: Schema.Array(Schema.String),
|
|
57
|
+
findings: Schema.Array(GuideFinding)
|
|
58
|
+
}) {
|
|
59
|
+
}
|
|
60
|
+
const trialJson = {
|
|
61
|
+
type: "object",
|
|
62
|
+
properties: { differences: { type: "array", items: { type: "string" } }, findings: findingItems },
|
|
63
|
+
required: ["differences", "findings"]
|
|
64
|
+
};
|
|
65
|
+
class AuditRecord extends Schema.Class("AuditRecord")({
|
|
66
|
+
kept: Schema.Array(GuideFinding)
|
|
67
|
+
}) {
|
|
68
|
+
}
|
|
69
|
+
const encodeAudit = Schema.encodeSync(Schema.fromJsonString(AuditRecord));
|
|
70
|
+
const decodeAudit = Schema.decodeUnknownOption(Schema.fromJsonString(AuditRecord));
|
|
71
|
+
const program = Effect.gen(function* () {
|
|
72
|
+
const input = yield* resolveFlowInput("Audit the porting rulebook against sample sources");
|
|
73
|
+
const coder = coderFromEnv(process.env);
|
|
74
|
+
const files = nodePlainFileStore;
|
|
75
|
+
const knobs = portEnv(process.env);
|
|
76
|
+
const sampleSize = Math.max(1, Number.parseInt(process.env.LLM4TS_PORT_GUIDE_SAMPLE ?? "3", 10) || 3);
|
|
77
|
+
const trialOn = process.env.LLM4TS_PORT_GUIDE_TRIAL?.trim().toLowerCase() !== "off";
|
|
78
|
+
const stateDir = portStateDir(input.workDir);
|
|
79
|
+
const reportPath = join(stateDir, "guide-audit.md");
|
|
80
|
+
const recordPath = join(stateDir, "guide-audit.json");
|
|
81
|
+
yield* runNode({
|
|
82
|
+
workDir: input.workDir,
|
|
83
|
+
workspace: input.workspace,
|
|
84
|
+
userPrompt: input.prompt,
|
|
85
|
+
coder,
|
|
86
|
+
reasoning: asReadOnly(coder),
|
|
87
|
+
reviewers: [asReadOnly(coder)],
|
|
88
|
+
environment: process.env
|
|
89
|
+
}, (context) => Effect.gen(function* () {
|
|
90
|
+
const events = context.events;
|
|
91
|
+
const repo = yield* makeNodeWorkspace(input.workDir);
|
|
92
|
+
const opened = yield* stage(events, "pack", openPack({
|
|
93
|
+
environment: process.env,
|
|
94
|
+
launchDir: input.workspace,
|
|
95
|
+
flowDir: import.meta.dirname
|
|
96
|
+
}));
|
|
97
|
+
const porting = yield* asPortingPack(opened);
|
|
98
|
+
const rulebookPath = `${opened.dir}/prompts/porting.md`;
|
|
99
|
+
// An approved, unapplied audit from an earlier run: apply it to the rulebook and stop.
|
|
100
|
+
const earlier = yield* files.read(reportPath);
|
|
101
|
+
if (earlier !== undefined) {
|
|
102
|
+
const approval = retroApprovalOf(earlier);
|
|
103
|
+
if (approval.approved && !approval.applied) {
|
|
104
|
+
const recordText = yield* files.read(recordPath);
|
|
105
|
+
const record = recordText === undefined ? undefined : decodeAudit(recordText);
|
|
106
|
+
if (record !== undefined && record._tag === "Some") {
|
|
107
|
+
let text = yield* opened.workspace
|
|
108
|
+
.read(rulebookPath)
|
|
109
|
+
.pipe(Effect.orElseSucceed(() => porting.rulebook));
|
|
110
|
+
let applied = 0;
|
|
111
|
+
for (const finding of record.value.kept) {
|
|
112
|
+
const next = applyRuleEdit(text, {
|
|
113
|
+
target: rulebookPath,
|
|
114
|
+
op: "append-rule",
|
|
115
|
+
line: finding.proposedRule,
|
|
116
|
+
why: finding.finding
|
|
117
|
+
});
|
|
118
|
+
if (next !== undefined) {
|
|
119
|
+
text = next;
|
|
120
|
+
applied += 1;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
yield* opened.workspace.write(rulebookPath, text);
|
|
124
|
+
yield* files.writeAtomic(reportPath, `${earlier.trimEnd()}\n${AppliedMarkerPrefix} ${new Date().toISOString()}\n`);
|
|
125
|
+
yield* events.publish(Info.make({
|
|
126
|
+
message: `port-guide: ${applied} rule(s) appended to ${join(opened.workspace.root, rulebookPath)}`
|
|
127
|
+
}));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const manifest = yield* stage(events, "manifest", portManifest(repo, files, input.workDir, porting));
|
|
133
|
+
const sample = manifest.slice(0, sampleSize);
|
|
134
|
+
const sources = [];
|
|
135
|
+
for (const entry of sample) {
|
|
136
|
+
const text = (yield* files.read(join(input.workDir, entry.source))) ?? "";
|
|
137
|
+
sources.push(`### ${entry.source}\n\`\`\`\n${cap(text, Math.floor(knobs.sourceChars / Math.max(1, sample.length))).text}\n\`\`\``);
|
|
138
|
+
}
|
|
139
|
+
const samples = sources.join("\n\n");
|
|
140
|
+
const dimensions = porting.pack.audit ?? defaultAuditDimensions;
|
|
141
|
+
const auditor = (dimension) => structuredAndPublish(context.reasoning, events, [
|
|
142
|
+
`You audit a porting rulebook along one dimension: ${dimension}.`,
|
|
143
|
+
"Read the rulebook and the sample source files. Report as findings the places where the",
|
|
144
|
+
"rulebook is silent, ambiguous, or wrong for what the samples actually contain: a construct",
|
|
145
|
+
"the samples use that the rules do not map, a mapping that would produce wrong or",
|
|
146
|
+
"non-idiomatic target code, two rules that conflict. Each finding names its evidence (a",
|
|
147
|
+
"rulebook line or a sample line) and proposes one rule, one line, that would settle it.",
|
|
148
|
+
"Report nothing the rulebook already covers. Return ONLY JSON matching the schema.",
|
|
149
|
+
"",
|
|
150
|
+
"# Rulebook",
|
|
151
|
+
porting.rulebook,
|
|
152
|
+
"",
|
|
153
|
+
"# Sample sources",
|
|
154
|
+
samples
|
|
155
|
+
].join("\n"), Findings, findingsJson, "auditor");
|
|
156
|
+
const audited = yield* stage(events, "audit", Effect.forEach(dimensions, auditor, { concurrency: 3 }));
|
|
157
|
+
let proposed = audited.flatMap((result) => result.findings);
|
|
158
|
+
const trial = [];
|
|
159
|
+
if (trialOn && sample.length > 0) {
|
|
160
|
+
yield* stage(events, "trial port", Effect.forEach(sample, (entry) => Effect.gen(function* () {
|
|
161
|
+
const source = (yield* files.read(join(input.workDir, entry.source))) ?? "";
|
|
162
|
+
const byRules = {
|
|
163
|
+
...entry,
|
|
164
|
+
target: `.llm4ts/port/trial/by-rules/${entry.target}`
|
|
165
|
+
};
|
|
166
|
+
const native = { ...entry, target: `.llm4ts/port/trial/native/${entry.target}` };
|
|
167
|
+
const rulesChat = yield* makeChat(context.coder, {
|
|
168
|
+
system: implementerSystem(porting),
|
|
169
|
+
events,
|
|
170
|
+
agent: "coder",
|
|
171
|
+
stall: { repeats: 5 }
|
|
172
|
+
});
|
|
173
|
+
yield* rulesChat.ask(implementerPrompt(byRules, source, porting, knobs.sourceChars));
|
|
174
|
+
const nativeChat = yield* makeChat(context.coder, {
|
|
175
|
+
system: nativeImplementerSystem(porting),
|
|
176
|
+
events,
|
|
177
|
+
agent: "coder",
|
|
178
|
+
stall: { repeats: 5 }
|
|
179
|
+
});
|
|
180
|
+
yield* nativeChat.ask(`Port \`${entry.source}\` natively. Write the ported file at: ${native.target}\n\nSource file:\n\`\`\`\n${cap(source, knobs.sourceChars).text}\n\`\`\``);
|
|
181
|
+
const rulesDraft = (yield* files.read(join(input.workDir, byRules.target))) ?? "(no draft written)";
|
|
182
|
+
const nativeDraft = (yield* files.read(join(input.workDir, native.target))) ?? "(no draft written)";
|
|
183
|
+
const compared = yield* structuredAndPublish(context.reasoning, events, [
|
|
184
|
+
`Compare the two drafts of \`${entry.source}\`: one written by the rulebook, one natively.`,
|
|
185
|
+
"List the differences that matter (where the native port made a better or different",
|
|
186
|
+
"choice than the rules forced), and for each that the rulebook should settle, a finding",
|
|
187
|
+
"with evidence and one proposed rule. Return ONLY JSON matching the schema.",
|
|
188
|
+
"",
|
|
189
|
+
"# Rulebook",
|
|
190
|
+
porting.rulebook,
|
|
191
|
+
"",
|
|
192
|
+
"# Draft by the rules",
|
|
193
|
+
"```",
|
|
194
|
+
cap(rulesDraft, 20_000).text,
|
|
195
|
+
"```",
|
|
196
|
+
"",
|
|
197
|
+
"# Native draft",
|
|
198
|
+
"```",
|
|
199
|
+
cap(nativeDraft, 20_000).text,
|
|
200
|
+
"```"
|
|
201
|
+
].join("\n"), Trial, trialJson, "trial-compare");
|
|
202
|
+
trial.push(...compared.differences.map((line) => `${entry.source}: ${line}`));
|
|
203
|
+
proposed = [...proposed, ...compared.findings];
|
|
204
|
+
}), { concurrency: 1 }));
|
|
205
|
+
}
|
|
206
|
+
// Three refuters per finding; a majority rejecting drops it.
|
|
207
|
+
const kept = [];
|
|
208
|
+
const dropped = [];
|
|
209
|
+
yield* stage(events, "refute", Effect.forEach(proposed, (finding) => Effect.gen(function* () {
|
|
210
|
+
const votes = yield* Effect.forEach([1, 2, 3], (vote) => structuredAndPublish(context.reasoning, events, [
|
|
211
|
+
`Refuter ${vote} of 3. Does this finding hold against the rulebook and the samples?`,
|
|
212
|
+
"Refute it (holds: false) if the rulebook already covers it, if the evidence does not",
|
|
213
|
+
"show what the finding claims, or if the proposed rule contradicts another rule. Return",
|
|
214
|
+
"ONLY JSON matching the schema.",
|
|
215
|
+
"",
|
|
216
|
+
`Dimension: ${finding.dimension}`,
|
|
217
|
+
`Finding: ${finding.finding}`,
|
|
218
|
+
`Evidence: ${finding.evidence}`,
|
|
219
|
+
`Proposed rule: ${finding.proposedRule}`,
|
|
220
|
+
"",
|
|
221
|
+
"# Rulebook",
|
|
222
|
+
porting.rulebook,
|
|
223
|
+
"",
|
|
224
|
+
"# Sample sources",
|
|
225
|
+
samples
|
|
226
|
+
].join("\n"), Refute, refuteJson, "refuter"), { concurrency: 3 });
|
|
227
|
+
if (standsAfterRefutes(votes.map((vote) => vote.holds)))
|
|
228
|
+
kept.push(finding);
|
|
229
|
+
else
|
|
230
|
+
dropped.push(finding);
|
|
231
|
+
}), { concurrency: 2 }));
|
|
232
|
+
yield* files.writeAtomic(reportPath, renderGuideAudit({
|
|
233
|
+
pack: porting.pack.name,
|
|
234
|
+
kept,
|
|
235
|
+
dropped,
|
|
236
|
+
trial,
|
|
237
|
+
sample: sample.map((entry) => entry.source)
|
|
238
|
+
}));
|
|
239
|
+
yield* files.writeAtomic(recordPath, `${encodeAudit(AuditRecord.make({ kept }))}\n`);
|
|
240
|
+
yield* events.publish(Info.make({
|
|
241
|
+
message: `port-guide: ${kept.length} finding(s) kept, ${dropped.length} dropped — read ${reportPath}, tick \`${ApprovedMarker}\`, run again to append the rules to ${rulebookPath}`
|
|
242
|
+
}));
|
|
243
|
+
}));
|
|
244
|
+
});
|
|
245
|
+
runFlowMain(program);
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// A precomputed cross-file table for the implementers (ADR 0028, the Bun
|
|
2
|
+
// port's LIFETIMES.tsv): every unit the pack's `## Ledger` regex names in a
|
|
3
|
+
// source file is classified by the reasoning seat with the line that proves
|
|
4
|
+
// it; UNKNOWN and low-confidence rows plus a fifth of the rest face three
|
|
5
|
+
// refuters; the table lands beside the specs and `port-files` hands each
|
|
6
|
+
// implementer its own rows, "trust the table over local guessing".
|
|
7
|
+
//
|
|
8
|
+
// LLM4TS_PACK=zig-rust llm4ts run port-ledger --repo ~/src/bun
|
|
9
|
+
//
|
|
10
|
+
// Knobs: LLM4TS_PORT_CONCURRENCY (4), LLM4TS_PORT_SOURCE_CHARS (120000).
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import * as Effect from "effect/Effect";
|
|
13
|
+
import * as Ref from "effect/Ref";
|
|
14
|
+
import * as Schema from "effect/Schema";
|
|
15
|
+
import { cap } from "@llm4ts/flow/Context";
|
|
16
|
+
import { FlowAborted } from "@llm4ts/flow/FlowError";
|
|
17
|
+
import { Info } from "@llm4ts/flow/FlowEvents";
|
|
18
|
+
import { LedgerRow, renderLedger, standsAfterRefutes, unitsIn } from "@llm4ts/flow/Port";
|
|
19
|
+
import { runQueue } from "@llm4ts/flow/WorkQueue";
|
|
20
|
+
import { asReadOnly, coderFromEnv, makeNodeWorkspace, nodePlainFileStore, openPack, resolveFlowInput, runFlowMain, runNode, stage } from "@llm4ts/runner";
|
|
21
|
+
import { structuredAndPublish } from "@llm4ts/flow/Flow";
|
|
22
|
+
import { asPortingPack, ledgerPath, portEnv, portManifest } from "./lib/port.js";
|
|
23
|
+
class Classified extends Schema.Class("Classified")({
|
|
24
|
+
rows: Schema.Array(Schema.Struct({
|
|
25
|
+
unit: Schema.String,
|
|
26
|
+
class: Schema.String,
|
|
27
|
+
evidence: Schema.String,
|
|
28
|
+
confidence: Schema.Literals(["high", "medium", "low"])
|
|
29
|
+
}))
|
|
30
|
+
}) {
|
|
31
|
+
}
|
|
32
|
+
const classifiedJson = {
|
|
33
|
+
type: "object",
|
|
34
|
+
properties: {
|
|
35
|
+
rows: {
|
|
36
|
+
type: "array",
|
|
37
|
+
items: {
|
|
38
|
+
type: "object",
|
|
39
|
+
properties: {
|
|
40
|
+
unit: { type: "string" },
|
|
41
|
+
class: { type: "string" },
|
|
42
|
+
evidence: { type: "string" },
|
|
43
|
+
confidence: { type: "string", enum: ["high", "medium", "low"] }
|
|
44
|
+
},
|
|
45
|
+
required: ["unit", "class", "evidence", "confidence"]
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
required: ["rows"]
|
|
50
|
+
};
|
|
51
|
+
class Refute extends Schema.Class("Refute")({
|
|
52
|
+
holds: Schema.Boolean,
|
|
53
|
+
why: Schema.String,
|
|
54
|
+
correctedClass: Schema.optionalKey(Schema.String)
|
|
55
|
+
}) {
|
|
56
|
+
}
|
|
57
|
+
const refuteJson = {
|
|
58
|
+
type: "object",
|
|
59
|
+
properties: {
|
|
60
|
+
holds: { type: "boolean" },
|
|
61
|
+
why: { type: "string" },
|
|
62
|
+
correctedClass: { type: "string" }
|
|
63
|
+
},
|
|
64
|
+
required: ["holds", "why"]
|
|
65
|
+
};
|
|
66
|
+
const program = Effect.gen(function* () {
|
|
67
|
+
const input = yield* resolveFlowInput("Classify every unit the pack's ledger regex names");
|
|
68
|
+
const coder = coderFromEnv(process.env);
|
|
69
|
+
const files = nodePlainFileStore;
|
|
70
|
+
const knobs = portEnv(process.env);
|
|
71
|
+
yield* runNode({
|
|
72
|
+
workDir: input.workDir,
|
|
73
|
+
workspace: input.workspace,
|
|
74
|
+
userPrompt: input.prompt,
|
|
75
|
+
coder,
|
|
76
|
+
reasoning: asReadOnly(coder),
|
|
77
|
+
reviewers: [asReadOnly(coder)],
|
|
78
|
+
environment: process.env
|
|
79
|
+
}, (context) => Effect.gen(function* () {
|
|
80
|
+
const events = context.events;
|
|
81
|
+
const repo = yield* makeNodeWorkspace(input.workDir);
|
|
82
|
+
const opened = yield* stage(events, "pack", openPack({
|
|
83
|
+
environment: process.env,
|
|
84
|
+
launchDir: input.workspace,
|
|
85
|
+
flowDir: import.meta.dirname
|
|
86
|
+
}));
|
|
87
|
+
const porting = yield* asPortingPack(opened);
|
|
88
|
+
const ledger = porting.pack.ledger;
|
|
89
|
+
if (ledger === undefined) {
|
|
90
|
+
return yield* FlowAborted.make({
|
|
91
|
+
message: `pack '${porting.pack.name}' has no '## Ledger' section (- unit: <regex>, - classes: …, - question: …)`
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
const classes = ledger.classes.length === 0 ? ["UNKNOWN"] : ledger.classes;
|
|
95
|
+
const unknown = classes.find((name) => name.toUpperCase() === "UNKNOWN") ?? "UNKNOWN";
|
|
96
|
+
const manifest = yield* stage(events, "manifest", portManifest(repo, files, input.workDir, porting));
|
|
97
|
+
const rows = yield* Ref.make([]);
|
|
98
|
+
const finished = yield* Ref.make(new Set());
|
|
99
|
+
yield* runQueue({
|
|
100
|
+
label: "ledger",
|
|
101
|
+
items: manifest,
|
|
102
|
+
done: (entry) => Effect.map(Ref.get(finished), (set) => set.has(entry.id)),
|
|
103
|
+
concurrency: knobs.concurrency,
|
|
104
|
+
maxRounds: 2,
|
|
105
|
+
events,
|
|
106
|
+
work: (entry) => Effect.gen(function* () {
|
|
107
|
+
const text = (yield* files.read(join(input.workDir, entry.source))) ?? "";
|
|
108
|
+
const units = unitsIn(text, ledger.unit);
|
|
109
|
+
if (units.length === 0) {
|
|
110
|
+
yield* Ref.update(finished, (set) => new Set([...set, entry.id]));
|
|
111
|
+
return { note: "no units" };
|
|
112
|
+
}
|
|
113
|
+
const classified = yield* structuredAndPublish(context.reasoning, events, [
|
|
114
|
+
`Classify every unit listed below in \`${entry.source}\`. ${ledger.question}`,
|
|
115
|
+
`Classes: ${classes.join(", ")}. Use ${unknown} when the file alone cannot tell.`,
|
|
116
|
+
"For each unit give the class, the evidence as `file:line` of the statement that proves",
|
|
117
|
+
"it (an initialisation, a deinit, an assignment), and your confidence. Return ONLY JSON",
|
|
118
|
+
"matching the schema.",
|
|
119
|
+
"",
|
|
120
|
+
"Units:",
|
|
121
|
+
...units.map((unit) => `- unit: ${unit}`),
|
|
122
|
+
"",
|
|
123
|
+
"```",
|
|
124
|
+
cap(text, knobs.sourceChars).text,
|
|
125
|
+
"```"
|
|
126
|
+
].join("\n"), Classified, classifiedJson, "ledger");
|
|
127
|
+
const settled = [];
|
|
128
|
+
for (const [index, row] of classified.rows.entries()) {
|
|
129
|
+
if (!units.includes(row.unit))
|
|
130
|
+
continue;
|
|
131
|
+
const doubtful = row.class.toUpperCase() === unknown.toUpperCase() ||
|
|
132
|
+
row.confidence === "low" ||
|
|
133
|
+
index % 5 === 0;
|
|
134
|
+
let klass = classes.includes(row.class) ? row.class : unknown;
|
|
135
|
+
let confidence = row.confidence;
|
|
136
|
+
if (doubtful) {
|
|
137
|
+
const votes = yield* Effect.forEach([1, 2, 3], (vote) => structuredAndPublish(context.reasoning, events, [
|
|
138
|
+
`Refuter ${vote} of 3. Does this classification hold? ${ledger.question}`,
|
|
139
|
+
`Unit \`${row.unit}\` in \`${entry.source}\` was classified ${klass} (evidence: ${row.evidence}).`,
|
|
140
|
+
`Classes: ${classes.join(", ")}. Refute it (holds: false) and give correctedClass when the`,
|
|
141
|
+
"file shows a different class, or when the evidence does not support it. Return ONLY JSON.",
|
|
142
|
+
"",
|
|
143
|
+
"```",
|
|
144
|
+
cap(text, knobs.sourceChars).text,
|
|
145
|
+
"```"
|
|
146
|
+
].join("\n"), Refute, refuteJson, "ledger-refuter"), { concurrency: 3 });
|
|
147
|
+
if (!standsAfterRefutes(votes.map((vote) => vote.holds))) {
|
|
148
|
+
const corrected = votes
|
|
149
|
+
.map((vote) => vote.correctedClass)
|
|
150
|
+
.find((candidate) => candidate !== undefined && classes.includes(candidate));
|
|
151
|
+
klass = corrected ?? unknown;
|
|
152
|
+
confidence = corrected === undefined ? "low" : "medium";
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
settled.push(LedgerRow.make({
|
|
156
|
+
file: entry.source,
|
|
157
|
+
unit: row.unit,
|
|
158
|
+
class: klass,
|
|
159
|
+
evidence: row.evidence,
|
|
160
|
+
confidence
|
|
161
|
+
}));
|
|
162
|
+
}
|
|
163
|
+
yield* Ref.update(rows, (all) => [...all, ...settled]);
|
|
164
|
+
yield* Ref.update(finished, (set) => new Set([...set, entry.id]));
|
|
165
|
+
return { note: `${settled.length} unit(s)` };
|
|
166
|
+
})
|
|
167
|
+
});
|
|
168
|
+
const all = [...(yield* Ref.get(rows))].sort((left, right) => `${left.file}\t${left.unit}`.localeCompare(`${right.file}\t${right.unit}`));
|
|
169
|
+
const path = ledgerPath(porting);
|
|
170
|
+
yield* files.writeAtomic(join(input.workDir, path), renderLedger(all));
|
|
171
|
+
yield* context.git.commitPaths(`port: ledger of ${all.length} unit(s)`, [path]);
|
|
172
|
+
const counts = new Map();
|
|
173
|
+
for (const row of all)
|
|
174
|
+
counts.set(row.class, (counts.get(row.class) ?? 0) + 1);
|
|
175
|
+
yield* events.publish(Info.make({
|
|
176
|
+
message: `port-ledger: ${all.length} unit(s) in ${manifest.length} file(s) → ${path} (${[...counts.entries()].map(([klass, count]) => `${klass} ${count}`).join(", ")})`
|
|
177
|
+
}));
|
|
178
|
+
}));
|
|
179
|
+
});
|
|
180
|
+
runFlowMain(program);
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// The differential tier (ADR 0028, the Bun port's test swarm): every test
|
|
2
|
+
// file runs once on the legacy build for a baseline, then on the target; a
|
|
3
|
+
// file passes when the target exits 0 and its pass count equals the
|
|
4
|
+
// baseline's; the rest are classified diverge, crash or hang and written as
|
|
5
|
+
// `.diag` files; one fixer per red file reads its diag as its only runtime
|
|
6
|
+
// evidence; two votes review; one rebuild per round; until green or stalled.
|
|
7
|
+
//
|
|
8
|
+
// LLM4TS_PACK=zig-rust llm4ts run port-tests --repo ~/src/bun
|
|
9
|
+
//
|
|
10
|
+
// Knobs: LLM4TS_PORT_CONCURRENCY (4), LLM4TS_REVIEW_VOTES (2 here),
|
|
11
|
+
// LLM4TS_PORT_TEST_ROUNDS (4), LLM4TS_GATE_TAIL_CHARS (4000).
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import * as Duration from "effect/Duration";
|
|
14
|
+
import * as Effect from "effect/Effect";
|
|
15
|
+
import * as Ref from "effect/Ref";
|
|
16
|
+
import * as Schema from "effect/Schema";
|
|
17
|
+
import { makeChat } from "@llm4ts/flow/Chat";
|
|
18
|
+
import { FlowAborted, Stalled } from "@llm4ts/flow/FlowError";
|
|
19
|
+
import { Info } from "@llm4ts/flow/FlowEvents";
|
|
20
|
+
import { diffVerdict, renderDiag, renderDifferentialReport } from "@llm4ts/flow/Port";
|
|
21
|
+
import { ReviewResult, adversarialReviewer, lintCommand, reviewAndFixLoop } from "@llm4ts/flow/Review";
|
|
22
|
+
import { matchingFiles } from "@llm4ts/flow/SpecChecks";
|
|
23
|
+
import { runQueue } from "@llm4ts/flow/WorkQueue";
|
|
24
|
+
import { asReadOnly, coderFromEnv, makeNodeWorkspace, nodePlainFileStore, nodeProcessExecutor, openPack, resolveFlowInput, runFlowMain, runNode, stage } from "@llm4ts/runner";
|
|
25
|
+
import { asPortingPack, compileFixerSystem, portEnv, portStateDir } from "./lib/port.js";
|
|
26
|
+
const Baseline = Schema.fromJsonString(ReviewResult);
|
|
27
|
+
const encodeBaseline = Schema.encodeSync(Baseline);
|
|
28
|
+
const decodeBaseline = Schema.decodeUnknownOption(Baseline);
|
|
29
|
+
const slug = (path) => path.replace(/[^a-z0-9]+/giu, "-").replace(/^-|-$/gu, "");
|
|
30
|
+
const program = Effect.gen(function* () {
|
|
31
|
+
const input = yield* resolveFlowInput("Run every test file on the legacy and the target build, fix what diverges");
|
|
32
|
+
const coder = coderFromEnv(process.env);
|
|
33
|
+
const files = nodePlainFileStore;
|
|
34
|
+
const knobs = portEnv(process.env);
|
|
35
|
+
const maxRounds = Math.max(1, Number.parseInt(process.env.LLM4TS_PORT_TEST_ROUNDS ?? "4", 10) || 4);
|
|
36
|
+
const tailChars = Math.max(500, Number.parseInt(process.env.LLM4TS_GATE_TAIL_CHARS ?? "4000", 10) || 4000);
|
|
37
|
+
const stateDir = join(portStateDir(input.workDir), "tests");
|
|
38
|
+
yield* runNode({
|
|
39
|
+
workDir: input.workDir,
|
|
40
|
+
workspace: input.workspace,
|
|
41
|
+
userPrompt: input.prompt,
|
|
42
|
+
coder,
|
|
43
|
+
reasoning: asReadOnly(coder),
|
|
44
|
+
reviewers: [asReadOnly(coder)],
|
|
45
|
+
environment: process.env
|
|
46
|
+
}, (context) => Effect.gen(function* () {
|
|
47
|
+
const events = context.events;
|
|
48
|
+
const repo = yield* makeNodeWorkspace(input.workDir);
|
|
49
|
+
const opened = yield* stage(events, "pack", openPack({
|
|
50
|
+
environment: process.env,
|
|
51
|
+
launchDir: input.workspace,
|
|
52
|
+
flowDir: import.meta.dirname
|
|
53
|
+
}));
|
|
54
|
+
const porting = yield* asPortingPack(opened);
|
|
55
|
+
const differential = porting.pack.differential;
|
|
56
|
+
if (differential === undefined) {
|
|
57
|
+
return yield* FlowAborted.make({
|
|
58
|
+
message: `pack '${porting.pack.name}' has no '## Differential' section (- tests: <regex>, - legacy: <cmd {{file}}>, - target: <cmd {{file}}>)`
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
const timeout = Duration.seconds(differential.timeoutSeconds);
|
|
62
|
+
const testFiles = yield* stage(events, "test files", matchingFiles(repo, differential.tests, porting.pack.exclude));
|
|
63
|
+
const command = (template, file) => template.map((part) => part.replaceAll("{{file}}", file));
|
|
64
|
+
const build = porting.pack.gate("build") ?? porting.pack.gate("check");
|
|
65
|
+
const baselineOf = (file) => Effect.gen(function* () {
|
|
66
|
+
const path = join(stateDir, `${slug(file)}.baseline.json`);
|
|
67
|
+
const stored = yield* files.read(path);
|
|
68
|
+
const decoded = stored === undefined ? undefined : decodeBaseline(stored);
|
|
69
|
+
if (decoded !== undefined && decoded._tag === "Some")
|
|
70
|
+
return decoded.value;
|
|
71
|
+
const legacy = yield* lintCommand(nodeProcessExecutor, events, command(differential.legacy, file), input.workDir, { timeout });
|
|
72
|
+
yield* files.writeAtomic(path, encodeBaseline(legacy));
|
|
73
|
+
return legacy;
|
|
74
|
+
});
|
|
75
|
+
let previous;
|
|
76
|
+
let flat = 0;
|
|
77
|
+
for (let round = 1; round <= maxRounds; round += 1) {
|
|
78
|
+
if (build !== undefined) {
|
|
79
|
+
const built = yield* stage(events, `build round ${round}`, lintCommand(nodeProcessExecutor, events, build, input.workDir, {
|
|
80
|
+
timeout: Duration.minutes(20)
|
|
81
|
+
}));
|
|
82
|
+
if (!built.isClean) {
|
|
83
|
+
return yield* FlowAborted.make({
|
|
84
|
+
message: `the target does not build; run port-compile first:\n${built.issues
|
|
85
|
+
.map((issue) => issue.description)
|
|
86
|
+
.join("\n")
|
|
87
|
+
.slice(-tailChars)}`
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const verdicts = yield* stage(events, `differential round ${round}`, Effect.forEach(testFiles, (file) => Effect.gen(function* () {
|
|
92
|
+
const legacy = yield* baselineOf(file);
|
|
93
|
+
const target = yield* lintCommand(nodeProcessExecutor, events, command(differential.target, file), input.workDir, { timeout });
|
|
94
|
+
const verdict = diffVerdict(file, legacy, target, [input.workDir]);
|
|
95
|
+
if (verdict.class !== "pass" && verdict.class !== "legacy-red") {
|
|
96
|
+
yield* files.writeAtomic(join(stateDir, `${slug(file)}.diag.md`), renderDiag(verdict, tailChars));
|
|
97
|
+
}
|
|
98
|
+
return verdict;
|
|
99
|
+
}), { concurrency: knobs.concurrency }));
|
|
100
|
+
yield* files.writeAtomic(join(stateDir, `report-${round}.md`), renderDifferentialReport(round, verdicts));
|
|
101
|
+
const red = verdicts.filter((verdict) => verdict.class === "diverge" || verdict.class === "crash" || verdict.class === "hang");
|
|
102
|
+
yield* events.publish(Info.make({
|
|
103
|
+
message: `port-tests: round ${round}, ${verdicts.length} file(s): ${verdicts.length - red.length - verdicts.filter((v) => v.class === "legacy-red").length} pass, ${red.length} red, ${verdicts.filter((v) => v.class === "legacy-red").length} red on legacy too`
|
|
104
|
+
}));
|
|
105
|
+
if (red.length === 0) {
|
|
106
|
+
yield* events.publish(Info.make({
|
|
107
|
+
message: `port-tests: green after ${round} round(s) — reports under ${stateDir}`
|
|
108
|
+
}));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (previous !== undefined && red.length >= previous) {
|
|
112
|
+
flat += 1;
|
|
113
|
+
if (flat >= 2) {
|
|
114
|
+
return yield* Stalled.make({
|
|
115
|
+
signal: "no-progress",
|
|
116
|
+
detail: `port-tests: ${red.length} red file(s) after round ${round}, no fewer than before`
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
flat = 0;
|
|
122
|
+
}
|
|
123
|
+
previous = red.length;
|
|
124
|
+
if (round === maxRounds)
|
|
125
|
+
break;
|
|
126
|
+
const finished = yield* Ref.make(new Set());
|
|
127
|
+
yield* runQueue({
|
|
128
|
+
label: `port-tests fix round ${round}`,
|
|
129
|
+
items: red.map((verdict) => ({ id: verdict.file, verdict })),
|
|
130
|
+
done: (item) => Effect.map(Ref.get(finished), (set) => set.has(item.id)),
|
|
131
|
+
concurrency: knobs.concurrency,
|
|
132
|
+
maxRounds: 1,
|
|
133
|
+
events,
|
|
134
|
+
ledger: { files, path: join(stateDir, "fix-ledger.jsonl") },
|
|
135
|
+
work: (item) => Effect.gen(function* () {
|
|
136
|
+
const diag = (yield* files.read(join(stateDir, `${slug(item.id)}.diag.md`))) ??
|
|
137
|
+
renderDiag(item.verdict, tailChars);
|
|
138
|
+
const chat = yield* makeChat(context.coder, {
|
|
139
|
+
system: compileFixerSystem(porting),
|
|
140
|
+
events,
|
|
141
|
+
agent: "coder",
|
|
142
|
+
stall: { repeats: 5 }
|
|
143
|
+
});
|
|
144
|
+
yield* chat.ask([
|
|
145
|
+
`Test file \`${item.id}\` passes on the legacy build and is ${item.verdict.class} on the target.`,
|
|
146
|
+
"The diagnostic below is your ONLY runtime evidence: fix the target code it points at (never",
|
|
147
|
+
"the test), do not rebuild or run tests yourself, and say `confidence: low` if you are",
|
|
148
|
+
"guessing without runtime confirmation.",
|
|
149
|
+
"",
|
|
150
|
+
diag
|
|
151
|
+
].join("\n"));
|
|
152
|
+
yield* reviewAndFixLoop({
|
|
153
|
+
reviewers: [adversarialReviewer, ...porting.pack.lenses],
|
|
154
|
+
reviewerService: context.reviewers[0] ?? context.reasoning,
|
|
155
|
+
coder: chat,
|
|
156
|
+
taskTitle: `port-tests ${item.id}`,
|
|
157
|
+
currentDiff: context.git.diffAll,
|
|
158
|
+
events,
|
|
159
|
+
maxRounds: 2,
|
|
160
|
+
votes: knobs.votes,
|
|
161
|
+
oracle: { diff: context.git.diffAll }
|
|
162
|
+
});
|
|
163
|
+
yield* Ref.update(finished, (set) => new Set([...set, item.id]));
|
|
164
|
+
return { note: item.verdict.class };
|
|
165
|
+
})
|
|
166
|
+
}).pipe(Effect.catchTag("Stalled", () => Effect.void));
|
|
167
|
+
const dirty = yield* context.git.uncommittedFiles;
|
|
168
|
+
if (dirty.length > 0) {
|
|
169
|
+
yield* context.git.commitAll(`port-tests: round ${round}, ${red.length} red file(s) worked`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
yield* events.publish(Info.make({
|
|
173
|
+
message: `port-tests: ${maxRounds} round(s) done, red files remain — see ${stateDir}`
|
|
174
|
+
}));
|
|
175
|
+
}));
|
|
176
|
+
});
|
|
177
|
+
runFlowMain(program);
|
package/kits/port/README.md
CHANGED
|
@@ -18,10 +18,15 @@ kits/port/
|
|
|
18
18
|
patterns/pitfalls-zig-rust.md syntactically alike, semantically different
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
Flows
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
Flows, in the order a port runs them: `port-guide` (audit the rulebook on a
|
|
22
|
+
few samples, behind an approval), `port-ledger` (classify the units the pack
|
|
23
|
+
names into `ledger.tsv`), `port-files` (drafts, with a pilot), `port-compile`
|
|
24
|
+
(diagnostics as a queue), `port-tests` (the differential tier). A pack for
|
|
25
|
+
another pair copies one of these and replaces the rulebook, the pitfall card,
|
|
26
|
+
`target:`, the diagnostics command, the ledger regex and the two test
|
|
27
|
+
commands.
|
|
24
28
|
|
|
25
|
-
| Pack | Source → target
|
|
26
|
-
| ---------- |
|
|
27
|
-
| `zig-rust` | Zig → Rust
|
|
29
|
+
| Pack | Source → target | Diagnostics | Ledger units |
|
|
30
|
+
| ---------- | ------------------------------- | ----------------------------------- | ------------------------ |
|
|
31
|
+
| `zig-rust` | Zig → Rust | `cargo check --message-format=json` | pointer and slice fields |
|
|
32
|
+
| `scala-ts` | Scala 3 / ZIO 2 → TS / Effect 4 | `tsc --pretty false` | classes, objects, traits |
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Pack: scala-ts
|
|
2
|
+
|
|
3
|
+
source: scala
|
|
4
|
+
sources: .*\.scala$
|
|
5
|
+
exclude: (^|/)(target|node_modules|\.bloop|\.metals|project)/
|
|
6
|
+
target: {{dir}}/{{base}}.ts
|
|
7
|
+
comment: //
|
|
8
|
+
specs-dir: docs/port
|
|
9
|
+
features-dir: docs/port/features
|
|
10
|
+
|
|
11
|
+
## Gates
|
|
12
|
+
|
|
13
|
+
- typecheck: pnpm typecheck
|
|
14
|
+
- test: pnpm test
|
|
15
|
+
|
|
16
|
+
## Diagnostics
|
|
17
|
+
|
|
18
|
+
- command: pnpm exec tsc -p tsconfig.json --pretty false
|
|
19
|
+
- format: tsc
|
|
20
|
+
|
|
21
|
+
## Ledger
|
|
22
|
+
|
|
23
|
+
- unit: ^\s*(?:final\s+)?(?:case\s+)?(?:class|object|trait|enum)\s+(\w+)
|
|
24
|
+
- classes: SERVICE, LAYER, DATA, ERROR, STREAM, TEST, UTIL, UNKNOWN
|
|
25
|
+
- question: What kind of thing is this declaration in an Effect port? SERVICE (a ZIO service trait → Context.Service), LAYER (a ZLayer → Layer), DATA (a case class or enum → Schema.Class or a tagged union), ERROR (an error ADT → Schema.TaggedError), STREAM (ZStream producer → Stream), TEST (a spec → @effect/vitest), UTIL (pure helpers).
|
|
26
|
+
|
|
27
|
+
## Audit
|
|
28
|
+
|
|
29
|
+
- dimensions: error channel and variance, services and layers, data modelling and schemas, streams and resources, concurrency primitives, test idioms, what not to translate
|
|
30
|
+
|
|
31
|
+
## Review rules
|
|
32
|
+
|
|
33
|
+
`any`, unchecked casts, namespaces and a global `Error` in the error channel
|
|
34
|
+
are findings. Every expected failure is a `Schema.TaggedError`; every
|
|
35
|
+
replaceable dependency is a service with a layer. Relative imports carry `.ts`
|
|
36
|
+
extensions.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Scala/ZIO → TypeScript/Effect pitfalls — alike on the page, different at runtime
|
|
3
|
+
matches: .
|
|
4
|
+
tags: port, scala, zio, effect
|
|
5
|
+
---
|
|
6
|
+
1. **Type parameter order.** `ZIO[R, E, A]` is `Effect<A, E, R>`: a mechanical
|
|
7
|
+
copy of the parameter list silently swaps the requirement and the value.
|
|
8
|
+
2. **Equality.** `==` on a case class compares structure; `===` on an object
|
|
9
|
+
compares identity. Use `Equal.equals` or compare fields.
|
|
10
|
+
3. **Laziness.** A Scala `lazy val` or by-name parameter evaluates once or on
|
|
11
|
+
demand; a TypeScript expression evaluates where it is written. Wrap in a
|
|
12
|
+
thunk or `Effect.suspend`.
|
|
13
|
+
4. **Option.get and head.** Scala throws; TypeScript yields `undefined` and
|
|
14
|
+
carries on. Make the absence a typed failure.
|
|
15
|
+
5. **Integer division and overflow.** Scala `Int` wraps at 32 bits and `/`
|
|
16
|
+
truncates; JavaScript numbers are doubles. Use `Math.trunc` and check
|
|
17
|
+
ranges where the source relied on `Int`.
|
|
18
|
+
6. **String formatting and `toString`.** A Scala `toString` on a case class
|
|
19
|
+
prints its fields; the TypeScript default prints `[object Object]`.
|
|
20
|
+
7. **Implicit conversions and givens.** They vanish in the port; every one is
|
|
21
|
+
an explicit call or a service the draft must name.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
You are translating one Scala 3 / ZIO 2 file to TypeScript with Effect 4.
|
|
2
|
+
Read this whole document before writing any code. The first pass is a
|
|
3
|
+
**draft** `.ts` beside the `.scala`, same basename, that captures the logic
|
|
4
|
+
faithfully; it does **not** need to type-check. The compile pass makes it
|
|
5
|
+
type-check module by module.
|
|
6
|
+
|
|
7
|
+
## Ground rules
|
|
8
|
+
|
|
9
|
+
- Same module, same names (camelCase for values, PascalCase for types), same
|
|
10
|
+
order of declarations, same control flow. Reviewers diff the two files side
|
|
11
|
+
by side.
|
|
12
|
+
- Use Effect 4 as the repository uses it: `Effect.gen` for sequencing,
|
|
13
|
+
`Effect.fn` for named reusable operations, `Context.Service` and `Layer` for
|
|
14
|
+
replaceable dependencies, `Schema.Class` / `Schema.TaggedClass` for data,
|
|
15
|
+
`Schema.TaggedError` for expected failures. Never `any`, never an unchecked
|
|
16
|
+
cast, never a global `Error` as a domain error.
|
|
17
|
+
- Relative imports use `.ts` extensions; a module imports a contract, never a
|
|
18
|
+
sibling's internals.
|
|
19
|
+
- Leave `// TODO(port): <reason>` for anything you cannot translate
|
|
20
|
+
confidently. Do not guess.
|
|
21
|
+
- Do not translate build definitions, sbt plugins or Scala-only tooling;
|
|
22
|
+
note them as `// SKIPPED(port): <what>`.
|
|
23
|
+
|
|
24
|
+
## Type map
|
|
25
|
+
|
|
26
|
+
| Scala / ZIO | TypeScript / Effect |
|
|
27
|
+
| ------------------------------------ | ---------------------------------------------- |
|
|
28
|
+
| `ZIO[R, E, A]` | `Effect.Effect<A, E, R>` (note the order) |
|
|
29
|
+
| `UIO[A]` / `Task[A]` | `Effect.Effect<A>` / `Effect.Effect<A, UnknownException>` |
|
|
30
|
+
| `ZStream[R, E, A]` | `Stream.Stream<A, E, R>` |
|
|
31
|
+
| `ZLayer[RIn, E, ROut]` | `Layer.Layer<ROut, E, RIn>` |
|
|
32
|
+
| `trait Service` + `ZIO.service` | `Context.Service` class + `yield* Service` |
|
|
33
|
+
| `case class` | `Schema.Class` |
|
|
34
|
+
| `enum` / sealed trait ADT | `Schema.Union` of `Schema.TaggedClass` |
|
|
35
|
+
| `Option[A]` | optional property / `A \| undefined` |
|
|
36
|
+
| `Either[E, A]` | `Result` or `Effect.Effect<A, E>` |
|
|
37
|
+
| `Chunk[A]` | `ReadonlyArray<A>` |
|
|
38
|
+
| `Ref[A]`, `Queue[A]`, `Hub[A]` | `Ref`, `Queue`, `PubSub` |
|
|
39
|
+
| `Scope` / `acquireRelease` | `Scope` / `Effect.acquireRelease` |
|
|
40
|
+
| `Schedule` | `Schedule` |
|
|
41
|
+
| zio-json codecs | `Schema` with `Schema.fromJsonString` |
|
|
42
|
+
| ZIO Test `spec` / `test` | `@effect/vitest` `describe` / `it.effect` |
|
|
43
|
+
| `for` comprehension | `Effect.gen(function* () { … })` |
|
|
44
|
+
|
|
45
|
+
## Idiom map
|
|
46
|
+
|
|
47
|
+
- `ZIO.attempt(…)` → `Effect.try(…)`; `ZIO.fail(E)` → `Effect.fail(E)`; `.orDie` → `Effect.orDie`.
|
|
48
|
+
- `.provide(layer)` / `.provideSome` → `Effect.provide(layer)`; layer composition order follows `Layer.provide`.
|
|
49
|
+
- `.catchSome { case e: X => … }` → `Effect.catchTag("X", …)`.
|
|
50
|
+
- `zio.Duration` → `Duration` from effect; `ZIO.sleep` → `Effect.sleep`.
|
|
51
|
+
- Implicit parameters and givens become explicit arguments or services.
|
|
52
|
+
- `==` on case classes is structural; TypeScript needs `Equal.equals` or a field-by-field compare.
|
|
53
|
+
|
|
54
|
+
## Output format
|
|
55
|
+
|
|
56
|
+
End the file with the trailer the flow reads:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
// PORT STATUS
|
|
60
|
+
// source: <path>.scala
|
|
61
|
+
// confidence: high | medium | low
|
|
62
|
+
// todos: <count of TODO(port)>
|
|
63
|
+
// notes: <one line>
|
|
64
|
+
```
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
files: \.ts$
|
|
3
|
+
---
|
|
4
|
+
Review a TypeScript draft against the rulebook as a port reviewer would, with
|
|
5
|
+
the Scala source in mind: the diff is the draft. Report as findings: a method,
|
|
6
|
+
field or case the source has that the draft lacks; `any`, an unchecked cast, a
|
|
7
|
+
namespace, or a global `Error` in an error channel; a service without a layer
|
|
8
|
+
where the source had a `ZLayer`; a relative import without a `.ts` extension;
|
|
9
|
+
a case class turned into a plain interface where a `Schema.Class` belongs;
|
|
10
|
+
`ZIO[R, E, A]` order kept as `Effect<R, E, A>`; a `for` comprehension turned
|
|
11
|
+
into nested callbacks where `Effect.gen` was the answer; a guessed translation
|
|
12
|
+
where a `TODO(port)` was honest. Do not report imports that cannot resolve
|
|
13
|
+
yet: the compile pass owns those.
|
|
@@ -17,6 +17,23 @@ features-dir: docs/port/features
|
|
|
17
17
|
- command: cargo check --workspace --message-format=json
|
|
18
18
|
- format: cargo
|
|
19
19
|
|
|
20
|
+
## Ledger
|
|
21
|
+
|
|
22
|
+
- unit: ^\s+(\w+):\s*(?:\?\*|\*|\[\]|\[\*\])
|
|
23
|
+
- classes: OWNED, SHARED, BORROW_PARAM, BORROW_FIELD, STATIC, JSC_BORROW, BACKREF, INTRUSIVE, FFI, ARENA, UNKNOWN
|
|
24
|
+
- question: Who owns the memory this pointer or slice field points at, and how long does it live? The Rust type follows from the class (OWNED → Box/Vec, BORROW_* → a reference with a lifetime, SHARED → Rc/Arc, FFI → a raw pointer).
|
|
25
|
+
|
|
26
|
+
## Differential
|
|
27
|
+
|
|
28
|
+
- tests: ^test/.*\.test\.(ts|js)$
|
|
29
|
+
- legacy: scripts/legacy-test.sh {{file}}
|
|
30
|
+
- target: scripts/target-test.sh {{file}}
|
|
31
|
+
- timeout: 60
|
|
32
|
+
|
|
33
|
+
## Audit
|
|
34
|
+
|
|
35
|
+
- dimensions: error model, allocator threading, collections and strings, comptime carry-over, pointer and ownership idioms, API shape, what not to translate
|
|
36
|
+
|
|
20
37
|
## Review rules
|
|
21
38
|
|
|
22
39
|
Every `unsafe` block carries a `// SAFETY: <why>` comment, and no new
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@llm4ts/shell",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.34.0",
|
|
4
4
|
"description": "Interactive shell and CLI for llm4ts: flow discovery, run-a-flow, and view",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -52,9 +52,9 @@
|
|
|
52
52
|
"dependencies": {
|
|
53
53
|
"@effect/platform-node": "4.0.0",
|
|
54
54
|
"@effect/platform-node-shared": "4.0.0",
|
|
55
|
-
"@llm4ts/core": "2.
|
|
56
|
-
"@llm4ts/flow": "2.
|
|
57
|
-
"@llm4ts/runner": "2.
|
|
55
|
+
"@llm4ts/core": "2.34.0",
|
|
56
|
+
"@llm4ts/flow": "2.34.0",
|
|
57
|
+
"@llm4ts/runner": "2.34.0"
|
|
58
58
|
},
|
|
59
59
|
"peerDependencies": {
|
|
60
60
|
"effect": "4.0.0"
|