Skip to content

Campaigns

Most debt paydown is an incremental migration: every class component to hooks, every page under pages/ to app/, every bounded context out of the mud and behind a port. The work is too big for one change, so it is cut into tickets, and the tickets become the only record of how far it has got. Nothing static enforces that a count goes down; nothing tells someone editing a file that the file is inside a migration, or which step it is at; nothing notices when a migration has quietly stopped.

campaigns makes the refactor an object in the repository:

A campaign over a scope pursues objectives across sectors, each recognized by its perimeter, through phases — defined or open — toward an end, as the legacy shrinks.

An objective is a detector with a ledger that only shrinks on its own: a pattern the code is moving away from, every place it still occurs recorded, growth refused unless it comes with a reason. A sector is the piece of code that moves through the campaign as one — a bounded context, a component folder, a single file — and the code declares it, through the campaign’s perimeter; the manifest never lists one. A phase is a named, ordered group of objectives: a sector stands at the first phase whose objectives still have holdouts in it, and the last phase is the end. And the thing the rest exists to serve is the nudge: architecture campaigns status --changed tells a person, or an agent working on their behalf, which campaign the files they touched belong to, what phase that sector is at, and what small concrete thing in the touched files would move it toward the next.

The minimal campaign is one objective, no perimeter, no phases — what this repository runs on itself:

campaigns:
lowering-reports-not-throws:
why: "`loadPolicy` returns a `Result`, but a manifest that lowers badly escapes it as a thrown `Error`."
how: "Return `Result.fail(new ConfigInvalid({ configPath, detail }))` and let `loadPolicy` pass it up."
owner: "@dataquail"
scope: ["packages/core/src/manifest/**"]
staleAfter: 90d
objectives:
throws-in-lowering:
holdout: match
match:
all:
- path: { file: "\\.ts$", fileNot: "\\.test\\.ts$" }
- syntax: { pattern: "throw new Error($$$)" }
probes:
fires:
- path: packages/core/src/manifest/zzprobe.ts
source: 'const lower = () => { throw new Error("bad manifest"); };'
ignores:
- path: packages/core/src/manifest/zzprobe.ts
source: "const lower = () => Result.fail(new ConfigInvalid({ configPath, detail }));"

The full shape — the design’s ball-of-mud-to-DDD campaign — is a perimeter that births a sector per bounded context, a ladder of phases each recognized by criteria, a transitional shape the end forbids, a step no analysis sees, and an open phase where the plan runs out:

campaigns:
billing-ddd:
scope: src/**
legacy: src/services/** # what no sector has claimed; stands at `domain`
perimeter:
marker: "**/context.ts" # names the bounded context, may list the globs it owns
onTouch: ratchet # growth in a touched sector is refused; the default for a defined phase
phases:
- id: domain
intent: a domain module with unit tests, no I/O
objectives: [no-io-in-domain, domain-has-unit-tests]
- id: repository
intent: a repository behind a port, one migration, an integration test
objectives: [no-raw-queries-outside-repository, has-migration, has-integration-test]
- id: dual-write
intent: the feature flag writes to both tables; nothing reads the new one yet
objectives: [has-flag, has-both-writes] # windowed: gone by `cutover`
- id: backfilled
intent: legacy rows copied into the new table and verified
attested: true # no detector sees a backfill; `campaigns attest` leaves this phase
- id: cutover
intent: the flag and the legacy write path are removed
objectives: [no-flag, no-legacy-write]
- id: aggregates # open, and therefore last: no objectives yet
intent: >
Model refunds and disputes inside the billing aggregate, or split them out —
undecided until two contexts have reached `cutover`.
objectives:
no-io-in-domain:
holdout: declaration
match:
{
all:
[
{ path: { file: "/domain/" } },
{ members: { subject: calls, name: "readFileSync" } },
],
}
probes: { fires: [{ path: src/billing/domain/a.ts, source: "readFileSync('x')" }] }
domain-has-unit-tests: # per file: each domain file owes a sibling test
holdout: file
match:
{
all:
[{ path: { file: "/domain/.*\\.ts$" } }, { not: { requires: ["{base}.test.ts"] } }],
}
probes: { fires: [{ path: src/billing/domain/a.ts }] }
no-raw-queries-outside-repository:
holdout: match
match:
{
all:
[{ not: { path: { file: "/repository/" } } }, { syntax: { pattern: "knex($$$)" } }],
}
probes: { fires: [{ path: src/billing/a.ts, source: "knex('x')" }] }
has-migration: # a presence: the sector is the holdout until one file in it matches
holdout: sector
sector: { has: { path: { file: "/migrations/.*\\.ts$" } } }
has-integration-test:
holdout: sector
sector: { has: { path: { file: "/repository/.*\\.integration\\.test\\.ts$" } } }
has-flag: # a transitional shape: must be there in `dual-write`, must be gone by `cutover`
holdout: sector
until: cutover
sector: { has: { content: { regex: "flags\\.billingDualWrite" } } }
probes: { fires: [{ path: src/billing/a.ts, source: "flags.billingDualWrite" }] }
has-both-writes:
{
holdout: sector,
until: cutover,
sector: { has: { content: { regex: "writeBoth" } } },
probes: { fires: [{ path: src/billing/a.ts, source: "writeBoth()" }] },
}
no-flag:
{
holdout: file,
match: { content: { regex: "flags\\.billingDualWrite" } },
probes: { fires: [{ path: src/billing/a.ts, source: "flags.billingDualWrite" }] },
}
no-legacy-write:
{
holdout: match,
match: { syntax: { pattern: "legacyBilling.write($$$)" } },
probes: { fires: [{ path: src/billing/a.ts, source: "legacyBilling.write(x)" }] },
}
Field Meaning
why the rationale, printed by explain and conformance
how what a reader at a holdout does about it — the message every hit carries, unless the objective states its own
owner who is running it; printed in the status table
scope which files it sees: alias-aware globs, as graph takes; or { path, extensions } to widen the walk — see The scope
legacy which unclaimed files count as legacy; omit for all of them — see Sectors
perimeter how a sector is recognized — see Sectors
onTouch what a touched sector owes: advise, ratchet or paydown — see The nudge
phases the ladder, in order — see Phases
endState sugar for the last phase: a sector-relative node tree — see The end state
objectives the detectors, keyed by id — see Objectives
staleAfter 14d, 12h: how long without progress before the report calls it stalled; omit for never
onComplete keep (the default — stays as a guard against recurrence) or remove (check fails until it is deleted)

Everything above objectives is optional. A campaign’s findings are of kind campaign, their rule name is campaign/<campaign>/<objective>, and they are judged against the objective’s ledger, never against the baseline.

An objective is a detector, a granularity, probes, and a ledger that only shrinks on its own. It belongs to one campaign and is named by at most one phase; an objective no phase names is in window in every phase, from the legacy to the end — the campaign’s standing measures.

Field Meaning
holdout what one ledger entry is — file, declaration, match or sector — see The holdout
match a per-file detector: all, any, not over the terms; evaluated by both hosts
sector a term over the sector’s files, instead of match: has, oneRoot or oneHost — see Sector terms
until the phase at which the objective stops counting, exclusive — see Windows
how this objective’s own message, over the campaign’s
probes sources it must fire on and stay silent on — see Probes; required with match

Exactly one of match and sector. Sharing one detector across campaigns goes through the manifest’s defs/use; there is no top-level pool of objectives.

An objective declares what a holdout is, and that decides both what its ledger entries look like and what a term from another level means.

Holdout A holdout is… Entry Fingerprint subject
file a whole file file (none)
declaration a named declaration in the file file#Name the declaration’s name
match one matched expression file#Anchor#hash the enclosing declaration and a short hash of the matched text
sector the sector itself ~ (none) — for a sector term: one while it fails, zero once it holds

Entries are written relative to the sector’s root — the marker’s folder, the matched glob, the file’s own folder, or the repository for the implicit sector and the legacy — so a lift from hapi/src/billing/ to nest/src/billing/ moves nothing in the ledger. Fingerprints anchor on declarations, never on positions, so an entry survives a reformat as a baseline entry does. A match entry’s hash changes when the matched text changes; an edit elsewhere inside the same declaration leaves the anchor in place, and check treats the entry as the same one — objectives clear rewrites the hash without counting it as progress. A match at the top level of a file has no anchor and is keyed by its hash alone.

Every term answers at one level. path, imports, requires, content and a fn that returns a boolean are statements about the file; exports and members are about a declaration; syntax, report and a fn that returns a list are about a match. The holdout decides what a term from another level means:

  • In a file objective every term is existential — the file contains one — and not is contains none. The objective yields at most one hit per file.
  • In a declaration or match objective, the candidates are what the declaration- and match-level terms produce. A file-level term is a filter over all of them: true admits every candidate, false admits none. A declaration-level term in a match objective speaks to the match’s anchor; a match-level term in a declaration objective sources the declaration each match sits in.
  • not is the complement within those candidates. all: [syntax A, not syntax B] is the declarations A matches that B does not. not syntax B alone is nothing at all — there is no universe of “every other declaration” to draw from — and a probe will say so at load.

A sector is never listed in the manifest. It is born by the perimeter, which has five forms, because the pieces that move through a refactor as one come in four sizes and one of them may already be named by a project system:

Perimeter Sectors Identity
{ marker } one per file matching the glob — **/context.ts, **/port.ts — which may export a sector object naming it and listing the globs it owns: export const sector = { name: "billing", owns: ["src/controllers/billing*", "src/services/billing/**"] }. Without one, the folder holding the marker is the sector, named after the folder. Read, never executed. the name
{ glob } one per match: src/components/*/ the matched path
{ match, probes } one per detector match — every exported page, every exported component — at the holdout named (declaration by default). The perimeter must recognize a sector in every phase, not only the shape the campaign is leaving, so at least one of its fires probes must be a sector in its end shape: one no objective fires on. Refused at load otherwise. file#declaration
file one per file, for a refactor where a file is the thing that moves the path without its extension
nx the workspace’s projects, read from their project.json files the project’s name

The owns list exists because a legacy tree is usually laid out by technical concern — controllers/, services/, repositories/ — so the piece that must move together is spread over several folders, and a sector that could only be born by co-locating first would demand the hardest step before the first measurement. A name declared by two markers is one sector with both roots. A match perimeter matching “class component” would un-birth the sector the moment the class was gone, which is the moment the first phase was met: the perimeter for a pages-to-app-router campaign is “an exported component, in either shape”, and the first phase’s objective is the old shape. file identity drops the extension so a.js → a.ts is one sector before and after — the one transition that campaign exists for.

The scope is one implicit sector. A campaign with no perimeter still has a sector, named scope, rooted at the repository — so phases, windows and the per-sector ledger apply to a sector-less campaign without a second code path, and a library-swap campaign, whose phases are campaign-wide, is written with none.

Legacy is the remainder, and stands at the first phase. Everything in the scope no sector claims is the sector legacy. The first phase’s objectives are in window for it, so a holdout moved from a sector into the legacy is unrecorded growth there, not a disappearance; the legacy is not a sink. legacy: <globs> narrows which unclaimed files count; an unclaimed file outside them is in no sector at all. A file in two sectors — nested perimeters — fails check, as a folder under src/ that no node governs fails today. Deleting a marker returns its folder to the legacy; the nudge names it on the diff that does it.

scope is the globs the campaign sees. Written as { path, extensions } it also widens the walk: { path: src/**, extensions: [.js, .jsx] } lets a JS-to-TS campaign see the JavaScript half a TypeScript pack does not visit. A file the pack cannot parse contributes no facts, so an objective over such files is path-only, or report- or fn-backed. Only the campaign that widened the walk sees those files; no other family does, in either host.

A phase is a named, ordered group of objectives. It is defined when it names at least one objective (or is attested, or carries an endState) and open when it carries only an intent — and then it is last. Phases are not nested and carry no policy of their own; a phase’s criteria are its objectives. The loader refuses an open phase that is not last, an objective named by two phases, and a phase naming an objective the campaign does not declare.

Field Meaning
id kebab-case
intent prose: what the phase is for. Required on an open phase; welcome on a defined one. What the nudge says when there are no criteria yet
objectives the ids this phase is recognized by
attested true for a step no detector sees — a backfill, a flag flip — left by campaigns attest
onTouch this phase’s word over the campaign’s
endState a sector-relative node tree expanding into this phase’s structural objectives — see The end state
concessions [{ reason, at, by? }]: the receipt a change to a defined phase needs — see Changing a defined phase

A sector’s phase is derived, never declared: the first phase, in order, with residue for that sector — an objective the phase names with a holdout in the sector, an attested phase not yet attested for it, or the open phase, which always has residue. A sector past the last phase of a ladder that ends defined is done. This can move down when a plan edit adds an objective to an earlier phase, which is correct behaviour under a fluid plan. There is no commitment file and no advance verb; the stall clock is what says nobody is paying.

Reached. What derivation cannot say is where a sector has been, and windows need that. So objectives clear records, in the sector’s own file, the furthest phase it has ever been derived at — derived by the tool, written on the run, checked by check, never edited by hand.

Why every phase has teeth. The obvious way to game “legacy is shrinking” is relocation: move a 2,000-line service into a folder with a context.ts. Because the first phase has real objectives, that folder is a sector at phase one with a hundred holdouts, the phase distribution shows a sector that just appeared at the bottom, and the legacy line shrinks by the same amount — both are reported, and the report never collapses them into one number.

An objective counts for a sector from the phase that names it until the phase its until names, exclusive; the default is to the end. That is how a phase recognizes a shape the end forbids: has-flag is a holdout while the flag is missing during dual-write, and stops counting at cutover, where no-flag starts. When a sector enters an objective’s window, its occurrences are recorded as that sector’s initial by objectives clear — growth that is the plan working, not a regression. When a sector leaves a window, clear records its remaining holdouts as closed, not cleared, so a window shutting is never counted as progress and never moves the stall clock. And a window whose until is at or before the sector’s reached is shut for that sector for good: when a later plan edit sends the sector back down the ladder, the transitional shapes it has already been through are not demanded again. The loader refuses an until naming a phase that does not exist, or one at or before the naming phase.

Expected growth is a window; unexpected growth is a concession. A parallel-change step raises a count on purpose — the shim that calls the old API, the flag that writes twice. Under a ledger that only shrinks, every such step is a regression demanding a reason, which is noise; the fix is to say where the growth belongs. When the growth is a place rather than a class — one shim file — narrowing the detector is the right tool, not a phase.

A phase with attested: true has no detector; it is left by architecture campaigns attest <sector> <phase> --reason "<why>" [--evidence <url>], which writes a dated entry in the sector’s file. The tool cannot see a backfill or a flag flip; it can insist that someone said so, on the record. An attestation is accepted only while the sector’s derived phase is that phase, so it cannot be recorded ahead and passed through on arrival.

An open phase collects: the intent is what the nudge says, and architecture campaigns note <sector> "…" appends a dated remark for the next person or agent, recorded under the phase it was left at. Refining an open phase into a defined one — adding its first objective, with a probe — is free, and the ledger records the sector initials on the next clear; notes left under the phase before it was refined leave the nudge and stay in the file. The conformance report flags this as plan refined, apart from plan changed, so the one a reviewer must read every time stays readable.

Any change to a defined phase’s objectives, an objective’s detector, or its position fails check unless the phase carries a new concessions entry naming a reason, dated. The tool does not judge whether the new criteria are weaker — that is not decidable in general and wrong on renames — so this is code review with a receipt. One receipt is the whole price: the concession authorizes the next objectives clear to re-baseline the sectors already in that phase’s window, recording { from, to, reason } in each ledger, so a widened detector does not also demand a concession per new hit. What check compares against is the plan objectives clear last wrote, in .architecture-campaigns/<campaign>/plan.json.

Three terms are quantified over the sector’s files rather than answered by one, and an objective holding one has holdout: sector: its holdout is the sector itself, one while the term fails and zero once it holds.

Term Holds when…
has: <detector> at least one file in the sector satisfies the detector — a presence: a migration, an integration test, a flag. What a per-file detector cannot say, because a holdout is a thing that must go to zero and a presence a thing that must exist
oneRoot: true every file of the sector sits under the root its perimeter was found at — co-located, which is what an endState needs
oneHost: <glob> every file of the sector matches the host — lifted whole

They need the sector’s file list and nothing else, so the CLI reports them; the plugin, which sees one file, never reports one and reads its effect on the sector’s phase through the ledger.

For a campaign whose end is a layering — an onion, CQRS, the strangler’s new host — writing structural objectives by hand is tedious and error-prone. endState is one ordinary node tree whose root ~/ is the sector rather than the repository, written on a phase (or on the campaign, where it belongs to the last phase; a campaign with no phases gets one, end). It expands into one objective per family it lowers to — end-state-imports, end-state-members, end-state-surface, end-state-structure, end-state-exports — each with holdout: declaration, keyed file#subject from the sector’s root. It is compiled and probed once at an abstract root, so a tree whose rules cannot fire is refused at load like any other, and lowered once per sector with the sector’s root as root when the CLI evaluates it.

endState:
~/:
surface: [{ message: "no default exports", kinds: [default] }]
children:
"domain/":
{
layout: open,
children: {},
imports: { allow: ["~/domain/**"] },
members: [{ use: no-file-system-calls }],
}
"ports/": { layout: open, children: {}, imports: { allow: ["~/domain/**"] } }
"adapters/":
{
layout: open,
children: {},
imports: { allow: ["~/ports/**", { sector: "*", via: "~/ports/" }] },
}

The one allow entry the sector-relative tree adds is { sector, via } — another sector (* for any), through its port: for each other sector it expands to that sector’s via folder. An end state needs a sector with one root; a campaign whose perimeter is a marker (which may list globs) is refused one at load, and oneRoot as an earlier phase’s objective is how it gets there. The plugin never evaluates an end state; its residue reaches a file only as the phase it puts the sector at.

Term Shape Holds when…
path { file, fileNot?, subject?, convention? } the repo-relative path matches file (a regular expression) and not fileNot; with subject and convention, the capture group named has the convention’s shape
imports { resolves, symbols? } some import of the file resolves to the target — a path glob, { external: <package> } or { builtin: <module> } — and, with symbols, pulls one of those names across it
exports { name?, kinds?, declares?, reexport? } an export site the surface selectors admit
members { subject, name?, in?, declares? } a member or called name the members selectors admit; calls is about the file, members about the declaration it is in
requires ["{base}.test.ts", …] every named sibling exists — the structure parity strings
content { regex } the regular expression matches the file’s text (multiline)
syntax an ast-grep rule, plus where the engine finds a node — see The syntax term
report { command | file, format, pattern?, codes?, codesNot? } another program reported a diagnostic on the file — see The report term
fn "module#export" the function answers true, or lists subjects — see The function term

Each term is an object with exactly one key, so a misspelled one is a decode error naming the line rather than a term that quietly matches nothing. path.convention is one of kebab-case, camelCase, PascalCase, snake_case or { regex }; the path term reads positively — the name has this shape — so a rename campaign detects the old shape.

all, any and not compose terms, and each other, to any depth — the same three words ast-grep uses inside a rule.

rsc-boundaries:
why: A component that uses client-only APIs must say so, or the server render fails at runtime.
how: Add the `use client` directive at the top of the file, or move the hook into a client component.
scope: ["app/**"]
staleAfter: 30d
objectives:
client-boundary:
holdout: file
match:
all:
- not: { content: { regex: '^["'']use client["'']' } }
- any:
- syntax: { pattern: "$HOOK($$$)", where: { HOOK: { regex: "^use[A-Z]" } } }
- imports: { resolves: { external: framer-motion } }
- fn: ./campaigns/rsc.mjs#needsClientBoundary
probes:
fires: [{ path: app/x.tsx, source: "export default () => { useState(); }" }]

Terms are evaluated cheapest first — the path, then the facts the parser already produced (imports, exports, members), then the file system (requires), the text (content), the syntax tree (syntax, parsed once per file and shared by every objective), and last a predicate function. An all stops at the first file-level term that fails, so a path term in front of a syntax term is what keeps the parse off files the objective was never about.

The syntax term is an ast-grep rule object — pattern, kind, regex, has, inside, precedes, follows, nthChild, all, any, not — with where beside it. Metavariables are $NAME for one node and $$$ for any number. A pattern is matched structurally, so class $NAME extends $BASE { $$$ } finds a class whatever its body says, and $HOOK($$$) finds every call.

where narrows a metavariable:

where:
HOOK: { regex: "^use[A-Z]" }
BASE: { binding: { resolves: { external: react }, member: [Component, PureComponent] } }

regex is over the text the metavariable captured. binding follows the identifier at the root of the capture back to the import that bound it — C in import { Component as C }, React in import React from "react" — resolves that edge the way every other rule resolves one, and checks the target against resolves and the name pulled across against member: the exported name for a named import, the property accessed (React.ComponentComponent) for a default or namespace one. This is how “a class that extends a React component” is said without listing every alias the file might use, and it is done in the core against the file’s facts, not by the engine.

The names kind takes, inside has and inside too, are the engine’s node kinds — and the engine is ast-grep, so they are tree-sitter’s for the grammar the file is parsed with: class_declaration, call_expression, arrow_function. A relational rule (has, inside) searches the immediate children or ancestors unless it says stopBy: end. pattern is engine-neutral by construction; a later matcher over a different tree would ship a documented kind mapping rather than a compatibility surprise.

campaigns is the one family @goodbones/core does not own. It ships as @goodbones/campaigns, a package that depends on the core and on nothing else, and both hosts pull it in for you — there is nothing to install and nothing to configure.

The split is visible in exactly one place, and only if you are embedding the loader yourself rather than using the CLI or the plugin. A host composes the family as an extension:

import { campaignsExtension, campaignsOf } from "@goodbones/campaigns";
import { loadPolicy } from "@goodbones/core";
const policy = loadPolicy({
// …
extensions: [campaignsExtension({ functions, reports })],
});
const { campaignRules, ledgers } = campaignsOf(policy);

An extension claims its own top-level manifest keys — here campaigns and ledger — and decodes them with its own codec. The core splits those keys off before decoding the rest, so a manifest key that belongs to no loaded family is still refused as a misspelling, and the errors a campaign’s own codec reports still name the line and the use they came through. A policy loaded without the extension simply has no campaigns; architecture check on a manifest that declares none is not an error either way.

Why it is separate: the six architecture families answer “what may never happen”, and this one answers “what is the code moving away from, and how far has it got”. The second question needs a ledger, a git diff, a clock and another program’s diagnostics, none of which the first needs. Every dependency runs one way — nothing in the core imports this family — so keeping them in one package only meant a policy that states architecture alone paid for machinery it never ran.

The matcher is @goodbones/ast-grep, which both hosts compose into the TypeScript pack. The plugin parses sourceCode.text with the same matcher the CLI uses, so this family’s parity contract is one engine rather than a corpus. A campaign whose scope covers a language with no matcher is refused at load, with the language named.

Some patterns no parser of ours sees: a type error, a finding from another linter, anything a program prints one line per occurrence. The report term reads such a program’s output and makes each diagnostic a match, so a campaign can ratchet down what another tool reports — with the granularity a count-per-file baseline throws away.

type-errors:
why: The strict tsconfig cannot land while these remain.
how: Fix the type error at its source; do not add a cast or a `!`.
owner: "@team/platform"
scope: ["src/**"]
staleAfter: 30d
objectives:
tsc:
holdout: match
match:
report:
command: "npx tsc --noEmit --pretty false"
format: tsc
codesNot: [TS6133]
probes:
fires:
[
{
path: src/a.ts,
report: [{ line: 1, code: TS2551, message: "Property 'x' does not exist" }],
},
]
ignores:
[
{
path: src/b.ts,
report: [{ line: 1, code: TS6133, message: "'x' is declared but never used" }],
},
]
Field Meaning
command a program run from the repository root, once per process, or a list of them; a non-zero exit is expected (tsc exits 2 when there are errors)
file instead of command: a report an earlier step wrote, relative to the repository root, or a list of them
format tsc (file(line,col): error TSxxxx: message), eslint (--format json), oxlint (--format json), or regex
pattern regex only: named groups file and line, and optionally column, code, message; one diagnostic per matching line
codes the codes the term speaks to (TS2551, no-unused-vars, eslint(no-debugger)); omit for every one
codesNot codes carved back out

Each diagnostic is anchored on the declaration at its position through the scope’s syntax matcher — anchorAt, the second thing a matcher answers — and keyed Anchor#code#hash(message), so an entry survives the line moving and changes when the message does. Two identical diagnostics at two positions in one declaration are two entries (~2, ~3), so a ledger keeps its count; a diagnostic printed twice at one position is one. A diagnostic with no enclosing declaration, or in a file with no matcher, is keyed by code and message alone.

A tool that checks one project at a time — tsc --project, a language service over a tsconfig — is run once per project, and command takes the list:

match:
report:
command:
- "npx effect-tsgo diagnostics --project packages/server/tsconfig.src.json --format text --severity message"
- "npx effect-tsgo diagnostics --project packages/web/tsconfig.json --format text --severity message"
format: regex
pattern: '^(?<file>.+?)\((?<line>\d+),(?<column>\d+)\): message effect\((?<code>\w+)\): (?<message>.*)$'

The outputs are one report: each is parsed, the diagnostics are joined in the order the commands are written, and a diagnostic two of them print — a project’s program includes the files of the projects it references, so the same finding is reported under each — is kept once, when identical in file, position, code and message. The commands run at the same time, as many at once as the machine has cores, in both hosts: the CLI runs them before it walks a file and the plugin as it loads. file takes a list the same way, for a report written one file per project.

The commands run once per process and the output is cached: the CLI is one process per check, and oxlint’s language server is one process per editor session, which sees the report as of when it loaded the plugin. Under the plugin a command is forked from oxlint itself, and the plugin runs it as it loads, before a file is linted — once the linter is running, its per-thread AST buffers can add up to one mapping larger than the machine’s RAM and swap, and Linux’s default overcommit heuristic refuses to fork such a process (spawnSync /bin/sh ENOMEM); at load, the process is small. Running at load means the commands run on every plugin load, an editor session included, whether or not a selected file is opened. A report an earlier step writes to a file — file: tsc-report.txt — is the form for CI as well as for the editor, and the one to use when the command is slow; command: is the CLI’s form, where check is the only thing in the process.

A command that cannot be spawned, or a file no step wrote, is one failure per run, not one per file: the source keeps the failure as it would have kept the report, check stops with it, and the plugin reports it once, on the first file a campaign naming that report selects, while every other campaign on that file is still judged. A probe answers the term with report: [{ line, column?, code?, message? }], positions one-based as a tool prints them, so no program runs at load.

fn: ./campaigns/rsc.mjs#needsClientBoundary names an export of a module, resolved relative to the root manifest. The host imports it before the policy loads and refuses a reference whose export is not a function. A .mjs manifest may hold the function itself in place of the string.

/** @type {import("@goodbones/core").CampaignPredicate} */
export const needsClientBoundary = ({ file, text, facts, syntax }) =>
facts.specifiers.some((one) => one.startsWith("@vendor/browser-")) ||
(syntax?.findAll({ pattern: "window.$X" }).length ?? 0) > 0;

It is given the file’s path, its text, its facts (specifiers, bindings, memberSites, exportSites) and its syntax tree (null without a matcher), and answers either a boolean — a statement about the file — or a list of { subject, range? }: declaration names in a declaration objective, ledger keys of its own choosing in a match one. CampaignPredicate is exported from @goodbones/core so the module typechecks on its own. The function runs last, after every other term, and only when the cheaper ones have not already decided.

Every match objective carries probes, and one that fails a probe refuses to load — the same vacuity check every rule makes, with a second half. probes.fires (at least one) are sources the objective must report; probes.ignores are sources it must stay silent on, and when one of those fires the error names the first leaf term that admitted it — which, for a detector that is composed, is the term to tighten. A has term’s detector and a match perimeter’s are proven the same way.

A probe is a path — the file it stands for, which must be inside the campaign’s own scope — and, for a detector that reads the file, a source: a snippet parsed by the scope’s language and matcher at load. edges maps each specifier in the snippet to its target in place of the live resolver, in the same three forms imports.resolves takes, and files lists the siblings that exist in place of the file system. A detector holding a content, syntax, exports, members or fn term is refused at lowering if any of its probes has no source, since a path alone cannot exercise it.

probes:
fires:
- path: src/legacy/util.ts
source: 'import { sdk } from "vendor"; export const x = sdk();'
edges: { vendor: { external: vendor } }
files: [src/legacy/util.test.ts]
ignores:
- path: src/util.ts
source: "export const x = 1;"

A campaign joins no coverage row: its reach is its scope, so the coverage numbers and the residue are unchanged by adding one. It transforms no code — a campaign says what, why and which phase; codemods say how. It traces no runtime behaviour, runs no tests, and lands nothing across repositories. And, unless its scope widens the walk, it walks what the language pack walks: the TypeScript pack visits .ts, .tsx, .mts and .cts.

The family shipped as a list of campaigns each carrying one detect. That shape is refused by name: a campaign is now a map keyed by its id, what a campaign used to be is one of its objectives, unit is holdout and detect is match; title, why, how, owner, staleAfter and onComplete sit on the campaign. A ledger in the first layout, .architecture-campaigns/<id>.json, is read as the one objective’s under the implicit sector and rewritten in the new layout by the next objectives clear. campaigns init, prune and allow are objectives clear (which also writes a first ledger) and objectives concede.