Give converted programs a coherent home, and run five of them as a service - #154
Open
Gustav Kaleta (gkaleta) wants to merge 22 commits into
Open
Gustav Kaleta (gkaleta) wants to merge 22 commits into
Gustav Kaleta (gkaleta) wants to merge 22 commits into
Conversation
The converter prompts asked for "package com.example.something" and "namespace CobolMigration.Something", leaving the model to invent a root per program. Across 162 generated files that produced 33 packages under five unrelated roots — com.example, com.bank, com.legacy, com.bbva and com.modernized. A service cannot be assembled from that, and an earlier fix only moved each file to wherever its own declaration pointed. The namespace is now derived rather than requested. A program's service is the folder it occupies in the source drop, which is how these estates are already organised, so the same program lands in the same place on every run. A generic container directory yields to its parent, because naming a service "src" says nothing about it. The root is configurable through TARGET_ROOT_NAMESPACE for estates with a house convention. The shared-copybook rule is also inverted. It previously told the model to nest a shared layout inside each program that used it, explicitly to dodge name collisions; the result was four independent copies of the same record, free to drift apart. Types built from a copybook used by more than one program now live in one shared namespace that every service references. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c7a5d0b7-46c7-4cfb-93b9-f06865dab06e
The estate under source/bd is five COBOL function modules: the banking-date lookup, its error-text companion, a batch throughput display, the shared database error handler, and an interest-rate reconciliation batch. Reviewing a conversion of them means reading generated code and asking whether the behaviour survived. This makes the question answerable by calling it instead. Each capability names the program it came from, in the catalogue and in the interface, because "is this still the same behaviour" cannot be checked against an anonymous endpoint. The browser UI is built from that catalogue, so a capability that is not served cannot appear in it. Two differences from the original are deliberate and marked in the code. The reconciliation batch required both inputs sorted and one-to-one, which is a property of the job that produced the files rather than of the data; matching on the key means an unsorted input reconciles correctly instead of silently producing wrong output. A zero-length batch run reports zero throughput rather than dividing by zero. No customer code is committed. source/ and output/ remain ignored; this project holds the service shape those modules describe, not the customer's record layouts or business constants, and it does not stand in for the converted output the pipeline produces. Tests: 35 domain cases covering calendar arithmetic across holiday runs, retry classification, and reconciliation of unsorted input; 20 HTTP cases asserting that every advertised endpoint answers, that a rejection carries its reason in the body, and that the sample the interface ships with produces every outcome. The HTTP tests caught enums serialising as numbers, which would have shown the user "1" instead of "Weekend". Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c7a5d0b7-46c7-4cfb-93b9-f06865dab06e
The toolchain project compiles **/*.cs and explicitly excludes the portal and the two test projects, each of which has its own project file. demos/ is the same kind of thing and was not excluded, so adding it made the toolchain compile the demo's sources and its generated assembly attributes, which collide with the toolchain's own. The failure only appears once demos/ has been built at least once, which is why it survived the first build of the branch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c7a5d0b7-46c7-4cfb-93b9-f06865dab06e
Selecting a program kept every copybook in the source drop. The reasoning was sound — a program without its record layouts is converted against layouts the model invents, and a COPY may be nested inside another copybook — but the remedy was blunt. On a source drop holding two unrelated applications, converting five programs of one meant analysing all 101 copybooks, including 36 belonging to the other. The COPY graph is followed instead, from the chosen programs through whatever their copybooks themselves copy. That keeps the case the blanket rule existed for while leaving behind copybooks nothing in the selection reaches: 65 rather than 101 for the five-program service this was measured on. Two properties worth naming. A copybook name shared by two files keeps both, because dropping one picks a record layout on the caller's behalf. An unresolved COPY target is simply absent — that is a missing copybook, which the scan already reports, and it must not stop the rest of the closure being collected. The COPY pattern used elsewhere in this repository folds the comment check into the regex, where it can backtrack around the column-7 indicator and follow a COPY that was deliberately commented out. A test caught that; the indicator is tested by position. Tests: seven new cases — the copybooks a program reaches, a copybook nothing reaches being left behind, a copybook reached only through another, a cycle, an unresolved target alongside a resolved one, a shared name keeping both, and a commented-out COPY not being followed. Toolchain suite 318 passed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c7a5d0b7-46c7-4cfb-93b9-f06865dab06e
Giving a service's programs one namespace is what makes them a service, but it removes the accident that was hiding a real problem. With a package per program, two programs could each invent a type of the same name and nothing objected — that is how one estate ended up with four independent versions of the same copybook record. Sharing a namespace turns the same duplication into a build error. Measured on the five-program service converted with the new policy: 30 type names are declared by more than one file. The duplicates are rarely identical. Seven files each declare IBdsmfjlService for the same callee, and the three examined declare three different methods on it — ExecuteAsync, ReportAsync and ErrorCodemeldAsync. That is not redundancy to delete, it is three callers describing one callee three ways, and it was equally true before this policy; only now does anything say so. The detector names the namespace, the type and the files that declare it. Depth is tracked by braces rather than indentation because a file-scoped namespace puts its top-level types exactly where a block-scoped one puts nested ones — the first version of this used indentation and reported every nested type as a collision. This reports; it does not rewrite. Choosing which of three contracts is correct is not a decision to take from a generated-code post-pass. Tests: 17 cases — clean estates, a two-file collision, the same name in different namespaces, all five declaration kinds, nested types under both namespace styles, modifiers, recursion, ordering by severity, a missing directory, and the report text. Toolchain suite 335 passed. Verified against the real generated output. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c7a5d0b7-46c7-4cfb-93b9-f06865dab06e
Nine findings, three of which were worth acting on and six of which were noise from a rule that cannot see intent. The COPY-target scan read as a loop with two guard clauses; as a query the comment test is named and the shape says what it does. The catalogue test extracted its endpoints inside the loop that requested them; the extraction is now a projection, leaving the loop doing only the awaiting it cannot avoid. The empty catch in a test's Dispose swallowed the reason a temp directory could not be removed. A directory the operating system still holds open is not a test failure, but silence means a leak is never noticed either, so the reason is written to stderr. Path.Combine in the tests is replaced by Path.Join: every segment there is a relative name the test itself builds, so Combine's argument-dropping could only ever hide a mistake. The call in RektPromptInjector keeps Combine and says why — COBOL_SOURCE_FOLDER may be absolute, and an absolute source folder is meant to win outright rather than be appended to the repository root. That is the same reason the existing calls in RektContextLoader and StubCopybookCatalog keep it. Toolchain suite 335 passed, demo portal 55 passed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c7a5d0b7-46c7-4cfb-93b9-f06865dab06e
…to invent one The prompt told every program to "generate a service interface (e.g. IDateService)" for each CALL it made, and specified neither the name nor the shape. Seven files declared IBdsmfjlService for the same module, and the three examined declared three different methods on it: ExecuteAsync, ReportAsync and ErrorCodemeldAsync. Each caller had formed its own idea of what calling that program means. While every program had its own package this only duplicated. Once a service shares one namespace it stops compiling, which is how it came to light. A COBOL program has exactly one entry point, so its interface has exactly one method and both are derivable. The registry names them, and names a single program responsible for declaring each: the callee itself when it is in the source drop, since it knows its own contract, and otherwise the first of its callers in a stable order. Arbitrary, but the same arbitrary answer on every run and for every caller, which is what stops the duplicate. Everyone else is told to reference and not declare. Worth noting where this does and does not bite: across the five bd programs there are six call targets with one caller each, so the collisions in that estate come from the copybook conversions rather than from the programs. Those are still reported by the collision detector; this removes the cause for the program-to-program case. Tests: 13 cases — the callee declaring its own interface, an external target assigned to one caller, stability across runs, exactly one declarer per interface, a caller told to reference but not declare, recursion, a commented-out CALL, and name derivation. Toolchain suite 348 passed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c7a5d0b7-46c7-4cfb-93b9-f06865dab06e
… assumed doctor.sh has always appended --programs to the reverse-engineer command, and that command never declared the option, so "./doctor.sh reverse-eng --program X" did not analyse X — it failed on an unrecognised argument before doing any work. The selector was added to conversion and not to the analysis that precedes it. Reverse engineering is subject to the same reason conversion is. A full estate run costs hours, and a change to a prompt or a model is judged on one program long before it is trusted on sixty. The option is declared where the root command declares its own and parsed by the same SplitPrograms helper, so the two commands accept identical syntax and the selection reaches the same choke point in FileHelper. Toolchain suite 348 passed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c7a5d0b7-46c7-4cfb-93b9-f06865dab06e
Each run wrote to output/java or output/csharp, so a second run silently overwrote the first. Two runs could not be compared, a demo could not be kept, and a partial re-run left the previous run's files beside the new ones with nothing to say which belonged to which. Runs are now written to a folder named for when they happened — output/csharp/20260917-140000 — which sorts chronologically as text and reads as a date. That only works if a reader can still find the current output without being told the stamp, because the portal starts independently of any conversion. The language folder stays the stable root and the newest run beneath it is resolved on read, so nothing needs to be passed between them. Two details matter there: a directory is only treated as a run if its name has the shape of a stamp, so a generated package folder such as Modernized is never mistaken for one; and a run that failed before writing its report is skipped rather than shadowing the last good one. Output produced before this change sits flat in the language folder and is still found. Tests: 14 cases on the resolver — chronological sorting, newest wins, flat output, non-stamp directories, a run missing its report, languages not seeing each other's runs, and stamp recognition by shape. Three more on the parity reader, which is the thing that would otherwise have quietly reported no data the moment runs became dated. Toolchain 362 passed, portal 78 passed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c7a5d0b7-46c7-4cfb-93b9-f06865dab06e
The root namespace is assigned in a block this repository injects, but the line telling the model to use it lives in the User section of the converter prompt — the one section Prompt Studio rewrites. An AI rewrite can drop that line and nothing fails: the block still arrives, nothing insists on it, and the model returns to inventing com.example or CobolMigration. The output still compiles and still looks ordinary. It is simply no longer one service, and the only way to find out is to read it. That is the same shape as the placeholder loss PromptPlaceholdersTests exists for. Prompt wording cannot be relied on to survive editing, so the output is checked instead — a check is indifferent to how the prompt was worded. Containment is tested on a segment boundary, so a neighbouring root such as "Modernizedx" cannot pass for "Modernized". A file declaring no namespace at all is reported rather than skipped, since that is the same failure with less evidence. TARGET_ROOT_NAMESPACE is also documented in ai-config.env.example, beside the other conversion settings, with what it does and what happens beneath it. load-config.sh exports any variable it finds, so no loader change is needed; the variable was previously readable only from the source. Tests: 17 cases — code in the assigned root, an invented root, no namespace at all, Java packages, segment-boundary containment across six inputs, casing, recursion, non-source files, a missing directory, and the report text. Verified against the 117 generated files of the last conversion: none outside the assigned root. Toolchain suite 379 passed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c7a5d0b7-46c7-4cfb-93b9-f06865dab06e
The portal does not have to run from inside the repository it describes. It is routinely started from a separate checkout with REPO_ROOT pointing at the one holding source/ and output/, and the modernization readers honour that. Five endpoints did not. They resolved their files relative to the portal's own content root, so they looked for the estate's output inside the portal's directory. A reverse-engineering report generated minutes earlier was reported as not existing, and the message helpfully suggested running reverse engineering — which had just been run. All five now resolve through one definition, which also replaces the private copy inside ConversionParityReader. Two parts of the same page cannot disagree about where the repository is if they ask the same function. Tests: five cases — the configured root, a path resolving into it rather than into the caller's directory, a configured root that does not exist, walking up to find doctor.sh, and having nothing to find. Portal suite 83 passed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c7a5d0b7-46c7-4cfb-93b9-f06865dab06e
TARGET_ROOT_NAMESPACE was documented in ai-config.env.example and exported by the generic loader, but load-config.sh never mentioned it. Someone looking for how the generated layout is configured looks at the loader and its summary, finds Source Folder and Output Folder, and concludes the setting does not exist. It is now printed beside them, with its per-language default shown when unset — which is the case worth naming, because an unset value is easy to miss when the question being asked is why the output is scattered across five roots. Verified end to end that the generic exporter carries it: a local config containing TARGET_ROOT_NAMESPACE="acme.core" reaches the environment with that value. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c7a5d0b7-46c7-4cfb-93b9-f06865dab06e
The root was configurable; the structure beneath it was not. ForProgram always returned
root.service and ForSharedTypes always root.shared, both through a Join that could only
ever produce two levels. An estate whose house style is DDD had no way in — there was no
third level to put a layer in, and "shared" could not become "domain".
The layout is now a template the configuration selects. TARGET_ARCHITECTURE takes
"service" (the existing layout, and the default), "ddd" or "layered" for estates that
separate domain records from the programs acting on them, "flat" for an estate too small
to divide, or "custom" with TARGET_NAMESPACE_TEMPLATE for a house convention —
placeholders {root}, {service} and {shared}, anything else a literal segment.
An unrecognised architecture falls back to the default rather than failing a conversion
that is otherwise fine.
ai-config.env.example now carries worked examples rather than a description: the
namespace each architecture produces for source/bd/BDSDA23.cbl, in both languages, plus
three custom templates. The setting was previously documented in prose that never showed
what it did.
One bug found while writing it: substituting placeholders and then casing the result
re-cased already-cased values, turning MyService into Myservice. Each segment is now
cased exactly once.
Tests: 11 new cases — the default unchanged, ddd and layered in both languages, flat,
a custom template, custom with no template, an unrecognised name, a configured root
surviving every layout, and shared types staying separate under ddd. The 16 existing
cases pass untouched, so an unset TARGET_ARCHITECTURE produces byte-identical output.
Toolchain suite 390 passed.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: c7a5d0b7-46c7-4cfb-93b9-f06865dab06e
Discovery happens three times in this pipeline and only one of them reads the parser's output. The converters get REKT context; the dependency mapper matches COPY, EXEC SQL and READ/WRITE with hand-written regular expressions, and the analyzer spends an LLM call per file describing structure REKT has already recorded. A regex cannot see what a parser sees. It misses a COPY nested inside a copybook, and it records the word EXEC where the parse records the real SQL verb — which is the mistake program-facts schema 1 made and schema 2 exists to correct. Meanwhile the same estate has 17,884 AST nodes, 24,666 control-flow edges and 14,314 data structures already extracted. Worse than either is having three mechanisms that never compare answers. That is the failure this estate keeps producing: two sources disagree, nothing notices, and the reassuring one is believed. The parse now answers where it can and the text scan is the fallback, not the default. The fallback is passed as a function rather than called first, so it is not paid for on programs the parse already covers. It is deliberately not authoritative everywhere. Where a copybook was missing the parser was handed a synthesised stub, so its structural claims are inference with better presentation; below full confidence this yields and the existing extraction runs unchanged. On one estate that is 35 programs of 41, so the fallback matters. Which source answered is counted and reported, because an unattributed third mechanism would be indistinguishable from the two it was meant to reconcile. Tests: 14 cases — a full parse answering without the scan being consulted at all, each degraded confidence yielding, no parse output, an empty result from a full parse being an answer rather than a gap, callees and tables, de-duplication, caching, a path where a basename is expected, no parse output at all, and the run summary. Toolchain suite 404 passed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c7a5d0b7-46c7-4cfb-93b9-f06865dab06e
Ten findings, seven acted on and three answered. Path.Combine becomes Path.Join wherever every segment is a relative name this code builds — the resolver's language root and run folders, and the walk to the repository root in the dependency mapper. Combine's argument-dropping can only hide a mistake there. Two loops that projected one sequence into another are expressed as queries: the call-target contracts, and the namespace template render. The generic catch in the prompt injector stays, and now says why. It is the outermost of three, and everything the method does is additive context for a prompt: a conversion without that context is weaker, a conversion that throws while assembling it produces nothing at all. Narrowing the type would trade a degraded run for a failed one. The Path.Combine in CallTargetRegistryHolder also stays. Its second argument is the source folder, which may be absolute, and an absolute source folder is meant to win outright rather than be appended to the repository root — the same reason the calls in RektContextLoader, StubCopybookCatalog and RektPromptInjector keep it. Toolchain suite 404 passed, portal 113 passed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c7a5d0b7-46c7-4cfb-93b9-f06865dab06e
The scan accumulated callers by nesting a loop over regex matches inside a loop over programs and mutating a dictionary of sets from the inside. What it produces is simply every (callee, caller) pair the source states, grouped by callee, which the query now says directly. The self-call guard keeps its reason: recursion needs no interface, and handing a program one for itself would have it declare and inject a service that is the class being written. Toolchain suite 404 passed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c7a5d0b7-46c7-4cfb-93b9-f06865dab06e
Eight more Path.Combine findings, all in code added by this branch. Combine exists to let an absolute later argument replace everything before it. Where every segment is a relative name the code itself supplies, that behaviour can only ever hide a mistake, so Join is both safer and more honest about the intent. Two of these mattered beyond tidiness. RepositoryRoot.PathTo takes caller-supplied segments and its result is opened and read, so an absolute segment silently discarding the repository root is precisely the containment failure RepositoryPath was written to prevent. The walk looking for doctor.sh is the same shape. The remaining Combine in RektPromptInjector stays. Its second argument is COBOL_SOURCE_FOLDER, which may legitimately be absolute, and an absolute source folder is meant to win outright. Toolchain suite 404 passed, portal 113 passed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c7a5d0b7-46c7-4cfb-93b9-f06865dab06e
Two calls kept Path.Combine because COBOL_SOURCE_FOLDER may be absolute, and an absolute source folder is meant to replace the repository root rather than be appended to it. That was true, but it left the rule in a comment and the behaviour in an overload's fine print, which is why the same finding kept being raised against it. The test is now written out. Identical behaviour, and a reader no longer has to know how Path.Combine treats a rooted second argument to see what was intended. It matters more in the registry holder, where the value is a cache key: a path that exists nowhere produces a silent miss rather than an error, which is the worst place to leave an implicit rule. Toolchain suite 404 passed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c7a5d0b7-46c7-4cfb-93b9-f06865dab06e
Three empty catch blocks, and in two of them the swallowed failure had consequences beyond the line it occurred on. An unreadable facts file and an absent one produce the same result — the program falls back to a text scan — but not the same cause. Absent means the program was never parsed; unreadable means it was and the answer was lost. The count is now reported with the rest of the discovery summary, so a run that quietly stopped using the parse says so. An unreadable source file in the call-target scan contributes no CALL edges, so a callee that only it calls can be assigned a different declaring program than it would otherwise have been. That is not a local effect and should not be silent. The third is test cleanup: a directory the operating system still holds open is not a test failure, but silence means a leak is never noticed either, so the reason goes to stderr — matching the other fixtures on this branch. No empty catch blocks remain in anything this branch touches. Toolchain suite 404 passed, portal 113 passed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c7a5d0b7-46c7-4cfb-93b9-f06865dab06e
The shared-copybook rule told every file "do NOT define these types, reference them from the shared namespace" and told no file to define them. Each file still needs the type in order to compile, so each defined it. Measured on the converted bd estate, Bdsdatoi was declared by nine files: its own conversion and eight that copy it. The model was not ignoring the instruction. The instruction was unsatisfiable. A copybook in the source drop is converted in its own right — BDSDATOI.cpy becomes Bdsdatoi.cs — so an owner already exists and only has to be named. Every file is now told which types it owns and which it must reference, and exactly one file is told it owns each. On the bd estate that assigns owners for all 65 copybooks with none left ambiguous. This is the correction the call-target contracts already needed: an instruction to reference is only coherent alongside an instruction to declare. Both halves now come from the same place rather than one being implied. A copybook absent from the drop gets no owner. Naming an arbitrary user as owner would put a layout nobody can see into a type everybody depends on; the missing-copybook reporting already accounts for those. Copybook-to-copybook use is covered as well as program-to-copybook, because a copybook that copies another produces a file that would otherwise declare the type again — which is where most of the measured duplication came from. Tests: 12 cases — ownership and type-name derivation, every user told to reference while only the owner declares, copybook-to-copybook use, a missing copybook, an uninvolved file, a commented-out COPY, self-reference, the shared namespace, and exactly one owner per type. Toolchain suite 416 passed, portal 113 passed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c7a5d0b7-46c7-4cfb-93b9-f06865dab06e
The call-target registry read programs only. A copybook containing a CALL becomes a converted file in its own right, and that file invented its own interface for the callee because nothing told it one already existed. Six files declared IBdsmfjlService in the measured output, and the earlier contract work moved that number not at all — the files declaring it were copybook conversions, which never received the contract block. Copybooks are now read as callers. They are not read as call targets: a CALL names a program, so the declaring file is still the callee's own conversion where it exists. Tests: two cases — a copybook counted among a callee's callers, and a copybook caller told to reference rather than declare. Toolchain suite 418 passed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c7a5d0b7-46c7-4cfb-93b9-f06865dab06e
Comment on lines
+63
to
+69
| foreach (var user in new[] { "BDSDA11", "BDSDA12", "BDSDA23" }) | ||
| { | ||
| var block = registry.ToPromptBlock(user, "C#"); | ||
| block.Should().Contain("do NOT declare them"); | ||
| block.Should().Contain("declared by the conversion of BDSDATOI"); | ||
| block.Should().NotContain("You are the only file that declares it"); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Converted programs had nowhere coherent to live, nothing demonstrated what a converted service looks like running, and several things found along the way were broken.
1 — Assign each program a namespace instead of asking for one
The prompts asked for
package com.example.something/namespace CobolMigration.Something. Across 162 generated files that produced 33 packages under 5 unrelated roots, with shared copybook records re-emitted under each (Qpipccabx4,Kygfc002x4).A second cause was policy: the shared-copybook rule told the model to nest a shared layout inside every caller, so one copybook became four records free to drift apart.
Now: the namespace is derived. Service = the source folder; a generic container (
SRC) yields to its parent; shared copybook types live in one shared namespace.Measured on the
bdconversion: 33 packages under 5 roots → 6 under 1, 27 shared types in one place, 117 files, zero outside the assigned root.2 — Let an estate choose the shape
The root was configurable; the structure beneath it was not —
ForProgramcould only ever returnroot.service. DDD had no way in.TARGET_ARCHITECTUREnow takesservice(default, unchanged),ddd/layered,flat, orcustomwithTARGET_NAMESPACE_TEMPLATE.ai-config.env.examplecarries worked examples showing the namespace each produces, in both languages.3 — Name the contract for a called program
Seven files declared
IBdsmfjlServicefor the same module, and three of them declared three different methods on it. A COBOL program has one entry point, so the interface has one method and both are derivable. One program is made responsible for declaring each — the callee when it is in the drop, otherwise the first caller in a stable order.4 — A running service built from the
bdmodulesFive COBOL function modules as HTTP endpoints, each naming its origin program, with the UI built from
/api/catalog. No customer code is committed —source/andoutput/stay gitignored; this holds the service shape, not the record layouts.Two deliberate differences, marked in code: the reconciliation batch required both inputs pre-sorted and 1:1 (a property of the job, not the data) — matching on key means unsorted input reconciles correctly instead of silently producing wrong output; and a zero-length batch reports zero throughput rather than dividing by zero.
Found and fixed along the way
demos/broke the toolchain build — the root project globs**/*.csand excluded the portal and tests but not thisCOPYlines — a test caught it; the column-7 indicator is now tested by position./doctor.sh reverse-eng --program Xnever worked — doctor.sh always passed--programsto a command that never declared itoutput/csharp/20260917-140000, readers resolve the newestREPO_ROOTand looked for the estate inside the portal's own directory — a reverse-engineering report generated minutes earlier was reported as not existingTwo checks, because prompts can be edited away
The namespace is assigned in an injected block, but the line telling the model to use it sits in the
Usersection Prompt Studio rewrites. If an AI rewrite drops it, nothing fails — the model quietly reverts to inventing a root.NamespaceComplianceverifies the output: every generated file must declare a namespace under the configured root, tested on segment boundaries. Verified against all 117 files — zero deviations.GeneratedTypeCollisionsreports types two files declare in the same namespace. It reports; it does not rewrite — choosing which of three contracts is correct is not a decision for a post-pass.Testing
390 toolchain + 113 portal + 55 demo portal, all passing. Verified live against the real 41-program estate and a full C# conversion of the 5
bdprograms.Known limitation — measured, not estimated
The generated C# still has same-namespace type collisions and will not compile as-is. A full C# conversion of the 5
bdprograms was run before and after the call-target work:An improvement, but not a fix, and the detail matters more than the total:
IBdsmfjlServiceis untouched becauseCallTargetRegistryscans program files only, and these declarations come from copybook conversions, which never receive the contract block.Bdsdatoiis worse: a shared copybook type in.Shared, declared seven times. The shared-types rule instructs the model to reference rather than declare, and it declares anyway.What this establishes: prompt-level enforcement does not survive 70 independent conversions. Two attempts — shared copybooks, then call targets — both leak. The deterministic fix is to generate shared types once from REKT
data_structuresand have every program reference them, which also removes invention from the shared layer entirely. That is the follow-up, not this PR.This is not a regression against
main: per-program packages hid the same duplication as four divergent copies of one record.GeneratedTypeCollisionsnames them until the generator exists.