Skip to content

feat(lint): readable multi-line output with context and category icons - #2

Merged
ilanh merged 1 commit into
mainfrom
feat/readable-lint
Nov 2, 2026
Merged

ilanh merged 1 commit into
mainfrom
feat/readable-lint

Conversation

@ilanh

@ilanh ilanh commented Nov 2, 2026 •

Copy link
Copy Markdown
Owner

What

lint now prints a rustc/miette-style report instead of one line per finding: severity + category icons in the header, file:line:col, the surrounding source with the offending call underlined, a copy-pasteable @openshell directive under = help: (with the right comment leader for the language), = note: where useful, findings grouped by file and a summary by severity and category.

Flags

flag meaning
--format human|json|compact human is the new default. compact is the old one-line format, byte for byte. json is unchanged. --json stays as an alias of --format json
--color auto|always|never auto = terminal only; honours NO_COLOR, CLICOLOR_FORCE, TERM=dumb. The flag wins over the environment
--no-icons (alias --ascii) plain ASCII. Icons also switch off by themselves for a non-UTF-8 locale or TERM=dumb

Icons

category icon severity icon
net 🌐 error ✖
fs 📁 warning ⚠
env (secret reads / providers) 🔑 info ℹ
exec ⚙️
directive 📝
policy 📜

Compatibility

  • Breaking: the default human output is different. Scripts, CI and AI agents that parse it should use --format compact (identical to the old output, including the lint: N directive(s); ... line on stderr) or --format json. The AI skill and the docs now say so.
  • JSON shape unchanged (new Finding fields are serde(skip)); exit codes and --deny-warnings are unchanged for every format. The fixtures suite (116 checks) passes unmodified against the new binary.
  • No new dependencies (ANSI and width handling live in src/report.rs), so cargo audit is unaffected.

Before

scripts/deploy.sh:2: error[directive]: usage: egress <host>:<port>[,<port>...] reason="..." [access=read-only|read-write|full] [protocol=rest|...] [allow="METHOD:/path"]... [bin=/path] [rule=name] [enforce=enforce|audit] [tls=skip]
src/server.ts:14: error[directive]: egress requires reason="..." (it becomes the comment in policy.yaml)
scripts/deploy.sh:3: warning[net]: curl/wget without an @openshell directive (suggest: @openshell egress registry.npmjs.org:443 reason="...")
scripts/deploy.sh:3: warning[fs]: output redirection to /opt/out.json, which is outside the sandbox baseline (/tmp) and every declared fs write root (add `@openshell fs write <dir> reason="..."` or `@openshell ignore kind=fs`)
src/server.ts:5: warning[env]: reads secret-like variable GITHUB_TOKEN with no `@openshell provider GITHUB_TOKEN`; if a provider supplies it, declare it; otherwise `@openshell ignore kind=env reason="..."`
src/server.ts:6: warning[net]: fetch() without an @openshell directive (suggest: @openshell egress api.github.com:443 reason="...")
src/server.ts:9: warning[fs]: filesystem write (writeFileSync) to /var/lib/app/state.json, which is outside the sandbox baseline (/tmp) and every declared fs write root (add `@openshell fs write <dir> reason="..."` or `@openshell ignore kind=fs`)
src/worker.py:5: warning[net]: requests without an @openshell directive (no literal URL in the arguments: declare the host with @openshell egress, or @openshell ignore kind=net)
src/worker.py:8: warning[fs]: open() for writing to /srv/cache/blob.bin, which is outside the sandbox baseline (/tmp) and every declared fs write root (add `@openshell fs write <dir> reason="..."` or `@openshell ignore kind=fs`)
src/server.ts:10: info[exec]: subprocess: the spawned program is the binary that opens its own connections; if it talks to the network add `bin=` on its egress, else `@openshell ignore kind=exec reason="..."`
bad-policy.yaml:0: error[policy]: filesystem_policy.read_write: `relative/path` must be an absolute path (OpenShell rejects relative filesystem paths; write the in-sandbox path)
bad-policy.yaml:0: error[policy]: network_policies.api.endpoints[0]: `localhost` is loopback, link-local or unspecified; OpenShell never authorizes these destinations (a sandbox-local service needs no network rule)
bad-policy.yaml:0: warning[policy]: network_policies.api: empty `binaries` matches no executable, so the rule allows nothing
lint: 1 directive(s); 4 error(s), 8 warning(s), 1 info

After

full report (icons, no colour)
📄 bad-policy.yaml  (3 findings)

✖ 📜 error[policy]: `relative/path` must be an absolute path (OpenShell rejects relative filesystem paths; write the in-sandbox path)
 --> bad-policy.yaml
  = note: at `filesystem_policy.read_write`

✖ 📜 error[policy]: `localhost` is loopback, link-local or unspecified; OpenShell never authorizes these destinations (a sandbox-local service needs no network rule)
 --> bad-policy.yaml
  = note: at `network_policies.api.endpoints[0]`

⚠ 📜 warning[policy]: empty `binaries` matches no executable, so the rule allows nothing
 --> bad-policy.yaml
  = note: at `network_policies.api`

📄 scripts/deploy.sh  (3 findings)

✖ 📝 error[directive]: malformed @openshell directive
 --> scripts/deploy.sh:2:3
  |
1 | #!/usr/bin/env bash
2 | # @openshell egress
  |   ^^^^^^^^^^^^^^^^^ does not match the directive syntax
3 | curl -fsSL https://registry.npmjs.org/left-pad > /opt/out.json
  |
  = help: expected syntax: egress <host>:<port>[,<port>...] reason="..." [access=read-only|read-write|full] [protocol=rest|...] [allow="METHOD:/path"]... [bin=/path] [rule=name] [enforce=enforce|audit] [tls=skip]

⚠ 🌐 warning[net]: unannotated network call to registry.npmjs.org:443
 --> scripts/deploy.sh:3:1
  |
1 | #!/usr/bin/env bash
2 | # @openshell egress
3 | curl -fsSL https://registry.npmjs.org/left-pad > /opt/out.json
  | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ curl/wget reaches registry.npmjs.org:443 with no matching @openshell egress
  |
  = help: declare the destination on the line above:
          # @openshell egress registry.npmjs.org:443 reason="..."
  = note: a directive covers calls on its own line and up to 8 lines below it (`scope=file` covers the whole file)

⚠ 📁 warning[fs]: filesystem write to /opt/out.json is outside the sandbox
 --> scripts/deploy.sh:3:48
  |
1 | #!/usr/bin/env bash
2 | # @openshell egress
3 | curl -fsSL https://registry.npmjs.org/left-pad > /opt/out.json
  |                                                ^^^^^^^^^^^^^^^ output redirection
  |
  = help: allow writes to the directory:
          # @openshell fs write /opt reason="..."
          or, if this is not a real write:
          # @openshell ignore kind=fs reason="..."
  = note: the sandbox may write only under /tmp and the declared `fs write` roots

📄 src/server.ts  (5 findings)

⚠ 🔑 warning[env]: secret-like variable GITHUB_TOKEN is read but no provider declares it
 --> src/server.ts:5:17
  |
3 |
4 | export async function sync(userId: string) {
5 |   const token = process.env.GITHUB_TOKEN;
  |                 ^^^^^^^^^^^^^^^^^^^^^^^^ reads GITHUB_TOKEN
6 |   const res = await fetch("https://api.github.com/repos/ilanh/openshell-annotate/issues", {
  |
  = help: if an OpenShell provider supplies it, declare it:
          // @openshell provider GITHUB_TOKEN
          if it is not a credential:
          // @openshell ignore kind=env reason="..."

⚠ 🌐 warning[net]: unannotated network call to api.github.com:443
 --> src/server.ts:6:21
  |
4 | export async function sync(userId: string) {
5 |   const token = process.env.GITHUB_TOKEN;
6 |   const res = await fetch("https://api.github.com/repos/ilanh/openshell-annotate/issues", {
  |                     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ fetch() reaches api.github.com:443 with no matching @openshell egress
7 |     headers: { Authorization: `Bearer ${token}` },
  |
  = help: declare the destination on the line above:
          // @openshell egress api.github.com:443 reason="..."
  = note: a directive covers calls on its own line and up to 8 lines below it (`scope=file` covers the whole file)

⚠ 📁 warning[fs]: filesystem write to /var/lib/app/state.json is outside the sandbox
  --> src/server.ts:9:3
   |
 7 |     headers: { Authorization: `Bearer ${token}` },
 8 |   });
 9 |   writeFileSync("/var/lib/app/state.json", JSON.stringify(await res.json()));
   |   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ filesystem write (writeFileSync)
10 |   spawn("git", ["fetch", "origin"]);
   |
   = help: allow writes to the directory:
           // @openshell fs write /var/lib/app reason="..."
           or, if this is not a real write:
           // @openshell ignore kind=fs reason="..."
   = note: the sandbox may write only under /tmp and the declared `fs write` roots

ℹ ⚙️ info[exec]: subprocess runs a program the policy does not describe
  --> src/server.ts:10:3
   |
 8 |   });
 9 |   writeFileSync("/var/lib/app/state.json", JSON.stringify(await res.json()));
10 |   spawn("git", ["fetch", "origin"]);
   |   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ subprocess
11 |   return userId;
   |
   = help: if the program opens its own connections, add `bin=<path>` to its egress directive; otherwise:
           // @openshell ignore kind=exec reason="..."
   = note: OpenShell matches network rules on the spawned binary, not on this script

✖ 📝 error[directive]: egress requires reason="..." (it becomes the comment in policy.yaml)
  --> src/server.ts:14:4
   |
12 | }
13 |
14 | // @openshell egress api.stripe.com:443
   |    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
15 | export async function charge() {
   |

📄 src/worker.py  (2 findings)

⚠ 🌐 warning[net]: network call with no literal destination
 --> src/worker.py:5:12
  |
3 | def pull(url):
4 |     # destination is only known at runtime
5 |     return requests.get(url, timeout=5)
  |            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ requests: destination is not a string literal
6 |
  |
  = help: declare the host this call connects to, on the line above:
          # @openshell egress <host>:443 reason="..."
          or, if it is not real egress:
          # @openshell ignore kind=net reason="..."
  = note: a directive covers calls on its own line and up to 8 lines below it (`scope=file` covers the whole file)

⚠ 📁 warning[fs]: filesystem write to /srv/cache/blob.bin is outside the sandbox
 --> src/worker.py:8:10
  |
6 |
7 | def save(blob):
8 |     with open("/srv/cache/blob.bin", "wb") as f:
  |          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ open() for writing
9 |         f.write(blob)
  |
  = help: allow writes to the directory:
          # @openshell fs write /srv/cache reason="..."
          or, if this is not a real write:
          # @openshell ignore kind=fs reason="..."
  = note: the sandbox may write only under /tmp and the declared `fs write` roots

✖ 4 errors · ⚠ 8 warnings · ℹ 1 info  (13 findings in 4 files; 1 directive)
by category: 📝 directive 2 · 🔑 env 1 · ⚙️ exec 1 · 📁 fs 3 · 🌐 net 3 · 📜 policy 3

Tests

  • 8 new tests: exact plain and coloured snapshots (tests/golden/, UPDATE_GOLDEN=1 rewrites them), icon and ASCII fallback matrix, colour flag/env matrix, JSON/compact stability and exit codes across formats, windowing of long lines, tabs and multibyte text, policy findings, a usage-message case, plus 4 unit tests in report.rs.
  • Existing lint tests now call --format compact.
  • TOOLCHAIN=1.94 scripts/ci-local.sh passes: fmt, clippy -D warnings, tests, docs check.

The default lint output is now a rustc-style report: a header per finding with
severity and category icons, the location (file:line:col), the surrounding
source lines with the offending call underlined, and a copy-pasteable
@openshell directive under "= help:". Findings are grouped by file and end
with a summary by severity and category.

- new flags: --format human|json|compact, --color auto|always|never,
  --no-icons (alias --ascii). --json stays as an alias of --format json.
- compact reproduces the old one-line format byte for byte, json is unchanged
  (new Finding fields are not serialized); exit codes and --deny-warnings are
  independent of the format.
- colour honours NO_COLOR and CLICOLOR_FORCE; icons fall back to ASCII for
  non-UTF-8 locales and TERM=dumb.
- ast sites now carry a column span; no new dependencies.
- README, developer guide and the AI skill document the formats (agents should
  parse --format compact); generated CLI docs refreshed.

Signed-off-by: Ilan Harel <[email protected]>
@ilanh
ilanh merged commit b81f0b9 into main Nov 2, 2026
2 checks passed
@ilanh
ilanh deleted the feat/readable-lint branch November 2, 2026 08:01
ilanh added a commit that referenced this pull request Nov 2, 2026
## What

`scan`, `diff-logs` and the `generate` summary get the same readable
treatment as `lint` (PR #2): multi-line grouped output, `file:line` with
a source snippet, category icons, counts and colour.

- **scan**: directives grouped by file, each with its icon (🌐 net, 📁 fs,
🔑 env, ⚙️ exec, 📝 directive), reason, `--> file:line` and the source
line. Footer: `✔ 8 directives in 4 files` and counts by category.
- **diff-logs**: sections by action (missing directives, binary not
covered, L7 rules too narrow, manual review, 🧹 stale, declined, not
actionable). Each fix is previewed as an inserted `+` line above the
line `--write` would use (with the file's own comment leader), plus `=
why:` / `= where:` / `= add:`. Footer counts suggestions and names the
follow-up `--write` command.
- **generate**: the policy YAML on stdout / `-o FILE` is byte-identical
for every format. The readable summary goes to **stderr**: rules with
endpoints, access, binaries and the `file:line` they came from,
filesystem roots, providers (and unmapped), process, wrapped warnings.

## Flags (shared with lint)

`--format human|json|compact` (default `human`), `--color
auto|always|never` (honours `NO_COLOR`, `CLICOLOR_FORCE`, `TERM=dumb`;
for `generate` the colour decision follows stderr), `--no-icons` /
`--ascii` (also automatic for non-UTF-8 locales).

- `--format compact` is the previous output, byte for byte (checked
against the old binary for scan and diff-logs, and for generate's
stderr).
- `--json` stays an alias for `--format json` (conflicts with
`--format`). `generate --format json` prints a JSON summary on stderr.
- Exit codes do not depend on the format.

## Other

- `src/render.rs` (new) holds the three renderers; `report.rs` only
exposes a few helpers.
- Docs: README section with samples, `docs/CLI.md` regenerated,
`AGENTS.md` note; the AI skill now uses `--format compact` for `scan`
and `diff-logs` (parsing).
- Separate commit: fixes the clippy `needless_late_init` hit by the
newer stable toolchain in `src/directive.rs` (pre-existing on main).

## Checks

- `TOOLCHAIN=1.94 scripts/ci-local.sh` passes (fmt, clippy -D warnings,
46 integration tests + unit + docs tests, docs check); `cargo +stable
clippy --all-targets -- -D warnings` clean.
- New tests: plain and colour snapshots (`tests/golden`) for scan,
diff-logs and generate summary; byte-identical policy for every format
and with `-o`; compact/json stability; colour and ASCII flag/env matrix
for all three commands.
- Fixtures repo (no sandbox): 116 pass / 0 fail, identical to the run
with the previous binary.

---------

Signed-off-by: Ilan Harel <[email protected]>
Co-authored-by: Ilan Harel <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant