Files
reverse-engineering-anything/src/domain/analysisView/environmentStateWriteChecks.ts
T
Edgar Arakelyan 5375ff0f9a feat(environment): extend evidence-gated write coverage
Retain bodiless writes, proven omitted fields, identity-free projections and digit-group sequences. Preserve late projection provenance and current omission facts, and check absence statuses before replay shortcuts.

Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing)
2026-10-22 23:46:52 +08:00

597 lines
22 KiB
TypeScript

import { ServedOrder } from "./environmentStateWriteOrder.js";
import type { JsonValue } from "../jsonValue.js";
import { compositeKey } from "../unicodeCodePointOrder.js";
import {
at,
decodedSegment,
isObject,
listItems,
listsAll,
missingRequiredFields,
pathSegmentMatches,
renderStateEntity,
renderStateList,
replaceAt,
same,
scalarText,
type JsonObject,
type StateDocument,
type StateEntity,
type StateItemView,
type StateListView,
type StateRecordFacts,
type StateView,
segmentsOf,
} from "./environmentStateRules.js";
import { pageBound } from "./environmentStatePaging.js";
import { answerQuery, renderQueryList } from "./environmentStateListQuery.js";
import { singleQueryValues } from "../recordedTransportUrl.js";
import type { ServedLine } from "./environmentStateCheckedLines.js";
import { insertIndex } from "./environmentStateWriteExplain.js";
import type { InsertRule } from "./environmentStateWriteRules.js";
export const entityTarget = (type: string, collection: string, id: string) =>
`e${compositeKey([type, collection, id])}`;
export const collectionTarget = (type: string, collection: string) =>
`c${compositeKey([type, collection])}`;
export const documentTarget = (tool: string) => `d${compositeKey([tool])}`;
/** A target `remove` dropped, so a later absence can still name the collection and id serving resolved. */
interface RemovedTarget {
readonly type: string;
readonly collection: string;
readonly id: string;
}
/** The gate's copy of the state: entities in collection order and documents, changed by modelled writes. */
export class SimulatedState {
readonly #entities = new Map<string, StateEntity>();
readonly #collections = new Map<string, string[]>();
readonly #documents = new Map<string, JsonValue>();
readonly #removed: RemovedTarget[] = [];
readonly #served = new ServedOrder();
constructor(
entities: readonly StateEntity[],
documents: readonly StateDocument[],
) {
for (const entity of entities) this.put(entity);
this.#served.reset(
entities.map(({ type, collection, id }) =>
entityTarget(type, collection, id),
),
);
for (const document of documents)
this.#documents.set(document.tool, document.value);
}
entity(
type: string,
collection: string,
id: string,
): StateEntity | undefined {
return this.#entities.get(entityTarget(type, collection, id));
}
members(type: string, collection: string): StateEntity[] {
return (
this.#collections.get(collectionTarget(type, collection)) ?? []
).map((key) => this.#entities.get(key)!);
}
ofType(type: string): StateEntity[] {
return this.#served.keys
.map((key) => this.#entities.get(key)!)
.filter((entity) => entity.type === type);
}
knownCollections(type: string): readonly string[] {
return this.#served.known(type);
}
/** Replace an entity in place, or add it at `index` of its collection (the end when omitted). */
put(entity: StateEntity, index?: number): void {
const key = entityTarget(entity.type, entity.collection, entity.id);
const known = this.#entities.has(key);
this.#entities.set(key, entity);
if (known) return;
const collection = collectionTarget(entity.type, entity.collection);
const order = this.#collections.get(collection) ?? [];
const at = index ?? order.length;
this.#served.place(key, order, at);
order.splice(at, 0, key);
this.#collections.set(collection, order);
this.#served.remember(entity.type, entity.collection);
}
remove(entity: StateEntity): void {
const key = entityTarget(entity.type, entity.collection, entity.id);
this.#entities.delete(key);
this.#served.remove(key);
const collection = collectionTarget(entity.type, entity.collection);
const order = this.#collections.get(collection) ?? [];
this.#collections.set(
collection,
order.filter((member) => member !== key),
);
const index = this.#removed.findIndex(
(target) =>
target.type === entity.type &&
target.collection === entity.collection &&
target.id === entity.id,
);
if (index >= 0) this.#removed.splice(index, 1);
this.#removed.push({
type: entity.type,
collection: entity.collection,
id: entity.id,
});
}
/** Removed targets of this type the state does not hold again. */
removedTargets(type: string): readonly RemovedTarget[] {
return this.#removed.filter(
(target) =>
target.type === type &&
!this.#entities.has(entityTarget(type, target.collection, target.id)),
);
}
/** Place created entities again as a create under another insert rule would have. */
reinsert(
type: string,
collection: string,
entities: readonly StateEntity[],
rule: InsertRule,
): void {
for (const entity of entities) this.remove(entity);
for (const entity of entities) {
const order = this.members(type, collection).map(
(member) => member.fields,
);
this.put(entity, insertIndex(rule, order, entity.fields));
}
}
document(tool: string): JsonValue | undefined {
return this.#documents.get(tool);
}
setDocument(tool: string, value: JsonValue): void {
this.#documents.set(tool, value);
}
}
/** An item read status that says the entity does not exist: 400, 404 or 410, as applications answer a missing id. */
export const isAbsence = (status: number | null): boolean =>
status === 400 || status === 404 || status === 410;
/** A 2xx record: the only status whose response shows what a request did. */
export const succeeded = (record: StateRecordFacts): boolean =>
record.status !== null && record.status >= 200 && record.status < 300;
/** Hand a base recording's body and a read's body to `use` together, parsing a recording once when both are the same. */
export const withBodies = <T>(
withBody: <U>(ordinal: number, use: (body: JsonValue | undefined) => U) => U,
ordinals: { readonly base: number; readonly read: number },
use: (base: JsonValue | undefined, body: JsonValue | undefined) => T,
): T => {
const { base, read } = ordinals;
return base === read
? withBody(read, (body) => use(body, body))
: withBody(base, (served) => withBody(read, (body) => use(served, body)));
};
/** A check's outcome and the targets it covered. */
export interface CheckOutcome {
readonly pass: boolean;
/** The entities, by target, whose place a list read shows state order decides; absent, every listed entity's. */
readonly positional?: ReadonlySet<string>;
readonly targets: readonly string[];
/** Entities the recording, or the served base of a recorded-membership list, lists that the state does not hold, by target. */
readonly unheld: readonly string[];
/** For a failed list read that differs only in some members' fields, those members' targets. */
readonly differing?: readonly string[];
/** Required fields the read shows the application leaves out of entities the state holds. */
readonly omitted?: readonly string[];
}
/** Whether a passing check shows where a create placed its entity: a list's does unless a sort orders that entity, leaving it tied with none, or it matched alone. */
export const showsPlacement = (
view: StateView,
outcome: Pick<CheckOutcome, "positional">,
inserted: ReadonlyMap<string, string>,
tool: string,
): boolean =>
view.shape === "list" &&
(outcome.positional === undefined ||
[...inserted].some(
([target, create]) => create === tool && outcome.positional!.has(target),
));
/** What a check compares: the read's recorded body and the served base recording it would answer on. */
export interface ReadBodies {
readonly body: JsonValue | undefined;
/** The recording whose envelope the served answer keeps: an item view's envelope, a list line's latest init recording. */
readonly base: JsonValue | undefined;
readonly volatile: ReadonlySet<string>;
}
/** An item read: the answer the held entity renders into the served envelope must equal the recording; a recorded 404 or 410 agrees with none held. */
/** How many of a held collection's segments match a path's verbatim, or undefined when one matches neither verbatim nor either spelling once percent-decoded. */
const segmentMatch = (
held: readonly string[],
read: readonly string[],
): number | undefined => {
if (held.length !== read.length) return undefined;
let verbatim = 0;
for (const [index, segment] of held.entries()) {
const value = read[index]!;
if (segment === value) verbatim += 1;
else if (!pathSegmentMatches(segment, value)) return undefined;
}
return verbatim;
};
/** A held target an item path names, preferring the path's own collection, then a verbatim spelling. */
const matchingTarget = (
path: {
readonly read: readonly string[];
readonly ids: readonly string[];
readonly written: string;
},
collections: readonly string[],
held: (collection: string, id: string) => boolean,
): { readonly collection: string; readonly id: string } | undefined => {
for (const id of path.ids)
if (held(path.written, id)) return { collection: path.written, id };
const ranked = [...new Set(collections)]
.flatMap((collection) => {
const verbatim = segmentMatch(segmentsOf(collection), path.read);
return verbatim === undefined ? [] : [{ collection, verbatim }];
})
.sort((left, right) => right.verbatim - left.verbatim);
for (const { collection } of ranked)
for (const id of path.ids)
if (held(collection, id)) return { collection, id };
return undefined;
};
/**
* The entity an item read or write names, as serving names it: in a held collection its path matches, verbatim
* spellings first, the id percent-decoded once, then as written; else a removed target still named that way, so
* an absence keeps the collection and id a delete resolved; else the collection and decoded id as written.
*/
export const itemTarget = (
state: SimulatedState,
type: string,
record: Pick<StateRecordFacts, "path">,
): { readonly collection: string; readonly id: string } => {
const read = segmentsOf(record.path);
const raw = read.pop() ?? "";
const path = {
read,
ids: [...new Set([decodedSegment(raw), raw])],
written: `/${read.join("/")}`,
};
const removed = state.removedTargets(type);
return (
matchingTarget(
path,
state.ofType(type).map((entity) => entity.collection),
(collection, id) => state.entity(type, collection, id) !== undefined,
) ??
matchingTarget(
path,
removed.map((target) => target.collection),
(collection, id) =>
removed.some(
(target) => target.collection === collection && target.id === id,
),
) ?? { collection: path.written, id: path.ids[0]! }
);
};
/** The held collection of a type a path names, as serving picks a create's: the most verbatim spelling, else the path as written. */
export const heldCollection = (
state: SimulatedState,
type: string,
path: string,
): string => {
const read = segmentsOf(path);
let held: string | undefined;
let verbatim = -1;
for (const collection of new Set(
state.ofType(type).map((entity) => entity.collection),
)) {
const matched = segmentMatch(segmentsOf(collection), read);
if (matched !== undefined && matched > verbatim) {
held = collection;
verbatim = matched;
}
}
return held ?? `/${read.join("/")}`;
};
export const checkItemRead = (
state: SimulatedState,
view: StateItemView,
record: StateRecordFacts,
check: ReadBodies,
): CheckOutcome | undefined => {
const { collection, id } = itemTarget(state, view.type, record);
const entity = state.entity(view.type, collection, id);
const targets = [entityTarget(view.type, collection, id)];
if (isAbsence(record.status) || (succeeded(record) && check.body === null))
return { pass: entity === undefined, targets, unheld: [] };
if (!succeeded(record) || check.base === undefined) return undefined;
const object =
check.body === undefined ? undefined : at(check.body, view.pointer);
// A usable body that no longer holds the entity is not what serving answers for one it holds.
if (!isObject(object))
return check.body === undefined || entity === undefined
? undefined
: { pass: false, targets, unheld: [] };
if (entity === undefined) return { pass: false, targets, unheld: targets };
// Serving refuses an entity lacking a field every recording carried, so no read can confirm one.
if (missingRequiredFields(entity, view, check.volatile).length > 0)
return { pass: false, targets, unheld: [] };
const shown = renderStateEntity(entity, view, check.volatile, object);
return {
pass: same(replaceAt(check.base, view.pointer, shown), check.body),
targets,
unheld: [],
};
};
/** A list read's bodies with the listed items of the read and of its served base. */
interface ListBodies {
readonly body: JsonValue;
readonly base: JsonValue;
readonly items: readonly JsonObject[];
readonly baseItems: readonly JsonObject[];
readonly idField: string;
readonly volatile: ReadonlySet<string>;
/** The collection serving lists: the answering line's. */
readonly collection: string;
}
/** The served list rendered from the base with `members`, compared with the read; volatile fields take the read's values. */
const listCheck = (
view: StateListView,
check: ListBodies,
members: readonly StateEntity[],
/** The matches a whole or query answer counts; undefined for recorded members, whose totals stay as recorded. */
total: number | undefined,
): Pick<CheckOutcome, "pass" | "differing"> => {
if (
members.some(
(entity) =>
missingRequiredFields(entity, view, check.volatile).length > 0,
)
)
return { pass: false };
const observed = new Map(
check.items.map((item) => [scalarText(item[check.idField]) ?? "", item]),
);
const rendered = members.map((entity) =>
renderStateEntity(entity, view, check.volatile, observed.get(entity.id)),
);
const render = (items: readonly JsonObject[]): JsonValue =>
view.query === undefined || total === undefined
? renderStateList(view, check.base, items)
: renderQueryList(
{ ...view, query: view.query },
check.base,
items,
total,
);
if (same(render(rendered), check.body)) return { pass: true };
// When the recorded members alone account for the difference, only they differ.
const differs = members.filter((entity, index) => {
const item = observed.get(entity.id);
return item !== undefined && !same(rendered[index], item);
});
const patched = rendered.map(
(item, index) => observed.get(members[index]!.id) ?? item,
);
return differs.length > 0 && same(render(patched), check.body)
? {
pass: false,
differing: differs.map((entity) =>
entityTarget(view.type, check.collection, entity.id),
),
}
: { pass: false };
};
/** The base line's and the read's listed entities by target: those the state holds, and those it no longer does. */
const listedTargets = (
state: SimulatedState,
view: StateListView,
check: ListBodies,
): { readonly targets: string[]; readonly unheld: string[] } => {
const targets: string[] = [];
const unheld: string[] = [];
const ids = [...check.baseItems, ...check.items].map(
(item) => scalarText(item[check.idField]) ?? "",
);
for (const id of new Set(ids))
(state.entity(view.type, check.collection, id) === undefined
? unheld
: targets
).push(entityTarget(view.type, check.collection, id));
return { targets, unheld };
};
/**
* A list read on a line the query answers: the query's matches in the state, in its order and page. Only a line whose
* parameters all sort or page, and that shows every match, covers the collection; any other covers the entities it lists.
*/
const checkQueryList = (
state: SimulatedState,
view: StateListView,
check: ListBodies,
values: ReadonlyMap<string, string>,
): CheckOutcome | undefined => {
const { collection } = check;
const result = answerQuery(
view.query!,
values,
state.members(view.type, collection),
);
if (result === undefined) return undefined;
const outcome = listCheck(view, check, result.page, result.total);
return {
...outcome,
// A lone match has no neighbour whose order it shows, so it shows nothing of where a create put it.
...(result.positional === "all" && result.total > 1
? {}
: {
positional: new Set(
(result.positional === "all" ? [] : result.positional).map(
(entity) => entityTarget(view.type, collection, entity.id),
),
),
}),
targets: [
...new Set([
...(result.whole ? [collectionTarget(view.type, collection)] : []),
...result.page.map((entity) =>
entityTarget(view.type, collection, entity.id),
),
// A failed read also covers the held entities it lists, as a write may have moved them out of the answer.
...(outcome.pass
? []
: listedTargets(state, view, { ...check, baseItems: [] }).targets),
]),
],
// What the base line or the read listed that the state no longer holds: the read shows whether it went.
unheld: listedTargets(state, view, check).unheld,
};
};
/** A list read of every entity of a collection: membership, order, fields and counters must render from the state. */
const checkAllList = (
state: SimulatedState,
view: StateListView,
check: ListBodies,
): CheckOutcome => {
const { collection } = check;
const members = state.members(view.type, collection);
const held = new Set(members.map((entity) => entity.id));
return {
...listCheck(view, check, members, members.length),
targets: [
collectionTarget(view.type, collection),
...members.map((entity) =>
entityTarget(view.type, collection, entity.id),
),
],
unheld: check.items
.map((item) => scalarText(item[check.idField]) ?? "")
.filter((id) => !held.has(id))
.map((id) => entityTarget(view.type, collection, id)),
};
};
/** A list read of recorded members: the served list holds the base's members the state still holds, so a member it dropped or never listed fails. */
const checkRecordedList = (
state: SimulatedState,
view: StateListView,
check: ListBodies,
): CheckOutcome => {
const { collection } = check;
const idOf = (item: JsonObject) => scalarText(item[check.idField]) ?? "";
const members = check.baseItems.flatMap((item) => {
const entity = state.entity(view.type, collection, idOf(item));
return entity === undefined ? [] : [entity];
});
return {
...listCheck(view, check, members, undefined),
...listedTargets(state, view, check),
};
};
/** The read's query values when serving evaluates the query for it: on a named query line, or with query values on a line it did not name. */
const evaluatedQuery = (
view: StateListView,
record: StateRecordFacts,
line: ServedLine,
): ReadonlyMap<string, string> | undefined =>
(
line.named
? view.query?.record_ordinals.includes(line.ordinal) === true
: line.queried === true
)
? singleQueryValues(record.query)
: undefined;
/** A list read on a checked line, compared with the answer the served environment renders on that line's base recording from the collection it names: every entity when that line lists them all. */
export const checkListRead = (
state: SimulatedState,
view: StateListView,
record: StateRecordFacts,
check: ReadBodies & {
readonly idField: string;
readonly collection: string;
/** The checked line serving answers the read on. */
readonly line: ServedLine;
},
): CheckOutcome | undefined => {
if (!succeeded(record)) return undefined;
const { line } = check;
const query = evaluatedQuery(view, record, line);
const all = query === undefined && listsAll(view, line.ordinal);
const { body, base } = check;
const baseItems =
base === undefined ? undefined : listItems(base, view.pointer);
if (body === undefined || base === undefined || baseItems === undefined)
return undefined;
// Serving answers a page too small for the collection from its recording, so such a read checks nothing.
const size = pageBound(record.query, baseItems.length);
if (
all &&
size !== undefined &&
state.members(view.type, check.collection).length > size
)
return undefined;
// A usable body that no longer holds the list is not what serving answers on this line.
const items = listItems(body, view.pointer);
// A line the read did not name keeps its own call's envelope, such as a link naming that call's values, which no write changes.
const envelope = line.named || items === undefined ? base : body;
// A query answered on a line the read did not name lists none of that line's members, so only the read's show what the state no longer holds.
const bodies = {
...check,
body,
base: envelope,
items: items ?? [],
baseItems: line.named || query === undefined ? baseItems : [],
};
const outcome =
query !== undefined
? checkQueryList(state, view, bodies, query)
: all
? checkAllList(state, view, bodies)
: checkRecordedList(state, view, bodies);
if (outcome === undefined) return undefined;
return items === undefined ? { ...outcome, pass: false } : outcome;
};
/** A document read on its checked line must equal the held document. */
export const checkDocumentRead = (
state: SimulatedState,
tool: string,
record: StateRecordFacts,
body: JsonValue | undefined,
): CheckOutcome | undefined => {
if (!succeeded(record)) return undefined;
const value = state.document(tool);
if (body === undefined || value === undefined) return undefined;
return {
pass: same(value, body),
targets: [documentTarget(tool)],
unheld: [],
};
};