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
7 changes: 2 additions & 5 deletions packages/inspector/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ The package ships the capability, not agent tools: a harness defines its own too

## Output

The default JSON is capped at 12,000 UTF-8 bytes, including metadata. `snippets` contain file, start/end line, kinds, associated input target indices, **unmodified full source lines**, and whether that exact block occurs only once in the file. No pretty-printing or ellipses are inserted into code. Adjacent/overlapping blocks merge across targets.
The default JSON is capped at 12,000 UTF-8 bytes, including metadata. Each snippet's `id` names its file and its start and end lines (`file:start-end`); a snippet also carries its kinds, **unmodified full source lines**, and whether that exact block occurs only once in the file. When a query names more than one target, `targets` gives the input target indices it serves. No pretty-printing or ellipses are inserted into code. Adjacent/overlapping blocks merge across targets.

```json
{
Expand All @@ -51,9 +51,6 @@ The default JSON is capped at 12,000 UTF-8 bytes, including metadata. `snippets`
"snippets": [
{
"id": "scene.html:20-24",
"file": "scene.html",
"startLine": 20,
"endLine": 24,
"kind": ["animation creation"],
"targets": [0, 1],
"code": "the exact original source lines, including whitespace",
Expand All @@ -65,7 +62,7 @@ The default JSON is capped at 12,000 UTF-8 bytes, including metadata. `snippets`

This illustration omits metadata. Actual files in the implementation report show the full output.

Shared helpers appear once with all **queried** targets using them. `coTargets` names observed additional DOM targets of the same tween; it does not claim to enumerate all separate calls to a helper. Creation statements and authored caller statements are mapped; small named helper functions are returned whole. Variable assignments are not a stack/data-flow history. A missing source origin increments `unmappedAnimations`, rather than guessing an editable location.
Shared helpers appear once with all **queried** targets using them. `coTargets` names up to three observed additional DOM targets of the same tween, prioritizing elements outside the query, and `coTargetsOmitted` counts the rest; it does not claim to enumerate all separate calls to a helper. Creation statements and authored caller statements are mapped; small named helper functions are returned whole. Variable assignments are not a stack/data-flow history. A missing source origin increments `unmappedAnimations`, rather than guessing an editable location.

HTML snippets are uniquely identified authored opening tags (their full source lines); dynamically generated markup may have no authored tag match. CSS is an authored-selector candidate scan, not proof of active cascade, media-query state, or inherited styles. In particular, a class reused in different scoped scene styles can have multiple candidate source rules.

Expand Down
46 changes: 28 additions & 18 deletions packages/inspector/src/mapInspection.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,5 @@
import { Sources, markupSnippet, mergeSnippets } from "./sources";
import type { InspectRequest, RuntimeResponse, Snippet } from "./types";
const coverage = [
"GSAP associations observed since page initialization; callback-created animations may require an explicit time query.",
"Animation source dependencies include lexically resolved declarations in the same script; later mutations, imports, and cross-script bindings are not traced.",
"CSS selector matches are candidates, not a full cascade or dependency graph. HTML is the authored opening tag when uniquely located.",
"Direct constructors, other animation systems, iframe contents, removed animations and unexecuted paths may be absent; missing origins are reported.",
];
const KEPT_FIRST_UNDER_BUDGET = [
"animation creation",
"animation caller",
Expand All @@ -21,6 +15,14 @@ const rank = (kind: string) => {
const priority = (snippet: Snippet) => Math.min(...snippet.kind.map(rank));
const byPosition = (a: Snippet, b: Snippet) =>
a.file.localeCompare(b.file) || a.startLine - b.startLine;
const CO_TARGETS_SHOWN = 3;

function shown(snippet: Snippet, targetCount: number) {
const { id, kind, code, uniqueInFile, targets } = snippet;
return targetCount === 1
? { id, kind, code, uniqueInFile }
: { id, kind, targets, code, uniqueInFile };
}

export function mapInspection(
raw: RuntimeResponse,
Expand All @@ -39,6 +41,7 @@ export function mapInspection(
const { sources, map, catalog, generation, sourceVersion, reused, start, visualState } = context;
const maxBytes = request.maxBytes ?? 12000;
const collected: Snippet[] = [];
const queriedRefs = new Set(raw.results.flatMap((result) => result.elements.map((el) => el.ref)));
const results = raw.results.map((result, index) => {
let unmappedAnimations = 0;
for (const animation of result.animations) {
Expand Down Expand Up @@ -67,20 +70,24 @@ export function mapInspection(
if (snippet) collected.push(snippet);
}
}
const coTargets = [
...new Map(
result.animations
.flatMap((animation) => animation.targets)
.filter((element) => !result.elements.some((own) => own.ref === element.ref))
.map((element) => [element.ref, element]),
).values(),
].sort((a, b) => Number(queriedRefs.has(a.ref)) - Number(queriedRefs.has(b.ref)));
return {
target: index,
status: result.status,
candidates: result.candidates,
candidatesOmitted: 0,
animations: result.animations.length,
coTargets: [
...new Map(
result.animations
.flatMap((animation) => animation.targets)
.filter((element) => !result.elements.some((own) => own.ref === element.ref))
.map((element) => [element.ref, element]),
).values(),
].slice(0, 10),
coTargets: coTargets.slice(0, CO_TARGETS_SHOWN),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A fixed first-three cap can hide the only unqueried side effect of a shared tween. Query four elements A–D when one animation targets A, B, C, D, then X in that order. Each result removes only its own element before slice(0,3), so all four reports display other queried elements and none names X; each merely reports one omitted co-target, with no pagination path for that identity. Previously the ten-entry list included X, and this loses useful side-effect context even with ample maxBytes. Please prioritize distinct unqueried co-targets across the queried results (or expose a way to retrieve the omitted identities), and add the four-query/fifth-target regression.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in acd38cc: rank co-targets outside all queried result elements first, then apply the existing three-item cap. Added the four-query/fifth-target regression; it fails before the fix and all 10 mapping tests pass afterward. Normal lint, format, typecheck, fallow and commit hooks pass.

...(coTargets.length > CO_TARGETS_SHOWN && {
coTargetsOmitted: coTargets.length - CO_TARGETS_SHOWN,
}),
unmappedAnimations,
scopeTruncated: result.truncated,
};
Expand All @@ -100,11 +107,10 @@ export function mapInspection(
sourceVersion: sourceVersion,
reused,
results,
snippets: [] as Snippet[],
snippets: [] as ReturnType<typeof shown>[],
omitted: [] as { id: string; bytes: number }[],
omittedCount: 0,
nextOffset: null as number | null,
coverage,
recordLimitReached: raw.recordLimitReached,
hooked: raw.hooked,
debugAvailable: true,
Expand All @@ -125,8 +131,10 @@ export function mapInspection(
}
};
trimCandidates(maxBytes / 2, 5);
const targetCount = request.targets.length;
const kept: Snippet[] = [];
for (let i = offset; i < selected.length; i++) {
response.snippets.push(selected[i]);
response.snippets.push(shown(selected[i], targetCount));
if (size() > maxBytes - 700) {
response.snippets.pop();
response.omittedCount = selected.length - i;
Expand All @@ -137,8 +145,10 @@ export function mapInspection(
response.nextOffset = nothingFit ? (i + 1 < selected.length ? i + 1 : null) : i;
break;
}
kept.push(selected[i]);
}
if (!request.snippets) response.snippets.sort(byPosition);
if (!request.snippets)
response.snippets = kept.sort(byPosition).map((snippet) => shown(snippet, targetCount));
trimCandidates(maxBytes, 0);
if (Buffer.byteLength(JSON.stringify(response)) > maxBytes)
throw new Error("metadata_exceeds_budget: use fewer targets or increase maxBytes");
Expand Down
21 changes: 8 additions & 13 deletions packages/inspector/tests/browser.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { mkdtempSync, mkdirSync, copyFileSync, writeFileSync, readFileSync, rmSync } from "node:fs";
import { snippetSource } from "./snippetSource";
import { mkdtempSync, mkdirSync, copyFileSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import assert from "node:assert/strict";
Expand Down Expand Up @@ -39,18 +40,10 @@ try {
assert(
cold.snippets.some((s) => s.code.includes("function move") || s.code.includes("tl.to(element")),
);
assert(cold.snippets.some((s) => s.targets.length === 2 && s.code.includes("tl.to(element")));
assert(cold.snippets.some((s) => s.targets?.length === 2 && s.code.includes("tl.to(element")));
checks.push("multi-target source deduplication");
for (const snippet of cold.snippets) {
const text = readFileSync(join(project, snippet.file), "utf8");
assert.equal(
snippet.code,
text
.split(/(?<=\n)/)
.slice(snippet.startLine - 1, snippet.endLine)
.join(""),
);
}
for (const snippet of cold.snippets)
assert.equal(snippet.code, snippetSource(project, snippet.id));
checks.push("literal snippets equal original bytes");
const single = await inspector.inspect({ targets: [{ selector: "#title" }] });
const ambiguity = await inspector.inspect({
Expand All @@ -65,7 +58,9 @@ try {
["found", "missing", "found"],
);
assert.equal(ambiguity.results[0].candidates.length, 2);
assert(ambiguity.snippets.some((s) => s.targets.includes(0) && s.code.includes("tl.to(element")));
assert(
ambiguity.snippets.some((s) => s.targets?.includes(0) && s.code.includes("tl.to(element")),
);
const overlapping = await inspector.inspect({ targets: [{ selector: "#scene-a, #title" }] });
assert.equal(overlapping.results[0].animations, single.results[0].animations);
checks.push("class groups, overlapping scopes, partial success and optional within");
Expand Down
13 changes: 3 additions & 10 deletions packages/inspector/tests/film.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { snippetSource } from "./snippetSource";
import { Inspector } from "../src/service";
import { cpSync, mkdtempSync, readFileSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
Expand Down Expand Up @@ -57,16 +58,8 @@ try {
notes.afterState = await state();
assert.equal(inspector.stats.browserStarts, 1);
assert.equal(inspector.stats.pageLoads, 2);
for (const snippet of after.snippets) {
const text = readFileSync(join(project, snippet.file), "utf8");
assert.equal(
snippet.code,
text
.split(/(?<=\n)/)
.slice(snippet.startLine - 1, snippet.endLine)
.join(""),
);
}
for (const snippet of after.snippets)
assert.equal(snippet.code, snippetSource(project, snippet.id));
const afterState = notes.afterState as typeof before;
assert.deepEqual(afterState.timelines, before.timelines);
const parity = [];
Expand Down
12 changes: 3 additions & 9 deletions packages/inspector/tests/live.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { snippetSource } from "./snippetSource";
import assert from "node:assert/strict";
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
Expand Down Expand Up @@ -89,17 +90,10 @@ try {
assert.equal(result.results[0]!.unmappedAnimations, 0);
assert(
result.snippets.some(
(s) => s.code.includes("function move") && s.targets.includes(0) && s.targets.includes(1),
(s) => s.code.includes("function move") && s.targets?.includes(0) && s.targets.includes(1),
),
);
for (const s of result.snippets)
assert.equal(
s.code,
readFileSync(join(dir, s.file), "utf8")
.split(/(?<=\n)/)
.slice(s.startLine - 1, s.endLine)
.join(""),
);
for (const s of result.snippets) assert.equal(s.code, snippetSource(dir, s.id));
const timeline = await live.timeline(runner, { at: 2, component: { selector: "#title" } });
assert.equal(timeline.status, "found");
assert(timeline.text.includes("index.html:"), "timeline maps its animations to source lines");
Expand Down
80 changes: 79 additions & 1 deletion packages/inspector/tests/mapInspection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ describe("components report priority", () => {

it("returns the snippets it keeps in file order", () => {
const { report } = withAnimation({ targets: [{ selector: ".w-dot" }], maxBytes: 256000 });
const lines = report.snippets.map((s) => s.startLine);
const lines = report.snippets.map((s) => Number(/:(\d+)-/.exec(s.id)![1]));
expect(lines).toEqual([...lines].sort((a, b) => a - b));
expect(report.snippets.at(-1)?.kind).toEqual(["animation creation"]);
expect(report.omittedCount).toBe(0);
Expand All @@ -206,3 +206,81 @@ describe("components report priority", () => {
expect(fetched.omittedCount).toBe(0);
});
});

describe("components report shape", () => {
const call = `tl.to(".w-dot", { x: 100, duration: 1 }, 2);`;

function report(targets: number, coTargets: number) {
const dir = mkdtempSync(join(tmpdir(), "inspect-shape-"));
const html = `<html><body>\n<script>\nconst tl = gsap.timeline();\n${call}\n</script>\n</body></html>`;
writeFileSync(join(dir, "index.html"), html);
const sources = new Sources(dir);
const start = html.indexOf(call);
const creation = sources.slice(
"index.html",
start,
start + call.length,
"animation creation",
0,
)!;
const moved = Array.from({ length: coTargets + 1 }, (_, n) => element(n));
const result = (n: number) => ({
target: { selector: `.w-dot.t${n}` },
status: "found" as const,
candidates: [element(n)],
animations: [{ stack: "s", attachmentStacks: [], targets: moved } as never],
elements: [element(n)],
css: [],
truncated: false,
});
try {
return mapInspection(
{
results: Array.from({ length: targets }, (_, n) => result(n)),
recorded: 1,
recordLimitReached: false,
hooked: true,
},
{ targets: Array.from({ length: targets }, (_, n) => ({ selector: `.w-dot.t${n}` })) },
{
sources,
map: () => [{ ...creation, targets: Array.from({ length: targets }, (_, n) => n) }],
catalog: new Map<string, Snippet>(),
generation: "g",
sourceVersion: "v",
reused: false,
start: performance.now(),
},
);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}

it("names a snippet's file and lines once, in its id, and carries no fixed disclaimers", () => {
const shown = report(1, 0);
expect(shown).not.toHaveProperty("coverage");
expect(shown.snippets).toEqual([
{ id: "index.html:4-4", kind: ["animation creation"], code: `${call}\n`, uniqueInFile: true },
]);
});

it("says which target each snippet serves when the query names more than one", () => {
expect(report(2, 0).snippets[0]).toMatchObject({ id: "index.html:4-4", targets: [0, 1] });
});

it("keeps an unqueried co-target visible when four queried elements share its tween", () => {
for (const result of report(4, 4).results) {
expect(result.coTargets[0]?.ref).toBe(element(4).ref);
expect(result.coTargets).toHaveLength(3);
expect(result.coTargetsOmitted).toBe(1);
}
});

it("lists three elements the same animation also moves and counts the rest", () => {
const [result] = report(1, 7).results;
expect(result!.coTargets).toHaveLength(3);
expect(result!.coTargetsOmitted).toBe(4);
expect(report(1, 2).results[0]).not.toHaveProperty("coTargetsOmitted");
});
});
11 changes: 11 additions & 0 deletions packages/inspector/tests/snippetSource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";

/** The file lines a snippet id, `<file>:<start>-<end>`, names, as they are on disk now. */
export function snippetSource(project: string, id: string): string {
const [, file, startLine, endLine] = /^(.+):(\d+)-(\d+)$/.exec(id)!;
return readFileSync(join(project, file!), "utf8")
.split(/(?<=\n)/)
.slice(Number(startLine) - 1, Number(endLine))
.join("");
}
Loading