@clossys/writer 0.3.6 → 0.3.9

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/src/cli.ts CHANGED
@@ -130,11 +130,11 @@ import {
130
130
  type AddressabilityGateResult,
131
131
  type AddressabilityScanResult,
132
132
  } from "./addressability.js";
133
- import { checkCopyTraceability, type CopyGateResult } from "./copy-gate.js";
133
+ import { checkCopyTraceability, type CopyGateFinding, type CopyGateIgnored, type CopyGateResult } from "./copy-gate.js";
134
134
  import { checkLocaleCoverage, type LocaleCoverageReport } from "./locale-coverage.js";
135
135
  import { checkPassageComposition, readPassageRecord, type PassageGateResult } from "./passage.js";
136
136
  import { readCopyRecord } from "./registry.js";
137
- import { scanCopySourceTree, type ScanResult } from "./scan.js";
137
+ import { scanCopySourceTree, type ScanResult, type UncheckedItem } from "./scan.js";
138
138
  import { checkVoiceDerivationCoverage, type VoiceDerivationCoverageResult } from "./voice/index.js";
139
139
 
140
140
  const USAGE = `Usage: writer-check <record-file> [scan-dir] [options]
@@ -154,8 +154,18 @@ are separate, stricter gates; neither runs as part of this default command.
154
154
 
155
155
  Options:
156
156
  --help Print this message and exit 0.
157
-
158
- Exit codes: 0 = clean, 1 = at least one finding, 2 = could not run (bad input, missing/invalid record, nothing matched to scan, or every matched file failed to parse).
157
+ --format <text|json> Output format. Defaults to text. "json" prints exactly one
158
+ machine-readable object to stdout (see CopyTraceabilityReport in
159
+ cli.ts) instead of the human-readable report — "verdict" states the
160
+ same clean/findings/indeterminate outcome as the exit code, but
161
+ "findings" and "unchecked" are always both present, so a consumer
162
+ that only checks the exit code is never the only way to see what a
163
+ run DID measure: a scan that produced real findings AND one
164
+ unclassifiable construct in the same run (issue #753) still exits 2
165
+ (an indeterminate result must never read as clean), but its findings
166
+ remain reachable in this object rather than discarded with it.
167
+
168
+ Exit codes: 0 = clean, 1 = at least one finding, 2 = could not run (bad input, missing/invalid record, nothing matched to scan, every matched file failed to parse, or at least one JSX construct recognized but not reliably classified — see --format json above to still recover any findings a run like that DID produce).
159
169
 
160
170
  Run "writer-check voice-derivation-coverage --help" for the second subcommand's own usage.
161
171
  Run "writer-check locale-coverage --help" for the third subcommand's own usage.
@@ -196,19 +206,30 @@ export class CliInputError extends Error {}
196
206
  interface ParsedArgs {
197
207
  recordFile?: string;
198
208
  scanDir?: string;
209
+ format: "text" | "json";
199
210
  help: boolean;
200
211
  }
201
212
 
202
213
  function parseArgs(argv: string[]): ParsedArgs {
203
214
  let recordFile: string | undefined;
204
215
  let scanDir: string | undefined;
216
+ let format: "text" | "json" = "text";
205
217
  let help = false;
206
218
 
207
- for (const arg of argv) {
219
+ for (let i = 0; i < argv.length; i++) {
220
+ const arg = argv[i] as string;
208
221
  if (arg === "--help" || arg === "-h") {
209
222
  help = true;
210
223
  continue;
211
224
  }
225
+ if (arg === "--format") {
226
+ const value = argv[++i];
227
+ if (value !== "text" && value !== "json") {
228
+ throw new CliInputError(`--format must be "text" or "json", got ${JSON.stringify(value)}`);
229
+ }
230
+ format = value;
231
+ continue;
232
+ }
212
233
  if (arg.startsWith("-")) {
213
234
  throw new CliInputError(`unknown flag "${arg}"`);
214
235
  }
@@ -221,7 +242,7 @@ function parseArgs(argv: string[]): ParsedArgs {
221
242
  }
222
243
  }
223
244
 
224
- return { recordFile, scanDir, help };
245
+ return { recordFile, scanDir, format, help };
225
246
  }
226
247
 
227
248
  function requireDirectory(label: string, path: string): void {
@@ -706,6 +727,74 @@ function printAddressabilityReport(result: AddressabilityGateResult): void {
706
727
  * that reason — `run()` below is the only caller that reads the real
707
728
  * `process.argv`.
708
729
  */
730
+ /**
731
+ * The default command's own structured, machine-readable report — issue
732
+ * #753's second half. `printGateReport`/`printScanAccounting` already put
733
+ * every finding a run DID produce ahead of any exit-code decision in the
734
+ * text output; the gap #753 found is that a consumer keying on the exit
735
+ * code alone (2 = indeterminate) cannot reach those findings at all,
736
+ * because nothing besides that human-readable text carries them. This
737
+ * type is that carrier: `--format json` prints EXACTLY ONE of these,
738
+ * nothing else, to stdout, whatever `exitCode` turns out to be — the same
739
+ * "one parseable object regardless of verdict" contract `inspector`'s own
740
+ * `VerifyStandardsReport`/`--format json` already publishes (see
741
+ * `packages/inspector/src/cli.ts`), and the same shape `observer`'s
742
+ * fleet-coverage grader and `addressability.ts`'s own `AddressabilityGateResult`
743
+ * use: a per-item verdict (here, each `CopyGateFinding`/`UncheckedItem`) is
744
+ * a DIFFERENT question from the run's one overall verdict, and collapsing
745
+ * the two into a bare exit code is exactly what discarded 292 real
746
+ * findings alongside one unclassifiable construct in #753's own repro.
747
+ *
748
+ * `verdict` is the SAME three states this whole package uses everywhere
749
+ * else (`"clean" | "findings" | "indeterminate"`), computed with the
750
+ * identical precedence `main()`'s own exit-code decision already applies
751
+ * below — `unchecked.length > 0` wins over `findings.length > 0`, which
752
+ * wins over a clean pass. Never a fourth state, and never inferred
753
+ * separately from `exitCode` by a consumer — read `verdict` (or
754
+ * `exitCode`) and treat `findings`/`unchecked` as what was actually
755
+ * measured, present or empty independent of which verdict resulted.
756
+ *
757
+ * `reason` is populated ONLY for the "could not run at all" shape of
758
+ * indeterminate — a missing/invalid copy record, an untrustworthy
759
+ * exclusion list, or a scan that matched/parsed nothing — where `findings`
760
+ * and `unchecked` are necessarily both `[]` because `checkCopyTraceability`
761
+ * never ran. It is `undefined` whenever a real scan DID run, including the
762
+ * `"indeterminate"` case #753 is about (real `findings`, plus one or more
763
+ * `unchecked` entries) — that case needs no extra prose beyond the data
764
+ * itself, which is the entire point of shipping it as data.
765
+ */
766
+ export interface CopyTraceabilityReport {
767
+ recordFile: string;
768
+ scanDir: string;
769
+ verdict: "clean" | "findings" | "indeterminate";
770
+ exitCode: 0 | 1 | 2;
771
+ reason?: string;
772
+ filesScanned: number;
773
+ candidatesScanned: number;
774
+ matched: number;
775
+ findings: CopyGateFinding[];
776
+ ignored: CopyGateIgnored[];
777
+ unchecked: UncheckedItem[];
778
+ parseFailures: { file: string; detail: string }[];
779
+ }
780
+
781
+ function emptyReport(recordFile: string, scanDir: string, exitCode: 0 | 1 | 2, reason: string): CopyTraceabilityReport {
782
+ return {
783
+ recordFile,
784
+ scanDir,
785
+ verdict: "indeterminate",
786
+ exitCode,
787
+ reason,
788
+ filesScanned: 0,
789
+ candidatesScanned: 0,
790
+ matched: 0,
791
+ findings: [],
792
+ ignored: [],
793
+ unchecked: [],
794
+ parseFailures: [],
795
+ };
796
+ }
797
+
709
798
  export function main(argv: string[]): number {
710
799
  // Subcommand dispatch: only `argv[0] === "voice-derivation-coverage"` or
711
800
  // `argv[0] === "locale-coverage"` exactly diverts to the second/third
@@ -729,13 +818,20 @@ export function main(argv: string[]): number {
729
818
  throw new CliInputError("record-file is required");
730
819
  }
731
820
 
821
+ const jsonMode = args.format === "json";
822
+ const emitJson = (report: CopyTraceabilityReport): void => {
823
+ if (jsonMode) console.log(JSON.stringify(report, null, 2));
824
+ };
825
+
732
826
  const recordFile = resolve(args.recordFile);
733
827
  const scanDir = resolve(args.scanDir ?? process.cwd());
734
828
  requireFile("record-file", recordFile);
735
829
  requireDirectory("scan-dir", scanDir);
736
830
 
737
- console.log(`Copy record: ${recordFile}`);
738
- console.log(`Scan directory: ${scanDir}`);
831
+ if (!jsonMode) {
832
+ console.log(`Copy record: ${recordFile}`);
833
+ console.log(`Scan directory: ${scanDir}`);
834
+ }
739
835
 
740
836
  // The copy record itself missing/unreadable/unparseable/invalid is
741
837
  // fail-closed: there is no trustworthy set of registered entries to
@@ -744,9 +840,19 @@ export function main(argv: string[]): number {
744
840
  // `strategy-facts-check` holds `facts.json` to.
745
841
  const read = readCopyRecord(recordFile);
746
842
  if (!read.complete || !read.record) {
747
- console.error(`\nCopy record could not be loaded:`);
748
- for (const issue of read.issues) console.error(` [${issue.reason}] ${issue.detail}`);
749
- console.error("Refusing to report a pass with no trustworthy copy record to check source against.");
843
+ if (!jsonMode) {
844
+ console.error(`\nCopy record could not be loaded:`);
845
+ for (const issue of read.issues) console.error(` [${issue.reason}] ${issue.detail}`);
846
+ console.error("Refusing to report a pass with no trustworthy copy record to check source against.");
847
+ }
848
+ emitJson(
849
+ emptyReport(
850
+ recordFile,
851
+ scanDir,
852
+ 2,
853
+ `copy record could not be loaded: ${read.issues.map((i) => `[${i.reason}] ${i.detail}`).join("; ")}`,
854
+ ),
855
+ );
750
856
  return 2;
751
857
  }
752
858
 
@@ -758,7 +864,7 @@ export function main(argv: string[]): number {
758
864
  // own options) does not require touching this exit-code logic again —
759
865
  // see this file's top doc comment.
760
866
  const scan = scanCopySourceTree(scanDir); // throws (fail-closed) on an unreadable directory — caught by run()
761
- printScanAccounting(scan);
867
+ if (!jsonMode) printScanAccounting(scan);
762
868
 
763
869
  // A malformed pathExclusions entry means this run cannot trust which
764
870
  // files were correctly excluded — "could not run", not "ran and found
@@ -767,8 +873,13 @@ export function main(argv: string[]): number {
767
873
  // walk itself found anything.
768
874
  const invalidPathExclusions = scan.pathExclusionFindings.filter((f) => f.severity === "error");
769
875
  if (invalidPathExclusions.length > 0) {
770
- console.error(
771
- `\n${invalidPathExclusions.length} pathExclusions entr${invalidPathExclusions.length === 1 ? "y is" : "ies are"} invalid — refusing to report a pass built on an exclusion list that cannot be trusted.`,
876
+ if (!jsonMode) {
877
+ console.error(
878
+ `\n${invalidPathExclusions.length} pathExclusions entr${invalidPathExclusions.length === 1 ? "y is" : "ies are"} invalid — refusing to report a pass built on an exclusion list that cannot be trusted.`,
879
+ );
880
+ }
881
+ emitJson(
882
+ emptyReport(recordFile, scanDir, 2, `${invalidPathExclusions.length} invalid pathExclusions entr(y/ies)`),
772
883
  );
773
884
  return 2;
774
885
  }
@@ -779,12 +890,19 @@ export function main(argv: string[]): number {
779
890
  // scan.parseFailures) are both, deliberately, the same outcome here —
780
891
  // neither one is "scanned everything, found nothing wrong".
781
892
  if (scan.filesScanned === 0) {
782
- console.error(
893
+ const reason =
783
894
  scan.parseFailures.length > 0
784
- ? `\nEvery file matched under "${scanDir}" failed to parse — nothing was actually scanned.`
785
- : `\nNo files matched under "${scanDir}" — nothing was scanned.`,
786
- );
787
- console.error("Refusing to report a pass for a scan that checked nothing.");
895
+ ? `every file matched under "${scanDir}" failed to parse — nothing was actually scanned`
896
+ : `no files matched under "${scanDir}" — nothing was scanned`;
897
+ if (!jsonMode) {
898
+ console.error(
899
+ scan.parseFailures.length > 0
900
+ ? `\nEvery file matched under "${scanDir}" failed to parse — nothing was actually scanned.`
901
+ : `\nNo files matched under "${scanDir}" — nothing was scanned.`,
902
+ );
903
+ console.error("Refusing to report a pass for a scan that checked nothing.");
904
+ }
905
+ emitJson(emptyReport(recordFile, scanDir, 2, reason));
788
906
  return 2;
789
907
  }
790
908
 
@@ -807,15 +925,25 @@ export function main(argv: string[]): number {
807
925
  // read as fully accounted-for, it does not hide anything that was
808
926
  // learned.
809
927
  if (scan.parseFailures.length > 0) {
810
- console.error(
811
- `\n${scan.parseFailures.length} of ${scan.parseFailures.length + scan.filesScanned} matched file(s) under "${scanDir}" could not be parsed and were never examined for copy.`,
928
+ if (!jsonMode) {
929
+ console.error(
930
+ `\n${scan.parseFailures.length} of ${scan.parseFailures.length + scan.filesScanned} matched file(s) under "${scanDir}" could not be parsed and were never examined for copy.`,
931
+ );
932
+ console.error("Refusing to report a pass for a scan whose coverage is incomplete.");
933
+ }
934
+ emitJson(
935
+ emptyReport(
936
+ recordFile,
937
+ scanDir,
938
+ 2,
939
+ `${scan.parseFailures.length} of ${scan.parseFailures.length + scan.filesScanned} matched file(s) could not be parsed and were never examined for copy`,
940
+ ),
812
941
  );
813
- console.error("Refusing to report a pass for a scan whose coverage is incomplete.");
814
942
  return 2;
815
943
  }
816
944
 
817
945
  const result = checkCopyTraceability(scan.candidates, scan.citations, read.record, scan.filesScanned, scan.unchecked);
818
- printGateReport(result);
946
+ if (!jsonMode) printGateReport(result);
819
947
 
820
948
  // `unchecked` wins over everything else in the exit-code decision — see
821
949
  // this file's own top doc comment for why it is a `2`, not a `1`: it
@@ -824,9 +952,34 @@ export function main(argv: string[]): number {
824
952
  // finding was still printed above, so nothing real is hidden — this
825
953
  // only refuses to let the run as a whole read as clean or as
826
954
  // fully-accounted-for.
827
- if (result.unchecked.length > 0) return 2;
828
-
829
- return result.findings.length > 0 ? 1 : 0;
955
+ //
956
+ // THIS is #753's own partial-evaluation case: `result.findings` can be
957
+ // (and in #753's own repro, was) non-empty at the exact same time as
958
+ // `result.unchecked` — a real 292-finding scan sitting right next to one
959
+ // unclassifiable construct. `verdict` is still, correctly,
960
+ // `"indeterminate"` (never `"clean"`, never silently `"findings"`) —
961
+ // but unlike a bare exit code, `--format json`'s report carries
962
+ // `findings` (all 292 of them) AND `unchecked` (the one construct) in
963
+ // the SAME object, so a consumer keying on `exitCode`/`verdict` alone
964
+ // still fails closed exactly as this package intends, while a consumer
965
+ // that reads further is never forced to throw away what was measured.
966
+ const exitCode: 0 | 1 | 2 = result.unchecked.length > 0 ? 2 : result.findings.length > 0 ? 1 : 0;
967
+ const verdict: CopyTraceabilityReport["verdict"] =
968
+ result.unchecked.length > 0 ? "indeterminate" : result.findings.length > 0 ? "findings" : "clean";
969
+ emitJson({
970
+ recordFile,
971
+ scanDir,
972
+ verdict,
973
+ exitCode,
974
+ filesScanned: scan.filesScanned,
975
+ candidatesScanned: result.candidatesScanned,
976
+ matched: result.matched,
977
+ findings: result.findings,
978
+ ignored: result.ignored,
979
+ unchecked: result.unchecked,
980
+ parseFailures: scan.parseFailures.map((p) => ({ file: p.file, detail: p.detail })),
981
+ });
982
+ return exitCode;
830
983
  }
831
984
 
832
985
  function run(): void {
package/src/index.ts CHANGED
@@ -54,6 +54,12 @@ export { readCopyRecord } from "./registry.js";
54
54
  export type { CopyRegistryReadIssue, CopyRegistryReadIssueReason, CopyRegistryReadResult } from "./registry.js";
55
55
 
56
56
  export { checkCopyRecord } from "./checker.js";
57
+ export { assessApprovedCopyCoverageRate } from "./approved-copy-coverage-rate.js";
58
+ export type {
59
+ ApprovedCopyCoverageRateAssessment,
60
+ ApprovedCopyCoverageRateFinding,
61
+ ApprovedCopyCoverageRateState,
62
+ } from "./approved-copy-coverage-rate.js";
57
63
  export type {
58
64
  CopyEntryCheckResult,
59
65
  CopyEntrySkip,
package/src/scan.ts CHANGED
@@ -1319,6 +1319,37 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
1319
1319
  if (j >= n) return backtrack(); // ran off the end before the head ever closed — not committed, silently not-JSX
1320
1320
 
1321
1321
  const cj = content[j] as string;
1322
+
1323
+ // Comment trivia BETWEEN attributes (`//` line comment or `/* */`
1324
+ // block comment) — legal TSX a formatter routinely produces
1325
+ // (issue #753). Checked before the self-closing `/>` test below,
1326
+ // since both start with `/`: `content[j + 1] === ">"` is the
1327
+ // self-close, anything else after a `/` here is a comment, never
1328
+ // a division operator — there is no expression context inside an
1329
+ // opening tag's attribute list for `/` to divide. Treated exactly
1330
+ // like the whitespace just skipped above: this function has not
1331
+ // committed to "this is real JSX" yet (no `>`/`/>` found), so
1332
+ // consuming a comment here is silent trivia-skipping, not a
1333
+ // reportable event — matching every other pre-commit case in
1334
+ // this loop (e.g. the TSX generic-arrow-function `,` below,
1335
+ // which also backtracks with no message). An unterminated `/*`
1336
+ // is the one exception: it means this position can never
1337
+ // resolve to a valid tag head, so this reports the SAME "not
1338
+ // JSX after all" backtrack as running off the end of the file,
1339
+ // above.
1340
+ if (cj === "/" && content[j + 1] === "/") {
1341
+ const nl = content.indexOf("\n", j);
1342
+ j = nl === -1 ? n : nl;
1343
+ continue;
1344
+ }
1345
+ if (cj === "/" && content[j + 1] === "*") {
1346
+ const close = content.indexOf("*/", j + 2);
1347
+ if (close === -1) return backtrack(); // unterminated block comment — not committed, silently not-JSX
1348
+ advanceLine(j, close + 2);
1349
+ j = close + 2;
1350
+ continue;
1351
+ }
1352
+
1322
1353
  if (cj === "/" && content[j + 1] === ">") {
1323
1354
  selfClosing = true;
1324
1355
  j += 2;