Commit graph inspector/core/json/schemaLint.ts
Author SHA1 Message Date
cliffhall
182c48f296 fix: stop claiming a bare-true replacement narrows the contract
Copilot review round 8 (its one suppressed finding; no new inline comments).

The `boolean-schema` suggestion for a bare `true` said the replacement
"narrows the schema deliberately". That holds in an ordinary property
position and inverts under a negating parent: `{"not": true}` rejects
every value, so swapping in `{"type": "object", …}` leaves the parent
rejecting only objects and every non-object then passes — the enclosing
contract is widened. Under `if` it redirects which branch applies rather
than tightening anything.

- Parent-aware advice under `not`: suggest `{}`, which is the exact
  object form of `true` and is already exempt from `untyped-schema` in
  that position, with the message saying why a concrete type would be
  wrong there.
- Everywhere else, and in the `untyped-schema` suggestion too, describe
  the replacement as a deliberate CHANGE of contract rather than a
  narrowing. A suggestion has no business claiming an effect it cannot
  guarantee across the positions it can print from.

Tests pin the `not`-specific advice and assert no bare-`true` suggestion
in any of the three positions contains "narrow". The CLI README's sample
report is re-captured from a real run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 03:41:17 -04:00
cliffhall
31bdd532a6 fix: walk contentSchema, await the error envelope, correct the README claim
Copilot review round 7.

- Add `contentSchema` to the traversal. It holds the schema the decoded
  string content must satisfy, so a bare `true` or a remote `$ref` under
  it is a real finding all three clients were walking past. Also admit
  the three content keywords to `CONSTRAINING_KEYWORDS` on the same
  documented grounds as `format` — annotation-only by vocabulary, but
  plainly an attempt to constrain, so reporting such a schema as
  accepting anything would be noise. The comment now names that
  category rather than singling `format` out.

- Make `handleError` async and await the envelope write. Round 6 made
  the `--strict` report await its write and stopped one line short: the
  envelope emitted for the very same throw still raced `process.exit()`,
  so the machine-readable line a CI caller parses could be lost on a
  pipe. The launcher's comment claiming the envelope was "well inside
  the pipe buffer" was wrong reasoning and is replaced — fitting in the
  buffer says nothing about whether the write was performed. Its call
  site now awaits; the bin's `.catch(handleError)` needed no change.

- The CLI README called its sample block the "complete output" when the
  error envelope follows it on every non-zero exit. Now scoped to "the
  complete report", with the envelope named and linked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 03:26:39 -04:00
cliffhall
488106f235 fix: scan only subschema positions, await stderr, show the whole report
Copilot review round 6.

- Share one traversal between `walk` and `declaresAnyId`. The `$id` guard
  added last round descended through EVERY object value, including
  instance data — `examples: [{"$id": "x"}]`, a `default`, a `const` —
  where `$id` is a payload key declaring no embedded resource. Reading
  one there suppressed `remote-ref` for the whole document, turning last
  round's false-positive fix into a blanket false negative. Both passes
  now go through `forEachSubschema`, driven by the same keyword tables,
  so they cannot drift about what counts as a schema position.

- Await the stderr writes. `writeSchemaLintReport` used a bare
  `process.stderr.write`, but both CLI exit paths call `process.exit()`
  as soon as the work returns, which discards anything still buffered on
  a piped stderr — truncating or losing the multi-block `--strict`
  report on exactly the redirected-output runs a CI caller uses. Added
  `awaitableError` beside the existing `awaitableLog` and awaited it.
  The test's fake defers its callback so a synchronous one cannot make
  the assertion vacuous; two existing stderr stubs that never invoked
  their callbacks were fixed in the same pass.

- Show the CLI README's sample report in full. It printed one error
  block immediately followed by the summary, a shape the formatter
  cannot produce — every finding gets a block. Captured from a real run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 03:10:37 -04:00
cliffhall
68c4abf37a fix: count severities correctly, gate remote-ref on $id, order --strict checks
Copilot review round 5.

- The sidebar tooltip applied `hasError` to the TOTAL, so a tool with one
  error and three warnings announced as "4 schema portability errors" —
  a defect the previous round introduced while adding that wording, and
  worse than the vague text it replaced. Both the tooltip and the
  accessible label now carry the breakdown, formatted by a new shared
  `summarizeToolFindings` so the fourth place to phrase a severity count
  cannot drift from the other three.

- Suppress `remote-ref` when the document declares an `$id` anywhere.
  `$id` establishes a base URI, so with a root `$id` of
  `https://example.com/root` the ref `https://example.com/root#/$defs/x`
  resolves to THIS document and needs no fetch — the finding and its
  "inline the referenced schema" remediation would both be wrong.
  Classifying correctly needs full RFC 3986 resolution against nested
  embedded resources, which this module does not do, so it declines
  rather than guessing. A missed finding is the acceptable direction.

- Run the `--strict` validations before every short-circuit return.
  `servers/list`, `servers/show`, `--list-stored-auth` and
  `--print-handoff` all return from `parseArgs` before any connect, so
  the checks further down let `--strict --method servers/list` succeed
  while silently ignoring a tools/list-only flag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 02:54:21 -04:00
cliffhall
2cb23a8b21 fix: map the new exit code, keep add runnable, distinguish severity by shape
Copilot review round 4.

- Add `SCHEMA_UNPORTABLE` to `codeForExit`. `CliExitCodeError` lets a
  caller omit the envelope, so without the case an envelope-less throw
  reported the generic `error` — the same silent mislabelling the rename
  was meant to prevent, one layer down. Covered via the envelope-less
  construction that exposed it.

- Keep a local `type` (and the preset's `required`) alongside the remote
  `$ref` in the showcase config. The TUI's `schemaToForm` dispatches on
  `type`, so a `$ref`-only property fell through to a string field that
  `add`'s numeric handler then rejected — making the README's "every
  showcase tool is runnable" claim false there. The `remote-ref` finding
  is unaffected, since that rule reads `$ref` independently.

- Restrict `type-union` to well-formed unions: non-empty, unique, and
  all recognized JSON Schema type names. Its message asserts the
  construct is *legal* JSON Schema, which is false for `[]`, `[3]` or
  `["bananas"]`, and the `anyOf` it suggested for those would be invalid
  too. Malformed schemas stay the SDK parser's business.

- Drop `if`/`then`/`else` from the presence-only allowlist. They only
  constrain as a pair, so `{"if": {"const": 1}}` accepts every value and
  was evading `untyped-schema`; the pairing is now checked explicitly.

- Distinguish the web sidebar severities by SHAPE, not just colour — a
  circle for an error, a triangle for a warning — and name the severity
  in the tooltip. Colour alone left a colour-blind reader unable to tell
  them apart.

- Fix the stale "across 2 tools" count in the CLI README's hint sample.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 02:37:10 -04:00
cliffhall
cac97c142c fix: drop the unreachable root rule, correct the always-false suggestion
Copilot review round 2.

- Remove `non-object-root` entirely. It could never fire: the SDK types
  `inputSchema` with `type: literal("object")`, so a tool with any other
  root fails `ListToolsResultSchema` and `salvageListItems` drops it
  before any client sees it. Verified against a live server advertising
  `inputSchema: {type: "array"}` — `listAllTools()` returns []. A rule
  that cannot fire is worse than none, because the docs then claim a
  check the tool does not perform; the condition is already reported
  through the malformed-items surface. Pinned by an integration test so
  a change in that behavior is a signal rather than a silent gap.

- Fix the bare-`false` suggestion. `properties: {a: false}` forbids the
  property; deleting the entry *permits* it with any value under the
  default `additionalProperties`, so the advice inverted the schema it
  replaced. Now recommends `{"not": {}}` and says why deletion differs.

- Exempt `not`-parented schemas from `untyped-schema`, so the `{"not":
  {}}` above does not trip this module's own rule — under negation an
  always-accepting subschema means always-reject, making "accepts any
  value" the exact opposite of what it does. Scoped to that position,
  not inherited by its subtree.

- Say plainly, in the `true` and untyped suggestions, that they narrow
  the schema deliberately rather than implying an equivalent rewrite.
  `true` has no portable object-form equivalent — `{}` is the very shape
  this lint flags.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 02:02:26 -04:00
cliffhall
c389565783 fix: address Copilot review on the schema portability lint
- Scope `non-object-root` to `inputSchema`. The pinned SDK types
  `inputSchema` with `type: literal("object")` but `outputSchema` as a
  bare `looseObject`, so a `{"type":"string"}` output is conforming; the
  rule was claiming a requirement MCP does not make, at error severity.

- Decide `untyped-schema` from an allowlist of constraining keywords
  rather than a denylist of annotations. JSON Schema ignores keywords it
  does not recognize, so `{"vendorHint": true}` accepts every value and a
  denylist passed it as constrained. `$defs`-only schemas now flag too.

- Walk `dependentSchemas` and draft-07 `dependencies`, with a test
  pinning that the property-name-array form is not a schema position.

- Suggest `anyOf` branches for an array-form `type`, never "drop it from
  `required`" — accepting `null` and permitting absence are independent
  contracts, so the old suggestion silently changed the schema's meaning.

- Reject `--strict` alongside `--app-info`. That path returns NDJSON from
  `runMethod` and never reaches the lint, so the flag was a no-op gate
  that could never fail.

- Colour the web sidebar flag from the `--inspector-*` severity tokens,
  matching the detail panel and avoiding the default yellow that failed
  the a11y check.

- Add a live integration test for `rawToolSchemas`. It caught that an
  `outputSchema` override on a preset returning no structured content
  makes every call to that tool fail client-side validation — the
  showcase config did exactly that, so the flagged tool could not be
  run. Moved the bare `true` onto `get_temp`, documented the caveat, and
  pinned both halves.

- Drop the double cast from the lint test fixture.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 01:41:12 -04:00
cliffhall
2f9e0901c1 feat: flag unportable tool schemas in all three clients
Closes #1005

A tool schema can be perfectly legal JSON Schema and still be rejected by
the client the server is meant to run against — Go's `jsonschema` emits a
bare `true` for `interface{}`, which the SDK accepts and Claude Code
refuses with an opaque "Invalid input". The Inspector passed it through
silently.

Adds `core/json/schemaLint.ts`, a portability lint over a tool's
inputSchema/outputSchema, and surfaces one shared verdict everywhere:
the CLI's `--strict` report (exit 6 on an error-severity finding), the
TUI's tool list marker + detail pane, and the web Tools sidebar flag +
detail section.

Deliberately not a JSON Schema validator: a census of 617 public servers
found zero that fail the SDK's own parse, so a conformance check would
report nothing on real servers. Each rule is instead a construct that is
legal JSON Schema and known to be refused or mishandled by a shipping
client — a bare boolean in a schema position, an array-form `type`, a
remote `$ref`, a schema with no validation keyword, a non-object root.

`test-servers/configs/unportable-schemas-http.json` exercises every rule,
via a new `rawToolSchemas` override — the Zod-built presets cannot emit
any of these shapes, which is the same reason a real server only hits
this when its schemas come from another generator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 01:18:53 -04:00