@clossys/writer 0.3.9 → 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 +647 -6
- package/dist/addressability.d.ts +11 -1
- package/dist/addressability.d.ts.map +1 -1
- package/dist/addressability.js +169 -3
- package/dist/addressability.js.map +1 -1
- 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 +264 -22
- 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/fold-wallpaper-gate.d.ts +7 -0
- package/dist/fold-wallpaper-gate.d.ts.map +1 -0
- package/dist/fold-wallpaper-gate.js +29 -0
- package/dist/fold-wallpaper-gate.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 +18 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -1
- package/dist/live-copy-gate.d.ts +16 -0
- package/dist/live-copy-gate.d.ts.map +1 -0
- package/dist/live-copy-gate.js +135 -0
- package/dist/live-copy-gate.js.map +1 -0
- 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/render-registry-parity.d.ts +20 -0
- package/dist/render-registry-parity.d.ts.map +1 -0
- package/dist/render-registry-parity.js +45 -0
- package/dist/render-registry-parity.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 +231 -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/treatment-word-budget.d.ts +31 -0
- package/dist/treatment-word-budget.d.ts.map +1 -0
- package/dist/treatment-word-budget.js +80 -0
- package/dist/treatment-word-budget.js.map +1 -0
- package/dist/types.d.ts +76 -7
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +9 -7
- package/dist/types.js.map +1 -1
- package/fit-signals.json +11 -0
- package/package.json +201 -11
- package/skill/SKILL.md +49 -0
- package/src/addressability.ts +186 -4
- package/src/approval-bypass.ts +2263 -0
- package/src/approval.ts +491 -0
- package/src/approve-cli.ts +474 -0
- package/src/cli.ts +274 -22
- package/src/field-options.ts +134 -0
- package/src/fold-wallpaper-gate.ts +29 -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 +107 -1
- package/src/live-copy-gate.ts +150 -0
- 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/render-registry-parity.ts +65 -0
- package/src/resolve.ts +185 -19
- package/src/scan.ts +97 -6
- package/src/schema.ts +247 -5
- package/src/site-identity.ts +125 -0
- package/src/treatment-word-budget.ts +100 -0
- package/src/types.ts +83 -7
- package/templates/front-door.en.json +426 -0
- package/CHANGELOG.md +0 -330
package/src/approval.ts
ADDED
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approval-record helpers shared by the schema, `writer-check approve` and
|
|
3
|
+
* `writer-check approval-state`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
7
|
+
import { resolve } from "node:path";
|
|
8
|
+
import type { CopyApproval, CopyRegistry, CopyRegistryEntry } from "./types.js";
|
|
9
|
+
import { computeCopyFingerprint } from "./fingerprint.js";
|
|
10
|
+
import { checkApprovalBypass, scanApprovalBypass, type ApprovalBypassFinding, type ApprovalBypassGateResult, type ApprovalBypassScanResult, type ApprovalBypassUncheckedItem } from "./approval-bypass.js";
|
|
11
|
+
import { parseCopyRegistry } from "./schema.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* One delegate scope item: a dot-separated entry-id namespace with no
|
|
15
|
+
* wildcard, e.g. `"site"` or `"site.home"`. Unlike `CopyEntryId`, a single
|
|
16
|
+
* segment is allowed, because a namespace may be a top-level one.
|
|
17
|
+
*/
|
|
18
|
+
export const COPY_DELEGATE_SCOPE_RE = /^[a-z0-9]+(-[a-z0-9]+)*(\.[a-z0-9]+(-[a-z0-9]+)*)*$/;
|
|
19
|
+
|
|
20
|
+
/** True when `value` is a well-formed delegate scope item. */
|
|
21
|
+
export function isDelegateScopeItem(value: unknown): value is string {
|
|
22
|
+
return typeof value === "string" && COPY_DELEGATE_SCOPE_RE.test(value);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* True when `entryId` falls inside one of `scope`'s namespaces: it equals an
|
|
27
|
+
* item, or starts with `item + "."`. `"site.home"` covers `"site.home"` and
|
|
28
|
+
* `"site.home.title"`, never `"site.homepage.title"`.
|
|
29
|
+
*/
|
|
30
|
+
export function isEntryInDelegateScope(entryId: string, scope: readonly string[]): boolean {
|
|
31
|
+
return scope.some((item) => entryId === item || entryId.startsWith(`${item}.`));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* True when `entry`'s recorded `textFingerprint` no longer matches its
|
|
36
|
+
* current `text` — the record was made for wording that has since changed.
|
|
37
|
+
* Exported so `resolve.ts` and `assessCopyApprovals` share one predicate
|
|
38
|
+
* rather than two independently-drifting copies of the same comparison.
|
|
39
|
+
*/
|
|
40
|
+
export function isApprovalStale(entry: Pick<CopyRegistryEntry, "text" | "approval">): boolean {
|
|
41
|
+
const approval = entry.approval;
|
|
42
|
+
if (!approval) return false;
|
|
43
|
+
return computeCopyFingerprint(entry.text) !== approval.textFingerprint;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** True when `approval.expiresAt` is set and has passed as of `now`. */
|
|
47
|
+
export function isApprovalExpired(approval: CopyApproval, now: Date): boolean {
|
|
48
|
+
return approval.expiresAt !== undefined && now.getTime() >= Date.parse(approval.expiresAt);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The rule an `assessCopyApprovals` finding was raised for. */
|
|
52
|
+
export type CopyApprovalFindingRule =
|
|
53
|
+
| "approval-stale"
|
|
54
|
+
| "approval-expired"
|
|
55
|
+
| "approval-pending-owner-review"
|
|
56
|
+
| "approval-record-missing";
|
|
57
|
+
|
|
58
|
+
/** One thing `assessCopyApprovals` found about the current approval state of a registry entry. */
|
|
59
|
+
export interface CopyApprovalFinding {
|
|
60
|
+
rule: CopyApprovalFindingRule;
|
|
61
|
+
severity: "error" | "warning";
|
|
62
|
+
entryId: string;
|
|
63
|
+
message: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Reports the current approval state of every `approved` entry in
|
|
68
|
+
* `registry` — draft and retired entries have nothing to approve and are
|
|
69
|
+
* ignored. An `approved` entry with no record at all is a warning
|
|
70
|
+
* (`approval-record-missing`): the pre-record state every registry started
|
|
71
|
+
* in, not yet a defect. A record that no longer matches its entry's text
|
|
72
|
+
* (`approval-stale`) or has passed its `expiresAt` (`approval-expired`) is
|
|
73
|
+
* an error — the checks are mutually exclusive, staleness taking priority
|
|
74
|
+
* since an expired-but-also-stale record's fingerprint mismatch is the more
|
|
75
|
+
* fundamental problem. A current delegate record still awaiting owner
|
|
76
|
+
* sign-off (`pendingOwnerReview: true`) is a warning, not an error: the
|
|
77
|
+
* entry is legitimately approved for now, just not yet durably so.
|
|
78
|
+
*/
|
|
79
|
+
export function assessCopyApprovals(registry: CopyRegistry, now: Date): CopyApprovalFinding[] {
|
|
80
|
+
const findings: CopyApprovalFinding[] = [];
|
|
81
|
+
for (const entry of registry.entries) {
|
|
82
|
+
if (entry.status !== "approved") continue;
|
|
83
|
+
const approval = entry.approval;
|
|
84
|
+
if (!approval) {
|
|
85
|
+
findings.push({
|
|
86
|
+
rule: "approval-record-missing",
|
|
87
|
+
severity: "warning",
|
|
88
|
+
entryId: entry.id,
|
|
89
|
+
message: `Entry "${entry.id}" is approved but has no approval record.`,
|
|
90
|
+
});
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (isApprovalStale(entry)) {
|
|
94
|
+
findings.push({
|
|
95
|
+
rule: "approval-stale",
|
|
96
|
+
severity: "error",
|
|
97
|
+
entryId: entry.id,
|
|
98
|
+
message: `Entry "${entry.id}"'s approval record no longer matches its current text.`,
|
|
99
|
+
});
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (isApprovalExpired(approval, now)) {
|
|
103
|
+
findings.push({
|
|
104
|
+
rule: "approval-expired",
|
|
105
|
+
severity: "error",
|
|
106
|
+
entryId: entry.id,
|
|
107
|
+
message: `Entry "${entry.id}"'s approval expired at ${approval.expiresAt}.`,
|
|
108
|
+
});
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (approval.approvedBy === "delegate" && approval.pendingOwnerReview === true) {
|
|
112
|
+
findings.push({
|
|
113
|
+
rule: "approval-pending-owner-review",
|
|
114
|
+
severity: "warning",
|
|
115
|
+
entryId: entry.id,
|
|
116
|
+
message: `Entry "${entry.id}" was approved by a delegate and is still awaiting owner review.`,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return findings;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ============================================================================
|
|
124
|
+
// `writer-check approval-state` — combines TWO independent checks into one
|
|
125
|
+
// three-state verdict: `assessCopyApprovals` above (is the REGISTRY's own
|
|
126
|
+
// approval bookkeeping current — no stale or expired record, no delegate
|
|
127
|
+
// record stuck pending review) and `scanApprovalBypass`/`checkApprovalBypass`
|
|
128
|
+
// (`./approval-bypass.ts`) (does CONSUMER SOURCE actually route copy through
|
|
129
|
+
// that bookkeeping, or around it). Either half can fail independently: a
|
|
130
|
+
// registry can be perfectly current while a component still hard-codes
|
|
131
|
+
// `status: "approved"`, and a codebase can route every read through
|
|
132
|
+
// `createCopyResolver` while the registry itself carries a record that has
|
|
133
|
+
// gone stale. This command is the one place both are reported together,
|
|
134
|
+
// under one exit code.
|
|
135
|
+
//
|
|
136
|
+
// Not exported from `index.ts` (like `mainApproveCommand` in
|
|
137
|
+
// `approve-cli.ts`) — this is CLI surface, dispatched from `cli.ts`'s
|
|
138
|
+
// bottom-of-file `argv[0]` branch, not library surface.
|
|
139
|
+
// ============================================================================
|
|
140
|
+
|
|
141
|
+
export const APPROVAL_STATE_USAGE = `Usage: writer-check approval-state <registry-file> <scan-dir> [options]
|
|
142
|
+
|
|
143
|
+
registry-file Path to a CopyRegistry JSON file. Required.
|
|
144
|
+
scan-dir Directory to scan for approval-bypass patterns in consumer source. Required.
|
|
145
|
+
|
|
146
|
+
Reports the current approval state of a copy registry from two angles: is
|
|
147
|
+
every approved entry's own record current (see assessCopyApprovals in
|
|
148
|
+
approval.ts — a stale or expired record is an error; a missing record or a
|
|
149
|
+
delegate record still pending owner review is a warning), and does consumer
|
|
150
|
+
source in scan-dir actually route copy through the registry's approval
|
|
151
|
+
lifecycle rather than around it (see scanApprovalBypass/checkApprovalBypass
|
|
152
|
+
in approval-bypass.ts — approval-set-in-code and copy-read-without-resolver
|
|
153
|
+
are errors; an unchecked registry load or reference is neither a pass nor a
|
|
154
|
+
finding, just an incomplete picture).
|
|
155
|
+
|
|
156
|
+
Options:
|
|
157
|
+
--help Print this message and exit 0.
|
|
158
|
+
--extensions <ext>[,<ext>...] File extension(s) to scan, including the leading dot (for example .mjs). Repeatable, and each occurrence may itself be a comma-separated list; values union across both forms. When omitted, default is .ts, .tsx, .js, .jsx. Every value must include the leading dot; a flag that resolves to zero extensions is a usage error.
|
|
159
|
+
--format <json> When "json", prints exactly one machine-readable object ({ verdict, findings, unchecked, counts }) to stdout instead of the human-readable report. Any other value is a usage error.
|
|
160
|
+
--now <ISO> ISO 8601 UTC timestamp (YYYY-MM-DDTHH:MM:SSZ, optionally with milliseconds) to evaluate staleness/expiry against. Defaults to the current time.
|
|
161
|
+
|
|
162
|
+
Exit codes: 0 = satisfied (every approved entry's record is current, no
|
|
163
|
+
approval-bypass finding, nothing left unchecked, and at least one file was
|
|
164
|
+
scanned), 1 = violated (at least one error-severity registry finding —
|
|
165
|
+
approval-stale or approval-expired — or at least one approval-bypass finding
|
|
166
|
+
— approval-set-in-code or copy-read-without-resolver), 2 = could not run or
|
|
167
|
+
indeterminate (bad arguments; the registry file is missing/unreadable/not
|
|
168
|
+
valid JSON/fails validateCopyRegistryShape; scan-dir is not a directory; the
|
|
169
|
+
scan itself could not run; or — with zero error-severity finding — at least
|
|
170
|
+
one unchecked registry load/reference, at least one file that failed to
|
|
171
|
+
parse, or zero files scanned). Warnings never change the exit code.
|
|
172
|
+
`;
|
|
173
|
+
|
|
174
|
+
/** Bad arguments only — mirrors `approve-cli.ts`'s `ApproveInputError`. */
|
|
175
|
+
class ApprovalStateInputError extends Error {}
|
|
176
|
+
|
|
177
|
+
const APPROVAL_STATE_EXTENSION_RE = /^\.[a-zA-Z0-9]+$/;
|
|
178
|
+
/** Same shape schema.ts's own (unexported) `ISO_UTC_TIMESTAMP_RE` accepts. */
|
|
179
|
+
const APPROVAL_STATE_NOW_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/;
|
|
180
|
+
|
|
181
|
+
interface ApprovalStateParsedArgs {
|
|
182
|
+
registryFile?: string;
|
|
183
|
+
scanDir?: string;
|
|
184
|
+
/** Set only when the caller passed at least one `--extensions` flag. */
|
|
185
|
+
extensions?: string[];
|
|
186
|
+
format: "text" | "json";
|
|
187
|
+
now?: string;
|
|
188
|
+
help: boolean;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Parses `--extensions` with the identical rules `cli.ts`'s
|
|
193
|
+
* `parseAddressabilityArgs` applies to its own `--extensions` flag —
|
|
194
|
+
* repeatable, comma-separated, leading dot required, an empty resolved set
|
|
195
|
+
* is a usage error rather than a silent fallback to the default. Duplicated
|
|
196
|
+
* rather than imported from `cli.ts`, since `cli.ts` imports this file's
|
|
197
|
+
* `mainApprovalStateCommand` — importing back would create a real
|
|
198
|
+
* value-level cycle between the two, unlike the `schema.ts` cycle this
|
|
199
|
+
* file's `parseCopyRegistry` import already tolerates (see this section's
|
|
200
|
+
* top doc comment).
|
|
201
|
+
*/
|
|
202
|
+
function parseApprovalStateArgs(argv: string[]): ApprovalStateParsedArgs {
|
|
203
|
+
let registryFile: string | undefined;
|
|
204
|
+
let scanDir: string | undefined;
|
|
205
|
+
const extensions: string[] = [];
|
|
206
|
+
let sawExtensions = false;
|
|
207
|
+
let format: "text" | "json" = "text";
|
|
208
|
+
let now: string | undefined;
|
|
209
|
+
let help = false;
|
|
210
|
+
|
|
211
|
+
for (let i = 0; i < argv.length; i++) {
|
|
212
|
+
const arg = argv[i] as string;
|
|
213
|
+
if (arg === "--help" || arg === "-h") {
|
|
214
|
+
help = true;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (arg === "--extensions") {
|
|
218
|
+
sawExtensions = true;
|
|
219
|
+
const value = argv[++i];
|
|
220
|
+
if (value === undefined || value.startsWith("-")) {
|
|
221
|
+
throw new ApprovalStateInputError("--extensions requires an extension such as .mjs");
|
|
222
|
+
}
|
|
223
|
+
const tokens = value.split(",");
|
|
224
|
+
if (tokens.length === 0 || tokens.some((token) => token.trim().length === 0)) {
|
|
225
|
+
throw new ApprovalStateInputError(
|
|
226
|
+
`--extensions requires at least one non-empty extension such as .mjs, got ${JSON.stringify(value)}`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
for (const rawToken of tokens) {
|
|
230
|
+
const token = rawToken.trim().toLowerCase();
|
|
231
|
+
if (!token.startsWith(".")) {
|
|
232
|
+
throw new ApprovalStateInputError(`--extensions values must include the leading dot, got ${JSON.stringify(token)}`);
|
|
233
|
+
}
|
|
234
|
+
if (!APPROVAL_STATE_EXTENSION_RE.test(token)) {
|
|
235
|
+
throw new ApprovalStateInputError(`invalid --extensions value ${JSON.stringify(token)}`);
|
|
236
|
+
}
|
|
237
|
+
extensions.push(token);
|
|
238
|
+
}
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (arg === "--format") {
|
|
242
|
+
const value = argv[++i];
|
|
243
|
+
if (value !== "json") {
|
|
244
|
+
throw new ApprovalStateInputError(`--format only accepts "json", got ${JSON.stringify(value)}`);
|
|
245
|
+
}
|
|
246
|
+
format = "json";
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (arg === "--now") {
|
|
250
|
+
const value = argv[++i];
|
|
251
|
+
if (value === undefined || value.startsWith("-")) {
|
|
252
|
+
throw new ApprovalStateInputError("--now requires an ISO 8601 UTC timestamp");
|
|
253
|
+
}
|
|
254
|
+
now = value;
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (arg.startsWith("-")) {
|
|
258
|
+
throw new ApprovalStateInputError(`unknown flag "${arg}"`);
|
|
259
|
+
}
|
|
260
|
+
if (registryFile === undefined) {
|
|
261
|
+
registryFile = arg;
|
|
262
|
+
} else if (scanDir === undefined) {
|
|
263
|
+
scanDir = arg;
|
|
264
|
+
} else {
|
|
265
|
+
throw new ApprovalStateInputError(`unexpected extra argument "${arg}"`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return { registryFile, scanDir, extensions: sawExtensions ? extensions : undefined, format, now, help };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function parseApprovalStateNow(raw: string | undefined): Date {
|
|
273
|
+
if (raw === undefined) return new Date();
|
|
274
|
+
if (!APPROVAL_STATE_NOW_RE.test(raw) || Number.isNaN(Date.parse(raw))) {
|
|
275
|
+
throw new ApprovalStateInputError(`--now must be an ISO 8601 UTC timestamp (e.g. 2026-01-01T00:00:00Z), got ${JSON.stringify(raw)}`);
|
|
276
|
+
}
|
|
277
|
+
return new Date(raw);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function errorMessage(error: unknown): string {
|
|
281
|
+
return error instanceof Error ? error.message : String(error);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
interface ApprovalStateUnchecked {
|
|
285
|
+
file: string;
|
|
286
|
+
line: number;
|
|
287
|
+
kind: string;
|
|
288
|
+
detail: string;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
type ApprovalStateVerdict = "violated" | "indeterminate" | "satisfied";
|
|
292
|
+
|
|
293
|
+
function combineUnchecked(scan: ApprovalBypassScanResult, bypass: ApprovalBypassGateResult): ApprovalStateUnchecked[] {
|
|
294
|
+
const fromParseFailures: ApprovalStateUnchecked[] = scan.parseFailures.map((p) => ({
|
|
295
|
+
file: p.file,
|
|
296
|
+
line: 0,
|
|
297
|
+
kind: "parse-failure",
|
|
298
|
+
detail: p.detail,
|
|
299
|
+
}));
|
|
300
|
+
return [...(bypass.unchecked as ApprovalBypassUncheckedItem[]), ...fromParseFailures];
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function computeApprovalStateVerdict(registryFindings: CopyApprovalFinding[], bypass: ApprovalBypassGateResult): ApprovalStateVerdict {
|
|
304
|
+
const hasRegistryError = registryFindings.some((f) => f.severity === "error");
|
|
305
|
+
if (hasRegistryError || bypass.verdict === "violated") return "violated";
|
|
306
|
+
if (bypass.verdict === "indeterminate") return "indeterminate";
|
|
307
|
+
return "satisfied";
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function approvalStateExitCode(verdict: ApprovalStateVerdict): number {
|
|
311
|
+
return verdict === "violated" ? 1 : verdict === "indeterminate" ? 2 : 0;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Emitted when this command cannot even reach the point of scanning (a bad
|
|
316
|
+
* registry file, an unusable scan-dir, or the scan itself throwing) — always
|
|
317
|
+
* exit 2, always `verdict: "indeterminate"` in JSON mode, with `error`
|
|
318
|
+
* naming the reason. Bad ARGUMENTS never reach here — those are caught in
|
|
319
|
+
* `mainApprovalStateCommand`'s own outer catch, before `--format` is even
|
|
320
|
+
* meaningfully known to matter, and always print usage to stderr regardless
|
|
321
|
+
* of `--format`.
|
|
322
|
+
*/
|
|
323
|
+
function emitApprovalStateCouldNotRun(format: "text" | "json", reason: string): number {
|
|
324
|
+
if (format === "json") {
|
|
325
|
+
console.log(
|
|
326
|
+
JSON.stringify({
|
|
327
|
+
verdict: "indeterminate",
|
|
328
|
+
findings: [],
|
|
329
|
+
unchecked: [],
|
|
330
|
+
counts: { errors: 0, warnings: 0, unchecked: 0, filesScanned: 0, coupledFiles: 0 },
|
|
331
|
+
error: reason,
|
|
332
|
+
}),
|
|
333
|
+
);
|
|
334
|
+
} else {
|
|
335
|
+
console.error(`[approval-state] could not run: ${reason}`);
|
|
336
|
+
}
|
|
337
|
+
return 2;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Prints the human-text or `--format json` report and returns the exit code
|
|
342
|
+
* — the one place `assessCopyApprovals`' registry findings and
|
|
343
|
+
* `checkApprovalBypass`'s source findings are merged into a single verdict.
|
|
344
|
+
* See this section's top doc comment for why the two are combined rather
|
|
345
|
+
* than reported as two separate commands.
|
|
346
|
+
*/
|
|
347
|
+
function reportApprovalState(
|
|
348
|
+
format: "text" | "json",
|
|
349
|
+
registryPath: string,
|
|
350
|
+
scanDirPath: string,
|
|
351
|
+
registryFindings: CopyApprovalFinding[],
|
|
352
|
+
scan: ApprovalBypassScanResult,
|
|
353
|
+
bypass: ApprovalBypassGateResult,
|
|
354
|
+
): number {
|
|
355
|
+
const verdict = computeApprovalStateVerdict(registryFindings, bypass);
|
|
356
|
+
const exitCode = approvalStateExitCode(verdict);
|
|
357
|
+
|
|
358
|
+
const registryErrors = registryFindings.filter((f) => f.severity === "error");
|
|
359
|
+
const registryWarnings = registryFindings.filter((f) => f.severity === "warning");
|
|
360
|
+
const unchecked = combineUnchecked(scan, bypass);
|
|
361
|
+
const errorCount = registryErrors.length + bypass.findings.length;
|
|
362
|
+
|
|
363
|
+
if (format === "json") {
|
|
364
|
+
const findings = [
|
|
365
|
+
...registryFindings.map((f) => ({ source: "registry" as const, rule: f.rule, severity: f.severity, entryId: f.entryId, message: f.message })),
|
|
366
|
+
...bypass.findings.map((f: ApprovalBypassFinding) => ({ source: "source" as const, rule: f.rule, severity: f.severity, file: f.file, line: f.line, message: f.detail })),
|
|
367
|
+
];
|
|
368
|
+
console.log(
|
|
369
|
+
JSON.stringify({
|
|
370
|
+
verdict,
|
|
371
|
+
findings,
|
|
372
|
+
unchecked,
|
|
373
|
+
counts: {
|
|
374
|
+
errors: errorCount,
|
|
375
|
+
warnings: registryWarnings.length,
|
|
376
|
+
unchecked: unchecked.length,
|
|
377
|
+
filesScanned: scan.filesScanned,
|
|
378
|
+
coupledFiles: scan.coupledFiles.length,
|
|
379
|
+
},
|
|
380
|
+
}),
|
|
381
|
+
);
|
|
382
|
+
return exitCode;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
console.log(`[approval-state] registry: ${registryPath}`);
|
|
386
|
+
console.log(`[approval-state] scan dir: ${scanDirPath}`);
|
|
387
|
+
console.log(
|
|
388
|
+
`[approval-state] ${errorCount} error(s), ${registryWarnings.length} warning(s), ${unchecked.length} unchecked item(s), ${scan.filesScanned} file(s) scanned, ${scan.coupledFiles.length} coupled file(s).`,
|
|
389
|
+
);
|
|
390
|
+
for (const f of registryErrors) {
|
|
391
|
+
console.log(`[approval-state] [${f.rule}] ${f.entryId}: ${f.message}`);
|
|
392
|
+
}
|
|
393
|
+
for (const f of bypass.findings) {
|
|
394
|
+
console.log(`[approval-state] [${f.rule}] ${f.file}:${f.line} ${f.detail}`);
|
|
395
|
+
}
|
|
396
|
+
if (registryWarnings.length > 0) {
|
|
397
|
+
console.log(`[approval-state] warnings:`);
|
|
398
|
+
for (const f of registryWarnings) {
|
|
399
|
+
console.log(`[approval-state] [${f.rule}] ${f.entryId}: ${f.message}`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if (unchecked.length > 0) {
|
|
403
|
+
console.log(`[approval-state] unchecked:`);
|
|
404
|
+
for (const u of unchecked) {
|
|
405
|
+
console.log(`[approval-state] [${u.kind}] ${u.file}:${u.line} ${u.detail}`);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (bypass.reasons.length > 0) {
|
|
409
|
+
console.log(`[approval-state] reasons:`);
|
|
410
|
+
for (const r of bypass.reasons) {
|
|
411
|
+
console.log(`[approval-state] ${r}`);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
console.log(`[approval-state] verdict: ${verdict}`);
|
|
415
|
+
return exitCode;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Exported (unlike a typical CLI `main`) so this package's tests (which do
|
|
420
|
+
* not ship) can exercise the whole argv-to-exit-code contract directly, without spawning a subprocess —
|
|
421
|
+
* the same discipline `mainApproveCommand` (`approve-cli.ts`) and
|
|
422
|
+
* `mainAddressabilityCheck`/`mainPassagesCheck` (`cli.ts`) already hold to.
|
|
423
|
+
* `argv` excludes the leading `"approval-state"` token. Never throws: every
|
|
424
|
+
* failure, expected or not, is caught here and mapped to exit code 2.
|
|
425
|
+
*/
|
|
426
|
+
export function mainApprovalStateCommand(argv: string[]): number {
|
|
427
|
+
try {
|
|
428
|
+
const args = parseApprovalStateArgs(argv);
|
|
429
|
+
if (args.help) {
|
|
430
|
+
console.log(APPROVAL_STATE_USAGE);
|
|
431
|
+
return 0;
|
|
432
|
+
}
|
|
433
|
+
if (args.registryFile === undefined) {
|
|
434
|
+
throw new ApprovalStateInputError("registry-file is required");
|
|
435
|
+
}
|
|
436
|
+
if (args.scanDir === undefined) {
|
|
437
|
+
throw new ApprovalStateInputError("scan-dir is required");
|
|
438
|
+
}
|
|
439
|
+
const now = parseApprovalStateNow(args.now);
|
|
440
|
+
|
|
441
|
+
const registryPath = resolve(process.cwd(), args.registryFile);
|
|
442
|
+
const scanDirPath = resolve(process.cwd(), args.scanDir);
|
|
443
|
+
|
|
444
|
+
let raw: string;
|
|
445
|
+
try {
|
|
446
|
+
raw = readFileSync(registryPath, "utf8");
|
|
447
|
+
} catch (error) {
|
|
448
|
+
return emitApprovalStateCouldNotRun(args.format, `registry file "${registryPath}" could not be read: ${errorMessage(error)}`);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
let parsedJson: unknown;
|
|
452
|
+
try {
|
|
453
|
+
parsedJson = JSON.parse(raw);
|
|
454
|
+
} catch (error) {
|
|
455
|
+
return emitApprovalStateCouldNotRun(args.format, `registry file "${registryPath}" is not valid JSON: ${errorMessage(error)}`);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
let registry: CopyRegistry;
|
|
459
|
+
try {
|
|
460
|
+
registry = parseCopyRegistry(parsedJson);
|
|
461
|
+
} catch (error) {
|
|
462
|
+
return emitApprovalStateCouldNotRun(args.format, errorMessage(error));
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (!existsSync(scanDirPath) || !statSync(scanDirPath).isDirectory()) {
|
|
466
|
+
return emitApprovalStateCouldNotRun(args.format, `scan-dir "${scanDirPath}" is not a directory`);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const registryFindings = assessCopyApprovals(registry, now);
|
|
470
|
+
|
|
471
|
+
let scan: ApprovalBypassScanResult;
|
|
472
|
+
try {
|
|
473
|
+
scan = scanApprovalBypass(scanDirPath, { registryPath, extensions: args.extensions });
|
|
474
|
+
} catch (error) {
|
|
475
|
+
return emitApprovalStateCouldNotRun(args.format, errorMessage(error));
|
|
476
|
+
}
|
|
477
|
+
const bypass = checkApprovalBypass(scan);
|
|
478
|
+
|
|
479
|
+
return reportApprovalState(args.format, registryPath, scanDirPath, registryFindings, scan, bypass);
|
|
480
|
+
} catch (error) {
|
|
481
|
+
if (error instanceof ApprovalStateInputError) {
|
|
482
|
+
console.error(`writer-check approval-state: ${error.message}`);
|
|
483
|
+
console.error(`\n${APPROVAL_STATE_USAGE}`);
|
|
484
|
+
return 2;
|
|
485
|
+
}
|
|
486
|
+
console.error(
|
|
487
|
+
`writer-check approval-state: unexpected error: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`,
|
|
488
|
+
);
|
|
489
|
+
return 2;
|
|
490
|
+
}
|
|
491
|
+
}
|