mirror of
https://github.com/conorbronsdon/avoid-ai-writing.git
synced 2026-09-19 01:32:11 +08:00
* 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:
@@ -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).
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user