@llm4ts/flow 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/dist/Diagnostics.d.ts +28 -0
- package/dist/Diagnostics.d.ts.map +1 -0
- package/dist/Diagnostics.js +103 -0
- package/dist/Diagnostics.js.map +1 -0
- package/dist/FlowError.d.ts +1 -1
- package/dist/FlowError.js +1 -1
- package/dist/FlowError.js.map +1 -1
- package/dist/Pack.d.ts +39 -0
- package/dist/Pack.d.ts.map +1 -1
- package/dist/Pack.js +38 -0
- package/dist/Pack.js.map +1 -1
- package/dist/Port.d.ts +116 -0
- package/dist/Port.d.ts.map +1 -0
- package/dist/Port.js +345 -0
- package/dist/Port.js.map +1 -0
- package/dist/Retro.d.ts.map +1 -1
- package/dist/Retro.js +9 -2
- package/dist/Retro.js.map +1 -1
- package/dist/WorkQueue.d.ts +63 -0
- package/dist/WorkQueue.d.ts.map +1 -0
- package/dist/WorkQueue.js +117 -0
- package/dist/WorkQueue.js.map +1 -0
- package/package.json +5 -2
- package/src/Diagnostics.ts +125 -0
- package/src/FlowError.ts +1 -1
- package/src/Pack.ts +80 -0
- package/src/Port.ts +429 -0
- package/src/Retro.ts +9 -2
- package/src/WorkQueue.ts +194 -0
package/src/Pack.ts
CHANGED
|
@@ -79,6 +79,44 @@ export interface Pack {
|
|
|
79
79
|
* pack lens carries after the shared preamble, or `- preamble: off`.
|
|
80
80
|
*/
|
|
81
81
|
readonly reviewRules: { readonly text: string; readonly preamble: boolean } | undefined
|
|
82
|
+
/**
|
|
83
|
+
* `target:` (ADR 0028): the template of a source file's port
|
|
84
|
+
* (`{{dir}}/{{base}}.rs`); present only in a porting pack.
|
|
85
|
+
*/
|
|
86
|
+
readonly target: string | undefined
|
|
87
|
+
/** `comment:` (ADR 0028): the target language's line-comment marker for the PORT STATUS trailer; default `//`. */
|
|
88
|
+
readonly comment: string | undefined
|
|
89
|
+
/**
|
|
90
|
+
* `## Diagnostics` (ADR 0028): `- command: …` prints what is red after a
|
|
91
|
+
* build, `- format: json | cargo` says how to read it. Present only in a
|
|
92
|
+
* porting pack.
|
|
93
|
+
*/
|
|
94
|
+
/**
|
|
95
|
+
* `## Ledger` (ADR 0028): what `port-ledger` classifies. `- unit: <regex>`
|
|
96
|
+
* whose first capture group names a unit in a source file, `- classes: A,
|
|
97
|
+
* B, UNKNOWN`, `- question: <what to decide for each unit>`.
|
|
98
|
+
*/
|
|
99
|
+
readonly ledger:
|
|
100
|
+
| { readonly unit: string; readonly classes: ReadonlyArray<string>; readonly question: string }
|
|
101
|
+
| undefined
|
|
102
|
+
/**
|
|
103
|
+
* `## Differential` (ADR 0028): what `port-tests` compares. `- tests: <regex>`
|
|
104
|
+
* over test files, `- legacy: <command with {{file}}>` run on the old
|
|
105
|
+
* build, `- target: <command with {{file}}>` on the new, `- timeout: <s>`.
|
|
106
|
+
*/
|
|
107
|
+
readonly differential:
|
|
108
|
+
| {
|
|
109
|
+
readonly tests: string
|
|
110
|
+
readonly legacy: ReadonlyArray<string>
|
|
111
|
+
readonly target: ReadonlyArray<string>
|
|
112
|
+
readonly timeoutSeconds: number
|
|
113
|
+
}
|
|
114
|
+
| undefined
|
|
115
|
+
/** `## Audit` (ADR 0028): `- dimensions: a, b, c` the rulebook auditors take; default list in the flow. */
|
|
116
|
+
readonly audit: ReadonlyArray<string> | undefined
|
|
117
|
+
readonly diagnostics:
|
|
118
|
+
| { readonly command: ReadonlyArray<string>; readonly format: "json" | "cargo" | "tsc" }
|
|
119
|
+
| undefined
|
|
82
120
|
readonly dir: string
|
|
83
121
|
readonly gate: (name: string) => ReadonlyArray<string> | undefined
|
|
84
122
|
readonly prompt: (name: string) => string | undefined
|
|
@@ -305,6 +343,42 @@ export const loadPack = Effect.fn("@llm4ts/flow/Pack.load")(function* (
|
|
|
305
343
|
message: `pack manifest 'spec-schema:' must be 'pagespec' when set, got: ${specSchema}`
|
|
306
344
|
})
|
|
307
345
|
}
|
|
346
|
+
const diagnosticsValues = namedItems(section(manifest.sections, "Diagnostics"))
|
|
347
|
+
const diagnostics =
|
|
348
|
+
diagnosticsValues.command === undefined
|
|
349
|
+
? undefined
|
|
350
|
+
: {
|
|
351
|
+
command: diagnosticsValues.command.split(/\s+/),
|
|
352
|
+
format:
|
|
353
|
+
diagnosticsValues.format?.toLowerCase() === "cargo"
|
|
354
|
+
? ("cargo" as const)
|
|
355
|
+
: diagnosticsValues.format?.toLowerCase() === "tsc"
|
|
356
|
+
? ("tsc" as const)
|
|
357
|
+
: ("json" as const)
|
|
358
|
+
}
|
|
359
|
+
const ledgerValues = namedItems(section(manifest.sections, "Ledger"))
|
|
360
|
+
const ledger =
|
|
361
|
+
ledgerValues.unit === undefined
|
|
362
|
+
? undefined
|
|
363
|
+
: {
|
|
364
|
+
unit: ledgerValues.unit,
|
|
365
|
+
classes: commaList(ledgerValues.classes),
|
|
366
|
+
question: ledgerValues.question ?? "Classify this unit."
|
|
367
|
+
}
|
|
368
|
+
const differentialValues = namedItems(section(manifest.sections, "Differential"))
|
|
369
|
+
const differential =
|
|
370
|
+
differentialValues.tests === undefined ||
|
|
371
|
+
differentialValues.legacy === undefined ||
|
|
372
|
+
differentialValues.target === undefined
|
|
373
|
+
? undefined
|
|
374
|
+
: {
|
|
375
|
+
tests: differentialValues.tests,
|
|
376
|
+
legacy: differentialValues.legacy.split(/\s+/),
|
|
377
|
+
target: differentialValues.target.split(/\s+/),
|
|
378
|
+
timeoutSeconds: Number.parseInt(differentialValues.timeout ?? "", 10) || 120
|
|
379
|
+
}
|
|
380
|
+
const auditBody = section(manifest.sections, "Audit")
|
|
381
|
+
const audit = auditBody === undefined ? undefined : commaList(namedItems(auditBody).dimensions)
|
|
308
382
|
const oracleValues = namedItems(section(manifest.sections, "Oracle"))
|
|
309
383
|
const oracle: OracleRules | undefined =
|
|
310
384
|
section(manifest.sections, "Oracle") === undefined
|
|
@@ -379,6 +453,12 @@ export const loadPack = Effect.fn("@llm4ts/flow/Pack.load")(function* (
|
|
|
379
453
|
consolidate,
|
|
380
454
|
oracle,
|
|
381
455
|
reviewRules,
|
|
456
|
+
target: fields["target"],
|
|
457
|
+
comment: fields["comment"],
|
|
458
|
+
ledger,
|
|
459
|
+
differential,
|
|
460
|
+
audit,
|
|
461
|
+
diagnostics,
|
|
382
462
|
dir: directory,
|
|
383
463
|
gate: (name) => gates[name],
|
|
384
464
|
prompt: (name) => prompts[name],
|
package/src/Port.ts
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
// Porting a code base file by file (ADR 0028): the manifest, its batches, the
|
|
2
|
+
// deterministic target path, the machine-readable PORT STATUS trailer every
|
|
3
|
+
// draft ends with, and the pilot report. Pure; the flows own the seats.
|
|
4
|
+
import { basename, dirname, extname } from "node:path"
|
|
5
|
+
import * as Schema from "effect/Schema"
|
|
6
|
+
import type { ReviewResult } from "./Review.ts"
|
|
7
|
+
import { DraftApprovalMarker } from "./Approval.ts"
|
|
8
|
+
import type { QueueOutcome } from "./WorkQueue.ts"
|
|
9
|
+
|
|
10
|
+
export class PortEntry extends Schema.Class<PortEntry>("PortEntry")({
|
|
11
|
+
/** Repo-relative source path; also the queue item id. */
|
|
12
|
+
id: Schema.String,
|
|
13
|
+
source: Schema.String,
|
|
14
|
+
target: Schema.String,
|
|
15
|
+
loc: Schema.Int
|
|
16
|
+
}) {}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The target path of a source, from the pack's `target:` template:
|
|
20
|
+
* `{{dir}}` (the source's directory, "" at the root), `{{base}}` (its name
|
|
21
|
+
* without extension), `{{ext}}` (the extension without the dot), `{{path}}`
|
|
22
|
+
* (the whole source path).
|
|
23
|
+
*/
|
|
24
|
+
export const targetPathOf = (template: string, source: string): string => {
|
|
25
|
+
const dir = dirname(source)
|
|
26
|
+
const ext = extname(source)
|
|
27
|
+
const rendered = template
|
|
28
|
+
.replaceAll("{{dir}}", dir === "." ? "" : dir)
|
|
29
|
+
.replaceAll("{{base}}", basename(source, ext))
|
|
30
|
+
.replaceAll("{{ext}}", ext.replace(/^\./u, ""))
|
|
31
|
+
.replaceAll("{{path}}", source)
|
|
32
|
+
return rendered.replace(/^\/+/u, "").replaceAll("//", "/")
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const linesOf = (text: string): number =>
|
|
36
|
+
text.length === 0 ? 0 : text.split(/\r?\n/u).filter((line) => line.trim().length > 0).length
|
|
37
|
+
|
|
38
|
+
export interface BatchPolicy {
|
|
39
|
+
/** Files per batch. Default 100. */
|
|
40
|
+
readonly files?: number
|
|
41
|
+
/** A batch whose first file is longer than this (lines) shrinks to `small`. Default 2200. */
|
|
42
|
+
readonly bigLoc?: number
|
|
43
|
+
/** Default 6. */
|
|
44
|
+
readonly small?: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Contiguous batches of the manifest; a batch led by a big file is small (the Bun rule). */
|
|
48
|
+
export const batchesOf = (
|
|
49
|
+
entries: ReadonlyArray<PortEntry>,
|
|
50
|
+
policy: BatchPolicy = {}
|
|
51
|
+
): ReadonlyArray<ReadonlyArray<PortEntry>> => {
|
|
52
|
+
const files = Math.max(1, policy.files ?? 100)
|
|
53
|
+
const bigLoc = policy.bigLoc ?? 2_200
|
|
54
|
+
const small = Math.max(1, policy.small ?? 6)
|
|
55
|
+
const batches: Array<ReadonlyArray<PortEntry>> = []
|
|
56
|
+
let index = 0
|
|
57
|
+
while (index < entries.length) {
|
|
58
|
+
const first = entries[index]
|
|
59
|
+
const size = first !== undefined && first.loc > bigLoc ? small : files
|
|
60
|
+
batches.push(entries.slice(index, index + size))
|
|
61
|
+
index += size
|
|
62
|
+
}
|
|
63
|
+
return batches
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const PortConfidence = Schema.Literals(["high", "medium", "low"])
|
|
67
|
+
export type PortConfidence = typeof PortConfidence.Type
|
|
68
|
+
|
|
69
|
+
export interface PortStatus {
|
|
70
|
+
readonly confidence: PortConfidence | undefined
|
|
71
|
+
readonly todos: number | undefined
|
|
72
|
+
readonly notes: string | undefined
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const statusWindow = 40
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The PORT STATUS trailer of a draft: in any comment syntax, within the last
|
|
79
|
+
* lines of the file, `confidence: high|medium|low`, `todos: <n>`, `notes: …`.
|
|
80
|
+
* All undefined when the draft has no trailer.
|
|
81
|
+
*/
|
|
82
|
+
export const portStatusIn = (text: string): PortStatus => {
|
|
83
|
+
const lines = text.split(/\r?\n/u)
|
|
84
|
+
const tail = lines.slice(Math.max(0, lines.length - statusWindow))
|
|
85
|
+
const start = tail.findIndex((line) => /PORT STATUS/u.test(line))
|
|
86
|
+
if (start < 0) {
|
|
87
|
+
return { confidence: undefined, todos: undefined, notes: undefined }
|
|
88
|
+
}
|
|
89
|
+
const block = tail.slice(start).join("\n")
|
|
90
|
+
const confidence = /confidence:\s*(high|medium|low)\b/iu.exec(block)?.[1]?.toLowerCase()
|
|
91
|
+
const todos = /todos?:\s*(\d+)/iu.exec(block)?.[1]
|
|
92
|
+
const notes = /notes?:\s*(.+)$/imu.exec(block)?.[1]?.trim()
|
|
93
|
+
return {
|
|
94
|
+
confidence:
|
|
95
|
+
confidence === "high" || confidence === "medium" || confidence === "low"
|
|
96
|
+
? confidence
|
|
97
|
+
: undefined,
|
|
98
|
+
todos: todos === undefined ? undefined : Number.parseInt(todos, 10),
|
|
99
|
+
notes: notes === undefined || notes.length === 0 ? undefined : notes.replace(/\s*\*\/\s*$/u, "")
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The instruction every implementer gets: how to end the file. */
|
|
104
|
+
export const portStatusInstruction = (comment: string): string =>
|
|
105
|
+
[
|
|
106
|
+
`End the file with a machine-readable trailer in ${comment} comments, exactly this shape:`,
|
|
107
|
+
`${comment} PORT STATUS`,
|
|
108
|
+
`${comment} source: <the source path>`,
|
|
109
|
+
`${comment} confidence: high | medium | low`,
|
|
110
|
+
`${comment} todos: <number of TODO(port) markers you left>`,
|
|
111
|
+
`${comment} notes: <one line: what you could not translate faithfully, or "none">`,
|
|
112
|
+
"`confidence: low` means the logic is probably wrong and must be re-read against the source."
|
|
113
|
+
].join("\n")
|
|
114
|
+
|
|
115
|
+
export interface PilotFacts {
|
|
116
|
+
readonly outcomes: ReadonlyArray<QueueOutcome>
|
|
117
|
+
readonly piloted: number
|
|
118
|
+
readonly remaining: number
|
|
119
|
+
readonly elapsedMs: number
|
|
120
|
+
readonly estimatedCostUsd: number | undefined
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const minutes = (ms: number): string => `${(ms / 60_000).toFixed(1)} min`
|
|
124
|
+
|
|
125
|
+
/** The pilot report (R7 of the plan): rates measured, totals extrapolated, an approval to continue. */
|
|
126
|
+
export const renderPilotReport = (facts: PilotFacts): string => {
|
|
127
|
+
const done = facts.outcomes.filter((outcome) => outcome.status === "done")
|
|
128
|
+
const failed = facts.outcomes.filter((outcome) => outcome.status === "failed")
|
|
129
|
+
const count = (level: PortConfidence) =>
|
|
130
|
+
done.filter((outcome) => outcome.confidence === level).length
|
|
131
|
+
const todos = done.reduce((sum, outcome) => sum + (outcome.todos ?? 0), 0)
|
|
132
|
+
const perItemMs = facts.piloted === 0 ? 0 : facts.elapsedMs / facts.piloted
|
|
133
|
+
const perItemCost =
|
|
134
|
+
facts.estimatedCostUsd === undefined || facts.piloted === 0
|
|
135
|
+
? undefined
|
|
136
|
+
: facts.estimatedCostUsd / facts.piloted
|
|
137
|
+
return [
|
|
138
|
+
"# Port pilot",
|
|
139
|
+
"",
|
|
140
|
+
`- piloted: ${facts.piloted} file(s), ${done.length} done, ${failed.length} failed, in ${minutes(facts.elapsedMs)}`,
|
|
141
|
+
`- confidence: ${count("high")} high, ${count("medium")} medium, ${count("low")} low; ${todos} TODO(port) marker(s)`,
|
|
142
|
+
...(perItemCost === undefined
|
|
143
|
+
? []
|
|
144
|
+
: [
|
|
145
|
+
`- cost: ~$${(facts.estimatedCostUsd ?? 0).toFixed(2)} (~$${perItemCost.toFixed(3)} per file)`
|
|
146
|
+
]),
|
|
147
|
+
`- remaining: ${facts.remaining} file(s) → about ${minutes(perItemMs * facts.remaining)} at this rate${
|
|
148
|
+
perItemCost === undefined ? "" : ` and ~$${(perItemCost * facts.remaining).toFixed(2)}`
|
|
149
|
+
}`,
|
|
150
|
+
"",
|
|
151
|
+
...(failed.length === 0
|
|
152
|
+
? []
|
|
153
|
+
: [
|
|
154
|
+
"## Failed",
|
|
155
|
+
"",
|
|
156
|
+
...failed.map((outcome) => `- ${outcome.id}: ${outcome.note ?? ""}`),
|
|
157
|
+
""
|
|
158
|
+
]),
|
|
159
|
+
"Read the drafted files beside their sources, then tick the box to let the next run",
|
|
160
|
+
"port the rest; edit `prompts/porting.md` in the pack first if the drafts show a",
|
|
161
|
+
"pattern the rulebook should settle.",
|
|
162
|
+
"",
|
|
163
|
+
DraftApprovalMarker,
|
|
164
|
+
""
|
|
165
|
+
].join("\n")
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** A whole new file rendered as a unified diff of additions, for reviewers that judge diffs. */
|
|
169
|
+
export const asAddedFileDiff = (path: string, content: string): string => {
|
|
170
|
+
const lines = content.split(/\r?\n/u)
|
|
171
|
+
return [
|
|
172
|
+
`diff --git a/${path} b/${path}`,
|
|
173
|
+
"new file mode 100644",
|
|
174
|
+
"--- /dev/null",
|
|
175
|
+
`+++ b/${path}`,
|
|
176
|
+
`@@ -0,0 +1,${lines.length} @@`,
|
|
177
|
+
...lines.map((line) => `+${line}`)
|
|
178
|
+
].join("\n")
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---- Ledger (port-ledger) --------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
/** One classified unit of a source file, with the line that proves it. */
|
|
184
|
+
export class LedgerRow extends Schema.Class<LedgerRow>("LedgerRow")({
|
|
185
|
+
file: Schema.String,
|
|
186
|
+
unit: Schema.String,
|
|
187
|
+
class: Schema.String,
|
|
188
|
+
/** `file:line` of the statement the classification rests on. */
|
|
189
|
+
evidence: Schema.String,
|
|
190
|
+
confidence: PortConfidence
|
|
191
|
+
}) {}
|
|
192
|
+
|
|
193
|
+
const tsvEscape = (value: string): string => value.replaceAll("\t", " ").replaceAll("\n", " ")
|
|
194
|
+
|
|
195
|
+
/** The ledger as a TSV with a header: `file unit class evidence confidence`. */
|
|
196
|
+
export const renderLedger = (rows: ReadonlyArray<LedgerRow>): string =>
|
|
197
|
+
[
|
|
198
|
+
"file\tunit\tclass\tevidence\tconfidence",
|
|
199
|
+
...rows.map((row) =>
|
|
200
|
+
[row.file, row.unit, row.class, row.evidence, row.confidence].map(tsvEscape).join("\t")
|
|
201
|
+
)
|
|
202
|
+
].join("\n") + "\n"
|
|
203
|
+
|
|
204
|
+
export const parseLedger = (text: string): ReadonlyArray<LedgerRow> =>
|
|
205
|
+
text
|
|
206
|
+
.split(/\r?\n/u)
|
|
207
|
+
.slice(1)
|
|
208
|
+
.filter((line) => line.trim().length > 0)
|
|
209
|
+
.flatMap((line) => {
|
|
210
|
+
const [file, unit, klass, evidence, confidence] = line.split("\t")
|
|
211
|
+
return file === undefined || unit === undefined || klass === undefined
|
|
212
|
+
? []
|
|
213
|
+
: [
|
|
214
|
+
LedgerRow.make({
|
|
215
|
+
file,
|
|
216
|
+
unit,
|
|
217
|
+
class: klass,
|
|
218
|
+
evidence: evidence ?? "",
|
|
219
|
+
confidence:
|
|
220
|
+
confidence === "high" || confidence === "medium" || confidence === "low"
|
|
221
|
+
? confidence
|
|
222
|
+
: "low"
|
|
223
|
+
})
|
|
224
|
+
]
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
/** The rows of one source file, rendered for its implementer ("trust the table over local guessing"). */
|
|
228
|
+
export const ledgerRowsFor = (rows: ReadonlyArray<LedgerRow>, file: string): string | undefined => {
|
|
229
|
+
const mine = rows.filter((row) => row.file === file)
|
|
230
|
+
return mine.length === 0
|
|
231
|
+
? undefined
|
|
232
|
+
: mine
|
|
233
|
+
.map((row) => `- ${row.unit}: ${row.class} (${row.evidence}; ${row.confidence})`)
|
|
234
|
+
.join("\n")
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The units a pack's `## Ledger` regex names in a source file, each once. */
|
|
238
|
+
export const unitsIn = (text: string, unitRegex: string): ReadonlyArray<string> => {
|
|
239
|
+
const pattern = new RegExp(unitRegex, "gmu")
|
|
240
|
+
const units: Array<string> = []
|
|
241
|
+
for (let match = pattern.exec(text); match !== null; match = pattern.exec(text)) {
|
|
242
|
+
const name = match.slice(1).find((group) => group !== undefined && group.length > 0)
|
|
243
|
+
if (name !== undefined && !units.includes(name)) units.push(name)
|
|
244
|
+
if (match[0].length === 0) pattern.lastIndex += 1
|
|
245
|
+
}
|
|
246
|
+
return units
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ---- Refutes (port-guide, port-ledger) ----------------------------------------------------
|
|
250
|
+
|
|
251
|
+
/** A claim stands unless a majority of its refuters reject it (the Bun port's 3-vote refute). */
|
|
252
|
+
export const standsAfterRefutes = (votes: ReadonlyArray<boolean>): boolean => {
|
|
253
|
+
const rejected = votes.filter((holds) => !holds).length
|
|
254
|
+
return votes.length === 0 ? true : rejected * 2 < votes.length
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ---- Guide audit (port-guide) -------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
export class GuideFinding extends Schema.Class<GuideFinding>("GuideFinding")({
|
|
260
|
+
dimension: Schema.String,
|
|
261
|
+
finding: Schema.String,
|
|
262
|
+
/** A rulebook line, a sample source line, or a draft line that shows it. */
|
|
263
|
+
evidence: Schema.String,
|
|
264
|
+
/** The rule the rulebook should gain or change, one line. */
|
|
265
|
+
proposedRule: Schema.String
|
|
266
|
+
}) {}
|
|
267
|
+
|
|
268
|
+
export const defaultAuditDimensions: ReadonlyArray<string> = [
|
|
269
|
+
"error model",
|
|
270
|
+
"ownership and memory",
|
|
271
|
+
"collections and strings",
|
|
272
|
+
"control flow and compile-time constructs",
|
|
273
|
+
"API shape and naming",
|
|
274
|
+
"concurrency and resources",
|
|
275
|
+
"build, test and tooling idioms",
|
|
276
|
+
"what not to translate"
|
|
277
|
+
]
|
|
278
|
+
|
|
279
|
+
export const renderGuideAudit = (facts: {
|
|
280
|
+
readonly pack: string
|
|
281
|
+
readonly kept: ReadonlyArray<GuideFinding>
|
|
282
|
+
readonly dropped: ReadonlyArray<GuideFinding>
|
|
283
|
+
readonly trial: ReadonlyArray<string>
|
|
284
|
+
readonly sample: ReadonlyArray<string>
|
|
285
|
+
}): string =>
|
|
286
|
+
[
|
|
287
|
+
`# Rulebook audit — pack ${facts.pack}`,
|
|
288
|
+
"",
|
|
289
|
+
`- sample files: ${facts.sample.join(", ") || "none"}`,
|
|
290
|
+
`- findings kept after the refute: ${facts.kept.length}; dropped: ${facts.dropped.length}`,
|
|
291
|
+
"",
|
|
292
|
+
...(facts.kept.length === 0
|
|
293
|
+
? ["No finding survived: the rulebook stands as written.", ""]
|
|
294
|
+
: [
|
|
295
|
+
"## Proposed rules",
|
|
296
|
+
"",
|
|
297
|
+
"Each is an `append-rule` to `prompts/porting.md`, applied on the next run once approved.",
|
|
298
|
+
"",
|
|
299
|
+
...facts.kept.flatMap((finding) => [
|
|
300
|
+
`### ${finding.dimension}`,
|
|
301
|
+
"",
|
|
302
|
+
`**Finding.** ${finding.finding.trim()}`,
|
|
303
|
+
"",
|
|
304
|
+
`**Evidence.** ${finding.evidence.trim()}`,
|
|
305
|
+
"",
|
|
306
|
+
"```diff",
|
|
307
|
+
`+ - ${finding.proposedRule.trim()}`,
|
|
308
|
+
"```",
|
|
309
|
+
""
|
|
310
|
+
])
|
|
311
|
+
]),
|
|
312
|
+
...(facts.trial.length === 0
|
|
313
|
+
? []
|
|
314
|
+
: [
|
|
315
|
+
"## Trial port: where the native port and the rulebook port differ",
|
|
316
|
+
"",
|
|
317
|
+
...facts.trial.map((line) => `- ${line}`),
|
|
318
|
+
""
|
|
319
|
+
]),
|
|
320
|
+
...(facts.dropped.length === 0
|
|
321
|
+
? []
|
|
322
|
+
: [
|
|
323
|
+
"## Dropped by the refute",
|
|
324
|
+
"",
|
|
325
|
+
...facts.dropped.map((finding) => `- ${finding.dimension}: ${finding.finding.trim()}`),
|
|
326
|
+
""
|
|
327
|
+
]),
|
|
328
|
+
DraftApprovalMarker,
|
|
329
|
+
""
|
|
330
|
+
].join("\n")
|
|
331
|
+
|
|
332
|
+
// ---- Differential (port-tests) ------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
export const DiffClass = Schema.Literals(["pass", "diverge", "crash", "hang", "legacy-red"])
|
|
335
|
+
export type DiffClass = typeof DiffClass.Type
|
|
336
|
+
|
|
337
|
+
export class DiffVerdict extends Schema.Class<DiffVerdict>("DiffVerdict")({
|
|
338
|
+
file: Schema.String,
|
|
339
|
+
class: DiffClass,
|
|
340
|
+
legacyPassed: Schema.optionalKey(Schema.Int),
|
|
341
|
+
targetPassed: Schema.optionalKey(Schema.Int),
|
|
342
|
+
detail: Schema.String
|
|
343
|
+
}) {}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* The Bun test swarm's verdict: a test file passes when the target exits 0
|
|
347
|
+
* AND its pass count equals the legacy baseline's (when both are known). A
|
|
348
|
+
* legacy run that is red itself is no evidence about the target.
|
|
349
|
+
*/
|
|
350
|
+
export const diffVerdict = (
|
|
351
|
+
file: string,
|
|
352
|
+
legacy: ReviewResult,
|
|
353
|
+
target: ReviewResult,
|
|
354
|
+
roots: ReadonlyArray<string> = []
|
|
355
|
+
): DiffVerdict => {
|
|
356
|
+
const strip = (text: string): string =>
|
|
357
|
+
roots.reduce((acc, root) => acc.replaceAll(`${root}/`, ""), text)
|
|
358
|
+
const legacyPassed = legacy.passed
|
|
359
|
+
const targetPassed = target.passed
|
|
360
|
+
const counts = {
|
|
361
|
+
...(legacyPassed === undefined ? {} : { legacyPassed }),
|
|
362
|
+
...(targetPassed === undefined ? {} : { targetPassed })
|
|
363
|
+
}
|
|
364
|
+
if (!legacy.isClean) {
|
|
365
|
+
return DiffVerdict.make({
|
|
366
|
+
file,
|
|
367
|
+
class: "legacy-red",
|
|
368
|
+
...counts,
|
|
369
|
+
detail: "the legacy build fails this file too; nothing to compare"
|
|
370
|
+
})
|
|
371
|
+
}
|
|
372
|
+
const gate = target.issues.find((issue) => issue.gateClass !== undefined)
|
|
373
|
+
if (gate?.gateClass === "hang") {
|
|
374
|
+
return DiffVerdict.make({ file, class: "hang", ...counts, detail: strip(gate.description) })
|
|
375
|
+
}
|
|
376
|
+
if (gate?.gateClass === "crash") {
|
|
377
|
+
return DiffVerdict.make({ file, class: "crash", ...counts, detail: strip(gate.description) })
|
|
378
|
+
}
|
|
379
|
+
if (!target.isClean) {
|
|
380
|
+
return DiffVerdict.make({
|
|
381
|
+
file,
|
|
382
|
+
class: "diverge",
|
|
383
|
+
...counts,
|
|
384
|
+
detail: strip(target.issues.map((issue) => issue.description).join("\n"))
|
|
385
|
+
})
|
|
386
|
+
}
|
|
387
|
+
if (legacyPassed !== undefined && targetPassed !== undefined && targetPassed !== legacyPassed) {
|
|
388
|
+
return DiffVerdict.make({
|
|
389
|
+
file,
|
|
390
|
+
class: "diverge",
|
|
391
|
+
...counts,
|
|
392
|
+
detail: `${targetPassed} test(s) pass on the target, ${legacyPassed} on the legacy build`
|
|
393
|
+
})
|
|
394
|
+
}
|
|
395
|
+
return DiffVerdict.make({ file, class: "pass", ...counts, detail: "" })
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** The `.diag` a fixer reads as its only runtime evidence. */
|
|
399
|
+
export const renderDiag = (verdict: DiffVerdict, tailChars = 4_000): string =>
|
|
400
|
+
[
|
|
401
|
+
`# ${verdict.file}: ${verdict.class}`,
|
|
402
|
+
"",
|
|
403
|
+
`- legacy passed: ${verdict.legacyPassed ?? "unknown"}; target passed: ${verdict.targetPassed ?? "unknown"}`,
|
|
404
|
+
"",
|
|
405
|
+
"## Target output (tail)",
|
|
406
|
+
"",
|
|
407
|
+
"```",
|
|
408
|
+
verdict.detail.length > tailChars ? `…${verdict.detail.slice(-tailChars)}` : verdict.detail,
|
|
409
|
+
"```",
|
|
410
|
+
""
|
|
411
|
+
].join("\n")
|
|
412
|
+
|
|
413
|
+
export const renderDifferentialReport = (
|
|
414
|
+
round: number,
|
|
415
|
+
verdicts: ReadonlyArray<DiffVerdict>
|
|
416
|
+
): string => {
|
|
417
|
+
const by = (klass: DiffClass) => verdicts.filter((verdict) => verdict.class === klass)
|
|
418
|
+
return [
|
|
419
|
+
`# Differential tests, round ${round}`,
|
|
420
|
+
"",
|
|
421
|
+
`- files: ${verdicts.length}; pass ${by("pass").length}, diverge ${by("diverge").length}, crash ${by("crash").length}, hang ${by("hang").length}, legacy-red ${by("legacy-red").length}`,
|
|
422
|
+
"",
|
|
423
|
+
...(["diverge", "crash", "hang"] as const).flatMap((klass) =>
|
|
424
|
+
by(klass).length === 0
|
|
425
|
+
? []
|
|
426
|
+
: [`## ${klass}`, "", ...by(klass).map((verdict) => `- ${verdict.file}`), ""]
|
|
427
|
+
)
|
|
428
|
+
].join("\n")
|
|
429
|
+
}
|
package/src/Retro.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* tasks appended to a story's plan, or a story entry edited in the epic
|
|
10
10
|
* plan (which restarts that story). Nothing is applied unreviewed.
|
|
11
11
|
*/
|
|
12
|
-
import { join } from "node:path"
|
|
12
|
+
import { join, resolve, sep } from "node:path"
|
|
13
13
|
import * as Effect from "effect/Effect"
|
|
14
14
|
import * as Option from "effect/Option"
|
|
15
15
|
import * as Schema from "effect/Schema"
|
|
@@ -1278,7 +1278,14 @@ export const applyApprovedRetros = Effect.fn("@llm4ts/flow/Retro.apply")(functio
|
|
|
1278
1278
|
yield* say(`${runId}: rule edit to ${edit.target} is not allowed; skipped`)
|
|
1279
1279
|
continue
|
|
1280
1280
|
}
|
|
1281
|
-
const
|
|
1281
|
+
const root = resolve(options.rulesRoot)
|
|
1282
|
+
const path = resolve(root, edit.target)
|
|
1283
|
+
// The allowlist already rejects absolute paths and `..`; this keeps the
|
|
1284
|
+
// resolved file under the repository root whatever the string looked like.
|
|
1285
|
+
if (path !== root && !path.startsWith(`${root}${sep}`)) {
|
|
1286
|
+
yield* say(`${runId}: rule edit to ${edit.target} resolves outside the repository; skipped`)
|
|
1287
|
+
continue
|
|
1288
|
+
}
|
|
1282
1289
|
const existing = yield* files.read(path).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
1283
1290
|
const next = applyRuleEdit(existing, edit)
|
|
1284
1291
|
if (next === undefined) {
|