Skip to content

Commit e24a475

Browse files
authored
Merge pull request #2369 from getappmap/feat/sequence-diagram-labels
feat(sequence-diagram): carry AppMap labels on diagram actions
2 parents 78d4f20 + d37c53e commit e24a475

13 files changed

Lines changed: 537 additions & 137 deletions

File tree

packages/cli/src/cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import InstallCommand from './cmds/agentInstaller/install-agent';
2121
import StatusCommand from './cmds/agentInstaller/status';
2222
import { default as OpenAPICommand } from './cmds/openapi/openapi';
2323
import PruneCommand from './cmds/prune/prune';
24+
import TrimCommand from './cmds/trim/trim';
2425
import RecordCommand from './cmds/record/record';
2526
import { handleWorkingDirectory } from './lib/handleWorkingDirectory';
2627
import { locateAppMapDir } from './lib/locateAppMapDir';
@@ -146,6 +147,7 @@ yargs(process.argv.slice(2))
146147
.command(SequenceDiagramCommand)
147148
.command(SequenceDiagramDiffCommand)
148149
.command(PruneCommand)
150+
.command(TrimCommand)
149151
.command(ArchiveCommand)
150152
.command(RestoreCommand)
151153
.command(CompareCommand)

packages/cli/src/cmds/prune/pruneAppMap.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ export const pruneAppMap = (appMap: AppMap, size: number): PruneResult => {
2222
return { appmap };
2323
};
2424

25+
// Deserializes an untrusted serialized filter (the CLI accepts it as a
26+
// base64url-encoded string) into an AppMapFilter and applies it to the map.
27+
// Labeled so appmap-review interprets changes to this deserialization boundary.
28+
// @label security.deserialization
2529
export const pruneWithFilter = (appMap: AppMap, serializedFilter: string): PruneResult => {
2630
// TODO: update type for AppMap
2731
const fullMap = buildAppMap().source(appMap).normalize().build() as any;

packages/cli/src/cmds/query/lib/recordingId.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import type { AppmapInfo } from '../queries/tree';
3434
// shapes silently collide in real corpora (multiple recordings share
3535
// the same basename or name), and the resulting wrong-recording
3636
// resolution is harder to debug than an explicit error.
37+
// @label security.path-resolution
3738
export function resolveAppmapPath(db: sqlite3.Database, ref: unknown): AppmapInfo {
3839
const s = String(ref);
3940
if (looksLikeDisplayLabel(s)) {

packages/cli/src/cmds/query/lib/treeRender.ts

Lines changed: 10 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,16 @@ import {
1010
} from '../queries/tree';
1111
import { formatCount, formatMs, formatTable } from './format';
1212
import { projectLogMessage } from './logMessage';
13+
import {
14+
MCP_RETURN_VALUE_BUDGET,
15+
truncate,
16+
truncateStructValue,
17+
} from '../../../lib/truncateStructValue';
18+
import type { StructBudget } from '../../../lib/truncateStructValue';
19+
20+
// Re-exported so existing importers (e.g. treeRender.spec.ts) keep working.
21+
export { MCP_RETURN_VALUE_BUDGET, truncateStructValue };
22+
export type { StructBudget };
1323

1424
const INDENT = ' ';
1525

@@ -76,10 +86,6 @@ function bracket(ms: number | null): string {
7686
return ms == null ? '' : `[${formatMs(ms)}]`;
7787
}
7888

79-
function truncate(s: string, n: number): string {
80-
return s.length <= n ? s : s.slice(0, n - 1) + '…';
81-
}
82-
8389
// Dense one-line-per-event rendering for MCP responses. Each line starts
8490
// with the event_id (so the agent can drill via find_calls/find_queries)
8591
// and includes file:line for source-reading without a follow-up call.
@@ -110,127 +116,6 @@ function renderTreeLineForMcp(node: TreeNode): string {
110116
}
111117
}
112118

113-
// Return values in the dense MCP tree are truncated value-aware, not by a
114-
// flat prefix cut. A flat cut wastes the budget on identity fields — a
115-
// record like `ApprovalRequest[requestId=<uuid>, loanId=<uuid>,
116-
// status=APPROVED, …]` spends ~80 chars on two UUIDs before reaching the
117-
// state field that actually distinguishes the call. truncateStructValue
118-
// parses the struct shape and budgets *per field value*, clipping
119-
// id/hash-shaped values hard so semantic fields downstream survive.
120-
export interface StructBudget {
121-
perValueCap: number; // max chars for an ordinary field value
122-
idCap: number; // max chars for a uuid/hash-shaped value (identity, low signal)
123-
maxFields: number; // fields rendered before the tail is elided
124-
flatCap: number; // flat cap applied to non-struct strings
125-
}
126-
127-
export const MCP_RETURN_VALUE_BUDGET: StructBudget = {
128-
perValueCap: 48,
129-
idCap: 12,
130-
maxFields: 16,
131-
flatCap: 120,
132-
};
133-
134-
// A uuid, a hex object hash (`@1a2b3c4`, `0x00007f…`), or either prefixed
135-
// by a short tag (`LOAN-<uuid>`, `PID-<uuid>`). These are identity, not
136-
// state — low root-cause signal — so they get the tight idCap.
137-
const ID_VALUE_RE =
138-
/(?:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|(?:@|0x)[0-9a-f]{4,})/i;
139-
140-
const CLOSE_FOR: Record<string, string> = { '[': ']', '{': '}', '(': ')' };
141-
142-
interface Struct {
143-
prefix: string; // leading type name (may be empty for a bare collection)
144-
open: string; // opening delimiter
145-
body: string; // comma-separated field list
146-
close: string; // closing delimiter
147-
}
148-
149-
// Recognize the toString()/repr shapes AppMap captures across the four
150-
// supported languages:
151-
// Java record / Python repr Type[…] / Type(…)
152-
// Java bean / JS-ish object Type{…} / {…}
153-
// Ruby #<Type …>
154-
// Returns null for primitives, `[object Object]`-style opaque values, and
155-
// `Type@1a2b3c` hashes — those fall back to a flat truncation.
156-
function parseStruct(s: string): Struct | null {
157-
const ruby = /^#<([^\s>]+)\s+(.+)>$/.exec(s);
158-
if (ruby) return { prefix: `#<${ruby[1]} `, open: '', body: ruby[2], close: '>' };
159-
const m = /^([A-Za-z_][\w.$]*)?([[{(])([\s\S]*)([\]})])$/.exec(s);
160-
if (m && CLOSE_FOR[m[2]] === m[4]) {
161-
return { prefix: m[1] ?? '', open: m[2], body: m[3], close: m[4] };
162-
}
163-
return null;
164-
}
165-
166-
// Split a struct body on top-level ", ". Commas nested inside [], {}, (),
167-
// or <> belong to a field's own value and must not split it.
168-
function splitTopLevelFields(body: string): string[] {
169-
if (body.length === 0) return [];
170-
const out: string[] = [];
171-
let depth = 0;
172-
let start = 0;
173-
for (let i = 0; i < body.length; i++) {
174-
const c = body[i];
175-
if (c === '[' || c === '{' || c === '(' || c === '<') depth++;
176-
else if (c === ']' || c === '}' || c === ')' || c === '>') depth = Math.max(0, depth - 1);
177-
else if (c === ',' && depth === 0 && body[i + 1] === ' ') {
178-
out.push(body.slice(start, i));
179-
start = i + 2;
180-
i++;
181-
}
182-
}
183-
out.push(body.slice(start));
184-
return out;
185-
}
186-
187-
// A field value that is itself a struct (a nested record, or an element
188-
// of a collection-of-records like `getParticipants`) recurses so its own
189-
// fields get the per-field budget too — flat-clipping it would collapse
190-
// `[ParticipantState[…status=PENDING], …]` down to an opaque stub. Depth
191-
// is bounded so a pathologically deep value can't recurse without end.
192-
const MAX_STRUCT_DEPTH = 4;
193-
194-
// Truncate one field value: recurse if it is itself a struct, else
195-
// id/hash-shaped values get the tight idCap and all others the perValueCap.
196-
function truncateFieldValue(value: string, budget: StructBudget, depth: number): string {
197-
if (depth < MAX_STRUCT_DEPTH) {
198-
const nested = parseStruct(value);
199-
if (nested) return renderStruct(nested, budget, depth + 1);
200-
}
201-
const cap = ID_VALUE_RE.test(value) ? budget.idCap : budget.perValueCap;
202-
return truncate(value, cap);
203-
}
204-
205-
// Truncate one `name=value` / `name: value` field, keeping the name whole
206-
// and budgeting only the value. A field with no recognizable name
207-
// separator (a bare collection element) is budgeted as a whole value.
208-
function truncateField(field: string, budget: StructBudget, depth: number): string {
209-
const m = /^(\s*[\w$]+\s*)([:=])(\s*)([\s\S]*)$/.exec(field);
210-
if (!m) return truncateFieldValue(field, budget, depth);
211-
return `${m[1]}${m[2]}${m[3]}${truncateFieldValue(m[4], budget, depth)}`;
212-
}
213-
214-
function renderStruct(st: Struct, budget: StructBudget, depth: number): string {
215-
const fields = splitTopLevelFields(st.body);
216-
const kept = fields.slice(0, budget.maxFields).map((f) => truncateField(f, budget, depth));
217-
const elided = fields.length - kept.length;
218-
const tail = elided > 0 ? `${kept.length > 0 ? ', ' : ''}…+${elided} more` : '';
219-
return `${st.prefix}${st.open}${kept.join(', ')}${tail}${st.close}`;
220-
}
221-
222-
// Value-aware truncation for a captured return value / parameter. Parses
223-
// the struct shape and budgets per field value, recursing into nested
224-
// structs; non-struct strings fall back to a flat cap. See StructBudget.
225-
export function truncateStructValue(
226-
s: string,
227-
budget: StructBudget = MCP_RETURN_VALUE_BUDGET
228-
): string {
229-
const st = parseStruct(s);
230-
if (!st) return truncate(s, budget.flatCap);
231-
return renderStruct(st, budget, 1);
232-
}
233-
234119
function renderFunctionForMcp(n: FunctionNode): string {
235120
const id = n.fqid ?? `${n.defined_class}${n.is_static ? '.' : '#'}${n.method_id}`;
236121
const where =

packages/cli/src/cmds/trim/trim.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { mkdirSync, readFileSync } from 'fs';
2+
import { basename, dirname, join } from 'path';
3+
import Yargs from 'yargs';
4+
5+
import { handleWorkingDirectory } from '../../lib/handleWorkingDirectory';
6+
import { MCP_RETURN_VALUE_BUDGET, truncateStructValue } from '../../lib/truncateStructValue';
7+
import type { StructBudget } from '../../lib/truncateStructValue';
8+
import { writeFileAtomic } from '../../utils';
9+
10+
// The captured `value` of a parameter, return value, receiver, or log message.
11+
interface ValueSlot {
12+
value?: unknown;
13+
}
14+
15+
interface AppMapEvent {
16+
parameters?: ValueSlot[];
17+
message?: ValueSlot[];
18+
receiver?: ValueSlot;
19+
return_value?: ValueSlot;
20+
exceptions?: ValueSlot[];
21+
}
22+
23+
interface AppMap {
24+
events?: AppMapEvent[];
25+
}
26+
27+
function trimSlot(slot: ValueSlot | undefined, budget: StructBudget): void {
28+
if (slot && typeof slot.value === 'string') {
29+
slot.value = truncateStructValue(slot.value, budget);
30+
}
31+
}
32+
33+
// Truncate every captured value string in an AppMap, in place. Trimming only
34+
// touches `value` strings — the call structure, code objects, SQL, and every
35+
// other stable property are untouched — so a trimmed AppMap is behaviorally
36+
// identical (its sequence-diagram digest is unchanged) but much smaller.
37+
export function trimAppMap(appmap: AppMap, budget: StructBudget = MCP_RETURN_VALUE_BUDGET): AppMap {
38+
for (const event of appmap.events ?? []) {
39+
for (const p of event.parameters ?? []) trimSlot(p, budget);
40+
for (const m of event.message ?? []) trimSlot(m, budget);
41+
trimSlot(event.receiver, budget);
42+
trimSlot(event.return_value, budget);
43+
for (const x of event.exceptions ?? []) trimSlot(x, budget);
44+
}
45+
return appmap;
46+
}
47+
48+
export default {
49+
command: 'trim <files..>',
50+
51+
describe:
52+
'Shrink AppMaps by truncating captured parameter, return, receiver, and message values',
53+
54+
builder: (args: Yargs.Argv) => {
55+
args.positional('files', {
56+
describe: 'AppMap file(s) to trim',
57+
type: 'string',
58+
});
59+
60+
args.option('max-length', {
61+
describe:
62+
'Maximum length of any captured value string — caps flat strings and struct field/id values alike',
63+
type: 'number',
64+
default: MCP_RETURN_VALUE_BUDGET.flatCap,
65+
});
66+
67+
args.option('output-dir', {
68+
describe: 'Write trimmed AppMaps here (default: overwrite each file in place)',
69+
type: 'string',
70+
alias: 'o',
71+
});
72+
73+
args.option('directory', {
74+
describe: 'Working directory for the command',
75+
type: 'string',
76+
alias: 'd',
77+
});
78+
79+
return args;
80+
},
81+
82+
handler: async (argv: any): Promise<void> => {
83+
handleWorkingDirectory(argv.directory);
84+
85+
// --max-length is a hard cap on every captured value string: it sets the
86+
// flat cap and, so the flag means what it says, lowers the per-field and id
87+
// caps to match (never above their defaults). maxFields 12 (vs the MCP
88+
// renderer's 16) — trim leans slightly more aggressive since a baseline
89+
// wants leanness over readability.
90+
const maxLength = argv.maxLength as number;
91+
const budget: StructBudget = {
92+
perValueCap: Math.min(MCP_RETURN_VALUE_BUDGET.perValueCap, maxLength),
93+
idCap: Math.min(MCP_RETURN_VALUE_BUDGET.idCap, maxLength),
94+
maxFields: 12,
95+
flatCap: maxLength,
96+
};
97+
const files = argv.files as string[];
98+
if (argv.outputDir) mkdirSync(argv.outputDir, { recursive: true });
99+
100+
let failed = 0;
101+
for (const file of files) {
102+
let before: string;
103+
let trimmed: string;
104+
try {
105+
before = readFileSync(file, 'utf8');
106+
trimmed = JSON.stringify(trimAppMap(JSON.parse(before) as AppMap, budget));
107+
} catch (error) {
108+
// Skip an unreadable/malformed file rather than aborting: one bad file
109+
// in a batch must not stop the rest or leave earlier files half-done.
110+
failed += 1;
111+
console.warn(`trim ${file}: skipped (${(error as Error).message})`);
112+
continue;
113+
}
114+
const outputPath = argv.outputDir ? join(argv.outputDir, basename(file)) : file;
115+
// Write atomically (temp file in the same dir, then rename) so an
116+
// interrupted or failed write never leaves a partial AppMap in place.
117+
await writeFileAtomic(dirname(outputPath), basename(outputPath), 'trim.tmp', trimmed);
118+
const pct = Math.round((100 * trimmed.length) / before.length);
119+
console.warn(`trim ${file}: ${before.length} -> ${trimmed.length} bytes (${pct}%)`);
120+
}
121+
if (files.length > 0 && failed === files.length) {
122+
throw new Error(`trim: all ${failed} input file(s) failed to process`);
123+
}
124+
},
125+
};

0 commit comments

Comments
 (0)