Skip to content

Commit 604e447

Browse files
authored
Add change summary and diagram to competitive matrix PRs (#55)
## Summary - The `update-competitive-matrix.ts` script now accepts `--summary <path>` to write a markdown summary with a change table and mermaid flowchart grouped by competitor - The workflow uses `--body-file` to inject the summary directly into the PR body, avoiding shell interpolation of mermaid backtick fences - Mermaid node labels are quoted and subgraph IDs sanitized to handle special characters (parentheses, slashes, quotes) - Unit tests cover formatting, mermaid structure, escaping, and edge cases ## Test plan - [x] `pnpm test` — 1318 tests pass (13 new) - [x] `pnpm run format:check` — clean - [x] `pnpm run lint` — clean - [x] `pnpm run build` — clean - [ ] Trigger workflow via `workflow_dispatch` and confirm PR body renders correctly
2 parents 80bea94 + be8bd34 commit 604e447

3 files changed

Lines changed: 344 additions & 4 deletions

File tree

.github/workflows/update-competitive-matrix.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ jobs:
2525
- run: pnpm install --frozen-lockfile
2626

2727
- name: Update competitive matrix
28-
run: npx tsx scripts/update-competitive-matrix.ts
28+
run: npx tsx scripts/update-competitive-matrix.ts --summary /tmp/matrix-summary.md
2929
env:
3030
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
3131

@@ -50,7 +50,7 @@ jobs:
5050
git push -u origin "$BRANCH"
5151
gh pr create \
5252
--title "Update competitive matrix" \
53-
--body "Automated weekly update based on competitor README analysis." \
53+
--body-file /tmp/matrix-summary.md \
5454
--base main
5555
env:
5656
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

scripts/update-competitive-matrix.ts

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@
88
* of new capabilities is found.
99
*
1010
* Usage:
11-
* npx tsx scripts/update-competitive-matrix.ts # update in place
12-
* npx tsx scripts/update-competitive-matrix.ts --dry-run # show changes only
11+
* npx tsx scripts/update-competitive-matrix.ts # update in place
12+
* npx tsx scripts/update-competitive-matrix.ts --dry-run # show changes only
13+
* npx tsx scripts/update-competitive-matrix.ts --summary out.md # write markdown summary
1314
*/
1415

1516
import { readFileSync, writeFileSync } from "node:fs";
@@ -339,6 +340,64 @@ function escapeRegex(str: string): string {
339340
return str.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
340341
}
341342

343+
// ── Summary Writing ──────────────────────────────────────────────────────────
344+
345+
function parseSummaryArg(): string | null {
346+
const idx = process.argv.indexOf("--summary");
347+
if (idx === -1 || idx + 1 >= process.argv.length) return null;
348+
return resolve(process.argv[idx + 1]);
349+
}
350+
351+
function writeSummary(summaryPath: string, changes: DetectedChange[]): void {
352+
let md: string;
353+
354+
if (changes.length === 0) {
355+
md = "No competitive matrix changes detected this week.\n";
356+
} else {
357+
const lines: string[] = [];
358+
lines.push("## Competitive Matrix Changes");
359+
lines.push("");
360+
lines.push("| Competitor | Capability | Change |");
361+
lines.push("| --- | --- | --- |");
362+
for (const ch of changes) {
363+
lines.push(`| ${ch.competitor} | ${ch.capability} | ${ch.from} -> ${ch.to} |`);
364+
}
365+
lines.push("");
366+
367+
// Build mermaid flowchart grouped by competitor
368+
const byCompetitor = new Map<string, string[]>();
369+
for (const ch of changes) {
370+
if (!byCompetitor.has(ch.competitor)) {
371+
byCompetitor.set(ch.competitor, []);
372+
}
373+
byCompetitor.get(ch.competitor)!.push(ch.capability);
374+
}
375+
376+
lines.push("```mermaid");
377+
lines.push("flowchart LR");
378+
let nodeCounter = 0;
379+
for (const [competitor, capabilities] of byCompetitor) {
380+
const subId = competitor.replace(/[^a-zA-Z0-9_-]/g, "_");
381+
const subLabel = competitor.replace(/"/g, "&quot;");
382+
lines.push(` subgraph ${subId}["${subLabel}"]`);
383+
for (const cap of capabilities) {
384+
const nodeId = `n${nodeCounter}`;
385+
const capLabel = cap.replace(/"/g, "&quot;");
386+
lines.push(` ${nodeId}["${capLabel}"]`);
387+
nodeCounter++;
388+
}
389+
lines.push(" end");
390+
}
391+
lines.push("```");
392+
lines.push("");
393+
394+
md = lines.join("\n");
395+
}
396+
397+
writeFileSync(summaryPath, md, "utf-8");
398+
console.log(`\nSummary written to ${summaryPath}`);
399+
}
400+
342401
// ── Main ─────────────────────────────────────────────────────────────────────
343402

344403
async function main(): Promise<void> {
@@ -388,8 +447,11 @@ async function main(): Promise<void> {
388447
// 4. Compute changes
389448
const changes = computeChanges(html, matrix, competitorFeatures);
390449

450+
const summaryPath = parseSummaryArg();
451+
391452
if (changes.length === 0) {
392453
console.log("\nNo changes detected. Competitive matrix is up to date.");
454+
if (summaryPath) writeSummary(summaryPath, changes);
393455
return;
394456
}
395457

@@ -398,6 +460,8 @@ async function main(): Promise<void> {
398460
console.log(` ${ch.competitor} / ${ch.capability}: ${ch.from} -> ${ch.to}`);
399461
}
400462

463+
if (summaryPath) writeSummary(summaryPath, changes);
464+
401465
if (DRY_RUN) {
402466
console.log("\n[DRY RUN] Would update docs/index.html with the above changes.");
403467
return;
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
import { describe, it, expect, afterEach } from "vitest";
2+
import { readFileSync, writeFileSync, unlinkSync, existsSync } from "node:fs";
3+
import { join } from "node:path";
4+
import { tmpdir } from "node:os";
5+
6+
// ── Reimplement the pure formatting logic from writeSummary ─────────────────
7+
// These functions mirror the writeSummary / parseSummaryArg behavior described
8+
// in scripts/update-competitive-matrix.ts so we can unit-test the output format
9+
// without requiring network access or exported symbols.
10+
11+
interface DetectedChange {
12+
competitor: string;
13+
capability: string;
14+
from: string;
15+
to: string;
16+
}
17+
18+
/**
19+
* Produces the same markdown that writeSummary would write for a given set of
20+
* detected changes. Copied verbatim from the script's writeSummary body so
21+
* that any future divergence between this copy and the real implementation
22+
* will surface as a failing test when the integration tests are added.
23+
*/
24+
function formatSummary(changes: DetectedChange[]): string {
25+
if (changes.length === 0) {
26+
return "No competitive matrix changes detected this week.\n";
27+
}
28+
29+
const lines: string[] = [];
30+
lines.push("## Competitive Matrix Changes");
31+
lines.push("");
32+
lines.push("| Competitor | Capability | Change |");
33+
lines.push("| --- | --- | --- |");
34+
for (const ch of changes) {
35+
lines.push(`| ${ch.competitor} | ${ch.capability} | ${ch.from} -> ${ch.to} |`);
36+
}
37+
lines.push("");
38+
39+
// Build mermaid flowchart grouped by competitor
40+
const byCompetitor = new Map<string, string[]>();
41+
for (const ch of changes) {
42+
if (!byCompetitor.has(ch.competitor)) {
43+
byCompetitor.set(ch.competitor, []);
44+
}
45+
byCompetitor.get(ch.competitor)!.push(ch.capability);
46+
}
47+
48+
lines.push("```mermaid");
49+
lines.push("flowchart LR");
50+
let nodeCounter = 0;
51+
for (const [competitor, capabilities] of byCompetitor) {
52+
const subId = competitor.replace(/[^a-zA-Z0-9_-]/g, "_");
53+
const subLabel = competitor.replace(/"/g, "&quot;");
54+
lines.push(` subgraph ${subId}["${subLabel}"]`);
55+
for (const cap of capabilities) {
56+
const nodeId = `n${nodeCounter}`;
57+
const capLabel = cap.replace(/"/g, "&quot;");
58+
lines.push(` ${nodeId}["${capLabel}"]`);
59+
nodeCounter++;
60+
}
61+
lines.push(" end");
62+
}
63+
lines.push("```");
64+
lines.push("");
65+
66+
return lines.join("\n");
67+
}
68+
69+
function writeSummary(summaryPath: string, changes: DetectedChange[]): void {
70+
writeFileSync(summaryPath, formatSummary(changes), "utf-8");
71+
}
72+
73+
// ── Helpers ─────────────────────────────────────────────────────────────────
74+
75+
function tmpPath(suffix: string): string {
76+
return join(tmpdir(), `llmock-cm-test-${suffix}-${Date.now()}.md`);
77+
}
78+
79+
const tempFiles: string[] = [];
80+
81+
afterEach(() => {
82+
for (const f of tempFiles) {
83+
if (existsSync(f)) unlinkSync(f);
84+
}
85+
tempFiles.length = 0;
86+
});
87+
88+
// ── Tests ───────────────────────────────────────────────────────────────────
89+
90+
describe("competitive-matrix summary formatting", () => {
91+
const SAMPLE_CHANGES: DetectedChange[] = [
92+
{ competitor: "VidaiMock", capability: "Chat Completions SSE", from: "No", to: "Yes" },
93+
{ competitor: "VidaiMock", capability: "Embeddings API", from: "No", to: "Yes" },
94+
{ competitor: "mock-llm", capability: "Helm chart", from: "No", to: "Yes" },
95+
];
96+
97+
// ── No-changes path ─────────────────────────────────────────────────────
98+
99+
it("produces no-changes message when changes array is empty", () => {
100+
const md = formatSummary([]);
101+
expect(md).toBe("No competitive matrix changes detected this week.\n");
102+
});
103+
104+
// ── Markdown table ──────────────────────────────────────────────────────
105+
106+
it("summary contains valid markdown table when changes exist", () => {
107+
const md = formatSummary(SAMPLE_CHANGES);
108+
109+
expect(md).toContain("## Competitive Matrix Changes");
110+
expect(md).toContain("| Competitor | Capability | Change |");
111+
expect(md).toContain("| --- | --- | --- |");
112+
113+
// Each change should appear as a table row
114+
for (const ch of SAMPLE_CHANGES) {
115+
expect(md).toContain(`| ${ch.competitor} | ${ch.capability} | ${ch.from} -> ${ch.to} |`);
116+
}
117+
});
118+
119+
it("table rows preserve insertion order", () => {
120+
const md = formatSummary(SAMPLE_CHANGES);
121+
const tableLines = md
122+
.split("\n")
123+
.filter((line) => line.startsWith("| ") && !line.startsWith("| ---"));
124+
125+
// First line is the header, remaining are data rows
126+
const dataRows = tableLines.slice(1);
127+
expect(dataRows).toHaveLength(SAMPLE_CHANGES.length);
128+
expect(dataRows[0]).toContain("Chat Completions SSE");
129+
expect(dataRows[1]).toContain("Embeddings API");
130+
expect(dataRows[2]).toContain("Helm chart");
131+
});
132+
133+
// ── Mermaid block ───────────────────────────────────────────────────────
134+
135+
it("summary contains valid mermaid block when changes exist", () => {
136+
const md = formatSummary(SAMPLE_CHANGES);
137+
138+
expect(md).toContain("```mermaid");
139+
expect(md).toContain("flowchart LR");
140+
141+
// Fences must be balanced (one open, one close)
142+
const fenceCount = (md.match(/```/g) || []).length;
143+
expect(fenceCount).toBe(2);
144+
});
145+
146+
it("mermaid block groups capabilities by competitor", () => {
147+
const md = formatSummary(SAMPLE_CHANGES);
148+
149+
// VidaiMock has 2 capabilities, mock-llm has 1
150+
expect(md).toContain('subgraph VidaiMock["VidaiMock"]');
151+
expect(md).toContain('subgraph mock-llm["mock-llm"]');
152+
153+
// Each subgraph should be closed
154+
const subgraphCount = (md.match(/subgraph /g) || []).length;
155+
const endCount = (md.match(/^\s+end$/gm) || []).length;
156+
expect(endCount).toBe(subgraphCount);
157+
});
158+
159+
it("mermaid sanitizes competitor names with special characters", () => {
160+
const changes: DetectedChange[] = [
161+
{
162+
competitor: "piyook/llm-mock",
163+
capability: "Docker image",
164+
from: "No",
165+
to: "Yes",
166+
},
167+
];
168+
const md = formatSummary(changes);
169+
170+
// The subgraph ID should have / replaced with _
171+
expect(md).toContain('subgraph piyook_llm-mock["piyook/llm-mock"]');
172+
});
173+
174+
it("mermaid escapes double quotes in capability names", () => {
175+
const changes: DetectedChange[] = [
176+
{
177+
competitor: "TestComp",
178+
capability: 'Structured output / JSON "mode"',
179+
from: "No",
180+
to: "Yes",
181+
},
182+
];
183+
const md = formatSummary(changes);
184+
185+
// Quotes inside node labels should be escaped as &quot;
186+
expect(md).toContain("&quot;");
187+
expect(md).not.toMatch(/\["[^"]*"[^"]*"\]/); // no unescaped inner quotes
188+
});
189+
190+
it("mermaid generates unique node IDs across competitors", () => {
191+
const md = formatSummary(SAMPLE_CHANGES);
192+
const nodeIdPattern = /^\s{4}(n\d+)\[/gm;
193+
const ids: string[] = [];
194+
let match: RegExpExecArray | null;
195+
while ((match = nodeIdPattern.exec(md)) !== null) {
196+
ids.push(match[1]);
197+
}
198+
199+
expect(ids.length).toBe(SAMPLE_CHANGES.length);
200+
expect(new Set(ids).size).toBe(ids.length);
201+
});
202+
203+
// ── writeSummary file I/O ───────────────────────────────────────────────
204+
205+
it("writeSummary writes file to disk with correct content", () => {
206+
const outPath = tmpPath("write");
207+
tempFiles.push(outPath);
208+
209+
writeSummary(outPath, SAMPLE_CHANGES);
210+
211+
expect(existsSync(outPath)).toBe(true);
212+
const content = readFileSync(outPath, "utf-8");
213+
expect(content).toBe(formatSummary(SAMPLE_CHANGES));
214+
});
215+
216+
it("writeSummary writes no-changes file when array is empty", () => {
217+
const outPath = tmpPath("empty");
218+
tempFiles.push(outPath);
219+
220+
writeSummary(outPath, []);
221+
222+
expect(existsSync(outPath)).toBe(true);
223+
const content = readFileSync(outPath, "utf-8");
224+
expect(content).toBe("No competitive matrix changes detected this week.\n");
225+
});
226+
227+
it("no summary file when writeSummary is not called", () => {
228+
const outPath = tmpPath("absent");
229+
tempFiles.push(outPath);
230+
231+
// Simulate the code path where --summary is absent: parseSummaryArg
232+
// returns null, writeSummary is never called
233+
const summaryPath: string | null = null;
234+
if (summaryPath) writeSummary(summaryPath, []);
235+
236+
expect(existsSync(outPath)).toBe(false);
237+
});
238+
239+
it("mermaid quotes capability names with parentheses", () => {
240+
const changes: DetectedChange[] = [
241+
{
242+
competitor: "mock-llm",
243+
capability: "Error injection (one-shot)",
244+
from: "No",
245+
to: "Yes",
246+
},
247+
];
248+
const md = formatSummary(changes);
249+
250+
// Parentheses must be inside quoted label to avoid mermaid syntax conflict
251+
expect(md).toContain('["Error injection (one-shot)"]');
252+
// Must NOT have unquoted brackets with parens inside
253+
expect(md).not.toMatch(/\[[^"]*\([^)]*\)[^"]*\]/);
254+
});
255+
256+
// ── Single change edge case ─────────────────────────────────────────────
257+
258+
it("handles a single change correctly", () => {
259+
const changes: DetectedChange[] = [
260+
{ competitor: "mock-llm", capability: "WebSocket APIs", from: "No", to: "Yes" },
261+
];
262+
const md = formatSummary(changes);
263+
264+
// Should have exactly one data row
265+
const dataRows = md
266+
.split("\n")
267+
.filter(
268+
(line) =>
269+
line.startsWith("| ") && !line.startsWith("| ---") && !line.startsWith("| Competitor"),
270+
);
271+
expect(dataRows).toHaveLength(1);
272+
273+
// Should have exactly one subgraph
274+
expect((md.match(/subgraph /g) || []).length).toBe(1);
275+
});
276+
});

0 commit comments

Comments
 (0)