Skip to content

feat(validate): add -o, --output <file> for diagnostics output - #101

Merged
char0n merged 8 commits into
mainfrom
frantuma/issue-94
Jul 21, 2026
Merged

feat(validate): add -o, --output <file> for diagnostics output#101
char0n merged 8 commits into
mainfrom
frantuma/issue-94

Conversation

@frantuma

Copy link
Copy Markdown
Member

Summary

Adds a -o, --output <file> option to speclynx validate that writes the selected formatter's output to a file instead of stdout. Implements #94.

speclynx validate openapi.yaml --format json -o report.json

The option mirrors the overlay command's existing -o, --output, so the flag is consistent across commands. The file is created/overwritten; write errors surface on stderr with a non-zero exit; and the validation exit code is otherwise unchanged. File content is byte-identical to stdout (formatter output plus a trailing newline).

Changes

  • src/commands/validate/index.ts — adds .option('-o, --output <file>', 'write diagnostics to file instead of stdout'), grouped with the other output flags.
  • src/commands/validate/action.ts — adds output?: string to ValidateActionOptions and branches the render step: fs.writeFileSync(path.resolve(opts.output), rendered, 'utf-8') when -o is given, else process.stdout.write(rendered). No new imports (fs/path already present). Exit-code semantics (process.exitCode, computed from the full diagnostic set), the sort-once/cap-once logic, and the finally { service.terminate() } are all preserved — -o never changes the exit code, and a failed write is caught by the existing handler that reports Error: … on stderr with exit 1.
  • test/commands/validate/index.ts — a --output describe block with three tests: writing a stylish report to a file (empty stdout, exit 0), writing JSON diagnostics for an invalid doc (empty stdout, non-zero exit preserved, file parses to a non-empty array), and a write-error path (unwritable path → non-zero exit + Error: on stderr).
  • README.md — documents the option in the validate options table, the output paragraph, and a new example.
  • package-lock.json — incidental reconciliation with the Babel 8 dependency set on this branch (fills in the missing @emnapi/core/@emnapi/runtime optional-peer entries so npm ci is back in sync). No production dependencies changed.

Alignment with CLI Agent Experience guidelines (#95)

Reviewed against the Agent-Friendly CLI Checklist and related resources linked from #95. This change advances several checklist items and introduces no regressions (each behavior verified against the built binary):

  • Composability / stream discipline — with -o, stdout stays empty and the report goes only to the file; errors go to stderr. stdout remains reserved for composable data.
  • Machine-readable output--format json -o report.json writes a clean, parseable diagnostics array (the JSON path carries no ANSI escapes regardless of color settings), which is the machine-readable report-file use case validate: add -o, --output <file> to write diagnostics to a file #94 called for.
  • Actionable errors and exit codes — no "error text with exit 0": success writes the file and exits 0; an unwritable path exits 1 with the error on stderr; the validation exit code is unaffected by the output destination.
  • Predictable, consistent flags-o, --output <file> matches overlay's option verbatim, satisfying the "consistent flag names across commands" rule.
  • Discoverability--help lists the option with a clear description, and the README documents it with a copy-paste example.
  • Safe side effects — writing is idempotent/overwrite-safe (re-running produces a byte-identical file) and non-destructive, so no confirmation prompt is needed.

One out-of-scope observation surfaced during review, tracked for #95 rather than this PR: the stylish formatter emits ANSI color when FORCE_COLOR=1 is set even to a non-TTY destination (pre-existing chalk behavior affecting piped stdout identically; default and NO_COLOR runs write clean files, and the JSON path is always clean). A destination-aware color fix belongs with the broader CLI-AX work in #95.

Verification

  • npm run typescript:check-types, npm run lint — clean.
  • npm test — full suite passes (71 passing), including the three new --output tests.
  • Manual: valid doc -o (exit 0, empty stdout, file has "No problems found"); invalid doc --format json -o (exit 1, empty stdout, file is a non-empty array); unwritable path (exit 1, Error: on stderr); both -o and --output spellings work; --help lists the option.

Closes #94
Refs #95

@frantuma

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9442ac0a67

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/commands/validate/action.ts Outdated
Comment thread src/commands/validate/action.ts Outdated
frantuma added a commit that referenced this pull request Jul 20, 2026
Address Codex review of #101:

- Refuse `-o/--output` when it resolves to the input file. Previously
  `validate spec.yaml -o spec.yaml` silently overwrote the API document
  with the diagnostics report (and could exit 0), destroying it. Checked
  up front, before the heavy apidom-ls import, so it fails fast.
- Disable ANSI color when writing to a file. chalk keys color off
  process.stdout, so a stylish report written to a file from a TTY
  contained escape sequences. The formatter now takes an optional
  `color` flag; the action passes `color: false` for file output, so
  file reports are always plain text regardless of TTY/FORCE_COLOR.

Adds tests for both (input preserved on rejection; no ESC bytes in the
file even under FORCE_COLOR) and documents the behavior in the README.
frantuma added 2 commits July 20, 2026 15:30
Add a `-o, --output <file>` option to `speclynx validate` that writes the
selected formatter's output to a file instead of stdout, mirroring the
`overlay` command's `-o, --output`. The file is created/overwritten;
write errors surface on stderr with a non-zero exit, and the validation
exit code is otherwise unchanged. File content is byte-identical to
stdout (formatter output plus a trailing newline).

Also reconcile package-lock.json with the Babel 8 dependency set on this
branch (fills in the missing @emnapi/core and @emnapi/runtime optional
peer entries so `npm ci` is back in sync).

Closes #94
Address Codex review of #101:

- Refuse `-o/--output` when it resolves to the input file. Previously
  `validate spec.yaml -o spec.yaml` silently overwrote the API document
  with the diagnostics report (and could exit 0), destroying it. Checked
  up front, before the heavy apidom-ls import, so it fails fast.
- Disable ANSI color when writing to a file. chalk keys color off
  process.stdout, so a stylish report written to a file from a TTY
  contained escape sequences. The formatter now takes an optional
  `color` flag; the action passes `color: false` for file output, so
  file reports are always plain text regardless of TTY/FORCE_COLOR.

Adds tests for both (input preserved on rejection; no ESC bytes in the
file even under FORCE_COLOR) and documents the behavior in the README.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds file output support to speclynx validate, including plain-text formatting, safeguards, tests, and documentation.

Changes:

  • Adds -o, --output <file> for diagnostic reports.
  • Disables ANSI colors for file output.
  • Adds output behavior tests and documentation.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/commands/validate/index.ts Registers the output option.
src/commands/validate/action.ts Writes reports to files and protects input paths.
src/commands/validate/formatters/types.ts Adds formatter color control.
src/commands/validate/formatters/stylish.ts Disables colors when requested.
test/commands/validate/index.ts Tests output files, errors, and colors.
README.md Documents file output usage.
package-lock.json Reconciles optional Babel dependencies.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread test/commands/validate/index.ts Outdated
Comment thread src/commands/validate/action.ts Outdated
@char0n

char0n commented Jul 20, 2026

Copy link
Copy Markdown
Member

Review findings

The feature design is right, but commit c3f03cd ("guard --output against input overwrite and ANSI leak") introduces two build-breaking defects and one logic bug. As-is the branch fails tsc — I ran tsc --noEmit against c3f03cd to confirm each item below.

🔴 Blocking

1. src/commands/validate/action.ts:123 references an undefined variable resolvedPath.
The overwrite guard reads:

if (opts.output && path.resolve(opts.output) === resolvedPath) {

No resolvedPath is declared in the file — the resolved input is fileURI (line 86). Compilation fails:

src/commands/validate/action.ts(123,52): error TS2304: Cannot find name 'resolvedPath'.

This contradicts the "typescript:check-types clean" claim in the description.

2. test/commands/validate/index.ts:4-9 — duplicate fs and os imports.
The commit added import fs / import os at the top without removing the pre-existing ones, so each is imported twice. This is a hard SyntaxError: Identifier 'os' has already been declared under both Babel (test compilation) and native Node ESM, plus:

test/commands/validate/index.ts(4,8): error TS2300: Duplicate identifier 'fs'.
test/commands/validate/index.ts(5,8): error TS2300: Duplicate identifier 'os'.
test/commands/validate/index.ts(7,8): error TS2300: Duplicate identifier 'os'.
test/commands/validate/index.ts(9,8): error TS2300: Duplicate identifier 'fs'.

The whole validate test file cannot build, so the "71 passing" result can't hold on this branch. Fix: drop the duplicate import lines.

🟠 Logic bug (survives fixing #1)

3. The overwrite guard never triggers, even with the right variable name.
It compares a filesystem path against a URI:

path.resolve(opts.output) === fileURI   // "/home/u/spec.yaml" === "file:///home/u/spec.yaml"

fileURI is always a file:///… (or http(s)://…) URL while path.resolve(opts.output) is a bare path — they can never be equal, so validate spec.yaml -o spec.yaml would still overwrite the input, the exact scenario the commit claims to prevent. Compare path-to-path instead, e.g. path.resolve(opts.output) === path.resolve(source) (guarding the http/https case where there is no local input to clobber), or fileURLToPath(fileURI) when the scheme is file. Worth re-checking the new "refuse to overwrite" test once #1/#2 build — it likely passes for the wrong reason.

💡 Suggestion: simplify the color handling

The FormatterContext.color flag + new Chalk({ level: 0 }) refactor threads color-intent through types.ts, stylish.ts, and severityToString's signature — and that refactor is where both typos above crept in. The same result (plain text on disk) can be achieved by stripping ANSI at the single write site, leaving the formatter oblivious to its destination:

const payload = opts.output ? stripAnsi(rendered) : rendered;
fs.writeFileSync(path.resolve(opts.output), payload, 'utf-8');

This collapses the whole color half of the commit into one line, removes the stylish.ts changes entirely, and eliminates one of the two compile failures. (strip-ansi is a tiny dep, or a small regex if you'd rather add none.)

✅ Looks good

  • Flag naming -o, --output <file> matches overlay verbatim — good consistency.
  • Exit-code semantics, lazy imports, and finally { service.terminate() } are correctly preserved by the -o branch.
  • README updates are accurate.

Verdict: request changes — #1 and #2 must be fixed before merge, and #3 makes the headline safety feature a no-op.

Address review of the rebased branch (char0n, Copilot):

- Fix the overwrite guard, broken by the rebase onto the URL-input work
  (#102). It referenced an undefined `resolvedPath` (build failure) and,
  once named, compared a filesystem path against a file:// URI so it
  never matched. Now compares path-to-path via fileURLToPath(fileURI),
  and skips http(s) inputs where there is no local file to clobber.
- Remove duplicate `fs` import in the validate test left by the rebase
  (was a duplicate-identifier build error).
- Simplify color handling: instead of threading a `color` flag through
  FormatterContext/stylish/severityToString, strip ANSI at the single
  file write site. Formatters are oblivious to their destination again;
  stylish.ts and types.ts revert to their pre-refactor form.

tsc, eslint, and the validate suite (incl. the overwrite and no-ANSI
file tests) pass.
@frantuma

Copy link
Copy Markdown
Member Author

Thanks @char0n — all three defects and the suggestion are addressed in 0c72cbc. The two build breaks came from the manual rebase onto #102, not the original commit.

🔴 #1 — undefined resolvedPath: fixed. #102 renamed the resolved input to fileURI; the guard now uses it.

🔴 #2 — duplicate fs/os imports: fixed. The rebase merged two import blocks; removed the duplicate fs so each builtin is imported once.

🟠 #3 — guard was a no-op (path vs URI): fixed, and you were right to flag the test. The guard now compares path-to-path, path.resolve(opts.output) === fileURLToPath(fileURI), and skips http/https inputs (no local file to clobber). On the "refuse to overwrite" test: it does not pass for the wrong reason — it asserts the exact must differ from the input file string, which only the guard emits, plus that the input is preserved byte-for-byte. With the old no-op guard that assertion would have failed (validation would have run instead), so the test correctly gates the behavior; I re-verified it passes for the right reason now.

💡 Suggestion — simplify color handling: applied. Dropped the FormatterContext.color flag and the new Chalk({ level: 0 }) threading; stylish.ts and types.ts are reverted to their pre-refactor form (verified byte-identical). ANSI is now stripped at the single file write site. I used a tiny local stripAnsi (a one-line ESC[…m regex) rather than adding strip-ansi as a dependency — the SGR sequences chalk emits are the only ones in play, and it avoids touching the lockfile again after the rebase.

tsc --noEmit and eslint are clean, and the validate suite passes (including the overwrite and no-ANSI-to-file tests).

Note on the 2 input URIs HTTP tests: they can time out at the 10s mocha limit under heavy load — they pass consistently at a higher timeout and at normal load, and they're from #102, unrelated to this change. Flagging rather than changing the timeout, since it was deliberately restored to 10s in c4163ea.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

Comment thread src/commands/validate/action.ts Outdated
Move stripAnsi into its own module and apply it only to non-json file
output, matching jentic-api-scorecard's writeReport: the json formatter
emits no ANSI, so its output is written verbatim. Wrap write failures
with the resolved path (failed to write <path>: <message>) for a clearer
error than the bare Node message.

Refs #94
char0n added 4 commits July 21, 2026 10:18
Move the stdout-vs-file dispatch, ANSI stripping, and contextual write
error out of the action into a writeReport helper (mirrors
jentic-api-scorecard's output.ts). The action now just renders and calls
writeReport; the input-overwrite guard stays in the action since it runs
before the heavy apidom-ls import. strip-ansi becomes output.ts's helper.

Refs #94
The overwrite guard compared resolved path strings only, so a symlink,
hard link, or case-insensitive-filesystem alias to the input slipped
through and clobbered the document. The input was just read, so it exists
on disk; wouldOverwriteInput now also compares filesystem identity (dev +
ino) when the output exists, falling back to the string compare when it
does not. ino==0 (some Windows/network FS) is not trusted. A 'd
sub-document is still out of scope.

Refs #94
Replace the run-on exit-code sentence with a code table and a stdout/
stderr distinguisher. The old 'exits 1 on a failing diagnostic, 0
otherwise' wording was inaccurate: hard errors (missing input, bad
$ref, unwritable/input-colliding -o path, internal failure) also exit 1
with zero diagnostics. Clarify that empty stdout + non-zero means the
run failed, while diagnostics + non-zero means the document is invalid.

Refs #94
Summary of the code-review follow-ups applied on this branch, why, and
the issues opened for out-of-scope items.

Addressed here:
- Gate ANSI stripping on format so json file output is written verbatim;
  only the stylish formatter emits chalk colors. (4f18b59)
- Extract report writing (stdout-vs-file dispatch, ANSI strip, contextual
  write error) into output.ts, with strip-ansi as its helper — keeps the
  action focused and the write path unit-testable. (11a63cf, 4f18b59)
- Wrap write failures with the resolved path: 'failed to write <path>:
  <message>', clearer than the bare Node error. (4f18b59)
- Harden the input-overwrite guard: compare filesystem identity (dev+ino)
  when the output exists, not just resolved path strings, so symlink,
  hard link, and case-insensitive-fs aliases to the input are caught.
  ino==0 is distrusted; a $ref'd sub-document stays out of scope. (4f95f4d)
- Document exit codes as a table and clarify the stdout/stderr
  distinguisher; the old '1 on failing diagnostic, 0 otherwise' wording
  ignored hard-error paths that also exit 1. (d3d761c)

Follow-up issues opened:
- #108 — add the same input-overwrite guard to overlay apply/diff, which
  share -o/--output but can currently clobber their inputs.
- #109 — support piped stdin input for validate, with a --base-uri anchor
  for $ref resolution and explicit rules for the neither/both source cases.

Refs #94, #95, #108, #109
@char0n

char0n commented Jul 21, 2026

Copy link
Copy Markdown
Member

Review follow-ups applied

Pushed a set of commits addressing the review feedback (0c72cbc..252babf).

Addressed on this branch

  • Gate ANSI stripping on format (4f18b59) — file output for --format json is written verbatim; stripping applies only to stylish, the sole formatter that emits chalk colors. Closes the theoretical case of a literal ESC[…m byte being mangled in JSON output.
  • Extract report writing into output.ts (11a63cf, 4f18b59) — the stdout-vs-file dispatch, ANSI strip, and contextual write error now live in one writeReport helper (with strip-ansi.ts as its helper), keeping the action focused and the write path unit-testable.
  • Contextual write errors (4f18b59) — a failed write now reports failed to write <path>: <message> instead of the bare Node error, so the destination is named.
  • Harden the input-overwrite guard against aliases (4f95f4d) — the guard previously compared resolved path strings only. Since the input has just been read (so it exists on disk), wouldOverwriteInput now also compares filesystem identity (dev + ino) when the output exists, catching symlink, hard-link, and case-insensitive-FS aliases to the input. The string compare is retained for a not-yet-created output; ino === 0 is distrusted. A regression test symlinks a path to the input and asserts -o <symlink> is rejected with the document byte-for-byte intact. (This resolves the outstanding Copilot alias comment.)
  • Document exit codes as a table (d3d761c) — the previous "exits 1 on a failing diagnostic, 0 otherwise" wording was inaccurate: hard errors (missing input, unresolvable $ref, unwritable or input-colliding -o path, internal failure) also exit 1 with zero diagnostics. The docs now pair a code table with the stdout/stderr distinguisher (empty stdout + non-zero = run failed; diagnostics + non-zero = document invalid).

npm run typescript:check-types, npm run lint, and npm test (82 passing) are all clean.

Out of scope — follow-up issues opened

Also still out of scope for the guard itself: an output path matching a relative $ref'd sub-document rather than the root document — the resolved ref set isn't tracked at the guard site.

@char0n
char0n merged commit 5a153da into main Jul 21, 2026
9 checks passed
@char0n
char0n deleted the frantuma/issue-94 branch July 21, 2026 08:43
github-actions Bot pushed a commit that referenced this pull request Jul 21, 2026
# [1.4.0](v1.3.0...v1.4.0) (2026-07-21)

### Features

* **validate:** add -o, --output <file> for diagnostics output ([#101](#101)) ([5a153da](5a153da)), closes [#94](#94)
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 1.4.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

validate: add -o, --output <file> to write diagnostics to a file

3 participants