Uh oh!
There was an error while loading. Please reload this page.
fix(service-automation): one renderer for the contested-flow phrase, and the two spellings it had drifted into - #12607
Conversation
…12563) One event — a flow name claimed by more than one definition — was described to an operator in three places, each with its own private `const describe` beside the log call. Nothing held them equal, and two axes had already drifted: `flow-precedence.ts` rendered `package "crm"` while the other two rendered `package 'crm'`, and the two engine copies interpolated a bare `undefined` where the CLI copy rendered a real fallback. `describeFlowContender` sat exactly where a shared renderer would live and read like one, but it is a CLASSIFIER with no prose in it — so each caller wrote its own sentence while an export that looked reusable sat beside them. That is the durable part of the defect: a missing renderer is a gap someone fills, a misleading one is a gap that keeps getting re-filled privately. The two copies in this package now call one exported `renderFlowContender`, and `describeFlowContender`'s own docs say what it is not. Both spellings were decided on measurement: - Single quotes, measured against this package rather than voted across the three copies: 203 single-quoted interpolations in operator prose under `service-automation/src` against 3 double-quoted, one of which was this phrase. The sentence already single-quotes the flow name beside it. - A named fallback instead of `package 'undefined'`. This package's callers cannot reach that branch today (`isCodeArtifactBody` is false on a falsy `_packageId`) — but that is a property of today's callers, not of an exported function. The third copy, in `@objectstack/cli`'s startup banner, deliberately keeps its own structural rendering: that package takes no static value import of `@objectstack/service-automation`, and its engine reads are feature-detected so a host on an older automation package still boots its banner. It is held equal by a test-only agreement pin instead, which is red in both directions. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0194kbQJxUvv2yvsGRtuXpP5
📓 Docs Drift Check2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 651ebf1578b346058e3e8826691b91f336d95f09 && git checkout 651ebf1578b346058e3e8826691b91f336d95f09
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7c0d0c39552b0eb4aa5c324d183f5eb2105e498e ba3a391b1ec8875775cfc8f436c5e8ac8d05e126 && git checkout -B drift-repro 7c0d0c39552b0eb4aa5c324d183f5eb2105e498e && git merge --no-ff ba3a391b1ec8875775cfc8f436c5e8ac8d05e126
node scripts/docs-audit/affected-docs.mjs --json 7c0d0c39552b0eb4aa5c324d183f5eb2105e498e |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#12563
One event — a flow name claimed by more than one definition — was described to an operator in three places, each with its own private
const describebeside the log call. Nothing held the copies equal, and two axes had already drifted before anyone looked.The two copies inside
@objectstack/service-automationare now one exported renderer,renderFlowContender. The third copy, in@objectstack/cli's startup banner, deliberately keeps its own rendering and is held equal by a test-only agreement pin. Why that split is the shape, measured rather than assumed, is the first section below.The boundary question, answered before writing code
The card's shape A ("export a renderer, have all three call it") required knowing what a value import would cost
packages/cli. Measured onorigin/main:@objectstack/service-automationinpackages/cli/srcawait import(...)inutils/data-migration-plugins.ts, behindopts.automation === trueutils/format.tsimport type; only a prose mentionpackages/cli/srcimportingutils/format.jsSo a renderer import into
format.tswould be this package's first static value import of the automation package, placed in a util that every CLI command's module graph already reaches —os whoami,os init,os loginincluded. That is precisely the property PR #12562 spent effort buying: its engine reads are structural and feature-detected so a host on an older automation package still boots its banner.⇒ A-full is not available. The fold ends the class where the producer lives; the boundary is held by a pin instead.
Both divergence axes, decided on measurement
1. Quoting — measured against the producing package, never majority-voted.
packages/services/service-automation/srcpackages/objectql/srcpackages/cli/srcOne of the producing package's 3 double-quoted uses was this phrase. The sentence it lands in already single-quotes the flow name, which is the more free-form of the two values, so single quotes add no ambiguity the line does not already carry.
packageIdis an unconstrainedz.string()inpackages/spec(no character pattern anywhere), so neither spelling is provably safe against an adversarial id — this one is at least the house convention. ⇒ single.2. Absent
packageId. The two engine copies interpolated a bareundefined; the CLI copy rendered a real fallback. The renderer takes the CLI's answer,a code-shipped package (id unknown).This package's own callers cannot reach that branch today —
isCodeArtifactBodyis false on a falsy_packageId, so asource: 'package'contender always carries one. That is recorded on the card as a non-premise, not a live bug, and it is not treated as one here. But unreachability is a property of today's callers, not of an exported function, so the renderer is made safe rather than left safe-by-luck.The trap the card is actually about
describeFlowContenderis exported, sits exactly where a shared renderer would live, and reads like one — but it is a classifier with no prose in it. A missing renderer is a gap someone fills; a misleading one is a gap that keeps getting re-filled privately, which is what the third copy was. Its TSDoc now says what it is not, and points at the renderer.What holds the copies equal, proven by ablation
Two layers, deliberately non-redundant — the call sites assert through the renderer, and the renderer's own output is pinned as literals, so the two cannot drift together and stay green.
dist/Every leg: unique-anchor mutation refusing to run on a non-unique or zero-hit anchor, post-write removed/injected counts observed, blob hash compared against the
HEADblob,ablation-dist-preflightfor presence on the mutation leg and--absenton the restore leg, and restoration proven by an emptygit diff HEADplus a matching hash. Ablation C mutatedpackages/cli/src/utils/format.tstransiently in the local worktree only — that file is not edited by this PR and was restored byte-identically (eb214450f7e96edd75a67e65ee06022ca3aa4161before and after).A first attempt at ablation A was a no-op — the
perlsubstitution matched zero times and exited 0. It was caught by the byte-hash check before any verdict was read, and is recorded here because "editor exit code" and "the edit happened" are different facts.Cross-lane fence
packages/cli/src/utils/format.tsbelongs to thedomain:clilane and was not cleared at dispatch. Cleared here, immediately before the work: no openclaude/PR touches that file orpackages/services/service-automation/(all 9 open branches fetched and diffed againstorigin/main), and same-day churn on it is only PR #12562, already merged. This PR does not edit that file in any case.Verification
Suites, on
ba3a391:@objectstack/service-automationfull suite — 91 files, 1086 tests, all passed.flow-name-shadowing.test.tswent 11 to 14 cases: all 11 original names survive, 3 added. No case moved — the only change to a pre-existing case is the expected string in the precedence-warning row, which is prose, not classification.@objectstack/cli— the 6 banner/format suites, 50 tests, all passed, including the new pin's 4.tsc --noEmitonpackages/cli: clean, and--listFilesconfirms the new test file is in the program (a green typecheck that never read it would have said nothing).packages/services/service-automationhas notypecheckscript (it is a DEBT-ledger package), so a--filterrun oftypecheckmatches zero scripts and exits 0 having measured nothing. Measured directly instead:tsc --noEmitreports exactly 3 errors, all pre-existingTS2341innested-region-parity.test.ts, matching the ledger's recorded count. Debt unchanged.Gates — union re-derived from the actual changed set via
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackon this tree, exit codes captured before any pipe. 25 families, all green. Three first returnedPREREQUISITE NOT MET(NOT MEASURED, not red):check:i18nandcheck:i18n-coverageneeded the built CLI, andcheck:type-check-debtrefused to re-measure without the workspace closure. Both readings are recorded — afterturbo run buildoverpackages, all three are OK, withcheck-type-check-coverage --re-measure: OK — 31 ledger entries re-measured, none above its recorded number.Worth naming:
check-test-source-alias OK — 72 packages with tests scanned; 61 registered as still resolving a workspace dep through dist. The new cross-package import does not widen the shrink-onlyKNOWN_UNALIASED_TEST_IMPORTSledger —@objectstack/service-automationwas already registered for@objectstack/clivia the dynamic import named above, so the set is unchanged. The alternative, aliasing that dep to source invitest.config.ts, was deliberately refused: it would pull that package's entire import surface into this package's resolution domain for all ~185 test files, to buy staleness-resistance on one three-branch pure function.pnpm lint(repo-scale,eslint . --no-inline-config) — exit 0, 55s, run in full rather than narrowed.Changeset
minoron@objectstack/service-automation, argued in the file: it adds an export to a published package's public API and changes shipped operator-facing log text. The precedence warning now renderspackage 'crm'where it renderedpackage "crm"; the plugin's bootstrap warning is byte-identical for every input its callers can produce.Consumer sets, re-derived rather than copied
printServerReady— 8 importers: 1 production (commands/serve.ts) and 7 test files, one of them new here.collectAutomationSummary— defined and called incommands/serve.ts, imported by exactly 2 test files, no production consumer outside its own module.Clause-2
Declared
noat dispatch, and it holds:describeFlowContenderandresolveFlowPrecedencesemantics are untouched, no test case moved, and the diff changes operator prose only.Generated by Claude Code