@llm4ts/shell 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,618 +0,0 @@
1
- // Legacy modernization phase 4: prove the implementation equivalent to its spec pack.
2
- //
3
- // Runs rooted at the TARGET repository (`--repo <target>`) behind the enforced
4
- // clean-room wall: equivalence is proven against specs and vectors, never
5
- // against the original code.
6
- //
7
- // 1. Per spec'd program, generate equivalence test vectors from the spec and
8
- // its BDD scenarios — resumable, one .jsonl per program (delete a file to
9
- // regenerate just that program).
10
- // 2. Replay every vector through the pack's `replay:` command (a vector JSON
11
- // on stdin, the resulting observations as a JSON array on stdout) and diff
12
- // the observations under the pack's comparison policy.
13
- // 3. Report rule-by-rule coverage against the frozen rules.txt — the rule
14
- // universe extraction wrote; the target side never re-enumerates it.
15
- // 4. Triage the failures into fix specs plus plan tasks appended for
16
- // modernize-implement, extend the provenance manifest with the report
17
- // hash, commit, and fail while any vector is red.
18
- //
19
- // Run: modernize-verify --repo ~/services/meridian-transfers
20
- import { join } from "node:path"
21
- import * as Effect from "effect/Effect"
22
- import * as Schema from "effect/Schema"
23
- import type { JsonSchema } from "@llm4ts/core/Models"
24
- import {
25
- CurrentEquivSchema,
26
- EquivVector,
27
- Observations,
28
- readEquivVectors,
29
- replayEquivVector,
30
- diffObservations,
31
- writeEquivVectors,
32
- type EquivObservation
33
- } from "@llm4ts/flow/Equiv"
34
- import { renderEquivReport, VectorVerdict } from "@llm4ts/flow/EquivReport"
35
- import { FlowAborted, FlowLlmError, PlanParseError } from "@llm4ts/flow/FlowError"
36
- import { Info } from "@llm4ts/flow/FlowEvents"
37
- import type { Pack } from "@llm4ts/flow/Pack"
38
- import { makePlanStore } from "@llm4ts/flow/Persistence"
39
- import { stage } from "@llm4ts/flow/PlanExecution"
40
- import { Plan, Task } from "@llm4ts/flow/Plan"
41
- import { Provenance, makeProvenanceStore } from "@llm4ts/flow/Provenance"
42
- import { matchingFiles } from "@llm4ts/flow/SpecChecks"
43
- import { checkWall, wallBreachMessage } from "@llm4ts/flow/Wall"
44
- import type { WorkspaceShape } from "@llm4ts/flow/Workspace"
45
- import { generateVectorsResumably } from "@llm4ts/modernize/Artifacts"
46
- import { asReadOnly, coderFromEnv } from "@llm4ts/runner/Connectors"
47
- import { resolveFlowInput } from "@llm4ts/runner/FlowArgs"
48
- import { runFlowMain, runNode } from "@llm4ts/runner/FlowRunner"
49
- import { nodePlainFileStore } from "@llm4ts/runner/NodePlainFileStore"
50
- import { nodeProcessExecutor } from "@llm4ts/runner/NodeProcessExecutor"
51
- import { makeNodeWorkspace } from "@llm4ts/runner/NodeWorkspace"
52
- import { openPack } from "@llm4ts/runner/Packs"
53
-
54
- const ModDir = "docs/modernization"
55
-
56
- /**
57
- * The model-facing vector shape: flat maps only, so structured output stays
58
- * schema-simple. `kind` is "record" (an emitted record) or "db" (a mutation).
59
- */
60
- class GeneratedObservation extends Schema.Class<GeneratedObservation>("GeneratedObservation")({
61
- kind: Schema.String,
62
- channel: Schema.String,
63
- op: Schema.String,
64
- key: Schema.Record(Schema.String, Schema.String),
65
- fields: Schema.Record(Schema.String, Schema.String)
66
- }) {}
67
-
68
- class GeneratedVector extends Schema.Class<GeneratedVector>("GeneratedVector")({
69
- id: Schema.String,
70
- rules: Schema.Array(Schema.String),
71
- inputs: Schema.Record(Schema.String, Schema.String),
72
- observations: Schema.Array(GeneratedObservation)
73
- }) {}
74
-
75
- class GeneratedVectors extends Schema.Class<GeneratedVectors>("GeneratedVectors")({
76
- vectors: Schema.Array(GeneratedVector)
77
- }) {}
78
-
79
- const generatedVectorsJsonSchema: JsonSchema = {
80
- type: "object",
81
- properties: {
82
- vectors: {
83
- type: "array",
84
- items: {
85
- type: "object",
86
- properties: {
87
- id: { type: "string" },
88
- rules: { type: "array", items: { type: "string" } },
89
- inputs: { type: "object", additionalProperties: { type: "string" } },
90
- observations: {
91
- type: "array",
92
- items: {
93
- type: "object",
94
- properties: {
95
- kind: { type: "string", enum: ["record", "db"] },
96
- channel: { type: "string" },
97
- op: { type: "string" },
98
- key: { type: "object", additionalProperties: { type: "string" } },
99
- fields: { type: "object", additionalProperties: { type: "string" } }
100
- },
101
- required: ["kind", "channel", "op", "key", "fields"]
102
- }
103
- }
104
- },
105
- required: ["id", "rules", "inputs", "observations"]
106
- }
107
- }
108
- },
109
- required: ["vectors"]
110
- }
111
-
112
- class FixSpec extends Schema.Class<FixSpec>("FixSpec")({
113
- title: Schema.String,
114
- spec: Schema.String,
115
- taskTitle: Schema.String,
116
- taskDescription: Schema.String
117
- }) {}
118
-
119
- class VerifyOutcome extends Schema.Class<VerifyOutcome>("VerifyOutcome")({
120
- fixes: Schema.Array(FixSpec)
121
- }) {}
122
-
123
- const verifyOutcomeJsonSchema: JsonSchema = {
124
- type: "object",
125
- properties: {
126
- fixes: {
127
- type: "array",
128
- items: {
129
- type: "object",
130
- properties: {
131
- title: { type: "string" },
132
- spec: { type: "string" },
133
- taskTitle: { type: "string" },
134
- taskDescription: { type: "string" }
135
- },
136
- required: ["title", "spec", "taskTitle", "taskDescription"]
137
- }
138
- }
139
- },
140
- required: ["fixes"]
141
- }
142
-
143
- const slug = (title: string): string =>
144
- title
145
- .toLowerCase()
146
- .replace(/[^a-z0-9]+/g, "-")
147
- .replace(/^-|-$/g, "")
148
- .slice(0, 60)
149
-
150
- const toObservation = (
151
- vectorId: string,
152
- generated: GeneratedObservation
153
- ): Effect.Effect<EquivObservation, PlanParseError> => {
154
- switch (generated.kind.toLowerCase()) {
155
- case "record":
156
- return Effect.succeed(Observations.record(generated.channel, generated.fields))
157
- case "db":
158
- return Effect.succeed(
159
- Observations.dbMutation(generated.channel, generated.op, generated.key, generated.fields)
160
- )
161
- default:
162
- return Effect.fail(
163
- PlanParseError.make({
164
- message: `vector ${vectorId}: unknown observation kind '${generated.kind}' (expected record|db)`
165
- })
166
- )
167
- }
168
- }
169
-
170
- const toVector = Effect.fn("modernize-verify.toVector")(function* (
171
- program: string,
172
- generated: GeneratedVector
173
- ) {
174
- const observations: Array<EquivObservation> = []
175
- for (const observation of generated.observations) {
176
- observations.push(yield* toObservation(generated.id, observation))
177
- }
178
- return EquivVector.make({
179
- schemaVersion: CurrentEquivSchema,
180
- program,
181
- id: generated.id,
182
- tier: "generated",
183
- rules: generated.rules,
184
- inputs: generated.inputs,
185
- observations
186
- })
187
- })
188
-
189
- const generatePrompt = (
190
- pack: Pack,
191
- program: string,
192
- spec: string,
193
- feature: string,
194
- rules: ReadonlyArray<string>
195
- ): string =>
196
- [
197
- pack.prompt("vectors") ?? "",
198
- "",
199
- `Generate equivalence test vectors for the program ${program} from its behavioural spec and`,
200
- "BDD scenarios below. Vectors are the generated tier: they prove spec conformance, so cover",
201
- "every rule the spec states — normal paths, boundary values (thresholds exactly at/over/under),",
202
- "and error paths (rejects, insufficient funds, validation order).",
203
- "",
204
- "For each vector:",
205
- '- "id": short kebab-case name describing the case.',
206
- '- "rules": the source units it exercises, chosen ONLY from this list (use the exact names).',
207
- rules.join("\n"),
208
- '- "inputs": a flat string map — the fields the replay harness needs to drive one execution',
209
- " (amounts as plain decimal strings, dates ISO, codes verbatim from the spec).",
210
- '- "observations": the EXACT outcomes the spec promises, in order. kind "record" for emitted',
211
- ' records ("channel" names the output, "fields" carries values, "op" and "key" empty); kind',
212
- ' "db" for database mutations ("channel" = table, "op" = insert/update/delete, "key" addresses',
213
- ' the row, "fields" = the values written). Every amount, status, and reason code must come',
214
- " from the spec — never invent values.",
215
- "",
216
- "Aim for 5–8 vectors: the happy path, each boundary, each reject.",
217
- "",
218
- "Spec:",
219
- spec,
220
- "",
221
- "Scenarios:",
222
- feature
223
- ].join("\n")
224
-
225
- const triagePrompt = (
226
- pack: Pack,
227
- failing: ReadonlyArray<VectorVerdict>,
228
- specText: string
229
- ): string => {
230
- const details = failing
231
- .map((verdict) => {
232
- const mismatches = verdict.mismatches
233
- .map((mismatch) => {
234
- switch (mismatch._tag) {
235
- case "FieldDiff":
236
- return ` - ${mismatch.at}: ${mismatch.field} expected ${mismatch.expected}, actual ${mismatch.actual}`
237
- case "Missing":
238
- return ` - missing: ${JSON.stringify(mismatch.expected)}`
239
- case "Unexpected":
240
- return ` - unexpected: ${JSON.stringify(mismatch.actual)}`
241
- }
242
- })
243
- .join("\n")
244
- return `- ${verdict.vector.program} ${verdict.vector.id} (rules: ${verdict.vector.rules.join(", ")})\n${mismatches}`
245
- })
246
- .join("\n")
247
- return [
248
- pack.prompt("review") ?? "",
249
- "",
250
- "The equivalence harness replayed test vectors against the implementation and found the",
251
- "mismatches below. For each DISTINCT root cause produce one fix: a short spec document",
252
- "(Markdown: the rule violated, expected vs actual behaviour, the failing vector ids) and a",
253
- "plan task (title + description naming the spec rules). Group mismatches sharing a cause.",
254
- "If a mismatch reveals a wrong or ambiguous SPEC rather than wrong code, say so explicitly",
255
- "in that fix document — spec gaps go back to extraction, not to the coder.",
256
- "",
257
- "Mismatches:",
258
- details,
259
- "",
260
- "Specs under test:",
261
- specText
262
- ].join("\n")
263
- }
264
-
265
- /** The spec'd programs: top-level `<NAME>.md` files under the specs dir, indexes aside. */
266
- const specPrograms = Effect.fn("modernize-verify.specPrograms")(function* (
267
- target: WorkspaceShape,
268
- specsDir: string
269
- ) {
270
- const paths = yield* matchingFiles(target, `^${specsDir}/[^/]+\\.md$`).pipe(
271
- Effect.orElseSucceed(() => [])
272
- )
273
- const excluded = new Set(["traceability", "mapping", "README"])
274
- return paths
275
- .map((path) => (path.split("/").at(-1) ?? path).replace(/\.md$/, ""))
276
- .filter((name) => !excluded.has(name))
277
- .sort()
278
- })
279
-
280
- const program = Effect.gen(function* () {
281
- const input = yield* resolveFlowInput("Prove the implementation equivalent to its spec pack")
282
- const coder = coderFromEnv(process.env)
283
- const files = nodePlainFileStore
284
- const planPath = join(input.workDir, ModDir, "plan.md")
285
- const vectorsDir = join(input.workDir, ModDir, "vectors")
286
-
287
- yield* runNode(
288
- {
289
- workDir: input.workDir,
290
- workspace: input.workspace,
291
- userPrompt: input.prompt,
292
- coder,
293
- reasoning: asReadOnly(coder),
294
- environment: process.env
295
- },
296
- (context) =>
297
- Effect.gen(function* () {
298
- const target = yield* makeNodeWorkspace(input.workDir)
299
- const { pack } = yield* stage(
300
- context.events,
301
- "pack",
302
- openPack({
303
- environment: process.env,
304
- launchDir: input.workspace,
305
- flowDir: import.meta.dirname
306
- })
307
- )
308
-
309
- yield* stage(
310
- context.events,
311
- "wall",
312
- Effect.gen(function* () {
313
- if (pack.sources === undefined) {
314
- return yield* context.events.publish(
315
- Info.make({ message: "pack has no sources regex — wall check skipped" })
316
- )
317
- }
318
- const result = yield* checkWall(target, pack.sources)
319
- if (result._tag === "Breached") {
320
- return yield* FlowAborted.make({
321
- message: wallBreachMessage(
322
- result,
323
- "Equivalence is proven against specs and vectors, never against the original code."
324
- )
325
- })
326
- }
327
- yield* context.events.publish(
328
- Info.make({ message: "clean-room wall: no legacy source in the target workspace" })
329
- )
330
- })
331
- )
332
-
333
- const rulesText = (yield* files.read(join(input.workDir, pack.specsDir, "rules.txt"))) ?? ""
334
- const universe = rulesText
335
- .split(/\r?\n/)
336
- .map((line) => line.trim())
337
- .filter((line) => line.length > 0)
338
- if (universe.length === 0) {
339
- yield* context.events.publish(
340
- Info.make({
341
- message:
342
- "no rules.txt in the seeded spec pack — the report will use the vectors' own " +
343
- "rules and cannot flag unexercised ones"
344
- })
345
- )
346
- }
347
-
348
- const programs = yield* stage(
349
- context.events,
350
- "programs",
351
- specPrograms(target, pack.specsDir)
352
- )
353
- if (programs.length === 0) {
354
- return yield* FlowAborted.make({
355
- message: `no specs under ${pack.specsDir} — run modernize-seed first`
356
- })
357
- }
358
-
359
- // Generated-first and resumable per program: an existing .jsonl is kept.
360
- yield* stage(
361
- context.events,
362
- "vectors",
363
- Effect.gen(function* () {
364
- const summary = yield* generateVectorsResumably(
365
- files,
366
- programs,
367
- (name) =>
368
- Effect.gen(function* () {
369
- const spec =
370
- (yield* files.read(join(input.workDir, pack.specsDir, `${name}.md`))) ?? ""
371
- const featurePaths = yield* matchingFiles(
372
- target,
373
- `^${pack.featuresDir}/.*\\.feature$`
374
- ).pipe(Effect.orElseSucceed(() => []))
375
- const featurePath = featurePaths.find(
376
- (path) =>
377
- (path.split("/").at(-1) ?? "").replace(/\.feature$/, "").toLowerCase() ===
378
- name.toLowerCase()
379
- )
380
- const feature =
381
- featurePath === undefined
382
- ? ""
383
- : yield* target.read(featurePath).pipe(Effect.orElseSucceed(() => ""))
384
- const generated = yield* context.reasoning
385
- .executeStructured(
386
- generatePrompt(pack, name, spec, feature, universe),
387
- GeneratedVectors,
388
- generatedVectorsJsonSchema
389
- )
390
- .pipe(Effect.mapError(FlowLlmError.from))
391
- if (generated.vectors.length === 0) {
392
- return yield* FlowAborted.make({
393
- message: `generator produced no vectors for ${name}`
394
- })
395
- }
396
- const vectors: Array<EquivVector> = []
397
- for (const candidate of generated.vectors) {
398
- vectors.push(yield* toVector(name, candidate))
399
- }
400
- // generateVectorsResumably persists the returned text, so the
401
- // encoder writes into a scratch path and hands back its body.
402
- const scratch = join(vectorsDir, `.${name}.tmp`)
403
- yield* writeEquivVectors(files, scratch, vectors)
404
- const encoded = (yield* files.read(scratch)) ?? ""
405
- yield* files.remove(scratch)
406
- yield* context.events.publish(
407
- Info.make({ message: `${vectors.length} vector(s) generated for ${name}` })
408
- )
409
- return encoded
410
- }),
411
- vectorsDir
412
- )
413
- if (summary.skipped.length > 0) {
414
- yield* context.events.publish(
415
- Info.make({
416
- message: `vectors exist for ${summary.skipped.join(", ")} — skipping`
417
- })
418
- )
419
- }
420
- })
421
- )
422
-
423
- if (pack.replay === undefined) {
424
- return yield* FlowAborted.make({
425
- message:
426
- `pack '${pack.name}' has no replay: command — add one (reads a vector JSON on ` +
427
- "stdin, prints the resulting observations as a JSON array on stdout)"
428
- })
429
- }
430
- const replayCommand = pack.replay
431
-
432
- const verdicts = yield* stage(
433
- context.events,
434
- "replay",
435
- Effect.gen(function* () {
436
- const vectorFiles = yield* matchingFiles(target, `^${ModDir}/vectors/.*\\.jsonl$`).pipe(
437
- Effect.orElseSucceed(() => [])
438
- )
439
- const vectors: Array<EquivVector> = []
440
- for (const path of [...vectorFiles].sort()) {
441
- vectors.push(...(yield* readEquivVectors(files, join(input.workDir, path))))
442
- }
443
- if (vectors.length === 0) {
444
- return yield* FlowAborted.make({
445
- message: `no vectors under ${vectorsDir} — nothing to replay`
446
- })
447
- }
448
- const results: Array<VectorVerdict> = []
449
- for (const vector of vectors) {
450
- const replayed = yield* replayEquivVector(
451
- nodeProcessExecutor,
452
- context.events,
453
- replayCommand,
454
- input.workDir,
455
- vector
456
- )
457
- if (replayed._tag === "Crashed") {
458
- yield* context.events.publish(
459
- Info.make({
460
- message:
461
- `replay failed for ${vector.program}/${vector.id} ` +
462
- `(exit ${replayed.exitCode}): ${replayed.problem.slice(0, 300)}`
463
- })
464
- )
465
- results.push(
466
- VectorVerdict.make({
467
- vector,
468
- mismatches: vector.observations.map((expected) => ({
469
- _tag: "Missing" as const,
470
- expected
471
- }))
472
- })
473
- )
474
- } else {
475
- results.push(
476
- VectorVerdict.make({
477
- vector,
478
- mismatches: diffObservations(
479
- vector.observations,
480
- replayed.actual,
481
- pack.equivalence
482
- )
483
- })
484
- )
485
- }
486
- }
487
- return results
488
- })
489
- )
490
-
491
- const allRules =
492
- universe.length > 0
493
- ? universe
494
- : [...new Set(verdicts.flatMap((verdict) => verdict.vector.rules))].sort()
495
- const failing = verdicts.filter((verdict) => !verdict.passed)
496
-
497
- yield* stage(
498
- context.events,
499
- "report",
500
- files.writeAtomic(
501
- join(input.workDir, ModDir, "equivalence.md"),
502
- renderEquivReport(verdicts, allRules)
503
- )
504
- )
505
-
506
- if (failing.length > 0) {
507
- yield* stage(
508
- context.events,
509
- "triage",
510
- Effect.gen(function* () {
511
- const specTexts: Array<string> = [
512
- (yield* files.read(join(input.workDir, pack.specsDir, "traceability.md"))) ?? ""
513
- ]
514
- for (const name of programs) {
515
- specTexts.push(
516
- (yield* files.read(join(input.workDir, pack.specsDir, `${name}.md`))) ?? ""
517
- )
518
- }
519
- const outcome = yield* context.reasoning
520
- .executeStructured(
521
- triagePrompt(pack, failing, specTexts.join("\n\n")),
522
- VerifyOutcome,
523
- verifyOutcomeJsonSchema
524
- )
525
- .pipe(Effect.mapError(FlowLlmError.from))
526
- for (const fix of outcome.fixes) {
527
- yield* files.writeAtomic(
528
- join(input.workDir, pack.specsDir, "fixes", `fix-${slug(fix.title)}.md`),
529
- `# ${fix.title}\n\n${fix.spec}\n`
530
- )
531
- }
532
- if (outcome.fixes.length > 0) {
533
- const store = makePlanStore(files)
534
- const plan = yield* store.load(planPath)
535
- if (plan === undefined) {
536
- return yield* FlowAborted.make({
537
- message: `no plan at ${planPath} — run modernize-seed first`
538
- })
539
- }
540
- yield* store.save(
541
- planPath,
542
- Plan.make({
543
- ...plan,
544
- tasks: [
545
- ...plan.tasks,
546
- ...outcome.fixes.map((fix) =>
547
- Task.make({
548
- title: fix.taskTitle,
549
- description: fix.taskDescription,
550
- completed: false
551
- })
552
- )
553
- ]
554
- })
555
- )
556
- yield* context.events.publish(
557
- Info.make({
558
- message: `${outcome.fixes.length} fix task(s) appended — rerun modernize-implement`
559
- })
560
- )
561
- }
562
- })
563
- )
564
- }
565
-
566
- yield* stage(
567
- context.events,
568
- "provenance",
569
- Effect.gen(function* () {
570
- const manifest = join(input.workDir, ModDir, "provenance.json")
571
- if ((yield* files.read(manifest)) === undefined) {
572
- return yield* context.events.publish(
573
- Info.make({ message: "no provenance.json — seeded by an older run; skipping" })
574
- )
575
- }
576
- const provenance = makeProvenanceStore(files)
577
- const hashes = yield* provenance.hashFiles(input.workDir, [`${ModDir}/equivalence.md`])
578
- const report = Object.values(hashes)[0]
579
- // Spreading into a plain object would not satisfy the schema's
580
- // encoder — the manifest must stay a Provenance instance.
581
- yield* provenance.extend(manifest, (current) =>
582
- report === undefined
583
- ? current
584
- : Provenance.make({ ...current, equivalenceReport: report })
585
- )
586
- })
587
- )
588
-
589
- const generated = verdicts.filter((verdict) => verdict.vector.tier === "generated").length
590
- const captured = verdicts.filter((verdict) => verdict.vector.tier === "captured").length
591
- const summary =
592
- `${verdicts.filter((verdict) => verdict.passed).length}/${verdicts.length} vectors green ` +
593
- `(${generated} generated, ${captured} captured)`
594
-
595
- yield* stage(
596
- context.events,
597
- "commit",
598
- context.git
599
- .commitAll(
600
- `modernize(${pack.name}): verify — ${summary}` +
601
- (failing.length > 0 ? `; ${failing.length} failing triaged` : "")
602
- )
603
- .pipe(Effect.asVoid)
604
- )
605
-
606
- if (failing.length > 0) {
607
- return yield* FlowAborted.make({
608
- message:
609
- `equivalence not proven: ${failing.length} failing vector(s) — see ` +
610
- `${ModDir}/equivalence.md; fix specs filed, rerun modernize-implement`
611
- })
612
- }
613
- yield* context.events.publish(Info.make({ message: summary }))
614
- })
615
- )
616
- })
617
-
618
- runFlowMain(program)