@wirecat/cli-base 0.50.4 → 0.51.1
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 +31 -0
- 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 +12 -0
- package/dist/standards/flags-what.js +7 -0
- package/docs/standards/README.md +2 -2
- package/docs/standards/comparisons.md +19 -4
- package/docs/standards/new-cli.md +2 -2
- package/docs/standards/releases.md +10 -22
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,37 @@ 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.1 — 24.10.2026
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- The shared vocabulary recognizes `--draft` as a switch that saves text for review without sending. Enabling an adapter’s draft capability no longer creates an unknown-option-role lint failure.
|
|
16
|
+
|
|
17
|
+
### Removed
|
|
18
|
+
|
|
19
|
+
- **No wait between releases and no weekly limit on breaking changes** (`WC-REL-8`, [Releases](docs/standards/releases.md)). A messenger CLI no longer waits two hours after its last version, and a shared library may ship a change that makes the CLIs change their code at any time. `WC-REL-9` is merged into `WC-REL-8`. The release lock stays. Nothing changes in code.
|
|
20
|
+
|
|
21
|
+
## 0.51.0 — 24.10.2026
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- **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.
|
|
26
|
+
|
|
27
|
+
### Changed — may break callers
|
|
28
|
+
|
|
29
|
+
- **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.
|
|
30
|
+
|
|
31
|
+
- **Cross-CLI contract drift cannot be accepted through a lint baseline.** Declare intentional differences with
|
|
32
|
+
their exact expected values and a specific reason; a changed or stale exemption fails. `audit --complete`
|
|
33
|
+
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
|
|
34
|
+
options satisfy matching command-local controls, so their placement alone is no longer a divergence.
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
|
|
38
|
+
- **Complete audits keep runtime availability separate from static contracts.** A currently denied or available command retains the same protected grammar and output definition.
|
|
39
|
+
|
|
40
|
+
- **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.
|
|
41
|
+
|
|
11
42
|
## 0.50.4 — 24.10.2026
|
|
12
43
|
|
|
13
44
|
### Added
|
|
@@ -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"
|
package/dist/dev/main.js
CHANGED
|
@@ -11,12 +11,13 @@ import spawn from "cross-spawn";
|
|
|
11
11
|
import * as v from "valibot";
|
|
12
12
|
import { parseCommandsDocument } from "../commands/document.js";
|
|
13
13
|
import { changelogProblems, docsProblems, JOURNAL_IDS, markdownFiles, packageVersion, structureProblems, versionScript, } from "../release/index.js";
|
|
14
|
-
import { AUDIT_BLOCKS, PARITY_GROUPS, REQUIRED_ROOTS } from "../standards/audit.js";
|
|
14
|
+
import { AUDIT_BLOCKS, defineParityGroups, PARITY_GROUPS, REQUIRED_ROOTS } from "../standards/audit.js";
|
|
15
15
|
import { defineReasons } from "../standards/errors.js";
|
|
16
16
|
import { baselineProblems, baselineSchema, LINT_RULES, OVERRIDE_BUDGET, OVERRIDE_RULE_REVIEW, overridesProblems, overridesSchema, rulesForReview, } from "../standards/lint.js";
|
|
17
17
|
import { issuesOf } from "../standards/registry.js";
|
|
18
18
|
import { VOCABULARY } from "../standards/vocabulary.js";
|
|
19
19
|
import { auditClis, scorecardTable, snakeKeys } from "./audit.js";
|
|
20
|
+
import { readCapabilities } from "./capabilities.js";
|
|
20
21
|
import { changelogChange, landFold, processShell } from "./land.js";
|
|
21
22
|
import { baselineOf, judge, lintCommands } from "./lint.js";
|
|
22
23
|
import { nextVersion } from "./next-version.js";
|
|
@@ -48,7 +49,7 @@ const USAGE = `usage: cli-dev <command> [options]
|
|
|
48
49
|
lint/baseline/<rule>.json to today's violations. --schemas
|
|
49
50
|
is a JSON object of the CLI's own JSON Schemas by name
|
|
50
51
|
audit (--cli <bin> | --commands <file>)... [--schemas <cli>=<file>]... [--reasons <cli>=<file>]...
|
|
51
|
-
[--lint <cli>=<dir>]... [--baseline <dir>] [--for <cli>] [--json] [--write-baseline]
|
|
52
|
+
[--lint <cli>=<dir>]... [--capabilities <file>] [--complete] [--differences <file>] [--baseline <dir>] [--for <cli>] [--json] [--write-baseline]
|
|
52
53
|
several CLIs against every lint rule and against each other:
|
|
53
54
|
required roots, same path same contract, same flag same
|
|
54
55
|
meaning, same entity same schema, same failure same reason.
|
|
@@ -186,12 +187,12 @@ const readJson = (path) => {
|
|
|
186
187
|
throw new Error(`${path}: ${error.message}`);
|
|
187
188
|
}
|
|
188
189
|
};
|
|
189
|
-
const commandsJson = (io, cli, file) => {
|
|
190
|
+
const commandsJson = (io, cli, file, commandArgs = ["commands", "--json"]) => {
|
|
190
191
|
if (file)
|
|
191
192
|
return readJson(isAbsolute(file) ? file : join(io.cwd, file));
|
|
192
193
|
const bin = cli ?? "";
|
|
193
194
|
const script = /\.[cm]?js$/.test(bin);
|
|
194
|
-
const run = spawn.sync(script ? process.execPath : bin, [...(script ? [bin] : []),
|
|
195
|
+
const run = spawn.sync(script ? process.execPath : bin, [...(script ? [bin] : []), ...commandArgs], {
|
|
195
196
|
cwd: io.cwd,
|
|
196
197
|
encoding: "utf8",
|
|
197
198
|
maxBuffer: 64 * 1024 * 1024,
|
|
@@ -331,6 +332,9 @@ const auditRun = (args, io) => {
|
|
|
331
332
|
cli: { type: "string", multiple: true },
|
|
332
333
|
commands: { type: "string", multiple: true },
|
|
333
334
|
schemas: { type: "string", multiple: true },
|
|
335
|
+
capabilities: { type: "string" },
|
|
336
|
+
complete: { type: "boolean", default: false },
|
|
337
|
+
differences: { type: "string" },
|
|
334
338
|
reasons: { type: "string", multiple: true },
|
|
335
339
|
lint: { type: "string", multiple: true },
|
|
336
340
|
baseline: { type: "string", default: "audit/baseline" },
|
|
@@ -352,18 +356,60 @@ const auditRun = (args, io) => {
|
|
|
352
356
|
const reasons = byCli(values.reasons, "--reasons");
|
|
353
357
|
const lintDirs = byCli(values.lint, "--lint");
|
|
354
358
|
const problems = [];
|
|
359
|
+
const evidence = new Map();
|
|
360
|
+
if (values.complete) {
|
|
361
|
+
for (const [index, bin] of (values.cli ?? []).entries()) {
|
|
362
|
+
const raw = raws[index];
|
|
363
|
+
const data = commandsJson(io, bin, undefined, ["commands", "info", "--json"]);
|
|
364
|
+
if (typeof raw.cli === "string")
|
|
365
|
+
evidence.set(raw.cli, data);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
355
368
|
const inputs = raws.map((raw) => {
|
|
356
369
|
const parsed = parseCommandsDocument(snakeKeys(raw));
|
|
357
370
|
if (!parsed.document)
|
|
358
371
|
throw new Error(`a commands document is not the shape cli-base's commandsDocument() writes: ${parsed.problems[0]}`);
|
|
359
372
|
const cli = parsed.document.cli;
|
|
360
|
-
const input = { document: parsed.document, raw };
|
|
373
|
+
const input = { document: parsed.document, raw, ...evidence.get(cli) };
|
|
361
374
|
const schemaFile = schemas.get(cli);
|
|
362
375
|
if (schemaFile)
|
|
363
376
|
input.schemas = readJson(at(schemaFile));
|
|
364
377
|
const reasonFile = reasons.get(cli);
|
|
365
378
|
if (reasonFile)
|
|
366
379
|
input.reasons = defineReasons(readJson(at(reasonFile)));
|
|
380
|
+
if (values.complete) {
|
|
381
|
+
if (!input.contracts ||
|
|
382
|
+
typeof input.contracts !== "object" ||
|
|
383
|
+
Array.isArray(input.contracts) ||
|
|
384
|
+
Object.keys(input.contracts).length === 0)
|
|
385
|
+
problems.push(`${cli}: complete audit needs core contract references from commands info`);
|
|
386
|
+
for (const root of REQUIRED_ROOTS) {
|
|
387
|
+
const path = root.path.join(" ");
|
|
388
|
+
if (!input.contracts?.[path])
|
|
389
|
+
problems.push(`${cli}: complete audit needs an inherited contract reference for ${path}`);
|
|
390
|
+
}
|
|
391
|
+
if (!input.schemas ||
|
|
392
|
+
typeof input.schemas !== "object" ||
|
|
393
|
+
Array.isArray(input.schemas) ||
|
|
394
|
+
Object.keys(input.schemas).length === 0)
|
|
395
|
+
problems.push(`${cli}: complete audit needs nonempty entity schemas from commands info or --schemas`);
|
|
396
|
+
if (!Array.isArray(input.reasons) || input.reasons.length === 0)
|
|
397
|
+
problems.push(`${cli}: complete audit needs declared error reasons from commands info or --reasons`);
|
|
398
|
+
if (input.reasons)
|
|
399
|
+
input.reasons = defineReasons(input.reasons);
|
|
400
|
+
if (input.schemas) {
|
|
401
|
+
const visit = (commands) => {
|
|
402
|
+
for (const command of commands) {
|
|
403
|
+
const names = [command.output?.entity, command.query?.entity].filter((name) => name !== undefined);
|
|
404
|
+
for (const name of names)
|
|
405
|
+
if (input.schemas?.[name] === undefined)
|
|
406
|
+
problems.push(`${cli}: complete audit has no schema for ${name} (${command.path.join(" ")})`);
|
|
407
|
+
visit(command.commands);
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
visit(input.document.commands);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
367
413
|
const dir = lintDirs.get(cli);
|
|
368
414
|
if (dir) {
|
|
369
415
|
const overridesPath = join(at(dir), "overrides.json");
|
|
@@ -398,8 +444,32 @@ const auditRun = (args, io) => {
|
|
|
398
444
|
problems.push(`--for names ${values.for}, which is not audited`);
|
|
399
445
|
const baselineDir = at(values.baseline);
|
|
400
446
|
const baseline = readBaseline(baselineDir, values.baseline, problems);
|
|
447
|
+
let groups = PARITY_GROUPS;
|
|
448
|
+
if (values.differences) {
|
|
449
|
+
const schema = v.array(v.strictObject({
|
|
450
|
+
group: v.string(),
|
|
451
|
+
rule: v.string(),
|
|
452
|
+
target: v.string(),
|
|
453
|
+
expected: v.string(),
|
|
454
|
+
reason: v.string(),
|
|
455
|
+
}));
|
|
456
|
+
const parsed = v.safeParse(schema, readJson(at(values.differences)));
|
|
457
|
+
if (!parsed.success)
|
|
458
|
+
throw new Error(`${values.differences}: ${issuesOf(parsed.issues).join("; ")}`);
|
|
459
|
+
for (const entry of parsed.output)
|
|
460
|
+
if (!groups.some((group) => group.name === entry.group))
|
|
461
|
+
throw new Error(`${values.differences}: unknown group ${entry.group}`);
|
|
462
|
+
groups = defineParityGroups(groups.map((group) => ({
|
|
463
|
+
...group,
|
|
464
|
+
differences: [
|
|
465
|
+
...group.differences,
|
|
466
|
+
...parsed.output.filter((entry) => entry.group === group.name).map(({ group: _, ...entry }) => entry),
|
|
467
|
+
],
|
|
468
|
+
})));
|
|
469
|
+
}
|
|
401
470
|
const result = auditClis(inputs, {
|
|
402
|
-
|
|
471
|
+
...(values.capabilities ? { capabilities: readCapabilities(readJson(at(values.capabilities))) } : {}),
|
|
472
|
+
groups,
|
|
403
473
|
roots: REQUIRED_ROOTS,
|
|
404
474
|
vocabulary: VOCABULARY,
|
|
405
475
|
baseline,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { CommandInfo } from "../commands/index.js";
|
|
2
|
+
export interface ContractEdit {
|
|
3
|
+
command: readonly string[];
|
|
4
|
+
field: string;
|
|
5
|
+
before: unknown;
|
|
6
|
+
after: unknown;
|
|
7
|
+
reason: string;
|
|
8
|
+
}
|
|
9
|
+
/** A baseline cannot replace a core definition: only an exact, reasoned edit changes its effective value. */
|
|
10
|
+
export declare const sharedContractProblems: (commands: readonly CommandInfo[], references: Readonly<Record<string, Readonly<Record<string, unknown>>>>, edits?: readonly ContractEdit[]) => {
|
|
11
|
+
path: string;
|
|
12
|
+
message: string;
|
|
13
|
+
}[];
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
const stable = (value) => {
|
|
2
|
+
if (Array.isArray(value))
|
|
3
|
+
return value.map(stable);
|
|
4
|
+
if (value && typeof value === "object")
|
|
5
|
+
return Object.fromEntries(Object.entries(value)
|
|
6
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
7
|
+
.map(([key, child]) => [key, stable(child)]));
|
|
8
|
+
return value;
|
|
9
|
+
};
|
|
10
|
+
const same = (left, right) => JSON.stringify(stable(left ?? null)) === JSON.stringify(stable(right ?? null));
|
|
11
|
+
const FIELDS = new Set([
|
|
12
|
+
"name",
|
|
13
|
+
"summary",
|
|
14
|
+
"description",
|
|
15
|
+
"origin",
|
|
16
|
+
"api_operation",
|
|
17
|
+
"effects",
|
|
18
|
+
"state",
|
|
19
|
+
"partial",
|
|
20
|
+
"lifetime",
|
|
21
|
+
"reaches",
|
|
22
|
+
"mcp",
|
|
23
|
+
"offline",
|
|
24
|
+
"cooperative",
|
|
25
|
+
"examples",
|
|
26
|
+
"output",
|
|
27
|
+
"query",
|
|
28
|
+
"runnable",
|
|
29
|
+
"arguments",
|
|
30
|
+
"options",
|
|
31
|
+
"aliases",
|
|
32
|
+
"children",
|
|
33
|
+
]);
|
|
34
|
+
/** A baseline cannot replace a core definition: only an exact, reasoned edit changes its effective value. */
|
|
35
|
+
export const sharedContractProblems = (commands, references, edits = []) => {
|
|
36
|
+
const effective = new Map(Object.entries(references).map(([path, contract]) => [path, structuredClone(contract)]));
|
|
37
|
+
const problems = [];
|
|
38
|
+
for (const edit of edits) {
|
|
39
|
+
const path = edit.command.join(" ");
|
|
40
|
+
const contract = effective.get(path);
|
|
41
|
+
if (!contract ||
|
|
42
|
+
!FIELDS.has(edit.field) ||
|
|
43
|
+
edit.reason.trim().length < 10 ||
|
|
44
|
+
!same(contract[edit.field], edit.before) ||
|
|
45
|
+
same(edit.before, edit.after)) {
|
|
46
|
+
problems.push({ path, message: `invalid or stale explicit override for ${edit.field}` });
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (edit.after === null)
|
|
50
|
+
delete contract[edit.field];
|
|
51
|
+
else
|
|
52
|
+
contract[edit.field] = structuredClone(edit.after);
|
|
53
|
+
}
|
|
54
|
+
const walk = (items, inherited) => {
|
|
55
|
+
for (const command of items) {
|
|
56
|
+
const path = command.path.join(" ");
|
|
57
|
+
const expected = effective.get(path);
|
|
58
|
+
if (expected) {
|
|
59
|
+
const { path: _, usage: __, commands: nested, ...fields } = command;
|
|
60
|
+
const original = references[path]?.children;
|
|
61
|
+
const actual = {
|
|
62
|
+
...fields,
|
|
63
|
+
...(Array.isArray(original)
|
|
64
|
+
? { children: nested.filter((child) => original.includes(child.name)).map((child) => child.name) }
|
|
65
|
+
: {}),
|
|
66
|
+
};
|
|
67
|
+
const wanted = { ...expected, ...inherited };
|
|
68
|
+
const changed = [...new Set([...Object.keys(actual), ...Object.keys(wanted)])].filter((field) => field !== "aliases" &&
|
|
69
|
+
field !== "availability" &&
|
|
70
|
+
!same(actual[field], wanted[field]));
|
|
71
|
+
if (changed.length)
|
|
72
|
+
problems.push({ path, message: `core contract differs without an exact override: ${changed.join(", ")}` });
|
|
73
|
+
effective.delete(path);
|
|
74
|
+
}
|
|
75
|
+
walk(command.commands, {
|
|
76
|
+
...(inherited.mcp === false || expected?.mcp === false ? { mcp: false } : {}),
|
|
77
|
+
...(inherited.offline === false || expected?.offline === false ? { offline: false } : {}),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
walk(commands, {});
|
|
82
|
+
for (const path of effective.keys())
|
|
83
|
+
problems.push({ path, message: "core contract names a command absent from discovery" });
|
|
84
|
+
return problems;
|
|
85
|
+
};
|
|
@@ -23,6 +23,7 @@ declare const parityGroupSchema: v.StrictObjectSchema<{
|
|
|
23
23
|
readonly differences: v.ArraySchema<v.StrictObjectSchema<{
|
|
24
24
|
readonly rule: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a lower-case word, words joined by -">]>;
|
|
25
25
|
readonly target: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "must not be empty">]>;
|
|
26
|
+
readonly expected: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "must not be empty">]>, undefined>;
|
|
26
27
|
readonly reason: v.SchemaWithPipe<readonly [v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "must not be empty">]>, v.MinLengthAction<string, 10, "say why, in a sentence">]>;
|
|
27
28
|
}, undefined>, undefined>;
|
|
28
29
|
}, undefined>;
|
package/dist/standards/audit.js
CHANGED
|
@@ -10,6 +10,11 @@ const auditRuleSchema = v.strictObject({ id: word, severity: v.picklist(LINT_SEV
|
|
|
10
10
|
export const defineAuditRules = (input) => parseRegistry("audit rules", v.array(auditRuleSchema), input, (rules) => duplicates(rules.map((rule) => rule.id)).map((id) => `"${id}" is listed twice`));
|
|
11
11
|
/** The rules that compare CLIs; each CLI is also judged alone by every lint rule. */
|
|
12
12
|
export const AUDIT_RULES = defineAuditRules([
|
|
13
|
+
{
|
|
14
|
+
id: "shared-command-contract",
|
|
15
|
+
severity: "high",
|
|
16
|
+
summary: "core-owned command definitions change only through exact, reasoned overrides",
|
|
17
|
+
},
|
|
13
18
|
{
|
|
14
19
|
id: "required-root",
|
|
15
20
|
severity: "high",
|
|
@@ -44,7 +49,12 @@ const parityGroupSchema = v.strictObject({
|
|
|
44
49
|
/** `tree` compares every path; otherwise the root words whose paths are compared. */
|
|
45
50
|
covers: v.union([v.literal("tree"), v.pipe(v.array(segment), v.minLength(1))]),
|
|
46
51
|
/** Permanent differences, each with its reason; they count against the override budget of the group. */
|
|
47
|
-
differences: v.array(v.strictObject({
|
|
52
|
+
differences: v.array(v.strictObject({
|
|
53
|
+
rule: word,
|
|
54
|
+
target: text,
|
|
55
|
+
expected: v.optional(text),
|
|
56
|
+
reason: v.pipe(text, v.minLength(10, "say why, in a sentence")),
|
|
57
|
+
})),
|
|
48
58
|
});
|
|
49
59
|
export const defineParityGroups = (input) => parseRegistry("parity groups", v.array(parityGroupSchema), input, (groups) => [
|
|
50
60
|
...duplicates(groups.map((group) => group.name)).map((name) => `"${name}" is listed twice`),
|
|
@@ -291,6 +291,12 @@ export const HOW_FLAGS = [
|
|
|
291
291
|
type: "duration",
|
|
292
292
|
meaning: "how old a stored snapshot may be and still count as fresh",
|
|
293
293
|
},
|
|
294
|
+
{
|
|
295
|
+
name: "--draft",
|
|
296
|
+
role: "mode",
|
|
297
|
+
type: "switch",
|
|
298
|
+
meaning: "save text for the owner to review and send, without sending a message",
|
|
299
|
+
},
|
|
294
300
|
{ name: "--md", role: "mode", type: "switch", meaning: "the text is the messenger's Markdown" },
|
|
295
301
|
{ name: "--no-backup", role: "mode", type: "switch", meaning: "do not back the file up before deleting it" },
|
|
296
302
|
{ name: "--no-input", role: "mode", type: "switch", meaning: "never prompt or open an interactive login" },
|
|
@@ -448,6 +454,12 @@ export const HOW_FLAGS = [
|
|
|
448
454
|
meaning: "override one permission for this run; repeat it for more",
|
|
449
455
|
},
|
|
450
456
|
{ name: "--read-only", role: "mode", type: "switch", meaning: "refuse every write, local and remote" },
|
|
457
|
+
{
|
|
458
|
+
name: "--append",
|
|
459
|
+
role: "mode",
|
|
460
|
+
type: "switch",
|
|
461
|
+
meaning: "keep existing entries and add the explicitly named one beside them",
|
|
462
|
+
},
|
|
451
463
|
{ name: "--replace", role: "mode", type: "switch", meaning: "overwrite an existing one of the same name" },
|
|
452
464
|
{
|
|
453
465
|
name: "--rescan",
|
|
@@ -436,6 +436,13 @@ export const WHAT_FLAGS = [
|
|
|
436
436
|
},
|
|
437
437
|
{ name: "--task", value: "<id>", role: "target", type: "id", meaning: "a task, by id" },
|
|
438
438
|
{ name: "--to", value: "<chat>", role: "target", type: "id", meaning: "the chat it goes to" },
|
|
439
|
+
{
|
|
440
|
+
name: "--topic-name",
|
|
441
|
+
value: "<name>",
|
|
442
|
+
role: "target",
|
|
443
|
+
type: "string",
|
|
444
|
+
meaning: "a stored meeting topic label, by name or alias",
|
|
445
|
+
},
|
|
439
446
|
{ name: "--topic", value: "<id>", role: "target", type: "id", meaning: "the forum topic, in a chat that has topics" },
|
|
440
447
|
{ name: "--transcript-id", value: "<id>", role: "target", type: "id", meaning: "one transcript version, by id" },
|
|
441
448
|
{ name: "--until-id", value: "<id>", role: "target", type: "id", meaning: "up to and including this message id" },
|
package/docs/standards/README.md
CHANGED
|
@@ -38,8 +38,8 @@ rules it keeps. A new CLI is ready for its first release when every check under
|
|
|
38
38
|
| [Testing](testing.md) | `TEST` | 17 | Sandbox, no network, no real waits, leak tests, coverage, contracts, test matrix, parity |
|
|
39
39
|
| [Security](security.md) | `SECR` | 20 | Secrets, logs, keyring, argv, error text, untrusted text, scanners, workflow hardening, reporting |
|
|
40
40
|
| [Documents](docs.md) | `DOC` | 20 | User pages and developer pages, generated pages, current facts, changelog |
|
|
41
|
-
| [Comparisons](comparisons.md) | `CMP` |
|
|
42
|
-
| [Releases](releases.md) | `REL` |
|
|
41
|
+
| [Comparisons](comparisons.md) | `CMP` | 11 | Which tools a comparison page lists, rows as user tasks, agents and safety, where others are better, proof of marks |
|
|
42
|
+
| [Releases](releases.md) | `REL` | 15 | `bin/release`, the release lock, versions, release reports, trusted publishing |
|
|
43
43
|
| [CI](ci.md) | `CI` | 12 | Hooks, the pull request budget, full validation, monthly runs |
|
|
44
44
|
| [Time](time.md) | `TIME` | 6 | UTC in the store and in answers, the zone at input and display edges, date-only filters, tests in another zone |
|
|
45
45
|
| [Style](style.md) | `STYLE` | 10 | Comments, TypeScript habits, commit messages |
|
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
## Scope
|
|
4
4
|
|
|
5
|
-
How a CLI's comparison page
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
How a CLI's comparison page is written: which tools it compares, which rows it has, how a mark is proven, and what it
|
|
6
|
+
says about the places where another tool is better. The page is read by someone choosing a tool for a job, not by
|
|
7
|
+
someone reviewing our code.
|
|
8
|
+
|
|
9
|
+
The page lives in the site repository (`cli-docs`, published on wirecat.dev), not in the CLI repository, because a
|
|
10
|
+
comparison changes when the other tools change, not when we release. The CLI repository keeps a short
|
|
11
|
+
`docs/compare.md` that links to the site page, and its other docs link there too.
|
|
8
12
|
|
|
9
13
|
Rule ids on this page are `WC-CMP-<n>`. How to read a rule, how ids work and which rule wins are in the
|
|
10
14
|
[index](README.md). The general rules for user pages are in [Documents](docs.md).
|
|
@@ -112,7 +116,18 @@ Rule ids on this page are `WC-CMP-<n>`. How to read a rule, how ids work and whi
|
|
|
112
116
|
- **Exceptions:** none.
|
|
113
117
|
- **Applies to:** all CLIs. **Since:** cli-base 0.50.4. **Rulings:** owner, 2026-10-24.
|
|
114
118
|
|
|
119
|
+
### WC-CMP-11 · Our marks are re-checked at each release
|
|
120
|
+
|
|
121
|
+
- **Why:** our own marks go stale with every release, and a mark that says we lack something we have shipped, or the
|
|
122
|
+
reverse, is the error a reader finds first.
|
|
123
|
+
- **Enforced by:** review only.
|
|
124
|
+
- **Example:** the release report lists the compare page as checked. Before a release, each ✅, 🟡 and ❌ for the CLI is re-proven against the release build, the check date
|
|
125
|
+
and version in the intro are updated in the site repository, and the release report names the page.
|
|
126
|
+
- **Exceptions:** none.
|
|
127
|
+
- **Applies to:** all CLIs. **Since:** cli-base 0.50.5. **Rulings:** owner, 2026-10-24.
|
|
128
|
+
|
|
115
129
|
## Sources
|
|
116
130
|
|
|
117
131
|
- Owner review of the max, zm and memo comparison pages, 2026-10-24.
|
|
118
|
-
- The tg comparison page,
|
|
132
|
+
- The tg comparison page, the first page written this way.
|
|
133
|
+
- Owner decision, 2026-10-24: comparison pages live in `cli-docs`; each CLI's docs link to them.
|
|
@@ -92,8 +92,8 @@ whatsapp-cli is the worked example: it has every file below. Compare with it whe
|
|
|
92
92
|
8. **`SECURITY.md`**: how to report a vulnerability, linking cli-testing's security method. See
|
|
93
93
|
[Security](security.md).
|
|
94
94
|
9. **`bin/release`**, **`release.yml`** and a release skill, copied from the nearest CLI. `bin/release` takes the
|
|
95
|
-
release lock (WC-REL-3) and npm's trusted publisher settings name `release.yml`. A messenger CLI also
|
|
96
|
-
|
|
95
|
+
release lock (WC-REL-3) and npm's trusted publisher settings name `release.yml`. A messenger CLI also checks a signed
|
|
96
|
+
release report (WC-REL-10). See [Releases](releases.md).
|
|
97
97
|
10. **`src/version.ts`** kept in step with `package.json`. Proved by: `pnpm version:check`.
|
|
98
98
|
|
|
99
99
|
## Done when
|
|
@@ -88,29 +88,17 @@ on the [CI](ci.md) page.
|
|
|
88
88
|
- **Exceptions:** a pre-release version (`0.41.0-rc.1`) is chosen by hand.
|
|
89
89
|
- **Applies to:** all CLIs and shared libraries. **Since:** cli-base 0.41.0. **Rulings:** none.
|
|
90
90
|
|
|
91
|
-
### WC-REL-8 ·
|
|
91
|
+
### WC-REL-8 · Any package may release at any time
|
|
92
92
|
|
|
93
|
-
- **Why:** a
|
|
94
|
-
|
|
95
|
-
- **Enforced by:**
|
|
96
|
-
- **Example:** tg-cli,
|
|
97
|
-
|
|
98
|
-
- **Exceptions:**
|
|
99
|
-
- **Applies to:**
|
|
100
|
-
|
|
101
|
-
### WC-REL-9 ·
|
|
102
|
-
|
|
103
|
-
- **Why:** the limit keeps the CLIs from chasing versions; a change that no CLI has to follow gives them nothing to
|
|
104
|
-
chase.
|
|
105
|
-
- **Enforced by:** `cli-core: parity.yml` (it decides which changes count; review keeps the week).
|
|
106
|
-
- **Example:** cli-core's parity jobs build tg-cli, max-cli and whatsapp-cli from their `main` against every pull
|
|
107
|
-
request. A change to a stable export counts unless all three jobs pass with each CLI's `main` unchanged; its
|
|
108
|
-
changelog still lists it under "Changed — may break callers" for outside users. It counts when a job fails, when it
|
|
109
|
-
passes only against a CLI branch made for the change, and when it compiles but changes behaviour that any CLI must
|
|
110
|
-
adapt to. Parity cannot see that last case, so when in doubt the change counts. Gather the changes that count into
|
|
111
|
-
one release a week, and bump the library in every CLI that uses it on the same day.
|
|
112
|
-
- **Exceptions:** none.
|
|
113
|
-
- **Applies to:** shared libraries. **Since:** cli-base 0.41.0. **Rulings:** NEED-473, NEED-1552.
|
|
93
|
+
- **Why:** a wait between releases or a weekly limit on breaking changes only delays a fix; the owner removed both
|
|
94
|
+
limits on 2026-10-24.
|
|
95
|
+
- **Enforced by:** review only.
|
|
96
|
+
- **Example:** tg-cli can release an hour after its last version, and cli-core can ship a breaking change on the same
|
|
97
|
+
day as the previous one. Bump the library in every CLI that uses it on the day it ships.
|
|
98
|
+
- **Exceptions:** none. The release lock (WC-REL-3) still keeps two releases on one machine from running at once.
|
|
99
|
+
- **Applies to:** all CLIs and shared libraries. **Since:** cli-base 0.52.0. **Rulings:** NEED-1741.
|
|
100
|
+
|
|
101
|
+
### WC-REL-9 · Merged into [WC-REL-8](#wc-rel-8--any-package-may-release-at-any-time)
|
|
114
102
|
|
|
115
103
|
### WC-REL-10 · A messenger CLI is released only with a release report the owner signed for that commit
|
|
116
104
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wirecat/cli-base",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.51.1",
|
|
4
4
|
"description": "The parts every command line tool needs: output modes, terminal rendering, error model, exit codes, credentials, clocks",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Viacheslav Ptsarev",
|