You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
PR 3 — Binary skeleton: cmd/audit/ Go module embedding gojq
Note on the split. This plan describes the full PR 3 scope. To keep the reviewable surface manageable, the implementation ships as two chained PRs:
PR 3a (this PR) — substrate parsers (front-matter, manifest.toml, catalog envelope), discovery, .audit/ cache, extractor subprocess runner, and the extract + status subcommands. audit query is stubbed to print "lands in the follow-up PR". gojq is not yet a dependency.
PR 3b (follow-up) — embedded gojq engine, audit query (with the slurpfile / catalog-input wiring per the table below), and the end-to-end integration test exercising extract → query. Adds gojq to go.mod and wires cmd/audit/main.go's query case to the new handler.
Acceptance criteria below cover the full scope; PR 3a delivers everything except the engine and the query handler.
Stand up the audit binary in Go. Three subcommands ship in this PR: extract, query, status. report lands in PR 4; init lands in PR 4. The binary does not yet have a goreleaser pipeline — that is also PR 4.
What this PR delivers, end-to-end, after go build -o audit ./cmd/audit:
audit extract typescript --root <path> [--shared <path>] [--touched <path>] — runs every [[command]] block in extractors/typescript/manifest.toml as subprocesses, materializes catalogs into .audit/catalogs/<output_file>, updates .audit/meta.json.
audit query <name> [--arg/--argjson/--env knobs] [--format text|jsonl] — resolves the named query via lookup-order discovery, parses its front-matter to know which catalog(s) to feed, evaluates with embedded gojq, prints to stdout.
audit status — prints what is cached under .audit/, the resolved source paths for queries and extractors, source-mtime staleness vs each catalog, and cwd-vs-meta.json.root mismatch warnings.
Naked jq and direct extractor invocations remain unaffected. .jq files and manifest.toml files are not edited in this PR.
ADR alignment
ADR
Decision PR 3 implements
0001 (amended by 0007)
Per-repo .audit/ is the canonical cache. meta.json carries audit_version, last_touched_at, root, catalogs[<kind>] (with path, source_sha, envelope_summary, cli_args).
0002
Two parsers: #! key: value front-matter on .jq files; TOML manifest.toml on extractor dirs.
0003
The binary does not invent shapes — it reads queries' #! shape: for audit status listing and routes JSONL evaluation through gojq unchanged. The shape-typed markdown renderer is PR 4's responsibility; the JSONL envelope itself flows through this PR transparently.
0004
Router: binary owns dispatch, .audit/, manifest parsing, lookup-order discovery. Extractors are os/exec subprocesses per manifest's invocation. Queries are evaluated by embedded gojq.
0005
gojq is embedded. The front-matter parser recognizes #! engine: jq (no current query uses it; the verification step confirmed parity); when present, the binary os/execs system jq for that one query.
0006
Queries are embedded via //go:embed. Extractors are not embedded — they require host-language runtimes (npm, swift). Discovery is lookup-order: explicit flag → $AUDIT_HOME → cwd-relative → embedded (queries only) / ~/.config/audit/ (extractors; PR 4's init populates this, PR 3 only reads from it).
0007
meta.json carries envelope_summary cached from each catalog file's #141 envelope; the catalog file is authoritative and re-read on every audit status / audit query to refresh the cache when it differs.
Module path: github.com/jakebromberg/code-audit-pipeline. (Confirmed against repo origin URL.)
Dependencies (pinned):
github.com/itchyny/gojq v0.12.19 — already the verified version from PR step 3.
github.com/BurntSushi/toml v1.4.0 — stdlib-grade TOML parser. No reflection magic beyond what we exercise on manifest.toml.
No cobra, no urfave/cli. Subcommand dispatch is hand-rolled in cmd/audit/main.go over flag.NewFlagSet — three subcommands don't justify a framework, and the dispatch logic stays under 80 lines.
Subcommand contracts
audit extract <extractor>
Flags:
Flag
Type
Meaning
--root <path>
string, required
Audited repo root.
--shared <path>
string, optional
Secondary package root; passed to extractor when manifest's optional_args declares a --shared activation.
--touched <path>
string, optional
JSON file with touched-in-window file list. Same condition.
--include-tests
bool, optional
Flag-only activation.
--extractors-dir <path>
string, optional
Override the extractors directory; bypasses lookup order.
--catalog <kind>
string, optional, repeatable
Limit to specific [[command]] block(s) within the extractor manifest. Default: run all.
Behavior:
Resolve extractors/<name>/manifest.toml via lookup-order discovery (see Discovery section).
Parse the manifest.
For each [[command]] block (filtered by --catalog if provided):
Substitute placeholders ({root}, {output}, {shared}, {touched}, {extensions}, {min_body_lines}, {references_output}, {files_output}) into the invocation token list.
Apply optional_args whose when conditions match (shared_set, touched_set, include_tests_set, references_enabled, files_enabled, min_body_lines_set, extensions_set).
os/exec the resulting argv with cwd = the extractor directory. Stream stderr through; capture stdout to the temp file <.audit/catalogs/<output_file>>.tmp. Atomic rename on success.
After each successful subprocess, read the produced catalog's envelope head (top JSON object, entries not yet streamed), compute envelope_summary, compute the file's sha256.
Update .audit/meta.json (read, merge, write) with one catalogs[<kind>] entry per produced catalog plus any sibling outputs (references-graph, files) the manifest declares.
Manage .gitignore: append .audit/ if not present.
Exit code: non-zero if any subprocess exits non-zero, or if the produced catalog fails to parse, or if the placeholder substitution leaves an unresolved {...} token.
audit query <name>
Flags:
Flag
Type
Meaning
--arg NAME VALUE
repeatable
Passed to gojq as a string binding. Validated against front-matter arg: declarations.
--argjson NAME VALUE
repeatable
Passed to gojq as a JSON-parsed value. Validated similarly.
--env NAME=VALUE
repeatable
Sets os.Environ for gojq's $ENV.NAME lookups, layered over the inherited environment.
--format text|jsonl
optional, default text
Maps to OUTPUT_FORMAT env var seen by the query.
--queries-dir <path>
optional
Override; bypasses lookup order.
--catalog <path>
repeatable, optional
Explicit catalog input override. Order matches the front-matter catalog: list. Useful for CI driving the binary against a catalog that isn't under .audit/.
Behavior:
Resolve the query file via discovery (see Discovery section).
Parse its front-matter.
Validate --arg / --argjson against the front-matter arg: lines. Required args missing → exit 2 with audit query --help <name>-style error. Type mismatches (e.g., --arg X "abc" where front-matter says X number) → exit 2.
Front-matter catalog: A, B, C → positional is A.json; trailing kinds are mounted as --slurpfile-equivalent variables. The slurpfile variable name is the kind's underscore-form: references-graph → $refs, type-catalog → $types, function-catalog → $functions. Exception: cross-catalog-name-collisions (catalog: type-catalog, type-catalog) takes left/right slurpfiles bound as $left / $right and uses null input (-n-equivalent); the binary recognizes the two-of-same-kind case and switches to that wiring. The mapping is in internal/engine/engine.go's catalog-to-variable table; PR 3 covers the seven distinct catalog declarations produced by PR 2's front-matter (verified by grep '^#! catalog:' pipeline/queries/*.jq | sort -u):
Declaration
Positional input
Slurpfile bindings
Null input?
type-catalog
type-catalog.json
—
no
function-catalog
function-catalog.json
—
no
file-hashes
file-hashes.json
—
no
files
files.json
—
no
type-catalog, references-graph
type-catalog.json
$refs ← references.json
no
function-catalog, type-catalog
function-catalog.json
$types ← type-catalog.json
no
type-catalog, type-catalog
(none)
$left, $right ← user-supplied
yes
The last row is the cross-catalog-name-collisions case. Both slurpfiles are user-supplied (via --catalog <path> overrides) because the audit cache holds only one type-catalog at a time; cross-catalog comparison is between two separate audit roots and the user names both. Any new query that adds a novel combination requires a one-line addition to this table.
--catalog overrides apply in declaration order.
Apply --env to the process env for the duration of the query evaluation.
If front-matter declares #! engine: jq, shell out to system jq with the constructed argv. Otherwise, use embedded gojq.
Stream output to stdout.
Exit code: 0 on success; 1 if the query errors at evaluation time; 2 on argument validation failure; 3 if the required catalog inputs aren't present under .audit/ and --catalog wasn't given.
audit status
No flags beyond --root <path> (defaults to cwd).
Output: human-readable, one section per concern:
Audit root: /Users/jake/Developer/wxyc-ios-64
Cache: .audit/ (initialized 2026-05-30T14:22:11Z, audit-version 0.1.0)
Queries source: embedded (use --queries-dir to override; or set AUDIT_HOME)
Extractors source: ./extractors/ (cwd-relative)
Catalogs cached:
type-catalog 2026-05-30T14:22:11Z schema=1.1 extractor=typescript@0.5.0 3 files newer than catalog
function-catalog 2026-05-30T14:22:11Z schema=1.1 extractor=typescript@0.5.0 ok
references-graph 2026-05-30T14:22:11Z schema=1.1 (sibling of type-catalog) ok
Warnings:
type-catalog: 3 source files modified since extraction. Run `audit extract typescript` to refresh.
Exit code: 0 if all-clean; 1 if any catalog is stale or the cwd doesn't match meta.json.root.
Grammar: lines starting with #! (hash, bang, space) up to the first non-#!-prefixed non-blank line. Other lines (regular jq comments, query body) are not consumed. Each #! line is #! <key>: <value>. Whitespace around the colon is stripped. The value is taken verbatim from the first non-space character after the colon to end-of-line.
Recognized keys (per PR 2's grammar, plus engine from ADR-0005):
Key
Cardinality
Parsed form
query
1
string
shape
1, comma-sep
one or two of cluster|pair|metric
catalog
1, comma-sep
one or more catalog-kind strings
arg
0..N
triplet: <name> <type> <default-or-"required">; type ∈ number|string|json
env
0..N
triplet: <NAME> <type> <default>; type as above, default is empty string when value is ""
formats
1, comma-sep
subset of text|jsonl
desc
1
string
version
0..1
integer; absent means 1 (PR-3 only recognizes grammar version 1)
engine
0..1
jq (only legal value; absent means embedded gojq)
Unrecognized keys are an error in PR 3, not a warning. (Future grammar growth bumps version; the parser rejects unknown keys at the current version to surface typos.) When version is absent, the parser uses 1; when version: 2 (or higher) appears, the parser errors with "front-matter version N not supported by this audit-version" so old binaries fail loudly against newer queries rather than silently mis-interpreting.
Validation:
query, shape, catalog, formats, desc all required.
shape value(s) must come from the legal set.
arg/env triplets must have exactly 3 whitespace-separated tokens.
query must be unique across the discovered query corpus (validated at the discovery layer when listing queries; the parser itself just records the value).
Test fixtures live in internal/frontmatter/testdata/ — three valid headers (one of each shape) plus negative cases (missing required key, bad arg triplet, unknown key, double query:).
Every command's invocation must contain {output} (so the binary knows where to redirect). {root} is recommended but not required (file-hashes uses --root; future extractors may use a different convention).
Every optional_args[].when and sibling_outputs[].when must come from a recognized set (shared_set, touched_set, include_tests_set, references_enabled, files_enabled, min_body_lines_set, extensions_set). Unknown values are an error — PR 3 closes the set by exhausting the three manifests in tree.
Test fixtures live in internal/manifest/testdata/.
Output sink: writer for raw strings (-r equivalent).
Engine selection: embedded (default) or system (when front-matter declares engine: jq).
Embedded path implementation:
Parse query source into *gojq.Query via gojq.Parse.
Compile with gojq.WithModuleLoader(gojq.NewModuleLoader([]string{libDir})).
For slurpfiles: read file, parse JSON into interface{}, wrap as [value] (matching jq's slurpfile semantics).
Iterate with the appropriate input: positional catalog file, or null when the front-matter shape mandates -n-style invocation.
-r semantics: when the iterator yields a string, write it raw (no JSON-encoding) plus newline. Non-strings emit gojq's default JSON encoding.
System-jq path implementation:
Build argv: jq -L <libDir> -rf <queryPath> plus --slurpfile NAME path per binding, plus --argjson / --arg, plus positional catalog file or -n.
Inherit env, overlaying any --env overrides.
Pipe stdout/stderr through; preserve exit code.
The two paths share a small driver that builds the variable-binding plan from the front-matter; the only divergence is "how do we apply this plan to gojq's Code/Run versus jq's argv?" This keeps the engine-switch behavior auditable.
.audit/ cache (internal/auditdir)
Surface:
Open(root string) (*Cache, error) — reads meta.json if present; bootstraps an empty one if not. Auto-appends .audit/ to .gitignore on first write.
Source-mtime. For each catalog, walk the audit root (skipping the default skip-list documented in docs/pipeline-contract.md: .git, node_modules, dist, build, coverage, dotdirs) and count files whose mtime exceeds envelope_summary.generated_at. Report the count; ≥1 is "stale" (warning, exit 1 from audit status).
Root mismatch. Compare cwd's nearest enclosing .audit/'s meta.json.root against the actual absolute path. Hard mismatch → exit non-zero from audit status and audit query (the silent-wrong-answer case ADR-0001 closes).
package main
import"embed"//go:generate go run ./internal/genqueries -src ../../pipeline/queries -dst ./queries//go:embed queries/*.jq//go:embed queries/_canonical.jqvarembeddedQueries embed.FS
The embed lives in cmd/audit/ (package main) because //go:embed patterns must be in a subtree of the embedding file's directory and cannot use .. — so pipeline/queries/ cannot be referenced directly from a Go file that lives under internal/ or cmd/audit/. The canonical source remains pipeline/queries/; cmd/audit/queries/ is a build-artifact copy.
internal/genqueries/main.go is a tiny Go program (~30 lines) that:
Removes cmd/audit/queries/ if it exists.
Copies every *.jq file (including _canonical.jq) from the configured -src directory into -dst.
Refuses to operate if -src is missing or empty.
Why a Go program rather than cp / rsync: portable across platforms (no cp -R quirks; tested by go test on every CI platform), no shell dependency, no .bat for Windows.
cmd/audit/queries/ is added to .gitignore. go generate ./... is run as the first step in CI and is a documented one-time setup for local development. Whether the symlink-or-generate approach is right vs. relocating to a top-level package is the most likely review point — flagged.
A small helper exposes the embedded FS plus a query-name lister (basename minus .jq, excluding _canonical.jq since it's a library, not a runnable query).
Tests
Two layers.
Unit (per-package)
internal/frontmatter: parse every PR-2-shipped header pattern (testdata/headers/*.jq mirroring real production headers); negative cases listed above. Runs in <50ms.
internal/manifest: parse all three in-tree manifest.toml files (re-used via testdata symlinks); negative cases for missing fields, unknown when values, bad schema_version. Runs in <50ms.
internal/discovery: resolve queries+extractors under each of the four lookup precedence levels using t.TempDir() setups. Runs in <100ms.
internal/engine: a tiny synthetic catalog (testdata/tiny-type-catalog.json, 5 entries) plus three queries pulled from the embedded FS (exact-duplicates, migration-progress, cross-catalog-name-collisions — covers the three shapes and the slurpfile/null-input paths). Verifies byte-equality against the same query run through system jq on the same fixtures (the same parity check PR step 3 validated, now in Go's test runner). Runs in <500ms.
internal/auditdir: read-modify-write meta.json; staleness check using t.TempDir with controlled mtimes. Runs in <100ms.
internal/extractor: substitute placeholders against an in-memory manifest; check argv equality. No actual subprocess (mocked via interface). Runs in <50ms.
internal/catalog: parse an envelope head; compute envelope_summary. Runs in <50ms.
Integration (cmd/audit_test.go)
Spins up t.TempDir() as a fake repo containing:
extractors/typescript/ symlinked in from the real tree (so the real manifest.toml and type-catalog.mjs drive the test — same approach the existing integration tests use).
A minimal src/ with three .ts files (~30 lines total) that produce a deterministic type-catalog.
The node_modules/ install is gated behind an env var (AUDIT_INTEGRATION=1) so CI runs it but local-dev go test skips it.
Asserts:
audit extract typescript --root <tmp>/src produces <tmp>/.audit/catalogs/type-catalog.json with non-empty entries.
audit extract updates meta.json correctly (catalogs entry shape per the schema above).
audit query exact-duplicates (no clusters expected on the trivial fixture) prints nothing in JSONL mode, exits 0.
audit query migration-progress --arg old_sig <s1> --arg new_sig <s2> --arg label test prints a parseable percent line.
audit query <name> --catalog <abs/path/to/catalog.json> works even without .audit/ cache.
audit status reports the catalogs correctly.
The full integration suite (the existing test_queries_integration.sh running 27 runnable queries × 2 modes — pipeline/queries/ contains 28 .jq files, but _canonical.jq is a shared library, not a runnable query) is not Go-test-runtime; it stays as a separate shellcheck/CI step and is unaffected by this PR. PR 3 does not duplicate that coverage in Go.
CI integration
New job appended to .github/workflows/ci.yml:
go:
name: go binaryruns-on: ubuntu-lateststeps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5with:
go-version: '1.23'cache: true
- name: Generate embedded queriesrun: go generate ./...
- name: Go vetrun: go vet ./...
- name: Go testrun: go test ./...
- name: Go buildrun: go build -o /tmp/audit ./cmd/audit
- name: Smoke testenv:
AUDIT_INTEGRATION: "1"run: | cd extractors/typescript && npm ci && cd - go test -tags integration ./cmd/audit/...
Existing shellcheck, pipeline, swift jobs unchanged.
Out of scope for #177 entirely; see ADR-0007 "Future integration."
Acceptance criteria
go build -o audit ./cmd/audit produces a binary on Linux and macOS.
go test ./... passes with -race.
audit extract typescript --root <real repo> populates .audit/catalogs/type-catalog.json whose byte content matches the catalog produced by running node type-catalog.mjs --root <same> directly (same extractor, same wiring, same output).
audit query <name> for each of the 27 queries produces byte-identical output to the equivalent naked-jq invocation against the same catalog, in both text and jsonl modes, for the case-study TypeScript catalog. (The verification step proved this for the engine; PR 3 proves the wiring.)
audit status warns when source files have been modified since extraction and exits non-zero on hard cwd-vs-root mismatch.
.audit/ is auto-added to .gitignore on first extract; idempotent on subsequent runs.
CI's new go job passes on first push.
PR delta is under 1000 lines (Go + tests + CI yaml + plan doc).
Risk notes
go generate ordering. Forgetting to run go generate ./... before go build produces a binary with stale or empty embedded queries — silently. Mitigations: CI runs go generate as step 1; the embedded helper's tests assert a known query is reachable so missing-files surfaces as a test failure rather than a runtime mystery; the README will document the go generate step alongside npm install for the TypeScript extractor.
gojq goroutine cancellation.gojq.Code.RunWithContext honors ctx.Done(), but iterator drain after cancellation is the caller's responsibility. The engine wrapper consumes the iterator in a tight loop with a select{ctx, iter} switch so SIGINT exits cleanly.
TOML re-parsing on each invocation. Three manifests, each <50 lines — measurably free. No caching layer needed in PR 3.
engine: jq shell-out path is untested at integration level. No query currently declares it. PR 3 includes a unit test that exercises the shell-out code path against a synthetic query file, so the branch isn't dead code.
PR 3 — Binary skeleton: cmd/audit/ Go module embedding gojq
Parent
Tracker: #177 (Audit binary implementation). Previous steps: PR 1 (#180, cluster envelope, merged), PR 2 (#185, front-matter + manifest.toml, merged), gojq compatibility verification (#188, merged — confirmed all 27 queries × 2 modes parity-clean, no
#! engine: jqopt-outs needed at this snapshot).Source ADRs: 0001 (
.audit/cache directory, amended by 0007), 0002 (hybrid registration), 0003 (cluster envelope), 0004 (router architecture), 0005 (Go + gojq), 0006 (bundling + discovery), 0007 (reconciliation with snapshot family).Scope
Stand up the
auditbinary in Go. Three subcommands ship in this PR:extract,query,status.reportlands in PR 4;initlands in PR 4. The binary does not yet have a goreleaser pipeline — that is also PR 4.What this PR delivers, end-to-end, after
go build -o audit ./cmd/audit:audit extract typescript --root <path> [--shared <path>] [--touched <path>]— runs every[[command]]block inextractors/typescript/manifest.tomlas subprocesses, materializes catalogs into.audit/catalogs/<output_file>, updates.audit/meta.json.audit query <name> [--arg/--argjson/--env knobs] [--format text|jsonl]— resolves the named query via lookup-order discovery, parses its front-matter to know which catalog(s) to feed, evaluates with embeddedgojq, prints to stdout.audit status— prints what is cached under.audit/, the resolved source paths for queries and extractors, source-mtime staleness vs each catalog, and cwd-vs-meta.json.rootmismatch warnings.Naked
jqand direct extractor invocations remain unaffected..jqfiles andmanifest.tomlfiles are not edited in this PR.ADR alignment
.audit/is the canonical cache.meta.jsoncarriesaudit_version,last_touched_at,root,catalogs[<kind>](withpath,source_sha,envelope_summary,cli_args).#! key: valuefront-matter on.jqfiles; TOMLmanifest.tomlon extractor dirs.#! shape:foraudit statuslisting and routes JSONL evaluation through gojq unchanged. The shape-typed markdown renderer is PR 4's responsibility; the JSONL envelope itself flows through this PR transparently..audit/, manifest parsing, lookup-order discovery. Extractors areos/execsubprocesses per manifest'sinvocation. Queries are evaluated by embeddedgojq.#! engine: jq(no current query uses it; the verification step confirmed parity); when present, the binaryos/execs systemjqfor that one query.//go:embed. Extractors are not embedded — they require host-language runtimes (npm, swift). Discovery is lookup-order: explicit flag →$AUDIT_HOME→ cwd-relative → embedded (queries only) /~/.config/audit/(extractors; PR 4'sinitpopulates this, PR 3 only reads from it).meta.jsoncarriesenvelope_summarycached from each catalog file's #141 envelope; the catalog file is authoritative and re-read on everyaudit status/audit queryto refresh the cache when it differs.Go module layout
Module path:
github.com/jakebromberg/code-audit-pipeline. (Confirmed against repo origin URL.)Dependencies (pinned):
github.com/itchyny/gojq v0.12.19— already the verified version from PR step 3.github.com/BurntSushi/toml v1.4.0— stdlib-grade TOML parser. No reflection magic beyond what we exercise onmanifest.toml.No cobra, no urfave/cli. Subcommand dispatch is hand-rolled in
cmd/audit/main.gooverflag.NewFlagSet— three subcommands don't justify a framework, and the dispatch logic stays under 80 lines.Subcommand contracts
audit extract <extractor>Flags:
--root <path>--shared <path>optional_argsdeclares a--sharedactivation.--touched <path>--include-tests--extractors-dir <path>--catalog <kind>[[command]]block(s) within the extractor manifest. Default: run all.Behavior:
extractors/<name>/manifest.tomlvia lookup-order discovery (see Discovery section).[[command]]block (filtered by--catalogif provided):{root},{output},{shared},{touched},{extensions},{min_body_lines},{references_output},{files_output}) into theinvocationtoken list.optional_argswhosewhenconditions match (shared_set,touched_set,include_tests_set,references_enabled,files_enabled,min_body_lines_set,extensions_set).os/execthe resulting argv with cwd = the extractor directory. Stream stderr through; capture stdout to the temp file<.audit/catalogs/<output_file>>.tmp. Atomic rename on success.entriesnot yet streamed), computeenvelope_summary, compute the file's sha256..audit/meta.json(read, merge, write) with onecatalogs[<kind>]entry per produced catalog plus any sibling outputs (references-graph,files) the manifest declares..gitignore: append.audit/if not present.Exit code: non-zero if any subprocess exits non-zero, or if the produced catalog fails to parse, or if the placeholder substitution leaves an unresolved
{...}token.audit query <name>Flags:
--arg NAME VALUEarg:declarations.--argjson NAME VALUE--env NAME=VALUEos.Environfor gojq's$ENV.NAMElookups, layered over the inherited environment.--format text|jsonlOUTPUT_FORMATenv var seen by the query.--queries-dir <path>--catalog <path>catalog:list. Useful for CI driving the binary against a catalog that isn't under.audit/.Behavior:
--arg/--argjsonagainst the front-matterarg:lines. Required args missing → exit 2 withaudit query --help <name>-style error. Type mismatches (e.g.,--arg X "abc"where front-matter saysX number) → exit 2.Front-matter
catalog: A→ positional input is.audit/catalogs/A.json(or--catalogoverride V5 substrate: function-body extractor + duplicates query #1).Front-matter
catalog: A, B, C→ positional isA.json; trailing kinds are mounted as--slurpfile-equivalent variables. The slurpfile variable name is the kind's underscore-form:references-graph→$refs,type-catalog→$types,function-catalog→$functions. Exception:cross-catalog-name-collisions(catalog: type-catalog, type-catalog) takes left/right slurpfiles bound as$left/$rightand uses null input (-n-equivalent); the binary recognizes the two-of-same-kind case and switches to that wiring. The mapping is ininternal/engine/engine.go's catalog-to-variable table; PR 3 covers the seven distinct catalog declarations produced by PR 2's front-matter (verified bygrep '^#! catalog:' pipeline/queries/*.jq | sort -u):type-catalogfunction-catalogfile-hashesfilestype-catalog, references-graph$refs← references.jsonfunction-catalog, type-catalog$types← type-catalog.jsontype-catalog, type-catalog$left,$right← user-suppliedThe last row is the
cross-catalog-name-collisionscase. Both slurpfiles are user-supplied (via--catalog <path>overrides) because the audit cache holds only one type-catalog at a time; cross-catalog comparison is between two separate audit roots and the user names both. Any new query that adds a novel combination requires a one-line addition to this table.--catalogoverrides apply in declaration order.--envto the process env for the duration of the query evaluation.#! engine: jq, shell out to systemjqwith the constructed argv. Otherwise, use embeddedgojq.Exit code: 0 on success; 1 if the query errors at evaluation time; 2 on argument validation failure; 3 if the required catalog inputs aren't present under
.audit/and--catalogwasn't given.audit statusNo flags beyond
--root <path>(defaults to cwd).Output: human-readable, one section per concern:
Exit code: 0 if all-clean; 1 if any catalog is stale or the cwd doesn't match
meta.json.root.Front-matter parser (
internal/frontmatter)Surface:
Parse(reader io.Reader) (*Header, error).Grammar: lines starting with
#!(hash, bang, space) up to the first non-#!-prefixed non-blank line. Other lines (regular jq comments, query body) are not consumed. Each#!line is#! <key>: <value>. Whitespace around the colon is stripped. The value is taken verbatim from the first non-space character after the colon to end-of-line.Recognized keys (per PR 2's grammar, plus
enginefrom ADR-0005):queryshapecluster|pair|metriccatalogarg<name> <type> <default-or-"required">; type ∈number|string|jsonenv<NAME> <type> <default>; type as above, default is empty string when value is""formatstext|jsonldescversionenginejq(only legal value; absent means embedded gojq)Unrecognized keys are an error in PR 3, not a warning. (Future grammar growth bumps
version; the parser rejects unknown keys at the current version to surface typos.) Whenversionis absent, the parser uses 1; whenversion: 2(or higher) appears, the parser errors with "front-matter version N not supported by this audit-version" so old binaries fail loudly against newer queries rather than silently mis-interpreting.Validation:
query,shape,catalog,formats,descall required.shapevalue(s) must come from the legal set.arg/envtriplets must have exactly 3 whitespace-separated tokens.querymust be unique across the discovered query corpus (validated at the discovery layer when listing queries; the parser itself just records the value).Test fixtures live in
internal/frontmatter/testdata/— three valid headers (one of each shape) plus negative cases (missing required key, bad arg triplet, unknown key, doublequery:).Manifest parser (
internal/manifest)Surface:
Parse(path string) (*Manifest, error).Schema mapped to Go structs:
Validation:
schema_version == 1. Future bumps gated explicitly.extractor.name,extractor.versionrequired.[[command]].invocationmust contain{output}(so the binary knows where to redirect).{root}is recommended but not required (file-hashes uses--root; future extractors may use a different convention).optional_args[].whenandsibling_outputs[].whenmust come from a recognized set (shared_set,touched_set,include_tests_set,references_enabled,files_enabled,min_body_lines_set,extensions_set). Unknown values are an error — PR 3 closes the set by exhausting the three manifests in tree.Test fixtures live in
internal/manifest/testdata/.Discovery (
internal/discovery)Two surfaces:
ResolveQueriesDir(opts) (source string, fs fs.FS, err error)— returns the directory (orembed.FSslice) holding_canonical.jq+ queries.ResolveExtractorsDir(opts) (string, error)— returns the absolute directory holding per-language extractor subdirectories.Lookup order (per ADR-0006):
--queries-dir/--extractors-dir). Wins absolutely.$AUDIT_HOME/pipeline/queries/and$AUDIT_HOME/extractors/../pipeline/queries/and./extractors/. Picks up local edits when running from a checkout.embed.FS(always present).~/.config/audit/extractors/(populated byaudit init— PR 4). If absent,audit extracterrors with a message pointing ataudit init.Resolution stops at the first hit. The chosen source path is surfaced in
audit statusso the lookup order is never invisible.gojq engine (
internal/engine)Surface:
Run(ctx, opts) error.optscarries:-Llibrary directory (forinclude "_canonical";).argjson/arg/slurpfile/ null-input flag.-requivalent).embedded(default) orsystem(when front-matter declaresengine: jq).Embedded path implementation:
*gojq.Queryviagojq.Parse.gojq.WithModuleLoader(gojq.NewModuleLoader([]string{libDir})).interface{}, wrap as[value](matching jq's slurpfile semantics).nullwhen the front-matter shape mandates-n-style invocation.-rsemantics: when the iterator yields a string, write it raw (no JSON-encoding) plus newline. Non-strings emitgojq's default JSON encoding.System-jq path implementation:
jq -L <libDir> -rf <queryPath>plus--slurpfile NAME pathper binding, plus--argjson/--arg, plus positional catalog file or-n.--envoverrides.The two paths share a small driver that builds the variable-binding plan from the front-matter; the only divergence is "how do we apply this plan to gojq's Code/Run versus jq's argv?" This keeps the engine-switch behavior auditable.
.audit/cache (internal/auditdir)Surface:
Open(root string) (*Cache, error)— readsmeta.jsonif present; bootstraps an empty one if not. Auto-appends.audit/to.gitignoreon first write.(c *Cache) PutCatalog(kind string, args CLIArgs, envelopeHead json.RawMessage, fileSHA string) error(c *Cache) CatalogPath(kind string) (string, ok bool)(c *Cache) Status() ([]CatalogStatus, error)— runs source-mtime staleness check, returns a list.(c *Cache) Save() error— atomic write.meta.jsonschema (per ADR-0001 amended by ADR-0007):{ "audit_version": "0.1.0", "last_touched_at": "2026-05-30T14:22:11Z", "root": "/Users/jake/Developer/wxyc-ios-64", "catalogs": { "type-catalog": { "path": "catalogs/type-catalog.json", "source_sha": "<sha256 of catalogs/type-catalog.json>", "envelope_summary": { "schema_version": "1.1", "extractor": { "name": "type-catalog", "language": "typescript", "version": "0.5.0" }, "fingerprint_v": "<from envelope>", "generated_at": "2026-05-30T14:22:11Z" }, "cli_args": { "root": "/Users/jake/Developer/wxyc-ios-64", "shared": null, "touched": null } } } }Staleness checks:
root(skipping the default skip-list documented indocs/pipeline-contract.md:.git,node_modules,dist,build,coverage, dotdirs) and count files whose mtime exceedsenvelope_summary.generated_at. Report the count; ≥1 is "stale" (warning, exit 1 fromaudit status)..audit/'smeta.json.rootagainst the actual absolute path. Hard mismatch → exit non-zero fromaudit statusandaudit query(the silent-wrong-answer case ADR-0001 closes).Extractor subprocess invocation (
internal/extractor)Surface:
Run(ctx, manifestPath string, cmdSel CommandSelector, args CLIArgs) ([]CatalogResult, error).Token substitution table:
{root}args.Root(required by every manifest in tree).{output}<auditdir>/catalogs/<command.output_file>{shared}args.Shared; activated bywhen = "shared_set".{touched}args.Touched; activated bywhen = "touched_set".{extensions}args.Extensions; activated bywhen = "extensions_set".{min_body_lines}args.MinBodyLines; activated bywhen = "min_body_lines_set".{references_output}<auditdir>/catalogs/references.json; activated bywhen = "references_enabled".{files_output}<auditdir>/catalogs/files.json; activated bywhen = "files_enabled".Substitution leaves no
{...}tokens behind; if any remain (e.g., a future manifest references{unknown}), the binary errors before exec.Subprocess execution:
cwd= the extractor directory (so relative invocations likenode type-catalog.mjsfind the script).AUDIT_VERSIONso extractors can record provenance in their own envelopes if they want.<output>.tmp; on success, atomic rename to<output>. Stderr → streamed through (so users see progress).Embedded queries (
cmd/audit/embed.go)The embed lives in
cmd/audit/(packagemain) because//go:embedpatterns must be in a subtree of the embedding file's directory and cannot use..— sopipeline/queries/cannot be referenced directly from a Go file that lives underinternal/orcmd/audit/. The canonical source remainspipeline/queries/;cmd/audit/queries/is a build-artifact copy.internal/genqueries/main.gois a tiny Go program (~30 lines) that:cmd/audit/queries/if it exists.*.jqfile (including_canonical.jq) from the configured-srcdirectory into-dst.-srcis missing or empty.Why a Go program rather than
cp/rsync: portable across platforms (nocp -Rquirks; tested bygo teston every CI platform), no shell dependency, no.batfor Windows.cmd/audit/queries/is added to.gitignore.go generate ./...is run as the first step in CI and is a documented one-time setup for local development. Whether the symlink-or-generate approach is right vs. relocating to a top-level package is the most likely review point — flagged.A small helper exposes the embedded FS plus a query-name lister (basename minus
.jq, excluding_canonical.jqsince it's a library, not a runnable query).Tests
Two layers.
Unit (per-package)
internal/frontmatter: parse every PR-2-shipped header pattern (testdata/headers/*.jqmirroring real production headers); negative cases listed above. Runs in <50ms.internal/manifest: parse all three in-treemanifest.tomlfiles (re-used viatestdatasymlinks); negative cases for missing fields, unknownwhenvalues, bad schema_version. Runs in <50ms.internal/discovery: resolve queries+extractors under each of the four lookup precedence levels usingt.TempDir()setups. Runs in <100ms.internal/engine: a tiny synthetic catalog (testdata/tiny-type-catalog.json, 5 entries) plus three queries pulled from the embedded FS (exact-duplicates,migration-progress,cross-catalog-name-collisions— covers the three shapes and the slurpfile/null-input paths). Verifies byte-equality against the same query run through systemjqon the same fixtures (the same parity check PR step 3 validated, now in Go's test runner). Runs in <500ms.internal/auditdir: read-modify-writemeta.json; staleness check usingt.TempDirwith controlled mtimes. Runs in <100ms.internal/extractor: substitute placeholders against an in-memory manifest; check argv equality. No actual subprocess (mocked via interface). Runs in <50ms.internal/catalog: parse an envelope head; computeenvelope_summary. Runs in <50ms.Integration (
cmd/audit_test.go)Spins up
t.TempDir()as a fake repo containing:extractors/typescript/symlinked in from the real tree (so the realmanifest.tomlandtype-catalog.mjsdrive the test — same approach the existing integration tests use).src/with three.tsfiles (~30 lines total) that produce a deterministic type-catalog.node_modules/install is gated behind an env var (AUDIT_INTEGRATION=1) so CI runs it but local-devgo testskips it.Asserts:
audit extract typescript --root <tmp>/srcproduces<tmp>/.audit/catalogs/type-catalog.jsonwith non-empty entries.audit extractupdatesmeta.jsoncorrectly (catalogs entry shape per the schema above).audit query exact-duplicates(no clusters expected on the trivial fixture) prints nothing in JSONL mode, exits 0.audit query migration-progress --arg old_sig <s1> --arg new_sig <s2> --arg label testprints a parseable percent line.audit query <name> --catalog <abs/path/to/catalog.json>works even without.audit/cache.audit statusreports the catalogs correctly.The full integration suite (the existing
test_queries_integration.shrunning 27 runnable queries × 2 modes —pipeline/queries/contains 28.jqfiles, but_canonical.jqis a shared library, not a runnable query) is not Go-test-runtime; it stays as a separate shellcheck/CI step and is unaffected by this PR. PR 3 does not duplicate that coverage in Go.CI integration
New job appended to
.github/workflows/ci.yml:Existing
shellcheck,pipeline,swiftjobs unchanged.What's NOT in this PR
audit reportaudit init--engineopt-out queries#! engine: jqso a future query can declare it without a binary change)audit snapshot capture,audit diff)Acceptance criteria
go build -o audit ./cmd/auditproduces a binary on Linux and macOS.go test ./...passes with-race.audit extract typescript --root <real repo>populates.audit/catalogs/type-catalog.jsonwhose byte content matches the catalog produced by runningnode type-catalog.mjs --root <same>directly (same extractor, same wiring, same output).audit query <name>for each of the 27 queries produces byte-identical output to the equivalent naked-jqinvocation against the same catalog, in both text and jsonl modes, for the case-study TypeScript catalog. (The verification step proved this for the engine; PR 3 proves the wiring.)audit statuswarns when source files have been modified since extraction and exits non-zero on hard cwd-vs-root mismatch..audit/is auto-added to.gitignoreon first extract; idempotent on subsequent runs.gojob passes on first push.Risk notes
go generateordering. Forgetting to rungo generate ./...beforego buildproduces a binary with stale or empty embedded queries — silently. Mitigations: CI runsgo generateas step 1; the embedded helper's tests assert a known query is reachable so missing-files surfaces as a test failure rather than a runtime mystery; the README will document thego generatestep alongsidenpm installfor the TypeScript extractor.gojq.Code.RunWithContexthonorsctx.Done(), but iterator drain after cancellation is the caller's responsibility. The engine wrapper consumes the iterator in a tight loop with aselect{ctx, iter}switch so SIGINT exits cleanly.engine: jqshell-out path is untested at integration level. No query currently declares it. PR 3 includes a unit test that exercises the shell-out code path against a synthetic query file, so the branch isn't dead code.