Files
reverse-engineering-anything/src/domain/desktop/appTaskDraftNested.ts
T
61ab3f0c4f feat(desktop): name a record by a nested field or a child's attribute (#9185)
* fix(desktop): keep a selected folder that is named like a database log

A folder the caller selected stays a selected root when its name ends in
-journal or -wal. A generated log beside a database file still does not.

* fix(desktop): refuse a build command that exceeds the argument vector

Splitting one argument still leaves the whole command under Linux's E2BIG
limit. A request past that budget is refused before the process starts.

* fix(desktop): remap a captured path in an env assignment

The program word stays the program. A NAME=value assignment keeps its name
and follows a captured path value when that path's spelling moves.

* fix(desktop): keep oversized text out of draft guards

A value or line longer than the draft's text bound is not copied into a
guard, so one large frame cannot fill the task by itself.

* docs(desktop): correct dconf and check-preview guidance

A dconf version kept without its bytes is unreadable, not an empty key
list. A values preview is copied into a check only when it is an uncut scalar.

* fix(desktop): keep an empty env value empty

An empty assignment is not a path. Resolving it named the working directory
and rewrote the value when that directory's spelling moved.

* fix(desktop): keep a run's font on the run, not the frame's text

A formatting attribute such as Scribus FONT is not the document's words.
Joining it across runs made a frame-wide text check of ItalicBold that
failed when a run was split. The font stays a value check on its own run.

* fix(desktop): do not pair JSON records by a selector-shaped field

A field named @record_id matched the id suffix and became [@record_id=one],
which reads an XML attribute. Records paired while their pointers matched
nothing. Names that start with @ or > are not id fields.

* fix(desktop): count a save-numbered id once when pairing members

xml:id and draw:id values such as id123456 were both generated and
numbered, so the same selector was counted twice and refused. A text
edit of two elements keyed only by those ids now keeps its check.

* fix(desktop): count members of root JSON arrays

* fix(desktop): preserve selected state directory aliases

* docs(desktop): explain tool and skill refresh prerequisites

* fix(desktop): align nested state selections with aliases

* fix(desktop): check build runtime export capability

* fix(desktop): provide complete runtime repack command

* fix(desktop): retain runtime probe failures

* test(desktop): identify capture independently of schema preflight

* fix(desktop): preserve document URLs and value formats

* fix(desktop): bound added checks and omit noisy text guards

* fix(desktop): exclude unseeded locks from task checks

* fix(desktop): preserve draft bounds and launch URLs

* fix(desktop): render draft values using their source format

* fix(desktop): preserve launch URLs and JSON draft values

* fix(desktop): recognize captured file URL documents

* fix(desktop): render captured file URLs for runtime home

* fix(desktop): distinguish phone numbers from save metadata

* fix(desktop): preserve launch URLs and user text

* fix(desktop): preserve captured URLs and user text

* fix(desktop): preserve local file URL authority

* fix(desktop): preserve local file URL authority

* fix(desktop): preserve local file URL authority

* fix(desktop): render captured file URLs at launch

* fix(desktop): classify numeric timestamps using field context

* fix(desktop): classify numeric timestamps using field context

* fix(desktop): classify numeric timestamps using field context

* fix(desktop): retain context for generated draft values

* fix(desktop): retain context for generated draft values

* fix(desktop): retain context for generated draft values

* test(desktop): cover generated IDs in text guards

* test(desktop): cover generated IDs in text guards

* test(desktop): cover generated IDs in text guards

* fix(desktop): isolate launcher URL encoding

* fix(desktop): isolate launcher URL encoding

* fix(desktop): isolate launcher URL encoding

* fix(desktop): isolate launcher URL encoding

* fix(desktop): retain clean text in noisy record fields

* fix(desktop): classify added line values in one pass

* fix(desktop): apply XML quote rules to text drafts

* fix(desktop): distinguish XML attributes from quoted text

* fix(desktop): preserve parsed XML line context

* Format appTaskChecks.ts with the pinned oxfmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(desktop): retain partial XML syntax facts

* fix(desktop): recognize markdown autolinks in drafts

* fix(desktop): reuse export exclusions in task drafts

* docs(desktop): clarify gzip scrub limits

* style(desktop): format the noisy-record draft files with the pinned oxfmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(desktop): give the root-array draft test its own describe

The records describe grew past the 150-line lint limit with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(desktop): move launch execution to boundary suite

* fix(desktop): redact build schema failure output

* refactor(desktop): normalize schema failure once

* fix(desktop): omit unsafe schema parser snippets

* fix(desktop): preserve draft change and selected path evidence

* test(desktop): group retained dconf note regressions

* fix(desktop): respect archive entry format in draft checks

* test(desktop): separate unowned-file draft note cases

* fix(desktop): preserve explicitly selected journal documents

* fix(xml): avoid scanning selected ancestors per tag

* fix(desktop): exclude covered text runs from draft counts

* fix: preserve text when drafting desktop checks

* fix: restore nested desktop state aliases

* refactor: share UUID recognition across draft checks

* fix: retain multiline XML attribute context

* fix: retain relationship identifiers in task drafts

* docs: clarify desktop draft identifier handling

* test(desktop): type formatting run cases as pairs

* fix(desktop): carry document format into selector evaluation

* test(desktop): separate markup boundary regression group

* fix(desktop): propagate formats through draft selectors

* fix(desktop): switch selector format within archive entries

* test(desktop): remove duplicate seed filename case

* test(desktop): pass JSON format in exported index parity

* docs(desktop): clarify task checks before scratch export

* fix(desktop): respell selected paths in file URLs

* test(desktop): group captured document spelling cases

* fix(desktop): bound draft preview serialization

* fix(desktop): restore selected state aliases

* fix(desktop): respect entry format in draft noise

* test(desktop): match exported alias evidence at its JSON boundary

* fix(desktop): retain app state logs in exported seeds

* fix: align desktop numeric seed comparisons

* test(desktop): keep one draft exclusion fixture import

* test(desktop): retain log eligibility across draft test groups

* style: fix desktop launch test formatting

* style: format native command path schema

* fix(desktop): map selected external launch paths

* style(desktop): format seed document regressions

* test(desktop): group launch URL regressions

* fix(desktop): omit redundant nested state aliases

* fix(desktop): compare added free text as a whole

* fix(desktop): check recompressed seeds as shipped

* docs(desktop): clarify recovered seed format

* test(desktop): group shipped gzip seed checks

* fix: retain quoted identifier text in task drafts

* fix: recognize Unicode XML in desktop state readers

* fix(desktop): bound nested draft checks

* fix(desktop): explain missing arrays in truncated views

* fix(desktop): find XML type references in archives

* test(desktop): remove duplicated capture assertions

* fix(desktop): retain nested omitted text boundaries

* style(desktop): format draft value imports

* test(desktop): group model and draft value checks

* fix(desktop): map captured launch paths once

* refactor: remove unused whole-text guard state

* refactor: stop passing unused text children

* test: align free-text checks with whole-note policy

* fix: validate recompressed desktop archive seeds

* style(desktop): format archive seed regression

* fix(desktop): retain placed-item sibling guards

* test(desktop): specify XML format in heap pairing fixture

* fix(desktop): redact runtime schema errors

* fix(desktop): normalize captured file URLs

* fix(desktop): report seeded files by capture path

* fix(desktop): retain startup readiness failure stage

* fix(desktop): retain JSON date checks

* fix(desktop): preserve archive formatting checks

* fix(desktop): preserve explicitly selected journal files

* fix(desktop): report build command transport limits

* docs(desktop): clarify unreadable dconf versions

* test(desktop): remove duplicate history regression

* fix(desktop): align XML names with exported reader

* fix(desktop): retain record identity in draft checks

* fix(desktop): scrub XML independently of reader compatibility

* style(desktop): format review fixes

* style(desktop): format capture review fixes

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 23:01:22 +08:00

243 lines
8.4 KiB
TypeScript

import type { JsonValue } from "../jsonValue.js";
import type { DesktopTaskCheckInput } from "./appTask.js";
import {
isBookkeepingName,
isStamp,
isVolatileValue,
} from "./appTaskDraftNoise.js";
import { valueEqualsCheck } from "./appTaskDraftScalars.js";
import {
isGeneratedId,
isIdentifyingAttribute,
isPlaceholder,
isViewStateName,
rankAttributes,
} from "./attributeRank.js";
import { escapeToken, isXmlElement, resolvePointer } from "./viewPointer.js";
import { spellCondition } from "./viewSelector.js";
import { checkFits, type AddedContext } from "./appTaskDraftAdded.js";
type Element = Record<string, JsonValue> & {
name: string;
attributes: Record<string, JsonValue>;
children: JsonValue[];
};
/** Checks of the records nested in one element added whole, and of one record's attributes. */
const MAX_NESTED_CHECKS = 6;
const MAX_PER_RECORD = 3;
/** Whether an element is a record of attributes alone, as KMyMoney's `<SPLIT value="-1749/20" account="A000001" …/>`: no element or text of its own. */
const attributesOnly = (item: JsonValue): item is Element =>
isXmlElement(item) &&
Object.keys(item.attributes).length > 0 &&
item.children.every(
(child) => typeof child === "string" && child.trim() === "",
);
/** The records of attributes alone among an element's children, when they are all one tag; empty otherwise. */
const recordsIn = (element: JsonValue | undefined): Element[] => {
const items = isXmlElement(element)
? element.children.filter(isXmlElement)
: [];
const records = items.filter(attributesOnly);
return records.length === items.length &&
records.every((record) => record.name === records[0]?.name)
? records
: [];
};
/** A list of records of attributes alone in an element: the steps from it to the element holding them, none for its own or a child's tag for a child's, as a transaction's `<SPLITS>`, and the records. */
interface RecordList {
readonly steps: readonly string[];
readonly records: readonly Element[];
}
/**
* The lists of records of attributes alone an element holds: its own
* children, or those of a child holding nothing else that its tag names
* alone, as a KMyMoney `<TRANSACTION>` its `<SPLITS>` of `<SPLIT>`.
*/
export const recordListsOf = (element: JsonValue): RecordList[] => {
const own = recordsIn(element);
if (own.length > 0) return [{ steps: [], records: own }];
const children = isXmlElement(element)
? element.children.filter(isXmlElement)
: [];
const tags = new Map<string, number>();
for (const { name } of children) tags.set(name, (tags.get(name) ?? 0) + 1);
return children.flatMap((child) => {
const records = recordsIn(child);
return records.length > 0 &&
tags.get(child.name) === 1 &&
!/[=[\]]/u.test(child.name)
? [{ steps: ["children", escapeToken(`[name=${child.name}]`)], records }]
: [];
});
};
/** The records at a list's steps in each like sibling of an element, as the splits of the transactions already in the book. */
const cousinsOf = (
likes: readonly JsonValue[],
steps: readonly string[],
tag: string,
): Element[] =>
likes.flatMap((like) =>
recordsIn(
steps.length === 0
? like
: resolvePointer(like, `/${steps.join("/")}`, "xml"),
).filter((record) => record.name === tag),
);
/** Whether a value a selector can carry and that names something across saves. */
const selectable = (key: string, value: JsonValue | undefined) =>
typeof value === "string" &&
value !== "" &&
!/[=[\]]/u.test(key) &&
!value.includes("][") &&
!isStamp(value) &&
!isPlaceholder(value) &&
!isVolatileValue(value, isBookkeepingName(key));
/**
* The selector naming a nested record alone among its list's: an
* identifier first, unless it is one the app generates or numbers anew in
* each record holding such a list, as KMyMoney's `S0001` in every
* transaction; then the best ranked attribute short of a coordinate or an
* amount, as a split's `[@account=A000001]`.
*/
const recordKey = (
record: Element,
records: readonly Element[],
cousins: readonly Element[],
chars: number,
) => {
const values = Object.entries(record.attributes).flatMap(([key, value]) =>
selectable(key, value) && typeof value === "string" && value.length <= chars
? [{ key, value }]
: [],
);
const repeated = (key: string, value: string) =>
cousins.some((cousin) => cousin.attributes[key] === value);
const [best] = [
...values.filter(
({ key, value }) =>
isIdentifyingAttribute(key) &&
!isGeneratedId(value) &&
!repeated(key, value),
),
...rankAttributes(
values.filter(({ key }) => !isIdentifyingAttribute(key)),
).filter(({ tier }) => tier < 3),
].filter(
({ key, value }) =>
records.filter((other) => other.attributes[key] === value).length === 1,
);
return best === undefined
? undefined
: {
key: best.key,
segment: escapeToken(
`[name=${record.name}]${spellCondition({ prefix: "@", ...best })}`,
),
};
};
/**
* The keys of a record checked one by one: those whose values vary among
* the like records, its list's and those under the element's like siblings,
* as a split's `value` and `payee`, but not its `memo` or `price` that every
* split holds alike, nor one each like record holds equal to an earlier one,
* as `shares` repeats `value`; never its selector's, an identifier, a time,
* a volatile value, view state or an id the app renumbers.
*/
const checkedKeys = (
record: Element,
likes: readonly Element[],
skip: string,
) => {
const keys: string[] = [];
for (const [key, value] of Object.entries(record.attributes)) {
const all = likes.map((like) => like.attributes[key]);
if (
key === skip ||
typeof value !== "string" ||
isIdentifyingAttribute(key) ||
isPlaceholder(value) ||
isStamp(value) ||
isVolatileValue(value, isBookkeepingName(key)) ||
isViewStateName(key) ||
new Set(all).size < 2 ||
keys.some((earlier) =>
likes.every(
(like) => like.attributes[earlier] === like.attributes[key],
),
)
)
continue;
keys.push(key);
}
return keys;
};
/** What checks of nested records read: the file, the end, and whether a pointer names its value without a step by position. */
export interface NestedContext extends Pick<AddedContext, "chars" | "long"> {
readonly file: string;
readonly final: JsonValue | null;
readonly named: (pointer: string) => boolean;
}
/**
* A `value_equals` per attribute of each record of attributes alone nested
* in an element added whole to a list, as each split of a KMyMoney
* transaction holds its amount, account and payee, which the element's own
* attributes and text leave out: the record named by `recordKey`, its
* attributes by `checkedKeys`, at most six in all. Each fails at the seed,
* which lacks the element. Empty for an element with no like sibling, as no
* record then tells what varies.
*/
export const nestedRecordChecks = (
context: NestedContext,
pointer: string,
element: JsonValue,
): DesktopTaskCheckInput[] => {
if (!isXmlElement(element)) return [];
const list = resolvePointer(
context.final,
pointer.replace(/\/[^/]+$/u, ""),
"xml",
);
const likes = (Array.isArray(list) ? list : []).filter(
(item) =>
item !== element && isXmlElement(item) && item.name === element.name,
);
if (likes.length === 0) return [];
return recordListsOf(element)
.flatMap(({ steps, records }) => {
const cousins = cousinsOf(likes, steps, records[0]?.name ?? "");
return records.flatMap((record) => {
const key = recordKey(record, records, cousins, context.chars);
if (key === undefined) return [];
const at = [pointer, ...steps, "children", key.segment].join("/");
return checkedKeys(record, [...records, ...cousins], key.key)
.filter((name) => {
const value = record.attributes[name];
return (
typeof value === "string" &&
checkFits(context, `${at}/attributes/${escapeToken(name)}`, value)
);
})
.slice(0, MAX_PER_RECORD)
.flatMap((name) => {
const field = `${at}/attributes/${escapeToken(name)}`;
const value = record.attributes[name];
return context.named(field) && typeof value === "string"
? [valueEqualsCheck(context.file, field, value)]
: [];
});
});
})
.slice(0, MAX_NESTED_CHECKS);
};