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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **`omni exec` distilled failed commands too (#122)**: the same invariant as #120, one layer down and from a different cause. `cli::exec` streamed the child's stdout straight through the distiller and only called `child.wait()` *afterwards*, so by the time the non-zero exit was known the distilled output had already been written — a failed command run through OMNI's own reproduction harness came out distilled. The exit code is only knowable once stdout is fully drained (and draining before `wait()` is also what avoids a full-pipe deadlock), so exec now buffers stdout, waits, then gates: non-zero exit emits stdout **verbatim** and skips distillation; zero exit distils exactly as before. Measured on 60 identical noise lines: `exit 1` now yields 60 verbatim lines where it previously collapsed to a single `[60 similar lines collapsed]` marker; `exit 0` still collapses. Stream-mode commands (`docker`/`npm`/`bun`) emit line-by-line before the exit code exists and cannot be gated, so they keep streaming; the stream-filter lookup is now shared via `pipe::stream_filter_for` so exec and the pipeline agree on which commands stream.
- **Distillers parsed OMNI's own collapse markers as data, and weak TOML filters hid it (#116, #110)**: `collapse` runs before `distill`, rewriting repeated lines into `[N similar lines collapsed]` markers — and a distiller that parses columns then read those markers as rows. A 35-row `kubectl get pods` table (30 Running, 5 CrashLoopBackOff) came out as `k8s: 2 pods | 0 running, 0 pending, 2 error / Problems: [30 (lines), [5 (lines)` — every reported "pod" was OMNI's own scaffolding, the real statuses destroyed. A distiller is just a later stage that parses its input, exactly what `pipeline::format::sniff` already shields structured payloads from; nothing shielded the distiller. It survived unseen because the broad `signals/domains/*.toml` filters won the alphabetical `find()` race and short-circuited the distiller for cargo/npm/docker/kubectl/terraform before it ever ran (#110). Two coupled fixes: (1) the distiller now reads the tool's **raw** output — collapse feeds only scoring and the fallback for commands no distiller claims, chosen by the shared `beats_guardrail` so a distiller that punts still yields the collapsed line savings; (2) a TOML filter only short-circuits the distiller if it actually beat that guardrail (ported from `hooks::pipe`, now shared in `guard::limits`), so weak filters fall through. Together the same table now distils to `k8s: 35 pods | 30 running, 0 pending, 5 error` with the five CrashLoopBackOff pods named. `TomlFilter::priority` remains parsed-but-unread — filed as #119, not fixed here.
- **`omni exec` ran a corrupted command when a shell was involved (#125)**: `cli::exec` flagged `needs_shell` whenever *any* argv element contained a shell metacharacter, then rebuilt the command as `format!("{} {}", cmd, cmd_args.join(" "))` and ran it under `sh -c`. For `omni exec sh -c 'echo A; echo B'` that produced `sh -c "sh -c echo A; echo B"` — a second shell layer, with `join(" ")` having already flattened the original quoting — so the outer shell ran `sh -c echo A` (A became `$0`, not an argument) and then `echo B`, and the output was just `B`. `omni exec` is the repo's own reproduction harness, so any repro of the form `omni exec sh -c '…'` silently ran the wrong command. A shell is now used only when the command arrived as a single unsplit string (`omni exec 'a; b'`); when argv is already split, each element belongs to the program and is passed through verbatim — the metacharacters are the inner program's, not omni's to reinterpret.
- **Composite npm scripts were distilled as a single tool, discarding the other gates (#106)**: a chained script (`npm run verify` = `build && tsc --noEmit && eslint && check:secrets && test`) concatenates several tools' output into one buffer, but the JS/TS dispatcher picked **one** distiller from the first signature it saw and handed it the whole thing. `is_tsc_output` matched `tsc --` inside npm's own `> build && tsc --noEmit && …` echo, so **19.5 KB collapsed to 14 bytes of `tsc: no errors`** — a false success that also erased four gate verdicts, including the secret scan's positive control (the line that proves the scan could see inside the bundle at all). Over-distilling a composite is token-*negative*: the agent re-ran `npm test`, `check:secrets`, and `lint` to recover what was dropped. No per-tool distiller can safely own a composite — an `&&` chain has no delimiter between the tools' outputs — so the dispatcher now detects npm's `> … && …` echo and declines, returning the buffer for the pipeline's generic collapse, which folds the repeated build noise while keeping every gate's distinct verdict line. (`make`/`npm-run-all` composites without an `&&` echo aren't covered yet — a follow-up when one is reported.)
- **Composite npm scripts were distilled as a single tool, discarding the other gates (#106)**: a chained script (`npm run verify` = `build && tsc --noEmit && eslint && check:secrets && test`) concatenates several tools' output into one buffer, but the JS/TS dispatcher picked **one** distiller from the first signature it saw and handed it the whole thing. `is_tsc_output` matched `tsc --` inside npm's own `> build && tsc --noEmit && …` echo, so **19.5 KB collapsed to 14 bytes of `tsc: no errors`** — a false success that also erased four gate verdicts, including the secret scan's positive control (the line that proves the scan could see inside the bundle at all). Over-distilling a composite is token-*negative*: the agent re-ran `npm test`, `check:secrets`, and `lint` to recover what was dropped. No per-tool distiller can safely own a composite — an `&&` chain has no delimiter between the tools' outputs — so the dispatcher now detects npm's `> … && …` echo and declines, returning the buffer for the pipeline's generic collapse, which folds the repeated build noise while keeping every gate's distinct verdict line. (`make`/`npm-run-all` composites without an `&&` echo aren't covered yet — filed as #129.)
- **`npm run format` (prettier) was reported as `eslint: no problems found` (#114)**: three compounding prettier defects in `distillers::jsts`. (1) `is_eslint_output` matched the bare word `eslint` — which appears as a *filename* (`eslint.config.js`) in prettier's file list — so a `prettier --write` run that rewrote files across 109 paths was distilled by `distill_eslint` as a clean run of a **different tool** (the same substring-in-data trap as #105/#106; a destructive command reported as finding nothing). (2) `is_prettier_output` only matched lowercase `checking `/`reformatted `, but prettier prints `Checking formatting…` and `[warn]` — so the detector was dead and never fired on real prettier output. (3) `distill_prettier` parsed **black**'s `reformatted N files` summary, which prettier never prints, so both counters stayed 0 and a *failing* `--check` and a *successful* `--write` both rendered as `prettier: 0 files reformatted, 0 unchanged`. All three are fixed: eslint detection now anchors on eslint's real line shape (`✖ N problems (`, a rule id, or a `<line>:<col> error|warning` finding) instead of a substring that occurs in filenames; prettier detection matches what prettier actually prints; and `distill_prettier` is rewritten against prettier's real output — `--check` surfaces the offending filenames (`prettier --check: N file(s) need formatting`), `--write` reports `N reformatted, M unchanged` and names the changed files (capped), and it declines (returns the input) rather than fabricate a count when neither mode is recognisable. Snapshot tests added for both modes. (Defect 4 in the report — `--check` output reordered with the `[warn]` filename dropped — is an upstream stdout/stderr-ordering observation the reporter did not trace; left for a separate issue.)

## [0.6.2] - 2026-07-17

Expand Down
129 changes: 102 additions & 27 deletions src/distillers/jsts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,18 +108,51 @@ fn is_playwright_output(lines: &[&str]) -> bool {
}

fn is_eslint_output(lines: &[&str]) -> bool {
// Anchor on eslint's real output shape, never the bare word "eslint" — that
// matched a *filename* (`eslint.config.js`) in prettier's file list and sent a
// `prettier --write` run to `distill_eslint`, which reported the wrong tool
// finding nothing (#114). Same substring-in-data trap as #105/#106.
lines.iter().any(|l| {
l.contains("eslint")
|| l.contains(" problems (")
|| (l.contains("error") && l.contains("@typescript-eslint"))
l.contains(" problems (") // summary: "✖ 3 problems (0 errors, 3 warnings)"
|| l.contains("@typescript-eslint/") // a real eslint rule id
|| is_eslint_finding_line(l) // " 12:5 warning <msg> <rule>"
})
}

/// eslint prints a finding as ` <line>:<col> error|warning …`.
fn is_eslint_finding_line(l: &str) -> bool {
let mut tokens = l.split_whitespace();
let Some((line, col)) = tokens.next().and_then(|t| t.split_once(':')) else {
return false;
};
if line.is_empty()
|| col.is_empty()
|| !line.bytes().all(|b| b.is_ascii_digit())
|| !col.bytes().all(|b| b.is_ascii_digit())
{
return false;
}
matches!(tokens.next(), Some("error" | "warning"))
}

fn is_prettier_output(lines: &[&str]) -> bool {
// Prettier's real output is capitalised (`Checking formatting…`, `[warn] …`),
// so the old lowercase `checking `/`reformatted ` never fired on it — the
// detector was dead (#114). Match what prettier actually prints, in either mode.
lines.iter().any(|l| {
l.contains("prettier")
|| l.contains("checking ") && l.contains(" files")
|| l.contains("reformatted ") && l.contains(" files")
l.contains("Checking formatting") // --check header
|| l.contains("[warn]") // --check finding / summary
|| l.contains("Code style issues") // --check summary
|| l.to_lowercase().contains("prettier") // command echo / banner, any case
|| is_prettier_write_line(l) // --write: "<path> <n>ms"
})
}

/// prettier `--write` prints one line per file: `<path> <n>ms`, with ` (unchanged)`
/// appended to files it left alone.
fn is_prettier_write_line(l: &str) -> bool {
l.split_whitespace().any(|t| {
t.len() > 2 && t.ends_with("ms") && t[..t.len() - 2].bytes().all(|b| b.is_ascii_digit())
})
}

Expand Down Expand Up @@ -539,30 +572,72 @@ fn distill_eslint(input: &str) -> String {
// ---------------------------------------------------------------------------

fn distill_prettier(input: &str) -> String {
let mut reformatted = 0;
let mut unchanged = 0;
// The old body parsed black's `reformatted N files` summary — prettier prints no
// such line, so both counters stayed 0 and a *failing* `--check` and a
// *successful* `--write` both rendered as "0 files reformatted, 0 unchanged"
// (#114). Parse prettier's real output per mode; if neither is recognisable,
// decline (return the input) rather than fabricate a count.
let lines: Vec<&str> = input.lines().collect();

for line in input.lines() {
let t = line.trim();
// Look for summary lines
if t.contains("reformatted ")
&& t.contains(" files")
&& let Some(num) = t.split_whitespace().find_map(|s| s.parse::<u32>().ok())
{
reformatted = num;
}
// In some output, it mentions "unchanged"
if t.contains(" unchanged")
&& let Some(num) = t.split_whitespace().find_map(|s| s.parse::<u32>().ok())
{
unchanged = num;
}
// --check: offending files are listed as `[warn] <path>`, ending with a boilerplate
// `[warn] Code style issues …` line. The filenames are the actionable signal.
let is_check = lines
.iter()
.any(|l| l.contains("Checking formatting") || l.contains("[warn]"));
if is_check {
let files: Vec<&str> = lines
.iter()
.filter_map(|l| l.trim().strip_prefix("[warn] "))
.filter(|f| !f.is_empty() && !f.starts_with("Code style issues"))
.collect();
return if files.is_empty() {
"prettier --check: all files formatted".to_string()
} else {
format!(
"prettier --check: {} file(s) need formatting\n{}",
files.len(),
capped_lines(&files, 20)
)
};
}

// --write: `<path> <n>ms` per file, ` (unchanged)` on files left alone.
let file_lines: Vec<&str> = lines
.iter()
.copied()
.filter(|l| is_prettier_write_line(l))
.collect();
if file_lines.is_empty() {
return input.to_string();
}
let unchanged = file_lines
.iter()
.filter(|l| l.contains("(unchanged)"))
.count();
let changed: Vec<&str> = file_lines
.iter()
.filter(|l| !l.contains("(unchanged)"))
.map(|l| l.split_whitespace().next().unwrap_or(""))
.collect();
let mut out = format!(
"prettier --write: {} reformatted, {} unchanged",
changed.len(),
unchanged
);
if !changed.is_empty() {
out.push('\n');
out.push_str(&capped_lines(&changed, 20));
}
out
}

format!(
"prettier: {} files reformatted, {} unchanged",
reformatted, unchanged
)
/// Render `items` one per indented line, capped, with an `… and N more` tail.
fn capped_lines(items: &[&str], cap: usize) -> String {
let mut out: Vec<String> = items.iter().take(cap).map(|s| format!(" {s}")).collect();
if items.len() > cap {
out.push(format!(" … and {} more", items.len() - cap));
}
out.join("\n")
}

// ---------------------------------------------------------------------------
Expand Down
14 changes: 14 additions & 0 deletions src/distillers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,20 @@ mod tests {
"playwright test"
);
snapshot_test!(test_jsts_eslint, "eslint_errors.txt", "eslint");
// #114: `prettier --write` (via `npm run format`) used to report `eslint: no
// problems found` — `is_eslint_output` matched the filename `eslint.config.js`
// in prettier's file list. It must now be recognised as prettier and summarised
// per real prettier output (both modes), never as a clean run of another tool.
snapshot_test!(
test_jsts_prettier_write,
"prettier_write.txt",
"npm run format"
);
snapshot_test!(
test_jsts_prettier_check,
"prettier_check.txt",
"prettier --check ."
);

snapshot_test!(
test_database_psql_error,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
source: src/distillers/mod.rs
expression: output
---
prettier --check: 2 file(s) need formatting
src/lib/i18n.tsx
src/routes/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
source: src/distillers/mod.rs
expression: output
---
prettier --write: 3 reformatted, 6 unchanged
src/lib/i18n.tsx
src/routes/index.tsx
src/components/Header.tsx
4 changes: 4 additions & 0 deletions tests/fixtures/prettier_check.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Checking formatting...
[warn] src/lib/i18n.tsx
[warn] src/routes/index.tsx
[warn] Code style issues found in 2 files. Run Prettier with --write to fix.
12 changes: 12 additions & 0 deletions tests/fixtures/prettier_write.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
> forgepod@0.0.0 format
> prettier --write .

.prettierrc.json 4ms (unchanged)
eslint.config.js 10ms (unchanged)
package.json 3ms (unchanged)
src/lib/i18n.tsx 21ms
src/routes/index.tsx 18ms
src/routes/about.tsx 6ms (unchanged)
src/components/Header.tsx 9ms
tsconfig.json 2ms (unchanged)
vite.config.ts 5ms (unchanged)