@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.
Files changed (109) hide show
  1. package/README.md +587 -2
  2. package/dist/approval-bypass.d.ts +120 -0
  3. package/dist/approval-bypass.d.ts.map +1 -0
  4. package/dist/approval-bypass.js +2318 -0
  5. package/dist/approval-bypass.js.map +1 -0
  6. package/dist/approval.d.ts +62 -0
  7. package/dist/approval.d.ts.map +1 -0
  8. package/dist/approval.js +421 -0
  9. package/dist/approval.js.map +1 -0
  10. package/dist/approve-cli.d.ts +82 -0
  11. package/dist/approve-cli.d.ts.map +1 -0
  12. package/dist/approve-cli.js +430 -0
  13. package/dist/approve-cli.js.map +1 -0
  14. package/dist/cli.d.ts +15 -0
  15. package/dist/cli.d.ts.map +1 -1
  16. package/dist/cli.js +48 -13
  17. package/dist/cli.js.map +1 -1
  18. package/dist/field-options.d.ts +59 -0
  19. package/dist/field-options.d.ts.map +1 -0
  20. package/dist/field-options.js +109 -0
  21. package/dist/field-options.js.map +1 -0
  22. package/dist/front-door.check.d.ts +2 -0
  23. package/dist/front-door.check.d.ts.map +1 -0
  24. package/dist/front-door.check.js +8 -0
  25. package/dist/front-door.check.js.map +1 -0
  26. package/dist/front-door.d.ts +82 -0
  27. package/dist/front-door.d.ts.map +1 -0
  28. package/dist/front-door.js +271 -0
  29. package/dist/front-door.js.map +1 -0
  30. package/dist/generated/advisor-plan-contract.generated.d.ts +3 -0
  31. package/dist/generated/advisor-plan-contract.generated.d.ts.map +1 -0
  32. package/dist/generated/advisor-plan-contract.generated.js +430 -0
  33. package/dist/generated/advisor-plan-contract.generated.js.map +1 -0
  34. package/dist/generated/contract-schema.generated.d.ts +97 -0
  35. package/dist/generated/contract-schema.generated.d.ts.map +1 -0
  36. package/dist/generated/contract-schema.generated.js +496 -0
  37. package/dist/generated/contract-schema.generated.js.map +1 -0
  38. package/dist/index.d.ts +14 -2
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +24 -0
  41. package/dist/index.js.map +1 -1
  42. package/dist/messaging-kit.d.ts +113 -0
  43. package/dist/messaging-kit.d.ts.map +1 -0
  44. package/dist/messaging-kit.js +121 -0
  45. package/dist/messaging-kit.js.map +1 -0
  46. package/dist/passage.d.ts +2 -1
  47. package/dist/passage.d.ts.map +1 -1
  48. package/dist/passage.js +2 -1
  49. package/dist/passage.js.map +1 -1
  50. package/dist/plan-authority.d.ts +73 -0
  51. package/dist/plan-authority.d.ts.map +1 -0
  52. package/dist/plan-authority.js +126 -0
  53. package/dist/plan-authority.js.map +1 -0
  54. package/dist/plan-contract.d.ts +16 -0
  55. package/dist/plan-contract.d.ts.map +1 -0
  56. package/dist/plan-contract.js +33 -0
  57. package/dist/plan-contract.js.map +1 -0
  58. package/dist/plan-digest.d.ts +23 -0
  59. package/dist/plan-digest.d.ts.map +1 -0
  60. package/dist/plan-digest.js +82 -0
  61. package/dist/plan-digest.js.map +1 -0
  62. package/dist/plan-rules.d.ts +54 -0
  63. package/dist/plan-rules.d.ts.map +1 -0
  64. package/dist/plan-rules.js +145 -0
  65. package/dist/plan-rules.js.map +1 -0
  66. package/dist/resolve.d.ts +40 -6
  67. package/dist/resolve.d.ts.map +1 -1
  68. package/dist/resolve.js +144 -18
  69. package/dist/resolve.js.map +1 -1
  70. package/dist/scan.d.ts +32 -0
  71. package/dist/scan.d.ts.map +1 -1
  72. package/dist/scan.js +67 -6
  73. package/dist/scan.js.map +1 -1
  74. package/dist/schema.d.ts +1 -0
  75. package/dist/schema.d.ts.map +1 -1
  76. package/dist/schema.js +209 -5
  77. package/dist/schema.js.map +1 -1
  78. package/dist/site-identity.d.ts +88 -0
  79. package/dist/site-identity.d.ts.map +1 -0
  80. package/dist/site-identity.js +82 -0
  81. package/dist/site-identity.js.map +1 -0
  82. package/dist/types.d.ts +65 -7
  83. package/dist/types.d.ts.map +1 -1
  84. package/dist/types.js +9 -7
  85. package/dist/types.js.map +1 -1
  86. package/package.json +7 -6
  87. package/skill/SKILL.md +2 -2
  88. package/src/approval-bypass.ts +2263 -0
  89. package/src/approval.ts +491 -0
  90. package/src/approve-cli.ts +474 -0
  91. package/src/cli.ts +46 -13
  92. package/src/field-options.ts +134 -0
  93. package/src/front-door.check.ts +10 -0
  94. package/src/front-door.ts +312 -0
  95. package/src/generated/advisor-plan-contract.generated.ts +430 -0
  96. package/src/generated/contract-schema.generated.ts +520 -0
  97. package/src/index.ts +97 -1
  98. package/src/messaging-kit.ts +195 -0
  99. package/src/passage.ts +2 -1
  100. package/src/plan-authority.ts +160 -0
  101. package/src/plan-contract.ts +45 -0
  102. package/src/plan-digest.ts +81 -0
  103. package/src/plan-rules.ts +179 -0
  104. package/src/resolve.ts +185 -19
  105. package/src/scan.ts +97 -6
  106. package/src/schema.ts +223 -5
  107. package/src/site-identity.ts +125 -0
  108. package/src/types.ts +72 -7
  109. package/templates/front-door.en.json +426 -0
@@ -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
+ }