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
4 changes: 2 additions & 2 deletions scripts/layering/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@
// composition file; premature implementation loading and forbidden cross-boundary edges fail (R13).
// - Over COMMAND-ATOMIC RUNTIME CUTOVERS: one parametrized gate reads the migrated-command
// table (appstate R22, shutdown R23, boot R20, apps R21, install/deploy R24-R27,
// lifecycle R28-R31, devices R17, logs R14, network R15, record R16, snapshot R32, diff R33)
// and proves each command keeps
// lifecycle R28-R31, devices R17, logs R14, network R15, record R16, snapshot R32, diff R33,
// viewport R34, get R36, is R37 — R35 reserved for find) and proves each command keeps
// exactly one platform-execution path — retired routes, admission, modules, and widened
// runtime access cannot coexist with its operation-fact-derived descriptor and handler.
// - Over CONTRACTS PRODUCTION SOURCE: contracts owns vocabulary only — host, process, and timer
Expand Down
12 changes: 12 additions & 0 deletions scripts/layering/runtime-command-cutover-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,17 @@ export type LegacyRetirementClaim = Readonly<{
daemonOnlyProviderMethods?: readonly string[];
/** `PlatformPlugin` facet keys retired with the legacy adapter. */
pluginFacetKeys?: readonly string[];
/**
* Static platform command sets this command's admission DATA was removed from — the whole
* retirement of a command whose legacy admission was a capability bucket plus set membership,
* with no adapter module, route, or dispatch projection to name.
*
* Every other form above names something that must NOT exist, which a row can satisfy by
* inventing a name that never existed. This one is two-sided and cannot: each named set must
* still EXIST in production source, and must no longer list the command. A fictional set fails
* the first half, a skipped deletion the second.
*/
staticCommandSets?: readonly string[];
}>;

/**
Expand Down Expand Up @@ -157,6 +168,7 @@ const RETIREMENT_FORMS = [
'daemonOnlyRouteNames',
'daemonOnlyProviderMethods',
'pluginFacetKeys',
'staticCommandSets',
] as const satisfies readonly (keyof LegacyRetirementClaim)[];

/**
Expand Down
54 changes: 54 additions & 0 deletions scripts/layering/runtime-command-cutover-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,3 +285,57 @@ test('every shipped row states its claims', () => {
[],
);
});

// A data-only admission retirement — a capability bucket plus static-set membership, with no
// module, route, or dispatch projection to name as gone. Both halves are planted, because the
// half that matters is the one an identifier-shaped claim cannot state: a set that never existed.
const DATA_ONLY_ROW: MigratedCommandCutover = {
...PLANTED_ROW,
legacyRetirement: { staticCommandSets: ['WEB_QUERY_COMMANDS'] },
};

test('a data-only retirement is a stated claim, so a row needs no invented identifier', () => {
assert.deepEqual(cutoverRowDefects(DATA_ONLY_ROW), []);
});

test('planted red: a row claiming a static command set that does not exist is rejected', () => {
assert.deepEqual(
summariesFor(
PLANTED_RULE,
[['src/core/capabilities.ts', `const WEB_QUERY_COMMANDS = ['find'];`]],
[
{
...DATA_ONLY_ROW,
legacyRetirement: { staticCommandSets: ['WEB_QUERY_COMMANDS_WITH_PLANTED'] },
},
],
).filter((summary) => summary.includes('static command set')),
[
"(planted cutover row): claims retired static command set 'WEB_QUERY_COMMANDS_WITH_PLANTED', which no production source declares",
],
);
});

test('planted red: a claimed static command set that still lists the command is rejected', () => {
assert.deepEqual(
summariesFor(
PLANTED_RULE,
[['src/core/capabilities.ts', `const WEB_QUERY_COMMANDS = ['find', 'planted'];`]],
[DATA_ONLY_ROW],
).filter((summary) => summary.includes('still admits')),
['src/core/capabilities.ts: static command set WEB_QUERY_COMMANDS still admits planted'],
);
});

test('a claimed static command set that exists and dropped the command passes', () => {
// Scoped to this column: the planted row's singular-execution claims are unrelated here and
// have their own cases above.
assert.deepEqual(
summariesFor(
PLANTED_RULE,
[['src/core/capabilities.ts', `const WEB_QUERY_COMMANDS = ['find'];`]],
[DATA_ONLY_ROW],
).filter((summary) => summary.includes('static command set')),
[],
);
});
54 changes: 54 additions & 0 deletions scripts/layering/runtime-command-cutover-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ function rowViolations(
violations.push(...narrowingViolations(row, file, program));
}
violations.push(...exactCallViolations(row, files, programs));
violations.push(...staticCommandSetViolations(row, files, programs));
const sources = new Map(files.map(({ path, source }) => [path, source]));
for (const check of rowChecks(row)) violations.push(...check(sources));
return violations;
Expand Down Expand Up @@ -356,6 +357,59 @@ function isAdmissionMember(
);
}

/**
* A data-only admission retirement, proven from both sides.
*
* A command whose legacy admission was a capability bucket plus membership in a static platform
* command set retires no module, route, or dispatch projection — there is no identifier to name
* as gone. Naming an invented one satisfies the non-empty shape check while proving nothing, so
* the row names the sets themselves: each must still be DECLARED in production source, and must
* no longer carry this command.
*
* The existence half is what an identifier-shaped claim cannot express. The membership half
* overlaps the automatic static-set column for `WEB`/`HARMONY`-named sets, deliberately: stating
* it here keeps the declared claim self-sufficient rather than dependent on that regex.
*/
function staticCommandSetViolations(
row: MigratedCommandCutover,
files: readonly ProductionSource[],
programs: ReadonlyMap<string, AstNode>,
): UnruledViolation[] {
const declared = row.legacyRetirement.staticCommandSets ?? [];
if (declared.length === 0) return [];
const violations: UnruledViolation[] = [];
const seen = new Set<string>();
for (const file of files) {
const program = programs.get(file.path);
if (!program) continue;
visitAst(program, (node) => {
const name = staticCommandSetName(node, declared);
if (name === undefined) return;
seen.add(name);
if (containsStringLiteral(node['init'], row.command)) {
violations.push(at(file, node, `static command set ${name} still admits ${row.command}`));
}
});
}
for (const name of declared) {
if (seen.has(name)) continue;
violations.push({
file: `(${row.command} cutover row)`,
line: 1,
message: `claims retired static command set '${name}', which no production source declares`,
});
}
return violations;
}

function staticCommandSetName(node: AstNode, declared: readonly string[]): string | undefined {
if (node['type'] !== 'VariableDeclarator') return undefined;
const id = node['id'] as AstNode | undefined;
if (id?.['type'] !== 'Identifier') return undefined;
const name = String(id['name']);
return declared.includes(name) ? name : undefined;
}

function containsStringLiteral(node: unknown, expected: string): boolean {
let found = false;
visitAst(node, (candidate) => {
Expand Down
40 changes: 39 additions & 1 deletion scripts/layering/runtime-command-cutover-table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ import { retiredDispatchProjectionViolations } from './runtime-command-cutover-d
* A row id is a report heading, so it must be unique across every stack that adds rows here.
* `cutoverTableDefects` rejects a duplicate; lifecycle starts at R28 after the accepted
* shutdown, install/deploy, and application-lifecycle allocations. Snapshot starts at R32;
* diff follows at R33, viewport at R34, and get at R36 (R35 is reserved for find).
* diff follows at R33, viewport at R34, get at R36, and is at R37. R35 stays reserved for
* find, whose cutover is deferred behind the Wave 5 `focus`/`type` surfaces.
*/
export const MIGRATED_COMMAND_CUTOVERS: readonly MigratedCommandCutover[] = [
{
Expand Down Expand Up @@ -548,6 +549,43 @@ export const MIGRATED_COMMAND_CUTOVERS: readonly MigratedCommandCutover[] = [
},
},
},
{
rule: 'R37 is-runtime-cutover',
command: 'is',
subject: 'element predicate',
tier: 'request-scoped',
execution: 'device-runtime',
// `is` retired no module, route, or dispatch projection — it had none. Its whole legacy
// admission was the capability bucket (rejected by this row's automatic descriptor column)
// plus membership in these two static sets, which is a DATA deletion. Naming the sets proves
// it from both sides: each must still be declared in production source and must no longer
// list `is`, so neither an invented name nor a skipped deletion can satisfy it.
legacyRetirement: {
staticCommandSets: ['HARMONYOS_SUPPORTED_COMMANDS', 'WEB_QUERY_COMMANDS'],
},
runtimeTypeNames: ['SnapshotRuntimeOperations'],
operations: { names: ['captureSnapshot', 'captureSnapshotWithoutActiveApp'] },
singularExecution: {
routes: ['dispatchIsViaRuntime'],
operations: ['captureSnapshot', 'captureSnapshotWithoutActiveApp'],
// `is` executes through the shared selector seam, so its capture owners are the SAME
// selectors `snapshot`/`diff`/`get` count. It declares no operation of its own: every
// predicate answers from the resolved tree, so `readTextAtPoint` stays R36's alone.
//
// Scope, stated so this is not read as absolute: the claim covers how a predicate is
// EXECUTED. Since the direct-iOS selector shortcut retired, the bound capture is the only
// thing that answers one. It does NOT claim the route makes no other device call — the
// Android foreground-blocker diagnostic still reaches adb through
// `platforms/android/app-lifecycle.ts`, on the FAILURE path only, where it can enrich an
// already-failed response's message but can never produce or change a verdict. That edge
// is pre-existing, co-owned with `wait`, and recorded as Wave 6 denominator work; R22's
// `appState` is its declared replacement.
operationOwners: {
captureSnapshot: ['selectActiveAppSnapshot'],
captureSnapshotWithoutActiveApp: ['selectSnapshotWithoutActiveApp'],
},
},
},
{
rule: 'R34 viewport-runtime-cutover',
command: 'viewport',
Expand Down
36 changes: 36 additions & 0 deletions src/__tests__/cli-exit-paths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,3 +222,39 @@ test('a --debug failure caps the daemon-log-tail dump instead of printing it unb
'expected the byte cap to drop the oldest lines, not just the 200-line cap',
);
});

// The end-to-end half of `is`'s documented contract: "is evaluates UI predicates against a
// selector expression and exits non-zero on failure" (website/docs/docs/commands.md).
//
// This deliberately does NOT know how the daemon decided. It was written when the direct-iOS
// shortcut answered some predicates itself and returned `{ok: true, pass: false}`, which the CLI
// rendered as `Passed: is text` with exit 0 (#1739). The shortcut is retired and every predicate
// now answers from the bound capture, so the guarantee is structural rather than guard-based —
// and this case survives that change untouched, because a failed assertion must exit non-zero
// whatever produced the failure.
test('a failed `is` predicate exits non-zero, whatever answered it', async () => {
const restoreEnv = installIsolatedCliTestEnv();
const exitSpy = installExitSpy();
const stderr = captureStderr();
const sendToDaemon = async (): Promise<DaemonResponse> => ({
ok: false,
error: {
code: 'COMMAND_FAILED',
message: 'is text failed for selector id=greeting: expected="Welcome" actual="Goodbye"',
details: { command: 'is', reason: 'predicate_failed', predicate: 'text' },
},
});

try {
await runCli(['is', 'text', 'id=greeting', 'Welcome'], { sendToDaemon });
} finally {
stderr.restore();
exitSpy.restore();
restoreEnv();
}

assert.deepEqual(exitSpy.calls, [1]);
const output = stderr.read();
assert.ok(output.includes('COMMAND_FAILED'), 'expected the typed failure on stderr');
assert.ok(!output.includes('Passed'), 'a failed assertion must never render as passed');
});
3 changes: 0 additions & 3 deletions src/core/__tests__/capabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,6 @@ test('macOS supports the Apple runner interaction core but excludes mobile-only
'find',
'focus',
'get',
'is',
'longpress',
'logs',
'perf',
Expand Down Expand Up @@ -306,7 +305,6 @@ test('Linux supports desktop interaction commands and blocks mobile/unsupported
'focus',
'get',
'home',
'is',
'longpress',
'press',
'screenshot',
Expand Down Expand Up @@ -334,7 +332,6 @@ test('web supports only the initial browser interaction slice', () => {
'find',
'get',
'hover',
'is',
'press',
'record',
'screenshot',
Expand Down
3 changes: 0 additions & 3 deletions src/core/__tests__/capability-plugin-routing-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,11 +180,9 @@ const HARMONYOS_SUPPORTED_COMMANDS_REF = new Set([
'fill',
'find',
'focus',
'get',
'home',
'gesture',
'keyboard',
'is',
'longpress',
'press',
'screenshot',
Expand Down Expand Up @@ -271,7 +269,6 @@ test('HarmonyOS static capabilities omit runtime-backed command admissions', ()
'focus',
'gesture',
'home',
'is',
'keyboard',
'longpress',
'perf',
Expand Down
3 changes: 1 addition & 2 deletions src/core/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@ const HARMONYOS_SUPPORTED_COMMANDS = new Set<string>([
'home',
'gesture',
'keyboard',
'is',
'longpress',
'press',
'scroll',
Expand All @@ -56,7 +55,7 @@ const HARMONYOS_SUPPORTED_COMMANDS = new Set<string>([
'type',
'wait',
]);
const WEB_QUERY_COMMANDS = ['audio', 'find', 'is', 'wait'] as const;
const WEB_QUERY_COMMANDS = ['audio', 'find', 'wait'] as const;
const WEB_INTERACTION_COMMANDS = [
'click',
'fill',
Expand Down
1 change: 1 addition & 0 deletions src/core/command-descriptor/__tests__/parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ const NO_CAPABILITY_PUBLIC_COMMANDS = new Set<string>([
PUBLIC_COMMANDS.get,
PUBLIC_COMMANDS.install,
PUBLIC_COMMANDS.installFromSource,
PUBLIC_COMMANDS.is,
PUBLIC_COMMANDS.logs,
PUBLIC_COMMANDS.network,
PUBLIC_COMMANDS.open,
Expand Down
3 changes: 1 addition & 2 deletions src/core/command-descriptor/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1203,10 +1203,9 @@ export const RAW_COMMAND_DESCRIPTORS = [
recordsSessionAction: true,
recordingEffect: 'observes-app',
daemon: { route: 'interaction', refFrameEffect: 'preserve' },
capability: ALL_DEVICE_COMMAND_CAPABILITY,
timeoutPolicy: postActionObservationTimeoutPolicy('is', PRESERVE_DAEMON_TIMEOUT_POLICY),
batchable: true,
platformExecution: LEGACY_PLATFORM_EXECUTION,
platformExecution: { kind: 'device-runtime', uses: selectorCaptureRuntimePlanUses },
},

// -- generic (route: generic) --
Expand Down
Loading
Loading