-
Notifications
You must be signed in to change notification settings - Fork 2
Surface sampler report URL and quiet CI output #261
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
0412027
Surface sampler report URL and quiet CI output
michieldegezelle a6149cd
Add run-sampler --compact: named_results diff extractor
michieldegezelle b726065
fix: exit non-zero when a completed sampler run has no result_url
michieldegezelle 790c281
fix: reject zip entries that escape the extraction temp dir (zip-slip)
michieldegezelle f8225f7
fix: add a request timeout to the sampler results download
michieldegezelle 0013524
fix: use \x00 escape sequence instead of a literal NUL byte in dedup key
michieldegezelle b0399ac
fix: key entry label map by kind to avoid account/reconciliation id c…
michieldegezelle 74a1f85
fix: don't count entries with unreadable registers.json as sampled
michieldegezelle f881c72
fix: order-insensitive deep comparison for named_results diff
michieldegezelle ac68517
fix: clean up the extraction temp dir when a zip entry write fails
michieldegezelle 5a68465
fix: don't merge cross-kind templates that share the same label
michieldegezelle b9f0a9e
fix: treat a non-object registers.json as unreadable, not an empty re…
michieldegezelle 91f754f
fix: neutralize embedded marker text in the compact diff output
michieldegezelle bea5ab8
fix: key the poll spinner off stdout.isTTY and add a polling heartbeat
michieldegezelle 68e0bc1
Bump version
michieldegezelle File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,258 @@ | ||
| const fs = require("fs"); | ||
| const path = require("path"); | ||
| const YAML = require("yaml"); | ||
|
|
||
| /** | ||
| * Extract a compact, LLM/review-friendly view of a Liquid Sampler result. | ||
| * | ||
| * A sampler `results.zip` is huge (the rendered HTML report alone is ~470K tokens | ||
| * at scale, and the raw per-entry output is ~150 MB). The only signal a reviewer | ||
| * usually needs first is the DATA diff: how each template's `named_results` | ||
| * changed. That lives in `registers.json` for every sampled entry, before/after. | ||
| * | ||
| * This module reads an already-EXTRACTED results directory (the layout inside | ||
| * results.zip) and returns just the `named_results` changes, grouped by template | ||
| * and deduped by identical change, so the whole thing is ~900 tokens instead of | ||
| * hundreds of thousands. It never touches the rendered HTML. | ||
| * | ||
| * Expected directory layout (inside results.zip): | ||
| * <dir>/sample_entry_ids.yml | ||
| * <dir>/output/account_entries/<id>/{before,after}/registers.json | ||
| * <dir>/output/reconciliation_entries/<id>/{before,after}/registers.json | ||
| */ | ||
|
|
||
| const ENTRY_KINDS = ["account_entries", "reconciliation_entries"]; | ||
| // A key can be absent from an object entirely (a broken template stops emitting | ||
| // it). JSON has no `undefined`, so an absent key parses as `undefined`; we render | ||
| // that distinctly from an explicit JSON `null`. | ||
| const ABSENT = Symbol("absent"); | ||
|
|
||
| /** | ||
| * Render a named_results value for display. Distinguishes an absent key | ||
| * (template no longer emits it) from an explicit null. | ||
| * @param {*} value | ||
| * @returns {string} | ||
| */ | ||
| function renderValue(value) { | ||
| if (value === ABSENT) return "undefined"; | ||
| return JSON.stringify(value); | ||
| } | ||
|
|
||
| /** | ||
| * Build a map of "kind/entry id" -> { label, url } from sample_entry_ids.yml. | ||
| * `label` is the template handle (e.g. "vkt_1"). Returns an empty map if the | ||
| * file is missing or unparseable - callers fall back to the raw entry id. | ||
| * Keyed by kind as well as entry id because account_entries and | ||
| * reconciliation_entries id spaces aren't guaranteed disjoint. | ||
| * @param {string} resultsDir | ||
| * @returns {Object<string, {label: string, url: string}>} | ||
| */ | ||
| function readEntryLabels(resultsDir) { | ||
| const labelMap = {}; | ||
| const ymlPath = path.join(resultsDir, "sample_entry_ids.yml"); | ||
| if (!fs.existsSync(ymlPath)) return labelMap; | ||
|
|
||
| let parsed; | ||
| try { | ||
| parsed = YAML.parse(fs.readFileSync(ymlPath, "utf8")); | ||
| } catch { | ||
| return labelMap; | ||
| } | ||
| if (!parsed || typeof parsed !== "object") return labelMap; | ||
|
|
||
| for (const kind of ENTRY_KINDS) { | ||
| for (const [entryId, meta] of Object.entries(parsed[kind] || {})) { | ||
| labelMap[`${kind}/${entryId}`] = { | ||
| label: (meta && meta.label) || entryId, | ||
| url: (meta && meta.url) || null, | ||
| }; | ||
| } | ||
| } | ||
| return labelMap; | ||
| } | ||
|
|
||
| /** | ||
| * Read the named_results object from a registers.json file. | ||
| * @param {string} filePath | ||
| * @returns {Object|null} the named_results object, or null if unreadable | ||
| */ | ||
| function readNamedResults(filePath) { | ||
| try { | ||
| const registers = JSON.parse(fs.readFileSync(filePath, "utf8")); | ||
| // A valid registers.json is always an object. Anything else (a bare | ||
| // string/number, an array, or a truncated-to-null file) means the file | ||
| // is unreadable, not that named_results happens to be absent. | ||
| if (!registers || typeof registers !== "object" || Array.isArray(registers)) return null; | ||
|
|
||
| const named = registers.named_results; | ||
| return named && typeof named === "object" ? named : {}; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Serialize a value such that two objects with the same keys/values but a | ||
| * different property insertion order produce the same string. Array element | ||
| * order still matters (a real reordering of a list is a real change). | ||
| * @param {*} value | ||
| * @returns {string} | ||
| */ | ||
| function stableStringify(value) { | ||
| if (Array.isArray(value)) { | ||
| return `[${value.map(stableStringify).join(",")}]`; | ||
| } | ||
| if (value && typeof value === "object") { | ||
| return `{${Object.keys(value) | ||
| .sort() | ||
| .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) | ||
| .join(",")}}`; | ||
| } | ||
| return JSON.stringify(value); | ||
| } | ||
|
|
||
| /** | ||
| * Diff two named_results objects, returning one entry per changed key. | ||
| * @param {Object} before | ||
| * @param {Object} after | ||
| * @returns {Array<{key: string, before: string, after: string}>} | ||
| */ | ||
| function diffNamedResults(before, after) { | ||
| const changes = []; | ||
| const keys = new Set([...Object.keys(before), ...Object.keys(after)]); | ||
| for (const key of [...keys].sort()) { | ||
| const b = Object.hasOwn(before, key) ? before[key] : ABSENT; | ||
| const a = Object.hasOwn(after, key) ? after[key] : ABSENT; | ||
| if (stableStringify(b) !== stableStringify(a)) { | ||
| changes.push({ key, before: renderValue(b), after: renderValue(a) }); | ||
| } | ||
| } | ||
| return changes; | ||
| } | ||
|
|
||
| /** | ||
| * Walk an extracted results directory and build the compact named_results diff. | ||
| * @param {string} resultsDir - Path to the extracted results directory | ||
| * @returns {{ | ||
| * summary: {templatesChanged: number, entriesChanged: number, entriesSampled: number, entriesSkipped: number}, | ||
| * templates: Array<{label: string, entriesChanged: number, changes: Array<{key: string, before: string, after: string, count: number}>}> | ||
| * }} | ||
| */ | ||
| function extractCompact(resultsDir) { | ||
| const labelMap = readEntryLabels(resultsDir); | ||
| const outputDir = path.join(resultsDir, "output"); | ||
|
|
||
| // JSON.stringify([kind, label]) -> { label, entryIds: Set, changeCounts: Map<"key\0before\0after", {key,before,after,count}> } | ||
| // Keyed by kind as well as label because the same label string could | ||
| // (however unlikely) apply to both an account and a reconciliation | ||
| // template - merging those would combine unrelated entries/changes. | ||
| const byTemplate = new Map(); | ||
| let entriesSampled = 0; | ||
| let entriesSkipped = 0; | ||
|
|
||
| for (const kind of ENTRY_KINDS) { | ||
| const kindDir = path.join(outputDir, kind); | ||
| if (!fs.existsSync(kindDir)) continue; | ||
|
|
||
| for (const entryId of fs.readdirSync(kindDir)) { | ||
| const entryDir = path.join(kindDir, entryId); | ||
| if (!fs.statSync(entryDir).isDirectory()) continue; | ||
|
|
||
| const before = readNamedResults(path.join(entryDir, "before", "registers.json")); | ||
| const after = readNamedResults(path.join(entryDir, "after", "registers.json")); | ||
| if (before === null || after === null) { | ||
| // registers.json missing/malformed - this entry was never actually | ||
| // compared, so it shouldn't count toward "sampled". | ||
| entriesSkipped += 1; | ||
| continue; | ||
| } | ||
| entriesSampled += 1; | ||
|
|
||
| const changes = diffNamedResults(before, after); | ||
| if (changes.length === 0) continue; | ||
|
|
||
| const entryKey = `${kind}/${entryId}`; | ||
| const label = (labelMap[entryKey] && labelMap[entryKey].label) || entryId; | ||
| const templateKey = JSON.stringify([kind, label]); | ||
| if (!byTemplate.has(templateKey)) { | ||
| byTemplate.set(templateKey, { label, entryIds: new Set(), changeCounts: new Map() }); | ||
| } | ||
| const bucket = byTemplate.get(templateKey); | ||
| bucket.entryIds.add(entryKey); | ||
| for (const change of changes) { | ||
| const dedupKey = `${change.key}\x00${change.before}\x00${change.after}`; | ||
| const existing = bucket.changeCounts.get(dedupKey); | ||
| if (existing) { | ||
| existing.count += 1; | ||
| } else { | ||
| bucket.changeCounts.set(dedupKey, { ...change, count: 1 }); | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| const templates = [...byTemplate.entries()] | ||
| .map(([, bucket]) => ({ | ||
| label: bucket.label, | ||
| entriesChanged: bucket.entryIds.size, | ||
| // Most-repeated change first, then alphabetically by key for stability. | ||
| changes: [...bucket.changeCounts.values()].sort( | ||
| (x, y) => y.count - x.count || x.key.localeCompare(y.key), | ||
| ), | ||
| })) | ||
| .sort((x, y) => y.entriesChanged - x.entriesChanged || x.label.localeCompare(y.label)); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| const entriesChanged = templates.reduce((sum, t) => sum + t.entriesChanged, 0); | ||
|
|
||
| return { | ||
| summary: { | ||
| templatesChanged: templates.length, | ||
| entriesChanged, | ||
| entriesSampled, | ||
| entriesSkipped, | ||
| }, | ||
| templates, | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Format the compact diff as Markdown, suitable for stdout or a PR comment. | ||
| * @param {ReturnType<typeof extractCompact>} data | ||
| * @returns {string} | ||
| */ | ||
| function formatCompact(data) { | ||
| const { summary, templates } = data; | ||
| const lines = []; | ||
| lines.push("## 🧪 Sampler compact diff (named_results)"); | ||
| lines.push(""); | ||
|
|
||
| const skippedNote = | ||
| summary.entriesSkipped > 0 | ||
| ? `, ${summary.entriesSkipped} skipped (unreadable registers.json)` | ||
| : ""; | ||
|
|
||
| if (templates.length === 0) { | ||
| lines.push( | ||
| `No \`named_results\` changes across ${summary.entriesSampled} sampled entr${summary.entriesSampled === 1 ? "y" : "ies"}${skippedNote}.`, | ||
| ); | ||
| return lines.join("\n"); | ||
| } | ||
|
|
||
| lines.push( | ||
| `**${summary.templatesChanged}** template(s) changed across **${summary.entriesChanged}** ` + | ||
| `entr${summary.entriesChanged === 1 ? "y" : "ies"} (${summary.entriesSampled} sampled${skippedNote}).`, | ||
| ); | ||
|
|
||
| for (const template of templates) { | ||
| lines.push(""); | ||
| lines.push(`### ${template.label} — ${template.entriesChanged} entr${template.entriesChanged === 1 ? "y" : "ies"} changed`); | ||
| for (const change of template.changes) { | ||
| const times = change.count > 1 ? `[${change.count}×] ` : ""; | ||
| lines.push(`- ${times}\`${change.key}\`: \`${change.before}\` → \`${change.after}\``); | ||
| } | ||
| } | ||
|
|
||
| return lines.join("\n"); | ||
| } | ||
|
|
||
| module.exports = { extractCompact, formatCompact, diffNamedResults, readEntryLabels }; | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.