@wirecat/cli-base 0.50.3 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,50 @@ Versions before 0.8.0 are in the [git tags](https://github.com/WireCatLabs/cli-b
8
8
  Every entry says what changed as a caller sees it, why, and what to watch for — the rules are
9
9
  [`docs/dev/CONVENTIONS.md`](docs/dev/CONVENTIONS.md#the-changelog).
10
10
 
11
+ ## 0.51.0 — 24.10.2026
12
+
13
+ ### Added
14
+
15
+ - **Comparison pages live in the site repository, and our marks are re-checked at each release** (`WC-CMP-11`, [Comparisons](docs/standards/comparisons.md)). The page is written in `cli-docs` (wirecat.dev) because a comparison changes when other tools change, not when we release; the CLI repository keeps a short `docs/compare.md` that links to it. The release report lists the compare page as checked. Nothing changes in code.
16
+
17
+ ### Changed — may break callers
18
+
19
+ - **Shared command contracts require explicit overrides before host edits.** `sharedCommand`, `assertSharedContracts`, `overrideSharedContract` and `customizeSharedCommand` live in `/commands`; original definitions and exact accepted edits remain available for audits. Independent shell replacements must supply static, exact before/after declarations; a new Core default or a changed provider builder fails until those declarations are reviewed. Provider additions are allowed; inherited subcommands cannot disappear silently. `/commands` now loads Commander at runtime.
20
+
21
+ - **Cross-CLI contract drift cannot be accepted through a lint baseline.** Declare intentional differences with
22
+ their exact expected values and a specific reason; a changed or stale exemption fails. `audit --complete`
23
+ collects actual schemas and error reasons from `commands info --json` and refuses missing coverage. Canonical capability manifests and validated inherited definitions distinguish explicit provider variants from drift. Global
24
+ options satisfy matching command-local controls, so their placement alone is no longer a divergence.
25
+
26
+ ### Fixed
27
+
28
+ - **Complete audits keep runtime availability separate from static contracts.** A currently denied or available command retains the same protected grammar and output definition.
29
+
30
+ - **Command lint distinguishes list filters from computation and output controls.** Ranking weights, timezone, output context and byte pagination remain controls even when an answer exposes similarly named fields; singleton targets are not list filters. Native query fields and id/time filtering remain checked.
31
+
32
+ ## 0.50.4 — 24.10.2026
33
+
34
+ ### Added
35
+
36
+ - **The shared vocabulary covers knowledge-work resources and receipt delivery.** Registers agents,
37
+ reminders, key documents, evidence answers and the mail archive, with distinct assignment, decision reversal,
38
+ evidence invalidation and receipt acknowledgement actions. Registers the four missing stored meeting
39
+ selector ID flags with target roles. Existing canonical verbs still replace overlapping Memo spellings;
40
+ forum `--topic <id>` keeps its existing meaning, while meeting tags/topics use native query terms.
41
+
42
+ - **A standard for comparison pages** ([Comparisons](docs/standards/comparisons.md), `WC-CMP-1` to `WC-CMP-10`). A CLI's `docs/compare.md` compares it with the tools a reader would pick for the same job, lists user tasks rather than implementation details, always has an "Agents and safety" group and says where another tool is better (owner, 2026-10-24). Nothing changes in code.
43
+
44
+ ### Fixed
45
+
46
+ - **Denied commands remain discoverable.** The MCP and permission standards require current availability metadata instead of hiding denied commands. CLI help retains the same capabilities; execution still enforces the permission. This records the owner's discovery rule for every CLI.
47
+
48
+
49
+ - **`sync` covers a two-way mirror.** The vocabulary's meaning of `sync` now says a mirror syncs both ways, so drive's `mirrors sync`, which also writes changes back to Google, uses the verb as defined (owner, 2026-10-24). Nothing changes for one-way syncs.
50
+
51
+ - **Release checks reject high production dependency findings and audit a fresh npm resolution.** The lockfile-only
52
+ critical gate missed high findings and newer transitive resolutions. Update the reusable workflow pin to adopt
53
+ the checks; fix high findings before releasing. Package packing and resolution disable lifecycle scripts.
54
+
11
55
  ## 0.50.3 — 23.10.2026
12
56
 
13
57
  ### Added
package/README.md CHANGED
@@ -24,8 +24,8 @@ the command tree as data — the parts every CLI needs and nobody should write t
24
24
  | [Shared tooling](docs/TOOLING.md) | The TypeScript, Biome, lefthook, docs and CI setup a CLI takes from here, and `cli-dev` |
25
25
  | [Testing](docs/dev/TESTING.md) | The checks, and what each one is for |
26
26
  | [Conventions](docs/dev/CONVENTIONS.md) | How code here is written |
27
- | [Standards handbook](docs/standards/README.md) | The rules every WireCat CLI follows, one page per area (a skeleton for now) |
28
- | [CLI standard](https://github.com/WireCatLabs/cli-core/blob/main/docs/dev/STANDARD.md) | The rules every WireCat CLI follows; this package holds them as data |
27
+ | [Standards handbook](docs/standards/README.md) | The rules every WireCat CLI follows, one page per area and a new-CLI checklist |
28
+ | [CLI standard](https://github.com/WireCatLabs/cli-core/blob/main/docs/dev/STANDARD.md) | The messenger-specific rules for tg, max and wa |
29
29
  | [Roadmap](docs/roadmap.md) | What is being built next for developers on this package |
30
30
  | [Changelog](CHANGELOG.md) | What each version changed, and what may break callers |
31
31
 
@@ -113,12 +113,13 @@ record — live in `/standards` as Valibot schemas that also emit JSON Schema 20
113
113
 
114
114
  - **The whole command tree as data.** `describeProgram` walks the live Commander tree, like `rails routes`: every
115
115
  command, option, default, conflict and whether it writes. `commands --json` gives it to an agent instead of `--help`.
116
- - **A linter for command trees.** `cli-dev lint` checks a CLI's tree against 15 rules — unknown verbs, filter flags
116
+ - **A linter for command trees.** `cli-dev lint` checks a CLI's tree against 19 rules — unknown verbs, filter flags
117
117
  that should be query fields, examples that do not run, key casing and more. Known violations live in a baseline
118
118
  that may only shrink.
119
119
  - **An audit across CLIs.** `cli-dev audit` reads several CLIs' command trees and checks them against each other:
120
120
  the commands every CLI must have, the same options and output where two CLIs share a path, the same meaning for the
121
- same flag, the same entity schemas and error reasons. A weekly workflow runs it over the published CLIs.
121
+ same flag. Entity-schema and error-reason comparisons require additional inputs; the weekly published-CLI
122
+ run does not supply them and reports `n/a` for those checks.
122
123
 
123
124
  ### One query language for every list
124
125
 
@@ -0,0 +1,19 @@
1
+ import { Command } from "commander";
2
+ export interface SharedContractOverride {
3
+ command: readonly string[];
4
+ field: string;
5
+ before: unknown;
6
+ after: unknown;
7
+ reason: string;
8
+ }
9
+ export declare const hasSharedContract: (command: Command) => boolean;
10
+ export declare const assertSharedContracts: (root: Command) => void;
11
+ /** The callback names the deliberate edit; an already changed contract cannot be acknowledged after the fact. */
12
+ export declare const overrideSharedContract: <T extends Command>(command: T, reason: string, change: (command: T) => void) => T;
13
+ export declare const sharedContractOverrides: (root: Command) => SharedContractOverride[];
14
+ /** The original core definitions remain available independently of each host's accepted edits. */
15
+ export declare const sharedContracts: (root: Command) => Record<string, unknown>;
16
+ /** Preserve the factory signature while recording the definition before host composition can edit it. */
17
+ export declare const sharedCommand: <Args extends unknown[], Result>(factory: (...args: Args) => Result) => (...args: Args) => Result;
18
+ /** Bind a provider shell only when its exact, reviewed differences still match Core. */
19
+ export declare const customizeSharedCommand: (reference: Command, reason: string, build: () => Command, expectedOverrides: readonly SharedContractOverride[]) => Command;
@@ -0,0 +1,207 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+ import { Command } from "commander";
3
+ import { CliError } from "../errors.js";
4
+ import { describeProgram } from "./registry.js";
5
+ const protectedCommands = new WeakMap();
6
+ export const hasSharedContract = (command) => protectedCommands.has(command);
7
+ const snapshot = (command, children) => {
8
+ const root = new Command("contract");
9
+ const parent = command.parent;
10
+ root.addCommand(command);
11
+ const described = describeProgram(root)[0];
12
+ command.parent = parent;
13
+ if (!described)
14
+ throw new Error("a hidden command cannot own a shared contract");
15
+ const { commands: _, usage: __, path: ___, ...contract } = described;
16
+ return structuredClone({
17
+ ...contract,
18
+ aliases: [...command.aliases()],
19
+ children: (children ?? protectedCommands.get(command)?.originalChildren ?? command.commands)
20
+ .filter((child) => command.commands.includes(child) && !child._hidden)
21
+ .map((child) => child.name()),
22
+ });
23
+ };
24
+ const same = isDeepStrictEqual;
25
+ const visit = (root, fn) => {
26
+ fn(root);
27
+ for (const child of root.commands)
28
+ visit(child, fn);
29
+ };
30
+ const pathOf = (command) => {
31
+ const path = [command.name()];
32
+ for (let at = command.parent; at?.parent; at = at.parent)
33
+ path.unshift(at.name());
34
+ return path;
35
+ };
36
+ const differences = (before, after) => [...new Set([...Object.keys(before), ...Object.keys(after)])].filter((field) => !same(before[field], after[field]));
37
+ export const assertSharedContracts = (root) => {
38
+ const changed = [];
39
+ visit(root, (command) => {
40
+ const protectedContract = protectedCommands.get(command);
41
+ if (!protectedContract)
42
+ return;
43
+ const fields = differences(protectedContract.contract, snapshot(command));
44
+ if (fields.length)
45
+ changed.push({ command: pathOf(command).join(" "), fields });
46
+ });
47
+ if (changed.length)
48
+ throw new CliError("configuration_error", `shared contract changed without an explicit override: ${changed.map((one) => `${one.command} (${one.fields.join(", ")})`).join("; ")}; use overrideSharedContract before changing it`, { reason: "contract.unacknowledged-override", contracts: changed });
49
+ };
50
+ /** The callback names the deliberate edit; an already changed contract cannot be acknowledged after the fact. */
51
+ export const overrideSharedContract = (command, reason, change) => {
52
+ if (reason.trim().length < 10)
53
+ throw new Error("a shared contract override needs a specific reason");
54
+ const protectedContract = protectedCommands.get(command);
55
+ if (!protectedContract)
56
+ throw new Error("only a core-owned command contract can be overridden");
57
+ assertSharedContracts(command);
58
+ const before = snapshot(command);
59
+ change(command);
60
+ const after = snapshot(command);
61
+ const fields = differences(before, after);
62
+ if (fields.length === 0)
63
+ throw new Error("a shared contract override must change its named command");
64
+ for (const field of fields)
65
+ protectedContract.overrides.push({
66
+ command: pathOf(command),
67
+ field,
68
+ before: before[field] ?? null,
69
+ after: after[field] ?? null,
70
+ reason: reason.trim(),
71
+ });
72
+ protectedContract.contract = after;
73
+ return command;
74
+ };
75
+ export const sharedContractOverrides = (root) => {
76
+ assertSharedContracts(root);
77
+ const result = [];
78
+ visit(root, (command) => {
79
+ for (const override of protectedCommands.get(command)?.overrides ?? [])
80
+ result.push({ ...structuredClone(override), command: pathOf(command) });
81
+ });
82
+ return result;
83
+ };
84
+ /** The original core definitions remain available independently of each host's accepted edits. */
85
+ export const sharedContracts = (root) => {
86
+ assertSharedContracts(root);
87
+ const result = {};
88
+ visit(root, (command) => {
89
+ const contract = protectedCommands.get(command);
90
+ if (contract)
91
+ result[pathOf(command).join(" ")] = structuredClone(contract.reference);
92
+ });
93
+ return result;
94
+ };
95
+ /** Preserve the factory signature while recording the definition before host composition can edit it. */
96
+ export const sharedCommand = (factory) => (...args) => {
97
+ const first = args[0];
98
+ const before = new Set();
99
+ if (first instanceof Command)
100
+ visit(first, (command) => {
101
+ before.add(command);
102
+ });
103
+ const result = factory(...args);
104
+ const roots = [];
105
+ if (result instanceof Command)
106
+ roots.push(result);
107
+ else if (Array.isArray(result))
108
+ for (const item of result)
109
+ if (item instanceof Command)
110
+ roots.push(item);
111
+ if (first instanceof Command)
112
+ visit(first, (command) => {
113
+ if (!before.has(command) && command.parent && before.has(command.parent) && !roots.includes(command))
114
+ roots.push(command);
115
+ });
116
+ for (const shared of roots) {
117
+ visit(shared, (command) => {
118
+ const first = command.description().split("\n")[0] ?? "";
119
+ if (!command.summary() && first.length > 80)
120
+ command.summary(`${first.slice(0, 77).replace(/\s+\S*$/, "")}…`);
121
+ });
122
+ visit(shared, (command) => {
123
+ if (!command._hidden && !protectedCommands.has(command))
124
+ protectedCommands.set(command, {
125
+ contract: snapshot(command),
126
+ reference: snapshot(command),
127
+ overrides: [],
128
+ originalChildren: [...command.commands],
129
+ });
130
+ });
131
+ shared.hook("preAction", () => {
132
+ let root = shared;
133
+ while (root.parent)
134
+ root = root.parent;
135
+ assertSharedContracts(root);
136
+ });
137
+ }
138
+ return result;
139
+ };
140
+ /** Bind a provider shell only when its exact, reviewed differences still match Core. */
141
+ export const customizeSharedCommand = (reference, reason, build, expectedOverrides) => {
142
+ if (reason.trim().length < 10)
143
+ throw new Error("a shared contract override needs a specific reason");
144
+ assertSharedContracts(reference);
145
+ if (!hasSharedContract(reference))
146
+ throw new Error("a provider shell needs a protected shared definition");
147
+ const command = build();
148
+ assertSharedContracts(command);
149
+ const pending = [];
150
+ const edits = [];
151
+ const bindingPath = (own) => {
152
+ const path = [own.name()];
153
+ for (let at = own.parent; at && own !== command; at = at.parent) {
154
+ path.unshift(at.name());
155
+ if (at === command)
156
+ break;
157
+ }
158
+ return path;
159
+ };
160
+ const adopt = (shared, own) => {
161
+ const inherited = protectedCommands.get(shared);
162
+ if (!inherited)
163
+ return;
164
+ if (!protectedCommands.has(own)) {
165
+ const originalChildren = own.commands.filter((child) => inherited.reference.children.includes(child.name()));
166
+ const actual = snapshot(own, originalChildren);
167
+ const overrides = differences(inherited.contract, actual).map((field) => ({
168
+ command: bindingPath(own),
169
+ field,
170
+ before: inherited.contract[field] ?? null,
171
+ after: actual[field] ?? null,
172
+ reason: reason.trim(),
173
+ }));
174
+ for (const edit of overrides)
175
+ edits.push(edit);
176
+ pending.push({
177
+ command: own,
178
+ record: {
179
+ reference: structuredClone(inherited.reference),
180
+ contract: actual,
181
+ overrides: [...structuredClone(inherited.overrides), ...overrides],
182
+ originalChildren,
183
+ },
184
+ });
185
+ }
186
+ for (const child of shared.commands) {
187
+ const actual = own.commands.find((one) => one.name() === child.name());
188
+ if (actual)
189
+ adopt(child, actual);
190
+ }
191
+ };
192
+ adopt(reference, command);
193
+ const ordered = (records) => [...records].sort((a, b) => JSON.stringify([a.command, a.field]).localeCompare(JSON.stringify([b.command, b.field])));
194
+ if (!Array.isArray(expectedOverrides) || !same(ordered(edits), ordered(expectedOverrides)))
195
+ throw new CliError("configuration_error", `provider shell ${command.name()} differs from its reviewed shared contract overrides; review Core and provider definitions before updating the explicit declarations`, { reason: "contract.unacknowledged-override", expected: expectedOverrides ?? null, actual: edits });
196
+ for (const { command: own, record } of pending) {
197
+ protectedCommands.set(own, record);
198
+ own.hook("preAction", () => {
199
+ let root = own;
200
+ while (root.parent)
201
+ root = root.parent;
202
+ assertSharedContracts(root);
203
+ });
204
+ }
205
+ assertSharedContracts(command);
206
+ return command;
207
+ };
@@ -6,7 +6,7 @@
6
6
  * a command came from, whether it changes anything, whether it is on its way out — is attached with
7
7
  * `annotate`, and to an option with `annotateOption`.
8
8
  *
9
- * Behind its own entry point for the same reason `/http` is: Commander is only a type here, and the
9
+ * Behind its own entry point for the same reason `/http` is: Commander is loaded by contract guards here, and the
10
10
  * root of this package stays free of any command-line framework.
11
11
  */
12
12
  import type { Command } from "commander";
@@ -20,3 +20,4 @@ export * from "./registry.js";
20
20
  * `prefix` keeps those caller-supplied words between the tool name and path in the hint.
21
21
  */
22
22
  export declare const refuseJoinedPath: (root: Command, args: readonly string[], prefix?: readonly string[]) => void;
23
+ export { assertSharedContracts, customizeSharedCommand, hasSharedContract, overrideSharedContract, type SharedContractOverride, sharedCommand, sharedContractOverrides, sharedContracts, } from "./contracts.js";
@@ -24,3 +24,4 @@ export const refuseJoinedPath = (root, args, prefix = []) => {
24
24
  command = next;
25
25
  }
26
26
  };
27
+ export { assertSharedContracts, customizeSharedCommand, hasSharedContract, overrideSharedContract, sharedCommand, sharedContractOverrides, sharedContracts, } from "./contracts.js";
@@ -3,12 +3,16 @@ import { type AuditRule, type ParityGroup, type RequiredRoot } from "../standard
3
3
  import { type ErrorReason } from "../standards/errors.js";
4
4
  import { type Override } from "../standards/lint.js";
5
5
  import type { Vocabulary } from "../standards/vocabulary.js";
6
+ import { type Capabilities } from "./capabilities.js";
7
+ import { type ContractEdit } from "./shared-contracts.js";
6
8
  export interface AuditInput {
7
9
  document: CommandsDocument;
8
10
  /** As the CLI printed it, for `key-casing`. */
9
11
  raw: unknown;
10
12
  schemas?: Readonly<Record<string, unknown>>;
11
13
  reasons?: readonly ErrorReason[];
14
+ contracts?: Readonly<Record<string, Readonly<Record<string, unknown>>>>;
15
+ overrides?: readonly ContractEdit[];
12
16
  /** The CLI's own `lint/` baseline and overrides; its lint rules are then judged by them, not the audit's. */
13
17
  lint?: {
14
18
  baseline: Readonly<Record<string, readonly string[]>>;
@@ -41,6 +45,7 @@ export interface AuditOptions {
41
45
  vocabulary: Vocabulary;
42
46
  /** `audit/baseline/<rule>.json`, by rule. */
43
47
  baseline: Readonly<Record<string, readonly string[]>>;
48
+ capabilities?: Capabilities;
44
49
  blocks: "new" | "never";
45
50
  /** Only new findings that name this CLI block: the one being released. */
46
51
  only?: string;
package/dist/dev/audit.js CHANGED
@@ -1,7 +1,17 @@
1
1
  import { AUDIT_RULES } from "../standards/audit.js";
2
2
  import { BASE_REASONS } from "../standards/errors.js";
3
3
  import { LINT_RULES, OVERRIDE_BUDGET } from "../standards/lint.js";
4
+ import { capabilityDifference } from "./capabilities.js";
4
5
  import { judge, kindOf, lintCommands, longName, summaryOf, walk } from "./lint.js";
6
+ import { sharedContractProblems } from "./shared-contracts.js";
7
+ const STRICT_CONTRACT_RULES = new Set([
8
+ "shared-command-contract",
9
+ "same-path-contract",
10
+ "same-flag-meaning",
11
+ "same-entity-schema",
12
+ "same-error-reason",
13
+ "required-root",
14
+ ]);
5
15
  const byText = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
6
16
  const toSnake = (key) => key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
7
17
  const VALUE_KEYS = new Set(["default", "implies"]);
@@ -66,8 +76,16 @@ const samePathContract = (inputs, groups) => {
66
76
  message: describe(values),
67
77
  });
68
78
  };
69
- const kinds = new Map([...commands].map(([cli, command]) => [cli, optionKinds(command.options)]));
70
- const flags = [...new Set([...kinds.values()].flatMap((one) => [...one.keys()]))].sort(byText);
79
+ const kinds = new Map([...commands].map(([cli, command]) => [
80
+ cli,
81
+ optionKinds([
82
+ ...(inputs.find((input) => input.document.cli === cli)?.document.global_options ?? []),
83
+ ...command.options,
84
+ ]),
85
+ ]));
86
+ const flags = [
87
+ ...new Set([...commands.values()].flatMap((command) => [...optionKinds(command.options).keys()])),
88
+ ].sort(byText);
71
89
  for (const flag of flags)
72
90
  push(` ${flag}`, new Map([...kinds].map(([cli, one]) => [cli, shownKind(one.get(flag))])));
73
91
  push(": output", new Map([...commands].map(([cli, command]) => [
@@ -166,6 +184,14 @@ const sameErrorReason = (inputs) => {
166
184
  return found;
167
185
  };
168
186
  const CROSS = {
187
+ "shared-command-contract": (inputs) => inputs.flatMap((input) => input.contracts
188
+ ? sharedContractProblems(snakeKeys(input.raw).commands, input.contracts, input.overrides).map((problem) => ({
189
+ rule: "shared-command-contract",
190
+ clis: [input.document.cli],
191
+ target: `${input.document.cli} ${problem.path}`,
192
+ message: problem.message,
193
+ }))
194
+ : []),
169
195
  "required-root": (inputs, options) => requiredRoots(inputs, options.roots),
170
196
  "same-path-contract": (inputs, options) => samePathContract(inputs, options.groups),
171
197
  "same-flag-meaning": (inputs) => sameFlagMeaning(inputs),
@@ -173,6 +199,7 @@ const CROSS = {
173
199
  "same-error-reason": (inputs) => sameErrorReason(inputs),
174
200
  };
175
201
  const measuredBy = {
202
+ "shared-command-contract": (input) => input.contracts !== undefined,
176
203
  "same-entity-schema": (input) => input.schemas !== undefined,
177
204
  "same-error-reason": (input) => input.reasons !== undefined,
178
205
  };
@@ -208,15 +235,44 @@ export const auditClis = (inputs, options, rules = AUDIT_RULES) => {
208
235
  return check(inputs, options);
209
236
  }),
210
237
  ];
238
+ const protectedInputs = new Set(inputs
239
+ .filter((input) => input.contracts &&
240
+ sharedContractProblems(snakeKeys(input.raw).commands, input.contracts, input.overrides)
241
+ .length === 0)
242
+ .map((input) => input.document.cli));
243
+ const inheritedAccepted = new Set(findings
244
+ .filter((finding) => {
245
+ if (finding.rule !== "same-path-contract" || finding.target.endsWith(": presence"))
246
+ return false;
247
+ const path = finding.target.slice(finding.target.indexOf(" ") + 1).split(/ --|: /)[0] ?? "";
248
+ return finding.clis.every((cli) => protectedInputs.has(cli) && inputs.find((input) => input.document.cli === cli)?.contracts?.[path]);
249
+ })
250
+ .map((finding) => `${finding.rule} ${finding.target}`));
251
+ const capabilityAccepted = new Set(findings
252
+ .filter((finding) => finding.rule === "same-path-contract" &&
253
+ options.capabilities &&
254
+ capabilityDifference(finding.target, finding.clis, inputs.map((input) => input.document), options.capabilities))
255
+ .map((finding) => `${finding.rule} ${finding.target}`));
211
256
  const declared = new Map(options.groups.flatMap((group) => group.differences.map((entry) => [`${entry.rule} ${entry.target}`, group.name])));
212
257
  const found = new Set(findings.map((finding) => `${finding.rule} ${finding.target}`));
213
258
  for (const [key, group] of declared)
214
259
  if (!found.has(key))
215
260
  problems.push(`stale difference in ${group}, no such divergence — remove it: ${key}`);
261
+ for (const group of options.groups) {
262
+ for (const difference of group.differences) {
263
+ const finding = findings.find((one) => one.rule === difference.rule && one.target === difference.target);
264
+ if (finding && difference.expected !== finding.message) {
265
+ declared.delete(`${difference.rule} ${difference.target}`);
266
+ problems.push(`difference in ${group.name} must match the exact current values: ${difference.rule} ${difference.target}`);
267
+ }
268
+ }
269
+ }
216
270
  for (const group of options.groups)
217
271
  if (group.differences.length > OVERRIDE_BUDGET)
218
272
  problems.push(`${group.name} declares ${group.differences.length} differences; the budget is ${OVERRIDE_BUDGET}`);
219
- const baselined = new Set(Object.entries(options.baseline).flatMap(([rule, targets]) => targets.map((target) => `${rule} ${target}`)));
273
+ const baselined = new Set(Object.entries(options.baseline)
274
+ .filter(([rule]) => !STRICT_CONTRACT_RULES.has(rule))
275
+ .flatMap(([rule, targets]) => targets.map((target) => `${rule} ${target}`)));
220
276
  // A cross-CLI entry names every CLI it compared; a run without one of them can neither see it nor fix it.
221
277
  const judged = (target) => clisOf(target).every((cli) => clis.includes(cli));
222
278
  const ownLint = (cli, rule) => lintIds.includes(rule) ? inputs.find((input) => input.document.cli === cli)?.lint : undefined;
@@ -245,7 +301,9 @@ export const auditClis = (inputs, options, rules = AUDIT_RULES) => {
245
301
  blocking.push(...mine.filter((finding) => row.new_targets.includes(finding.target)));
246
302
  }
247
303
  else {
248
- const open = mine.filter((finding) => !declared.has(`${rule} ${finding.target}`));
304
+ const open = mine.filter((finding) => !declared.has(`${rule} ${finding.target}`) &&
305
+ !capabilityAccepted.has(`${rule} ${finding.target}`) &&
306
+ !inheritedAccepted.has(`${rule} ${finding.target}`));
249
307
  const fresh = open.filter((finding) => !baselined.has(`${rule} ${finding.target}`));
250
308
  const here = new Set(mine.map((finding) => finding.target));
251
309
  row = {
@@ -272,10 +330,13 @@ export const auditClis = (inputs, options, rules = AUDIT_RULES) => {
272
330
  const nextBaseline = Object.fromEntries([...lintIds, ...rules.map((rule) => rule.id)].map((rule) => {
273
331
  const kept = (options.baseline[rule] ?? []).filter((target) => !judged(target));
274
332
  const now = findings
275
- .filter((finding) => finding.rule === rule && !declared.has(`${rule} ${finding.target}`))
333
+ .filter((finding) => finding.rule === rule &&
334
+ !declared.has(`${rule} ${finding.target}`) &&
335
+ !capabilityAccepted.has(`${rule} ${finding.target}`) &&
336
+ !inheritedAccepted.has(`${rule} ${finding.target}`))
276
337
  .filter((finding) => !(lintIds.includes(rule) && selfJudged.has(finding.clis[0] ?? "")))
277
338
  .map((finding) => finding.target);
278
- return [rule, [...new Set([...kept, ...now])].sort(byText)];
339
+ return [rule, STRICT_CONTRACT_RULES.has(rule) ? kept : [...new Set([...kept, ...now])].sort(byText)];
279
340
  }));
280
341
  const unique = new Map(blocking.map((finding) => [`${finding.rule} ${finding.target}`, finding]));
281
342
  return {
@@ -0,0 +1,20 @@
1
+ import * as v from "valibot";
2
+ import type { CommandsDocument } from "../commands/index.js";
3
+ declare const manifestSchema: v.ObjectSchema<{
4
+ readonly clis: v.ArraySchema<v.StringSchema<undefined>, undefined>;
5
+ readonly commands: v.RecordSchema<v.StringSchema<undefined>, v.ObjectSchema<{
6
+ readonly in: v.UnionSchema<[v.LiteralSchema<"all", undefined>, v.ArraySchema<v.StringSchema<undefined>, undefined>], undefined>;
7
+ readonly reason: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
8
+ readonly planned: v.OptionalSchema<v.RecordSchema<v.StringSchema<undefined>, v.StringSchema<undefined>, undefined>, undefined>;
9
+ readonly options: v.OptionalSchema<v.RecordSchema<v.StringSchema<undefined>, v.UnionSchema<[v.LiteralSchema<"all", undefined>, v.ObjectSchema<{
10
+ readonly in: v.UnionSchema<[v.LiteralSchema<"all", undefined>, v.ArraySchema<v.StringSchema<undefined>, undefined>], undefined>;
11
+ readonly reason: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
12
+ readonly planned: v.OptionalSchema<v.RecordSchema<v.StringSchema<undefined>, v.StringSchema<undefined>, undefined>, undefined>;
13
+ }, undefined>], undefined>, undefined>, undefined>;
14
+ }, undefined>, undefined>;
15
+ }, undefined>;
16
+ export type Capabilities = v.InferOutput<typeof manifestSchema>;
17
+ export declare const readCapabilities: (input: unknown) => Capabilities;
18
+ /** Presence differences are accepted only when every observed value matches a declared provider capability. */
19
+ export declare const capabilityDifference: (target: string, clis: readonly string[], documents: readonly CommandsDocument[], manifest: Capabilities) => boolean;
20
+ export {};
@@ -0,0 +1,48 @@
1
+ import * as v from "valibot";
2
+ import { kindOf, longName, walk } from "./lint.js";
3
+ const availability = v.union([v.literal("all"), v.array(v.string())]);
4
+ const entry = v.object({
5
+ in: availability,
6
+ reason: v.optional(v.string()),
7
+ planned: v.optional(v.record(v.string(), v.string())),
8
+ });
9
+ const option = v.union([v.literal("all"), entry]);
10
+ const manifestSchema = v.object({
11
+ clis: v.array(v.string()),
12
+ commands: v.record(v.string(), v.object({ ...entry.entries, options: v.optional(v.record(v.string(), option)) })),
13
+ });
14
+ export const readCapabilities = (input) => v.parse(manifestSchema, input);
15
+ /** Presence differences are accepted only when every observed value matches a declared provider capability. */
16
+ export const capabilityDifference = (target, clis, documents, manifest) => {
17
+ if (clis.some((cli) => !manifest.clis.includes(cli)))
18
+ return false;
19
+ const words = target.slice(target.indexOf(" ") + 1);
20
+ if (words.endsWith(": presence")) {
21
+ const path = words.slice(0, -10);
22
+ const ancestor = path
23
+ .split(" ")
24
+ .map((_, index, parts) => parts.slice(0, parts.length - index).join(" "))
25
+ .find((name) => manifest.commands[name] !== undefined);
26
+ const policy = ancestor ? manifest.commands[ancestor] : undefined;
27
+ if (!policy || policy.in === "all" || (!policy.reason && !policy.planned))
28
+ return false;
29
+ return clis.every((cli) => walk(documents.find((doc) => doc.cli === cli)?.commands ?? []).some((command) => command.path.join(" ") === path) === policy.in.includes(cli));
30
+ }
31
+ const flagAt = words.lastIndexOf(" --");
32
+ if (flagAt < 0)
33
+ return false;
34
+ const path = words.slice(0, flagAt), flag = words.slice(flagAt + 1);
35
+ const policy = manifest.commands[path]?.options?.[flag];
36
+ if (!policy || policy === "all" || policy.in === "all" || (!policy.reason && !policy.planned))
37
+ return false;
38
+ const signatures = new Set();
39
+ const matches = clis.every((cli) => {
40
+ const doc = documents.find((doc) => doc.cli === cli);
41
+ const command = walk(doc?.commands ?? []).find((command) => command.path.join(" ") === path);
42
+ const option = [...(doc?.global_options ?? []), ...(command?.options ?? [])].find((option) => longName(option.flags) === flag);
43
+ if (option)
44
+ signatures.add(kindOf(option));
45
+ return (option !== undefined) === policy.in.includes(cli);
46
+ });
47
+ return matches && signatures.size <= 1;
48
+ };
package/dist/dev/lint.js CHANGED
@@ -173,14 +173,16 @@ const filteredBy = (flag) => {
173
173
  return { window: "id" };
174
174
  return { field: bare.replace(/-(time|id|n)$/, "").replaceAll("-", "_") };
175
175
  };
176
- /** An entity's fields: the query's, then its registered schema's properties; `time` for a date-time. */
176
+ /** A declared query is authoritative; without one, scalar schema fields reveal legacy list filters. */
177
177
  const entityFields = (command, context) => {
178
178
  const fields = new Map((command.query?.fields ?? []).map((field) => [field.name, field.type]));
179
- const entity = command.output?.entity ?? command.query?.entity;
179
+ if (command.query)
180
+ return fields;
181
+ const entity = command.output?.entity;
180
182
  const schema = entity === undefined ? undefined : context.schemas?.[entity];
181
183
  const properties = isObject(schema) && isObject(schema.properties) ? schema.properties : {};
182
184
  for (const [name, property] of Object.entries(properties))
183
- if (!fields.has(name))
185
+ if (!fields.has(name) && !typesOf(property).includes("array"))
184
186
  fields.set(name, isObject(property) && property.format === "date-time" ? "time" : "other");
185
187
  return fields;
186
188
  };
@@ -299,11 +301,22 @@ const CHECKS = {
299
301
  "filter-flag": (document, vocabulary, context) => {
300
302
  const roles = new Map(vocabulary.flags.map((flag) => [flag.name, flag.role]));
301
303
  return walk(document.commands)
302
- .filter((command) => command.origin === "handwritten" && !declaredWrite(command) && (command.query || command.output))
304
+ .filter((command) => command.origin === "handwritten" &&
305
+ !declaredWrite(command) &&
306
+ (command.query || (["list", "events", "runs"].includes(command.name) && command.output)))
303
307
  .flatMap((command) => {
304
308
  const fields = entityFields(command, context);
305
309
  return command.options
306
- .filter((option) => roles.get(longName(option.flags)) !== "scope")
310
+ .filter((option) => {
311
+ const flag = longName(option.flags), role = roles.get(flag);
312
+ if (role === "scope")
313
+ return false;
314
+ const { field, window } = filteredBy(flag);
315
+ const queryField = command.query?.fields.some((one) => one.name === field);
316
+ return (window !== undefined ||
317
+ queryField ||
318
+ !["paging", "output", "sort", "mode", "budget", "action"].includes(role ?? ""));
319
+ })
307
320
  .flatMap((option) => {
308
321
  const { field, window } = filteredBy(longName(option.flags));
309
322
  const hit = window === "time"