@llm4ts/shell 2.32.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 +147 -0
- package/flows/port-compile.js +139 -0
- package/flows/port-files.js +173 -0
- 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 +32 -0
- 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 +43 -0
- package/kits/port/packs/zig-rust/patterns/pitfalls-zig-rust.md +22 -0
- package/kits/port/packs/zig-rust/prompts/porting.md +67 -0
- package/kits/port/packs/zig-rust/reviewers/port-fidelity.md +13 -0
- package/package.json +4 -4
|
@@ -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);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# port
|
|
2
|
+
|
|
3
|
+
Language-to-language ports, file by file, the way Bun was rewritten from Zig
|
|
4
|
+
to Rust in May 2026 (ADR 0028): a rulebook the human writes with the model, a
|
|
5
|
+
pilot of a few files behind an approval, then every source file drafted at its
|
|
6
|
+
target path by one implementer with exactly one source in view, reviewed by
|
|
7
|
+
two adversarial votes, fixed by a separate fixer, ending in a `PORT STATUS`
|
|
8
|
+
trailer; then compiler diagnostics worked as a queue, one unit per fixer, one
|
|
9
|
+
rebuild per round. The source stays in the tree as the spec: this kit is not
|
|
10
|
+
clean-room.
|
|
11
|
+
|
|
12
|
+
```text
|
|
13
|
+
kits/port/
|
|
14
|
+
packs/zig-rust/ the reference pair, distilled from the Bun port
|
|
15
|
+
pack.md sources:, target:, comment:, ## Gates, ## Diagnostics
|
|
16
|
+
prompts/porting.md the rulebook every implementer reads whole
|
|
17
|
+
reviewers/ lenses that diff source and target
|
|
18
|
+
patterns/pitfalls-zig-rust.md syntactically alike, semantically different
|
|
19
|
+
```
|
|
20
|
+
|
|
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.
|
|
28
|
+
|
|
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.
|