mergedFlagContractOtherwiseChanged was only ever exercised on the path where
every condition holds still, because `||` short-circuits: with no reviewed
entry the first operand already decides the outcome and the function is never
called at all. That left its five regression branches uncovered and put
changed-code coverage at 88.0952% against a 100% target.
Add the merge-path counterpart of the checkCompatibility bundled-regression
table, pairing each of shorthand / required / hidden / no-opt / scope with the
reviewed type change and requiring the type failure to reappear. Changed-code
coverage is now 100%.
The authoritative interface baseline and command-compatibility gates
rejected every flag type change on a historical command, with no review
channel — even when the new type only moves the same validation from RunE
to flag parsing. Both now consult a precise allowlist.
An entry must match command path, flag name and both type names exactly,
so it is direction-sensitive by construction, and it applies only when
nothing else about the flag moved (shorthand, required, hidden, no-opt,
scope). A bundled regression re-reports the type change.
The first and only entry is "dws minutes permission apply --policy" moving
from string to int (for #912): the old RunE parsed with
strconv.ParseInt(v, 10, 64) and enforced [2,4], the new one lets pflag
parse with base 0 and still enforces [2,4], so the historical set of
successful invocations is a subset of the new one. Base 0 additionally
accepts spellings like "0x3", which widens rather than narrows. Defaults
are excluded from the guard because the migration necessarily changes one.
In the snapshot gate the exemption resolves against the canonical
Command.Path, never the alias-expanded accepted path: an aliased command is
compared once per accepted spelling, so keying on that would let every
alias bypass the table.
The table is duplicated because check-authoritative-interface-baselines.sh
copies the whole scripts/policy/interface-baseline directory into a
worktree checked out at a historical revision and builds it there, so that
copy cannot import a package this branch adds. A guard test fails if the
two copies drift.
--ranges validated the position of "!" in the raw string and then returned the
trimmed halves, so " !A1:B2" was accepted and produced a set_cell_range /
clear_range operation carrying sheetId: "". Depending on how the server treats
an empty sheetId, the whole batch_update fails, or — worse — the operation lands
on the default worksheet instead of the one the user named, while the command
reports success.
Both halves must now be non-empty *after* trimming. batch-clear grew the same
hole independently (it duplicated the split inline); it now shares
splitSheetPrefixedRange, so the invariant holds by construction rather than by
being repeated correctly in two places.
batch-set-style --batch had the same gap at the JSON level: it only rejected
sheetId == "", so " " passed. It now judges the trimmed value but still sends
the raw one — sheetId may be a worksheet *name*, and names may legitimately
carry leading or trailing spaces, so trimming on the user's behalf would target
a different sheet. The --ranges form cannot express such a name anyway, which is
what --batch is for.
TestBlankSheetIdentifierIsRejectedBeforeAnyRemoteCall covers all three entry
points with calls == 0; TestBatchStyleSheetIDIsSentVerbatimNotTrimmed pins the
no-normalisation half.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both capabilities were added as flags on an existing leaf, and in both cases
the leaf's published interface stopped describing what the command did:
- `sheet create --values/--sheets/--styles` orchestrates create → probe →
resolve default worksheet → write → read back → optional styles, yet the
leaf still published `interface_mode: mcp` + `create_workspace_sheet`.
- `sheet export --export-format csv` reads `get_range_as_csv` and never
invokes `submit_export_job`, yet the leaf published `submit_export_job`.
Each moves to its own command, declaring the interface it actually uses:
`sheet create-with-data` is `composite` with a reviewed reason and no
`interface_ref`; `sheet export-csv` is `mcp` + `get_range_as_csv`. Both are
pinned in the interface-disposition contract test.
`sheet create` and `sheet export` are restored byte-for-byte to main, so the
compatibility gates see two `command_added` additions instead of four
locked-field changes. The split also removes a user-visible trap: `--range`
without `--export-format csv` used to be silently discarded and the whole
workbook exported; the cross-format flags no longer exist, pinned by
TestSheetExportAndExportCsvFlagsDoNotLeak.
Drops the 8 now-stale mapping-ledger exclusions that covered the flags on
`sheet.create_workspace_sheet` / `sheet.submit_export_job`, shrinking this
branch's exclusion surface. Skill references and CHANGELOG follow the split.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parseBorderStyles read only style and a string color, so every other key and
any non-string color was silently dropped:
{"top":{"style":"solid","colour":"#f00"}} succeeded and drew a border with no
colour, and color: 123 did the same. That contradicts the unknown-key
rejection this PR applies to --sheets and --styles — a partially applied
style reported as success is harder to notice than an error.
Each edge now accepts only style/color, rejects near-miss spellings with the
canonical key, and fails when style or color is present with the wrong type
or empty. All three entry points (set-style --border-styles-json,
batch-set-style --ranges/--batch, and create --styles border_styles) share
parseBorderStyles, so one fix covers them; tests assert the rejection happens
before any MCP call on every path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sweeping the same class the last review round hit: limits and behaviour this
PR added that only reached the Go long help, not the skill references an
agent actually reads.
- batch-set-style: the 200000-cell cumulative cap across all ranges was
missing (the 100-range cap and the atomic rollback were already there).
- sheet create --styles: size must be a positive integer (a fraction is
rejected rather than silently truncated) and the row/column range forms
reject trailing characters.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
json.Decoder.Decode returns after one value, so `--values '[[1]] trailing'`
was accepted as a valid matrix and the document got created anyway. A paste
that ran long, leftover shell concatenation, or two JSON values glued
together would silently drop the tail and still create a document the user
never asked for — and creation cannot be rolled back atomically. Require
EOF after the first value on both flags, following decodeOARequest.
docs(sheet): document the fail-closed CSV export and --allow-truncated
The skill references still claimed an oversized table is truncated with a
warning on stderr, and omitted the flag. The command now aborts before
writing anything when the server reports hasMore, so an agent relying on the
skill would misread the result and had no way to learn how to opt in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sheet create promises that every structural check happens before the first
MCP request, but each sheet spec was only checked for object type and name:
columns/data/dtypes/formats/startCell were left to table_put, so a bad type
created and renamed the remote document first and failed at write time,
leaving behind a document the user never successfully asked for. Validate
every provided field against table_put's input contract up front, and reject
the malformed {"sheets":"bad"} wrapper instead of treating it as one spec.
Also fixed while auditing the same flow:
- unknown/misspelled keys are now rejected in both --sheets and --styles.
The server DTOs are fixed beans, so a stray "datas" was silently dropped
and the read-back probe landed on the header row: full data loss reported
as success. Near-miss spellings get the canonical key in the message.
- columns is required (the server requires it), non-blank and trim-unique;
dtypes/formats keys must resolve to a column, since the server looks them
up by trimmed name and silently ignores the rest.
- sheetId inside a spec is rejected: the document does not exist yet.
- the read-back probe now honours header:false, mode:append (a fresh sheet
appends at row 1, the startCell row is ignored) and $-absolute/lowercase
startCell refs, which the server accepts after uppercasing.
- --values cells must be scalars; a map used to be written as "map[a:1]".
- the 30000-cell and 2000000-char write limits are enforced locally.
Docs: the --styles top level only accepts snake_case (camelCase aliases are
inner-field only), and the read-back probe is not pinned to A1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The --export-format csv branch reads get_range_as_csv, but the sheet export leaf
still declares interface_ref: submit_export_job, so discovering the csv
capability through Schema yields the wrong backing interface. Audit metadata
only — interface_ref is not read at runtime and routing is unaffected. Recorded
here so the limitation reaches release notes rather than living only in a code
comment; accurate attribution is tracked as follow-up.
The allowlist added in the previous commit keyed the reviewed
sheet.range_set_style migration by bare RPC name ("update_range"), but
interface_ref holds the canonicalized JSON that parseTool produces via
canonicalRawJSON. The lookup therefore never matched, the carve-out was
effectively disabled, and the real gate failed with
`schema tool "sheet/sheet.range_set_style" changed interface_ref`.
The existing redirect test did not catch this: it registered the fixture entry
using its own value and then asserted with the same value, so any format would
have passed. That is the same mistake as keying a probe on the author's field
spelling instead of the wire contract.
- The allowlist entry now uses the compact canonical JSON, taken from the gate's
own output rather than from pretty-printed `dws schema`.
- TestCrossPlatformCoverageReviewedRedirectKeysAreCanonicalJSON recomputes every
registered key through canonicalRawJSON, so a bare name or a pretty-printed
variant fails locally instead of only in CI.
sheet export keeps its reviewed mcp + submit_export_job declaration. The comment
now records the known trade-off explicitly: --export-format csv is a mutually
exclusive branch that reads get_range_as_csv and never invokes the declared
submit_export_job, so this declaration does not cover the csv branch's backing
interface. Attributing that branch is left out of scope for this change.
Verified against the real gate, not just unit tests: all four Interface
Integrity checks pass (authoritative-interface-integrity,
check-command-compatibility, schema-compatibility, skill-command-integrity).
Two review findings, plus a same-class defect found by sweeping for it.
1. compatibleInterfaceRefRedirect accepted any mcp tool repointing from any
non-empty interface_ref to any other, as long as no other check for that tool
failed. Schema shape cannot prove two RPCs share business semantics,
permissions, error behaviour, or side effects, so that would have let every
future backend swap bypass the gate it exists to enforce. It is now keyed on
an explicit reviewedInterfaceRefRedirect allowlist of exact tool + old→new
pairs, currently holding only the reviewed
sheet.range_set_style: update_range → set_cell_range migration. Every other
ref change is reported again.
2. sheet export --export-format csv only printed a stderr warning when
get_range_as_csv returned hasMore=true, then wrote --output and reported
success with exit code 0. Automated callers, and anyone not watching stderr,
would treat an incomplete file as a complete export, and an existing target
file was overwritten with truncated data. Truncation now fails before the
write (leaving any existing file untouched) unless --allow-truncated is
passed; with the opt-in the success line states the data is incomplete.
--allow-truncated is registered in the reviewed mapping ledger as a local
policy input.
3. Swept for the same classes and found sheet_dimension.go repeating the
fmt.Sscanf("%d") prefix-parse hole in three places: insert_dimension,
delete_dimension, and update_dimension all accepted --length "3x" as 3, so a
malformed value silently operated on the wrong row/column count — the delete
direction is not rollbackable. All three now use strconv.Atoi. (Checked and
cleared: sheet csv-get also surfaces hasMore, but it has no --output and only
returns the flag in its JSON payload, so it is not the same fail-open shape.)
Tests: allowlist rejection cases (unreviewed target ref, and the same pair on a
tool absent from the allowlist, asserted outside the table so the registration
survives until checkCompatibility runs); truncation fail-closed with a
pre-existing output file asserted byte-for-byte unchanged; --allow-truncated
write-through; and an untruncated read needing no opt-in. Changed-code coverage
stays at 100% (939 statements).
parseRowColRange gates --styles row_sizes/col_sizes before the document is
created. The row branch parsed with fmt.Sscanf(a, "%d", &r1), which consumes
only the leading digits and does not require the whole token, so "1x:3" was
silently accepted as row 1 and "2foo" as row 2. Such input passed pre-flight,
then update_dimension was sent to the wrong row after the document and data had
already been created — an unrollbackable wrong edit, the opposite of the gate's
purpose. The row branch now parses with strconv.Atoi, which requires the entire
token to be a valid integer.
The column branch had the same class of hole via a different path: parseA1Cell
appends "1" to the column token, so "A5" became "A51" and was accepted as
column A. A new isAllLetters pre-check requires the column token to be non-empty
and letters-only before parsing; genuine multi-letter columns like "AX" still
pass. With that guarantee the subsequent parseA1Cell can no longer fail, so its
now-dead error branch is removed.
Adds trailing-character rejection cases to TestParseRowColRange: "1x:3",
"2foo", "1 2:3" (rows), "A5:C", "A1", ":C" (columns), plus "AX:C" to confirm
multi-letter columns remain valid. Changed-code coverage stays at 100%.
Three review P1s.
1. CHANGELOG no longer asserts a breaking Schema change this PR does not ship.
sheet export / sheet create deliver main's mcp + interface_ref and
schema-compatibility reports ok (0 changed fields), so the "declared as
composite (breaking)" entry was false and is removed; the set-style entry
drops the "breaking" framing (accepted as compatible by the reviewed
mapping-exclusion carve-out); the duplicate ### Changed heading is merged.
2. firstNonEmptySheetSpecCell reads the start cell via
pickStr(spec, "startCell", "start_cell"). Sheet specs are forwarded verbatim
to table_put, whose wire fields are camelCase in this repo. The prior
snake_case-only read meant a user passing the real startCell would have data
written at the offset while the probe read A1 — a false "写入未生效" on a
successful write. camelCase preferred, snake_case kept for tolerance.
3. planStyleOps now parses cell_merges range with parseA1Range, matching the
cell_styles branch. planStyleOps is dry-run once before create_workspace_sheet
as the up-front structural gate, so an invalid range like "not-a-range" is now
rejected before any RPC instead of failing only at the final merge_cells call
and leaving an unrollbackable partially-completed document. merge_cells' range
contract is A1:B3-style, which parseA1Range covers (and it strips a Sheet1!
prefix).
Tests: cell-merges-invalid-range added to TestSheetCreateValidatesBeforeCreating
Document (asserts calls == 0); TestFirstNonEmptySheetSpecCell covers both
startCell and start_cell. Changed-code coverage stays at 100%; targeted sheet
suites pass; CHANGELOG has a single Changed section with no false breaking claim.
Adds coverage for the branches introduced by the per-sheet read-back:
resolveSheetIDsByName's RPC-error and unparseable-response paths, the create
--sheets path surfacing a list-fetch failure with the nodeId, and the
single-value data row in sheetSpecGrid. Changed-code coverage back to 100%.
The --values branch already reads back its first non-empty cell after writing,
to defend against the new-document initialization race where a write returns
success but the data does not land. The --sheets branch called table_put and
reported success with no read-back, so the same race would let the command exit
successfully while one or more sheets silently lost their initial data.
After table_put, the --sheets branch now:
- re-fetches get_all_sheets to build a name -> sheetId map (table_put reuses the
renamed default sheet and auto-creates the rest by name), and
- for every spec that actually has content, reads back its first expected
non-empty cell and fails if the read-back is empty or the sheet is missing.
firstNonEmptySheetSpecCell mirrors firstNonEmptyValuesCell: it treats columns as
the header row followed by data rows, honours start_cell, and returns
hasContent=false for a name-only spec so a legitimately empty sheet is not
misreported as data loss. Failures carry the nodeId and point at
sheet table-put for recovery, matching the --values branch's error shape.
Tests:
- TestSheetCreateWithSheetsVerifiesEachSheetLanded covers an empty read-back
(errors, naming the sheet + nodeId + table-put), a sheet missing from the
post-write listing, and a name-only sheet that must not trigger a read-back.
- TestFirstNonEmptySheetSpecCell covers header/data origins, an empty first
header cell, a start_cell offset, and content-less specs.
- Existing --sheets tests updated for the added get_all_sheets + per-sheet
read-back calls.
Reverts the interface_mode of sheet create and sheet export from composite back
to mcp with a single interface_ref, matching upstream/main and the existing
convention for multi-tool leaves (doc.create_document declares mcp +
create_document even though it also calls update_document).
Rationale:
- interface_ref is audit / traceability metadata; nothing reads it at runtime
(verified: rebuilding with a bogus interface_ref still routes to the correct
tool). Declaring the primary tool and treating the orchestration as an
implementation detail is the pattern main already uses.
- sheet export was mcp + submit_export_job on main; it already orchestrated
submit_export_job + query_export_job without declaring the poll. Adding an
--export-format csv branch does not change that shape, so it does not warrant
flipping to composite.
- sheet create was a genuine single-RPC command on main (create_workspace_sheet
only). The --values / --sheets / --styles orchestration I added runs after the
document exists; per the doc.create_document precedent it stays mcp.
This takes the sheet schema-compatibility failures from 4 to 0 without a waiver
or an admin override: the declarations now equal main's.
Also updates the wording of the seven new mapping-exclusion reasons for these
two commands (submit_export_job CSV params, create_workspace_sheet
values/sheets/styles) from "Composite ... input" to "Wrapper ... input", so the
reason text no longer collides with the interface_mode value now that both
leaves are mcp. The reasons are otherwise unchanged and still describe where
each value actually goes. range_batch_set_style keeps its "Composite" wording
because it genuinely stays interface_mode=composite.
Removes the two composite entries for these leaves from the interface
disposition contract test.
No execution path changes; targeted sheet suites, the disposition contract test,
and the schema-compat policy tests all pass; check-schema-catalog is green;
sheet-scoped schema-compatibility reports 0 failures.
Two compatibility carve-outs, written alongside the existing interface_type
retirement allowance. Both cover declarative provenance metadata that nothing
reads at runtime: the tool a leaf invokes is decided in the CLI source, so a
stale interface_ref or property misinforms a reader rather than misrouting a
call. Neither carve-out can mask a change to the surface callers depend on.
Cleared property through a reviewed mapping exclusion. A leaf whose backing RPC
moves to a nested payload has no honest flat property to publish. The two
alternatives are worse: keep naming a field the request no longer contains, or
let assembly fall back to flag_name_inference and publish a name that appears in
no request at all. Accepted only when the old value was non-empty, the new value
is empty, and the new value resolved through reviewed_mapping_exclusion. A
redirect to a different non-empty value, a clearing by inference or native
annotation, a clearing with no recorded source, and populating a previously
empty property all stay incompatible. The exclusion table cannot be abused to
wave arbitrary clearing through: internal/cli/schema_parameter_bindings.go
verifies every parameter claiming an exclusion really does deliver an empty
property, and every entry carries a non-empty reviewed reason.
Redirected interface_ref with an unchanged CLI contract. Accepted only when
interface_mode is unchanged and stays mcp, both refs are non-empty, and no other
compatibility failure was recorded for that tool. That last condition is the
operative definition of "the contract is unchanged" — it is measured, not
asserted, so it automatically covers a lost parameter, a newly required one, a
moved type / default / format / enum, a tightened constraint, a positional or
dry_run change, and any effect / risk / confirmation / idempotency move. Any one
of them re-reports the redirect, so a surface change cannot ride along behind a
backend move. Moving to or from composite is a change in kind rather than a
redirect and stays reported; so does removing a ref outright.
Deliberately still incompatible: mcp -> composite with the ref dropped. That is
a leaf declaring it now orchestrates several RPCs, which is a semantic upgrade
rather than a like-for-like substitution, and it belongs in review.
For this branch the two carve-outs take schema-compatibility from 17 changed
fields to 4 — the twelve sheet.range_set_style property clearings and its
update_range -> set_cell_range redirect are now accepted. The remaining four are
the interface_mode plus interface_ref pairs on sheet.create_workspace_sheet and
sheet.submit_export_job.
Tests: TestCrossPlatformCoverageSchemaCompatPropertyClearingExclusion and
TestCrossPlatformCoverageSchemaCompatInterfaceRefRedirect assert the accepted
shapes plus twelve neighbouring shapes that must stay incompatible; the drift
table gains cases for clearing without an exclusion and redirecting despite one.
P1 from automated review. --values decoded with plain json.Unmarshal, so every
number became a float64 and integers beyond 2^53 were rounded before anything
was written. The read-back only checks that the probe cell is non-empty, so the
corruption was reported as a successful write. Order numbers and snowflake IDs
are ordinary spreadsheet data.
Measured before the fix:
1234567890123456789 -> 1234567890123456768 snowflake id, tail rewritten
12345678901234567890 -> 12345678901234567000 20-digit order number
9007199254740993 -> 9007199254740992 2^53+1
--sheets had the same defect, which the review did not mention: its records and
data are forwarded verbatim to table_put, and the float64 round trip rewrote
1234567890123456789 as 1234567890123456800 before the request left the CLI.
Both channels now decode with json.Decoder.UseNumber, and cellToString emits a
json.Number through its String method so no float conversion happens on the way
to CSV. --styles keeps plain Unmarshal on purpose: its numbers are font sizes and
pixel dimensions, already constrained to int32 by pickNum, with no large-integer
case.
Tests assert the payload the CLI actually sends, not a recomputed decode:
- TestSheetCreatePreservesLargeIntegerLiterals checks the csv argument of
set_range_from_csv and the marshalled table_put arguments. Both halves also
assert the rounded forms are absent, so removing UseNumber fails the test
instead of passing on a lucky substring match.
- TestCellToStringKeepsJSONNumberVerbatim covers large, negative, fractional and
exponent literals, and keeps the existing float64 behaviour for other callers.
The shared scriptedToolCaller keeps only the last call, and the write is the
fourth of five, so the assertion needs an intermediate call. Rather than extend
that shared helper, this adds a callRecorder local to this file: InitDeps takes
the edition.ToolCaller interface, so embedding *scriptedToolCaller and
overriding CallTool is enough.
Changed-code coverage stays at 100.0000% (850 statements) per
check-coverage-gate.sh --changed-only.