@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 +44 -0
- package/README.md +5 -4
- package/dist/commands/contracts.d.ts +19 -0
- package/dist/commands/contracts.js +207 -0
- package/dist/commands/index.d.ts +2 -1
- package/dist/commands/index.js +1 -0
- package/dist/dev/audit.d.ts +5 -0
- package/dist/dev/audit.js +67 -6
- package/dist/dev/capabilities.d.ts +20 -0
- package/dist/dev/capabilities.js +48 -0
- package/dist/dev/lint.js +18 -5
- package/dist/dev/main.js +76 -6
- package/dist/dev/shared-contracts.d.ts +13 -0
- package/dist/dev/shared-contracts.js +85 -0
- package/dist/standards/audit.d.ts +1 -0
- package/dist/standards/audit.js +11 -1
- package/dist/standards/flags-how.js +6 -0
- package/dist/standards/flags-what.js +35 -0
- package/dist/standards/vocabulary.js +10 -1
- package/docs/standards/README.md +1 -0
- package/docs/standards/accounts.md +1 -2
- package/docs/standards/ci.md +5 -4
- package/docs/standards/commands.md +2 -2
- package/docs/standards/comparisons.md +133 -0
- package/docs/standards/mcp.md +27 -29
- package/docs/standards/permissions.md +1 -1
- package/package.json +1 -1
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
|
|
28
|
-
| [CLI standard](https://github.com/WireCatLabs/cli-core/blob/main/docs/dev/STANDARD.md) | The rules
|
|
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
|
|
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
|
|
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
|
+
};
|
package/dist/commands/index.d.ts
CHANGED
|
@@ -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
|
|
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";
|
package/dist/commands/index.js
CHANGED
|
@@ -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";
|
package/dist/dev/audit.d.ts
CHANGED
|
@@ -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]) => [
|
|
70
|
-
|
|
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)
|
|
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 &&
|
|
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
|
-
/**
|
|
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
|
-
|
|
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" &&
|
|
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) =>
|
|
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"
|