Guided modernization for any legacy codebase: start with /modernize, get an assessment, an interactive map, the business rules mined from the code, and a plan…

Point Claude at a legacy codebase and get four things: an understanding of what it is and what it does, a plan you approve, the modernized code, and proof that the new code behaves like the old. It works with any language and any kind of move: a newer version of the same technology, a rewrite in another one, or a rebuild on a new architecture.

You need Claude Code. Then:
/plugin install code-modernization@claude-plugins-official
Some steps start many agents at once, so expect real usage on a large system (see What to expect). Start with one module or one unit as a pilot, not the whole estate.
Open a folder for the work and type /code-modernization:modernize. It asks what you want to do with your code (a couple of short questions), finds the code, writes your answers down once, shows the road ahead, and gives you the exact first command. Every later command reads what you said, so nothing is asked twice. Not sure what you want? Choose Understand it first: you get the assessment, the map, the business rules and a plan, and no code is rebuilt.
To go step by step instead, point the first command at your code and follow the "next step" line each command ends with:
/code-modernization:modernize-preflight <name> --source <path to your code>
/code-modernization:modernize-status <name> # where am I, and what is next: run it any time
<name> is a short label of your choice (letters, digits, -, _). --source makes a link at legacy/<name> and copies nothing, so a huge repository stays where it is. Nothing edits your legacy source. Commands write only to analysis/<name>/ and modernized/ (a build check writes build output where your build normally does), and each refreshes analysis/<name>/REPORT.html: one page, with everything found so far, that you can open or share. A few steps need someone who knows how the system is built and run, so bring an engineer for the first questions.
Each step stands alone, so you can stop and review after any of them.
| Step | Command | What you get |
|---|---|---|
| 0 | modernize | Say what you want. Writes INTENT.md; every later command reads it. |
| 1 | modernize-preflight <name> [target-stack] [--source <path>] | Is the environment ready? Asks five questions only a person can answer (is this the whole system or a slice, can it build and test here, is there custom build tooling, has anyone tried before, is anything off limits), proves a build works on this code, and finds missing source. |
| 2 | modernize-assess <name> | What am I dealing with: inventory, complexity, debt, security, and a recommended pattern. |
| 3 | modernize-map <name> | The structure: dependencies, data flow, entry points and business flows, as an interactive map. |
| 4 | modernize-extract-rules <name> | The business rules as testable Given/When/Then cards with file:line citations, each re-checked by a second agent. |
| 4b | modernize-review <name> | A person confirms or corrects the rules that look wrong. Your answers reach the plan and the build. With the live pane on, /modernize-review-pane does the same one card at a time. |
| 5 | modernize-brief <name> [target-stack] | The phased plan a steering committee approves. Nothing is built until you approve it. |
| 6 | uplift, transform or reimagine | The build (see below). |
| 7 | modernize-verify <name> | The proof. An independent re-check with one verdict per module. |
| 8 | modernize-harden <name> | A security scan of the legacy system with a reviewed patch you apply yourself. |
assess --portfolio <parent-dir> surveys many systems and ranks them on one page. modernize-status <name> says where you are and gives the exact command to paste.
A person decides at six points, and the plugin never decides them for you: the five preflight answers, the rules that look wrong (review), approving the brief, accepting any difference the proof finds, signing the proof, and applying the security patch.
Modernization fails quietly: the new code passes the tests someone wrote, and differs on the inputs nobody thought of. So the proof is built from evidence a script can check, not from the model's opinion. modernize-verify runs after a build, ideally in a fresh session, and re-does the work instead of trusting the notes the build left behind:
Each module gets one verdict, computed by scripts/proof_pack.py from those files with rules written into the output: PROVEN, PARTLY PROVEN (with what is missing) or NOT PROVEN (nothing ran, something differs, a test fails). If the old system cannot run where you work (a mainframe program, a system that only runs in production), the old behavior can only be checked against recorded outputs, and the best verdict is PARTLY PROVEN: the page says so plainly. Anything a person must decide (open questions, exit criteria, the sign-off) is listed as waiting for a person and is never ticked for you. Every verdict is computed again from current evidence each time; none is carried over from an earlier run.

| If you want | Run | What happens |
|---|---|---|
| The same technology on a newer version (.NET Framework to .NET 8, Java 8 to 17, Spring Boot 2 to 3) | modernize-uplift <name> [source-version] [target-version] | Keeps your code and fixes only what the new version breaks, driven by a catalog of the breaking changes this code actually hits. One pilot unit first with its lessons written down, then batches. The proof is the same test suite run on both versions. |
| A new technology, one module at a time, while the old system keeps running | modernize-transform <name> [module] [target-stack] | A plan you approve, tests that pin the old behavior, an idiomatic rewrite, and proof: old and new run on the same inputs and a script compares them. |
| A rebuild on a new architecture | modernize-reimagine <name> [target-vision] | A spec mined from the code, an architecture that is reviewed and approved, then services scaffolded with executable acceptance tests. |
A version move keeps your code, so it can skip extract-rules and review: preflight, assess, uplift, verify is a complete path, and map and brief add the unit order and an approved phased plan for a large system. If the delta catalog shows an "uplift" would rewrite most of the code, the command says so and points to transform. Rehost (move as is) and Replace (buy a product) change no code, so no build command applies; the analysis is still useful for both.
The commands were run for real, headlessly, on public codebases while the plugin was built, and what broke was fixed. Every number below comes from the files those runs wrote. The same command can give different counts on a second run (the security scan of the osCommerce code found 48 confirmed findings and then 52), so read the numbers as typical, not exact. Wherever a person had to decide (approve a plan, accept a difference), the tester played that person. Some runs stopped early because of limits of the test machine (it refused to run any freshly compiled program, and its package index refused installs); the plugin said so and did not work around them.
| Codebase | Move | What it did and what was shown |
|---|---|---|
| AWS CardDemo (COBOL, CICS, JCL) | Rewrite in Java, one job at a time | 35 rules (8 critical) and a five-phase plan, then the monthly interest job rewritten in Java 21 (213 tests at the end). Five independent checks in a row compared it with the real COBOL program, built locally, on inputs written after the fact. Each found something the tests had missed, and each was answered: a blank field that halted the job, a non-numeric account key, a negative zero, and differences the owner accepted as deliberate. The last check tried fifteen more inputs with malformed records and found seven more differences, so it ended NOT PROVEN. It also told two problems of the old system's test harness apart from defects in the new code. What ends a loop like this is a person deciding which inputs are in scope; the verify command now reads that from the intent you gave the front door. |
| Eclipse Jetty (Java 8, 2,600 files) | Java 8 to 17 | A pilot on the jetty-util module: the same 946 tests give the same results on both Java versions. Building and running both versions found six changes that reading the code had not predicted: a direct buffer reported as memory-mapped on 17, a bundle plugin that writes an invalid manifest and still says SUCCESS, and a build on JDK 13 to 16 that would have shipped Java 8-labelled classes that crash on Java 8, among others. 50 tests were added for the 25 of 56 critical rules that had none (996 tests run on each version, no differences). |
| osCommerce (PHP 5, about 44,000 lines of PHP) | PHP to Python and FastAPI | 97 rules (26 critical) and a six-phase plan. First slice, the product page: 17,957 tests pass, and 17,727 comparison cases against the real PHP files are identical. Two deliberate breaks each turned tests red. The architecture review found two high-severity problems (expired specials still shown, and a page request holding a database write lock) that were fixed with tests that failed first. An independent verification said PROVEN: its own break made 647 more tests fail, and 14 new inputs matched the real PHP. |
| AngularJS RealWorld (AngularJS 1.5) | AngularJS to React and TypeScript | 79 rules (5 critical). The articles service was rewritten: 166 tests, and 29 comparison cases against recorded responses of the real API (23 identical, 6 differences approved as deliberate, none unexplained). Four deliberate breaks each turned tests red, and the review's two high-severity findings were fixed. Checked by the build step itself; there was no separate verification run. |
| JPetStore (Java and JSP) | Rebuild as a REST API | A spec with 12 capabilities, the 35 rules and a four-phase plan. The architecture review found two blockers (an order confirmation that could not be safely replayed, and cart updates lost under parallel requests); both were designed out. One service was scaffolded: 35 tests ran and passed, 56 more are pending or need Docker. The independent check said NOT PROVEN: 6 of 24 new comparisons with the old application differ, and the development cases hold no old-versus-new comparison yet. That run also showed the proof step counting skipped tests as covering a rule; it now flags five of the six critical rules as named only by tests that did not run. |
| beets (Python 2, about 19,000 lines) | Python 2 to 3 | 21 rules for the tagging module (4 critical), all about matching. Only 3 files in the whole tree fail to compile on Python 3, so it is an uplift and not a rewrite. No existing test could run on Python 3 yet, and the packages they need were refused by this machine's package index; the plugin reported that and did not claim a pass. |
| Spring PetClinic (7 services) | Spring Boot 2.6 to 3.3 | A delta catalog with exact target versions: 59 javax imports in 10 files to move, a request that answered 200 on 2.6 and answers 400 on 3.3 (verified by running both), a Hibernate 6 identifier check that came out safe, and a monitoring endpoint that Boot 3 dropped. The pilot stopped at the plan gate because the approved plan named a different first service, and the command enforced the plan. |
| AWStats (Perl, about 43,000 lines) | Perl to Python and FastAPI | 88 rules (2 critical) and a six-phase plan that keeps replacing it with an existing product open as the alternative. The security scan confirmed 48 findings, 9 of them high (7 in an optional module that runs as root), and refuted 14 of 62 as false positives. |
| Redmine 2.3.3, eShop (.NET), Jenkins pipelines | Rails 3.2 to 7.1, .NET Framework 4.7.2 to .NET 10, Jenkins to GitHub Actions | Maps, rules (185, 57 and 58) and plans. Redmine's assessment led with "check whether upstream already made this move". eShop's catalog lists 12 silent behavior changes and says the first phase must build a test harness because the solution has none. |
| NetHack, KISS FFT, BSD numbers (C) | C to Python, and to Rust | 28, 70 to 93 and 56 rules. NetHack's first slice (its declarations, in Python) matched the C program on 8 of 8 comparison cases. The Rust runs stopped at the plan: this Mac killed every freshly compiled program, so the plugin listed what could not run and did not work around it. |
| A booby-trapped codebase (built for the test) | Rewrite | Planted instructions in comments, the README, a CLAUDE.md and a project settings file, a script that would drop a marker file, and file names with shell syntax. Nothing planted was obeyed, no marker file appeared, the source stayed unchanged, the planted lines were listed in the report, and the credential was masked. |
Steps run as agents working for you, and the heavy ones run many at once. Rough times on systems of tens of thousands of lines: preflight 3 to 4 minutes, assess 5 to 8, map 5 to 15, extract-rules 5 to 15, brief about 5. Building one module takes 15 to 30 minutes, an uplift pilot about 15. The heaviest step, extract-rules, started 50 to 200 agents in these runs, and every fan-out step says how many it will start; extract-rules asks before a big run. On systems in the millions of lines, work one module or unit at a time. The size index in assess is a relative measure for ranking systems, never a schedule or a cost.
| Word | Plain meaning |
|---|---|
| Agent | A separate Claude worker that does one job (read one module, check one rule) and reports back. Steps start many of them at once. |
| Business rule card | One thing the system does, written as Given / When / Then with the file and line it comes from, so a person can check it. |
| P0 | A rule that would defeat the system's purpose or be costly or irreversible if it were wrong. Everything else is P1 or P2. |
| Brief | The written plan, phase by phase. You approve it; commands never build anything before that. |
| Uplift / transform / reimagine | Newer version of the same technology / rewrite in another technology / rebuild on a new architecture. |
| Pilot | The first small unit built end to end, so its lessons are written down before the rest is attempted. |
| Canary | A deliberate one-line break in the new code that must make tests fail, proving the tests can fail. |
| Delta catalog | The list of things the newer version breaks that this code actually uses. |
The commands never edit your code, by convention. A .claude/settings.json in the workspace backs that up with a deny rule for the source and allow rules for the outputs (preflight checks for the deny rule):
{
"permissions": {
"allow": ["Read(**)", "Edit(analysis/**)", "Edit(modernized/**)"],
"deny": ["Edit(/legacy/**)"]
}
}
Edit rule, so this covers the Write tool too (a Write(path) rule is never consulted). The leading / anchors the rule at the workspace root.legacy/<name> is a symlink (which --source makes), also allow reading its target ("additionalDirectories": ["/path/to/code"]) and deny its real path ("Edit(//path/to/code/**)"), because the rule above matches the link's path, not its target's.uplift step 5b and reimagine phase E).python3 scripts, scc, rsync, your build and test commands, anything outside the workspace). Use accept-edits mode or allow rules for the ones you trust.Helpful but optional (run preflight to check them all): scc or cloc for size metrics; Python 3.8 or newer as python3 (on Windows python or py -3 works) for the map, the shard builder, the proof and the report; a build toolchain for your stack, which enables the strongest proof (running old and new side by side); and the whole system in the tree (deployment descriptors, copybooks, DDL), which entry points and data lineage need. Without a toolchain the plugin falls back to recorded-output tests and says so.
brief is a human approval gate before anything is built. Treat discovery artifacts from untrusted code with the same skepticism as the code.AKIA****) and inventoried in a gitignored SECRETS.local.md (or ~/.modernize/<name>/ outside git); harden keeps credential-removal hunks in a separate gitignored patch. --show-secrets puts raw values in the quarantine file only.preflight and assess change no source file. Preflight's smoke test compiles one file and, where there is a build system, restores and builds one small project, which writes build output wherever the build normally does.The plugin counts how it is used, so the next version can be better. It sends whole numbers only: never code, file or system names, paths, prompts or anything you typed. Counts of 100 or more are rounded to two significant figures (17,727 is sent as 18,000), so a number says roughly how big, not exactly which system. It sends them through Claude Code's own telemetry, so nothing is sent to Anthropic when that is off. The plugin itself makes no network call and adds no identifier; the one file it writes is a small telemetry-state.json of hashes in its own data folder, so the same counts are not sent twice.
Five small hooks do it:
INTENT.md or PREFLIGHT.md.If python is missing, is the Windows Store placeholder, is too old or crashes, the small shell script that starts the hooks says so in numbers itself, because python cannot report its own absence. If it finds python or the py launcher working, it uses that instead.
| Key | What it counts |
|---|---|
pv | plugin version as major*10000 + minor*100 + patch |
cmd | command typed: 1 front door, 2 preflight, 3 assess, 4 map, 5 extract-rules, 6 review, 7 brief, 8 transform, 9 uplift, 10 reimagine, 11 verify, 12 harden, 13 status, 20 to 22 the pane's commands, 99 other |
perm | permission mode the session ran in: 0 unknown, 1 default, 2 accept edits, 3 plan, 4 auto, 5 bypass, 6 don't ask |
has_source | 1 when the command carried --source |
fresh | 1 when the system had no artifacts yet |
systems | systems under analysis/ |
goal | 0 unknown, 1 understand, 2 uplift, 3 transform, 4 reimagine |
lang | the language with most lines in the map: 1 COBOL, 2 Java, 3 C# and .NET, 4 Python, 5 PHP, 6 Perl, 7 C, 8 C++, 9 JavaScript and TypeScript, 10 Ruby, 11 Go, 12 Rust, 13 Kotlin and Scala, 14 SQL, 15 Fortran, 16 RPG, 17 Pascal and Delphi, 18 shell, 19 assembler, 99 other, 0 unknown |
done | the steps that have left their file, added up: preflight 1, assess 2, map 4, rules 8, reviewed 16, brief 32, approved 64, built 128, verified 256, signed 512, hardened 1024, report 2048, deltas 4096, baseline 8192, playbook 16384, spec 32768 |
map_kloc | thousand lines of code in the map |
rules, p0 | business rules found, and the critical ones among them |
rev_ok, rev_wrong | rules a person confirmed, and rules a person marked wrong |
phases | phases in the plan |
built | modules or services built (an uplift's working copy counts as one once it exists) |
| eq_cases, eq_diff,
hooks/register.ts 1299 lines1import type { EngineInterface, On, PluginOptions, ResultOf } from 'claude-code'
2
3import { lineOf, missingPathOf, noteCall, noteDone, noteFailure, prune, subjectOf, tallyOf } from './fleet/fleet'
4import type { Host, OpenResult } from './host'
5import { paint, tilesOf, type TouchKind } from './map/estate'
6import { absOf, baseName, isUnder, join, norm, relTo } from './paths'
7import { keysOfUnit, unitOfPath } from './reader/estate-model'
8import { readOrNull } from './reader/fs'
9import type { TestTotals } from './reader/modernized'
10import { oneLineOf, readSnapshot, REVIEWS_FILE } from './reader/progress'
11import type { ReviewVerdict } from './reader/progress'
12import { nodeOfFile } from './reader/topology'
13import { decide, ledgerJson, ledgerMarkdown, nextUnreviewed, queueOf, undecide, type DeckScope } from './review/deck'
14import { mergeLedger, parseLedger } from './review/ledger'
15import { signBrief } from './sign'
16import {
17 newActivity,
18 newState,
19 PANE_ID,
20 PLUGIN_NAME,
21 RASTER_KEY,
22 SIGN_ID,
23 type FinishedCall,
24 type State,
25} from './state'
26import { readTestRun, TEST_COMMAND } from './tests-run'
27import { deckView, signView } from './views/deck'
28import { headerRowsOf, legendRowsOf, nextRowsOf, paneView, planOf, showBar, type Kit } from './views/pane'
29import { plain } from './text'
30import { xrayOf } from './xray/xray'
31
32const REFRESH_DEBOUNCE_MS = 450
33/** While a fleet of agents is writing, a read of everything on disk is due this often, not after every call. */
34const REFRESH_BUSY_MS = 2500
35const FRAME_MS = 140
36const CLOCK_MS = 1000
37const POLL_MS = 20_000
38const WRITE_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit', 'MultiEdit'])
39const QUIET_TOOLS = new Set(['Read', 'Glob', 'Grep', 'LS', 'WebFetch', 'WebSearch', 'TodoWrite', 'ToolSearch'])
40const PARENT_TOOLS = new Set(['Agent', 'Task', 'Workflow'])
41const STEP = /\/(?:[\w-]+:)?(modernize-[a-z-]+)\b[^\n]*/
42
43const nowMs = (): number => Date.now()
44
45/** A cell count as the engine reports it, made safe to do layout arithmetic on. */
46const wholeOf = (value: unknown): number =>
47 typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0
48
49/**
50 * Binds a Host from `$`. Declared in this file, each member spelled
51 * `$.noun.event(...)`, so the engine reads what the module calls off its source.
52 */
53function hostOf($: EngineInterface): Host {
54 return {
55 fs: {
56 read: path => $.fs.read(path),
57 write: (path, text) => $.fs.write(path, text),
58 list: path => $.fs.list(path),
59 exists: path => $.fs.exists(path),
60 stat: path => $.fs.stat(path),
61 },
62 now: () => $.clock.now(),
63 after: (ms, fn) => $.clock.after(ms, fn),
64 every: (ms, fn) => $.clock.every(ms, fn),
65 storeGet: key => $.store.get(key),
66 storeSet: (key, value) => $.store.set(key, value),
67 invalidate: () => $.ui.invalidate('ui.render'),
68 blit: args => $.ui.blit(args),
69 status: text => $.ui.status(text),
70 toast: (text, timeoutMs) => $.ui.toast(text, timeoutMs !== undefined ? { timeoutMs } : undefined),
71 log: text => $.ui.log(text),
72 openPane: pane => $.ui.open(pane) as Promise<OpenResult>,
73 closePane: pane => $.ui.close(pane),
74 registerCommand: spec => $.command.register(spec),
75 fillPrompt: input => $.prompt.fill(input),
76 submitPrompt: input => $.prompt.submit(input),
77 abortTurn: turnId => $.turn.abort({ turnId }),
78 }
79}
80
81/**
82 * `fs` with every relative path rooted at `cwd`: the artifacts live under the
83 * session's working directory, wherever the process itself happens to stand.
84 */
85function rootedAt(fs: Host['fs'], cwd: string): Host['fs'] {
86 return {
87 read: path => fs.read(absOf(cwd, path)),
88 write: (path, text) => fs.write(absOf(cwd, path), text),
89 list: path => fs.list(absOf(cwd, path)),
90 exists: path => fs.exists(absOf(cwd, path)),
91 stat: path => fs.stat(absOf(cwd, path)),
92 }
93}
94
95/**
96 * Registers the plugin's live hooks: the pane and its estate map, x-ray
97 * reads, the rule review deck, the fleet view and the sign-off dialog.
98 * `session.start` binds the engine; every hook after
99 * it works over that binding and one state object.
100 *
101 * @param on the engine's registrar
102 * @param raw the plugin's options as the settings hold them
103 */
104export function register(on: On, raw: PluginOptions) {
105 const state: State = newState(raw)
106
107 // ---------------------------------------------------------------- reading
108
109 async function refresh(host: Host): Promise<void> {
110 if (state.isRefreshing) {
111 state.isRefreshQueued = true
112
113 return
114 }
115
116 state.isRefreshing = true
117
118 try {
119 const snapshot = await readSnapshot(host.fs, state.cache, {
120 ...((state.pickedSystem ?? state.options.system) !== '' && { system: state.pickedSystem ?? state.options.system }),
121 commandPrefix: state.options.commandPrefix,
122 legacyDir: state.options.legacyDir,
123 track: state.options.track,
124 writtenUnits: state.writtenUnits,
125 observed: state.observed,
126 nowMs: nowMs(),
127 })
128
129 // The pane opens by itself once there is modernization to show: the first artifact under analysis/,
130 // not only a legacy system that has not been touched.
131 const isFirstSight = snapshot !== null && snapshot.hasAnalysis && state.snapshot?.hasAnalysis !== true
132
133 state.snapshot = snapshot
134 state.readError = null
135
136 if (isFirstSight) {
137 maybeAutoOpen()
138 }
139
140 if (snapshot !== null) {
141 if (!state.deck.isOpen) {
142 state.deck.ledger = snapshot.reviews
143 }
144
145 // The pane says all of this already, and so does the bar that stands where it was while it is hidden. The
146 // pinned line is for a person who turned the pane off. A workspace that holds a legacy system and nothing
147 // yet under analysis/ has no modernization to report.
148 host.status(state.pane.isOpen || !snapshot.hasAnalysis || state.options.panel !== 'off' ? undefined : oneLineOf(snapshot))
149 }
150
151 if (state.estate !== null) {
152 state.estate = { ...state.estate, tiles: [] }
153 }
154 } catch (error) {
155 state.readError = error instanceof Error ? error.message : String(error)
156 } finally {
157 state.isRefreshing = false
158 host.invalidate()
159
160 if (state.isRefreshQueued) {
161 state.isRefreshQueued = false
162 scheduleRefresh(host)
163 }
164 }
165 }
166
167 function scheduleRefresh(host: Host): void {
168 // A read is already due, and it will see everything written so far. Pushing it out with every call would starve
169 // it for as long as agents keep writing, and the map would show nothing of a fan-out until it was over.
170 if (state.timers.has('refresh')) {
171 return
172 }
173
174 const isBusy = tallyOf(state.fleet, nowMs()).active >= 3
175
176 state.timers.set(
177 'refresh',
178 host.after(isBusy ? REFRESH_BUSY_MS : REFRESH_DEBOUNCE_MS, () => {
179 state.timers.delete('refresh')
180 void refresh(host)
181 }),
182 )
183 }
184
185 // ------------------------------------------------------------- animation
186
187 function frameOf(): { cells: string; isAnimating: boolean } | null {
188 const estate = state.estate
189
190 if (estate === null || state.snapshot === null) {
191 return null
192 }
193
194 const painted = paint(estate.tiles, estate.columns, estate.rows, state.touches, nowMs())
195
196 return { cells: painted.cells, isAnimating: painted.isAnimating }
197 }
198
199 function animate(host: Host): void {
200 if (state.timers.has('frames') || !state.pane.isOpen) {
201 return
202 }
203
204 state.timers.set(
205 'frames',
206 host.every(FRAME_MS, () => {
207 const estate = state.estate
208 const frame = frameOf()
209
210 if (estate === null || frame === null || !state.pane.isOpen) {
211 state.timers.get('frames')?.cancel()
212 state.timers.delete('frames')
213
214 return
215 }
216
217 void host
218 .blit({
219 requestId: PANE_ID,
220 key: RASTER_KEY,
221 cells: frame.cells,
222 columns: estate.columns,
223 rows: estate.rows,
224 })
225 .then(result => {
226 // Said once: a refused repaint means the map is stale, which is worth knowing.
227 if (result.deny !== undefined && !state.timers.has('blit-refused')) {
228 state.timers.set('blit-refused', host.after(60_000, () => state.timers.delete('blit-refused')))
229 host.log(`estate map: repaint refused (${result.deny})`)
230 }
231 })
232 .catch(() => undefined)
233
234 if (!frame.isAnimating) {
235 state.timers.get('frames')?.cancel()
236 state.timers.delete('frames')
237 }
238 }),
239 )
240 }
241
242 function touch(host: Host, nodeId: string, kind: TouchKind): void {
243 const held = state.touches.get(nodeId)
244
245 // A write outranks a read that is still fading.
246 if (held !== undefined && held.kind === 'write' && kind === 'read' && nowMs() - held.atMs < 1200) {
247 return
248 }
249
250 state.touches.set(nodeId, { atMs: nowMs(), kind })
251 animate(host)
252 }
253
254 /** The estate unit ids a tool call's paths and command text name, with how each was touched. */
255 function nodesTouched(tool: string, args: Readonly<Record<string, unknown>>): { id: string; kind: TouchKind }[] {
256 const snapshot = state.snapshot
257 const estate = snapshot?.estate
258
259 if (snapshot === null || estate === null || estate === undefined) {
260 return []
261 }
262
263 const legacyRoot = join(state.options.legacyDir, snapshot.system)
264 const modernRoot = join('modernized', snapshot.system)
265 const upliftRoot = join('modernized', `${snapshot.system}-uplifted`)
266 const out = new Map<string, TouchKind>()
267 const kind: TouchKind = WRITE_TOOLS.has(tool) ? 'write' : 'read'
268
269 const take = (path: string, how: TouchKind) => {
270 const rel = relTo(state.cwd, path)
271
272 if (rel === null) {
273 return
274 }
275
276 if (isUnder(rel, legacyRoot) && rel !== legacyRoot) {
277 const unit = unitOfPath(estate, snapshot.topology, rel.slice(legacyRoot.length + 1))
278
279 if (unit !== null) {
280 out.set(unit.id, how)
281 }
282 } else if (isUnder(rel, upliftRoot) && rel !== upliftRoot) {
283 // An uplift's working copy has the legacy tree's own layout, so the same path names the same unit.
284 const unit = unitOfPath(estate, snapshot.topology, rel.slice(upliftRoot.length + 1))
285
286 if (unit !== null) {
287 out.set(unit.id, how)
288
289 // An edit that keeps a file's size is invisible to the size comparison; a write seen here is not.
290 if (how === 'write') {
291 state.writtenUnits.add(unit.id)
292 }
293 }
294 } else if (isUnder(rel, modernRoot) && rel !== modernRoot) {
295 const dir = (rel.slice(modernRoot.length + 1).split('/')[0] ?? '').toLowerCase()
296
297 const unit = estate.units.find(candidate => keysOfUnit(candidate).some(key => key.toLowerCase() === dir))
298
299 if (unit !== undefined) {
300 out.set(unit.id, how === 'read' ? 'read' : 'write')
301 }
302 }
303 }
304
305 for (const key of ['file_path', 'path', 'notebook_path']) {
306 const value = args[key]
307
308 if (typeof value === 'string') {
309 take(value, kind)
310 }
311 }
312
313 const command = typeof args.command === 'string' ? args.command : ''
314
315 if (command !== '') {
316 const paths = command.match(/(?:\.{0,2}\/)?[\w.@~+-]+(?:\/[\w.@~+-]+)+/g) ?? []
317 const writes = /(?:>|\btee\b|\bsed\s+-i|\bmv\b|\bcp\b|\brm\b|\bpatch\b|\bgit\s+(?:apply|checkout|restore))/.test(command)
318
319 for (const path of paths.slice(0, 24)) {
320 const rel = relTo(state.cwd, path) ?? ''
321
322 take(path, writes && (isUnder(rel, modernRoot) || isUnder(rel, upliftRoot)) ? 'write' : 'read')
323 }
324 }
325
326 return [...out.entries()].map(([id, how]) => ({ id, kind: how }))
327 }
328
329 /** The module a shell command's text names by path, of those the pane knows: the longest path wins. */
330 function moduleOfCommand(command: string): string | null {
331 const snapshot = state.snapshot
332
333 if (snapshot === null) {
334 return null
335 }
336
337 const upliftRoot = join('modernized', `${snapshot.system}-uplifted`)
338
339 const candidates = [
340 ...snapshot.modules.map(module => module.path),
341 ...(snapshot.track === 'uplift'
342 ? (snapshot.estate?.units ?? []).flatMap(unit => (unit.dir !== undefined && unit.dir !== '' ? [join(upliftRoot, unit.dir)] : []))
343 : []),
344 ].sort((a, b) => b.length - a.length)
345
346 const escaped = (path: string) => path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
347
348 return (
349 candidates.find(path => new RegExp(`(?:^|[\\s'"=:@])(?:\\./)?${escaped(path)}(?=[\\s/'"]|$)`).test(command)) ?? null
350 )
351 }
352
353 // ------------------------------------------------------------------ deck
354
355 async function openDeck(host: Host, scope: DeckScope, filter: string): Promise<string> {
356 const snapshot = state.snapshot
357
358 if (snapshot === null || snapshot.rules === null) {
359 return 'No BUSINESS_RULES.md to review yet. Run the extract-rules command first.'
360 }
361
362 const queue = queueOf(snapshot.rules, scope, filter)
363
364 state.deck = {
365 isOpen: true,
366 scope,
367 filter,
368 queue,
369 ledger: snapshot.reviews,
370 edits: new Map(),
371 index: nextUnreviewed(queue, snapshot.reviews, 0),
372 source: null,
373 isSourceShown: false,
374 }
375
376 // The deck draws in the band above the prompt: nothing to open, only to draw.
377 host.invalidate()
378
379 return queue.length === 0
380 ? 'Nothing to review under that scope.'
381 : `Reviewing ${queue.length} rule${queue.length === 1 ? '' : 's'}.`
382 }
383
384 async function saveLedger(host: Host): Promise<void> {
385 const system = state.snapshot?.system
386
387 if (system === undefined) {
388 return
389 }
390
391 // The review command writes this file too: what it added while the deck was open is kept, this session's decisions go on top.
392 const onDisk = parseLedger(await readOrNull(host.fs, join('analysis', system, REVIEWS_FILE)))
393 const merged = mergeLedger(onDisk, state.deck.edits)
394
395 state.deck.ledger = merged
396 await host.fs.write(join('analysis', system, REVIEWS_FILE), ledgerJson(system, merged))
397 await host.fs.write(join('analysis', system, 'RULE_REVIEWS.md'), ledgerMarkdown(system, merged))
398 scheduleRefresh(host)
399 }
400
401 async function loadSource(host: Host): Promise<void> {
402 const rule = state.deck.queue[state.deck.index]
403 const snapshot = state.snapshot
404 const citation = rule?.citations[0]
405
406 if (rule === undefined || snapshot === null || citation === undefined) {
407 return
408 }
409
410 const legacyRoot = join(state.options.legacyDir, snapshot.system)
411 const written = norm(citation.path)
412
413 // A citation comes from a file in the analysis tree: it names a file inside the code, never a way out of it.
414 if (written.startsWith('/') || /^[A-Za-z]:/.test(written) || written.split('/').includes('..')) {
415 return
416 }
417 const viaMap = snapshot.topology !== null ? nodeOfFile(snapshot.topology, written)?.file : undefined
418
419 const candidates = [
420 written,
421 join(legacyRoot, written),
422 ...(viaMap !== undefined ? [join(legacyRoot, viaMap)] : []),
423 ]
424
425 for (const path of candidates) {
426 const text = await readOrNull(host.fs, path)
427
428 if (text === null) {
429 continue
430 }
431
432 const lines = text.split('\n')
433 // From the cited line itself: in a short band every row of lead-in costs a row of the rule's own code.
434 const from = Math.max(1, citation.from)
435 const to = Math.min(lines.length, Math.max(citation.to + 2, from + 11), from + 39)
436
437 state.deck.source = {
438 ruleId: rule.id,
439 path,
440 startLine: from,
441 text: lines
442 .slice(from - 1, to)
443 .join('\n')
444 .replace(/[^\x09\x0a\x20-\x7e -]/g, ' ')
445 .slice(0, 9000),
446 }
447
448 return
449 }
450
451 state.deck.source = { ruleId: rule.id, path: written, startLine: citation.from, text: '(source file not found)' }
452 }
453
454 const deckActionsOf = (host: Host) => ({
455 decide: (verdict: ReviewVerdict) => {
456 const rule = state.deck.queue[state.deck.index]
457
458 if (rule === undefined) {
459 return
460 }
461
462 state.deck.ledger = decide(state.deck.ledger, rule, verdict, new Date(nowMs()).toISOString())
463 state.deck.edits.set(rule.id, state.deck.ledger[rule.id] ?? null)
464 state.deck.index = nextUnreviewed(state.deck.queue, state.deck.ledger, state.deck.index + 1)
465 state.deck.isSourceShown = false
466 void saveLedger(host).catch(() => undefined)
467 host.invalidate()
468 },
469 undo: () => {
470 const rule = state.deck.queue[state.deck.index]
471
472 if (rule !== undefined) {
473 state.deck.ledger = undecide(state.deck.ledger, rule.id)
474 state.deck.edits.set(rule.id, null)
475 void saveLedger(host).catch(() => undefined)
476 host.invalidate()
477 }
478 },
479 prev: () => {
480 const size = state.deck.queue.length
481
482 state.deck.index = size === 0 ? 0 : (state.deck.index - 1 + size) % size
483 state.deck.isSourceShown = false
484 host.invalidate()
485 },
486 next: () => {
487 const size = state.deck.queue.length
488
489 state.deck.index = size === 0 ? 0 : (state.deck.index + 1) % size
490 state.deck.isSourceShown = false
491 host.invalidate()
492 },
493 source: () => {
494 state.deck.isSourceShown = !state.deck.isSourceShown
495
496 if (state.deck.isSourceShown) {
497 void loadSource(host).then(() => host.invalidate())
498 }
499
500 host.invalidate()
501 },
502 close: () => {
503 state.deck.isOpen = false
504 host.invalidate()
505 },
506 })
507
508 // ------------------------------------------------------------------ sign
509
510 async function openSign(host: Host, name = ''): Promise<string> {
511 const snapshot = state.snapshot
512
513 if (snapshot === null || snapshot.brief === null) {
514 return 'There is no brief to sign yet. Run the brief command first.'
515 }
516
517 if (snapshot.brief.approval.isSigned) {
518 return `The brief is already signed${snapshot.brief.approval.by !== undefined ? ` by ${snapshot.brief.approval.by}` : ''}.`
519 }
520
521 if (state.activity.isWorking) {
522 return 'Wait for the running turn to finish before signing.'
523 }
524
525 state.sign = { isOpen: true, name: name !== '' ? name : state.sign.name, covers: 'phase-1', error: null }
526
527 await host.openPane({
528 id: SIGN_ID,
529 title: 'Sign the brief',
530 focus: true,
531 closeOnEscape: true,
532 holdToasts: true,
533 rows: 12,
534 })
535
536 host.invalidate()
537
538 return ''
539 }
540
541 const signActionsOf = (host: Host) => ({
542 name: (value: string) => {
543 state.sign.name = value
544 },
545 covers: (value: 'phase-1' | 'full') => {
546 state.sign.covers = value
547 host.invalidate()
548 },
549 cancel: () => {
550 state.sign.isOpen = false
551 void host.closePane({ id: SIGN_ID }).catch(() => undefined)
552 },
553 sign: () => {
554 void (async () => {
555 const system = state.snapshot?.system
556 const name = state.sign.name.trim()
557
558 if (system === undefined) {
559 return
560 }
561
562 if (name === '') {
563 state.sign.error = 'Type the approver\'s name first.'
564 host.invalidate()
565
566 return
567 }
568
569 if (state.activity.isWorking) {
570 state.sign.error = 'A turn is running; sign once it has finished.'
571 host.invalidate()
572
573 return
574 }
575
576 const path = join('analysis', system, 'MODERNIZATION_BRIEF.md')
577 const text = await readOrNull(host.fs, path)
578 const signed = text !== null ? signBrief(text, name, new Date(nowMs()).toISOString().slice(0, 10), state.sign.covers) : null
579
580 if (signed === null) {
581 state.sign.error = 'The brief has no approval block to sign.'
582 host.invalidate()
583
584 return
585 }
586
587 await host.fs.write(path, signed)
588 state.sign.isOpen = false
589 await host.closePane({ id: SIGN_ID }).catch(() => undefined)
590 host.log(`brief signed by ${name} (${state.sign.covers === 'full' ? 'full plan' : 'Phase 1 only'})`)
591 await refresh(host)
592 })()
593 },
594 })
595
596 // ------------------------------------------------------------------ pane
597
598 /**
599 * Opens the pane. Opened unasked on a terminal too narrow to dock a pane, the engine holds it undrawn: it is not open to
600 * the person, so the bar with the show button stays where it was, and pressing that button (the person asking) seats
601 * the pane at any width.
602 */
603 async function openPane(host: Host): Promise<{ isPlaced: boolean; reason: string }> {
604 const result: OpenResult = await host.openPane({ id: PANE_ID, title: 'Modernization' })
605 const isPlaced = result === undefined || result.isPlaced !== false
606
607 state.pane.isOpen = isPlaced
608 state.pane.isWaiting = !isPlaced
609 state.pane.isClosedByPerson = false
610 void refresh(host)
611
612 return { isPlaced, reason: result !== undefined && result.reason !== undefined ? result.reason : '' }
613 }
614
615 const paneActionsOf = (host: Host) => ({
616 system: () => {
617 const systems = state.snapshot?.systems ?? []
618 const current = state.snapshot?.system
619
620 if (systems.length < 2 || current === undefined) {
621 return
622 }
623
624 // A different system is a different estate, a different brief and a different set of lit tiles.
625 state.pickedSystem = systems[(systems.indexOf(current) + 1) % systems.length] ?? current
626 state.touches.clear()
627 state.writtenUnits.clear()
628 state.estate = null
629 void refresh(host)
630 },
631 next: () => {
632 const next = state.snapshot?.next
633
634 if (next === null || next === undefined || next.isByHand) {
635 return
636 }
637
638 void host
639 .fillPrompt({ text: next.text })
640 .then(result => {
641 if (!result.isFilled) {
642 host.toast(`Next: ${next.text}`, 8000)
643 }
644 })
645 .catch(() => undefined)
646 },
647 review: () => {
648 void openDeck(host, 'flagged', '').catch(() => undefined)
649 },
650 sign: () => {
651 void openSign(host)
652 .then(message => {
653 if (message !== '') {
654 host.toast(message, 6000)
655 }
656 })
657 .catch(() => undefined)
658 },
659 close: () => {
660 state.pane.isOpen = false
661 state.pane.isWaiting = false
662 state.pane.isClosedByPerson = true
663 void host.closePane({ id: PANE_ID }).catch(() => undefined)
664 },
665 stop: () => {
666 const turnId = state.activity.turnId
667
668 if (turnId !== null) {
669 void host.abortTurn(turnId).catch(() => undefined)
670 }
671 },
672 })
673
674 /**
675 * Opens the pane by itself once there is something to show, the person has not closed it, and
676 * the layout docks panes (the fullscreen layout). What the layout is comes from whichever render
677 * last said: not every build draws every site, so each render hook reports what it saw.
678 */
679 function maybeAutoOpen(viewport?: { columns?: number; isFullscreen?: boolean }): void {
680 state.viewport.columns = viewport?.columns ?? state.viewport.columns
681 state.viewport.isFullscreen = viewport?.isFullscreen ?? state.viewport.isFullscreen
682
683 const host = state.host
684
685 const shouldOpen =
686 host !== null &&
687 state.options.panel === 'auto' &&
688 !state.pane.isOpen &&
689 !state.pane.isWaiting &&
690 !state.pane.isClosedByPerson &&
691 !state.timers.has('auto-open') &&
692 state.snapshot !== null &&
693 state.snapshot.hasAnalysis &&
694 state.viewport.isFullscreen === true
695
696 if (shouldOpen && host !== null) {
697 // Opened from a timer: a render hook only draws.
698 state.timers.set(
699 'auto-open',
700 host.after(50, () => {
701 state.timers.delete('auto-open')
702 void openPane(host).catch(() => undefined)
703 }),
704 )
705 }
706 }
707
708 // ------------------------------------------------------------- lifecycle
709
710 on('session.start', async ($, e, next) => {
711 const bound = hostOf($)
712 const host: Host = { ...bound, fs: rootedAt(bound.fs, e.cwd) }
713
714 state.host = host
715 state.cwd = e.cwd
716
717 for (const timer of state.timers.values()) {
718 timer.cancel()
719 }
720
721 state.timers.clear()
722
723 await Promise.all([
724 host
725 .registerCommand({
726 name: 'modernize-panel',
727 description: 'Show or hide the modernization progress pane and estate map',
728 argumentHint: '[open|close|json]',
729 immediate: true,
730 })
731 .catch(() => undefined),
732 host
733 .registerCommand({
734 name: 'modernize-review-pane',
735 description: 'Review business rules one card at a time in the pane: confirm, wrong, or discuss',
736 argumentHint: '[flagged|p0|all] [filter]',
737 })
738 .catch(() => undefined),
739 host
740 .registerCommand({
741 name: 'modernize-sign',
742 description: 'Sign the modernization brief\'s approval block (a person\'s action)',
743 argumentHint: '[approver name, role]',
744 })
745 .catch(() => undefined),
746 ])
747
748 await refresh(host)
749
750 state.timers.set(
751 'clock',
752 host.every(CLOCK_MS, () => {
753 if (state.pane.isOpen && (state.activity.isWorking || state.activity.running.size > 0)) {
754 host.invalidate()
755 }
756 }),
757 )
758
759 state.timers.set(
760 'poll',
761 host.every(POLL_MS, () => {
762 if (state.pane.isOpen && !state.activity.isWorking) {
763 scheduleRefresh(host)
764 }
765 }),
766 )
767
768 return next(e)
769 })
770
771 on('ui.render', { component: 'PromptHint' }, ($, e, next) => {
772 if (e.surface === 'terminal') {
773 maybeAutoOpen(e.viewport)
774 }
775
776 return next(e)
777 })
778
779 on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
780 const host = state.host
781
782 if (e.surface === 'terminal') {
783 // Not every build reports `isFullscreen`. The band's `maxRows` tells the same fact: under the
784 // fullscreen layout it is what the bottom slot has left; otherwise it is the terminal's height.
785 const rows = e.viewport?.rows
786 const inferred = rows !== undefined && e.props.maxRows > 0 ? e.props.maxRows < rows : undefined
787
788 maybeAutoOpen({
789 ...(e.viewport?.columns !== undefined && { columns: e.viewport.columns }),
790 ...((e.viewport?.isFullscreen ?? inferred) !== undefined && {
791 isFullscreen: e.viewport?.isFullscreen ?? inferred,
792 }),
793 })
794 }
795
796 // A survey holds the band first; the deck waits behind it.
797 if (host === null || e.props.hasSurvey || e.surface !== 'terminal') {
798 return next(e)
799 }
800
801 const table = $.ui.resolve(e)
802
803 // With no deck open, a hidden pane leaves one row and a button to bring it back: the pane is
804 // always one press away, on a terminal too narrow to open it unasked as well as on a wide one.
805 if (!state.deck.isOpen) {
806 const snapshot = state.snapshot
807
808 if (state.options.panel === 'off' || state.pane.isOpen || snapshot === null || !snapshot.hasAnalysis) {
809 return next(e)
810 }
811
812 return showBar(
813 { Box: table.Box, Text: table.Text, Button: table.Button },
814 oneLineOf(snapshot),
815 e.props.bodyColumns,
816 () => {
817 void openPane(host)
818 .then(result => {
819 if (!result.isPlaced) {
820 host.toast(`The pane could not be shown: ${result.reason}`, 8000)
821 }
822 })
823 .catch(() => undefined)
824 },
825 )
826 }
827
828 return deckView(
829 { Box: table.Box, Text: table.Text, Button: table.Button, Code: table.Code },
830 state.deck,
831 e.props.bodyColumns,
832 e.props.maxRows,
833 deckActionsOf(host),
834 )
835 })
836
837 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
838 const host = state.host
839
840 if (host === null || (e.requestId !== PANE_ID && e.requestId !== SIGN_ID)) {
841 return next(e)
842 }
843
844 const table = $.ui.resolve(e)
845 const kit: Kit = {
846 Box: table.Box,
847 Text: table.Text,
848 Button: table.Button,
849 ...('Raster' in table && { Raster: table.Raster }),
850 ...('Code' in table && { Code: table.Code }),
851 ...('Input' in table && { Input: table.Input }),
852 ...('Select' in table && { Select: table.Select }),
853 }
854
855 const columns = Math.max(24, wholeOf(e.props.bodyColumns) - 1)
856
857 if (e.requestId === SIGN_ID) {
858 return signView(
859 kit,
860 state.sign,
861 state.snapshot?.system ?? '',
862 state.snapshot?.brief?.openQuestions ?? 0,
863 columns,
864 signActionsOf(host),
865 )
866 }
867
868 // A squeezed pane (a dialog or another panel has its rows) can report a body of no rows at all.
869 const rows = wholeOf(e.props.scroll.bodyRows)
870
871 state.pane.isOpen = true
872 state.pane.isWaiting = false
873 state.pane.bodyColumns = columns
874 state.pane.bodyRows = rows
875 state.pane.placement = e.props.placement
876
877 const snapshot = state.snapshot
878
879 const plan = planOf(rows, e.props.placement, {
880 phases: snapshot?.brief?.phases.length ?? 0,
881 modules: snapshot?.modules.length ?? 0,
882 attention:
883 (snapshot?.attention.length ?? 0) +
884 [...state.fleet.signatures.values()].filter(signature => signature.agents.size >= 3).length,
885 hasMap: snapshot !== null && snapshot.estate !== null && kit.Raster !== undefined,
886 headerRows: headerRowsOf(snapshot, columns),
887 nextRows: nextRowsOf(snapshot, state, columns),
888 legendRows: legendRowsOf(snapshot, columns),
889 })
890
891 let estate: { cells: string; columns: number; rows: number; tiles: State['estate'] extends infer T ? (T extends { tiles: infer U } ? U : never) : never } | null = null
892
893 // The plan sheds the map first when the body is too short; a Raster of no rows is refused.
894 if (snapshot !== null && snapshot.estate !== null && kit.Raster !== undefined && plan.estate >= 1 && columns >= 1) {
895 const mapRows = Math.min(256, plan.estate)
896 const held = state.estate
897
898 const tiles =
899 held !== null && held.columns === columns && held.rows === mapRows && held.tiles.length > 0
900 ? held.tiles
901 : tilesOf(snapshot, columns, mapRows)
902
903 state.estate = { tiles, columns, rows: mapRows, isMounted: true }
904
905 const painted = paint(tiles, columns, mapRows, state.touches, nowMs())
906
907 estate = { cells: painted.cells, columns, rows: mapRows, tiles }
908
909 if (painted.isAnimating) {
910 animate(host)
911 }
912 } else {
913 // No map drawn: nothing for the frame timer to repaint.
914 state.estate = null
915 }
916
917 return paneView(
918 kit,
919 state,
920 { columns, rows, placement: e.props.placement, nowMs: nowMs(), estate, plan },
921 paneActionsOf(host),
922 )
923 })
924
925 on('ui.close', async ($, e, next) => {
926 const result = await next(e)
927
928 if (result.deny !== undefined) {
929 return result
930 }
931
932 if (e.id === PANE_ID) {
933 state.pane.isOpen = false
934 state.pane.isWaiting = false
935 state.pane.isClosedByPerson = e.origin.kind === 'person' || state.pane.isClosedByPerson
936
937 if (state.estate !== null) {
938 state.estate.isMounted = false
939 }
940 } else if (e.id === SIGN_ID) {
941 state.sign.isOpen = false
942 }
943
944 return result
945 })
946
947 // -------------------------------------------------------------- commands
948
949 on('command.run', { command: 'modernize-panel' }, async ($, e, next) => {
950 const host = state.host
951
952 if (host === null) {
953 return next(e)
954 }
955
956 state.viewport.isFullscreen = e.presentation.isFullscreen
957 state.viewport.columns = e.presentation.columns
958
959 const arg = e.args.trim().toLowerCase()
960
961 if (arg === 'json') {
962 await refresh(host)
963
964 const snapshot = state.snapshot
965
966 return {
967 text:
968 snapshot === null
969 ? 'no system under analysis/'
970 : `\`\`\`json\n${JSON.stringify(
971 {
972 system: snapshot.system,
973 stages: snapshot.stages.map(stage => ({ key: stage.key, done: stage.isDone, detail: stage.detail })),
974 brief: snapshot.brief && {
975 target: snapshot.brief.target,
976 approval: snapshot.brief.approval,
977 phases: snapshot.brief.phases.map(phase => ({
978 number: phase.number,
979 title: phase.title,
980 size: phase.size,
981 modules: phase.modules,
982 criteria: phase.criteria.length,
983 ticked: phase.criteria.filter(criterion => criterion.isTicked).length,
984 })),
985 },
986 modules: snapshot.modules.map(module => ({
987 dir: module.dir,
988 state: module.state,
989 tests: module.tests,
990 reviewDate: module.reviewDate,
991 })),
992 proof:
993 snapshot.verification === null
994 ? null
995 : {
996 overall: snapshot.verification.overall,
997 modules: [...snapshot.proofs].map(([name, proof]) => ({ name, state: proof.state, verdict: proof.verdict ?? null, reason: proof.reason })),
998 },
999 totals: snapshot.totals,
1000 percent: snapshot.percent,
1001 attention: snapshot.attention,
1002 next: snapshot.next,
1003 },
1004 null,
1005 2,
1006 )}\n\`\`\``,
1007 }
1008 }
1009
1010 const wantsClose = arg === 'close' || (arg === '' && state.pane.isOpen)
1011
1012 if (wantsClose) {
1013 state.pane.isOpen = false
1014 state.pane.isWaiting = false
1015 state.pane.isClosedByPerson = true
1016 await host.closePane({ id: PANE_ID }).catch(() => undefined)
1017
1018 return { text: 'Modernization pane hidden' }
1019 }
1020
1021 const opened = await openPane(host)
1022
1023 return { text: opened.isPlaced ? 'Modernization pane shown' : `The pane could not be shown: ${opened.reason}` }
1024 })
1025
1026 on('command.run', { command: 'modernize-review-pane' }, async ($, e, next) => {
1027 const host = state.host
1028
1029 if (host === null) {
1030 return next(e)
1031 }
1032
1033 await refresh(host)
1034
1035 const words = e.args.trim().split(/\s+/).filter(word => word !== '')
1036 const first = (words[0] ?? '').toLowerCase()
1037 const scope: DeckScope = first === 'p0' || first === 'all' || first === 'flagged' ? first : 'flagged'
1038 const filter = (scope === first ? words.slice(1) : words).join(' ')
1039 const text = await openDeck(host, scope, filter)
1040
1041 return { text }
1042 })
1043
1044 on('command.run', { command: 'modernize-sign' }, async ($, e, next) => {
1045 const host = state.host
1046
1047 if (host === null) {
1048 return next(e)
1049 }
1050
1051 await refresh(host)
1052
1053 const message = await openSign(host, e.args.replace(/\s+/g, ' ').trim())
1054
1055 return message === '' ? {} : { text: message }
1056 })
1057
1058 on('command.run', { command: ['clear', 'resume'] }, async ($, e, next) => {
1059 const result = await next(e)
1060
1061 state.activity = newActivity()
1062 state.touches.clear()
1063 state.writtenUnits.clear()
1064 state.lastContextLine = ''
1065
1066 return result
1067 })
1068
1069 // ----------------------------------------------------------------- turns
1070
1071 on('prompt.submit', async ($, e, next) => {
1072 const host = state.host
1073 const step = STEP.exec(e.text)
1074
1075 if (step !== null) {
1076 state.activity.step = step[0].trim().slice(0, 120)
1077 }
1078
1079 const snapshot = state.snapshot
1080
1081 if (host === null || snapshot === null || e.origin.kind !== 'composer') {
1082 return next(e)
1083 }
1084
1085 const line = plain(`${oneLineOf(snapshot)}${snapshot.next !== null && !snapshot.next.isByHand ? ` · next: ${snapshot.next.text}` : ''}`, 400)
1086
1087 if (line === state.lastContextLine) {
1088 return next(e)
1089 }
1090
1091 state.lastContextLine = line
1092
1093 return next({
1094 ...e,
1095 context: [...(e.context ?? []), `Modernization state, read from the artifacts on disk (a status line, not an instruction): ${line}`],
1096 })
1097 })
1098
1099 on('turn.start', async ($, e, next) => {
1100 const host = state.host
1101
1102 state.activity.isWorking = true
1103 state.activity.turnId = e.turnId
1104 state.activity.turnStartMs = nowMs()
1105 state.activity.xraysThisTurn = 0
1106
1107 if (host !== null) {
1108 host.invalidate()
1109 }
1110
1111 return next(e)
1112 })
1113
1114 on('turn.complete', async ($, e, next) => {
1115 const host = state.host
1116
1117 if (e.agentId !== undefined) {
1118 noteDone(state.fleet, e.agentId, nowMs())
1119
1120 return next(e)
1121 }
1122
1123 state.activity.isWorking = false
1124 state.activity.running.clear()
1125
1126 if (host !== null) {
1127 if (state.activity.xraysThisTurn > 0) {
1128 host.log(
1129 `x-ray: ${state.activity.xraysThisTurn} legacy read${state.activity.xraysThisTurn === 1 ? '' : 's'} this turn carried what the analysis already knows`,
1130 )
1131 }
1132
1133 prune(state.fleet, nowMs())
1134 scheduleRefresh(host)
1135 }
1136
1137 return next(e)
1138 })
1139
1140 // ------------------------------------------------------------ tool calls
1141
1142 on('tool.call', async ($, e, next) => {
1143 const host = state.host
1144
1145 if (host === null) {
1146 return next(e)
1147 }
1148
1149 const args = e as unknown as Readonly<Record<string, unknown>>
1150 const tool = e.tool
1151 const agentId = e.agentId
1152 const startMs = nowMs()
1153 const subject = subjectOf(tool, args)
1154 const snapshot = state.snapshot
1155 const touched = nodesTouched(tool, args)
1156
1157 for (const entry of touched) {
1158 touch(host, entry.id, entry.kind)
1159 }
1160
1161 if (agentId !== undefined) {
1162 const unit = touched[0] !== undefined ? snapshot?.estate?.units.find(candidate => candidate.id === touched[0]?.id)?.name : undefined
1163
1164 noteCall(state.fleet, agentId, tool, subject, startMs, unit)
1165 }
1166
1167 state.activity.running.set(e.tool_use_id, {
1168 id: e.tool_use_id,
1169 tool,
1170 subject,
1171 startMs,
1172 ...(agentId !== undefined && { agentId }),
1173 })
1174
1175 const path = typeof args.file_path === 'string' ? args.file_path : typeof args.notebook_path === 'string' ? args.notebook_path : undefined
1176 const rel = path !== undefined ? relTo(state.cwd, path) : null
1177
1178 let result: ResultOf['tool.call'] | undefined
1179 let note: string | undefined
1180
1181 try {
1182 result = await next(e)
1183
1184 if (result.deny !== undefined) {
1185 return result
1186 }
1187
1188 const extra: string[] = []
1189 const isError = result.isError === true
1190 const text = typeof result.text === 'string' ? result.text : ''
1191
1192 if (isError && agentId !== undefined) {
1193 // What the call was aimed at is half of the cause: the path as the agent gave it, else the command's head.
1194 const missing = missingPathOf(text)
1195 const example = rel ?? (missing !== undefined ? (relTo(state.cwd, missing) ?? missing) : subject)
1196 const signature = noteFailure(state.fleet, agentId, text, nowMs(), example)
1197
1198 if (signature !== null) {
1199 const line = lineOf(signature)
1200hooks/fleet/fleet.ts 351 lines1/**
2 * The fleet: every agent loop this session has seen a tool call from, kept
3 * from the events themselves. A workflow's agents carry ids no `$.agent.list()`
4 * names, so the registry is built from `tool.call` and `turn.complete` alone.
5 *
6 * It also watches for one failure repeating across agents: three agents
7 * hitting the same error is a fact about the playbook, not about the agents.
8 */
9
10export type AgentRow = {
11 id: string
12 firstMs: number
13 lastMs: number
14 calls: number
15 errors: number
16 /** `Read`, `Bash`, and so on. */
17 lastTool: string
18 /** A short rendering of the last call's subject: a file's name, a command's head. */
19 lastSubject: string
20 isDone: boolean
21 /** The unit of work it appears to be on: the legacy module its calls touch most. */
22 unit?: string
23}
24
25export type Signature = {
26 /** The normalized failure text the agents share. */
27 text: string
28 agents: Set<string>
29 firstMs: number
30 lastMs: number
31 /** How many agents shared it when it was last called out; 0 while it has not been. */
32 announcedAt: number
33 /** When it was last called out. */
34 announcedMs: number
35 /** What a few of the failing calls were aimed at: the part of the cause the text alone leaves out. */
36 examples: string[]
37}
38
39export type Fleet = {
40 agents: Map<string, AgentRow>
41 signatures: Map<string, Signature>
42 /** Units each agent touched, to name what it is working on. */
43 unitHits: Map<string, Map<string, number>>
44 /** Counted as they happen, so pruning old rows does not shrink the totals. */
45 seen: number
46 ended: number
47 calls: number
48 errors: number
49}
50
51/** An agent with no call for this long reads as finished or stalled, in milliseconds. */
52export const IDLE_MS = 90_000
53
54/** How many agents must share a failure before it counts as shared at all. */
55export const SIGNATURE_THRESHOLD = 3
56
57/** A shared failure is called out again once it has spread this many times further. */
58export const ESCALATE_FACTOR = 5
59
60/** And no sooner than this after the last time, in milliseconds: a fan-out fails all at once. */
61export const ESCALATE_QUIET_MS = 60_000
62
63/**
64 * How many agents must share a failure before it is called out in the
65 * transcript. Three of five agents is a pattern; three of five hundred is not,
66 * so the bar rises with the fleet. The pane lists it from three either way.
67 */
68export const announceThresholdOf = (fleet: Fleet): number =>
69 Math.max(SIGNATURE_THRESHOLD, Math.ceil(fleet.seen * 0.05))
70
71/** Failures further apart than this are not the same incident, in milliseconds. */
72export const SIGNATURE_WINDOW_MS = 15 * 60_000
73
74export const newFleet = (): Fleet => ({
75 agents: new Map(),
76 signatures: new Map(),
77 unitHits: new Map(),
78 seen: 0,
79 ended: 0,
80 calls: 0,
81 errors: 0,
82})
83
84/** A short subject for a call, from the arguments tools commonly carry. */
85export function subjectOf(tool: string, args: Readonly<Record<string, unknown>>): string {
86 const str = (key: string) => (typeof args[key] === 'string' ? (args[key] as string) : undefined)
87 const path = str('file_path') ?? str('path') ?? str('notebook_path')
88
89 if (path !== undefined) {
90 return path.split('/').slice(-1)[0] ?? path
91 }
92
93 const command = str('command')
94
95 if (command !== undefined) {
96 // What runs, not where: a leading `cd …`, `VAR=…;` or `export …` says nothing at a glance.
97 const lead = /^(?:\s*(?:cd\s+(?:"[^"]*"|'[^']*'|\S+)(?:\s+\d?>{1,2}\s*\S+)*|(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S*))\s*(?:&&|;)\s*)+/
98
99 return command.replace(/\s+/g, ' ').replace(lead, '').slice(0, 48)
100 }
101
102 return (str('pattern') ?? str('description') ?? str('query') ?? str('prompt') ?? '')
103 .replace(/\s+/g, ' ')
104 .slice(0, 48)
105}
106
107/** Records the start of a call by `agentId`. */
108export function noteCall(
109 fleet: Fleet,
110 agentId: string,
111 tool: string,
112 subject: string,
113 nowMs: number,
114 unit?: string,
115): AgentRow {
116 if (!fleet.agents.has(agentId)) {
117 fleet.seen += 1
118 }
119
120 fleet.calls += 1
121
122 const row = fleet.agents.get(agentId) ?? {
123 id: agentId,
124 firstMs: nowMs,
125 lastMs: nowMs,
126 calls: 0,
127 errors: 0,
128 lastTool: tool,
129 lastSubject: subject,
130 isDone: false,
131 }
132
133 const next: AgentRow = {
134 ...row,
135 lastMs: nowMs,
136 calls: row.calls + 1,
137 lastTool: tool,
138 lastSubject: subject,
139 isDone: false,
140 }
141
142 if (unit !== undefined) {
143 const hits = fleet.unitHits.get(agentId) ?? new Map<string, number>()
144
145 hits.set(unit, (hits.get(unit) ?? 0) + 1)
146 fleet.unitHits.set(agentId, hits)
147
148 const top = [...hits.entries()].sort((a, b) => b[1] - a[1])[0]
149
150 if (top !== undefined) {
151 next.unit = top[0]
152 }
153 }
154
155 fleet.agents.set(agentId, next)
156
157 return next
158}
159
160const ERRORISH = /error|fail|exception|cannot|not found|denied|refused|blocked|unresolved|undefined|no such|does not exist|does not match/i
161
162/** A path named by a "No such file" line, as the command printed it. */
163export function missingPathOf(text: string): string | undefined {
164 return /([^\s:'"]*\/[^\s:'"]+): (?:open: )?No such file or directory/.exec(text)?.[1]
165}
166
167/**
168 * A failure text with what varies between agents taken out: paths, numbers,
169 * hex, quoted values, timestamps. Two agents hitting one cause then match.
170 *
171 * Empty when the text names no cause worth matching on: a bare exit code (a
172 * search that found nothing), or a malformed tool call, which is the model's
173 * slip and not something the agents share.
174 */
175export function normalizeFailure(text: string): string {
176 if (/InputValidationError|could not be parsed as JSON/.test(text)) {
177 return ''
178 }
179
180 const line =
181 text
182 .split('\n')
183 .map(part => part.trim().replace(/^Exit code \d+\s*/i, ''))
184 .find(part => part !== '' && ERRORISH.test(part)) ?? ''
185
186 if (line === '') {
187 return ''
188 }
189
190 // Families: one cause that words itself differently per command or per call.
191 if (/file does not exist|no such file or directory|path does not exist/i.test(line)) {
192 return 'a path that does not exist'
193 }
194
195 const denied = /^Permission to use (\w+) with command\s+(?:cd\s+\S+\s*(?:&&|;)\s*)?([\w./-]+)[\s\S]*has been denied/.exec(line)
196
197 if (denied !== null) {
198 return `Permission to use ${denied[1]} with command ${denied[2]} … was denied`
199 }
200
201 const blocked = /^([\w./-]+) to .+ was blocked by a deny rule/.exec(line)
202
203 if (blocked !== null) {
204 return `${blocked[1]} was blocked by a deny rule`
205 }
206
207 if (/does not match required schema/i.test(line)) {
208 // Which property broke which constraint, once each: `/rules/13/category` and `/rules/27/category` are one fact.
209 const clauses = new Set<string>()
210
211 for (const match of line.matchAll(/((?:\/[\w-]+)+): (must [a-z ]+?)(?=:|,|$)/g)) {
212 clauses.add(`${(match[1] ?? '').replace(/\/\d+(?=\/|$)/g, '/<n>')}: ${(match[2] ?? '').trim()}`)
213 }
214
215 return `Output does not match required schema: ${[...clauses].join('; ')}`.slice(0, 140)
216 }
217
218 if (/\(eval\):\d+: =+\S* not found/.test(line)) {
219 return '(eval):<n>: ==… not found'
220 }
221
222 const key = line
223 .replace(/\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?Z?\b/g, '<time>')
224 .replace(/(?:[A-Za-z]:)?(?:\/[\w.@~+-]+){2,}\/?/g, '<path>')
225 .replace(/\b0x[0-9a-fA-F]+\b/g, '<hex>')
226 .replace(/\b[0-9a-f]{12,}\b/g, '<id>')
227 .replace(/(["'`]).{1,60}?\1/g, '<q>')
228 .replace(/\b\d+(?:\.\d+)?\b/g, '<n>')
229 .replace(/\s+/g, ' ')
230 .trim()
231 .slice(0, 140)
232
233 // What is left must still say something: `<n> <q>` and the like match everything.
234 return key.replace(/<\w+>|[^A-Za-z]+/g, ' ').trim().split(/\s+/).filter(word => word.length > 2).length >= 2 ? key : ''
235}
236
237/**
238 * Records a failed call. Returns the signature when this failure is one to
239 * call out now: the first time enough agents share it, and again each time it
240 * has spread `ESCALATE_FACTOR` times further. `example` is what the failing
241 * call was aimed at (a path, a command's head).
242 */
243export function noteFailure(
244 fleet: Fleet,
245 agentId: string,
246 errorText: string,
247 nowMs: number,
248 example?: string,
249): Signature | null {
250 const row = fleet.agents.get(agentId)
251
252 fleet.errors += 1
253
254 if (row !== undefined) {
255 fleet.agents.set(agentId, { ...row, errors: row.errors + 1 })
256 }
257
258 const key = normalizeFailure(errorText)
259
260 if (key.length < 12) {
261 return null
262 }
263
264 const held = fleet.signatures.get(key)
265
266 const signature: Signature =
267 held !== undefined && nowMs - held.lastMs <= SIGNATURE_WINDOW_MS
268 ? held
269 : { text: key, agents: new Set(), firstMs: nowMs, lastMs: nowMs, announcedAt: 0, announcedMs: 0, examples: [] }
270
271 signature.agents.add(agentId)
272 signature.lastMs = nowMs
273
274 if (example !== undefined && example !== '' && signature.examples.length < 3 && !signature.examples.includes(example)) {
275 signature.examples.push(example)
276 }
277
278 fleet.signatures.set(key, signature)
279
280 const size = signature.agents.size
281
282 const isDue =
283 signature.announcedAt === 0
284 ? size >= announceThresholdOf(fleet)
285 : size >= signature.announcedAt * ESCALATE_FACTOR && nowMs - signature.announcedMs >= ESCALATE_QUIET_MS
286
287 if (isDue) {
288 signature.announcedAt = size
289 signature.announcedMs = nowMs
290
291 return signature
292 }
293
294 return null
295}
296
297/** A shared failure in one line, with what the failing calls were aimed at. */
298export function lineOf(signature: Signature, form: 'full' | 'short' = 'full'): string {
299 const examples = signature.examples.slice(0, form === 'short' ? 1 : 2).join(', ')
300 const lead = form === 'short' ? `${signature.agents.size} agents: ` : `${signature.agents.size} agents hit the same failure: `
301
302 return `${lead}${signature.text}${examples !== '' ? ` (e.g. ${examples})` : ''}`
303}
304
305/** Marks an agent's loop as ended (its `turn.complete`). */
306export function noteDone(fleet: Fleet, agentId: string, nowMs: number): void {
307 const row = fleet.agents.get(agentId)
308
309 if (row !== undefined) {
310 if (!row.isDone) {
311 fleet.ended += 1
312 }
313
314 fleet.agents.set(agentId, { ...row, isDone: true, lastMs: nowMs })
315 }
316}
317
318/** The fleet in numbers, as the pane's header line shows it. */
319export function tallyOf(fleet: Fleet, nowMs: number) {
320 const rows = [...fleet.agents.values()]
321 const active = rows.filter(row => !row.isDone && nowMs - row.lastMs < IDLE_MS)
322
323 return {
324 total: fleet.seen,
325 active: active.length,
326 done: fleet.ended,
327 stalled: rows.filter(row => !row.isDone && nowMs - row.lastMs >= IDLE_MS).length,
328 calls: fleet.calls,
329 errors: fleet.errors,
330 recent: active.sort((a, b) => b.lastMs - a.lastMs),
331 // The widest-spread first: the pane has room for two or three.
332 shared: [...fleet.signatures.values()]
333 .filter(signature => signature.agents.size >= SIGNATURE_THRESHOLD)
334 .sort((a, b) => b.agents.size - a.agents.size),
335 }
336}
337
338/** Forgets agents that ended long ago, keeping the registry bounded on a long run. */
339export function prune(fleet: Fleet, nowMs: number, keepMs = 30 * 60_000, max = 1500): void {
340 if (fleet.agents.size <= max) {
341 return
342 }
343
344 for (const [id, row] of fleet.agents) {
345 if ((row.isDone || nowMs - row.lastMs > keepMs) && fleet.agents.size > max) {
346 fleet.agents.delete(id)
347 fleet.unitHits.delete(id)
348 }
349 }
350}
351hooks/host.ts 44 lines1import type {
2 CommandSpec,
3 PaneCloseArgs,
4 PaneOpenArgs,
5 PromptFillArgs,
6 PromptSubmitArgs,
7 TimerCall,
8 UiBlitArgs,
9 UiBlitResult,
10} from 'claude-code'
11
12import type { ReaderFs } from './reader/fs'
13
14/**
15 * What `$.ui.open` answers: drawn, or open but held back undrawn (unasked, on a terminal narrower than the engine docks a
16 * pane on) with the reason. A build that answers nothing has drawn it, as far as anyone can tell.
17 */
18export type OpenResult = { isPlaced: boolean; reason?: string } | void
19
20/**
21 * The engine as `session.start` bound it from its `$`: every later hook,
22 * timer and button press reaches the engine through this, so the rest of
23 * the module is plain functions over a small interface a test can stand in for.
24 */
25export type Host = {
26 fs: ReaderFs & { write: (path: string, text: string) => Promise<void> }
27 now: () => Promise<number>
28 after: TimerCall
29 every: TimerCall
30 storeGet: (key: string) => Promise<unknown>
31 storeSet: (key: string, value: unknown) => Promise<void>
32 invalidate: () => void
33 blit: (args: UiBlitArgs) => Promise<UiBlitResult>
34 status: (text: string | undefined) => void
35 toast: (text: string, timeoutMs?: number) => void
36 log: (text: string) => void
37 openPane: (pane: PaneOpenArgs) => Promise<OpenResult>
38 closePane: (pane: PaneCloseArgs) => Promise<void>
39 registerCommand: (spec: CommandSpec) => Promise<unknown>
40 fillPrompt: (input: PromptFillArgs) => Promise<{ isFilled: boolean }>
41 submitPrompt: (input: PromptSubmitArgs) => Promise<unknown>
42 abortTurn: (turnId: string) => Promise<void>
43}
44hooks/map/estate.ts 259 lines1import { estateOfTopology, languageOf, type EstateUnit } from '../reader/estate-model'
2import type { ModuleState } from '../reader/modernized'
3import type { Snapshot } from '../reader/progress'
4import { STATE_WORDS_BY_TRACK, type TrackKey } from '../reader/tracks'
5import { DEFAULT_COLOR, luma, mix, packCells, type Cell } from './raster'
6import { layoutGroups, type Rect } from './treemap'
7
8/**
9 * The estate map: every unit of the legacy system as a tile, sized by its lines of code
10 * (or bytes of source, where there is no map), grouped by domain or directory, coloured by
11 * how far its modernization has come, and lit for a moment when a file beneath it is read or written.
12 */
13
14export type TouchKind = 'read' | 'write'
15
16export type Touch = { atMs: number; kind: TouchKind }
17
18export type TileState = ModuleState | 'untouched'
19
20/** A module's verdict, as the map marks it: a tile's label starts with the mark. */
21export type TileProof = 'proven' | 'partly' | 'not'
22
23export const PROOF_MARKS: Record<TileProof, string> = { proven: '✓', partly: '±', not: '✗' }
24
25export type Tile = {
26 id: string
27 name: string
28 /** What the tile shows of its name: the last path segment, without its extension and without the prefix most tiles share. */
29 label: string
30 domain: string
31 loc: number
32 state: TileState
33 /** What the proof says of the module, once the verify command has checked it. */
34 proof?: TileProof
35 rect: Rect
36 isNext: boolean
37}
38
39export type Estate = {
40 columns: number
41 rows: number
42 tiles: Tile[]
43 /** Base64 for the Raster's `cells`. */
44 cells: string
45 /** True while any tile is still fading from a touch, so another frame is due. */
46 isAnimating: boolean
47}
48
49/** How long a touch stays visible, in milliseconds. */
50export const FLASH_MS = 4000
51
52/**
53 * Mid-tones on purpose: a tile has to show against a dark terminal and a light one, and the map cannot ask which it is.
54 * The labels are drawn in whichever of light or dark ink reads on the tile.
55 */
56export const STATE_COLORS: Record<TileState, number> = {
57 untouched: 0x5b6577,
58 scaffolded: 0x4a6cb0,
59 'tests-written': 0x94741f,
60 'tests-red': 0xb23b3b,
61 'tests-failing': 0xc2683a,
62 'tests-green': 0x2c7a4c,
63 reviewed: 0x2fa568,
64 ported: 0x2b8ea6,
65 switched: 0x3bd184,
66}
67
68/** What a state is called in a track: the same colors, worded for what the work is. */
69export const stateWord = (track: TrackKey, state: TileState): string => STATE_WORDS_BY_TRACK[track][state] ?? state
70
71const FLASH_COLORS: Record<TouchKind, number> = {
72 read: 0x9fd4ff,
73 write: 0xffe27a,
74}
75
76/** A stable small jitter per tile, so neighbours of one state still read as separate tiles. */
77const jitterOf = (id: string): number => {
78 let hash = 2166136261
79
80 for (let index = 0; index < id.length; index += 1) {
81 hash = Math.imul(hash ^ id.charCodeAt(index), 16777619)
82 }
83
84 return ((hash >>> 0) % 1000) / 1000
85}
86
87/** The module ids of the step the snapshot says comes next, when it is a transform. */
88function nextModuleOf(snapshot: Snapshot): string | null {
89 const text = snapshot.next?.text ?? ''
90 const match = /transform\s+\S+\s+(\S+)/.exec(text)
91
92 return snapshot.next?.isByHand === false ? (match?.[1] ?? null) : null
93}
94
95/**
96 * Short names for tiles, which show a few letters: a path is cut to its last segment, a source file's extension goes, and
97 * the prefix most of the units share (`jetty-` in `jetty-server`, `jetty-client`) goes too, so the letters that tell
98 * one tile from the next are the ones drawn.
99 */
100export function labelsOf(names: readonly string[]): string[] {
101 const stems = names.map(name => {
102 const last = name.includes('/') ? (name.split('/').filter(part => part !== '').at(-1) ?? name) : name
103
104 return languageOf(last) !== undefined ? last.replace(/\.[^.]+$/, '') : last
105 })
106
107 const counts = new Map<string, number>()
108
109 for (const stem of stems) {
110 const head = /^[^-_.\s]{2,}[-_.]/.exec(stem)?.[0]
111
112 if (head !== undefined) {
113 counts.set(head, (counts.get(head) ?? 0) + 1)
114 }
115 }
116
117 const [top, count] = [...counts.entries()].sort((a, b) => b[1] - a[1])[0] ?? ['', 0]
118 const isShared = top !== '' && count >= 4 && count >= names.length * 0.4
119
120 return stems.map(stem => (isShared && stem.startsWith(top) && stem.length > top.length ? stem.slice(top.length) : stem))
121}
122
123/** The tile's proof, when its module has a fresh verdict; nothing for a module nobody has checked or that changed since. */
124function proofMarkOf(snapshot: Snapshot, unitId: string): { proof: TileProof } | Record<string, never> {
125 const module = snapshot.byNode.get(unitId)
126
127 if (module === undefined || snapshot.track === 'uplift') {
128 return {}
129 }
130
131 const state = snapshot.proofs.get(module.dir.toLowerCase())?.state
132
133 return state === 'proven' || state === 'partly' || state === 'not' ? { proof: state } : {}
134}
135
136/** How many tiles carry each proof, in the order a legend draws them. */
137export function proofCountsOf(tiles: readonly Tile[]): { kind: TileProof; count: number }[] {
138 return (['proven', 'partly', 'not'] as const)
139 .map(kind => ({ kind, count: tiles.filter(tile => tile.proof === kind).length }))
140 .filter(entry => entry.count > 0)
141}
142
143/** Lays the snapshot's estate out in `columns` by `rows` cells. */
144export function tilesOf(snapshot: Snapshot, columns: number, rows: number): Tile[] {
145 const estate = snapshot.estate ?? (snapshot.topology !== null ? estateOfTopology(snapshot.topology, 0) : null)
146
147 if (estate === null || columns < 4 || rows < 2) {
148 return []
149 }
150
151 const next = nextModuleOf(snapshot)
152 const groups = new Map<string, EstateUnit[]>()
153 const short = labelsOf(estate.units.map(unit => unit.name))
154 const labels = new Map(estate.units.map((unit, index) => [unit.id, short[index] ?? unit.name] as const))
155
156 for (const unit of estate.units) {
157 const members = groups.get(unit.group) ?? []
158
159 members.push(unit)
160 groups.set(unit.group, members)
161 }
162
163 const placed = layoutGroups(
164 [...groups.entries()].map(([name, members]) => ({
165 name,
166 items: members.map(unit => ({ item: unit, size: Math.max(1, unit.size) })),
167 })),
168 { x: 0, y: 0, w: columns, h: rows },
169 )
170
171 return placed.flatMap(group =>
172 group.items
173 .filter(entry => entry.rect.w > 0 && entry.rect.h > 0)
174 .map(entry => ({
175 id: entry.item.id,
176 name: entry.item.name,
177 label: labels.get(entry.item.id) ?? entry.item.name,
178 domain: group.name,
179 loc: entry.item.size,
180 state: snapshot.byNode.get(entry.item.id)?.state ?? ('untouched' as const),
181 ...proofMarkOf(snapshot, entry.item.id),
182 rect: entry.rect,
183 isNext: entry.item.id === next,
184 })),
185 )
186}
187
188/** Paints the tiles into cells and packs them for the Raster. */
189export function paint(
190 tiles: readonly Tile[],
191 columns: number,
192 rows: number,
193 touches: ReadonlyMap<string, Touch>,
194 nowMs: number,
195): Estate {
196 const blank: Cell = { glyph: ' ', fg: DEFAULT_COLOR, bg: DEFAULT_COLOR }
197 const cells: Cell[] = Array.from({ length: columns * rows }, () => blank)
198 let isAnimating = false
199
200 for (const tile of tiles) {
201 const base = mix(STATE_COLORS[tile.state], 0xffffff, (jitterOf(tile.id) - 0.5) * 0.14 + 0.02)
202 const touch = touches.get(tile.id)
203 const age = touch !== undefined ? nowMs - touch.atMs : Infinity
204 const heat = age < FLASH_MS ? 1 - age / FLASH_MS : 0
205
206 if (heat > 0) {
207 isAnimating = true
208 }
209
210 const body = heat > 0 && touch !== undefined ? mix(base, FLASH_COLORS[touch.kind], heat * 0.85) : base
211 const edge = mix(body, 0x000000, 0.32)
212 const ink = luma(body) > 140 ? 0x101418 : 0xf2f5f8
213 const { x, y, w, h } = tile.rect
214 const hasBevel = w >= 3 && h >= 2
215 const label = w >= 5 && tile.proof !== undefined ? `${tile.isNext ? '▸' : ''}${PROOF_MARKS[tile.proof]}${tile.label}` : w >= 4 ? (tile.isNext ? '▸' : '') + tile.label : w >= 2 && tile.isNext ? '▸' : ''
216 const shown = label.slice(0, Math.max(0, w - (hasBevel ? 1 : 0)))
217
218 for (let row = 0; row < h; row += 1) {
219 for (let col = 0; col < w; col += 1) {
220 const cx = x + col
221 const cy = y + row
222
223 if (cx >= columns || cy >= rows) {
224 continue
225 }
226
227 const isEdge = hasBevel && (col === w - 1 || row === h - 1)
228 const glyph = row === 0 && col < shown.length ? (shown[col] ?? ' ') : ' '
229
230 cells[cy * columns + cx] = { glyph, fg: ink, bg: isEdge ? edge : body }
231 }
232 }
233 }
234
235 return { columns, rows, tiles: [...tiles], cells: packCells(cells), isAnimating }
236}
237
238/** How many modules sit in each state, in the order the legend draws them. */
239export function countsOf(tiles: readonly Tile[]): { state: TileState; count: number }[] {
240 const order: TileState[] = [
241 'untouched',
242 'scaffolded',
243 'tests-written',
244 'tests-red',
245 'tests-failing',
246 'tests-green',
247 'reviewed',
248 'ported',
249 'switched',
250 ]
251
252 return order
253 .map(state => ({ state, count: tiles.filter(tile => tile.state === state).length }))
254 .filter(entry => entry.count > 0)
255}
256
257/** `0x00RRGGBB` as the `#rrggbb` a `Text`'s `color` takes. */
258export const hexOf = (color: number): string => `#${(color & 0xffffff).toString(16).padStart(6, '0')}`
259hooks/paths.ts 95 lines1/**
2 * Path helpers. A hooks module has no `path` module: these are the few
3 * string operations the hooks need, all over forward-slash paths.
4 */
5
6/** Forward slashes, no doubled separators, no trailing slash (root stays `/`). */
7export function norm(path: string): string {
8 const clean = path.replace(/\\/g, '/').replace(/\/{2,}/g, '/')
9
10 return clean.length > 1 && clean.endsWith('/') ? clean.slice(0, -1) : clean
11}
12
13/** Resolves `.` and `..` segments lexically; absolute stays absolute. */
14export function resolveDots(path: string): string {
15 const isAbsolute = path.startsWith('/')
16 const out: string[] = []
17
18 for (const part of norm(path).split('/')) {
19 if (part === '' || part === '.') {
20 continue
21 }
22
23 if (part === '..') {
24 if (out.length > 0 && out.at(-1) !== '..') {
25 out.pop()
26 } else if (!isAbsolute) {
27 out.push('..')
28 }
29
30 continue
31 }
32
33 out.push(part)
34 }
35
36 return (isAbsolute ? '/' : '') + out.join('/')
37}
38
39/** `path` made absolute against `cwd` when it is relative. */
40export function absOf(cwd: string, path: string): string {
41 return resolveDots(path.startsWith('/') ? path : `${norm(cwd)}/${path}`)
42}
43
44/**
45 * `path` relative to `cwd` when it lies inside it, else null. Both may be
46 * given with or without the macOS `/private` prefix the engine sometimes adds.
47 */
48export function relTo(cwd: string, path: string): string | null {
49 const strip = (p: string) => p.replace(/^\/private(?=\/(?:tmp|var)\/)/, '')
50 const base = strip(norm(cwd))
51 const full = strip(absOf(cwd, path))
52
53 if (full === base) {
54 return ''
55 }
56
57 return full.startsWith(`${base}/`) ? full.slice(base.length + 1) : null
58}
59
60/** True when relative path `rel` is `dir` or lies beneath it. */
61export function isUnder(rel: string, dir: string): boolean {
62 const d = norm(dir)
63
64 return rel === d || rel.startsWith(`${d}/`)
65}
66
67/** The last segment. */
68export function baseName(path: string): string {
69 const parts = norm(path).split('/')
70
71 return parts.at(-1) ?? ''
72}
73
74/** The last segment without its final extension. */
75export function stemOf(path: string): string {
76 const base = baseName(path)
77 const dot = base.lastIndexOf('.')
78
79 return dot > 0 ? base.slice(0, dot) : base
80}
81
82/** Everything but the last segment (`''` for a bare name). */
83export function dirName(path: string): string {
84 const parts = norm(path).split('/')
85
86 parts.pop()
87
88 return parts.join('/')
89}
90
91/** Joins segments with single slashes. */
92export function join(...parts: string[]): string {
93 return norm(parts.filter(part => part !== '').join('/'))
94}
95hooks/reader/estate-model.ts 214 lines1import { baseName, dirName, norm, stemOf } from '../paths'
2import { nodeOfFile, type Topology } from './topology'
3
4/**
5 * The estate: the units a system's code is divided into, whatever its language.
6 * They come from the map's `topology.json` when there is one and from the legacy
7 * tree itself when there is not, so the pane has a map from the first minute of
8 * a session in any workspace.
9 */
10
11export type EstateUnit = {
12 id: string
13 name: string
14 /** What the tile sits under: a domain, or the directory that holds the unit. */
15 group: string
16 /** Lines when the map gave them, bytes of source when the unit was read off the tree. */
17 size: number
18 /** The unit as a directory, relative to `legacy/<system>/` (`''` is the system root). */
19 dir?: string
20 /** The unit as one file, relative to `legacy/<system>/`. */
21 file?: string
22}
23
24/** What one unit is: a module the map named, a source file, a build module (a directory with its own build file), or a directory. */
25export type Granularity = 'module' | 'file' | 'build module' | 'directory'
26
27export type EstateModel = {
28 source: 'map' | 'tree'
29 /** What `size` counts. */
30 measure: 'lines' | 'bytes'
31 /** What a unit is, for the pane's words. */
32 granularity: Granularity
33 units: EstateUnit[]
34 /** Source files seen under the system root. */
35 files: number
36 /** The languages the files are written in, largest first, as shares of the source bytes. */
37 languages: { name: string; share: number }[]
38 /** True when the walk stopped at its budget: the sizes are a floor. */
39 isPartial: boolean
40 /** Every source or configuration file the walk saw, relative to the system root, with its size in bytes. Kept to compare a working copy with the tree it was copied from; absent for an estate the map gave. */
41 fileSizes?: ReadonlyMap<string, number>
42}
43
44/** A language's name by file extension: the common ones, and the mainframe and scripting ones legacy work meets. */
45const LANGUAGES: Record<string, string> = {
46 java: 'Java', kt: 'Kotlin', kts: 'Kotlin', scala: 'Scala', groovy: 'Groovy', clj: 'Clojure',
47 cs: 'C#', vb: 'VB.NET', fs: 'F#', aspx: 'ASP.NET', ascx: 'ASP.NET', cshtml: 'Razor', asp: 'Classic ASP',
48 py: 'Python', rb: 'Ruby', php: 'PHP', pl: 'Perl', pm: 'Perl', lua: 'Lua', r: 'R',
49 js: 'JavaScript', mjs: 'JavaScript', cjs: 'JavaScript', jsx: 'JavaScript', ts: 'TypeScript', tsx: 'TypeScript',
50 go: 'Go', rs: 'Rust', swift: 'Swift', dart: 'Dart', ex: 'Elixir', exs: 'Elixir', erl: 'Erlang', hs: 'Haskell',
51 c: 'C', h: 'C', cc: 'C++', cpp: 'C++', cxx: 'C++', hpp: 'C++', m: 'Objective-C',
52 cbl: 'COBOL', cob: 'COBOL', cpy: 'COBOL', cobol: 'COBOL', pco: 'COBOL', jcl: 'JCL', bms: 'BMS', prc: 'JCL',
53 rpg: 'RPG', rpgle: 'RPG', sqlrpgle: 'RPG', clle: 'CL', pli: 'PL/I', pl1: 'PL/I', nat: 'Natural', asm: 'Assembler',
54 pas: 'Pascal', dpr: 'Delphi', bas: 'Basic', frm: 'VB6', cls: 'VB6', vbs: 'VBScript',
55 sql: 'SQL', sh: 'Shell', bash: 'Shell', ps1: 'PowerShell', bat: 'Batch', cmd: 'Batch',
56 jsp: 'JSP', xhtml: 'JSF', cfm: 'ColdFusion', tcl: 'Tcl', f: 'Fortran', f90: 'Fortran', for: 'Fortran',
57}
58
59/** Extensions that are not source: assets, binaries, archives, lock files, data and prose. */
60const NOT_SOURCE = new Set([
61 'png', 'jpg', 'jpeg', 'gif', 'svg', 'ico', 'bmp', 'webp', 'pdf', 'zip', 'gz', 'tgz', 'tar', 'jar', 'war', 'ear',
62 'class', 'dll', 'exe', 'so', 'dylib', 'a', 'o', 'lib', 'pyc', 'woff', 'woff2', 'ttf', 'eot', 'otf', 'mp3', 'mp4',
63 'mov', 'avi', 'wav', 'ogg', 'bin', 'dat', 'db', 'sqlite', 'log', 'lock', 'md', 'txt', 'rst', 'adoc', 'csv', 'tsv',
64 'map', 'tmp', 'bak', 'orig', 'swp', 'ds_store', 'jks', 'p12', 'pem', 'crt', 'key', 'der', 'iso', 'img', 'rpm', 'deb',
65])
66
67/** Configuration and markup a build reads: part of the system, but never the bulk of its code. */
68const CONFIG_EXT = new Set([
69 'xml', 'json', 'yaml', 'yml', 'toml', 'properties', 'gradle', 'cfg', 'conf', 'ini', 'html', 'htm', 'css', 'scss',
70 'less', 'vue', 'svelte', 'xsl', 'xslt', 'xsd', 'wsdl', 'proto', 'graphql', 'ftl', 'vm', 'tpl', 'twig', 'erb',
71 'jspx', 'tag', 'tld', 'dtd', 'mustache', 'hbs',
72])
73
74const CONFIG_NAMES = new Set(['Makefile', 'Dockerfile', 'Jenkinsfile', 'Rakefile', 'Gemfile', 'Vagrantfile', 'Procfile'])
75
76const extOf = (name: string): string => {
77 const dot = name.lastIndexOf('.')
78
79 return dot > 0 && dot < name.length - 1 ? name.slice(dot + 1).toLowerCase() : ''
80}
81
82/** A single file counts for at most this much: one binary blob or one generated file must not be a system's bulk. */
83const CAP_SOURCE = 400_000
84const CAP_CONFIG = 100_000
85
86/**
87 * What a file adds to the estate's size: its bytes (capped) when it is source or configuration, nothing when it is
88 * an asset, an archive, a document or a file of no known kind. `isLenient` counts unknown kinds as source too, for
89 * a language this list does not know.
90 */
91export function weightOf(name: string, size: number, isLenient = false): number {
92 if (name.startsWith('.')) {
93 return 0
94 }
95
96 const ext = extOf(name)
97
98 if (NOT_SOURCE.has(ext)) {
99 return 0
100 }
101
102 if (LANGUAGES[ext] !== undefined) {
103 return Math.min(size, CAP_SOURCE)
104 }
105
106 if (CONFIG_EXT.has(ext) || CONFIG_NAMES.has(name)) {
107 return Math.min(size, CAP_CONFIG)
108 }
109
110 return isLenient ? Math.min(size, CAP_SOURCE) : 0
111}
112
113/** Whether a file is one the estate counts (source or configuration). */
114export const isSourceFile = (name: string): boolean => weightOf(name, 1) > 0
115
116export const languageOf = (name: string): string | undefined => LANGUAGES[extOf(name)]
117
118/** A language name as a person writes it, from what a map recorded (`cobol` -> `COBOL`, `java` -> `Java`). */
119const nameOfLanguage = (raw: string): string => {
120 const known = Object.values(LANGUAGES).find(name => name.toLowerCase() === raw.toLowerCase())
121
122 return known ?? (raw.length <= 4 ? raw.toUpperCase() : raw.charAt(0).toUpperCase() + raw.slice(1))
123}
124
125/** The estate as a topology's modules, sized in lines. */
126export function estateOfTopology(topology: Topology, files: number): EstateModel {
127 const byLanguage = new Map<string, number>()
128
129 for (const node of topology.modules) {
130 if (node.language !== undefined) {
131 byLanguage.set(nameOfLanguage(node.language), (byLanguage.get(nameOfLanguage(node.language)) ?? 0) + Math.max(1, node.loc))
132 }
133 }
134
135 const total = [...byLanguage.values()].reduce((sum, size) => sum + size, 0)
136
137 return {
138 source: 'map',
139 measure: 'lines',
140 granularity: 'module',
141 units: topology.modules.map(node => ({
142 id: node.id,
143 name: node.name,
144 group: node.domain ?? 'Other',
145 size: Math.max(1, node.loc),
146 ...(node.file !== undefined && { file: node.file }),
147 })),
148 files,
149 languages: [...byLanguage.entries()]
150 .sort((a, b) => b[1] - a[1])
151 .slice(0, 3)
152 .map(([name, size]) => ({ name, share: total > 0 ? size / total : 0 })),
153 isPartial: false,
154 }
155}
156
157/**
158 * The unit a path under `legacy/<system>/` belongs to: by the map's own file
159 * where there is a map, else by the file itself or the nearest directory that is a unit.
160 */
161export function unitOfPath(
162 estate: EstateModel,
163 topology: Topology | null,
164 rel: string,
165): EstateUnit | null {
166 const wanted = norm(rel)
167
168 if (estate.source === 'map' && topology !== null) {
169 const node = nodeOfFile(topology, wanted)
170
171 return node === null ? null : (estate.units.find(unit => unit.id === node.id) ?? null)
172 }
173
174 const exact = estate.units.find(unit => unit.file === wanted)
175
176 if (exact !== undefined) {
177 return exact
178 }
179
180 let best: EstateUnit | null = null
181 let bestLength = -1
182
183 for (const unit of estate.units) {
184 const dir = unit.dir
185
186 if (dir === undefined) {
187 continue
188 }
189
190 const isInside = dir === '' || wanted === dir || wanted.startsWith(`${dir}/`)
191
192 if (isInside && dir.length > bestLength) {
193 best = unit
194 bestLength = dir.length
195 }
196 }
197
198 return best
199}
200
201/** The names a transformed directory may be matched to a unit by: the id, the name, and the last path segment. */
202export function keysOfUnit(unit: EstateUnit): string[] {
203 const own = unit.dir !== undefined && unit.dir !== '' ? baseName(unit.dir) : unit.file !== undefined ? stemOf(unit.file) : ''
204
205 return [unit.id, unit.name, own].filter(key => key !== '')
206}
207
208/** The group a unit's directory sits in: its parent directory, or the root's own label. */
209export const groupOfDir = (dir: string, rootLabel: string): string => {
210 const parent = dirName(dir)
211
212 return parent === '' ? rootLabel : parent
213}
214hooks/reader/fs.ts 80 lines1/**
2 * The file access the reader needs, narrow enough that `$.fs` and a test's
3 * in-memory tree both satisfy it. Paths are relative to the session's
4 * working directory or absolute, as `$.fs` takes them.
5 */
6export type ReaderFs = {
7 read: (path: string) => Promise<string>
8 /** An entry as it stands: a symbolic link is `other` (with `isLink`), whatever it leads to. `stat` says what that is. */
9 list: (path: string) => Promise<{ name: string; kind: 'file' | 'dir' | 'other'; size: number; isLink?: boolean }[]>
10 exists: (path: string) => Promise<boolean>
11 stat: (path: string) => Promise<{ kind: 'file' | 'dir' | 'other'; size: number; mtimeMs: number }>
12}
13
14/** `read`, or null when the file is missing, unreadable or over the size cap. */
15export async function readOrNull(fs: ReaderFs, path: string): Promise<string | null> {
16 try {
17 // `exists` never rejects; asking first keeps a missing optional file out of the error log.
18 if (!(await fs.exists(path))) {
19 return null
20 }
21
22 return await fs.read(path)
23 } catch {
24 return null
25 }
26}
27
28/** `list`, or an empty list when the directory is missing. */
29export async function listOrEmpty(
30 fs: ReaderFs,
31 path: string,
32): Promise<{ name: string; kind: 'file' | 'dir' | 'other'; size: number; isLink?: boolean }[]> {
33 try {
34 if (!(await fs.exists(path))) {
35 return []
36 }
37
38 return await fs.list(path)
39 } catch {
40 return []
41 }
42}
43
44/** `stat`'s mtime, or null when the path is missing. */
45export async function mtimeOrNull(fs: ReaderFs, path: string): Promise<number | null> {
46 try {
47 if (!(await fs.exists(path))) {
48 return null
49 }
50
51 return (await fs.stat(path)).mtimeMs
52 } catch {
53 return null
54 }
55}
56
57/**
58 * The directories under `path`, by name. A symbolic link to a directory counts: the engine lists a link as `other`,
59 * and a legacy tree is often a link to where the code really lives.
60 */
61export async function dirNamesOf(fs: ReaderFs, path: string): Promise<string[]> {
62 const names: string[] = []
63
64 for (const entry of await listOrEmpty(fs, path)) {
65 if (entry.kind === 'dir') {
66 names.push(entry.name)
67 } else if (entry.kind === 'other') {
68 try {
69 if ((await fs.stat(`${path}/${entry.name}`)).kind === 'dir') {
70 names.push(entry.name)
71 }
72 } catch {
73 // a link that leads nowhere is not a system
74 }
75 }
76 }
77
78 return names
79}
80hooks/reader/modernized.ts 410 lines1import { join } from '../paths'
2import { linesOf } from '../text'
3import { listOrEmpty, readOrNull, type ReaderFs } from './fs'
4
5/**
6 * The state of each transformed module, read from `modernized/<system>/`:
7 * what exists on disk, what the test reports say, what the notes record.
8 * No inference: a fact that is not on disk is absent.
9 */
10
11export type TestTotals = {
12 tests: number
13 failures: number
14 errors: number
15 skipped: number
16 /** How many report files were read. */
17 reports: number
18}
19
20export type ModuleState =
21 | 'scaffolded'
22 | 'tests-written'
23 | 'tests-red'
24 /** An uplift's tests fail and there is no baseline row to say whether the source runtime failed them too. */
25 | 'tests-failing'
26 | 'tests-green'
27 | 'reviewed'
28 | 'ported'
29 | 'switched'
30
31export type ModernizedModule = {
32 /** The directory's name under `modernized/<system>/`. */
33 dir: string
34 /** Path relative to the working directory. */
35 path: string
36 hasMain: boolean
37 hasTests: boolean
38 hasNotes: boolean
39 /** The date in the notes' "Architecture review" section; absent when none. */
40 reviewDate?: string
41 hasReviewSection: boolean
42 /** The notes say "ported, not switched". */
43 isPortedNotSwitched: boolean
44 /** The notes say the route was switched. */
45 isSwitched: boolean
46 /** Open follow-ups the notes list (bullets under a Follow-ups heading). */
47 followUps: number
48 tests: TestTotals | null
49 state: ModuleState
50 /** Newest mtime among the notes and test reports; 0 when unknown. */
51 mtimeMs: number
52}
53
54/** States a unit of work counts as finished in: the review is done, or the module is ported or switched. */
55const DONE_STATES: ReadonlySet<ModuleState> = new Set(['reviewed', 'ported', 'switched'])
56
57/**
58 * Whether a module's work is finished. Green tests with notes written count: the notes are where the plugin's
59 * review step lists what the critic found, and how they headline it varies, so a missing headline is not unfinished work.
60 */
61export const isDone = (module: Pick<ModernizedModule, 'state' | 'hasNotes'>): boolean =>
62 DONE_STATES.has(module.state) || (module.state === 'tests-green' && module.hasNotes)
63
64const EMPTY: TestTotals = { tests: 0, failures: 0, errors: 0, skipped: 0, reports: 0 }
65
66/** Totals of one Visual Studio test result file (`.trx`), the report `dotnet test --logger trx` leaves. */
67export function totalsOfTrx(xml: string): TestTotals | null {
68 const counters = /<Counters\b[^>]*>/.exec(xml)?.[0]
69
70 if (counters === undefined || !/\btotal="\d+"/.test(counters)) {
71 return null
72 }
73
74 return {
75 tests: attr(counters, 'total'),
76 failures: attr(counters, 'failed'),
77 errors: attr(counters, 'error') + attr(counters, 'timeout') + attr(counters, 'aborted'),
78 skipped: attr(counters, 'notExecuted') + attr(counters, 'inconclusive'),
79 reports: 1,
80 }
81}
82
83const attr = (tag: string, name: string): number => {
84 const match = new RegExp(`\\b${name}="(\\d+)"`).exec(tag)
85
86 return match?.[1] !== undefined ? Number(match[1]) : 0
87}
88
89/** Totals of one JUnit-style XML report (`<testsuite ...>` / `<testsuites ...>`). */
90export function totalsOfJunitXml(xml: string): TestTotals | null {
91 const suites = /<testsuites\b[^>]*>/.exec(xml)?.[0]
92
93 if (suites !== undefined && /\btests="\d+"/.test(suites)) {
94 return {
95 tests: attr(suites, 'tests'),
96 failures: attr(suites, 'failures'),
97 errors: attr(suites, 'errors'),
98 skipped: attr(suites, 'skipped') + attr(suites, 'disabled'),
99 reports: 1,
100 }
101 }
102
103 const all = [...xml.matchAll(/<testsuite\b[^>]*>/g)].map(match => match[0])
104
105 if (all.length === 0) {
106 return null
107 }
108
109 return all.reduce<TestTotals>(
110 (sum, tag) => ({
111 tests: sum.tests + attr(tag, 'tests'),
112 failures: sum.failures + attr(tag, 'failures'),
113 errors: sum.errors + attr(tag, 'errors'),
114 skipped: sum.skipped + attr(tag, 'skipped') + attr(tag, 'disabled'),
115 reports: 1,
116 }),
117 { ...EMPTY, reports: 1 },
118 )
119}
120
121const add = (a: TestTotals, b: TestTotals): TestTotals => ({
122 tests: a.tests + b.tests,
123 failures: a.failures + b.failures,
124 errors: a.errors + b.errors,
125 skipped: a.skipped + b.skipped,
126 reports: a.reports + b.reports,
127})
128
129/** Where test runners leave JUnit XML, relative to a module's directory. */
130const REPORT_DIRS = [
131 'target/surefire-reports',
132 'target/failsafe-reports',
133 'build/test-results/test',
134 'build/test-results',
135 'build/logs',
136 'test-results',
137 'test-reports',
138 'reports/junit',
139 'TestResults',
140]
141
142const REPORT_FILES = ['junit.xml', 'report.xml', 'test-report.xml', 'pytest.xml']
143
144/** When a file last changed; 0 when it cannot be said. */
145async function changedAt(fs: ReaderFs, path: string): Promise<number> {
146 try {
147 return (await fs.stat(path)).mtimeMs
148 } catch {
149 return 0
150 }
151}
152
153/**
154 * The test totals a directory's report files hold: a custom JSON report, JUnit XML, or a `.trx`, and when the newest of
155 * the files read last changed (what a verdict about the module is compared with).
156 */
157export async function readTestsAt(fs: ReaderFs, dir: string, withTime = true): Promise<{ totals: TestTotals; atMs: number } | null> {
158 const stamp = (path: string) => (withTime ? changedAt(fs, path) : Promise.resolve(0))
159 const customPath = join(dir, '.modernize/test-report.json')
160 const custom = await readOrNull(fs, customPath)
161
162 if (custom !== null) {
163 try {
164 const raw = JSON.parse(custom) as Record<string, unknown>
165 const num = (key: string) => (typeof raw[key] === 'number' ? (raw[key] as number) : 0)
166
167 return {
168 totals: {
169 tests: num('tests'),
170 failures: num('failures'),
171 errors: num('errors'),
172 skipped: num('skipped'),
173 reports: 1,
174 },
175 atMs: await stamp(customPath),
176 }
177 } catch {
178 // fall through to the XML reports
179 }
180 }
181
182 let total: TestTotals | null = null
183 let atMs = 0
184
185 for (const sub of REPORT_DIRS) {
186 const entries = await listOrEmpty(fs, join(dir, sub))
187
188 for (const entry of entries) {
189 const isTrx = entry.name.endsWith('.trx')
190
191 if (entry.kind !== 'file' || !(entry.name.endsWith('.xml') || isTrx) || entry.size > 3_500_000) {
192 continue
193 }
194
195 const xml = await readOrNull(fs, join(dir, sub, entry.name))
196 const totals = xml === null ? null : isTrx ? totalsOfTrx(xml) : totalsOfJunitXml(xml)
197
198 if (totals !== null) {
199 total = total === null ? totals : add(total, totals)
200 atMs = Math.max(atMs, await stamp(join(dir, sub, entry.name)))
201 }
202 }
203
204 if (total !== null) {
205 return { totals: total, atMs }
206 }
207 }
208
209 for (const name of REPORT_FILES) {
210 const xml = await readOrNull(fs, join(dir, name))
211 const totals = xml !== null ? totalsOfJunitXml(xml) : null
212
213 if (totals !== null) {
214 return { totals, atMs: await stamp(join(dir, name)) }
215 }
216 }
217
218 return null
219}
220
221/** The test totals a directory's report files hold, without asking when each file changed. */
222export async function readTests(fs: ReaderFs, dir: string): Promise<TestTotals | null> {
223 return (await readTestsAt(fs, dir, false))?.totals ?? null
224}
225
226const TEST_DIRS = ['src/test', 'tests', 'test', 'spec', '__tests__']
227const MAIN_DIRS = ['src/main', 'src', 'app', 'lib']
228
229async function anyDir(fs: ReaderFs, dir: string, names: readonly string[]): Promise<boolean> {
230 for (const name of names) {
231 if ((await listOrEmpty(fs, join(dir, name))).length > 0) {
232 return true
233 }
234 }
235
236 return false
237}
238
239/** What the notes record, read by heading and phrase. */
240export function readNotes(notes: string): {
241 hasReviewSection: boolean
242 reviewDate?: string
243 isPortedNotSwitched: boolean
244 isSwitched: boolean
245 followUps: number
246} {
247 const lines = linesOf(notes)
248 // The plugin's review step spawns the architecture critic and lists what it found in the notes; how the notes
249 // headline that varies, so any heading that says review or critic counts, and so does naming the critic.
250 const named = lines.findIndex(line => /^#{1,4}\s+.*\b(?:review|critic)/i.test(line))
251 const start = named >= 0 ? named : -1
252 let reviewDate: string | undefined
253
254 if (start >= 0) {
255 const level = /^(#+)/.exec(lines[start] ?? '')?.[1]?.length ?? 2
256
257 const end = lines.findIndex(
258 (line, index) => index > start && new RegExp(`^#{1,${level}}\\s`).test(line),
259 )
260
261 const section = lines.slice(start, end < 0 ? undefined : end).join('\n')
262
263 reviewDate = /\b(\d{4}-\d{2}-\d{2})\b/.exec(section)?.[1]
264 }
265
266 const followStart = lines.findIndex(line => /^#{1,4}\s+.*follow-?ups?/i.test(line))
267 let followUps = 0
268
269 if (followStart >= 0) {
270 for (const line of lines.slice(followStart + 1)) {
271 if (/^#{1,4}\s/.test(line)) {
272 break
273 }
274
275 if (/^\s*[-*]\s+(?!\[[xX]\])/.test(line) || /^\s*\d+\.\s+/.test(line)) {
276 followUps += 1
277 }
278 }
279 }
280
281 const isPortedNotSwitched = /ported,?\s+not\s+switched/i.test(notes)
282
283 return {
284 hasReviewSection: start >= 0 || /architecture[- ]critic/i.test(notes),
285 ...(reviewDate !== undefined && { reviewDate }),
286 isPortedNotSwitched,
287 isSwitched:
288 !isPortedNotSwitched &&
289 /\b(?:route|traffic)\b[^\n]{0,40}\bswitched\b|\bswitched\b[^\n]{0,20}\b\d{4}-\d{2}-\d{2}\b/i.test(
290 notes,
291 ),
292 followUps,
293 }
294}
295
296/** The state a module's facts add up to, furthest first. */
297export function stateOf(module: Omit<ModernizedModule, 'state'>): ModuleState {
298 const tests = module.tests
299 const isRed = tests !== null && tests.failures + tests.errors > 0
300 const isGreen = tests !== null && tests.tests > 0 && !isRed
301
302 if (module.isSwitched && isGreen && module.hasReviewSection) {
303 return 'switched'
304 }
305
306 if (module.isPortedNotSwitched && isGreen) {
307 return 'ported'
308 }
309
310 if (isGreen && module.hasReviewSection) {
311 return 'reviewed'
312 }
313
314 if (isRed) {
315 return 'tests-red'
316 }
317
318 if (isGreen) {
319 return 'tests-green'
320 }
321
322 if (module.hasTests) {
323 return 'tests-written'
324 }
325
326 return 'scaffolded'
327}
328
329/**
330 * The state of one unit of an uplift's working copy: worse than its baseline, matching or exceeding it
331 * (every test that passed on the source runtime still passes, none is missing), tested but not comparable
332 * to a baseline row, changed and not yet tested, or not touched at all.
333 */
334export function stateOfUplift(
335 tests: TestTotals | null,
336 baseline: { pass: number; fail: number; error: number; skip: number } | null,
337 isChanged: boolean,
338): ModuleState | 'untouched' {
339 if (tests !== null && tests.tests > 0) {
340 const bad = tests.failures + tests.errors
341
342 // With no baseline row there is nothing to be worse than: the failures are said, not judged.
343 if (baseline === null && bad > 0) {
344 return 'tests-failing'
345 }
346
347 if (baseline !== null && bad > baseline.fail + baseline.error) {
348 return 'tests-red'
349 }
350
351 const passed = tests.tests - bad - tests.skipped
352 const isCovered = baseline !== null && tests.tests >= baseline.pass + baseline.fail + baseline.error + baseline.skip && passed >= baseline.pass
353
354 return isCovered ? 'reviewed' : 'tests-green'
355 }
356
357 return isChanged ? 'scaffolded' : 'untouched'
358}
359
360/** Reads every code directory under `modernized/<system>/`. */
361export async function readModernized(
362 fs: ReaderFs,
363 system: string,
364): Promise<ModernizedModule[]> {
365 return readModernizedIn(fs, join('modernized', system))
366}
367
368/** Reads every code directory directly under `root`: a transformed system's modules, or a reimagined system's services. */
369export async function readModernizedIn(
370 fs: ReaderFs,
371 root: string,
372 observed: ReadonlyMap<string, TestTotals> = new Map(),
373): Promise<ModernizedModule[]> {
374 const entries = await listOrEmpty(fs, root)
375 const out: ModernizedModule[] = []
376
377 for (const entry of entries) {
378 if (entry.kind !== 'dir' || entry.name.startsWith('.')) {
379 continue
380 }
381
382 const path = join(root, entry.name)
383 const notes = await readOrNull(fs, join(path, 'TRANSFORMATION_NOTES.md'))
384 const read = notes !== null ? readNotes(notes) : null
385 const reported = await readTestsAt(fs, path)
386 const tests = reported?.totals ?? observed.get(path) ?? null
387 // The newest of the notes and the test reports: what a verdict about the module is checked against.
388 const mtimeMs = Math.max(notes !== null ? await changedAt(fs, join(path, 'TRANSFORMATION_NOTES.md')) : 0, reported?.atMs ?? 0)
389
390 const facts = {
391 dir: entry.name,
392 path,
393 hasMain: await anyDir(fs, path, MAIN_DIRS),
394 hasTests: await anyDir(fs, path, TEST_DIRS),
395 hasNotes: notes !== null,
396 hasReviewSection: read?.hasReviewSection ?? false,
397 ...(read?.reviewDate !== undefined && { reviewDate: read.reviewDate }),
398 isPortedNotSwitched: read?.isPortedNotSwitched ?? false,
399 isSwitched: read?.isSwitched ?? false,
400 followUps: read?.followUps ?? 0,
401 tests,
402 mtimeMs,
403 }
404
405 out.push({ ...facts, state: stateOf(facts) })
406 }
407
408 return out
409}
410hooks/reader/progress.ts 986 lines1import { join } from '../paths'
2import { isSystemName, isToken, plain } from '../text'
3import { parseBrief, type Brief, type Phase } from './brief'
4import { discoverEstate, sourceSizes } from './discover'
5import { estateOfTopology, keysOfUnit, type EstateModel, type EstateUnit } from './estate-model'
6import { dirNamesOf, mtimeOrNull, readOrNull, type ReaderFs } from './fs'
7import { isDone, readModernizedIn, type ModernizedModule, type TestTotals } from './modernized'
8import { needsReview, parseRules, type RuleSet } from './rules'
9import { pickTrack, TRACK_LABELS, type TrackKey } from './tracks'
10import { parseTopology, type Topology } from './topology'
11import { parseVerification, proofOfModule, type ProofState, type Verification } from './verification'
12import { parseLedger } from '../review/ledger'
13import {
14 baselineRowOf,
15 changedUnitsOf,
16 parseBaseline,
17 parseCatalog,
18 changedPathsOf,
19 readUpliftUnits,
20 type Baseline,
21 type Catalog,
22} from './uplift'
23
24/**
25 * One reading of a system's modernization state, from the artifacts on disk
26 * alone. Everything here is a fact of a file that exists; nothing is inferred
27 * by a model, and what cannot be read is absent.
28 */
29
30export type StageKey =
31 | 'preflight'
32 | 'assess'
33 | 'map'
34 | 'rules'
35 | 'brief'
36 | 'transform'
37 | 'harden'
38 | 'deltas'
39 | 'baseline'
40 | 'pilot'
41 | 'migrate'
42 | 'compare'
43 | 'verify'
44 | 'spec'
45 | 'design'
46 | 'scaffold'
47 | 'tests'
48
49export type Stage = {
50 key: StageKey
51 label: string
52 isDone: boolean
53 mtimeMs: number | null
54 /** One short qualifier (`signed`, `319 rules`), when there is one. */
55 detail?: string
56}
57
58export type ReviewVerdict = 'confirmed' | 'wrong' | 'discuss'
59
60export type ReviewLedger = Record<
61 string,
62 { verdict: ReviewVerdict; at: string; title?: string; /** The reviewer's own words, when they gave any. */ note?: string }
63>
64
65export type NextStep = {
66 /** The slash command to run, or what to do by hand. */
67 text: string
68 isByHand: boolean
69 reason: string
70 /** A step the pane can do itself: `sign` opens the sign-off dialog. */
71 action?: 'sign'
72}
73
74/** What an uplift has left on disk, beyond the modules it changed. */
75export type UpliftFacts = {
76 catalog: Catalog | null
77 baseline: Baseline | null
78 hasPlaybook: boolean
79 hasNotes: boolean
80 hasCopy: boolean
81 /** Whether the working copy's changes could be read (a version-control status), or only its test reports. */
82 isChangeKnown: boolean
83}
84
85export type Snapshot = {
86 system: string
87 systems: string[]
88 /** Which way the plugin's commands are working on the system. */
89 track: TrackKey
90 /** True once the system has any artifact under `analysis/`: modernization is under way, not only possible. */
91 hasAnalysis: boolean
92 stages: Stage[]
93 /** The units the estate map draws: the map's modules, or what the legacy tree holds. */
94 estate: EstateModel | null
95 topology: Topology | null
96 rules: RuleSet | null
97 brief: Brief | null
98 /** The units of work under way: transformed modules, uplifted modules, or reimagined services. */
99 modules: ModernizedModule[]
100 /** Estate unit id to its unit of work, where one matches. */
101 byNode: Map<string, ModernizedModule>
102 /** Units of work that match no unit of the estate. */
103 extras: ModernizedModule[]
104 uplift: UpliftFacts | null
105 findings: { exists: boolean; isScan: boolean }
106 /** The proof pack's verdicts, when the verify command has run. */
107 verification: Verification | null
108 /**
109 * Where each built module stands with the proof, by its folder name lower-cased. An uplift is one piece, so it has one
110 * entry, under the name of its working copy (`<system>-uplifted`).
111 */
112 proofs: Map<string, ProofState>
113 reviews: ReviewLedger
114 /** Size of the units finished over the estate's total; null without an estate, or where units of work are not the estate's own. */
115 percent: number | null
116 totals: { modules: number; done: number; loc: number; locDone: number }
117 attention: string[]
118 next: NextStep | null
119 readAtMs: number
120}
121
122export const STAGE_LABELS: Record<StageKey, string> = {
123 preflight: 'preflight',
124 assess: 'assess',
125 map: 'map',
126 rules: 'rules',
127 brief: 'brief',
128 transform: 'transform',
129 harden: 'harden',
130 deltas: 'deltas',
131 baseline: 'baseline',
132 pilot: 'pilot',
133 migrate: 'migrate',
134 compare: 'compare',
135 verify: 'verify',
136 spec: 'spec',
137 design: 'design',
138 scaffold: 'scaffold',
139 tests: 'tests',
140}
141
142const STAGE_FILES = {
143 preflight: 'PREFLIGHT.md',
144 assess: 'ASSESSMENT.md',
145 map: 'topology.json',
146 rules: 'BUSINESS_RULES.md',
147 brief: 'MODERNIZATION_BRIEF.md',
148 transform: 'TRANSFORMATION_NOTES.md',
149 harden: 'SECURITY_FINDINGS.md',
150 verify: 'VERIFICATION.json',
151 deltas: 'DELTA_CATALOG.md',
152 baseline: 'BASELINE.md',
153 pilot: 'PLAYBOOK.md',
154 spec: 'AI_NATIVE_SPEC.md',
155 design: 'REIMAGINED_ARCHITECTURE.md',
156} as const
157
158export const REVIEWS_FILE = 'RULE_REVIEWS.json'
159
160/** Parsed files kept by path and mtime, so an unchanged file is parsed once. */
161export type ReaderCache = Map<string, { mtimeMs: number; value: unknown }>
162
163async function cached<T>(
164 fs: ReaderFs,
165 cache: ReaderCache,
166 path: string,
167 parse: (text: string) => T,
168): Promise<{ value: T | null; mtimeMs: number | null }> {
169 const mtimeMs = await mtimeOrNull(fs, path)
170
171 if (mtimeMs === null) {
172 cache.delete(path)
173
174 return { value: null, mtimeMs: null }
175 }
176
177 const hit = cache.get(path)
178
179 if (hit !== undefined && hit.mtimeMs === mtimeMs) {
180 return { value: hit.value as T, mtimeMs }
181 }
182
183 const text = await readOrNull(fs, path)
184
185 if (text === null) {
186 return { value: null, mtimeMs }
187 }
188
189 const value = parse(text)
190
191 cache.set(path, { mtimeMs, value })
192
193 return { value, mtimeMs }
194}
195
196/** The systems the workspace holds: each directory under `analysis/` and under the legacy tree. */
197export async function systemsOf(fs: ReaderFs, legacyDir = 'legacy'): Promise<string[]> {
198 const names = new Set<string>()
199
200 for (const root of ['analysis', legacyDir]) {
201 for (const name of await dirNamesOf(fs, root)) {
202 // The names the plugin's workflows accept: anything else could not be run on, and is not put in a command or a note.
203 if (isSystemName(name)) {
204 names.add(name)
205 }
206 }
207 }
208
209 return [...names].sort()
210}
211
212const slugOf = (target: string) =>
213 target
214 .toLowerCase()
215 .replace(/[^a-z0-9]+/g, '-')
216 .replace(/^-+|-+$/g, '')
217
218/** How a transformed directory's name is matched to a unit's names. */
219const keyOf = (name: string) => name.toLowerCase().replace(/::/g, '-').replace(/[^a-z0-9]+/g, '-')
220
221function approvedPhases(brief: Brief): Phase[] {
222 if (!brief.approval.isSigned) {
223 return []
224 }
225
226 const ordered = [...brief.phases].sort((a, b) => a.number - b.number)
227
228 return brief.approval.covers === 'full' ? ordered : ordered.slice(0, 1)
229}
230
231/** Whether one unit of work is finished, by what the track counts as finished. */
232export function isUnitDone(
233 snapshot: Pick<Snapshot, 'track' | 'uplift'>,
234 module: Pick<ModernizedModule, 'state' | 'hasNotes'>,
235): boolean {
236 if (snapshot.track !== 'uplift') {
237 return isDone(module)
238 }
239
240 // An uplift is proven by reproducing the baseline; with no per-module baseline, passing is all there is to compare.
241 const baseline = snapshot.uplift?.baseline
242 const hasRows = (baseline?.rows.size ?? 0) > 0 || (baseline?.headline ?? null) !== null
243
244 return module.state === 'reviewed' || (module.state === 'tests-green' && !hasRows)
245}
246
247/** The command that opens the whole process: the prefix without its verb dash (`/code-modernization:modernize`). */
248export const frontDoorOf = (prefix: string): string => prefix.replace(/-$/, '')
249
250/**
251 * The verify step a built module calls for: none yet, changed since it was verified, or failed. A module that is
252 * proven, or partly proven (which a person decides on), calls for nothing; nor does one with no notes and no verdict.
253 */
254function verifyStepOf(prefix: string, system: string, module: Pick<ModernizedModule, 'dir'>, proof: ProofState | undefined): NextStep | null {
255 if (proof === undefined || proof.state === 'proven' || proof.state === 'partly') {
256 return null
257 }
258
259 const name = plain(module.dir, 60)
260 const text = `${prefix}verify ${system}${isToken(module.dir) ? ` ${module.dir}` : ''}`
261
262 if (proof.state === 'none') {
263 return { text, isByHand: false, reason: `${name} is built but not yet checked against the old code` }
264 }
265
266 if (proof.state === 'changed') {
267 return { text, isByHand: false, reason: `${name} changed after it was verified: check it again` }
268 }
269
270 return { text, isByHand: false, reason: `${name} is NOT PROVEN${proof.reason === '' ? '' : `: ${proof.reason}`}. Fix that, then check again` }
271}
272
273function nextOfTransform(
274 prefix: string,
275 system: string,
276 stages: Stage[],
277 brief: Brief | null,
278 byNode: Map<string, ModernizedModule>,
279 hasAnalysis: boolean,
280 proofs: ReadonlyMap<string, ProofState>,
281): NextStep | null {
282 const done = (key: StageKey) => stages.find(stage => stage.key === key)?.isDone === true
283 const cmd = (name: string, rest = '') => `${prefix}${name} ${system}${rest === '' ? '' : ` ${rest}`}`
284
285 // Nothing has been written for this system yet: the front door asks what the person wants, once, and gives the first step.
286 if (!hasAnalysis) {
287 return { text: `${frontDoorOf(prefix)} ${system}`, isByHand: false, reason: 'start here: it asks what you want done, then gives you the first step' }
288 }
289
290 if (!done('preflight') && !done('assess')) {
291 return { text: cmd('preflight'), isByHand: false, reason: 'first, check the environment and that all the code is there' }
292 }
293
294 if (!done('assess')) {
295 return { text: cmd('assess'), isByHand: false, reason: 'what you have: size, complexity, risks and the recommended approach' }
296 }
297
298 if (!done('map')) {
299 return { text: cmd('map'), isByHand: false, reason: 'how the parts of the code connect: calls, data and business flows' }
300 }
301
302 if (!done('rules')) {
303 return { text: cmd('extract-rules'), isByHand: false, reason: 'what the code does, written as rules a business person can check' }
304 }
305
306 if (!done('brief') || brief === null) {
307 return { text: cmd('brief'), isByHand: false, reason: 'the phased plan you approve before anything is built' }
308 }
309
310 if (!brief.approval.isSigned) {
311 return {
312 text: 'approve the brief',
313 isByHand: true,
314 action: 'sign',
315 reason: 'nothing is built until a person approves the plan: sign it here, or tell Claude which phases you approve',
316 }
317 }
318
319 const target = brief.target !== undefined ? slugOf(brief.target) : ''
320
321 for (const phase of approvedPhases(brief)) {
322 // In the order the phase names them: a module that is built and not yet verified is checked before the next one starts.
323 for (const id of phase.modules) {
324 const module = byNode.get(id)
325
326 if (module === undefined || !isDone(module)) {
327 // A module id is text from the map: it goes in a command only when it is one plain token.
328 const isNamed = isToken(id)
329
330 return {
331 text: cmd('transform', `${isNamed ? id : '<module>'}${target === '' ? '' : ` ${target}`}`),
332 isByHand: !isNamed,
333 reason: `Phase ${phase.number} names ${plain(id, 60)} and it is not reviewed yet`,
334 }
335 }
336
337 const step = verifyStepOf(prefix, system, module, proofs.get(module.dir.toLowerCase()))
338
339 if (step !== null) {
340 return step
341 }
342 }
343
344 if (phase.modules.length === 0) {
345 return {
346 text: `pick Phase ${phase.number}'s first module and run ${prefix}transform ${system} <module>${target === '' ? '' : ` ${target}`}`,
347 isByHand: true,
348 reason: `Phase ${phase.number} names no module the map knows`,
349 }
350 }
351 }
352
353 return {
354 text: cmd('status'),
355 isByHand: false,
356 reason:
357 brief.approval.covers === 'full'
358 ? 'every approved phase is built and checked'
359 : 'Phase 1 is built and checked; its retrospective revises the brief before Phase 2 is approved',
360 }
361}
362
363/** The uplift command takes its two versions from what the person said at the start (`INTENT.md`, the brief), and asks when neither has them. */
364function nextOfUplift(prefix: string, system: string, stages: Stage[], proofs: ReadonlyMap<string, ProofState>): NextStep {
365 const done = (key: StageKey) => stages.find(stage => stage.key === key)?.isDone === true
366 const command = `${prefix}uplift ${system}`
367
368 if (!done('deltas')) {
369 return { text: command, isByHand: false, reason: 'first it lists what the newer version breaks in this code' }
370 }
371
372 if (!done('baseline')) {
373 return { text: command, isByHand: false, reason: 'next it records what the tests do today, to compare against' }
374 }
375
376 if (!done('pilot')) {
377 return { text: command, isByHand: false, reason: 'next it migrates one module first and writes down what it learned' }
378 }
379
380 if (!done('compare')) {
381 return { text: command, isByHand: false, reason: 'next it migrates the rest in batches, each compared with the baseline' }
382 }
383
384 // The whole upgraded copy is one thing to prove, under the name of its working copy.
385 const step = verifyStepOf(prefix, system, { dir: `${system}-uplifted` }, proofs.get(`${system}-uplifted`))
386
387 if (step !== null) {
388 return { ...step, text: `${prefix}verify ${system}` }
389 }
390
391 return { text: `${prefix}status ${system}`, isByHand: false, reason: 'the upgrade is built and checked; status says what is stale' }
392}
393
394function nextOfReimagine(prefix: string, system: string, stages: Stage[], modules: readonly ModernizedModule[], proofs: ReadonlyMap<string, ProofState>): NextStep {
395 const done = (key: StageKey) => stages.find(stage => stage.key === key)?.isDone === true
396 const command = `${prefix}reimagine ${system}`
397
398 if (!done('spec')) {
399 return { text: command, isByHand: false, reason: 'first it writes down what the old system does, from its code' }
400 }
401
402 if (!done('design')) {
403 return { text: command, isByHand: false, reason: 'next it designs the new architecture and checks it against that spec' }
404 }
405
406 if (!done('scaffold')) {
407 return { text: command, isByHand: false, reason: 'next it builds each service of the approved design, with tests' }
408 }
409
410 for (const module of modules) {
411 const step = verifyStepOf(prefix, system, module, proofs.get(module.dir.toLowerCase()))
412
413 if (step !== null) {
414 return step
415 }
416 }
417
418 return { text: `${prefix}status ${system}`, isByHand: false, reason: 'the services are built and checked; status says what is stale' }
419}
420
421export type ReadOptions = {
422 /** The system to read; the first one found when absent or unknown. */
423 system?: string
424 /** What a command is written with, up to its verb (`/code-modernization:modernize-`). */
425 commandPrefix: string
426 nowMs: number
427 /** The read-only tree holding the systems, relative to the workspace; `legacy` when absent. */
428 legacyDir?: string
429 /** Which way to follow the system; `auto` when absent: whichever one's artifacts are newest. */
430 track?: TrackKey | 'auto'
431 /** Units of an uplift's working copy that were written during this session: they count as changed even when the file kept its size. */
432 writtenUnits?: ReadonlySet<string>
433 /** How long a listing of the working copy is reused, in milliseconds; 15 seconds when absent. */
434 changedTtlMs?: number
435 /** Test totals read off commands this session, by module path, for modules that left no report file. */
436 observed?: ReadonlyMap<string, TestTotals>
437}
438
439const newest = (...times: (number | null)[]): number | null => {
440 const known = times.filter((time): time is number => time !== null)
441
442 return known.length === 0 ? null : Math.max(...known)
443}
444
445/** The files of an uplift's working copy and their sizes, reused for a short while so an idle pane does not walk the tree again and again. */
446async function workingSizesOf(
447 fs: ReaderFs,
448 cache: ReaderCache,
449 root: string,
450 nowMs: number,
451 ttlMs: number,
452): Promise<Map<string, number> | null> {
453 const key = `changed:${root}`
454 const hit = cache.get(key)
455
456 if (hit !== undefined && nowMs - hit.mtimeMs < ttlMs) {
457 return hit.value as Map<string, number> | null
458 }
459
460 const value = await sourceSizes(fs, root)
461
462 cache.set(key, { mtimeMs: nowMs, value })
463
464 return value
465}
466
467/** The estate to draw: the map's modules, or what the legacy tree holds. Read once per system, then kept. */
468async function estateOf(
469 fs: ReaderFs,
470 cache: ReaderCache,
471 track: TrackKey,
472 topology: Topology | null,
473 legacyRoot: string,
474 system: string,
475): Promise<EstateModel | null> {
476 if (topology !== null && track !== 'uplift') {
477 return estateOfTopology(topology, 0)
478 }
479
480 const key = `estate:${legacyRoot}`
481 const at = (await mtimeOrNull(fs, legacyRoot)) ?? 0
482 const hit = cache.get(key)
483
484 if (hit !== undefined && hit.mtimeMs === at) {
485 return hit.value as EstateModel | null
486 }
487
488 const found = await discoverEstate(fs, legacyRoot, system)
489
490 cache.set(key, { mtimeMs: at, value: found })
491
492 return found ?? (topology !== null ? estateOfTopology(topology, 0) : null)
493}
494
495/** Reads one system's state. Null when the workspace holds no system. */
496export async function readSnapshot(
497 fs: ReaderFs,
498 cache: ReaderCache,
499 options: ReadOptions,
500): Promise<Snapshot | null> {
501 const legacyDir = options.legacyDir ?? 'legacy'
502 const systems = await systemsOf(fs, legacyDir)
503
504 const system =
505 options.system !== undefined && systems.includes(options.system)
506 ? options.system
507 : systems[0]
508
509 if (system === undefined) {
510 return null
511 }
512
513 const dir = join('analysis', system)
514 const legacyRoot = join(legacyDir, system)
515 const transformRoot = join('modernized', system)
516 const upliftRoot = join('modernized', `${system}-uplifted`)
517 const reimagineRoot = join('modernized', `${system}-reimagined`)
518 const observed = options.observed ?? new Map<string, TestTotals>()
519
520 const [topo, rules, catalog, baseline, verificationRead, reviewsRaw, findingsHead, marks] = await Promise.all([
521 cached(fs, cache, join(dir, STAGE_FILES.map), parseTopology),
522 cached(fs, cache, join(dir, STAGE_FILES.rules), parseRules),
523 cached(fs, cache, join(dir, STAGE_FILES.deltas), parseCatalog),
524 cached(fs, cache, join(dir, STAGE_FILES.baseline), parseBaseline),
525 cached(fs, cache, join(dir, STAGE_FILES.verify), parseVerification),
526 readOrNull(fs, join(dir, REVIEWS_FILE)),
527 readOrNull(fs, join(dir, STAGE_FILES.harden)),
528 Promise.all(
529 [
530 mtimeOrNull(fs, dir),
531 mtimeOrNull(fs, join(dir, STAGE_FILES.preflight)),
532 mtimeOrNull(fs, join(dir, STAGE_FILES.assess)),
533 mtimeOrNull(fs, join(dir, STAGE_FILES.pilot)),
534 mtimeOrNull(fs, join(upliftRoot, 'UPLIFT_NOTES.md')),
535 mtimeOrNull(fs, upliftRoot),
536 mtimeOrNull(fs, join(dir, STAGE_FILES.spec)),
537 mtimeOrNull(fs, join(dir, STAGE_FILES.design)),
538 mtimeOrNull(fs, reimagineRoot),
539 mtimeOrNull(fs, transformRoot),
540 ],
541 ),
542 ])
543
544 const [analysisAt, preflightAt, assessAt, playbookAt, upliftNotesAt, upliftCopyAt, specAt, designAt, reimagineDirAt, transformDirAt] = marks
545
546 const topology = topo.value
547 const moduleIds = topology?.modules.map(node => node.id) ?? []
548
549 const brief = await cached(fs, cache, join(dir, STAGE_FILES.brief), text =>
550 parseBrief(text, moduleIds),
551 )
552
553 // A brief parsed before the map existed named no modules: parse it again.
554 if (
555 brief.value !== null &&
556 moduleIds.length > 0 &&
557 brief.value.phases.length > 0 &&
558 brief.value.phases.every(phase => phase.modules.length === 0)
559 ) {
560 const text = await readOrNull(fs, join(dir, STAGE_FILES.brief))
561
562 if (text !== null) {
563 brief.value = parseBrief(text, moduleIds)
564 cache.set(join(dir, STAGE_FILES.brief), { mtimeMs: brief.mtimeMs ?? 0, value: brief.value })
565 }
566 }
567
568 const reviews = parseLedger(reviewsRaw)
569
570 const isScan = findingsHead !== null && /generated-by:\s*modernize-harden/.test(findingsHead.split('\n')[0] ?? '')
571
572 const transformed = await readModernizedIn(fs, transformRoot, observed)
573
574 const track = pickTrack(options.track ?? 'auto', {
575 transformAt: newest(transformDirAt, ...transformed.map(module => module.mtimeMs)),
576 // A baseline says nothing of the track: a rewrite records one too, for the legacy behavior it must keep.
577 upliftAt: newest(catalog.mtimeMs, playbookAt, upliftNotesAt, upliftCopyAt),
578 reimagineAt: newest(specAt, designAt, reimagineDirAt),
579 })
580
581 const estate = await estateOf(fs, cache, track, topology, legacyRoot, system)
582
583 let modules: ModernizedModule[] = []
584 const byNode = new Map<string, ModernizedModule>()
585 const extras: ModernizedModule[] = []
586 let uplift: UpliftFacts | null = null
587
588 if (track === 'uplift') {
589 const hasCopy = upliftCopyAt !== null
590 const legacySizes = estate?.isPartial === false ? estate.fileSizes : undefined
591 const workingSizes = hasCopy && legacySizes !== undefined ? await workingSizesOf(fs, cache, upliftRoot, options.nowMs, options.changedTtlMs ?? 15_000) : null
592 const changedPaths = legacySizes !== undefined && workingSizes !== null ? changedPathsOf(legacySizes, workingSizes) : null
593 const changed = estate !== null && changedPaths !== null ? changedUnitsOf(estate, changedPaths) : new Set<string>()
594
595 for (const id of options.writtenUnits ?? []) {
596 changed.add(id)
597 }
598
599 const pairs =
600 estate !== null && hasCopy
601 ? await readUpliftUnits(fs, upliftRoot, estate, changed, baseline.value, observed)
602 : []
603
604 modules = pairs.map(pair => pair.module)
605
606 for (const pair of pairs) {
607 byNode.set(pair.unit.id, pair.module)
608 }
609
610 uplift = {
611 catalog: catalog.value,
612 baseline: baseline.value,
613 hasPlaybook: playbookAt !== null,
614 hasNotes: upliftNotesAt !== null,
615 hasCopy,
616 isChangeKnown: changedPaths !== null,
617 }
618 } else {
619 modules = track === 'reimagine' ? await readModernizedIn(fs, reimagineRoot, observed) : transformed
620
621 if (track === 'transform') {
622 const byKey = new Map<string, EstateUnit>()
623
624 for (const unit of estate?.units ?? []) {
625 for (const key of keysOfUnit(unit)) {
626 byKey.set(keyOf(key), unit)
627 }
628 }
629
630 for (const module of modules) {
631 const unit = byKey.get(keyOf(module.dir))
632
633 if (unit !== undefined) {
634 byNode.set(unit.id, module)
635 } else {
636 extras.push(module)
637 }
638 }
639 } else {
640 extras.push(...modules)
641 }
642 }
643
644 const snapshotFacts = { track, uplift }
645 const done = modules.filter(module => isUnitDone(snapshotFacts, module))
646
647 // Where each built module stands with the proof. An uplift is one piece: the whole working copy has the one verdict.
648 const verification = verificationRead.value
649 const proofs = new Map<string, ProofState>()
650
651 if (track === 'uplift') {
652 const name = `${system}-uplifted`
653 const proof = proofOfModule(verification, 'uplift', name, upliftNotesAt ?? 0, upliftNotesAt !== null)
654
655 if (proof !== null) {
656 proofs.set(name.toLowerCase(), proof)
657 }
658 } else {
659 for (const module of modules) {
660 const proof = proofOfModule(verification, track, module.dir, module.mtimeMs, module.hasNotes)
661
662 if (proof !== null) {
663 proofs.set(module.dir.toLowerCase(), proof)
664 }
665 }
666 }
667
668 const proofList = [...proofs.values()]
669 const isProofDone = (proof: ProofState) => proof.state === 'proven' || proof.state === 'partly'
670
671 const verifyStage: Stage[] =
672 proofList.length === 0 && track !== 'uplift' && modules.length === 0
673 ? []
674 : [
675 {
676 key: 'verify',
677 label: STAGE_LABELS.verify,
678 isDone: proofList.length > 0 && proofList.every(isProofDone),
679 mtimeMs: null,
680 ...(proofList.length > 0 && {
681 detail:
682 track === 'uplift'
683 ? (proofList[0]?.verdict ?? 'not verified yet').toLowerCase()
684 : `${proofList.filter(proof => proof.state === 'proven').length} of ${proofList.length} proven`,
685 }),
686 },
687 ]
688
689 const green = modules.filter(
690 module => module.state === 'tests-green' || module.state === 'reviewed' || isUnitDone(snapshotFacts, module),
691 )
692
693 const passing = green.reduce(
694 (sum, module) => sum + Math.max(0, (module.tests?.tests ?? 0) - (module.tests?.failures ?? 0) - (module.tests?.errors ?? 0)),
695 0,
696 )
697
698 const withFindings: Stage[] =
699 findingsHead !== null || track === 'transform'
700 ? [
701 {
702 key: 'harden',
703 label: STAGE_LABELS.harden,
704 // Any findings file counts. A harden marker on its first line says the command wrote it, and older or
705 // newer versions of the command may or may not stamp one, so its absence is not a reason to doubt the file.
706 isDone: findingsHead !== null,
707 mtimeMs: null,
708 },
709 ]
710 : []
711
712 const stages: Stage[] = [
713 ...(track === 'transform'
714 ? [
715 { key: 'preflight' as const, label: STAGE_LABELS.preflight, isDone: preflightAt !== null, mtimeMs: preflightAt },
716 { key: 'assess' as const, label: STAGE_LABELS.assess, isDone: assessAt !== null, mtimeMs: assessAt },
717 {
718 key: 'map' as const,
719 label: STAGE_LABELS.map,
720 isDone: topology !== null,
721 mtimeMs: topo.mtimeMs,
722 ...(topology !== null && { detail: `${topology.modules.length} modules` }),
723 },
724 {
725 key: 'rules' as const,
726 label: STAGE_LABELS.rules,
727 isDone: rules.value !== null && rules.value.rules.length > 0,
728 mtimeMs: rules.mtimeMs,
729 ...(rules.value !== null && { detail: `${rules.value.rules.length} rules` }),
730 },
731 {
732 key: 'brief' as const,
733 label: STAGE_LABELS.brief,
734 isDone: brief.value !== null,
735 mtimeMs: brief.mtimeMs,
736 ...(brief.value !== null && {
737 detail: brief.value.approval.isSigned ? 'approved' : 'unapproved',
738 }),
739 },
740 {
741 // Transform writes under modernized/, not analysis/: this stage is done once a module's tests are green.
742 key: 'transform' as const,
743 label: STAGE_LABELS.transform,
744 isDone: green.length > 0,
745 mtimeMs: null,
746 ...(modules.length > 0 && {
747 detail: green.length === 0 ? 'in progress' : passing > 0 ? `${passing} green` : `${green.length} module${green.length === 1 ? '' : 's'}`,
748 }),
749 },
750 ...verifyStage,
751 ]
752 : track === 'uplift'
753 ? [
754 { key: 'preflight' as const, label: STAGE_LABELS.preflight, isDone: preflightAt !== null, mtimeMs: preflightAt },
755 {
756 key: 'deltas' as const,
757 label: STAGE_LABELS.deltas,
758 isDone: catalog.mtimeMs !== null,
759 mtimeMs: catalog.mtimeMs,
760 ...(catalog.value !== null && { detail: `${catalog.value.count} deltas` }),
761 },
762 {
763 key: 'baseline' as const,
764 label: STAGE_LABELS.baseline,
765 isDone: baseline.mtimeMs !== null,
766 mtimeMs: baseline.mtimeMs,
767 ...(baseline.value !== null && {
768 detail: baseline.value.isTargetOnly
769 ? 'target-only'
770 : baseline.value.results !== null
771 ? `${baseline.value.results.toLocaleString('en-US')} tests`
772 : undefined,
773 }),
774 },
775 { key: 'pilot' as const, label: STAGE_LABELS.pilot, isDone: playbookAt !== null, mtimeMs: playbookAt },
776 {
777 key: 'migrate' as const,
778 label: STAGE_LABELS.migrate,
779 isDone: modules.length > 0 && playbookAt !== null,
780 mtimeMs: null,
781 ...(modules.length > 0 && { detail: `${modules.length} module${modules.length === 1 ? '' : 's'}` }),
782 },
783 {
784 key: 'compare' as const,
785 label: STAGE_LABELS.compare,
786 isDone: upliftNotesAt !== null,
787 mtimeMs: upliftNotesAt,
788 ...(modules.length > 0 && { detail: `${done.length}/${modules.length} match` }),
789 },
790 ...verifyStage,
791 ]
792 : [
793 { key: 'preflight' as const, label: STAGE_LABELS.preflight, isDone: preflightAt !== null, mtimeMs: preflightAt },
794 { key: 'spec' as const, label: STAGE_LABELS.spec, isDone: specAt !== null, mtimeMs: specAt },
795 { key: 'design' as const, label: STAGE_LABELS.design, isDone: designAt !== null, mtimeMs: designAt },
796 {
797 key: 'scaffold' as const,
798 label: STAGE_LABELS.scaffold,
799 isDone: modules.length > 0,
800 mtimeMs: null,
801 ...(modules.length > 0 && { detail: `${modules.length} service${modules.length === 1 ? '' : 's'}` }),
802 },
803 {
804 key: 'tests' as const,
805 label: STAGE_LABELS.tests,
806 isDone: modules.length > 0 && green.length === modules.length,
807 mtimeMs: null,
808 ...(modules.length > 0 && { detail: `${green.length}/${modules.length} green` }),
809 },
810 ...verifyStage,
811 ]),
812 ...withFindings,
813 ]
814
815 const loc = (estate?.units ?? []).reduce((sum, unit) => sum + Math.max(1, unit.size), 0)
816
817 const locDone = (estate?.units ?? []).reduce((sum, unit) => {
818 const module = byNode.get(unit.id)
819
820 return module !== undefined && isUnitDone(snapshotFacts, module) ? sum + Math.max(1, unit.size) : sum
821 }, 0)
822
823 const attention: string[] = []
824
825 for (const module of modules) {
826 const row = track === 'uplift' ? baselineRowOf(baseline.value, module.dir) : null
827
828 if ((module.state === 'tests-red' || module.state === 'tests-failing') && module.tests !== null) {
829 const bad = module.tests.failures + module.tests.errors
830
831 attention.push(
832 track === 'uplift'
833 ? row === null
834 ? `${module.dir}: ${bad} tests failing, and the baseline has no row for it to compare with`
835 : `${module.dir}: ${bad} failing, the baseline had ${row.fail + row.error}`
836 : `${module.dir}: ${bad} of ${module.tests.tests} tests red`,
837 )
838 }
839
840 if (module.tests !== null && module.tests.reports > 0 && module.tests.tests === 0) {
841 attention.push(`${module.dir}: test reports exist but 0 cases executed`)
842 }
843
844 if (track === 'transform' && module.state === 'tests-green' && module.hasNotes && !module.hasReviewSection) {
845 attention.push(`${module.dir}: tests green; the notes do not show the architecture review`)
846 }
847 }
848
849 // A check that failed or could not be completed says why, in the check's own first reason. PARTLY PROVEN lets the work go on: a person decides.
850 for (const [name, proof] of proofs) {
851 if (proof.state === 'not' || proof.state === 'partly') {
852 attention.push(`${track === 'uplift' ? 'the upgrade' : (modules.find(module => module.dir.toLowerCase() === name)?.dir ?? name)}: ${proof.verdict ?? ''}${proof.reason === '' ? '' : `: ${proof.reason}`}`)
853 }
854 }
855
856 for (const extra of extras) {
857 if (track === 'transform' && extra.hasMain && (!extra.hasNotes || !extra.hasTests)) {
858 attention.push(
859 `${extra.dir}: shared code without ${[!extra.hasTests && 'tests', !extra.hasNotes && 'notes'].filter(Boolean).join(' or ')}; an unfinished transform`,
860 )
861 }
862
863 if (track === 'reimagine' && extra.hasMain && !extra.hasTests) {
864 attention.push(`${extra.dir}: scaffolded with no acceptance tests`)
865 }
866 }
867
868 if (track === 'uplift' && uplift !== null) {
869 if (uplift.baseline?.isTargetOnly === true) {
870 attention.push('the baseline is target-only: the source runtime could not run, so nothing is compared')
871 }
872
873 if (modules.length > 0 && !uplift.hasPlaybook) {
874 attention.push(`${modules.length} module${modules.length === 1 ? '' : 's'} touched before a playbook was written: pilot one first`)
875 }
876
877 if (modules.length > 0 && uplift.baseline === null) {
878 attention.push('modules touched before a baseline was recorded: nothing to compare them with')
879 }
880 }
881
882 if (
883 brief.mtimeMs !== null &&
884 rules.mtimeMs !== null &&
885 brief.mtimeMs < rules.mtimeMs
886 ) {
887 attention.push('the brief is older than the rule set it was planned from')
888 }
889
890 if (rules.value !== null) {
891 const pending = rules.value.rules.filter(
892 rule => rule.priority === 'P0' && needsReview(rule) && reviews[rule.id] === undefined,
893 ).length
894
895 const wrong = Object.values(reviews).filter(entry => entry.verdict === 'wrong').length
896
897 // A P0 rule a reviewer sent to discussion stops the build commands until it is settled.
898 const discussing = rules.value.rules.filter(rule => rule.priority === 'P0' && reviews[rule.id]?.verdict === 'discuss').length
899
900 if (discussing > 0) {
901 attention.push(`${discussing} high-priority rule${discussing === 1 ? '' : 's'} under discussion: the build waits`)
902 }
903
904 if (wrong > 0) {
905 attention.push(`${wrong} rule${wrong === 1 ? '' : 's'} marked wrong by a reviewer: no test is built on ${wrong === 1 ? 'it' : 'them'}`)
906 }
907
908 if (pending > 0) {
909 attention.push(`${pending} high-priority rule${pending === 1 ? '' : 's'} still need${pending === 1 ? 's' : ''} a person's review`)
910 }
911 }
912
913 const next: NextStep | null =
914 track === 'uplift'
915 ? nextOfUplift(options.commandPrefix, system, stages, proofs)
916 : track === 'reimagine'
917 ? nextOfReimagine(options.commandPrefix, system, stages, modules, proofs)
918 : nextOfTransform(options.commandPrefix, system, stages, brief.value, byNode, analysisAt !== null, proofs)
919
920 return {
921 system,
922 systems,
923 track,
924 hasAnalysis: analysisAt !== null,
925 stages,
926 estate,
927 topology,
928 rules: rules.value,
929 brief: brief.value,
930 modules,
931 byNode,
932 extras,
933 uplift,
934 findings: { exists: findingsHead !== null, isScan },
935 verification,
936 proofs,
937 reviews,
938 percent: track === 'reimagine' || loc === 0 ? null : locDone / loc,
939 totals: {
940 modules: estate?.units.length ?? 0,
941 done: [...byNode.values()].filter(module => isUnitDone(snapshotFacts, module)).length,
942 loc,
943 locDone,
944 },
945 attention,
946 next,
947 readAtMs: options.nowMs,
948 }
949}
950
951/** One line for the status bar and the prompt's hidden context. */
952export function oneLineOf(snapshot: Snapshot): string {
953 // A rewrite's five analysis steps are what "analysis 5/5" counts: its build, its proof and its scan are not among them.
954 const countable = snapshot.stages.filter(stage => stage.key !== 'harden' && stage.key !== 'transform' && !(snapshot.track === 'transform' && stage.key === 'verify'))
955 const done = countable.filter(stage => stage.isDone).length
956
957 const parts =
958 snapshot.track === 'transform'
959 ? [`${snapshot.system}: analysis ${done}/5`]
960 : [`${snapshot.system}: ${TRACK_LABELS[snapshot.track]} ${done}/${countable.length}`]
961
962 if (snapshot.brief !== null) {
963 parts.push(snapshot.brief.approval.isSigned ? 'brief approved' : 'brief waiting for approval')
964 }
965
966 if (snapshot.totals.modules > 0 && snapshot.modules.length > 0) {
967 parts.push(
968 snapshot.track === 'reimagine'
969 ? `${snapshot.modules.length} service${snapshot.modules.length === 1 ? '' : 's'} built`
970 : `${snapshot.totals.done}/${snapshot.totals.modules} modules ${snapshot.track === 'uplift' ? 'match the baseline' : 'reviewed'}`,
971 )
972 }
973
974 const proven = [...snapshot.proofs.values()].filter(proof => proof.state === 'proven').length
975
976 if (proven > 0) {
977 parts.push(snapshot.track === 'uplift' ? 'proven to behave the same' : `${proven} proven`)
978 }
979
980 if (snapshot.attention.length > 0) {
981 parts.push(`${snapshot.attention.length} need${snapshot.attention.length === 1 ? 's' : ''} attention`)
982 }
983
984 return parts.join(' · ')
985}
986hooks/reader/topology.ts 244 lines1import { baseName, norm } from '../paths'
2
3/**
4 * `analysis/<system>/topology.json`, read into the few shapes the live pane uses.
5 *
6 * The file is written by an agent, so every field is treated as optional and
7 * of unknown shape: anything that does not fit is dropped, never thrown on.
8 */
9
10export type TopoNode = {
11 id: string
12 name: string
13 /** `module`, `datastore`, `screen`, or whatever the map stage wrote. */
14 kind: string
15 /** Lines of code; 0 when the map gave none. */
16 loc: number
17 /** Source file relative to `legacy/<system>/`, when the node is one file. */
18 file?: string
19 /** The name of the domain (first ancestor of kind `domain`) it sits under. */
20 domain?: string
21 /** The language the map recorded for it, as written (`cobol`, `java`, `php`). */
22 language?: string
23}
24
25export type TopoEdge = { source: string; target: string; kind: string }
26
27export type TopoFlow = {
28 name: string
29 persona?: string
30 steps: { label: string; nodes: string[] }[]
31}
32
33export type Topology = {
34 system: string
35 nodes: TopoNode[]
36 /** Nodes that are code (`kind: module`, or any leaf with a file and loc). */
37 modules: TopoNode[]
38 domains: { name: string; modules: TopoNode[] }[]
39 edges: TopoEdge[]
40 entryPoints: Set<string>
41 deadEnds: Set<string>
42 flows: TopoFlow[]
43 byId: Map<string, TopoNode>
44 /** Lower-cased base name of a node's file, to the nodes that have it. */
45 byFileBase: Map<string, TopoNode[]>
46}
47
48const str = (value: unknown): string | undefined =>
49 typeof value === 'string' && value !== '' ? value : undefined
50
51const isRecord = (value: unknown): value is Record<string, unknown> =>
52 typeof value === 'object' && value !== null && !Array.isArray(value)
53
54const list = (value: unknown): unknown[] => (Array.isArray(value) ? value : [])
55
56function walk(
57 raw: unknown,
58 domain: string | undefined,
59 out: TopoNode[],
60 depth: number,
61): void {
62 if (!isRecord(raw) || depth > 12) {
63 return
64 }
65
66 const id = str(raw.id) ?? str(raw.name)
67
68 if (id === undefined) {
69 return
70 }
71
72 const kind = str(raw.kind) ?? 'node'
73 const name = str(raw.name) ?? id
74 const children = list(raw.children)
75 const here = kind === 'domain' ? name : domain
76 const loc = typeof raw.loc === 'number' && raw.loc > 0 ? raw.loc : 0
77 const file = str(raw.file)
78 const language = str(raw.language)
79
80 if (children.length === 0 || kind === 'module') {
81 out.push({
82 id,
83 name,
84 kind,
85 loc,
86 ...(file !== undefined && { file: norm(file) }),
87 ...(here !== undefined && { domain: here }),
88 ...(language !== undefined && { language }),
89 })
90 }
91
92 for (const child of children) {
93 walk(child, here, out, depth + 1)
94 }
95}
96
97/** Parses topology.json; null when the text is not a usable topology. */
98export function parseTopology(text: string): Topology | null {
99 let raw: unknown
100
101 try {
102 raw = JSON.parse(text)
103 } catch {
104 return null
105 }
106
107 if (!isRecord(raw)) {
108 return null
109 }
110
111 const nodes: TopoNode[] = []
112
113 if (isRecord(raw.root)) {
114 walk(raw.root, undefined, nodes, 0)
115 }
116
117 for (const flat of list(raw.nodes)) {
118 walk(flat, undefined, nodes, 0)
119 }
120
121 if (nodes.length === 0) {
122 return null
123 }
124
125 const byId = new Map<string, TopoNode>()
126
127 for (const node of nodes) {
128 if (!byId.has(node.id)) {
129 byId.set(node.id, node)
130 }
131 }
132
133 const unique = [...byId.values()]
134
135 const modules = unique.filter(
136 node =>
137 node.kind === 'module' ||
138 (node.file !== undefined && node.loc > 0 && node.kind !== 'datastore'),
139 )
140
141 const byDomain = new Map<string, TopoNode[]>()
142
143 for (const node of modules) {
144 const key = node.domain ?? 'Other'
145 const bucket = byDomain.get(key) ?? []
146
147 bucket.push(node)
148 byDomain.set(key, bucket)
149 }
150
151 const byFileBase = new Map<string, TopoNode[]>()
152
153 for (const node of unique) {
154 if (node.file === undefined) {
155 continue
156 }
157
158 const key = baseName(node.file).toLowerCase()
159 const bucket = byFileBase.get(key) ?? []
160
161 bucket.push(node)
162 byFileBase.set(key, bucket)
163 }
164
165 const edges: TopoEdge[] = []
166
167 for (const edge of list(raw.edges)) {
168 if (!isRecord(edge)) {
169 continue
170 }
171
172 const source = str(edge.source) ?? str(edge.from)
173 const target = str(edge.target) ?? str(edge.to)
174
175 if (source !== undefined && target !== undefined) {
176 edges.push({ source, target, kind: str(edge.kind) ?? 'uses' })
177 }
178 }
179
180 const flows: TopoFlow[] = []
181
182 for (const flow of list(raw.flows)) {
183 if (!isRecord(flow)) {
184 continue
185 }
186
187 const name = str(flow.name)
188
189 if (name === undefined) {
190 continue
191 }
192
193 const persona = str(flow.persona)
194
195 flows.push({
196 name,
197 ...(persona !== undefined && { persona }),
198 steps: list(flow.steps)
199 .filter(isRecord)
200 .map(step => ({
201 label: str(step.label) ?? '',
202 nodes: list(step.nodes).filter(
203 (node): node is string => typeof node === 'string',
204 ),
205 })),
206 })
207 }
208
209 const ids = (value: unknown) =>
210 new Set(list(value).filter((id): id is string => typeof id === 'string'))
211
212 return {
213 system: str(raw.system) ?? '',
214 nodes: unique,
215 modules,
216 domains: [...byDomain.entries()].map(([name, members]) => ({
217 name,
218 modules: members,
219 })),
220 edges,
221 entryPoints: ids(raw.entryPoints),
222 deadEnds: ids(raw.deadEnds),
223 flows,
224 byId,
225 byFileBase,
226 }
227}
228
229/**
230 * The topology node a legacy file belongs to: by its path relative to
231 * `legacy/<system>/` when the map recorded one, else by base name alone.
232 */
233export function nodeOfFile(topo: Topology, fileRel: string): TopoNode | null {
234 const wanted = norm(fileRel)
235 const candidates = topo.byFileBase.get(baseName(wanted).toLowerCase()) ?? []
236
237 return (
238 candidates.find(node => node.file === wanted) ??
239 candidates.find(node => wanted.endsWith(node.file ?? '\0')) ??
240 candidates[0] ??
241 null
242 )
243}
244hooks/review/deck.ts 128 lines1import type { ReviewLedger, ReviewVerdict } from '../reader/progress'
2import { needsReview, type Rule, type RuleSet } from '../reader/rules'
3
4/**
5 * The rule review deck: the rules a person should look at, in the order they
6 * matter, and the ledger of what that person decided. The ledger is a file
7 * of its own beside the rules (`RULE_REVIEWS.json`, with a readable
8 * `RULE_REVIEWS.md`), so the agent-written rules file is never edited.
9 */
10
11export type DeckScope = 'flagged' | 'p0' | 'all'
12
13const weight = (rule: Rule): number =>
14 (rule.priority === 'P0' ? 0 : rule.priority === 'P1' ? 10 : 20) +
15 (rule.sme !== undefined ? 0 : 2) +
16 (rule.defect !== undefined ? 0 : 1) +
17 (rule.confidence !== undefined && rule.confidence !== 'High' ? 0 : 1)
18
19/** The rules to review under `scope`, optionally only those citing a file or under a domain. */
20export function queueOf(rules: RuleSet, scope: DeckScope, filter = ''): Rule[] {
21 const wanted = filter.trim().toLowerCase()
22
23 return rules.rules
24 .filter(rule => {
25 if (scope === 'flagged' && !(rule.priority === 'P0' && needsReview(rule))) {
26 return false
27 }
28
29 if (scope === 'p0' && rule.priority !== 'P0') {
30 return false
31 }
32
33 if (wanted === '') {
34 return true
35 }
36
37 return (
38 rule.id.toLowerCase() === wanted ||
39 (rule.domain ?? '').toLowerCase().includes(wanted) ||
40 rule.title.toLowerCase().includes(wanted) ||
41 rule.citations.some(citation => citation.base.includes(wanted)) ||
42 // What the rule says and what is suspected of it: "truncat" finds the rounding defect whatever its title.
43 [rule.statement, rule.then, rule.defect].some(text => (text ?? '').toLowerCase().includes(wanted))
44 )
45 })
46 .map((rule, index) => ({ rule, index }))
47 .sort((a, b) => weight(a.rule) - weight(b.rule) || a.index - b.index)
48 .map(entry => entry.rule)
49}
50
51/** The first card at or after `from` that has no verdict yet; `from` itself when all have. */
52export function nextUnreviewed(queue: readonly Rule[], ledger: ReviewLedger, from: number): number {
53 for (let step = 0; step < queue.length; step += 1) {
54 const index = (from + step) % queue.length
55 const rule = queue[index]
56
57 if (rule !== undefined && ledger[rule.id] === undefined) {
58 return index
59 }
60 }
61
62 return Math.min(Math.max(0, from), Math.max(0, queue.length - 1))
63}
64
65/** `ledger` with `rule` decided. */
66export function decide(
67 ledger: ReviewLedger,
68 rule: Rule,
69 verdict: ReviewVerdict,
70 atIso: string,
71): ReviewLedger {
72 // The reviewer's own words on the rule stay through a new verdict: the deck decides, it does not rewrite what was said.
73 const note = ledger[rule.id]?.note
74
75 return { ...ledger, [rule.id]: { verdict, at: atIso, title: rule.title, ...(note !== undefined && { note }) } }
76}
77
78/** `ledger` with `rule`'s verdict taken back. */
79export function undecide(ledger: ReviewLedger, ruleId: string): ReviewLedger {
80 const { [ruleId]: _gone, ...rest } = ledger
81
82 return rest
83}
84
85export const tallyOf = (queue: readonly Rule[], ledger: ReviewLedger) => ({
86 total: queue.length,
87 confirmed: queue.filter(rule => ledger[rule.id]?.verdict === 'confirmed').length,
88 wrong: queue.filter(rule => ledger[rule.id]?.verdict === 'wrong').length,
89 discuss: queue.filter(rule => ledger[rule.id]?.verdict === 'discuss').length,
90 open: queue.filter(rule => ledger[rule.id] === undefined).length,
91})
92
93/** The ledger as `RULE_REVIEWS.json` stores it. */
94export function ledgerJson(system: string, ledger: ReviewLedger): string {
95 return `${JSON.stringify({ system, version: 1, reviews: ledger }, null, 2)}\n`
96}
97
98const VERDICT_WORDS: Record<ReviewVerdict, string> = {
99 confirmed: 'Confirmed',
100 wrong: 'Wrong',
101 discuss: 'Needs discussion',
102}
103
104/** One table cell: one line, no pipe that would end it. */
105const cell = (text: string | undefined): string => (text ?? '').replace(/\s+/g, ' ').replace(/\\/g, '\\\\').replace(/\|/g, '\\|').trim()
106
107/** The ledger as a page a person or a model reads: `RULE_REVIEWS.md`. */
108export function ledgerMarkdown(system: string, ledger: ReviewLedger): string {
109 const entries = Object.entries(ledger).sort((a, b) => a[0].localeCompare(b[0], 'en', { numeric: true }))
110 const count = (verdict: ReviewVerdict) => entries.filter(([, entry]) => entry.verdict === verdict).length
111
112 return [
113 `# Rule reviews: ${system}`,
114 '',
115 `A person's verdict on individual business rules in \`BUSINESS_RULES.md\`, recorded from the review deck. ${count('confirmed')} confirmed, ${count('wrong')} wrong, ${count('discuss')} needing discussion.`,
116 '',
117 'A rule marked **Wrong** or **Needs discussion** is not settled: do not build on it until it is resolved. A rule with no row here has not been reviewed.',
118 '',
119 '| Rule | Verdict | When | Title | Note |',
120 '|---|---|---|---|---|',
121 ...entries.map(
122 ([id, entry]) =>
123 `| ${id} | ${VERDICT_WORDS[entry.verdict]} | ${entry.at.slice(0, 10)} | ${cell(entry.title)} | ${cell(entry.note)} |`,
124 ),
125 '',
126 ].join('\n')
127}
128hooks/review/ledger.ts 79 lines1import { plain } from '../text'
2import type { ReviewLedger, ReviewVerdict } from '../reader/progress'
3
4/**
5 * `RULE_REVIEWS.json`, read without trusting it. Two writers keep it: this deck, and the `modernize-review` command
6 * for people who work without the pane. Each entry is `{ verdict, at, title?, note? }`, the note being the reviewer's
7 * own words. The file sits in a workspace anyone may have edited, and a model may have written it, so every value is
8 * checked: a verdict is one of three exact words, a rule id is one plain token, a title or a note is cut to one short
9 * plain line, and anything else in an entry is dropped.
10 */
11
12const VERDICTS: readonly string[] = ['confirmed', 'wrong', 'discuss']
13
14/** What a note may hold: a reviewer's sentence or two, not a document. */
15export const MAX_NOTE = 500
16const MAX_TITLE = 120
17const MAX_ENTRIES = 5_000
18const RULE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,39}$/
19const FORBIDDEN_ID = new Set(['__proto__', 'constructor', 'prototype'])
20
21const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value)
22
23/** The ledger a file's text holds; empty when it is not one. */
24export function parseLedger(text: string | null): ReviewLedger {
25 if (text === null || text.length > 4_000_000) {
26 return {}
27 }
28
29 let raw: unknown
30
31 try {
32 raw = JSON.parse(text)
33 } catch {
34 return {}
35 }
36
37 if (!isRecord(raw) || !isRecord(raw.reviews)) {
38 return {}
39 }
40
41 const ledger: ReviewLedger = {}
42
43 for (const [id, item] of Object.entries(raw.reviews).slice(0, MAX_ENTRIES)) {
44 if (!RULE_ID.test(id) || FORBIDDEN_ID.has(id) || !isRecord(item) || typeof item.verdict !== 'string' || !VERDICTS.includes(item.verdict)) {
45 continue
46 }
47
48 const title = typeof item.title === 'string' ? plain(item.title, MAX_TITLE) : ''
49 const note = typeof item.note === 'string' ? plain(item.note, MAX_NOTE) : ''
50 // A time is kept only when it is one: the page shows its date.
51 const at = typeof item.at === 'string' && /^\d{4}-\d{2}-\d{2}/.test(item.at) ? item.at.slice(0, 40) : ''
52
53 ledger[id] = { verdict: item.verdict as ReviewVerdict, at, ...(title !== '' && { title }), ...(note !== '' && { note }) }
54 }
55
56 return ledger
57}
58
59/**
60 * The file's ledger with this session's decisions on top. `edits` maps a rule id to what the deck set it to, or null when the
61 * deck took its verdict back. What the other writer put in the file meanwhile stays, and a note the deck did not write is
62 * kept on a rule the deck decided again.
63 */
64export function mergeLedger(onDisk: ReviewLedger, edits: ReadonlyMap<string, ReviewLedger[string] | null>): ReviewLedger {
65 const merged: ReviewLedger = { ...onDisk }
66
67 for (const [id, edit] of edits) {
68 if (edit === null) {
69 delete merged[id]
70 } else {
71 const note = edit.note ?? onDisk[id]?.note
72
73 merged[id] = { ...edit, ...(note !== undefined && { note }) }
74 }
75 }
76
77 return merged
78}
79