@clossys/writer 0.4.0 → 0.5.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/README.md +587 -2
- package/dist/approval-bypass.d.ts +120 -0
- package/dist/approval-bypass.d.ts.map +1 -0
- package/dist/approval-bypass.js +2318 -0
- package/dist/approval-bypass.js.map +1 -0
- package/dist/approval.d.ts +62 -0
- package/dist/approval.d.ts.map +1 -0
- package/dist/approval.js +421 -0
- package/dist/approval.js.map +1 -0
- package/dist/approve-cli.d.ts +82 -0
- package/dist/approve-cli.d.ts.map +1 -0
- package/dist/approve-cli.js +430 -0
- package/dist/approve-cli.js.map +1 -0
- package/dist/cli.d.ts +15 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +48 -13
- package/dist/cli.js.map +1 -1
- package/dist/field-options.d.ts +59 -0
- package/dist/field-options.d.ts.map +1 -0
- package/dist/field-options.js +109 -0
- package/dist/field-options.js.map +1 -0
- package/dist/front-door.check.d.ts +2 -0
- package/dist/front-door.check.d.ts.map +1 -0
- package/dist/front-door.check.js +8 -0
- package/dist/front-door.check.js.map +1 -0
- package/dist/front-door.d.ts +82 -0
- package/dist/front-door.d.ts.map +1 -0
- package/dist/front-door.js +271 -0
- package/dist/front-door.js.map +1 -0
- package/dist/generated/advisor-plan-contract.generated.d.ts +3 -0
- package/dist/generated/advisor-plan-contract.generated.d.ts.map +1 -0
- package/dist/generated/advisor-plan-contract.generated.js +430 -0
- package/dist/generated/advisor-plan-contract.generated.js.map +1 -0
- package/dist/generated/contract-schema.generated.d.ts +97 -0
- package/dist/generated/contract-schema.generated.d.ts.map +1 -0
- package/dist/generated/contract-schema.generated.js +496 -0
- package/dist/generated/contract-schema.generated.js.map +1 -0
- package/dist/index.d.ts +14 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -1
- package/dist/messaging-kit.d.ts +113 -0
- package/dist/messaging-kit.d.ts.map +1 -0
- package/dist/messaging-kit.js +121 -0
- package/dist/messaging-kit.js.map +1 -0
- package/dist/passage.d.ts +2 -1
- package/dist/passage.d.ts.map +1 -1
- package/dist/passage.js +2 -1
- package/dist/passage.js.map +1 -1
- package/dist/plan-authority.d.ts +73 -0
- package/dist/plan-authority.d.ts.map +1 -0
- package/dist/plan-authority.js +126 -0
- package/dist/plan-authority.js.map +1 -0
- package/dist/plan-contract.d.ts +16 -0
- package/dist/plan-contract.d.ts.map +1 -0
- package/dist/plan-contract.js +33 -0
- package/dist/plan-contract.js.map +1 -0
- package/dist/plan-digest.d.ts +23 -0
- package/dist/plan-digest.d.ts.map +1 -0
- package/dist/plan-digest.js +82 -0
- package/dist/plan-digest.js.map +1 -0
- package/dist/plan-rules.d.ts +54 -0
- package/dist/plan-rules.d.ts.map +1 -0
- package/dist/plan-rules.js +145 -0
- package/dist/plan-rules.js.map +1 -0
- package/dist/resolve.d.ts +40 -6
- package/dist/resolve.d.ts.map +1 -1
- package/dist/resolve.js +144 -18
- package/dist/resolve.js.map +1 -1
- package/dist/scan.d.ts +32 -0
- package/dist/scan.d.ts.map +1 -1
- package/dist/scan.js +67 -6
- package/dist/scan.js.map +1 -1
- package/dist/schema.d.ts +1 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +209 -5
- package/dist/schema.js.map +1 -1
- package/dist/site-identity.d.ts +88 -0
- package/dist/site-identity.d.ts.map +1 -0
- package/dist/site-identity.js +82 -0
- package/dist/site-identity.js.map +1 -0
- package/dist/types.d.ts +65 -7
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +9 -7
- package/dist/types.js.map +1 -1
- package/package.json +7 -6
- package/skill/SKILL.md +2 -2
- package/src/approval-bypass.ts +2263 -0
- package/src/approval.ts +491 -0
- package/src/approve-cli.ts +474 -0
- package/src/cli.ts +46 -13
- package/src/field-options.ts +134 -0
- package/src/front-door.check.ts +10 -0
- package/src/front-door.ts +312 -0
- package/src/generated/advisor-plan-contract.generated.ts +430 -0
- package/src/generated/contract-schema.generated.ts +520 -0
- package/src/index.ts +97 -1
- package/src/messaging-kit.ts +195 -0
- package/src/passage.ts +2 -1
- package/src/plan-authority.ts +160 -0
- package/src/plan-contract.ts +45 -0
- package/src/plan-digest.ts +81 -0
- package/src/plan-rules.ts +179 -0
- package/src/resolve.ts +185 -19
- package/src/scan.ts +97 -6
- package/src/schema.ts +223 -5
- package/src/site-identity.ts +125 -0
- package/src/types.ts +72 -7
- package/templates/front-door.en.json +426 -0
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `writer-check approve` — writes a `CopyRegistryEntry.approval` record so
|
|
3
|
+
* nobody has to hand-edit one. It exists as a command, not a hand-edited
|
|
4
|
+
* JSON field, for the same reason `textFingerprint` (`fingerprint.ts`) is a
|
|
5
|
+
* content hash rather than a human-maintained counter: a hand-typed record
|
|
6
|
+
* is easy to get wrong — copying yesterday's `approvedAt`, pinning `textFingerprint` to
|
|
7
|
+
* some other entry's text, or leaving `pendingOwnerReview` set after an
|
|
8
|
+
* owner actually reviewed it. Every field this command writes is either
|
|
9
|
+
* computed (`approvedAt` from the clock, `textFingerprint` from the
|
|
10
|
+
* entry's own current `text` via `computeCopyFingerprint`) or copied
|
|
11
|
+
* verbatim from an argument this command itself validated (`--delegate`,
|
|
12
|
+
* `--scope`, `--expires`) — there is no field here a human types free-form
|
|
13
|
+
* into the JSON.
|
|
14
|
+
*
|
|
15
|
+
* This module owns argument parsing, registry-file I/O, and the mutate-
|
|
16
|
+
* then-atomically-rename write; `./approval.ts`'s `isDelegateScopeItem` and
|
|
17
|
+
* `isEntryInDelegateScope` decide whether a requested id is in a delegate's
|
|
18
|
+
* declared scope, and `./schema.ts`'s `validateCopyRegistryShape` decides
|
|
19
|
+
* whether the registry file is trustworthy enough to mutate at all — this
|
|
20
|
+
* file calls both rather than re-deriving either.
|
|
21
|
+
*
|
|
22
|
+
* `main`-equivalent shape is `mainApproveCommand`, exported (unlike a
|
|
23
|
+
* typical CLI `main`) so this package's tests (which do not ship) can exercise the whole
|
|
24
|
+
* argv-to-exit-code contract directly against real `mkdtemp` fixture
|
|
25
|
+
* files, without spawning a subprocess — the same discipline every other
|
|
26
|
+
* subcommand in this package's `cli.ts` already holds to. `argv` excludes
|
|
27
|
+
* the leading `"approve"` token: `cli.ts`'s top-level dispatch calls
|
|
28
|
+
* `mainApproveCommand(argv.slice(1))` for `writer-check approve ...`, the same way `"passages"` and
|
|
29
|
+
* `"addressability"` are already dispatched there.
|
|
30
|
+
*
|
|
31
|
+
* Exit codes — a three-state contract matching every other gate/command in
|
|
32
|
+
* this package:
|
|
33
|
+
*
|
|
34
|
+
* 0 — written: every named entry was approved or revoked, and the
|
|
35
|
+
* registry file on disk now reflects it.
|
|
36
|
+
* 1 — refused: at least one named entry is unknown, is `retired`, or —
|
|
37
|
+
* for a delegate approval — falls outside `--scope`. This is
|
|
38
|
+
* ALL-OR-NOTHING: when any requested id has a problem, EVERY problem
|
|
39
|
+
* is printed and NOTHING is written, even for the other ids that
|
|
40
|
+
* would otherwise have succeeded. A partial write here would leave a
|
|
41
|
+
* consumer's own audit trail unable to tell whether a whole
|
|
42
|
+
* requested batch was actually reviewed together.
|
|
43
|
+
* 2 — could not run: bad arguments (see below), or the registry file is
|
|
44
|
+
* missing/unreadable, not valid JSON, or fails
|
|
45
|
+
* `validateCopyRegistryShape`. Nothing is written in this state
|
|
46
|
+
* either — this command never mutates a registry it does not first
|
|
47
|
+
* fully trust.
|
|
48
|
+
*
|
|
49
|
+
* Bad-argument cases that produce exit 2, all before the registry file is
|
|
50
|
+
* ever opened: no registry-file argument, zero entry ids, neither `--by`
|
|
51
|
+
* nor `--revoke` (or both), `--by` other than `owner`/`delegate`, `--by
|
|
52
|
+
* owner` combined with `--delegate`/`--scope`/`--expires`, `--by delegate`
|
|
53
|
+
* missing `--delegate` or `--scope`, an empty `--delegate` value, a
|
|
54
|
+
* `--scope` item that fails `isDelegateScopeItem` or an empty scope list,
|
|
55
|
+
* `--revoke` combined with any of `--by`/`--delegate`/`--scope`/
|
|
56
|
+
* `--expires`, an `--expires` value that is not a well-formed ISO 8601 UTC
|
|
57
|
+
* timestamp or is not strictly after the current time, or an unknown flag.
|
|
58
|
+
* `--help`/`-h` always prints usage and returns 0, taking precedence over
|
|
59
|
+
* every other check reachable in the same parse. Any other unexpected
|
|
60
|
+
* exception is caught and mapped to exit 2 as well — this command never
|
|
61
|
+
* lets an uncaught error double as a "something went wrong" report.
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
import { randomBytes } from "node:crypto";
|
|
65
|
+
import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
66
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
67
|
+
import { isDelegateScopeItem, isEntryInDelegateScope } from "./approval.js";
|
|
68
|
+
import { COPY_FINGERPRINT_ALGORITHM, computeCopyFingerprint } from "./fingerprint.js";
|
|
69
|
+
import { isIsoUtcTimestamp, validateCopyRegistryShape } from "./schema.js";
|
|
70
|
+
|
|
71
|
+
export const APPROVE_USAGE = `Usage: writer-check approve <registry-file> <entry-id>... --by owner
|
|
72
|
+
or: writer-check approve <registry-file> <entry-id>... --by delegate --delegate <id> --scope <ns>[,<ns>...] [--expires <ISO>]
|
|
73
|
+
or: writer-check approve <registry-file> <entry-id>... --revoke
|
|
74
|
+
|
|
75
|
+
registry-file Path to a CopyRegistry JSON file. Required.
|
|
76
|
+
entry-id One or more entry ids to approve or revoke (repeatable positional argument; duplicates are deduplicated). Required.
|
|
77
|
+
|
|
78
|
+
Options:
|
|
79
|
+
--help Print this message and exit 0.
|
|
80
|
+
--by <owner|delegate> Who is recording this approval. Exactly one of --by or --revoke is required.
|
|
81
|
+
--delegate <id> Opaque delegate identifier, never a personal name. Required (and only allowed) with --by delegate.
|
|
82
|
+
--scope <ns>[,<ns>...] One or more dot-separated entry-id namespaces the delegate may approve (repeatable; a comma-separated value and repeated flags union). Required (and only allowed) with --by delegate.
|
|
83
|
+
--expires <ISO> ISO 8601 UTC timestamp (YYYY-MM-DDTHH:MM:SSZ, optionally with milliseconds) after which the delegate approval expires. Must be strictly after the current time. Only allowed with --by delegate.
|
|
84
|
+
--revoke Return every named entry to "draft" and delete its approval record. Cannot be combined with --by, --delegate, --scope, or --expires.
|
|
85
|
+
|
|
86
|
+
Approving an entry that already carries a delegate's approval record with
|
|
87
|
+
--by owner replaces that record with the owner's own — this is how a
|
|
88
|
+
pending delegate approval is durably confirmed.
|
|
89
|
+
|
|
90
|
+
Exit codes: 0 = written (every named entry approved or revoked; the
|
|
91
|
+
registry file was updated), 1 = refused (an unknown, retired, or — for a
|
|
92
|
+
delegate approval — out-of-scope entry id was named; every problem is
|
|
93
|
+
printed and nothing is written), 2 = could not run (bad arguments, or the
|
|
94
|
+
registry file is missing/unreadable/not valid JSON/fails schema
|
|
95
|
+
validation; nothing is written).
|
|
96
|
+
`;
|
|
97
|
+
|
|
98
|
+
/** Bad arguments only — never thrown once the registry file has been read. */
|
|
99
|
+
class ApproveInputError extends Error {}
|
|
100
|
+
|
|
101
|
+
type ApproveAction =
|
|
102
|
+
| { kind: "approve-owner" }
|
|
103
|
+
| { kind: "approve-delegate"; delegate: string; scope: string[]; expiresAt?: string }
|
|
104
|
+
| { kind: "revoke" };
|
|
105
|
+
|
|
106
|
+
interface RawArgs {
|
|
107
|
+
registryFile?: string;
|
|
108
|
+
entryIds: string[];
|
|
109
|
+
by?: string;
|
|
110
|
+
delegate?: string;
|
|
111
|
+
scope?: string[];
|
|
112
|
+
expires?: string;
|
|
113
|
+
revoke: boolean;
|
|
114
|
+
help: boolean;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface ValidatedArgs {
|
|
118
|
+
registryFile: string;
|
|
119
|
+
entryIds: string[];
|
|
120
|
+
action: ApproveAction;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function parseRawArgs(argv: string[]): RawArgs {
|
|
124
|
+
let registryFile: string | undefined;
|
|
125
|
+
const entryIds: string[] = [];
|
|
126
|
+
let by: string | undefined;
|
|
127
|
+
let delegate: string | undefined;
|
|
128
|
+
let scope: string[] | undefined;
|
|
129
|
+
let expires: string | undefined;
|
|
130
|
+
let revoke = false;
|
|
131
|
+
let help = false;
|
|
132
|
+
|
|
133
|
+
for (let i = 0; i < argv.length; i++) {
|
|
134
|
+
const arg = argv[i] as string;
|
|
135
|
+
if (arg === "--help" || arg === "-h") {
|
|
136
|
+
help = true;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (arg === "--revoke") {
|
|
140
|
+
revoke = true;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (arg === "--by") {
|
|
144
|
+
const value = argv[++i];
|
|
145
|
+
if (value === undefined || value.startsWith("-")) throw new ApproveInputError("--by requires a value");
|
|
146
|
+
by = value;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (arg === "--delegate") {
|
|
150
|
+
const value = argv[++i];
|
|
151
|
+
if (value === undefined || value.startsWith("-")) throw new ApproveInputError("--delegate requires a value");
|
|
152
|
+
delegate = value;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (arg === "--scope") {
|
|
156
|
+
const value = argv[++i];
|
|
157
|
+
if (value === undefined || value.startsWith("-")) throw new ApproveInputError("--scope requires a value");
|
|
158
|
+
const tokens = value.split(",").map((token) => token.trim());
|
|
159
|
+
scope = [...(scope ?? []), ...tokens];
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (arg === "--expires") {
|
|
163
|
+
const value = argv[++i];
|
|
164
|
+
if (value === undefined || value.startsWith("-")) {
|
|
165
|
+
throw new ApproveInputError("--expires requires an ISO 8601 UTC timestamp");
|
|
166
|
+
}
|
|
167
|
+
expires = value;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (arg.startsWith("-")) {
|
|
171
|
+
throw new ApproveInputError(`unknown flag "${arg}"`);
|
|
172
|
+
}
|
|
173
|
+
if (registryFile === undefined) {
|
|
174
|
+
registryFile = arg;
|
|
175
|
+
} else {
|
|
176
|
+
entryIds.push(arg);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return { registryFile, entryIds, by, delegate, scope, expires, revoke, help };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* `YYYY-MM-DDTHH:MM:SS(.sss)?Z` — deliberately the same shape
|
|
185
|
+
* `schema.ts`'s own (unexported) `ISO_UTC_TIMESTAMP_RE` accepts for
|
|
186
|
+
* `approvedAt`/`expiresAt`, so a value this command accepts on argv is
|
|
187
|
+
* never one `validateCopyRegistryShape` would then reject from the file
|
|
188
|
+
* this command just wrote. `Date.parse` must also actually accept it, so a
|
|
189
|
+
* string that merely matches the pattern but names an impossible date
|
|
190
|
+
* (e.g. a bad month) is still rejected.
|
|
191
|
+
*/
|
|
192
|
+
function validateExpires(raw: string, now: Date): string {
|
|
193
|
+
if (!isIsoUtcTimestamp(raw)) {
|
|
194
|
+
throw new ApproveInputError(
|
|
195
|
+
`--expires must be an ISO 8601 UTC timestamp (e.g. 2026-01-01T00:00:00Z), got ${JSON.stringify(raw)}`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
if (Date.parse(raw) <= now.getTime()) {
|
|
199
|
+
throw new ApproveInputError(`--expires ${JSON.stringify(raw)} must be strictly after the current time`);
|
|
200
|
+
}
|
|
201
|
+
return raw;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Turns the raw, syntactically-parsed argv into a fully validated action —
|
|
206
|
+
* every semantic rule this command enforces before ever touching the
|
|
207
|
+
* registry file lives here. Throws `ApproveInputError` on the first
|
|
208
|
+
* violation, matching every other subcommand's `parseArgs` in this
|
|
209
|
+
* package.
|
|
210
|
+
*/
|
|
211
|
+
function validateArgs(raw: RawArgs, now: Date): ValidatedArgs {
|
|
212
|
+
if (!raw.registryFile) {
|
|
213
|
+
throw new ApproveInputError("registry-file is required");
|
|
214
|
+
}
|
|
215
|
+
if (raw.entryIds.length === 0) {
|
|
216
|
+
throw new ApproveInputError("at least one entry-id is required");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (!raw.revoke && raw.by === undefined) {
|
|
220
|
+
throw new ApproveInputError("exactly one of --by or --revoke is required");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
let action: ApproveAction;
|
|
224
|
+
if (raw.revoke) {
|
|
225
|
+
if (raw.by !== undefined || raw.delegate !== undefined || raw.scope !== undefined || raw.expires !== undefined) {
|
|
226
|
+
throw new ApproveInputError("--revoke cannot be combined with --by, --delegate, --scope, or --expires");
|
|
227
|
+
}
|
|
228
|
+
action = { kind: "revoke" };
|
|
229
|
+
} else if (raw.by === "owner") {
|
|
230
|
+
if (raw.delegate !== undefined || raw.scope !== undefined || raw.expires !== undefined) {
|
|
231
|
+
throw new ApproveInputError("--by owner cannot be combined with --delegate, --scope, or --expires");
|
|
232
|
+
}
|
|
233
|
+
action = { kind: "approve-owner" };
|
|
234
|
+
} else if (raw.by === "delegate") {
|
|
235
|
+
if (raw.delegate === undefined) {
|
|
236
|
+
throw new ApproveInputError("--by delegate requires --delegate <id>");
|
|
237
|
+
}
|
|
238
|
+
if (raw.delegate.trim().length === 0) {
|
|
239
|
+
throw new ApproveInputError("--delegate must not be empty");
|
|
240
|
+
}
|
|
241
|
+
const delegateId = raw.delegate;
|
|
242
|
+
if (raw.scope === undefined || raw.scope.length === 0) {
|
|
243
|
+
throw new ApproveInputError("--by delegate requires --scope <ns>[,<ns>...]");
|
|
244
|
+
}
|
|
245
|
+
for (const item of raw.scope) {
|
|
246
|
+
if (!isDelegateScopeItem(item)) {
|
|
247
|
+
throw new ApproveInputError(`invalid --scope item ${JSON.stringify(item)}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const expiresAt = raw.expires !== undefined ? validateExpires(raw.expires, now) : undefined;
|
|
251
|
+
action = { kind: "approve-delegate", delegate: delegateId, scope: raw.scope, expiresAt };
|
|
252
|
+
} else {
|
|
253
|
+
throw new ApproveInputError(`--by must be "owner" or "delegate", got ${JSON.stringify(raw.by)}`);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return { registryFile: raw.registryFile, entryIds: [...new Set(raw.entryIds)], action };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
interface ReadOutcome {
|
|
260
|
+
ok: boolean;
|
|
261
|
+
parsed?: Record<string, unknown>;
|
|
262
|
+
messages: string[];
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Reads, parses, and shape-validates the registry file — never throws.
|
|
267
|
+
* Deliberately does NOT go through `parseCopyRegistry`/`buildCopyRegistry`
|
|
268
|
+
* (`schema.ts`): those rebuild a fresh object from validated fields, which
|
|
269
|
+
* would lose the original file's key order and any field this package
|
|
270
|
+
* does not itself model. This command mutates the raw parsed JSON value in
|
|
271
|
+
* place instead (see `mainApproveCommand`), so it only needs
|
|
272
|
+
* `validateCopyRegistryShape`'s findings, never a rebuilt object.
|
|
273
|
+
*/
|
|
274
|
+
function readRegistry(path: string): ReadOutcome {
|
|
275
|
+
let raw: string;
|
|
276
|
+
try {
|
|
277
|
+
raw = readFileSync(path, "utf8");
|
|
278
|
+
} catch (error) {
|
|
279
|
+
return {
|
|
280
|
+
ok: false,
|
|
281
|
+
messages: [`registry file "${path}" could not be read: ${error instanceof Error ? error.message : String(error)}`],
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
let parsed: unknown;
|
|
286
|
+
try {
|
|
287
|
+
parsed = JSON.parse(raw);
|
|
288
|
+
} catch (error) {
|
|
289
|
+
return {
|
|
290
|
+
ok: false,
|
|
291
|
+
messages: [`registry file "${path}" is not valid JSON: ${error instanceof Error ? error.message : String(error)}`],
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const findings = validateCopyRegistryShape(parsed);
|
|
296
|
+
if (findings.length > 0) {
|
|
297
|
+
return {
|
|
298
|
+
ok: false,
|
|
299
|
+
messages: findings.map((f) => `[${f.rule}]${f.path ? ` ${f.path}:` : ""} ${f.message}`),
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return { ok: true, parsed: parsed as Record<string, unknown>, messages: [] };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function indexEntries(parsed: Record<string, unknown>): Map<string, Record<string, unknown>> {
|
|
307
|
+
const entries = parsed.entries as unknown[];
|
|
308
|
+
const byId = new Map<string, Record<string, unknown>>();
|
|
309
|
+
for (const raw of entries) {
|
|
310
|
+
const entry = raw as Record<string, unknown>;
|
|
311
|
+
byId.set(entry.id as string, entry);
|
|
312
|
+
}
|
|
313
|
+
return byId;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* All-or-nothing pre-flight: every requested id that is unknown, retired,
|
|
318
|
+
* or (for a delegate approval) outside `--scope` is collected here, so
|
|
319
|
+
* `mainApproveCommand` can print every problem and refuse the WHOLE batch
|
|
320
|
+
* — never write some of the requested entries and skip others.
|
|
321
|
+
*/
|
|
322
|
+
function findProblems(
|
|
323
|
+
entriesById: Map<string, Record<string, unknown>>,
|
|
324
|
+
entryIds: string[],
|
|
325
|
+
action: ApproveAction,
|
|
326
|
+
): string[] {
|
|
327
|
+
const problems: string[] = [];
|
|
328
|
+
for (const id of entryIds) {
|
|
329
|
+
const entry = entriesById.get(id);
|
|
330
|
+
if (!entry) {
|
|
331
|
+
problems.push(`unknown entry id "${id}"`);
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
if (entry.status === "retired") {
|
|
335
|
+
problems.push(`entry "${id}" is retired and cannot be approved or revoked`);
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if (action.kind === "approve-delegate" && !isEntryInDelegateScope(id, action.scope)) {
|
|
339
|
+
problems.push(`entry "${id}" is outside delegate scope [${action.scope.join(", ")}]`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return problems;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Builds a fresh `CopyApproval`-shaped record with keys in the exact order
|
|
347
|
+
* this package's contract fixes: `approvedBy`, `approvedAt`,
|
|
348
|
+
* `textFingerprint`, `fingerprintAlgorithm`, then — delegate records only —
|
|
349
|
+
* `delegate`, `pendingOwnerReview`, `expiresAt`. Assigning this whole
|
|
350
|
+
* object to `entry.approval` either appends a new `approval` key at the
|
|
351
|
+
* end of the entry (it did not exist before) or replaces an existing one
|
|
352
|
+
* in place (its position in the entry is unchanged, because reassigning an
|
|
353
|
+
* existing object key never moves it) — see this file's top doc comment
|
|
354
|
+
* and `mainApproveCommand` for why that is exactly the key-order contract
|
|
355
|
+
* this command must preserve.
|
|
356
|
+
*/
|
|
357
|
+
function buildApprovalRecord(action: ApproveAction, entry: Record<string, unknown>, now: Date): Record<string, unknown> {
|
|
358
|
+
const record: Record<string, unknown> = {
|
|
359
|
+
approvedBy: action.kind === "approve-delegate" ? "delegate" : "owner",
|
|
360
|
+
approvedAt: now.toISOString(),
|
|
361
|
+
textFingerprint: computeCopyFingerprint(entry.text as string),
|
|
362
|
+
fingerprintAlgorithm: COPY_FINGERPRINT_ALGORITHM,
|
|
363
|
+
};
|
|
364
|
+
if (action.kind === "approve-delegate") {
|
|
365
|
+
record.delegate = { id: action.delegate, scope: [...action.scope] };
|
|
366
|
+
record.pendingOwnerReview = true;
|
|
367
|
+
if (action.expiresAt !== undefined) {
|
|
368
|
+
record.expiresAt = action.expiresAt;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return record;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Writes `data` to `path` by writing a temp file in the SAME directory,
|
|
376
|
+
* then `renameSync`-ing it into place, so the registry file is replaced by
|
|
377
|
+
* one rename rather than rewritten in place. The temp file is cleaned up
|
|
378
|
+
* on any failure; a failure after a successful `renameSync` cannot happen,
|
|
379
|
+
* since nothing runs after it.
|
|
380
|
+
*/
|
|
381
|
+
function writeRegistryFile(path: string, data: unknown): void {
|
|
382
|
+
const dir = dirname(path);
|
|
383
|
+
const base = basename(path);
|
|
384
|
+
const tempPath = join(dir, `.${base}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
|
|
385
|
+
const content = `${JSON.stringify(data, null, 2)}\n`;
|
|
386
|
+
try {
|
|
387
|
+
writeFileSync(tempPath, content, "utf8");
|
|
388
|
+
renameSync(tempPath, path);
|
|
389
|
+
} catch (error) {
|
|
390
|
+
try {
|
|
391
|
+
if (existsSync(tempPath)) unlinkSync(tempPath);
|
|
392
|
+
} catch {
|
|
393
|
+
// Best-effort cleanup only — the original error below is what matters.
|
|
394
|
+
}
|
|
395
|
+
throw error;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Exported (unlike a typical CLI `main`) so this package's tests (which do
|
|
401
|
+
* not ship) can exercise the whole argv-to-exit-code contract directly against real
|
|
402
|
+
* `mkdtemp` fixture files, without spawning a subprocess — see this file's
|
|
403
|
+
* top doc comment for the full exit-code contract. `argv` excludes the
|
|
404
|
+
* leading `"approve"` subcommand token. `io.now` lets a test pin the clock
|
|
405
|
+
* that `approvedAt` and `--expires` validation are measured against;
|
|
406
|
+
* omitted, it defaults to `new Date()`.
|
|
407
|
+
*
|
|
408
|
+
* Never throws: every failure, expected or not, is caught here and mapped
|
|
409
|
+
* to an exit code — bad arguments and an untrustworthy registry file both
|
|
410
|
+
* become 2 with nothing written, a refused (but well-formed) request
|
|
411
|
+
* becomes 1 with nothing written, and a clean run becomes 0 with the
|
|
412
|
+
* registry file updated on disk.
|
|
413
|
+
*/
|
|
414
|
+
export function mainApproveCommand(argv: string[], io: { now?: Date } = {}): number {
|
|
415
|
+
try {
|
|
416
|
+
const now = io.now ?? new Date();
|
|
417
|
+
const raw = parseRawArgs(argv);
|
|
418
|
+
if (raw.help) {
|
|
419
|
+
console.log(APPROVE_USAGE);
|
|
420
|
+
return 0;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const validated = validateArgs(raw, now);
|
|
424
|
+
|
|
425
|
+
const registryFile = resolve(validated.registryFile);
|
|
426
|
+
const read = readRegistry(registryFile);
|
|
427
|
+
if (!read.ok || !read.parsed) {
|
|
428
|
+
console.error(`[approve] registry could not be loaded:`);
|
|
429
|
+
for (const message of read.messages) console.error(` ${message}`);
|
|
430
|
+
console.error("[approve] Refusing to modify a registry that could not be fully validated.");
|
|
431
|
+
return 2;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const entriesById = indexEntries(read.parsed);
|
|
435
|
+
const problems = findProblems(entriesById, validated.entryIds, validated.action);
|
|
436
|
+
if (problems.length > 0) {
|
|
437
|
+
console.error(`[approve] refusing to write — ${problems.length} problem(s) with the requested entries:`);
|
|
438
|
+
for (const problem of problems) console.error(` ${problem}`);
|
|
439
|
+
return 1;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const lines: string[] = [];
|
|
443
|
+
for (const id of validated.entryIds) {
|
|
444
|
+
const entry = entriesById.get(id) as Record<string, unknown>;
|
|
445
|
+
if (validated.action.kind === "revoke") {
|
|
446
|
+
entry.status = "draft";
|
|
447
|
+
delete entry.approval;
|
|
448
|
+
lines.push(`[approve] ${id}: revoked (now draft)`);
|
|
449
|
+
} else {
|
|
450
|
+
entry.status = "approved";
|
|
451
|
+
entry.approval = buildApprovalRecord(validated.action, entry, now);
|
|
452
|
+
lines.push(
|
|
453
|
+
validated.action.kind === "approve-delegate"
|
|
454
|
+
? `[approve] ${id}: approved by delegate ${validated.action.delegate} (pending owner review)`
|
|
455
|
+
: `[approve] ${id}: approved by owner`,
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
writeRegistryFile(registryFile, read.parsed);
|
|
461
|
+
for (const line of lines) console.log(line);
|
|
462
|
+
return 0;
|
|
463
|
+
} catch (error) {
|
|
464
|
+
if (error instanceof ApproveInputError) {
|
|
465
|
+
console.error(`writer-check approve: ${error.message}`);
|
|
466
|
+
console.error(`\n${APPROVE_USAGE}`);
|
|
467
|
+
return 2;
|
|
468
|
+
}
|
|
469
|
+
console.error(
|
|
470
|
+
`writer-check approve: unexpected error: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`,
|
|
471
|
+
);
|
|
472
|
+
return 2;
|
|
473
|
+
}
|
|
474
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -109,6 +109,21 @@
|
|
|
109
109
|
* all. This is the explicit third state this gate is built around:
|
|
110
110
|
* "could not check" must never be reported as a pass.
|
|
111
111
|
*
|
|
112
|
+
* A FIFTH and SIXTH subcommand, `approve` and `approval-state`, are
|
|
113
|
+
* dispatched the same way `addressability` and `passages` are — a fully
|
|
114
|
+
* separate top-level branch at the very bottom of this file, never folded
|
|
115
|
+
* through `main()`. `writer-check approve` writes a
|
|
116
|
+
* `CopyRegistryEntry.approval` record so nobody hand-edits one; its whole
|
|
117
|
+
* argument-parsing, validation, and atomic-write contract lives in
|
|
118
|
+
* `mainApproveCommand` (`./approve-cli.ts`) — this file only wires its
|
|
119
|
+
* exit code through. `writer-check approval-state` reports the current
|
|
120
|
+
* approval state of a registry from two angles at once: whether every
|
|
121
|
+
* approved entry's own record is current (`assessCopyApprovals`) and
|
|
122
|
+
* whether consumer source actually routes copy through the registry's
|
|
123
|
+
* approval lifecycle rather than around it (`scanApprovalBypass`/
|
|
124
|
+
* `checkApprovalBypass`); its own contract lives in
|
|
125
|
+
* `mainApprovalStateCommand` (`./approval.ts`).
|
|
126
|
+
*
|
|
112
127
|
* A malformed `pathExclusions` entry (see `scan.ts`/`path-exclusions.ts`)
|
|
113
128
|
* gets the same `2` treatment as `unchecked`, for the identical reason: an
|
|
114
129
|
* exclusion list this run cannot trust means this run cannot say which
|
|
@@ -134,6 +149,8 @@ import { checkCopyTraceability, type CopyGateFinding, type CopyGateIgnored, type
|
|
|
134
149
|
import { checkLocaleCoverage, type LocaleCoverageReport } from "./locale-coverage.js";
|
|
135
150
|
import { liveTreeExists, scanLiveCopyTrees } from "./live-copy-gate.js";
|
|
136
151
|
import { checkPassageComposition, readPassageRecord, type PassageGateResult } from "./passage.js";
|
|
152
|
+
import { mainApprovalStateCommand } from "./approval.js";
|
|
153
|
+
import { mainApproveCommand } from "./approve-cli.js";
|
|
137
154
|
import { readCopyRecord } from "./registry.js";
|
|
138
155
|
import { checkRenderRegistryParity } from "./render-registry-parity.js";
|
|
139
156
|
import { scanCopySourceTree, type ScanResult, type UncheckedItem } from "./scan.js";
|
|
@@ -147,13 +164,18 @@ const USAGE = `Usage: writer-check <record-file> [scan-dir] [options]
|
|
|
147
164
|
record-file Path to a CopyRecord JSON file (see @clossys/writer's README). Required.
|
|
148
165
|
scan-dir Directory to scan for user-facing string/template literals. Defaults to the current working directory.
|
|
149
166
|
|
|
150
|
-
See also "writer-check addressability [scan-dir]"
|
|
151
|
-
<registry-file>"
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
167
|
+
See also "writer-check addressability [scan-dir]", "writer-check passages
|
|
168
|
+
<registry-file>", "writer-check approve <registry-file> <entry-id>...", and
|
|
169
|
+
"writer-check approval-state <registry-file> <scan-dir>" (this same file's
|
|
170
|
+
other subcommands, all dispatched on argv[0] before anything below) —
|
|
171
|
+
addressability asks whether user-facing prose is resolved from the copy
|
|
172
|
+
registry by id, rather than typed inline; passages asks whether a passage
|
|
173
|
+
composes registered entries/terms rather than inlining a literal or
|
|
174
|
+
reaching into another passage's internals; approve writes an entry's
|
|
175
|
+
approval record; approval-state reports whether a
|
|
176
|
+
registry's approval records are current and whether consumer source
|
|
177
|
+
actually routes copy through them. All four are separate gates/commands;
|
|
178
|
+
none runs as part of this default command.
|
|
157
179
|
|
|
158
180
|
Options:
|
|
159
181
|
--help Print this message and exit 0.
|
|
@@ -177,6 +199,8 @@ Run "writer-check voice-derivation-coverage --help" for the second subcommand's
|
|
|
177
199
|
Run "writer-check locale-coverage --help" for the third subcommand's own usage.
|
|
178
200
|
Run "writer-check addressability --help" for that subcommand's own usage.
|
|
179
201
|
Run "writer-check passages --help" for the passage-composition subcommand's own usage.
|
|
202
|
+
Run "writer-check approve --help" for the approval-recording subcommand's own usage.
|
|
203
|
+
Run "writer-check approval-state --help" for the approval-state subcommand's own usage.
|
|
180
204
|
`;
|
|
181
205
|
|
|
182
206
|
const VOICE_DERIVATION_COVERAGE_USAGE = `Usage: writer-check voice-derivation-coverage <obligations-file> <brand-derived-rule-ids-file> [options]
|
|
@@ -1510,11 +1534,12 @@ function detectMainModule(): boolean {
|
|
|
1510
1534
|
}
|
|
1511
1535
|
|
|
1512
1536
|
/**
|
|
1513
|
-
* `writer-check`, `writer-check addressability`,
|
|
1514
|
-
*
|
|
1515
|
-
*
|
|
1516
|
-
*
|
|
1517
|
-
*
|
|
1537
|
+
* `writer-check`, `writer-check addressability`, `writer-check passages`,
|
|
1538
|
+
* `writer-check approve`, and `writer-check approval-state` are ONE `bin`
|
|
1539
|
+
* entry, one compiled file, dispatched by an explicit FIRST ARGUMENT — never
|
|
1540
|
+
* by how this file was invoked (its own path, a symlink name, an installed
|
|
1541
|
+
* `bin` shim, ...). That is deliberate: this repository's own root
|
|
1542
|
+
* `package.json` invokes every gate by compiled path
|
|
1518
1543
|
* (`node packages/ui/dist/tokens/contrast-cli.js`,
|
|
1519
1544
|
* `node packages/controller/dist/cli.js`, ...), never by `bin` name, so
|
|
1520
1545
|
* any dispatch keyed on the invoking path/name (a second `bin` entry
|
|
@@ -1524,7 +1549,11 @@ function detectMainModule(): boolean {
|
|
|
1524
1549
|
* exactly the "runs the wrong thing without ever failing" failure mode a
|
|
1525
1550
|
* gate must never have. `rawArgs[0]` is read BEFORE `main()`'s own
|
|
1526
1551
|
* `parseArgs` ever sees the array, so the default command's argument
|
|
1527
|
-
* shape is completely unaffected by
|
|
1552
|
+
* shape is completely unaffected by any subcommand existing at all.
|
|
1553
|
+
* `mainApproveCommand`/`mainApprovalStateCommand` never throw (see their own
|
|
1554
|
+
* doc comments), so — unlike `runAddressabilityCheck`/`runPassagesCheck`
|
|
1555
|
+
* above — their branches assign `process.exitCode` directly, with no
|
|
1556
|
+
* try/catch wrapper of their own to add.
|
|
1528
1557
|
*/
|
|
1529
1558
|
if (detectMainModule()) {
|
|
1530
1559
|
const rawArgs = process.argv.slice(2);
|
|
@@ -1532,6 +1561,10 @@ if (detectMainModule()) {
|
|
|
1532
1561
|
runAddressabilityCheck(rawArgs.slice(1));
|
|
1533
1562
|
} else if (rawArgs[0] === "passages") {
|
|
1534
1563
|
runPassagesCheck(rawArgs.slice(1));
|
|
1564
|
+
} else if (rawArgs[0] === "approve") {
|
|
1565
|
+
process.exitCode = mainApproveCommand(rawArgs.slice(1));
|
|
1566
|
+
} else if (rawArgs[0] === "approval-state") {
|
|
1567
|
+
process.exitCode = mainApprovalStateCommand(rawArgs.slice(1));
|
|
1535
1568
|
} else {
|
|
1536
1569
|
run();
|
|
1537
1570
|
}
|