diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f6e5ac88..549870163 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -95,27 +95,20 @@ jobs: steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - # Full history so the back-edge-ceiling ratchet can read the baseline - # at the merge-base and reject a same-PR raise. Mirrors the Fallow job. - fetch-depth: 0 - name: Setup toolchain uses: ./.github/actions/setup-node-pnpm with: install-deps: false - - name: Check import-direction DAG and ratchets + - name: Check import-direction DAG # Generalizes the former inline commands/-import grep into a structured # import-direction lint over the resolved graph. See scripts/layering/check.ts # and CONTEXT.md (Architecture: folder DAG + layering lint). Invoked directly # (no deps needed) so the job stays fast and does not require a pnpm install. - # LAYERING_BASE pins the baseline-monotonicity comparison to the PR base. - env: - LAYERING_BASE: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} run: | node --experimental-strip-types --test scripts/layering/model.test.ts - node --experimental-strip-types scripts/layering/check.ts --base "$LAYERING_BASE" + node --experimental-strip-types scripts/layering/check.ts affected-selector: name: Affected-check Selector diff --git a/.github/workflows/perf-nightly.yml b/.github/workflows/perf-nightly.yml index dc86586c4..45ab4ce04 100644 --- a/.github/workflows/perf-nightly.yml +++ b/.github/workflows/perf-nightly.yml @@ -112,18 +112,12 @@ jobs: profile: pixel_7 target: google_apis_playstore emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim -no-metrics - script: | - set -e - # Disable animations up front so accessibility dumps don't time out (the harness - # also runs `settings animations off`, this is belt-and-suspenders). - adb -s emulator-5554 shell settings put global window_animation_scale 0 || true - adb -s emulator-5554 shell settings put global transition_animation_scale 0 || true - adb -s emulator-5554 shell settings put global animator_duration_scale 0 || true - node --experimental-strip-types scripts/perf/run.ts \ - --platform android \ - --serial emulator-5554 \ - --n "$PERF_ROUNDS" --warmup 1 \ - --out-dir "$GITHUB_WORKSPACE/perf-results" + script: >- + set -e; + adb -s emulator-5554 shell settings put global window_animation_scale 0 || true; + adb -s emulator-5554 shell settings put global transition_animation_scale 0 || true; + adb -s emulator-5554 shell settings put global animator_duration_scale 0 || true; + node --experimental-strip-types scripts/perf/run.ts --platform android --serial emulator-5554 --n "$PERF_ROUNDS" --warmup 1 --out-dir "$GITHUB_WORKSPACE/perf-results" - name: Upload Android perf report if: always() diff --git a/AGENTS.md b/AGENTS.md index e51f8ce33..ca2f5b091 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -141,7 +141,7 @@ This repo encodes invariants as self-declaring gates. The correct response to a - every command declares a timeout policy on its descriptor: the timeout-policy completeness test - TS/Swift rule parity: golden tables under `contracts/fixtures/` consumed by vitest and the gated XCTest - cross-command apple-leak guard, folder DAG/import lint (including zero value-import cycles and - down-only exact target-spine back-edge identities), fallow (dead code, duplication, complexity) + zero target-spine back-edges), fallow (dead code, duplication, complexity) ## Hard Rules - Use process helpers from `src/utils/exec.ts` for TypeScript process execution: `runCmd`, `runCmdStreaming`, `runCmdSync`, `runCmdBackground`, and `runCmdDetached`. Do not import raw `spawn`/`spawnSync` outside `src/utils/exec.ts`; add or extend an exec helper instead. Plain `.mjs` packaging fixtures that cannot import TypeScript helpers should keep child-process usage local and prefer `execFile`/`execFileSync` over spawn. diff --git a/CONTEXT.md b/CONTEXT.md index 2e9c67464..a06cf80d8 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -96,8 +96,7 @@ The perfect-shape refactor is complete and merged. Its end-state: - Folder DAG + layering lint. `kernel`/`remote`/`metro`/`client`/`snapshot`/`screenshot-diff`/ `replay`/`cli-parser`/`daemon-client`+`server`/`sdk` are arranged as an import-direction DAG (imports point down toward the kernel sink), enforced in CI by `scripts/layering/check.ts`. - The gate rejects production value-import cycles and enforces the ranked target-spine's exact - back-edge identities as a down-only baseline in `scripts/layering/back-edge-baseline.json`. + The gate rejects production value-import cycles and every ranked target-spine back-edge. - Agent-cost. Responses carry a cost block and MCP `outputSchema`, rendered through a leveled `ResponseView`. diff --git a/docs/agents/contract-projection-output-economy-spike.md b/docs/agents/contract-projection-output-economy-spike.md index 0b1be8674..4f3f4e8ce 100644 --- a/docs/agents/contract-projection-output-economy-spike.md +++ b/docs/agents/contract-projection-output-economy-spike.md @@ -2,8 +2,9 @@ Issues: #1185, #1186 -This report keeps the two experiments bounded: one typed command family for executable contract -projection and one opt-in response family for digest expansion. +This report records two completed, bounded experiments: one typed command family for executable +contract projection and one opt-in response family for digest expansion. Neither result starts a +repository-wide migration. ## Selection evidence @@ -27,10 +28,10 @@ The baseline has three independent command-to-surface declarations per selected 2. the public client method signature; 3. the keyed MCP output-schema entry. -That is 15 independent projection declarations around five executable definitions. The spike will -make each executable definition the single command-to-client/MCP projection declaration and derive -the client surface and MCP schema map from those five definitions. Success is five declarations -instead of 15, without changing CLI, daemon, client, or MCP behavior. +That was 15 independent projection declarations around five executable definitions. The experiment +made each executable definition the single command-to-client/MCP projection declaration and +derived the client surface and MCP schema map from those five definitions. It reached five +declarations instead of 15 without changing CLI, daemon, client, or MCP behavior. The experiment stops at these five commands. It must not continue if it needs generated source, a schema DSL, a new dependency, a parallel registry, a new import back-edge, weaker parity checks, @@ -63,7 +64,7 @@ seven commands, zero fallback observations, exactly one retry, and all in-sessio preserved. The network addition must keep those measurements unchanged and separately prove that a failed request remains identifiable and actionable from the digest. -## Measurement plan +## Measurement method - Declaration count: count independent selected-command projection declarations before and after. - Runtime: compare median cold imports of the MCP command-tool projection at the merge base and @@ -92,8 +93,11 @@ from those projections, `AgentDeviceCommandClient` derives the five navigation m - Layering/Fallow: green; no new value back-edge (type-only import from client, value import only from the already-command-importing MCP schema map). -Recommendation: #1185 should CONTINUE incrementally, one already-typed closed-contract family at a -time, re-measuring declarations and bundle each step and stopping on any stop condition. +The selected five-command experiment is complete. Other already-typed closed-contract families are +candidates only, not a migration queue. Any follow-up must be proposed and measured independently, +must reduce declarations without weakening ownership or parity checks, and must stop on any of the +conditions above. Generated source, a schema DSL, a new dependency, a parallel registry, a new +back-edge, or a material runtime/bundle regression remains a stop signal. ## Digest expansion results (#1186) @@ -124,3 +128,71 @@ Defaults are unchanged; the digest is strictly opt-in via a non-default response risk: the dropped-field set is intentionally specific to the known verbose network payload fields; if a backend introduces a new large per-entry field it would remain until added to the drop list (safe by default — unknown fields are preserved, never silently narrowed). + +The selected network experiment is complete. Events, debug symbols, recording, and any new family +remain candidates only. A follow-up requires paired evidence: + +1. deterministic byte/shape reduction on representative fixtures; and +2. unchanged or better actionability, including follow-up command choice, fallback observation + count, retry count, identity, refs, warnings, artifacts, and in-session recovery. + +Byte reduction alone is not a successful digest. + +## Post-merge closeout evidence + +The deterministic suite was rerun after both implementations merged: + +- `pnpm test:output-economy`: 4 files and 26 tests passed. +- Network default/digest: 26,371 / 1,665 bytes; all eight entries and failed-request identity + remain available. +- MCP snapshot result: 1,173 bytes with four refs in the combined text + structured result. +- Routine workflow: 2,276 bytes across seven commands, zero fallback observations, one retry, and + `recoveryPreservesSession=true` with code, session, refs generation, retriable flag, and hint. + +`pnpm bench:help-conformance --dry-run` generated eight prompts for the four output/recovery cases +across the configured Haiku and GPT mini runners. This proves case selection and prompt plumbing +only. No scored small-model result is recorded here because authenticated `claude` and `codex` +runners were unavailable; future scored runs remain informational actionability oracles, not CI +gates. + +No new live-device result is recorded in this closeout. The local environment had no simulator, +emulator, or attached device. A one-round `perf-nightly` dispatch on the PR branch was also +unavailable because the GitHub integration could not create workflow-dispatch events. Historical +Android nightly logs did expose a deterministic workflow defect: the emulator action executes each +`script` line independently, so shell continuation backslashes reached `scripts/perf/run.ts` as a +literal `\` argument. The action now receives one folded shell script, so `set -e` governs the +benchmark and no continuation tokens cross action boundaries. The next scheduled run after merge +is the live validation point. Deterministic fixtures are not presented as live reliability +evidence. + +The six-month churn × current-size refresh continues to identify bounded candidates rather than a +split mandate: + +| File | Commits | LOC | Churn × LOC | +|---|---:|---:|---:| +| `src/cli/parser/cli-help.ts` | 34 | 1,227 | 41,718 | +| `src/core/dispatch-interactions.ts` | 31 | 1,123 | 34,813 | +| `src/client/client-types.ts` | 21 | 1,139 | 23,919 | + +Higher-churn files below 1,000 lines include `src/core/dispatch.ts`, `src/cli.ts`, and several +daemon handlers. These are seam-stability signals, not automatic split targets. A future split must +answer one ownership question, improve locality, preserve dependency direction, and mirror source +and test topology. The refresh deliberately avoids a blanket LOC gate; path-based commit counts +also undercount renamed files unless inspected with `git log --follow`. + +Repeat the broad ranking with: + +```sh +SINCE="$(node -e "const d = new Date(); d.setUTCMonth(d.getUTCMonth() - 6); process.stdout.write(d.toISOString().slice(0, 10))")" +git log --since="$SINCE" --name-only --pretty=format: -- 'src/**' \ + | rg -v '^$|__tests__|\.test\.' | sort | uniq -c | sort -rn \ + | while read -r commits file; do + test -f "$file" || continue + lines="$(wc -l < "$file")" + printf '%d\t%d\t%d\t%s\n' "$((commits * lines))" "$commits" "$lines" "$file" + done \ + | sort -rn +``` + +`src/utils` contains 106 TypeScript files: 71 production files and 35 tests. That is a standing +opportunistic ownership policy, not a mass relocation backlog. diff --git a/package.json b/package.json index 5c2f80033..0dbfacb46 100644 --- a/package.json +++ b/package.json @@ -118,7 +118,6 @@ "check:affected": "node --experimental-strip-types scripts/check-affected/run.ts", "check:affected:test": "node --experimental-strip-types --test scripts/check-affected/model.test.ts scripts/check-affected/run.test.ts", "check:layering": "node --experimental-strip-types --test scripts/layering/model.test.ts && node --experimental-strip-types scripts/layering/check.ts", - "check:layering:baseline": "node --experimental-strip-types scripts/layering/check.ts --update-baseline", "check:production-exports": "fallow dead-code --config fallow-production-exports.json --production --unused-exports --baseline fallow-baselines/production-unused-exports.json --fail-on-issues", "check:production-exports:baseline": "fallow dead-code --config fallow-production-exports.json --production --unused-exports --save-baseline fallow-baselines/production-unused-exports.json --summary", "check:bundle-owner-files": "node --experimental-strip-types scripts/check-bundle-owner-files.ts", diff --git a/scripts/layering/back-edge-baseline.json b/scripts/layering/back-edge-baseline.json deleted file mode 100644 index 0967ef424..000000000 --- a/scripts/layering/back-edge-baseline.json +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/scripts/layering/check.ts b/scripts/layering/check.ts index ed2fe8ee5..928735a25 100644 --- a/scripts/layering/check.ts +++ b/scripts/layering/check.ts @@ -6,9 +6,9 @@ // client ◄ daemon/client remote, metro ◄ daemon/client // sdk = re-export barrels only // -// The full zero-back-edge DAG remains a target. This gate enforces the three -// completed-move rules, rejects all production value-import cycles, and -// ratchets the ranked target-spine back-edges so existing debt can only shrink. +// The target DAG is complete. This gate enforces the three move rules, rejects +// all production value-import cycles, and rejects every ranked target-spine +// back-edge. import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; @@ -16,13 +16,9 @@ import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { backEdgePair, - collectBackEdges, - compareBackEdgeBaseline, - findBaselineRaises, findValueImportCycles, resolveImportEdges, topFolder, - type BackEdgeBaseline, type ImportEdge, type ResolvedImportEdge, } from './model.ts'; @@ -44,7 +40,6 @@ type Violation = { const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', }).trim(); -const baselinePath = path.join(repoRoot, 'scripts/layering/back-edge-baseline.json'); export function listSourceFiles(): string[] { // `src/**/*.ts` only matches nested files; root-level `src/*.ts` (e.g. @@ -144,123 +139,21 @@ function checkCycles(edges: readonly ResolvedImportEdge[]): Violation[] { })); } -function readBaseline(): BackEdgeBaseline { - if (!fs.existsSync(baselinePath)) { - throw new Error( - `Missing ${path.relative(repoRoot, baselinePath)}. Run pnpm check:layering:baseline.`, - ); - } - return JSON.parse(fs.readFileSync(baselinePath, 'utf8')) as BackEdgeBaseline; -} - -function writeBaseline(actual: BackEdgeBaseline): void { - fs.writeFileSync(baselinePath, `${JSON.stringify(actual, null, 2)}\n`); - process.stdout.write( - `Layering guard: updated ${path.relative(repoRoot, baselinePath)} with current back-edge identities.\n`, - ); -} - -// The committed baseline is the ratchet ceiling. Resolve the merge-base commit -// so the ceiling itself can be checked for monotonicity — a PR must not raise a -// number it is simultaneously being measured against. `--base ` (wired in -// CI, mirroring the Fallow job) is authoritative; locally we fall back to the -// merge-base with origin/main, and if neither resolves we skip the check rather -// than fail an offline run. -function resolveBaseRef(argv: readonly string[]): string | null { - const index = argv.indexOf('--base'); - const explicit = index >= 0 ? argv[index + 1] : process.env.LAYERING_BASE; - if (explicit && explicit.length > 0) return explicit; - try { - return execFileSync('git', ['merge-base', 'HEAD', 'origin/main'], { - cwd: repoRoot, - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - }).trim(); - } catch { - return null; - } -} - -function readBaselineAtRef(ref: string): BackEdgeBaseline | null { - try { - const contents = execFileSync( - 'git', - ['show', `${ref}:scripts/layering/back-edge-baseline.json`], - { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }, - ); - return JSON.parse(contents) as BackEdgeBaseline; - } catch { - // The baseline did not exist at the base commit (first introduction), so - // there is no prior ceiling to enforce monotonicity against. - return null; - } -} - -function deriveBaselineAtRef(ref: string, committed: BackEdgeBaseline): BackEdgeBaseline { - const files = execFileSync('git', ['ls-tree', '-r', '--name-only', ref, '--', 'src'], { - cwd: repoRoot, - encoding: 'utf8', - }) - .split('\n') - .filter(isProductionSourceFile); - const sources = new Map(files.map((file) => [file, ''])); - const committedSources = new Set( - Object.values(committed) - .flat() - .map((identity) => identity.split(' -> ')[0]!), - ); - for (const file of committedSources) { - if (!sources.has(file)) continue; - sources.set( - file, - execFileSync('git', ['show', `${ref}:${file}`], { - cwd: repoRoot, - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - }), - ); - } - return collectBackEdges(resolveImportEdges(sources)); -} - -function checkBaselineMonotonic(argv: readonly string[]): Violation[] { - const committed = readBaseline(); - const baseRef = resolveBaseRef(argv); - if (!baseRef) return []; - const base = readBaselineAtRef(baseRef) ?? deriveBaselineAtRef(baseRef, committed); - return findBaselineRaises(base, committed).map((raise) => ({ - rule: 'R6 back-edge-ceiling', - file: path.relative(repoRoot, baselinePath), - line: 1, - message: - `${raise.pair} baseline added ${raise.added.join(', ')}. ` + - `Remove the new back-edge instead of adding it to the baseline.`, - })); -} - -function checkBackEdgeRatchet( - edges: readonly ResolvedImportEdge[], - actual: BackEdgeBaseline, -): Violation[] { - const baseline = readBaseline(); - return compareBackEdgeBaseline(baseline, actual).map((drift) => { - const representative = edges.find((edge) => backEdgePair(edge) === drift.pair); - const details = [ - drift.added.length > 0 ? `added ${drift.added.join(', ')}` : '', - drift.removed.length > 0 ? `removed ${drift.removed.join(', ')}` : '', - ] - .filter(Boolean) - .join('; '); - return { - rule: 'R5 back-edge-ratchet', - file: representative?.file ?? path.relative(repoRoot, baselinePath), - line: representative?.line ?? 1, - message: - `${drift.pair} changed: ${details}. ` + - (drift.added.length > 0 - ? `Remove the new up-edge; existing debt may not be replaced or increased.` - : `Regenerate the baseline so the improvement becomes the new ceiling.`), - }; +function checkBackEdges(edges: readonly ResolvedImportEdge[]): Violation[] { + const seen = new Set(); + return edges.flatMap((edge) => { + const pair = backEdgePair(edge); + const identity = `${edge.file} -> ${edge.target}`; + if (!pair || seen.has(identity)) return []; + seen.add(identity); + return [ + { + rule: 'R5 zero-back-edges', + file: edge.file, + line: edge.line, + message: `${pair} back-edge: ${identity}. Move the shared contract below both owners.`, + }, + ]; }); } @@ -268,7 +161,7 @@ function report(files: readonly string[], violations: readonly Violation[]): num if (violations.length === 0) { process.stdout.write( `Layering guard: OK — ${files.length} source files satisfy R1-R3, contain no ` + - `value-import cycles, and match the down-only back-edge baseline.\n`, + `value-import cycles, and contain no target-spine back-edges.\n`, ); return 0; } @@ -294,18 +187,14 @@ function report(files: readonly string[], violations: readonly Violation[]): num return 1; } -export function main(argv = process.argv.slice(2)): number { +export function main(): number { const sourceFiles = listSourceFiles(); const edges = resolveImportEdges(readSources(sourceFiles)); - const violations = [...checkLayeringRules(edges), ...checkCycles(edges)]; - const actualBackEdges = collectBackEdges(edges); - if (argv.includes('--update-baseline')) { - if (violations.length > 0) return report(sourceFiles, violations); - writeBaseline(actualBackEdges); - } else { - violations.push(...checkBackEdgeRatchet(edges, actualBackEdges)); - violations.push(...checkBaselineMonotonic(argv)); - } + const violations = [ + ...checkLayeringRules(edges), + ...checkCycles(edges), + ...checkBackEdges(edges), + ]; return report(sourceFiles, violations); } diff --git a/scripts/layering/model.test.ts b/scripts/layering/model.test.ts index cc4051ced..00c12ea15 100644 --- a/scripts/layering/model.test.ts +++ b/scripts/layering/model.test.ts @@ -2,9 +2,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { listSourceFiles } from './check.ts'; import { - compareBackEdgeBaseline, collectBackEdges, - findBaselineRaises, findValueImportCycles, parseImports, resolveImportEdges, @@ -57,7 +55,7 @@ test('value cycles fail while type-only and dynamic cycles stay outside the grap assert.deepEqual(findValueImportCycles(nonValueCycle), []); }); -test('back-edge counts follow the documented target spine and drift in either direction', () => { +test('back-edge identities follow the documented target spine', () => { const edges = resolveImportEdges( new Map([ ['src/platforms/apple.ts', "import '../core/platform-plugin.ts';"], @@ -72,35 +70,6 @@ test('back-edge counts follow the documented target spine and drift in either di 'commands -> cli': ['src/commands/help.ts -> src/cli/parser.ts'], 'platforms -> core': ['src/platforms/apple.ts -> src/core/platform-plugin.ts'], }); - assert.deepEqual( - compareBackEdgeBaseline( - { 'commands -> cli': ['src/commands/help.ts -> src/cli/parser.ts'] }, - actual, - ), - [ - { - pair: 'platforms -> core', - added: ['src/platforms/apple.ts -> src/core/platform-plugin.ts'], - removed: [], - }, - ], - ); - assert.deepEqual( - compareBackEdgeBaseline( - { - ...actual, - 'commands -> client': ['src/commands/help.ts -> src/client/client.ts'], - }, - actual, - ), - [ - { - pair: 'commands -> client', - added: [], - removed: ['src/commands/help.ts -> src/client/client.ts'], - }, - ], - ); }); test('neutral ownership zones reject value imports into higher layers', () => { @@ -125,61 +94,6 @@ test('neutral ownership zones reject value imports into higher layers', () => { }); }); -test('exact back-edge identities reject same-count replacements', () => { - const baseline = { - 'commands -> cli': ['src/commands/a.ts -> src/cli/a.ts'], - }; - const actual = { - 'commands -> cli': ['src/commands/b.ts -> src/cli/b.ts'], - }; - assert.deepEqual(compareBackEdgeBaseline(baseline, actual), [ - { - pair: 'commands -> cli', - added: ['src/commands/b.ts -> src/cli/b.ts'], - removed: ['src/commands/a.ts -> src/cli/a.ts'], - }, - ]); -}); - -test('findBaselineRaises rejects additions and replacements but permits removal', () => { - const base = { - 'platforms -> core': ['src/platforms/a.ts -> src/core/a.ts'], - 'commands -> cli': ['src/commands/a.ts -> src/cli/a.ts'], - }; - assert.deepEqual( - findBaselineRaises(base, { - 'platforms -> core': [ - 'src/platforms/a.ts -> src/core/a.ts', - 'src/platforms/b.ts -> src/core/b.ts', - ], - 'commands -> cli': ['src/commands/b.ts -> src/cli/b.ts'], - 'commands -> client': ['src/commands/c.ts -> src/client/c.ts'], - }), - [ - { - pair: 'commands -> cli', - added: ['src/commands/b.ts -> src/cli/b.ts'], - }, - { - pair: 'commands -> client', - added: ['src/commands/c.ts -> src/client/c.ts'], - }, - { - pair: 'platforms -> core', - added: ['src/platforms/b.ts -> src/core/b.ts'], - }, - ], - ); - assert.deepEqual(findBaselineRaises(base, base), []); - assert.deepEqual( - findBaselineRaises(base, { - 'platforms -> core': [], - 'commands -> cli': ['src/commands/a.ts -> src/cli/a.ts'], - }), - [], - ); -}); - test('listSourceFiles includes root-level src/*.ts production files', () => { const files = new Set(listSourceFiles()); for (const rootFile of ['src/cli.ts', 'src/command-catalog.ts', 'src/backend.ts']) { diff --git a/scripts/layering/model.ts b/scripts/layering/model.ts index 29f0b1ab7..ea4d46436 100644 --- a/scripts/layering/model.ts +++ b/scripts/layering/model.ts @@ -14,13 +14,7 @@ export type ResolvedImportEdge = ImportEdge & { toZone: string; }; -export type BackEdgeBaseline = Record; - -export type BackEdgeDrift = { - pair: string; - added: string[]; - removed: string[]; -}; +export type BackEdgeMap = Record; const TARGET_DAG_RANK = new Map([ ['kernel', 0], @@ -246,7 +240,7 @@ export function backEdgePair(edge: ResolvedImportEdge): string | null { return `${edge.fromZone} -> ${edge.toZone}`; } -export function collectBackEdges(edges: readonly ResolvedImportEdge[]): BackEdgeBaseline { +export function collectBackEdges(edges: readonly ResolvedImportEdge[]): BackEdgeMap { const identitiesByPair = new Map>(); for (const edge of edges) { const pair = backEdgePair(edge); @@ -261,34 +255,3 @@ export function collectBackEdges(edges: readonly ResolvedImportEdge[]): BackEdge .map(([pair, identities]) => [pair, [...identities].sort()]), ); } - -export function compareBackEdgeBaseline( - baseline: BackEdgeBaseline, - actual: BackEdgeBaseline, -): BackEdgeDrift[] { - const pairs = new Set([...Object.keys(baseline), ...Object.keys(actual)]); - return [...pairs].sort().flatMap((pair) => { - const baselineIdentities = new Set(baseline[pair] ?? []); - const actualIdentities = new Set(actual[pair] ?? []); - const added = [...actualIdentities].filter((identity) => !baselineIdentities.has(identity)); - const removed = [...baselineIdentities].filter((identity) => !actualIdentities.has(identity)); - return added.length === 0 && removed.length === 0 ? [] : [{ pair, added, removed }]; - }); -} - -export type BaselineRaise = { - pair: string; - added: string[]; -}; - -export function findBaselineRaises( - base: BackEdgeBaseline, - committed: BackEdgeBaseline, -): BaselineRaise[] { - const pairs = new Set([...Object.keys(base), ...Object.keys(committed)]); - return [...pairs].sort().flatMap((pair) => { - const baseIdentities = new Set(base[pair] ?? []); - const added = (committed[pair] ?? []).filter((identity) => !baseIdentities.has(identity)); - return added.length > 0 ? [{ pair, added }] : []; - }); -}