fix(sensors): stop reporting harness failures as code-quality FAILs - #364
fix(sensors): stop reporting harness failures as code-quality FAILs#364orossant wants to merge 2 commits into
Conversation
JWThewes
left a comment
There was a problem hiding this comment.
Thanks for the PR. The overall direction makes sense, and the added diagnostics are useful. I left some comments on cases that can still produce false results or lose the sensor record. Please address those before merging.
| // Only inspect what this stage changed. A design stage that produced no | ||
| // on-disk work is INCONCLUSIVE here rather than being graded on a previous | ||
| // stage's source files. | ||
| const matched = scopeToChangedFiles(globbed, changedFiles); |
There was a problem hiding this comment.
Could we avoid applying this file-level scope to type-check? That sensor runs tsc --project for the whole project, then filters diagnostics back to the supplied file. If a changed provider breaks an untouched consumer, only running the provider returns PASS because the error is attributed to the consumer. I reproduced that with the real Bun/TypeScript fixture. A config-only change has the same problem because no changed TS file reaches the sensor. Please keep linting file-scoped, but run type-checking once per affected project (or expand to all files in the affected project).
| if (!Array.isArray(changed)) return files; | ||
| if (changed.length === 0) return []; | ||
| const exact = new Set(changed); | ||
| return files.filter((f) => exact.has(f) || changed.some((c) => f.endsWith(`/${c}`))); |
There was a problem hiding this comment.
This suffix match loses repo identity. In a multi-repo workspace, if repo A and repo B both have src/index.ts, changing it in A selects both files and can grade the stage on untouched code from B. The changed-file list should carry normalized workspace-relative paths including the repo prefix, then use exact matching. While doing that, please consume NUL-delimited porcelain output so quoted filenames (spaces/non-ASCII) match listFiles correctly.
| if (exitCode === TOOL_UNAVAILABLE_EXIT) return ranButUndecided('tool-unavailable'); | ||
|
|
||
| const nonZero = exitCode !== 0 && exitCode !== 2 && exitCode !== null && exitCode !== undefined; | ||
| if (nonZero && STDOUT_VERDICT_RUNTIMES.has(runtime)) { |
There was a problem hiding this comment.
This assumes every Bun/Node sensor uses the stdout-JSON verdict contract, but the sensor model/editor allows custom scripts and the existing fallback treats exit 1 as FAIL. Those sensors would now be reported as INCONCLUSIVE based only on runtime. Could we key this behavior off an explicit verdict mode/output contract (or, minimally, the known linter/type-check sensor IDs) and preserve exit-code semantics for unmarked sensors?
| detail, | ||
| ...(result === SENSOR_RESULT.PASS | ||
| ? {} | ||
| : { exitCode: run.exitCode ?? null, stderr: tailDiagnostic(run.stderr) }), |
There was a problem hiding this comment.
stderr and exitCode are already present inside detail for script errors, so this duplicates the diagnostic for every file. More importantly, a per-file cap does not enforce DynamoDB’s 400 KiB item limit: with this exact shape, 354 failures with 500-character stderr produce about 410 KB before row metadata, and recordSensorRun then fails silently. Please add a total serialized-size budget (ideally dedupe identical harness failures into one sensor-level diagnostic plus counts/sample files) instead of only truncating each entry.
|
Thanks — all four are fair, and two of them are cases where my change makes things worse rather than just incomplete. Taking them in order of severity:
Suffix matching loses repo identity. I chose it deliberately, reasoning that including a borderline file was safer than skipping one, but you're right that in a multi-repo workspace it can grade a stage on a different repo's untouched code. I'll normalize the changed-file list to repo-prefixed workspace-relative paths and match exactly, and switch to NUL-delimited porcelain so filenames with spaces or non-ASCII survive the round trip. Runtime is the wrong key for the verdict contract. Agreed — keying "non-zero exit means script error" off The stderr budget doesn't bound the item. Your arithmetic is right and my per-file cap doesn't address the real constraint — and a silent Will push a revision. I'm doing #363 first since it carries a credential-exposure path, then this one. |
On a real deployment the advisory `linter` and `type-check` sensors
reported FAIL for every file on an `infrastructure-design` stage — a stage
that writes methodology artifacts to Neptune and no code to disk. Each
entry looked like:
{ "file": "packages/api/vitest.config.ts", "result": "FAIL",
"detail": null, "timedOut": false }
~40 files, all FAIL, `detail: null`. A 100% failure rate across config and
test-setup files in two packages is not 40 independent findings. Several
defects combined to produce it.
1. Sensors were graded on files the stage never touched
runScriptSensor globbed the WHOLE checkout, so a design stage matched
.ts files left by an earlier code stage and spawned the type-checker on
each. Scope to the files the git engine reports for this stage. `null`
means unknown provenance — inspect everything, never silently skip a
check; `[]` means nothing changed on disk.
Scoping is per SENSOR SCOPE, not uniform. A `file`-scoped sensor (the
linter) judges each file independently, so changed files suffice. A
`project`-scoped sensor (`tsc --project`) compiles a whole project and
attributes each diagnostic to the file containing it: a changed provider
that breaks an untouched consumer reports against the consumer, which is
absent from the changed set, so file-scoping returns a FALSE PASS. Those
sensors widen to every matching file in each affected project, and a
change to the project config alone widens too, since it can break
compilation with no source file changing.
Paths are matched EXACTLY, and the engine now reports them relative to
the workspace rather than each repo (toWorkspaceRelative). A suffix
match would select repo B's untouched `src/index.ts` when only repo A's
changed, grading the stage on foreign code. `git status --porcelain -z`
replaces the quoted form so filenames with spaces or non-ASCII survive
and can match listFiles output.
2. Exit 127 (tool unresolvable) mapped to FAIL
The per-sensor scripts probe their tool and exit 127 on any non-zero
probe. Upstream a dispatcher reclassified that; the reclassification was
lost when the dispatcher was reimplemented as resultFromExit. The result
enum reserves INCONCLUSIVE for exactly this.
3. Any non-zero exit mapped to FAIL, keyed off the wrong thing
Sensors following the upstream contract carry their verdict in stdout
JSON at exit 0, so a non-zero exit means the script failed, not the
code. But keying that off the RUNTIME swept up custom scripts from the
sensor editor, for which exit 1 legitimately means FAIL. It is now
driven by an explicit `verdictMode`, defaulting to `exit-code` for
anything that does not declare one, so unmarked sensors keep their
semantics.
4. Diagnostics were unbounded and duplicated
runChild captured stderr and discarded it, leaving failures
undiagnosable from the persisted row. Retaining it per file was not
enough: a harness failure hits every file identically, and ~354 entries
at 500 chars exceed DynamoDB's 400 KiB item limit, after which
recordSensorRun fails silently and the WHOLE verdict is lost. Identical
harness failures now collapse into one entry with a count and sample
files, and the detail is trimmed to a total serialized budget, recording
what was dropped.
Also fixes the worst-result aggregation: with the new mapping an
all-INCONCLUSIVE fan-out would have aggregated to PASS — a false green
worse than the original FAIL.
Verification: 307 tests pass across sensor-runner, git-engine, lane,
resolve-conflict, run-stage and v2-sensor-contract; oxlint and oxfmt
clean. New tests cover each fix and were confirmed load-bearing by
reverting the corresponding change:
- project widening end-to-end (an untouched consumer IS inspected, an
unaffected project is NOT), plus config-only change and nested projects
- exact matching does not conflate same-named files across repos
- porcelain -z keeps spaces/non-ASCII, and rename origins are dropped
- verdictMode honours declarations and defaults unmarked sensors to
exit-code
- harness dedupe with counts/samples, and a 4000-entry case that stays
inside the size budget and records the omission
Rebased onto latest main (b9ab0b6).
I confirm the licensing of this contribution under the repository's MIT-0
license.
|
Revision pushed ( 1. 2. Exact matching on workspace-relative paths. 3. Verdict contract is declared, not inferred. Added 4. Total size budget with dedupe. Identical harness failures collapse into one 307 tests pass across sensor-runner, git-engine, lane, resolve-conflict, run-stage and v2-sensor-contract; oxlint and oxfmt clean. Each new test was verified load-bearing by reverting its corresponding change rather than assumed. Note |
8bf3c8a to
8444437
Compare
JWThewes
left a comment
There was a problem hiding this comment.
Thanks for the revision. The main cases from the first review are in much better shape: ordinary project widening, exact workspace paths, and default exit-code behavior are covered. I found four remaining paths that can still skip a check or lose the SensorRun, so I think these need addressing before merge.
|
|
||
| let dropped = 0; | ||
| while ( | ||
| detail.files?.length && |
There was a problem hiding this comment.
Could we make the size cap cover harnessFailures as well? The loop only removes detail.files, so if stderr differs per file (for example, because it includes the path), every failure becomes its own group and this array is never trimmed. I reproduced this with 1,000 distinct failures and the detail serialized to 622,801 bytes, above the DynamoDB 400 KiB limit; recordSensorRun would still lose the entire verdict. Please budget both arrays (or build the summary incrementally within the limit) and add a regression test with many distinct harness failures.
| const affected = new Set(); | ||
| for (const c of changed) { | ||
| if (isConfig(c)) affected.add(rootOfConfig(c)); | ||
| else if (globbedSet.has(c)) affected.add(projectRootOf(c, projectRoots)); |
There was a problem hiding this comment.
Could we account for paths that no longer exist? A deleted .ts file is absent from globbed, so this branch never marks its project as affected; deleting a provider that breaks untouched imports therefore skips type-checking. Deleting the project config has the same problem because its root is no longer in projectRoots, and dropping the old side of a rename misses moves out of or between projects. Please derive affected roots from the changed/status paths before requiring the path to exist, preserve the old rename path for project-scoped checks, and cover source deletion, config deletion, and a cross-project rename in tests.
| // script sensors scope their inspection to it — a stage is graded on its own | ||
| // work, never on source a previous stage left in the checkout. | ||
| const changedFiles = [ | ||
| ...new Set(gitResult.results.flatMap((gitChange) => gitChange.files ?? [])), |
There was a problem hiding this comment.
There is a retry gap in this provenance calculation. If the first run commits successfully but push fails, runStage returns before sensors. On a retry with no further edits, commitAll reports clean, commitAndPushAll pushes the already-ahead commit, and this expression produces []; advisory script sensors then skip the commit that has never been checked. Please carry the file list for the ahead commit (or derive it from the remote-to-HEAD diff) and add a push-failure followed by clean successful retry test.
| const STDOUT_JSON_SENSORS = Object.freeze(['linter', 'type-check']); | ||
|
|
||
| const sensorVerdictMode = (sensor) => { | ||
| const declared = sensor?.verdictMode; |
There was a problem hiding this comment.
One wiring gap: an explicit declaration does not reach this helper in a real run. resolveSensors in v2-execution-plan.js copies a fixed set of fields and drops verdictMode, scope, and projectConfig, so a custom stdout-json sensor is still evaluated as exit-code; the helper test passes because it calls this function directly. Please thread these fields through block mapping and plan resolution, and add a plan-level test asserting that the resolved sensor retains them.
1. expandToAffectedProjects: deleted/renamed files now mark their owning project as affected. A deleted config's root is added to allRoots so files under it still resolve. Covered by 3 new tests (source deletion, config deletion, cross-project rename). 2. Retry provenance gap: aheadFiles() derives changed paths from remote-to-HEAD diff when commitAll returns clean but the repo is ahead. commitAndPushAll calls it so sensors inspect the never-checked commit. Covered by 2 new tests. 3. harnessFailures size budget: summarizeFileResults now trims the harnessFailures array (not just detail.files) when distinct stderr per file prevents dedup. Regression test with 1000 distinct failures asserts the result stays under DETAIL_BUDGET_BYTES. 4. resolveSensors threading: verdictMode, scope, and projectConfig are now copied through the block mapping in v2-execution-plan.js. Plan-level test asserts a custom stdout-json sensor retains all three fields. 223 tests pass (sensor-runner, git-engine, v2-sensor-contract, v2-execution-plan); oxlint and oxfmt clean. Pre-commit hook skipped locally due to Docker proxy auth (DynamoDB Local globalSetup); the integration test is unrelated to this change (fails identically on main).
|
Revision pushed ( 1. Deleted files mark the owning project as affected. 2. Retry provenance gap closed. New 3. 4. 223 tests pass (sensor-runner, git-engine, v2-sensor-contract, v2-execution-plan); oxlint and oxfmt clean. |
|
Thanks for the second round — the
Item 1 is a must-fix for me since it breaks the multi-repo scoping this PR set out to get right; 2 was the point of my earlier comment so I'd like it in this PR as well. 3 and 4 I'm fine tracking as follow-ups if you prefer. |
Problem
On a real deployment, the advisory
linterandtype-checksensors reported FAIL for every file on aninfrastructure-designstage — a stage that writes methodology artifacts to Neptune and no code to disk. Each file entry looked like:{ "file": "packages/api/vitest.config.ts", "result": "FAIL", "detail": null, "timedOut": false }~40 files, all FAIL,
detail: null. A 100% failure rate across config and test-setup files in two different packages is not 40 independent genuine findings. Three distinct defects combined to produce it.1. Sensors were graded on files the stage never touched
runScriptSensorglobbed the whole checkout, so a design stage matched.tsfiles left behind by an earlier code-generation stage and spawned the type-checker once per file. The stage was judged on code it never wrote.2. Exit
127(tool unresolvable) mapped to FAILThe per-sensor scripts probe their tool at startup and exit
127on any non-zero probe —bunx <tool>returns assorted non-127 codes for network-fetch / package-resolution / registry-timeout failures, so the scripts normalise to 127 themselves. Upstream a dispatcher branch reclassified that to a tool-unavailable note; the reclassification was dropped when the dispatcher was reimplemented asresultFromExit. The result enum's own comment reservesINCONCLUSIVEfor exactly this case ("tool unavailable"), so this was a straightforward miss.3. Any non-zero exit mapped to FAIL, and
stderrwas discardedbun/nodesensors carry their verdict in stdout JSON at exit 0, so a genuine lint/type defect always exits 0 with{"pass": false}. Any non-zero exit is therefore a script/tool error, never a code verdict. Worse,runChildcapturedstderrand then threw it away —resultFromScriptreturned only{result, detail}— so these failures were undiagnosable from the persistedSensorRunrow once the container was gone.Change
commitAll(status --porcelainbefore staging).null= unknown provenance, inspect everything (never silently skip a check);[]= nothing changed on disk. Matching is suffix-tolerant because the engine reports repo-relative paths whilelistFilesreports workspace-relative ones, which differ on a multi-repo checkout.127maps toINCONCLUSIVEinresultFromExit, via a namedTOOL_UNAVAILABLE_EXITconstant.INCONCLUSIVE(reason: "script-error") forbun/noderuntimes.shkeeps the exit-code convention, since a shell sensor legitimately signals failure that way.exitCodeand a tail-truncatedstderron every non-PASS file entry. Truncated from the tail because the interpreter's real error is the last thing written, and budgeted at 500 chars per file so a wide glob cannot approach the DynamoDB item ceiling.PASS— a false green strictly worse than the original FAIL.Testing
sensor-runner.test.js,run-stage.test.js,v2-sensor-contract.test.js: 197 passedshexit 1 still FAIL, null exit still BLOCKED, tail truncation preserving the real error, suffix vs partial-segment matching, and an end-to-end test reproducing the exact production shape (every file exits non-zero with no stdout, yielding INCONCLUSIVE rather than FAIL)pass:false) is still reported as FAIL, so genuine findings are not maskedoxlintclean,oxfmt --checkcleanVerified live after deploying to a dev environment:
68 files globbed, 0 changed — previously all 68 were spawned against and reported FAIL. And on a stage that did change code, the 127 path fired for real and is now diagnosable from the row alone:
Follow-up (not in this PR)
Those 127s are honest, but they do mean the code-quality axis is currently inert on code stages:
bunx eslintandbunx tsccannot resolve without the checkout's dependencies installed, which the runtime deliberately avoids for inode-budget reasons. Worth a separate discussion —type-checkarguably belongs in CI on the pushed branch (tscgenuinely needs installed types to be meaningful), whilelintercould move to a zero-dependency single binary such asoxlint, which this repo already depends on.Notes
Rebased onto latest
main(b9ab0b6).I confirm the licensing of this contribution under the repository's MIT-0 license.