my-frontend-observer 0.3.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 (77) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +59 -7
  3. package/dist/application/comparisonService.d.ts +56 -0
  4. package/dist/application/comparisonService.js +77 -0
  5. package/dist/application/comparisonService.js.map +1 -0
  6. package/dist/application/frontendContractEvaluationService.d.ts +49 -0
  7. package/dist/application/frontendContractEvaluationService.js +112 -0
  8. package/dist/application/frontendContractEvaluationService.js.map +1 -0
  9. package/dist/application/frontendContractPersistenceService.d.ts +56 -0
  10. package/dist/application/frontendContractPersistenceService.js +91 -0
  11. package/dist/application/frontendContractPersistenceService.js.map +1 -0
  12. package/dist/artifacts/artifactReader.d.ts +19 -0
  13. package/dist/artifacts/artifactReader.js +36 -0
  14. package/dist/artifacts/artifactReader.js.map +1 -0
  15. package/dist/artifacts/comparisonArtifactReader.d.ts +18 -0
  16. package/dist/artifacts/comparisonArtifactReader.js +35 -0
  17. package/dist/artifacts/comparisonArtifactReader.js.map +1 -0
  18. package/dist/artifacts/comparisonArtifactWriter.d.ts +41 -0
  19. package/dist/artifacts/comparisonArtifactWriter.js +67 -0
  20. package/dist/artifacts/comparisonArtifactWriter.js.map +1 -0
  21. package/dist/artifacts/frontendContractArtifactReader.d.ts +24 -0
  22. package/dist/artifacts/frontendContractArtifactReader.js +47 -0
  23. package/dist/artifacts/frontendContractArtifactReader.js.map +1 -0
  24. package/dist/artifacts/frontendContractArtifactWriter.d.ts +34 -0
  25. package/dist/artifacts/frontendContractArtifactWriter.js +70 -0
  26. package/dist/artifacts/frontendContractArtifactWriter.js.map +1 -0
  27. package/dist/artifacts/frontendContractEvaluationArtifactReader.d.ts +17 -0
  28. package/dist/artifacts/frontendContractEvaluationArtifactReader.js +34 -0
  29. package/dist/artifacts/frontendContractEvaluationArtifactReader.js.map +1 -0
  30. package/dist/artifacts/frontendContractEvaluationArtifactWriter.d.ts +32 -0
  31. package/dist/artifacts/frontendContractEvaluationArtifactWriter.js +58 -0
  32. package/dist/artifacts/frontendContractEvaluationArtifactWriter.js.map +1 -0
  33. package/dist/cli.js +712 -3
  34. package/dist/cli.js.map +1 -1
  35. package/dist/domain/comparison.d.ts +198 -0
  36. package/dist/domain/comparison.js +324 -0
  37. package/dist/domain/comparison.js.map +1 -0
  38. package/dist/domain/comparisonEngine.d.ts +48 -0
  39. package/dist/domain/comparisonEngine.js +694 -0
  40. package/dist/domain/comparisonEngine.js.map +1 -0
  41. package/dist/domain/comparisonIdentity.d.ts +13 -0
  42. package/dist/domain/comparisonIdentity.js +46 -0
  43. package/dist/domain/comparisonIdentity.js.map +1 -0
  44. package/dist/domain/evidence.d.ts +2 -0
  45. package/dist/domain/evidence.js +4 -0
  46. package/dist/domain/evidence.js.map +1 -1
  47. package/dist/domain/frontendContractEvaluation.d.ts +57 -0
  48. package/dist/domain/frontendContractEvaluation.js +454 -0
  49. package/dist/domain/frontendContractEvaluation.js.map +1 -0
  50. package/dist/domain/frontendContractEvaluationArtifact.d.ts +65 -0
  51. package/dist/domain/frontendContractEvaluationArtifact.js +108 -0
  52. package/dist/domain/frontendContractEvaluationArtifact.js.map +1 -0
  53. package/dist/domain/frontendContractIdentity.d.ts +39 -0
  54. package/dist/domain/frontendContractIdentity.js +70 -0
  55. package/dist/domain/frontendContractIdentity.js.map +1 -0
  56. package/dist/domain/frontendContracts.d.ts +188 -0
  57. package/dist/domain/frontendContracts.js +260 -0
  58. package/dist/domain/frontendContracts.js.map +1 -0
  59. package/dist/domain/relationships.d.ts +168 -0
  60. package/dist/domain/relationships.js +343 -0
  61. package/dist/domain/relationships.js.map +1 -0
  62. package/dist/index.d.ts +36 -1
  63. package/dist/index.js +20 -1
  64. package/dist/index.js.map +1 -1
  65. package/docs/ARCHITECTURE.md +173 -3
  66. package/docs/CI_CD.md +76 -0
  67. package/docs/COMMANDS.md +299 -0
  68. package/docs/CONTRACTS.md +270 -6
  69. package/docs/CURRENT_STATE.md +183 -8
  70. package/docs/DEVELOPMENT.md +83 -12
  71. package/docs/PROJECT_OVERVIEW.md +18 -8
  72. package/docs/QUICKSTART.md +10 -0
  73. package/docs/RELEASE.md +17 -8
  74. package/docs/ROADMAP.md +10 -0
  75. package/docs/SECURITY.md +26 -1
  76. package/docs/WORKFLOWS.md +113 -6
  77. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -4,6 +4,9 @@ import { fileURLToPath } from 'node:url';
4
4
  import { normalizeRequest } from './request/request.js';
5
5
  import { getProducerInfo } from './domain/schema.js';
6
6
  import { observe } from './application/observationPersistence.js';
7
+ import { compareAndPersistFromArtifactRoots } from './application/comparisonService.js';
8
+ import { approveAndPersistBaseline, persistPerChangeContract } from './application/frontendContractPersistenceService.js';
9
+ import { evaluateAndPersistFromArtifactRoots } from './application/frontendContractEvaluationService.js';
7
10
  const defaultIO = {
8
11
  stdout: (text) => {
9
12
  process.stdout.write(text);
@@ -18,14 +21,25 @@ Usage:
18
21
  my-frontend-observer <command> [options]
19
22
 
20
23
  Commands:
21
- observe Capture one bounded browser observation and persist it as a
22
- portable artifact.
24
+ observe Capture one bounded browser observation and persist
25
+ it as a portable artifact.
26
+ compare Compare two persisted observations and write a
27
+ structured comparison artifact.
28
+ approve-baseline Explicitly approve and persist one already-authored
29
+ persistent baseline contract against the observation
30
+ it claims to approve.
31
+ save-change-contract Validate and persist one already-authored per-change
32
+ contract so it can later be evaluated.
33
+ evaluate-contract Evaluate a candidate change against an approved
34
+ baseline, a per-change contract, and existing
35
+ before/after/comparison evidence, and persist the
36
+ result.
23
37
 
24
38
  Options:
25
39
  --help Show this help.
26
40
  --version Print the package version.
27
41
 
28
- Run "my-frontend-observer observe --help" for observe command options.
42
+ Run "my-frontend-observer <command> --help" for command-specific options.
29
43
  `;
30
44
  const OBSERVE_HELP = `Usage:
31
45
  my-frontend-observer observe --url <loopback-url> [options]
@@ -73,6 +87,143 @@ request, unsafe/failed navigation, or a failed artifact write, prints
73
87
  structured diagnostics to stderr and exits nonzero. No progress output is
74
88
  printed during a normal capture.
75
89
  `;
90
+ const COMPARE_HELP = `Usage:
91
+ my-frontend-observer compare --before <observation-artifact-root> --after <observation-artifact-root> --output <directory> [options]
92
+
93
+ Required:
94
+ --before <path> Root directory of the "before" persisted
95
+ observation artifact (the directory containing
96
+ its manifest.json).
97
+ --after <path> Root directory of the "after" persisted
98
+ observation artifact.
99
+ --output <directory> Portable, relative output location for the
100
+ comparison artifact.
101
+
102
+ Options:
103
+ --config-file <json-file> Loads a comparison configuration from a local
104
+ JSON file: { "geometryTolerancePx": <0-10>,
105
+ "expectedDependencies": [ { "cause": { "target":
106
+ "...", "property": "x"|"y"|"width"|"height",
107
+ "direction": "increase"|"decrease"|"change"|
108
+ "unchanged" }, "effect": { ... same shape ... },
109
+ "source": "explicit-config" } ] }. Relative
110
+ paths resolve from the current working
111
+ directory; the file path itself is never
112
+ persisted into the artifact or included in the
113
+ comparison request identity. Without
114
+ --config-file, geometryTolerancePx defaults to
115
+ 0.5 with no declared dependencies.
116
+ --help Show this help.
117
+
118
+ Comparison reads two already-persisted observation artifacts and derives
119
+ evidence purely from their existing content - it never launches a browser
120
+ and never re-observes either target. On success, prints a concise result
121
+ and exits 0, including when the two observations are found to be
122
+ "incomparable" (that is itself a successful comparison outcome, not a
123
+ failure). On invalid syntax, an unreadable or structurally invalid source
124
+ artifact, invalid configuration, or a failed artifact write, prints
125
+ structured diagnostics to stderr and exits nonzero. No progress output is
126
+ printed during a normal comparison.
127
+ `;
128
+ const APPROVE_BASELINE_HELP = `Usage:
129
+ my-frontend-observer approve-baseline --observation <observation-artifact-root> --contract-file <json-file> --output <directory>
130
+
131
+ Required:
132
+ --observation <path> Root directory of the persisted observation
133
+ artifact (the directory containing its
134
+ manifest.json) that this baseline claims to
135
+ approve.
136
+ --contract-file <json-file> Local JSON file containing one already-authored
137
+ persistent baseline contract (the raw contract
138
+ value, no wrapper field). Relative paths resolve
139
+ from the current working directory; the file
140
+ path itself is never persisted or included in
141
+ any identity.
142
+ --output <directory> Portable, relative output location for the
143
+ baseline artifact.
144
+
145
+ Options:
146
+ --help Show this help.
147
+
148
+ This command is the only explicit baseline-approval act in the observer -
149
+ approval is never inferred from a successful comparison or evaluation. The
150
+ supplied contract's source-observation reference must match the supplied
151
+ observation artifact's stable identity; a mismatched or unrelated observation
152
+ is rejected. Any \`supersedesBaselineId\` already authored in the contract is
153
+ preserved exactly - this command never discovers, infers, or deletes a prior
154
+ baseline. Remains local and non-mutating: it never launches a browser and
155
+ never modifies the source observation or any existing baseline artifact. On
156
+ success, prints a concise result and exits 0. On invalid syntax, an
157
+ unreadable/malformed contract file, a non-baseline contract, a
158
+ structurally invalid contract, a source-observation mismatch, an existing
159
+ artifact collision, or a persistence failure, prints structured diagnostics
160
+ to stderr and exits nonzero.
161
+ `;
162
+ const SAVE_CHANGE_CONTRACT_HELP = `Usage:
163
+ my-frontend-observer save-change-contract --contract-file <json-file> --output <directory>
164
+
165
+ Required:
166
+ --contract-file <json-file> Local JSON file containing one already-authored
167
+ per-change contract (the raw contract value, no
168
+ wrapper field). Relative paths resolve from the
169
+ current working directory; the file path itself
170
+ is never persisted or included in any identity.
171
+ --output <directory> Portable, relative output location for the
172
+ change-contract artifact.
173
+
174
+ Options:
175
+ --help Show this help.
176
+
177
+ This command validates and persists a per-change contract only - it does not
178
+ approve anything. Any \`supersedesBaselineClauseIds\` already authored on a
179
+ clause is preserved exactly; resolving those references against a particular
180
+ baseline remains \`evaluate-contract\`'s responsibility, not this command's.
181
+ Remains local and non-mutating. On success, prints a concise result and
182
+ exits 0. On invalid syntax, an unreadable/malformed contract file, a
183
+ non-change contract (e.g. a persistent baseline contract), a structurally
184
+ invalid contract (including an authored \`unexpected\` category, which is
185
+ never a valid authored scope), an existing artifact collision, or a
186
+ persistence failure, prints structured diagnostics to stderr and exits
187
+ nonzero.
188
+ `;
189
+ const EVALUATE_CONTRACT_HELP = `Usage:
190
+ my-frontend-observer evaluate-contract --before <observation-artifact-root> --after <observation-artifact-root> --comparison <comparison-artifact-root> --baseline <baseline-contract-artifact-root> --change <per-change-contract-artifact-root> --output <directory> [--enforce]
191
+
192
+ Required:
193
+ --before <path> Root directory of the "before" persisted observation
194
+ artifact.
195
+ --after <path> Root directory of the "after" persisted observation
196
+ artifact.
197
+ --comparison <path> Root directory of the already-persisted comparison
198
+ artifact for that before/after pair.
199
+ --baseline <path> Root directory of the already-approved persistent
200
+ baseline contract artifact.
201
+ --change <path> Root directory of the already-persisted per-change
202
+ contract artifact.
203
+ --output <directory> Portable, relative output location for the
204
+ evaluation artifact.
205
+
206
+ Options:
207
+ --enforce Make a FAIL verdict produce a nonzero process exit status. A
208
+ FAIL evaluation is always persisted and printed identically
209
+ with or without this flag - it changes only the process exit
210
+ code, never evaluation identity, contents, or persistence.
211
+ --help Show this help.
212
+
213
+ This command never launches a browser, never re-resolves targets, and never
214
+ recomputes comparison or relationship evidence - it reads the already-
215
+ persisted before/after observations and comparison exactly as given and
216
+ evaluates the supplied baseline/change contracts against them exactly once.
217
+ A FAIL verdict (a found regression or unsatisfied contract clause) is a
218
+ successful, persisted evaluation outcome, not an execution error; without
219
+ --enforce it exits 0 like PASS. On success (evaluation constructed and
220
+ persisted, verdict PASS, or verdict FAIL without --enforce), prints a
221
+ concise result and exits 0. With --enforce and verdict FAIL, prints the same
222
+ result and exits nonzero. On invalid syntax, an unreadable/malformed/
223
+ incoherent source artifact, or a persistence failure (evaluation could not
224
+ even be constructed), prints structured diagnostics to stderr, persists
225
+ nothing, and exits nonzero.
226
+ `;
76
227
  function parseViewport(raw) {
77
228
  const match = /^(\d+)x(\d+)$/.exec(raw);
78
229
  if (!match)
@@ -269,6 +420,384 @@ function loadScrollScenarioFile(filePath) {
269
420
  }
270
421
  return { ok: true, scenario: parsed };
271
422
  }
423
+ /** CLI-syntax-only parsing, mirroring `parseObserveArgs`: shape/presence/duplication errors only. Comparison-config semantics stay owned by the existing domain validator. */
424
+ function parseCompareArgs(argv) {
425
+ const errors = [];
426
+ let beforeRoot;
427
+ let beforeFlagCount = 0;
428
+ let afterRoot;
429
+ let afterFlagCount = 0;
430
+ let outputLocation;
431
+ let outputFlagCount = 0;
432
+ let configFilePath;
433
+ let configFileFlagCount = 0;
434
+ for (let i = 0; i < argv.length; i += 1) {
435
+ const arg = argv[i];
436
+ switch (arg) {
437
+ case '--before': {
438
+ const value = argv[(i += 1)];
439
+ beforeFlagCount += 1;
440
+ if (value === undefined) {
441
+ errors.push('--before requires a path argument');
442
+ }
443
+ else if (beforeFlagCount > 1) {
444
+ errors.push('--before may only be specified once');
445
+ }
446
+ else {
447
+ beforeRoot = value;
448
+ }
449
+ break;
450
+ }
451
+ case '--after': {
452
+ const value = argv[(i += 1)];
453
+ afterFlagCount += 1;
454
+ if (value === undefined) {
455
+ errors.push('--after requires a path argument');
456
+ }
457
+ else if (afterFlagCount > 1) {
458
+ errors.push('--after may only be specified once');
459
+ }
460
+ else {
461
+ afterRoot = value;
462
+ }
463
+ break;
464
+ }
465
+ case '--output': {
466
+ const value = argv[(i += 1)];
467
+ outputFlagCount += 1;
468
+ if (value === undefined) {
469
+ errors.push('--output requires a directory argument');
470
+ }
471
+ else if (outputFlagCount > 1) {
472
+ errors.push('--output may only be specified once');
473
+ }
474
+ else {
475
+ outputLocation = value;
476
+ }
477
+ break;
478
+ }
479
+ case '--config-file': {
480
+ const value = argv[(i += 1)];
481
+ configFileFlagCount += 1;
482
+ if (value === undefined) {
483
+ errors.push('--config-file requires a file path argument');
484
+ }
485
+ else if (configFileFlagCount > 1) {
486
+ errors.push('--config-file may only be specified once');
487
+ }
488
+ else {
489
+ configFilePath = value;
490
+ }
491
+ break;
492
+ }
493
+ default:
494
+ errors.push(`unrecognized argument: ${arg}`);
495
+ }
496
+ }
497
+ if (beforeRoot === undefined)
498
+ errors.push('--before is required');
499
+ if (afterRoot === undefined)
500
+ errors.push('--after is required');
501
+ if (outputLocation === undefined)
502
+ errors.push('--output is required');
503
+ if (errors.length > 0)
504
+ return { ok: false, errors };
505
+ return {
506
+ ok: true,
507
+ beforeRoot: beforeRoot,
508
+ afterRoot: afterRoot,
509
+ outputLocation: outputLocation,
510
+ ...(configFilePath === undefined ? {} : { configFilePath }),
511
+ };
512
+ }
513
+ /**
514
+ * CLI/input-boundary-only responsibility, mirroring `loadScrollScenarioFile`:
515
+ * read one local JSON file and validate only the root shape this file format
516
+ * owns (plain, non-array object) - the file supplies the value of
517
+ * `ComparisonConfig` directly (no wrapper field). Every semantic rule
518
+ * (geometry tolerance bounds, dependency property/direction vocabulary,
519
+ * dependency source/provenance) stays owned by the existing comparison
520
+ * domain validator (`isValidComparisonConfig`, invoked inside
521
+ * `compareObservations`), not duplicated here. The file path itself is
522
+ * never returned to the caller beyond this function, so it can never reach
523
+ * the persisted comparison artifact or its request identity.
524
+ */
525
+ function loadComparisonConfigFile(filePath) {
526
+ let rawText;
527
+ try {
528
+ rawText = readFileSync(filePath, 'utf8');
529
+ }
530
+ catch (err) {
531
+ const message = err instanceof Error ? err.message : String(err);
532
+ return { ok: false, error: `--config-file could not be read: ${message}` };
533
+ }
534
+ let parsed;
535
+ try {
536
+ parsed = JSON.parse(rawText);
537
+ }
538
+ catch (err) {
539
+ const message = err instanceof Error ? err.message : String(err);
540
+ return { ok: false, error: `--config-file is not valid JSON: ${message}` };
541
+ }
542
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
543
+ return { ok: false, error: '--config-file root must be a JSON object' };
544
+ }
545
+ return { ok: true, config: parsed };
546
+ }
547
+ /**
548
+ * CLI/input-boundary-only responsibility, mirroring `loadComparisonConfigFile`:
549
+ * read one local JSON file and validate only the root shape this file format
550
+ * owns (plain, non-array object) - the file supplies the raw contract value
551
+ * directly (no wrapper field). Every semantic/structural rule (artifact
552
+ * kind, schema version, contract class, clause shape, authored category
553
+ * vocabulary) stays owned by the existing frozen domain validators
554
+ * (`isValidPersistentBaselineContract`/`isValidPerChangeContract`, invoked
555
+ * inside the application layer), never duplicated here. The file path
556
+ * itself is never returned to the caller beyond this function, so it can
557
+ * never reach a persisted artifact or its identity.
558
+ */
559
+ function loadContractFile(filePath, flagLabel) {
560
+ let rawText;
561
+ try {
562
+ rawText = readFileSync(filePath, 'utf8');
563
+ }
564
+ catch (err) {
565
+ const message = err instanceof Error ? err.message : String(err);
566
+ return { ok: false, error: `${flagLabel} could not be read: ${message}` };
567
+ }
568
+ let parsed;
569
+ try {
570
+ parsed = JSON.parse(rawText);
571
+ }
572
+ catch (err) {
573
+ const message = err instanceof Error ? err.message : String(err);
574
+ return { ok: false, error: `${flagLabel} is not valid JSON: ${message}` };
575
+ }
576
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
577
+ return { ok: false, error: `${flagLabel} root must be a JSON object` };
578
+ }
579
+ return { ok: true, contract: parsed };
580
+ }
581
+ /** CLI-syntax-only parsing, mirroring `parseCompareArgs`. */
582
+ function parseApproveBaselineArgs(argv) {
583
+ const errors = [];
584
+ let observationRoot;
585
+ let observationFlagCount = 0;
586
+ let contractFilePath;
587
+ let contractFileFlagCount = 0;
588
+ let outputLocation;
589
+ let outputFlagCount = 0;
590
+ for (let i = 0; i < argv.length; i += 1) {
591
+ const arg = argv[i];
592
+ switch (arg) {
593
+ case '--observation': {
594
+ const value = argv[(i += 1)];
595
+ observationFlagCount += 1;
596
+ if (value === undefined)
597
+ errors.push('--observation requires a path argument');
598
+ else if (observationFlagCount > 1)
599
+ errors.push('--observation may only be specified once');
600
+ else
601
+ observationRoot = value;
602
+ break;
603
+ }
604
+ case '--contract-file': {
605
+ const value = argv[(i += 1)];
606
+ contractFileFlagCount += 1;
607
+ if (value === undefined)
608
+ errors.push('--contract-file requires a file path argument');
609
+ else if (contractFileFlagCount > 1)
610
+ errors.push('--contract-file may only be specified once');
611
+ else
612
+ contractFilePath = value;
613
+ break;
614
+ }
615
+ case '--output': {
616
+ const value = argv[(i += 1)];
617
+ outputFlagCount += 1;
618
+ if (value === undefined)
619
+ errors.push('--output requires a directory argument');
620
+ else if (outputFlagCount > 1)
621
+ errors.push('--output may only be specified once');
622
+ else
623
+ outputLocation = value;
624
+ break;
625
+ }
626
+ default:
627
+ errors.push(`unrecognized argument: ${arg}`);
628
+ }
629
+ }
630
+ if (observationRoot === undefined)
631
+ errors.push('--observation is required');
632
+ if (contractFilePath === undefined)
633
+ errors.push('--contract-file is required');
634
+ if (outputLocation === undefined)
635
+ errors.push('--output is required');
636
+ if (errors.length > 0)
637
+ return { ok: false, errors };
638
+ return { ok: true, observationRoot: observationRoot, contractFilePath: contractFilePath, outputLocation: outputLocation };
639
+ }
640
+ /** CLI-syntax-only parsing, mirroring `parseCompareArgs`. */
641
+ function parseSaveChangeContractArgs(argv) {
642
+ const errors = [];
643
+ let contractFilePath;
644
+ let contractFileFlagCount = 0;
645
+ let outputLocation;
646
+ let outputFlagCount = 0;
647
+ for (let i = 0; i < argv.length; i += 1) {
648
+ const arg = argv[i];
649
+ switch (arg) {
650
+ case '--contract-file': {
651
+ const value = argv[(i += 1)];
652
+ contractFileFlagCount += 1;
653
+ if (value === undefined)
654
+ errors.push('--contract-file requires a file path argument');
655
+ else if (contractFileFlagCount > 1)
656
+ errors.push('--contract-file may only be specified once');
657
+ else
658
+ contractFilePath = value;
659
+ break;
660
+ }
661
+ case '--output': {
662
+ const value = argv[(i += 1)];
663
+ outputFlagCount += 1;
664
+ if (value === undefined)
665
+ errors.push('--output requires a directory argument');
666
+ else if (outputFlagCount > 1)
667
+ errors.push('--output may only be specified once');
668
+ else
669
+ outputLocation = value;
670
+ break;
671
+ }
672
+ default:
673
+ errors.push(`unrecognized argument: ${arg}`);
674
+ }
675
+ }
676
+ if (contractFilePath === undefined)
677
+ errors.push('--contract-file is required');
678
+ if (outputLocation === undefined)
679
+ errors.push('--output is required');
680
+ if (errors.length > 0)
681
+ return { ok: false, errors };
682
+ return { ok: true, contractFilePath: contractFilePath, outputLocation: outputLocation };
683
+ }
684
+ /** CLI-syntax-only parsing. `--enforce` is a boolean switch (no value); repeating it is harmless (idempotent), matching a boolean flag's natural semantics rather than the "may only be specified once" policy used for single-value flags. */
685
+ function parseEvaluateContractArgs(argv) {
686
+ const errors = [];
687
+ let beforeRoot;
688
+ let beforeFlagCount = 0;
689
+ let afterRoot;
690
+ let afterFlagCount = 0;
691
+ let comparisonRoot;
692
+ let comparisonFlagCount = 0;
693
+ let baselineRoot;
694
+ let baselineFlagCount = 0;
695
+ let changeRoot;
696
+ let changeFlagCount = 0;
697
+ let outputLocation;
698
+ let outputFlagCount = 0;
699
+ let enforce = false;
700
+ for (let i = 0; i < argv.length; i += 1) {
701
+ const arg = argv[i];
702
+ switch (arg) {
703
+ case '--before': {
704
+ const value = argv[(i += 1)];
705
+ beforeFlagCount += 1;
706
+ if (value === undefined)
707
+ errors.push('--before requires a path argument');
708
+ else if (beforeFlagCount > 1)
709
+ errors.push('--before may only be specified once');
710
+ else
711
+ beforeRoot = value;
712
+ break;
713
+ }
714
+ case '--after': {
715
+ const value = argv[(i += 1)];
716
+ afterFlagCount += 1;
717
+ if (value === undefined)
718
+ errors.push('--after requires a path argument');
719
+ else if (afterFlagCount > 1)
720
+ errors.push('--after may only be specified once');
721
+ else
722
+ afterRoot = value;
723
+ break;
724
+ }
725
+ case '--comparison': {
726
+ const value = argv[(i += 1)];
727
+ comparisonFlagCount += 1;
728
+ if (value === undefined)
729
+ errors.push('--comparison requires a path argument');
730
+ else if (comparisonFlagCount > 1)
731
+ errors.push('--comparison may only be specified once');
732
+ else
733
+ comparisonRoot = value;
734
+ break;
735
+ }
736
+ case '--baseline': {
737
+ const value = argv[(i += 1)];
738
+ baselineFlagCount += 1;
739
+ if (value === undefined)
740
+ errors.push('--baseline requires a path argument');
741
+ else if (baselineFlagCount > 1)
742
+ errors.push('--baseline may only be specified once');
743
+ else
744
+ baselineRoot = value;
745
+ break;
746
+ }
747
+ case '--change': {
748
+ const value = argv[(i += 1)];
749
+ changeFlagCount += 1;
750
+ if (value === undefined)
751
+ errors.push('--change requires a path argument');
752
+ else if (changeFlagCount > 1)
753
+ errors.push('--change may only be specified once');
754
+ else
755
+ changeRoot = value;
756
+ break;
757
+ }
758
+ case '--output': {
759
+ const value = argv[(i += 1)];
760
+ outputFlagCount += 1;
761
+ if (value === undefined)
762
+ errors.push('--output requires a directory argument');
763
+ else if (outputFlagCount > 1)
764
+ errors.push('--output may only be specified once');
765
+ else
766
+ outputLocation = value;
767
+ break;
768
+ }
769
+ case '--enforce':
770
+ enforce = true;
771
+ break;
772
+ default:
773
+ errors.push(`unrecognized argument: ${arg}`);
774
+ }
775
+ }
776
+ if (beforeRoot === undefined)
777
+ errors.push('--before is required');
778
+ if (afterRoot === undefined)
779
+ errors.push('--after is required');
780
+ if (comparisonRoot === undefined)
781
+ errors.push('--comparison is required');
782
+ if (baselineRoot === undefined)
783
+ errors.push('--baseline is required');
784
+ if (changeRoot === undefined)
785
+ errors.push('--change is required');
786
+ if (outputLocation === undefined)
787
+ errors.push('--output is required');
788
+ if (errors.length > 0)
789
+ return { ok: false, errors };
790
+ return {
791
+ ok: true,
792
+ beforeRoot: beforeRoot,
793
+ afterRoot: afterRoot,
794
+ comparisonRoot: comparisonRoot,
795
+ baselineRoot: baselineRoot,
796
+ changeRoot: changeRoot,
797
+ outputLocation: outputLocation,
798
+ enforce,
799
+ };
800
+ }
272
801
  function formatDiagnostic(diagnostic) {
273
802
  const target = diagnostic.targetName === undefined ? '' : ` (target: ${diagnostic.targetName})`;
274
803
  return `[${diagnostic.code}] ${diagnostic.message}${target}`;
@@ -326,6 +855,174 @@ async function runObserveCommand(argv, io) {
326
855
  io.stdout(`Diagnostics: ${result.diagnostics.length}\n`);
327
856
  return NON_SUCCESS_COMPLETION_STATES.has(result.completion.state) ? 1 : 0;
328
857
  }
858
+ /**
859
+ * Thin orchestration only: parse args, optionally load a config file, then
860
+ * delegate to the existing `compareAndPersistFromArtifactRoots` application
861
+ * function exactly once. No comparability/geometry/relationship/dependency
862
+ * logic lives here - see `src/domain/comparisonEngine.ts`. `incomparable` is
863
+ * a successful comparison outcome (the operation determined the two
864
+ * observations should not be treated as equivalent frontend states), so it
865
+ * exits 0 exactly like `comparable`/`comparable-with-warnings`; only a
866
+ * genuine parse/read/domain/persistence failure exits nonzero.
867
+ */
868
+ async function runCompareCommand(argv, io) {
869
+ if (argv.includes('--help')) {
870
+ io.stdout(COMPARE_HELP);
871
+ return 0;
872
+ }
873
+ const parsedArgs = parseCompareArgs(argv);
874
+ if (!parsedArgs.ok) {
875
+ for (const error of parsedArgs.errors)
876
+ io.stderr(`error: ${error}\n`);
877
+ io.stderr(COMPARE_HELP);
878
+ return 1;
879
+ }
880
+ let config;
881
+ if (parsedArgs.configFilePath !== undefined) {
882
+ const loaded = loadComparisonConfigFile(parsedArgs.configFilePath);
883
+ if (!loaded.ok) {
884
+ io.stderr(`error: ${loaded.error}\n`);
885
+ io.stderr(COMPARE_HELP);
886
+ return 1;
887
+ }
888
+ config = loaded.config;
889
+ }
890
+ // Exactly one application comparison attempt: two artifact reads, one pure comparison, persisted at most once.
891
+ const result = await compareAndPersistFromArtifactRoots(parsedArgs.beforeRoot, parsedArgs.afterRoot, {
892
+ ...(config === undefined ? {} : { config }),
893
+ outputLocation: parsedArgs.outputLocation,
894
+ });
895
+ if (!result.ok) {
896
+ for (const diagnostic of result.diagnostics)
897
+ io.stderr(`${formatDiagnostic(diagnostic)}\n`);
898
+ return 1;
899
+ }
900
+ io.stdout(`Comparison: ${result.comparisonId}\n`);
901
+ io.stdout(`State: ${result.comparability}\n`);
902
+ io.stdout(`Artifact: ${result.artifactRoot}\n`);
903
+ io.stdout(`Differences: ${result.differenceCount}\n`);
904
+ io.stdout(`Relationship changes: ${result.relationshipChangeCount}\n`);
905
+ io.stdout(`Diagnostics: ${result.diagnosticsCount}\n`);
906
+ return 0;
907
+ }
908
+ /**
909
+ * Thin orchestration only: parse args, load the raw contract JSON file, then
910
+ * delegate to the existing `approveAndPersistBaseline` application function
911
+ * exactly once. No contract/coherence validation lives here - see
912
+ * `src/application/frontendContractPersistenceService.ts`. This is the only
913
+ * command in the observer that approves a baseline.
914
+ */
915
+ async function runApproveBaselineCommand(argv, io) {
916
+ if (argv.includes('--help')) {
917
+ io.stdout(APPROVE_BASELINE_HELP);
918
+ return 0;
919
+ }
920
+ const parsedArgs = parseApproveBaselineArgs(argv);
921
+ if (!parsedArgs.ok) {
922
+ for (const error of parsedArgs.errors)
923
+ io.stderr(`error: ${error}\n`);
924
+ io.stderr(APPROVE_BASELINE_HELP);
925
+ return 1;
926
+ }
927
+ const loaded = loadContractFile(parsedArgs.contractFilePath, '--contract-file');
928
+ if (!loaded.ok) {
929
+ io.stderr(`error: ${loaded.error}\n`);
930
+ io.stderr(APPROVE_BASELINE_HELP);
931
+ return 1;
932
+ }
933
+ // Exactly one application approval attempt: one observation read, one coherence check, persisted at most once.
934
+ const result = await approveAndPersistBaseline(loaded.contract, parsedArgs.observationRoot, { outputLocation: parsedArgs.outputLocation });
935
+ if (!result.ok) {
936
+ for (const diagnostic of result.diagnostics)
937
+ io.stderr(`${formatDiagnostic(diagnostic)}\n`);
938
+ return 1;
939
+ }
940
+ io.stdout(`Baseline: ${result.baselineId}\n`);
941
+ io.stdout(`State: approved\n`);
942
+ io.stdout(`Artifact: ${result.artifactRoot}\n`);
943
+ io.stdout(`Clauses: ${result.clauseCount}\n`);
944
+ io.stdout(`Supersedes: ${result.supersedesBaselineId ?? 'none'}\n`);
945
+ return 0;
946
+ }
947
+ /**
948
+ * Thin orchestration only: parse args, load the raw contract JSON file, then
949
+ * delegate to the existing `persistPerChangeContract` application function
950
+ * exactly once. Persistence, not approval.
951
+ */
952
+ async function runSaveChangeContractCommand(argv, io) {
953
+ if (argv.includes('--help')) {
954
+ io.stdout(SAVE_CHANGE_CONTRACT_HELP);
955
+ return 0;
956
+ }
957
+ const parsedArgs = parseSaveChangeContractArgs(argv);
958
+ if (!parsedArgs.ok) {
959
+ for (const error of parsedArgs.errors)
960
+ io.stderr(`error: ${error}\n`);
961
+ io.stderr(SAVE_CHANGE_CONTRACT_HELP);
962
+ return 1;
963
+ }
964
+ const loaded = loadContractFile(parsedArgs.contractFilePath, '--contract-file');
965
+ if (!loaded.ok) {
966
+ io.stderr(`error: ${loaded.error}\n`);
967
+ io.stderr(SAVE_CHANGE_CONTRACT_HELP);
968
+ return 1;
969
+ }
970
+ // Exactly one application persistence attempt.
971
+ const result = await persistPerChangeContract(loaded.contract, { outputLocation: parsedArgs.outputLocation });
972
+ if (!result.ok) {
973
+ for (const diagnostic of result.diagnostics)
974
+ io.stderr(`${formatDiagnostic(diagnostic)}\n`);
975
+ return 1;
976
+ }
977
+ io.stdout(`Change contract: ${result.contractId}\n`);
978
+ io.stdout(`Artifact: ${result.artifactRoot}\n`);
979
+ io.stdout(`Clauses: ${result.clauseCount}\n`);
980
+ io.stdout(`Supersedes baseline clauses: ${result.supersedesBaselineClauseCount}\n`);
981
+ return 0;
982
+ }
983
+ /**
984
+ * Thin orchestration only: parse args, then delegate to the existing
985
+ * `evaluateAndPersistFromArtifactRoots` application function exactly once.
986
+ * No evaluation/tolerance/conflict/unexpected-classification logic lives
987
+ * here - see `src/domain/frontendContractEvaluation.ts`. `--enforce` is
988
+ * applied only after the evaluation has already been constructed and
989
+ * persisted: it selects the process exit status for an already-final FAIL
990
+ * result and never affects evaluation identity, contents, or persistence. A
991
+ * FAIL verdict is a successful, persisted evaluation outcome (a found
992
+ * regression), never treated as a construction/persistence failure.
993
+ */
994
+ async function runEvaluateContractCommand(argv, io) {
995
+ if (argv.includes('--help')) {
996
+ io.stdout(EVALUATE_CONTRACT_HELP);
997
+ return 0;
998
+ }
999
+ const parsedArgs = parseEvaluateContractArgs(argv);
1000
+ if (!parsedArgs.ok) {
1001
+ for (const error of parsedArgs.errors)
1002
+ io.stderr(`error: ${error}\n`);
1003
+ io.stderr(EVALUATE_CONTRACT_HELP);
1004
+ return 1;
1005
+ }
1006
+ // Exactly one application evaluation attempt: reads before/after/comparison/baseline/change once each,
1007
+ // calls the canonical evaluator exactly once, and persists exactly one evaluation artifact.
1008
+ const result = await evaluateAndPersistFromArtifactRoots(parsedArgs.beforeRoot, parsedArgs.afterRoot, parsedArgs.comparisonRoot, parsedArgs.baselineRoot, parsedArgs.changeRoot, {
1009
+ outputLocation: parsedArgs.outputLocation,
1010
+ });
1011
+ if (!result.ok) {
1012
+ for (const diagnostic of result.diagnostics)
1013
+ io.stderr(`${formatDiagnostic(diagnostic)}\n`);
1014
+ return 1;
1015
+ }
1016
+ io.stdout(`Evaluation: ${result.evaluationId}\n`);
1017
+ io.stdout(`Verdict: ${result.overallVerdict}\n`);
1018
+ io.stdout(`Artifact: ${result.artifactRoot}\n`);
1019
+ io.stdout(`Clauses: ${result.clauseResultCount}\n`);
1020
+ io.stdout(`Unexpected: ${result.unexpectedChangeCount}\n`);
1021
+ io.stdout(`Enforced: ${parsedArgs.enforce ? 'yes' : 'no'}\n`);
1022
+ if (parsedArgs.enforce && result.overallVerdict === 'FAIL')
1023
+ return 1;
1024
+ return 0;
1025
+ }
329
1026
  /** Testable CLI entry point: pure function of argv (+ injectable IO), no direct process.exit. */
330
1027
  export async function runCli(argv, io = defaultIO) {
331
1028
  const [command, ...rest] = argv;
@@ -344,6 +1041,18 @@ export async function runCli(argv, io = defaultIO) {
344
1041
  if (command === 'observe') {
345
1042
  return runObserveCommand(rest, io);
346
1043
  }
1044
+ if (command === 'compare') {
1045
+ return runCompareCommand(rest, io);
1046
+ }
1047
+ if (command === 'approve-baseline') {
1048
+ return runApproveBaselineCommand(rest, io);
1049
+ }
1050
+ if (command === 'save-change-contract') {
1051
+ return runSaveChangeContractCommand(rest, io);
1052
+ }
1053
+ if (command === 'evaluate-contract') {
1054
+ return runEvaluateContractCommand(rest, io);
1055
+ }
347
1056
  io.stderr(`error: unrecognized command "${command}"\n`);
348
1057
  io.stderr(TOP_LEVEL_HELP);
349
1058
  return 1;