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>
This commit is contained in:
cliffhall 2026-08-25 03:41:17 -04:00
commit 182c48f296
3 changed files with 71 additions and 8 deletions

View file

@ -173,7 +173,7 @@ handler adds one more stderr line after this, the
Error: tool "get_temp"
Path: outputSchema.properties.data
Issue: Bare `true` used where a schema object is expected.
Suggestion: Declare what the value actually is — e.g. `{"type": "object", "additionalProperties": true}` for a free-form object. That narrows the schema deliberately; `true` accepts any JSON value at all.
Suggestion: Declare what the value actually is — e.g. `{"type": "object", "additionalProperties": true}` for a free-form object. That is a deliberate change of contract, not an equivalent rewrite: `true` accepts any JSON value at all.
Warning: tool "echo"
Path: inputSchema.properties.show_ids
@ -183,7 +183,7 @@ Warning: tool "echo"
Warning: tool "echo"
Path: inputSchema.properties.opts
Issue: Schema carries no validation keyword at all, so it accepts any value — the object-literal spelling of a bare `true`.
Suggestion: Declare what the value actually is — e.g. `{"type": "object", "additionalProperties": true}` for a free-form object. That narrows the schema deliberately, which is the point: as written it constrains nothing.
Suggestion: Declare what the value actually is — e.g. `{"type": "object", "additionalProperties": true}` for a free-form object. That is a deliberate change of contract, which is the point: as written it constrains nothing.
Warning: tool "add"
Path: inputSchema.properties.a

View file

@ -132,6 +132,45 @@ describe("lintToolSchemas — boolean-schema", () => {
expect(findings[0]!.suggestion).toContain("additionalProperties");
});
it("suggests `{}` for a bare true under not, where a typed schema would widen", () => {
// `{"not": true}` rejects every value. Swapping in `{"type": "object", …}`
// leaves the parent rejecting only objects, so every non-object would then
// pass — the enclosing contract is widened, not narrowed. `{}` is the
// exact replacement, and is exempt from `untyped-schema` in that position.
const findings = lintToolSchemas(
tool({
inputSchema: { type: "object", properties: { a: { not: true } } },
}),
);
expect(rules(findings)).toEqual(["boolean-schema"]);
expect(findings[0]!.suggestion).toContain("exact replacement");
expect(findings[0]!.suggestion).not.toContain('"additionalProperties"');
});
it("never claims a bare-true replacement narrows the contract", () => {
// It narrows in an ordinary property position and does not under `not` or
// `if`, so the wording must not assert an effect it cannot guarantee.
const findings = lintToolSchemas(
tool({
inputSchema: {
type: "object",
properties: { a: true, b: { not: true }, c: { if: true } },
},
}),
);
// Four: a bare `true` in each of the three positions, plus `c` itself
// being unconstrained — a lone `if` asserts nothing without a `then`.
expect(rules(findings)).toEqual([
"boolean-schema",
"boolean-schema",
"untyped-schema",
"boolean-schema",
]);
for (const finding of findings) {
expect(finding.suggestion).not.toMatch(/narrow/i);
}
});
it("suggests the always-false object form for a bare false", () => {
// `properties: {a: false}` forbids the property. Deleting the entry would
// *permit* it with any value under the default `additionalProperties`, so

View file

@ -395,6 +395,31 @@ function forEachSubschema(
}
}
/**
* What to replace a bare `true` with — which depends on the keyword above it.
*
* `{}` and `true` are the same schema, so `{}` is always the *exact* rewrite.
* It is normally not what to advise, because `{}` is the very shape
* `untyped-schema` flags — hence the usual advice to declare a real type.
*
* Under `not` that reverses. `{"not": true}` rejects every value; swapping in
* `{"type": "object", …}` leaves the parent rejecting only objects, so every
* non-object now passes — the enclosing contract is **widened**, not narrowed.
* `{}` is exempt from `untyped-schema` in that position for the same reason,
* so it is both exact and safe to recommend there.
*
* Everywhere else the replacement is described as a deliberate *change* rather
* than a narrowing: in an ordinary property position it does narrow, but under
* a conditional (`if`) it redirects which branch applies, and a suggestion has
* no business claiming an effect it cannot guarantee.
*/
function booleanTrueSuggestion(keyword: string | undefined): string {
if (keyword === "not") {
return "Use `{}`, the object form of the always-true schema — under `not` that is an exact replacement. (Declaring a concrete type here would instead let every value of another type through.)";
}
return 'Declare what the value actually is — e.g. `{"type": "object", "additionalProperties": true}` for a free-form object. That is a deliberate change of contract, not an equivalent rewrite: `true` accepts any JSON value at all.';
}
/**
* Visit one schema position. `keyword` is the parent keyword this node sits
* under (undefined at the root), which is what decides whether a bare boolean
@ -418,11 +443,7 @@ function walk(
path,
`Bare \`${String(node)}\` used where a schema object is expected.`,
node
? // `true` accepts any JSON value, and no portable object form says
// exactly that — `{}` is the same shape this lint flags as untyped.
// So the advice is to narrow, and it says so rather than implying an
// equivalent rewrite.
'Declare what the value actually is — e.g. `{"type": "object", "additionalProperties": true}` for a free-form object. That narrows the schema deliberately; `true` accepts any JSON value at all.'
? booleanTrueSuggestion(keyword)
: // `false` in a property position means the property is FORBIDDEN.
// Deleting the entry is not the same thing: with `additionalProperties`
// absent (default `true`) the property becomes allowed with any value,
@ -505,7 +526,10 @@ function lintNode(
"warning",
path,
"Schema carries no validation keyword at all, so it accepts any value — the object-literal spelling of a bare `true`.",
'Declare what the value actually is — e.g. `{"type": "object", "additionalProperties": true}` for a free-form object. That narrows the schema deliberately, which is the point: as written it constrains nothing.',
// "changes", not "narrows": in an ordinary property position it does
// narrow, but under a conditional (`if`) a typed replacement redirects
// which branch applies rather than tightening anything.
'Declare what the value actually is — e.g. `{"type": "object", "additionalProperties": true}` for a free-form object. That is a deliberate change of contract, which is the point: as written it constrains nothing.',
);
}
}