feat: add machine-readable output to gate CLI and action (#252) (#313)

* feat: add machine-readable output to gate CLI and action

* fix: harden threshold integer parsing, ensure action pass default, and document schema

* fix: harden gate JSON action contract

* fix: map action output setup failures

* fix: normalize unexpected gate exits

---------

Co-authored-by: Conor Bronsdon <conorbronsdon@users.noreply.github.com>
This commit is contained in:
Emirhan Karaca
2026-09-14 19:06:35 +03:00
committed by GitHub
parent c6d03591d8
commit 8901f04735
6 changed files with 252 additions and 18 deletions
+4
View File
@@ -6,6 +6,10 @@ All notable changes to this project are documented here.
## [Unreleased]
### Added
- Add machine-readable `--json` output to `avoid-ai-writing-gate` and expose `pass`, `total-findings`, and `failed-files` step outputs in the GitHub Action (#252).
### Fixed
- Preserve non-tracking query parameters when removing AI-referrer parameters from URLs during rewrite validation (#210).
+39 -1
View File
@@ -400,7 +400,8 @@ recalibration work such as #70.
# .github/workflows/prose.yml
steps:
- uses: actions/checkout@v7
- uses: conorbronsdon/avoid-ai-writing@main
- id: gate
uses: conorbronsdon/avoid-ai-writing@main
with:
glob: "**/*.md"
threshold: "6"
@@ -410,6 +411,43 @@ steps:
For long-lived production workflows, pin `uses:` to a release tag or commit SHA
that contains `action.yml`.
The Action exposes step outputs via `$GITHUB_OUTPUT`:
- `pass`: `'true'` when all scanned files are within threshold; `'false'` on a threshold failure or operational error.
- `total-findings`: total count of deterministic findings across scanned files; unset on an operational error (exit 2).
- `failed-files`: count of files exceeding the threshold; unset on an operational error (exit 2).
Downstream steps can consume these outputs:
```yaml
- name: Report gate summary
if: always() && steps.gate.outputs.total-findings != ''
run: |
echo "Pass: ${{ steps.gate.outputs.pass }}"
echo "Total findings: ${{ steps.gate.outputs.total-findings }}"
echo "Failed files: ${{ steps.gate.outputs.failed-files }}"
```
The underlying `avoid-ai-writing-gate` CLI also accepts `--json` to emit structured JSON on stdout:
```json
{
"schemaVersion": 1,
"threshold": 6,
"context": "technical",
"sourceMode": "rendered-markdown",
"pass": false,
"totalFindings": 9,
"failedFiles": 1,
"files": [
{ "path": "README.md", "findings": 2, "pass": true, "types": ["em-dash", "tier1"] },
{ "path": "docs/guide.md", "findings": 7, "pass": false, "types": ["hedge-stack", "tier1", "tier2"] }
]
}
```
Top-level fields report `schemaVersion`, `threshold`, `context`, `sourceMode`, `pass` (boolean), `totalFindings`, `failedFiles`, and `files` (preserving scan order). Each file item reports `path`, `findings`, `pass`, and sorted distinct detector `types`. When no files match the input or glob, `files` is empty with `pass: true`.
`threshold` is the maximum number of deterministic findings allowed in **each**
file. The shipped default is **6**, chosen from the current human-control corpus
using the same `technical` + `rendered-markdown` settings as the Action. Across
+71 -7
View File
@@ -18,22 +18,86 @@ inputs:
description: Detector source mode (plain or rendered-markdown).
required: false
default: rendered-markdown
outputs:
pass:
description: "Whether all scanned files passed within the threshold (true/false); false on an operational error."
value: ${{ steps.gate.outputs.pass }}
total-findings:
description: "Total count of deterministic findings across all scanned files; unset when the scan exits with an operational error."
value: ${{ steps.gate.outputs.total-findings }}
failed-files:
description: "Count of files that exceeded the threshold; unset when the scan exits with an operational error."
value: ${{ steps.gate.outputs.failed-files }}
runs:
using: composite
steps:
- name: Gate prose with avoid-ai-writing detector
- id: gate
name: Gate prose with avoid-ai-writing detector
shell: bash
env:
AAW_GLOB: ${{ inputs.glob }}
AAW_THRESHOLD: ${{ inputs.threshold }}
AAW_CONTEXT: ${{ inputs.context }}
AAW_SOURCE_MODE: ${{ inputs.source-mode }}
run: >-
node "$GITHUB_ACTION_PATH/bin/avoid-ai-writing-gate.js"
--glob "$AAW_GLOB"
--threshold "$AAW_THRESHOLD"
--context "$AAW_CONTEXT"
--source-mode "$AAW_SOURCE_MODE"
run: |
if [ -n "$GITHUB_OUTPUT" ]; then
if ! echo "pass=false" >> "$GITHUB_OUTPUT"; then
echo "avoid-ai-writing-gate: could not initialize action outputs" >&2
exit 2
fi
fi
TEMP_ROOT="${RUNNER_TEMP:-${TMPDIR:-/tmp}}"
TMP_JSON="$(mktemp "$TEMP_ROOT/aaw-gate.XXXXXX")" || {
echo "avoid-ai-writing-gate: could not create a temporary output file" >&2
exit 2
}
trap 'rm -f "$TMP_JSON"' EXIT
set +e
node "$GITHUB_ACTION_PATH/bin/avoid-ai-writing-gate.js" \
--glob "$AAW_GLOB" \
--threshold "$AAW_THRESHOLD" \
--context "$AAW_CONTEXT" \
--source-mode "$AAW_SOURCE_MODE" \
--json > "$TMP_JSON"
EXIT_CODE=$?
set -e
if [ "$EXIT_CODE" -ne 0 ] && [ "$EXIT_CODE" -ne 1 ] && [ "$EXIT_CODE" -ne 2 ]; then
echo "avoid-ai-writing-gate: gate process exited unexpectedly with status $EXIT_CODE" >&2
EXIT_CODE=2
fi
if [ "$EXIT_CODE" -eq 0 ] || [ "$EXIT_CODE" -eq 1 ]; then
set +e
node -e '
const fs = require("fs");
const raw = fs.readFileSync(process.argv[1], "utf8");
const data = JSON.parse(raw);
const githubOutput = process.env.GITHUB_OUTPUT;
if (githubOutput) {
fs.appendFileSync(githubOutput, `pass=${data.pass}\n`);
fs.appendFileSync(githubOutput, `total-findings=${data.totalFindings}\n`);
fs.appendFileSync(githubOutput, `failed-files=${data.failedFiles}\n`);
}
if (data.files.length === 0) {
process.stdout.write("avoid-ai-writing-gate: no matching files; nothing to scan\n");
} else {
for (const f of data.files) {
const label = f.pass ? "PASS" : "FAIL";
const typeSummary = f.types.length ? ` [${f.types.join(", ")}]` : "";
process.stdout.write(`${label} ${f.path} — ${f.findings} finding(s), threshold ${data.threshold}${typeSummary}\n`);
}
}
' "$TMP_JSON"
PARSE_EXIT=$?
set -e
if [ "$PARSE_EXIT" -ne 0 ]; then
echo "avoid-ai-writing-gate: could not parse gate JSON output" >&2
EXIT_CODE=2
fi
fi
exit "$EXIT_CODE"
branding:
icon: edit-3
color: purple
+52 -7
View File
@@ -17,6 +17,7 @@ Options:
--threshold <count> Maximum findings per file (default: 6)
--context <general|technical|marketing|personal> Detector context (default: technical)
--source-mode <plain|rendered-markdown> Source mode (default: rendered-markdown)
--json Emit machine-readable JSON on stdout
-h, --help Show this help
Examples:
@@ -28,13 +29,14 @@ const CONTEXTS = ["general", "technical", "marketing", "personal"];
const SOURCE_MODES = ["plain", "rendered-markdown"];
function parseArgs(argv) {
const options = { help: false, glob: null, threshold: 6, context: "technical", sourceMode: "rendered-markdown", files: [] };
const options = { help: false, json: false, glob: null, threshold: 6, context: "technical", sourceMode: "rendered-markdown", files: [] };
let endOfOptions = false;
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (endOfOptions) { options.files.push(arg); continue; }
if (arg === "--") { endOfOptions = true; continue; }
if (arg === "-h" || arg === "--help") { options.help = true; continue; }
if (arg === "--json") { options.json = true; continue; }
if (["--glob", "--threshold", "--context", "--source-mode"].includes(arg)) {
const value = argv[i + 1];
if (value === undefined) return { error: `${arg} requires a value` };
@@ -42,7 +44,9 @@ function parseArgs(argv) {
if (arg === "--glob") options.glob = value;
if (arg === "--threshold") {
if (!/^\d+$/.test(value)) return { error: `invalid --threshold value: ${value}` };
options.threshold = Number(value);
const num = Number(value);
if (!Number.isSafeInteger(num) || num < 0) return { error: `invalid --threshold value: ${value}` };
options.threshold = num;
}
if (arg === "--context") {
if (!CONTEXTS.includes(value)) return { error: `invalid --context value: ${value}` };
@@ -91,8 +95,28 @@ function main(argv) {
files.push(...expanded.files);
}
files = [...new Set(files.map((file) => path.normalize(file)))];
if (files.length === 0) { process.stdout.write("avoid-ai-writing-gate: no matching files; nothing to scan\n"); return 0; }
if (files.length === 0) {
if (parsed.json) {
const emptyPayload = {
schemaVersion: 1,
threshold: parsed.threshold,
context: parsed.context,
sourceMode: parsed.sourceMode,
pass: true,
totalFindings: 0,
failedFiles: 0,
files: []
};
process.stdout.write(JSON.stringify(emptyPayload, null, 2) + "\n");
return 0;
}
process.stdout.write("avoid-ai-writing-gate: no matching files; nothing to scan\n");
return 0;
}
let failed = false;
let totalFindings = 0;
let failedFiles = 0;
const fileEntries = [];
for (const file of files) {
const input = readUtf8(file);
if (input.error) { process.stderr.write(`avoid-ai-writing-gate: ${input.error}\n`); return 2; }
@@ -108,10 +132,31 @@ function main(argv) {
const count = result.issues.length;
const types = [...new Set(result.issues.map((issue) => issue.type))].sort();
const over = count > parsed.threshold;
if (over) failed = true;
const label = over ? "FAIL" : "PASS";
const typeSummary = types.length ? ` [${types.join(", ")}]` : "";
process.stdout.write(`${label} ${file}${count} finding(s), threshold ${parsed.threshold}${typeSummary}\n`);
if (over) {
failed = true;
failedFiles += 1;
}
totalFindings += count;
const outputPath = file.split(path.sep).join("/");
fileEntries.push({ path: outputPath, findings: count, pass: !over, types });
if (!parsed.json) {
const label = over ? "FAIL" : "PASS";
const typeSummary = types.length ? ` [${types.join(", ")}]` : "";
process.stdout.write(`${label} ${file}${count} finding(s), threshold ${parsed.threshold}${typeSummary}\n`);
}
}
if (parsed.json) {
const payload = {
schemaVersion: 1,
threshold: parsed.threshold,
context: parsed.context,
sourceMode: parsed.sourceMode,
pass: !failed,
totalFindings,
failedFiles,
files: fileEntries
};
process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
}
return failed ? 1 : 0;
}
+80
View File
@@ -40,6 +40,14 @@ const badThreshold = run(["--threshold", "1.5", flagged]);
assert.strictEqual(badThreshold.status, 2);
assert.match(badThreshold.stderr, /invalid --threshold/);
const unsafeThreshold = run(["--threshold", "999999999999999999999999999999", flagged]);
assert.strictEqual(unsafeThreshold.status, 2);
assert.match(unsafeThreshold.stderr, /invalid --threshold/);
const infinityThreshold = run(["--threshold", "1".repeat(400), flagged]);
assert.strictEqual(infinityThreshold.status, 2);
assert.match(infinityThreshold.stderr, /invalid --threshold/);
const noInput = run([]);
assert.strictEqual(noInput.status, 2);
assert.match(noInput.stderr, /provide at least one file or --glob/);
@@ -97,5 +105,77 @@ assert.strictEqual(hookSource.status, 2);
assert.match(hookSource.stderr, /source-mode/);
assert.doesNotMatch(hookSource.stderr, /ENOENT/);
// Machine-readable --json output tests (#252)
const jsonPassing = run(["--json", clean]);
assert.strictEqual(jsonPassing.status, 0, jsonPassing.stderr);
assert.strictEqual(jsonPassing.stderr, "");
const passData = JSON.parse(jsonPassing.stdout);
assert.strictEqual(passData.schemaVersion, 1);
assert.strictEqual(passData.threshold, 6);
assert.strictEqual(passData.context, "technical");
assert.strictEqual(passData.sourceMode, "rendered-markdown");
assert.strictEqual(passData.pass, true);
assert.strictEqual(passData.totalFindings, 0);
assert.strictEqual(passData.failedFiles, 0);
assert.strictEqual(passData.files.length, 1);
assert.strictEqual(passData.files[0].path, clean.split(path.sep).join("/"));
assert.strictEqual(passData.files[0].findings, 0);
assert.strictEqual(passData.files[0].pass, true);
assert.deepStrictEqual(passData.files[0].types, []);
const jsonFailing = run(["--threshold", "0", "--json", flagged]);
assert.strictEqual(jsonFailing.status, 1, jsonFailing.stderr);
assert.strictEqual(jsonFailing.stderr, "");
const failData = JSON.parse(jsonFailing.stdout);
assert.strictEqual(failData.schemaVersion, 1);
assert.strictEqual(failData.threshold, 0);
assert.strictEqual(failData.pass, false);
assert.ok(failData.totalFindings > 0);
assert.strictEqual(failData.failedFiles, 1);
assert.strictEqual(failData.files.length, 1);
assert.strictEqual(failData.files[0].path, flagged.split(path.sep).join("/"));
assert.strictEqual(failData.files[0].pass, false);
assert.strictEqual(failData.files[0].findings, failData.totalFindings);
assert.ok(failData.files[0].types.length > 0);
const jsonMixed = run(["--threshold", "0", "--json", clean, flagged]);
assert.strictEqual(jsonMixed.status, 1, jsonMixed.stderr);
assert.strictEqual(jsonMixed.stderr, "");
const mixedData = JSON.parse(jsonMixed.stdout);
assert.strictEqual(mixedData.schemaVersion, 1);
assert.strictEqual(mixedData.pass, false);
assert.strictEqual(mixedData.failedFiles, 1);
assert.strictEqual(mixedData.totalFindings, failData.totalFindings);
assert.strictEqual(mixedData.files.length, 2);
const cleanEntry = mixedData.files.find((f) => f.path === clean.split(path.sep).join("/"));
const flaggedEntry = mixedData.files.find((f) => f.path === flagged.split(path.sep).join("/"));
assert.ok(cleanEntry && cleanEntry.pass && cleanEntry.findings === 0);
assert.ok(flaggedEntry && !flaggedEntry.pass && flaggedEntry.findings > 0);
const jsonEmpty = run(["--glob", "nonexistent/**/*.md", "--json"], gitRepo);
assert.strictEqual(jsonEmpty.status, 0, jsonEmpty.stderr);
assert.strictEqual(jsonEmpty.stderr, "");
const emptyData = JSON.parse(jsonEmpty.stdout);
assert.strictEqual(emptyData.schemaVersion, 1);
assert.strictEqual(emptyData.pass, true);
assert.strictEqual(emptyData.totalFindings, 0);
assert.strictEqual(emptyData.failedFiles, 0);
assert.deepStrictEqual(emptyData.files, []);
const jsonCustom = run(["--threshold", "2", "--context", "general", "--source-mode", "plain", "--json", clean]);
assert.strictEqual(jsonCustom.status, 0, jsonCustom.stderr);
const customData = JSON.parse(jsonCustom.stdout);
assert.strictEqual(customData.threshold, 2);
assert.strictEqual(customData.context, "general");
assert.strictEqual(customData.sourceMode, "plain");
const action = fs.readFileSync(path.join(__dirname, "../action.yml"), "utf8");
assert.match(action, /trap 'rm -f "\$TMP_JSON"' EXIT/);
assert.match(action, /EXIT_CODE=2/);
assert.match(action, /could not create a temporary output file/);
assert.match(action, /could not initialize action outputs/);
assert.match(action, /gate process exited unexpectedly with status \$EXIT_CODE/);
assert.match(action, /unset when the scan exits with an operational error/g);
fs.rmSync(tmp, { recursive: true, force: true });
console.log("avoid-ai-writing gate cli: ok");
+6 -3
View File
@@ -46,6 +46,7 @@ file rather than the composite score:
avoid-ai-writing-gate --glob "**/*.md" --context technical
avoid-ai-writing-gate --threshold 0 docs/strict-policy.md
avoid-ai-writing-gate --threshold 2 docs/guide.md README.md
avoid-ai-writing-gate --json --glob "**/*.md"
```
Exit codes:
@@ -54,10 +55,12 @@ Exit codes:
- `1`: at least one file exceeds the threshold;
- `2`: usage, glob-expansion, file-read, UTF-8, or unscannable-input error (including documents above the detector's 10,000-word limit).
The `--json` flag formats scan results as structured JSON on standard output with `schemaVersion`, per-file entries (`path`, `findings`, `pass`, `types`), and aggregates (`pass`, `totalFindings`, `failedFiles`).
The GitHub Action in `action.yml` exposes `glob`, `threshold`, `context`,
and `source-mode` inputs. The CLI, Action, and shipped pre-commit hook default
to **6 findings per file** with `technical` context and `rendered-markdown`
source mode.
and `source-mode` inputs, and outputs `pass`, `total-findings`, and `failed-files`.
The CLI, Action, and shipped pre-commit hook default to **6 findings per file**
with `technical` context and `rendered-markdown` source mode.
That default is measured rather than guessed. On the current 376-document human
control corpus under those exact settings, threshold 0 rejects 31.4% of human