Files
callstack__agent-device/scripts/layering/daemon-modularity.test.ts
Michał Pierzchała d77eff452d refactor(daemon): cut the last edges into the CLI schema layer (#2543) (#2562)
* refactor(command-registry): own command defaults; cut the daemon's CLI-schema edges

The daemon resolved routes through the command registry but still reached two symbols
across the layer boundary — recordedFlagKeys and applyCommandDefaults — both imported
from src/cli-schema/command-schema.ts, and the apps filter default was declared a second
time in the apps facet.

The registry is now the single source of command defaults: a COMMAND_DEFAULTS table typed
on CommandFlags plus applyCommandDefaults sit in registry.ts, where every other command
default (the defaultValue descriptors) is already declared. The CLI parser and the daemon
request scope both import it from @agent-device/command-registry/registry, the one module
already in both callers' eager closure. Registry and CLI closure are unchanged: the table
adds a literal, not a new import, so no entry grows. The apps facet default and
CommandSchema.defaults go away; DEFAULT_APPS_FILTER collapses into resolveAppsFilter's
provider fallback and stops being a public export.

session-action-recorder reads the recorded-flag vocabulary straight from
@agent-device/command-registry/flag-registry, and removing that import also drops
command-schema's now-unconsumed recordedFlagKeys re-export.

Part of #2545 / #2543.

* chore(gates): forbid the daemon importing the CLI schema layer

R10 (scripts/layering/daemon-modularity) now rejects any daemon import of src/cli-schema/,
value or type. The last two value edges into command-schema.ts are gone, so a total boundary
is available, and an explicit rule beats a rank that would let the next daemon module reach
the CLI schema layer again.

Part of #2545 / #2543.
2026-09-14 11:27:22 +02:00

712 lines
25 KiB
TypeScript

import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
checkDaemonModularityRatchets,
checkRetiredInteractionPaths,
checkRetiredSessionLifecyclePaths,
checkRetiredSessionObservabilityPaths,
checkRetiredSnapshotExecutionPaths,
DAEMON_MODULARITY_BASELINE,
} from './daemon-modularity.ts';
import { resolveImportEdges, targetDagZone, type ResolvedImportEdge } from './model.ts';
import type { LayeringRatchets } from './ratchet-reference.ts';
function importEdge(file: string, target: string): ResolvedImportEdge {
return {
file,
target,
spec: target,
line: 1,
dynamic: false,
typeOnly: true,
symbols: [],
bindingResidue: false,
fromZone: targetDagZone(file),
toZone: targetDagZone(target),
};
}
function baselineDaemonTypesEdges(): ResolvedImportEdge[] {
return DAEMON_MODULARITY_BASELINE.externalDaemonTypesImporters.map((file) =>
importEdge(file, 'src/daemon/daemon-request.ts'),
);
}
/** Every baseline edge is present and nothing else is forbidden: the quiet state of the ratchets. */
function baselineEdges(): ResolvedImportEdge[] {
return baselineDaemonTypesEdges();
}
/** Where a member of `zone` lives, so a zone count can be turned back into file paths. */
const ZONE_DIRECTORY: Readonly<Record<string, string>> = {
'(root)': 'src/',
'daemon-server': 'src/daemon/',
'ad-replay': 'packages/ad-replay/src/',
'provider-webdriver': 'packages/provider-webdriver/src/',
};
/** A cycle membership of `count` files per zone, so a zone count becomes file paths. */
function typeCycleMembers(zones: Readonly<Record<string, number>>): string[] {
return Object.entries(zones).flatMap(([zone, count]) =>
Array.from(
{ length: count },
(_, index) => `${ZONE_DIRECTORY[zone] ?? `src/${zone}/`}probe-${index}.ts`,
),
);
}
/**
* The merge-base measurement every test ratchets against. R9 and R10's R7 counts have no recorded
* numbers any more, so a test states its own reference tree instead of importing one.
*/
const REFERENCE: LayeringRatchets = {
typeInversions: {},
largestTypeCycle: typeCycleMembers({ 'provider-webdriver': 6 }),
sessionState: { writerOwnedFields: 19, ownerFileClaims: 22 },
};
/** The same measurement as the reference except where a test moves one number. */
function measured(overrides: Partial<LayeringRatchets> = {}): LayeringRatchets {
return { ...REFERENCE, ...overrides };
}
test('R10 rejects R7 ownership pressure that grew past the merge-base', () => {
const grownFields = checkDaemonModularityRatchets(
baselineEdges(),
measured({ sessionState: { writerOwnedFields: 20, ownerFileClaims: 23 } }),
REFERENCE,
);
assert.deepEqual(
grownFields.map(({ rule, message }) => ({ rule, message })),
[
{
rule: 'R10 daemon-modularity',
message:
'R7 writerOwnedFields grew to 20 (baseline 19 at the merge-base). Route the new write ' +
'through an existing owner instead.',
},
{
rule: 'R10 daemon-modularity',
message:
'R7 ownerFileClaims grew to 23 (baseline 22 at the merge-base). Route the new write ' +
'through an existing owner instead.',
},
],
);
});
test('R10 banks an R7 shrink with no edit anywhere', () => {
assert.deepEqual(
checkDaemonModularityRatchets(
baselineEdges(),
measured({ sessionState: { writerOwnedFields: 18, ownerFileClaims: 20 } }),
REFERENCE,
),
[],
);
});
test('R10 forbids the daemon importing the CLI schema layer, value or type', () => {
const valueEdge: ResolvedImportEdge = {
...importEdge('src/daemon/request-execution-scope.ts', 'src/cli-schema/command-schema.ts'),
typeOnly: false,
};
const typeEdge = importEdge(
'src/daemon/session-action-recorder.ts',
'src/cli-schema/command-schema.ts',
);
const violations = checkDaemonModularityRatchets(
[...baselineEdges(), valueEdge, typeEdge],
REFERENCE,
REFERENCE,
);
assert.deepEqual(
violations.map(({ rule, file }) => ({ rule, file })),
[
{ rule: 'R10 daemon-modularity', file: 'src/daemon/request-execution-scope.ts' },
{ rule: 'R10 daemon-modularity', file: 'src/daemon/session-action-recorder.ts' },
],
);
assert.match(violations[0]!.message, /must not import src\/cli-schema\/command-schema\.ts/);
});
test('R10 permits a non-daemon importer of the CLI schema layer', () => {
const cliEdge = importEdge('src/cli/parser/args.ts', 'src/cli-schema/command-schema.ts');
assert.deepEqual(
checkDaemonModularityRatchets([...baselineEdges(), cliEdge], REFERENCE, REFERENCE),
[],
);
});
test('external daemon request/session-state importer membership changes require the baseline to change', () => {
const edges = resolveImportEdges(
new Map([
[
'src/client/new-importer.ts',
"import type { SessionState } from '../daemon/session-state.ts';",
],
['src/daemon/session-state.ts', 'export type SessionState = { name: string };'],
]),
);
const violations = checkDaemonModularityRatchets(
[...baselineEdges(), ...edges],
REFERENCE,
REFERENCE,
);
assert.equal(violations.length, 1);
// The recorded list owns its own size (#2342 relocated five client files into it), so the
// message assertion reads it rather than pinning a literal that the baseline would outgrow.
assert.match(
violations[0]!.message,
new RegExp(
`may only shrink from the recorded ${DAEMON_MODULARITY_BASELINE.externalDaemonTypesImporters.length}`,
),
);
const removed = checkDaemonModularityRatchets(
baselineDaemonTypesEdges().slice(1),
REFERENCE,
REFERENCE,
);
assert.equal(removed.length, 1);
assert.match(removed[0]!.message, /delete it from externalDaemonTypesImporters/);
});
test('logical modules reject forbidden imports', () => {
const edges = resolveImportEdges(
new Map([
[
'packages/replay-test/src/internal/scheduler.ts',
"import type { Device } from '../../../../src/providers/device.ts';",
],
['src/providers/device.ts', 'export type Device = { id: string };'],
]),
);
const violations = checkDaemonModularityRatchets(
[...baselineEdges(), ...edges],
REFERENCE,
REFERENCE,
);
assert.equal(violations.length, 1);
assert.match(violations[0]!.message, /replay-test must not import/);
});
test('replay-test rejects request-global and engine-internal imports', () => {
const edges = resolveImportEdges(
new Map([
[
'packages/replay-test/src/internal/scheduler.ts',
[
"import { emitRequestProgress } from '../../../../src/request/progress.ts';",
"import { readReplayScriptMetadata } from '../../../../src/daemon/replay/internal/native-command.ts';",
"import { parseMaestroProgram } from '../../../../src/compat/maestro/program-ir-parser.ts';",
].join('\n'),
],
['src/request/progress.ts', 'export function emitRequestProgress() {}'],
[
'src/daemon/replay/internal/native-command.ts',
'export function readReplayScriptMetadata() {}',
],
['src/compat/maestro/program-ir-parser.ts', 'export function parseMaestroProgram() {}'],
]),
);
const violations = checkDaemonModularityRatchets(
[...baselineEdges(), ...edges],
REFERENCE,
REFERENCE,
);
assert.deepEqual(
violations.map(({ message }) => message.replace(/;.*/, '')),
[
'replay-test must not import src/request/progress.ts',
"packages/replay-test/src/internal/scheduler.ts must not import daemon-replay's internal tree (src/daemon/replay/internal/native-command.ts)",
'replay-test must not import src/compat/maestro/program-ir-parser.ts',
],
);
});
test('replay-test may still import its own files inside the package', () => {
const edges = resolveImportEdges(
new Map([
[
'packages/replay-test/src/internal/reporting.ts',
"import { spec } from './reporters/spec.ts';",
],
['packages/replay-test/src/internal/reporters/spec.ts', 'export const spec = 1;'],
]),
);
assert.deepEqual(
checkDaemonModularityRatchets([...baselineEdges(), ...edges], REFERENCE, REFERENCE),
[],
);
});
test('internal trees reject deep imports globally, including from daemon', () => {
const edges = resolveImportEdges(
new Map([
['src/daemon/adapter.ts', "import type { Plan } from '@agent-device/maestro/internal/plan';"],
['packages/maestro/src/internal/plan.ts', 'export type Plan = { steps: number };'],
]),
);
const violations = checkDaemonModularityRatchets(
[...baselineEdges(), ...edges],
REFERENCE,
REFERENCE,
);
assert.equal(violations.length, 1);
assert.match(violations[0]!.message, /must not import maestro's internal tree/);
});
test('daemon replay rejects handler, owner, session-store, and engine deep edges', () => {
const edges = resolveImportEdges(
new Map([
[
'src/daemon/handlers/session.ts',
"import { runReplayCommand } from '../replay/internal/native-command.ts';",
],
[
'src/daemon/replay/internal/test-command.ts',
"import { handleSessionCloseCommands } from '../../session-lifecycle/internal/session-close.ts';",
],
[
'src/daemon/replay/internal/close-command.ts',
"import { handleSessionCloseCommands } from '../../session-lifecycle/index.ts';",
],
[
'src/daemon/replay/internal/command-types.ts',
"import { SessionStore } from '../../session-store.ts';",
],
[
'packages/ad-replay/src/internal/step-loop.ts',
"import { runReplayCommand } from '../../../../src/daemon/replay/internal/native-command.ts';",
],
['src/daemon/replay/internal/native-command.ts', 'export function runReplayCommand() {}'],
[
'src/daemon/session-lifecycle/internal/session-close.ts',
'export function handleSessionCloseCommands() {}',
],
['src/daemon/session-lifecycle/index.ts', 'export function handleSessionCloseCommands() {}'],
['src/daemon/session-store.ts', 'export class SessionStore {}'],
]),
);
const violations = checkDaemonModularityRatchets(
[...baselineEdges(), ...edges],
REFERENCE,
REFERENCE,
);
assert.deepEqual(
violations.map(({ file, line, message }) => ({
file,
line,
message: message.replace(/;.*/, ''),
})),
[
{
file: 'src/daemon/handlers/session.ts',
line: 1,
message:
"src/daemon/handlers/session.ts must not import daemon-replay's internal tree (src/daemon/replay/internal/native-command.ts)",
},
{
file: 'src/daemon/replay/internal/test-command.ts',
line: 1,
message:
"src/daemon/replay/internal/test-command.ts must not import daemon-session-lifecycle's internal tree (src/daemon/session-lifecycle/internal/session-close.ts)",
},
{
file: 'src/daemon/replay/internal/close-command.ts',
line: 1,
message: 'daemon-replay must not import src/daemon/session-lifecycle/index.ts',
},
{
file: 'src/daemon/replay/internal/command-types.ts',
line: 1,
message: 'daemon-replay must not import src/daemon/session-store.ts',
},
{
file: 'packages/ad-replay/src/internal/step-loop.ts',
line: 1,
message:
"packages/ad-replay/src/internal/step-loop.ts must not import daemon-replay's internal tree (src/daemon/replay/internal/native-command.ts)",
},
],
);
});
test('session lifecycle rejects handler deep imports in both directions', () => {
const edges = resolveImportEdges(
new Map([
[
'src/daemon/handlers/session.ts',
"import { handleSessionInventoryCommands } from '../session-lifecycle/internal/inventory.ts';\nexport function handleSessionCommands() {}",
],
[
'src/daemon/session-lifecycle/internal/inventory.ts',
"import { handleSessionCommands } from '../../handlers/session.ts';\nexport function handleSessionInventoryCommands() {}",
],
[
'src/daemon/session-lifecycle/internal/session-close.ts',
"import { handleSessionCommands } from '../../handlers/session.ts';",
],
[
'src/daemon/session-lifecycle/index.ts',
'export function handleSessionInventoryCommands() {}',
],
]),
);
const violations = checkDaemonModularityRatchets(
[...baselineEdges(), ...edges],
REFERENCE,
REFERENCE,
);
assert.deepEqual(
violations.map(({ file, line, message }) => ({
file,
line,
message: message.replace(/;.*/, ''),
})),
[
{
file: 'src/daemon/handlers/session.ts',
line: 1,
message:
"src/daemon/handlers/session.ts must not import daemon-session-lifecycle's internal tree (src/daemon/session-lifecycle/internal/inventory.ts)",
},
{
file: 'src/daemon/session-lifecycle/internal/inventory.ts',
line: 1,
message: 'daemon-session-lifecycle must not import src/daemon/handlers/session.ts',
},
{
file: 'src/daemon/session-lifecycle/internal/session-close.ts',
line: 1,
message: 'daemon-session-lifecycle must not import src/daemon/handlers/session.ts',
},
],
);
});
test('interaction rejects handler crossings and deep imports around its facade', () => {
const edges = resolveImportEdges(
new Map([
[
'src/daemon/handlers/react-native.ts',
"import { refSnapshotFlagGuardResponse } from '../interaction/internal/interaction-flags.ts';\nexport function handleReactNativeCommands() {}",
],
[
'src/daemon/interaction/internal/interaction-runtime.ts',
"import { handleReactNativeCommands } from '../../handlers/react-native.ts';\nexport function createInteractionRuntime() {}",
],
[
'src/daemon/generic-settle.ts',
"import { createInteractionRuntime } from './interaction/internal/interaction-runtime.ts';",
],
[
'src/daemon/selector-runtime.ts',
"import { refSnapshotFlagGuardResponse } from './interaction/internal/interaction-flags.ts';",
],
[
'src/daemon/selector-runtime-backend.ts',
"import { readTextForNode } from './interaction/internal/interaction-ref-policy.ts';",
],
[
'src/daemon/interaction/internal/interaction-flags.ts',
'export function refSnapshotFlagGuardResponse() {}',
],
[
'src/daemon/interaction/internal/interaction-ref-policy.ts',
'export function readTextForNode() {}',
],
]),
);
const violations = checkDaemonModularityRatchets(
[...baselineEdges(), ...edges],
REFERENCE,
REFERENCE,
);
assert.equal(violations.length, 5);
assert.ok(
violations.some(({ message }) =>
message.includes(
"src/daemon/handlers/react-native.ts must not import daemon-interaction's internal tree",
),
),
);
assert.ok(
violations.some(({ message }) =>
message.includes(
"src/daemon/interaction/internal/interaction-runtime.ts must not import src/daemon/handlers/react-native.ts from daemon-interaction's internal tree",
),
),
);
assert.equal(
violations.filter(({ message }) =>
message.includes("must not import daemon-interaction's internal tree"),
).length,
4,
);
});
test('session observability rejects handler deep imports in both directions', () => {
const edges = resolveImportEdges(
new Map([
[
'src/daemon/handlers/session.ts',
"import { handleSessionObservabilityCommands } from '../session-observability/internal/session-observability.ts';\nexport function handleSessionCommands() {}",
],
[
'src/daemon/session-observability/internal/session-observability.ts',
"import { handleSessionCommands } from '../../handlers/session.ts';\nexport function handleSessionObservabilityCommands() {}",
],
[
'src/daemon/session-observability/index.ts',
'export function handleSessionObservabilityCommands() {}',
],
]),
);
const violations = checkDaemonModularityRatchets(
[...baselineEdges(), ...edges],
REFERENCE,
REFERENCE,
);
assert.deepEqual(
violations.map(({ file, line, message }) => ({
file,
line,
message: message.replace(/;.*/, ''),
})),
[
{
file: 'src/daemon/handlers/session.ts',
line: 1,
message:
"src/daemon/handlers/session.ts must not import daemon-session-observability's internal tree (src/daemon/session-observability/internal/session-observability.ts)",
},
{
file: 'src/daemon/session-observability/internal/session-observability.ts',
line: 1,
message: 'daemon-session-observability must not import src/daemon/handlers/session.ts',
},
],
);
});
test('snapshot execution cannot return to handler-owned support paths', () => {
const restored = [
'src/daemon/handlers/snapshot-capture.ts',
'src/daemon/handlers/snapshot-interactor-capture.ts',
'src/daemon/handlers/snapshot-session.ts',
];
assert.deepEqual(
checkRetiredSnapshotExecutionPaths(restored).map(({ file, message }) => ({ file, message })),
restored.map((file) => ({
file,
message:
`retired snapshot execution handler path was restored: ${file}. ` +
'Reuse the daemon-owned snapshot execution module instead of restoring shared mechanics beneath a route adapter.',
})),
);
assert.deepEqual(checkRetiredSnapshotExecutionPaths(['src/daemon/handlers/snapshot.ts']), []);
});
test('session lifecycle rejects restored neutral helper paths', () => {
const violations = checkRetiredSessionLifecyclePaths([
'src/daemon/session-device-resolution.ts',
'src/daemon/handlers/session-device-utils.ts',
'src/daemon/handlers/session-runtime-admission.ts',
'src/daemon/handlers/session-close-script.ts',
'src/daemon/handlers/session-close.ts',
]);
assert.deepEqual(
violations.map(({ message }) => message),
[
'retired session lifecycle path was restored: src/daemon/handlers/session-device-utils.ts. Keep the neutral seam at its daemon owner instead of rebuilding a handler grab-bag.',
'retired session lifecycle path was restored: src/daemon/handlers/session-runtime-admission.ts. Keep the neutral seam at its daemon owner instead of rebuilding a handler grab-bag.',
'retired session lifecycle path was restored: src/daemon/handlers/session-close-script.ts. Keep the neutral seam at its daemon owner instead of rebuilding a handler grab-bag.',
'retired session lifecycle path was restored: src/daemon/handlers/session-close.ts. Keep the neutral seam at its daemon owner instead of rebuilding a handler grab-bag.',
],
);
});
test('interaction rejects restored handler implementation paths', () => {
const violations = checkRetiredInteractionPaths([
'src/daemon/interaction/internal/interaction.ts',
'src/daemon/handlers/interaction-touch.ts',
]);
assert.deepEqual(violations, [
{
rule: 'R10 daemon-modularity',
file: 'src/daemon/handlers/interaction-touch.ts',
line: 1,
message:
'retired interaction handler path was restored: src/daemon/handlers/interaction-touch.ts. Keep route implementations behind src/daemon/interaction/index.ts instead of rebuilding a handler-owned interaction surface.',
},
]);
});
test('interaction rejects renamed handler implementation paths', () => {
const violations = checkRetiredInteractionPaths([
'src/daemon/handlers/interaction-touch-v2.ts',
'src/daemon/handlers/find-next.ts',
]);
assert.deepEqual(
violations.map(({ file, message }) => ({ file, message })),
[
{
file: 'src/daemon/handlers/interaction-touch-v2.ts',
message:
'retired interaction handler path was restored: src/daemon/handlers/interaction-touch-v2.ts. Keep route implementations behind src/daemon/interaction/index.ts instead of rebuilding a handler-owned interaction surface.',
},
{
file: 'src/daemon/handlers/find-next.ts',
message:
'retired interaction handler path was restored: src/daemon/handlers/find-next.ts. Keep route implementations behind src/daemon/interaction/index.ts instead of rebuilding a handler-owned interaction surface.',
},
],
);
});
test('session lifecycle rejects any restored open or close handler path', () => {
const violations = checkRetiredSessionLifecyclePaths([
'src/daemon/handlers/session-open-regressed.ts',
'src/daemon/handlers/session-close-regressed.ts',
]);
assert.deepEqual(
violations.map(({ file, message }) => ({
rule: 'R10 daemon-modularity',
file,
line: 1,
message,
})),
[
{
rule: 'R10 daemon-modularity',
file: 'src/daemon/handlers/session-open-regressed.ts',
line: 1,
message:
'retired session lifecycle path was restored: src/daemon/handlers/session-open-regressed.ts. Keep the neutral seam at its daemon owner instead of rebuilding a handler grab-bag.',
},
{
rule: 'R10 daemon-modularity',
file: 'src/daemon/handlers/session-close-regressed.ts',
line: 1,
message:
'retired session lifecycle path was restored: src/daemon/handlers/session-close-regressed.ts. Keep the neutral seam at its daemon owner instead of rebuilding a handler grab-bag.',
},
],
);
});
test('session observability rejects restored handler paths', () => {
const restoredPaths = [
'src/daemon/handlers/session-observability.ts',
'src/daemon/handlers/session-perf-runtime.ts',
'src/daemon/handlers/session-network.ts',
'src/daemon/handlers/session-audio.ts',
'src/daemon/handlers/session-network-regressed.ts',
'src/daemon/handlers/session-perf.ts',
'src/daemon/handlers/session-logs.ts',
'src/daemon/handlers/session-events.ts',
] as const;
const violations = checkRetiredSessionObservabilityPaths(restoredPaths);
assert.deepEqual(
violations.map(({ file, message }) => ({ file, message })),
restoredPaths.map((file) => ({
file,
message:
`retired session observability path was restored: ${file}. ` +
'Keep the neutral seam at its daemon owner instead of rebuilding a handler grab-bag.',
})),
);
});
test('R9 holds each zone to the merge-base and keeps engine files outside the component', () => {
// One commands file and one engine file traded for two provider-webdriver ones, so the
// total stays at the merge-base's size and only the per-zone claims are on trial.
const violations = checkDaemonModularityRatchets(
baselineEdges(),
measured({
largestTypeCycle: typeCycleMembers({
commands: 1,
'ad-replay': 1,
'provider-webdriver': 4,
}),
}),
REFERENCE,
);
assert.equal(violations.length, 3);
assert.ok(violations.some(({ message }) => /contains 1 commands file/.test(message)));
assert.ok(violations.some(({ message }) => /contains 1 ad-replay file/.test(message)));
assert.ok(violations.some(({ message }) => /engine file entered/.test(message)));
});
// #1837: the zone violation used to name the alphabetically-first zone member — a file that had
// been in the cycle all along — so the +1 was found only by diffing member lists between commits.
// The merge-base carries membership, so the message names exactly the files that joined.
test('R10 zone overflow names the member that joined the cycle', () => {
// Sorts after the provider-webdriver probes: a first-member pick could not name it by luck.
const joined = 'src/daemon/snapshot-interactor-capture.ts';
const members = [...typeCycleMembers({ 'provider-webdriver': 5 }), joined].sort();
const violations = checkDaemonModularityRatchets(
baselineEdges(),
measured({ largestTypeCycle: members }),
REFERENCE,
);
assert.equal(violations.length, 1);
const [violation] = violations;
assert.equal(violation!.rule, 'R10 daemon-modularity');
assert.equal(violation!.file, 'scripts/layering/daemon-modularity.ts');
assert.match(
violation!.message,
/contains 1 daemon-server file\(s\) \(baseline 0 at the merge-base\)/,
);
assert.match(
violation!.message,
new RegExp(`1 over the merge-base — the daemon-server file\\(s\\) that joined: ${joined}\\.`),
);
});
test('R9 rejects a cycle grown past the merge-base', () => {
const violations = checkDaemonModularityRatchets(
baselineEdges(),
measured({ largestTypeCycle: typeCycleMembers({ 'provider-webdriver': 7 }) }),
REFERENCE,
);
assert.equal(violations.length, 2);
assert.match(violations[0]!.rule, /^R9 /);
assert.match(violations[0]!.message, /grew to 7 files \(baseline 6 at the merge-base\)/);
assert.match(violations[1]!.rule, /^R10 /);
});
// The shrink direction used to need an edit in the same change, or the ceiling kept headroom the
// next change could spend. Measuring the merge-base banks it on merge, with nothing to lower.
test('R9 banks a shrink with no edit anywhere', () => {
assert.deepEqual(
checkDaemonModularityRatchets(
baselineEdges(),
measured({ largestTypeCycle: typeCycleMembers({ 'provider-webdriver': 5 }) }),
REFERENCE,
),
[],
);
});