Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions packages/capture-kit/src/ios-snapshot-acquisition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ const ACQUIRED_PRODUCER_CAPABILITY_DEFAULTS = {
viewportEvidence: 'available',
hittabilityEvidence: 'unavailable',
truncationEvidence: 'unavailable',
presentationOwner: 'ios-snapshot-engine',
} as const;

const IOS_SNAPSHOT_PRODUCER_CAPABILITY_VALUES = {
Expand All @@ -38,7 +37,6 @@ const IOS_SNAPSHOT_PRODUCER_CAPABILITY_VALUES = {
viewportEvidence: 'available',
hittabilityEvidence: 'available',
truncationEvidence: 'available',
presentationOwner: 'ios-snapshot-engine',
},
'simulator-ax-bridge': {
...ACQUIRED_PRODUCER_CAPABILITY_DEFAULTS,
Expand All @@ -49,7 +47,6 @@ const IOS_SNAPSHOT_PRODUCER_CAPABILITY_VALUES = {
},
hittabilityEvidence: 'available',
truncationEvidence: 'available',
presentationOwner: 'snapshot-state',
},
'appium-source': {
...ACQUIRED_PRODUCER_CAPABILITY_DEFAULTS,
Expand Down
4 changes: 2 additions & 2 deletions packages/capture-kit/src/ios-snapshot-engine/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ import {
} from '@agent-device/capture-kit/ios-snapshot-planning';
import { toIosSnapshotEngineErrorDetails } from './types.ts';
import {
compactIosInteractiveSnapshot,
createIosSnapshotEngine,
IosSnapshotEngineError,
presentIosInteractiveSnapshot,
presentIosSnapshot,
publishIosSnapshot,
} from './index.ts';
Expand Down Expand Up @@ -326,7 +326,7 @@ test('unavailable hittability never becomes regular actionability', () => {

test('interactive compaction stays available through the engine boundary', () => {
const rowRect = { x: 16, y: 80, width: 288, height: 52 };
const compacted = compactIosInteractiveSnapshot([
const compacted = presentIosInteractiveSnapshot([
node(0, 'Application', 'App', viewport),
node(1, 'Table', 'Settings', { x: 0, y: 40, width: 320, height: 200 }, 0),
node(2, 'Cell', 'General', rowRect, 1, 2),
Expand Down
4 changes: 0 additions & 4 deletions packages/capture-kit/src/ios-snapshot-engine/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,6 @@ export function presentIosSnapshot(
return presentIosRunnerSnapshot(input, request, foldPolicy);
}

export function compactIosInteractiveSnapshot(nodes: RawSnapshotNode[]): RawSnapshotNode[] {
return buildIosInteractiveSnapshotPresentation(nodes).nodes;
}

function presentAcquiredSnapshot(
acquisition: IosSnapshotAcquisition,
request: IosSnapshotRequest,
Expand Down
7 changes: 1 addition & 6 deletions packages/capture-kit/src/ios-snapshot-engine/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,4 @@
export {
compactIosInteractiveSnapshot,
createIosSnapshotEngine,
presentIosSnapshot,
publishIosSnapshot,
} from './engine.ts';
export { createIosSnapshotEngine, presentIosSnapshot, publishIosSnapshot } from './engine.ts';
export { presentIosRunnerSnapshot } from './runner-presentation.ts';
export {
buildIosInteractiveSnapshotPresentation,
Expand Down
1 change: 0 additions & 1 deletion packages/capture-kit/src/ios-snapshot-planning.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,6 @@ function acquiredProducer(
viewportEvidence: 'available',
hittabilityEvidence: 'available',
truncationEvidence: 'available',
presentationOwner: 'snapshot-state',
...overrides,
};
}
Expand Down
2 changes: 0 additions & 2 deletions packages/contracts/src/ios-snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ export type IosAcquisitionIntent = 'full' | 'surface-observation';
export type IosSnapshotProjection = 'regular' | 'raw';
export type IosSnapshotCompleteness = 'complete' | 'incomplete';
export type IosSnapshotEvidenceAvailability = 'available' | 'unavailable';
export type IosSnapshotPresentationOwner = 'ios-snapshot-engine' | 'snapshot-state';

export type IosSnapshotGeneration = string;

Expand Down Expand Up @@ -86,7 +85,6 @@ type IosSnapshotProducerCapabilityFacts = Readonly<{
viewportEvidence: IosSnapshotEvidenceAvailability;
hittabilityEvidence: IosSnapshotEvidenceAvailability;
truncationEvidence: IosSnapshotEvidenceAvailability;
presentationOwner: IosSnapshotPresentationOwner;
}>;

export type IosSnapshotAcquisitionProducerCapabilities = IosSnapshotProducerCapabilityFacts &
Expand Down
18 changes: 15 additions & 3 deletions packages/kernel/src/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,14 +198,26 @@ type OptionalProducerProvenance<Pair> = Pair extends {
: never;

/**
* The provenance carrier for {@link SnapshotState}: the producer may be absent (legacy states
* and fixtures predate it), but a present pair still has to come from the
* {@link SnapshotProvenance} table — the channel may not carry a foreign producer.
* The provenance carrier for {@link SnapshotState}: the producer may be absent (a client-side
* fallback that rebuilds a state from a bare backend result knows the channel and nothing more),
* but a present pair still has to come from the {@link SnapshotProvenance} table — the channel
* may not carry a foreign producer.
*/
export type SnapshotStateProvenance =
| OptionalProducerProvenance<SnapshotProvenance>
| { backend?: undefined; producer?: undefined };

/**
* The provenance a capture hands to the daemon snapshot assembly: either nothing is known about
* the origin, or the WHOLE pair is. A channel that arrived without its producer would leave the
* assembly guessing who presented the tree, which is exactly the backend-name presentation
* policy #2199 deleted — so it does not compile. Every production capture satisfies this: the
* interactor boundary (`SnapshotResult`) already carries {@link SnapshotProvenance}.
*/
export type SnapshotCaptureProvenance =
| SnapshotProvenance
| { backend?: undefined; producer?: undefined };

/**
* Narrows a provenance-carrying value to just its pair without decorrelating the two fields
* (reading `backend` and `producer` separately would lose the pairing for the type system).
Expand Down
4 changes: 4 additions & 0 deletions scripts/layering/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ import { sessionResourceOwnershipViolations } from './session-resource-ownership
import { applicationLifecycleOwnershipViolations } from './application-lifecycle-policy.ts';
import { iosSnapshotEngineOwnershipViolations } from './ios-snapshot-engine-policy.ts';
import { providerSnapshotPresentationViolations } from './provider-snapshot-presentation-policy.ts';
import { snapshotAssemblyPresentationViolations } from './snapshot-assembly-presentation-policy.ts';
import { RETIRED_PATH_RULES, retiredPathRuleViolations } from './retired-paths-policy.ts';

const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], {
Expand Down Expand Up @@ -435,6 +436,7 @@ export const LAYERING_RULE_IDS = [
'replay-ownership',
'ios-snapshot-engine-ownership',
'provider-snapshot-presentation-ownership',
'snapshot-assembly-presentation-neutrality',
] as const;

export type LayeringRuleId = (typeof LAYERING_RULE_IDS)[number];
Expand Down Expand Up @@ -487,6 +489,8 @@ export const LAYERING_RULES: Readonly<Record<LayeringRuleId, LayeringRule>> = {
),
'provider-snapshot-presentation-ownership': (context) =>
providerSnapshotPresentationViolations(context.sources, context.edges),
'snapshot-assembly-presentation-neutrality': (context) =>
snapshotAssemblyPresentationViolations(context.sources, context.edges),
};

export function main(): number {
Expand Down
150 changes: 150 additions & 0 deletions scripts/layering/snapshot-assembly-presentation-policy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { test } from 'node:test';
import {
SNAPSHOT_ASSEMBLY_FILES,
SNAPSHOT_ASSEMBLY_PRESENTATION_RULE,
snapshotAssemblyPresentationViolations,
} from './snapshot-assembly-presentation-policy.ts';
import { resolveImportEdges } from './model.ts';
import { workspaceSpecifierTargets } from './package-boundaries.ts';
import { listTrackedProductionSources } from './tracked-sources.ts';

const repoRoot = path.resolve(import.meta.dirname, '../..');
const assemblyFile = 'src/core/snapshot-state.ts';
const producerAdapter = 'packages/platform-apple/src/snapshot-source/adapter.ts';

function currentSources(overrides: ReadonlyMap<string, string> = new Map()): Map<string, string> {
const sources = new Map(
listTrackedProductionSources(repoRoot).map((file) => [
file,
fs.readFileSync(path.join(repoRoot, file), 'utf8'),
]),
);
for (const [file, source] of overrides) sources.set(file, source);
return sources;
}

function violations(overrides: ReadonlyMap<string, string> = new Map()) {
const sources = currentSources(overrides);
return snapshotAssemblyPresentationViolations(
sources,
resolveImportEdges(sources, workspaceSpecifierTargets(repoRoot)),
);
}

function appended(file: string, addition: string): ReadonlyMap<string, string> {
return new Map([[file, `${fs.readFileSync(path.join(repoRoot, file), 'utf8')}${addition}`]]);
}

test('the snapshot assembly and the producer adapters are presentation-neutral', () => {
assert.deepEqual(violations(), []);
});

test('R74 rejects the assembly importing the iOS snapshot engine', () => {
const result = violations(
appended(
assemblyFile,
`\nimport { presentIosInteractiveSnapshot } from '@agent-device/capture-kit/ios-snapshot-engine';\nvoid presentIosInteractiveSnapshot;\n`,
),
);

assert.ok(
result.some(
(entry) =>
entry.rule === SNAPSHOT_ASSEMBLY_PRESENTATION_RULE &&
entry.file === assemblyFile &&
entry.message.includes('must not import iOS presentation'),
),
JSON.stringify(result),
);
});

test('R74 rejects the assembly reading the producer capability table', () => {
const result = violations(
appended(
assemblyFile,
`\nimport { IOS_SNAPSHOT_PRODUCER_CAPABILITIES } from '@agent-device/capture-kit/ios-snapshot-acquisition';\nvoid IOS_SNAPSHOT_PRODUCER_CAPABILITIES;\n`,
),
);

assert.ok(
result.some(
(entry) =>
entry.rule === SNAPSHOT_ASSEMBLY_PRESENTATION_RULE &&
entry.file === assemblyFile &&
entry.message.includes('ios-snapshot-acquisition'),
),
JSON.stringify(result),
);
});

test('R74 rejects a backend-name presentation branch in the assembly', () => {
const result = violations(
appended(
assemblyFile,
`\nexport function presentsHere(backend: string): boolean {\n return backend === 'xctest';\n}\n`,
),
);

assert.ok(
result.some(
(entry) =>
entry.rule === SNAPSHOT_ASSEMBLY_PRESENTATION_RULE &&
entry.file === assemblyFile &&
entry.message.includes("('xctest')"),
),
JSON.stringify(result),
);
});

test('R74 rejects an assembly branch on a producer name', () => {
const result = violations(
appended(assemblyFile, `\nexport const legacyOwner = 'simulator-ax-bridge';\n`),
);

assert.ok(
result.some(
(entry) =>
entry.rule === SNAPSHOT_ASSEMBLY_PRESENTATION_RULE &&
entry.file === assemblyFile &&
entry.message.includes("('simulator-ax-bridge')"),
),
JSON.stringify(result),
);
});

test('R74 rejects a producer adapter importing iOS presentation', () => {
const result = violations(
appended(
producerAdapter,
`\nimport { presentIosSnapshot } from '@agent-device/capture-kit/ios-snapshot-engine';\nvoid presentIosSnapshot;\n`,
),
);

assert.ok(
result.some(
(entry) =>
entry.rule === SNAPSHOT_ASSEMBLY_PRESENTATION_RULE &&
entry.file === producerAdapter &&
entry.message.includes('producers report acquisition facts'),
),
JSON.stringify(result),
);
});

test('R74 fails closed when the assembly moves out from under it', () => {
const sources = currentSources();
for (const file of SNAPSHOT_ASSEMBLY_FILES) sources.delete(file);

const result = snapshotAssemblyPresentationViolations(
sources,
resolveImportEdges(sources, workspaceSpecifierTargets(repoRoot)),
);

assert.deepEqual(
result.filter((entry) => entry.message.includes('is missing')).map((entry) => entry.file),
[...SNAPSHOT_ASSEMBLY_FILES],
);
});
Loading
Loading