@gate-forge/pack-playwright 0.1.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 (140) hide show
  1. package/LICENSE +202 -0
  2. package/bin/gateforge-witness.js +40 -0
  3. package/dist/attestation/proxy.d.ts +40 -0
  4. package/dist/attestation/proxy.d.ts.map +1 -0
  5. package/dist/attestation/proxy.js +78 -0
  6. package/dist/attestation/proxy.js.map +1 -0
  7. package/dist/constants.d.ts +130 -0
  8. package/dist/constants.d.ts.map +1 -0
  9. package/dist/constants.js +138 -0
  10. package/dist/constants.js.map +1 -0
  11. package/dist/discovery/adapters.d.ts +167 -0
  12. package/dist/discovery/adapters.d.ts.map +1 -0
  13. package/dist/discovery/adapters.js +276 -0
  14. package/dist/discovery/adapters.js.map +1 -0
  15. package/dist/discovery/discover.d.ts +57 -0
  16. package/dist/discovery/discover.d.ts.map +1 -0
  17. package/dist/discovery/discover.js +544 -0
  18. package/dist/discovery/discover.js.map +1 -0
  19. package/dist/discovery/index.d.ts +15 -0
  20. package/dist/discovery/index.d.ts.map +1 -0
  21. package/dist/discovery/index.js +15 -0
  22. package/dist/discovery/index.js.map +1 -0
  23. package/dist/discovery/inference.d.ts +63 -0
  24. package/dist/discovery/inference.d.ts.map +1 -0
  25. package/dist/discovery/inference.js +173 -0
  26. package/dist/discovery/inference.js.map +1 -0
  27. package/dist/discovery/pytest-adapter.d.ts +205 -0
  28. package/dist/discovery/pytest-adapter.d.ts.map +1 -0
  29. package/dist/discovery/pytest-adapter.js +548 -0
  30. package/dist/discovery/pytest-adapter.js.map +1 -0
  31. package/dist/discovery/reconcile.d.ts +106 -0
  32. package/dist/discovery/reconcile.d.ts.map +1 -0
  33. package/dist/discovery/reconcile.js +268 -0
  34. package/dist/discovery/reconcile.js.map +1 -0
  35. package/dist/discovery/runner-env.d.ts +80 -0
  36. package/dist/discovery/runner-env.d.ts.map +1 -0
  37. package/dist/discovery/runner-env.js +139 -0
  38. package/dist/discovery/runner-env.js.map +1 -0
  39. package/dist/discovery/static-discovery.d.ts +121 -0
  40. package/dist/discovery/static-discovery.d.ts.map +1 -0
  41. package/dist/discovery/static-discovery.js +1028 -0
  42. package/dist/discovery/static-discovery.js.map +1 -0
  43. package/dist/discovery/supervised-run.d.ts +110 -0
  44. package/dist/discovery/supervised-run.d.ts.map +1 -0
  45. package/dist/discovery/supervised-run.js +328 -0
  46. package/dist/discovery/supervised-run.js.map +1 -0
  47. package/dist/discovery/trusted-config.d.ts +103 -0
  48. package/dist/discovery/trusted-config.d.ts.map +1 -0
  49. package/dist/discovery/trusted-config.js +117 -0
  50. package/dist/discovery/trusted-config.js.map +1 -0
  51. package/dist/fixture/evidence.d.ts +191 -0
  52. package/dist/fixture/evidence.d.ts.map +1 -0
  53. package/dist/fixture/evidence.js +366 -0
  54. package/dist/fixture/evidence.js.map +1 -0
  55. package/dist/fixture/fixture.d.ts +37 -0
  56. package/dist/fixture/fixture.d.ts.map +1 -0
  57. package/dist/fixture/fixture.js +38 -0
  58. package/dist/fixture/fixture.js.map +1 -0
  59. package/dist/fixture/witness-client.d.ts +164 -0
  60. package/dist/fixture/witness-client.d.ts.map +1 -0
  61. package/dist/fixture/witness-client.js +253 -0
  62. package/dist/fixture/witness-client.js.map +1 -0
  63. package/dist/index.d.ts +64 -0
  64. package/dist/index.d.ts.map +1 -0
  65. package/dist/index.js +55 -0
  66. package/dist/index.js.map +1 -0
  67. package/dist/json.d.ts +16 -0
  68. package/dist/json.d.ts.map +1 -0
  69. package/dist/json.js +21 -0
  70. package/dist/json.js.map +1 -0
  71. package/dist/reporter/ledger.d.ts +105 -0
  72. package/dist/reporter/ledger.d.ts.map +1 -0
  73. package/dist/reporter/ledger.js +197 -0
  74. package/dist/reporter/ledger.js.map +1 -0
  75. package/dist/reporter/reporter.cjs +105 -0
  76. package/dist/reporter/reporter.cjs.map +1 -0
  77. package/dist/reporter/reporter.d.cts +35 -0
  78. package/dist/reporter/reporter.d.cts.map +1 -0
  79. package/dist/reporter/reporter.d.ts +167 -0
  80. package/dist/reporter/reporter.d.ts.map +1 -0
  81. package/dist/reporter/reporter.js +673 -0
  82. package/dist/reporter/reporter.js.map +1 -0
  83. package/dist/setup.d.ts +49 -0
  84. package/dist/setup.d.ts.map +1 -0
  85. package/dist/setup.js +167 -0
  86. package/dist/setup.js.map +1 -0
  87. package/dist/supervisor/client.d.ts +73 -0
  88. package/dist/supervisor/client.d.ts.map +1 -0
  89. package/dist/supervisor/client.js +164 -0
  90. package/dist/supervisor/client.js.map +1 -0
  91. package/dist/supervisor/drain.d.ts +45 -0
  92. package/dist/supervisor/drain.d.ts.map +1 -0
  93. package/dist/supervisor/drain.js +235 -0
  94. package/dist/supervisor/drain.js.map +1 -0
  95. package/dist/supervisor/index.d.ts +16 -0
  96. package/dist/supervisor/index.d.ts.map +1 -0
  97. package/dist/supervisor/index.js +13 -0
  98. package/dist/supervisor/index.js.map +1 -0
  99. package/dist/supervisor/spool.d.ts +137 -0
  100. package/dist/supervisor/spool.d.ts.map +1 -0
  101. package/dist/supervisor/spool.js +263 -0
  102. package/dist/supervisor/spool.js.map +1 -0
  103. package/dist/surface.d.ts +134 -0
  104. package/dist/surface.d.ts.map +1 -0
  105. package/dist/surface.js +139 -0
  106. package/dist/surface.js.map +1 -0
  107. package/dist/witness/adapter-registry.d.ts +38 -0
  108. package/dist/witness/adapter-registry.d.ts.map +1 -0
  109. package/dist/witness/adapter-registry.js +158 -0
  110. package/dist/witness/adapter-registry.js.map +1 -0
  111. package/dist/witness/bin.d.ts +21 -0
  112. package/dist/witness/bin.d.ts.map +1 -0
  113. package/dist/witness/bin.js +172 -0
  114. package/dist/witness/bin.js.map +1 -0
  115. package/dist/witness/browser.d.ts +173 -0
  116. package/dist/witness/browser.d.ts.map +1 -0
  117. package/dist/witness/browser.js +580 -0
  118. package/dist/witness/browser.js.map +1 -0
  119. package/dist/witness/classifications.d.ts +42 -0
  120. package/dist/witness/classifications.d.ts.map +1 -0
  121. package/dist/witness/classifications.js +87 -0
  122. package/dist/witness/classifications.js.map +1 -0
  123. package/dist/witness/env-attestation.d.ts +98 -0
  124. package/dist/witness/env-attestation.d.ts.map +1 -0
  125. package/dist/witness/env-attestation.js +202 -0
  126. package/dist/witness/env-attestation.js.map +1 -0
  127. package/dist/witness/loopback-pins.d.ts +79 -0
  128. package/dist/witness/loopback-pins.d.ts.map +1 -0
  129. package/dist/witness/loopback-pins.js +244 -0
  130. package/dist/witness/loopback-pins.js.map +1 -0
  131. package/dist/witness/server.d.ts +34 -0
  132. package/dist/witness/server.d.ts.map +1 -0
  133. package/dist/witness/server.js +2539 -0
  134. package/dist/witness/server.js.map +1 -0
  135. package/dist/witness/types.d.ts +643 -0
  136. package/dist/witness/types.d.ts.map +1 -0
  137. package/dist/witness/types.js +2 -0
  138. package/dist/witness/types.js.map +1 -0
  139. package/package.json +64 -0
  140. package/python/gateforge_persistence_intents.py +100 -0
@@ -0,0 +1,673 @@
1
+ /**
2
+ * The gateforge Playwright reporter (plan §4.6/§4.7, GF-23/GF-24;
3
+ * Phase 4 adds claim injection, runner-outcome capture, and the honest
4
+ * aggregate).
5
+ *
6
+ * Per test it extracts claims from `{type: 'gateforge', description:
7
+ * '<obligation id>'}` annotations AND from the CLI-written claim
8
+ * injections (`claim-injections.json` — the resolved sidecar/native
9
+ * mappings, Phase 4): injections are carried on the session-open path so
10
+ * a mapped test's evidence lands on the right claims; native annotations
11
+ * keep working unchanged. At run end it writes the run-state artifacts
12
+ * the CLI's verifier consumes:
13
+ *
14
+ * - `claims.json` — Claim-shaped entries (schemaVersion, obligationId,
15
+ * testId, testFile, location) extracted from annotations + injections.
16
+ * - `records.json` — the WITNESS-ISSUED ledger verbatim: the witness is
17
+ * the only issuer of recordIds, and the reporter copies `GET
18
+ * /records` — it never reconstructs records from test-side data, so a
19
+ * fabricated bundle never enters this file (GF-23).
20
+ * - `ledger.json` — per-claim verdicts for downstream tools.
21
+ * - runner-outcomes doc (`GATEFORGE_OUTCOMES_FILE`) — per-instance
22
+ * outcomes, attempts, expected failures, and the run-level
23
+ * fixture/teardown outcome for trusted runner supervision (ADR 0005
24
+ * D2: input only, never signature authority).
25
+ *
26
+ * It then computes a per-claim ledger with the REAL verdict engine
27
+ * (`evaluateObligation`, G3) — never trusting the test — and prints a
28
+ * per-claim summary. The summary is HONEST BY CONSTRUCTION (plan Phase 4
29
+ * item 7): it NEVER prints an overall gate pass while unclaimed
30
+ * obligations remain — the authoritative CLI result is the only final
31
+ * gate result. Exit-code semantics: the AUTHORITATIVE code comes from
32
+ * `gateforge test-gates` (contract 4); the reporter only writes
33
+ * `process.exitCode = 1` when a blocking verdict exists AND
34
+ * `GATEFORGE_REPORTER_FAIL_RUN=1` is explicitly set (best-effort in
35
+ * standalone runs, where Playwright's own exit handling may clobber it).
36
+ *
37
+ * Claim-registry vs records mismatch (GF-24): obligations in the run
38
+ * document with no claim, and records with no matching claim, are
39
+ * printed as ledger notes — the CLI grades the former `missing`, so
40
+ * bypassing the fixture can never read satisfied.
41
+ *
42
+ * Phase 1 — session identity is supervisor-issued: evidence primitives
43
+ * resolve their session by the exact (workerIndex, testId) pair, and the
44
+ * witness mints sessions ONLY for the supervisor channel. Since the
45
+ * enforcement review (fix 3), the runner child holds NO supervisor
46
+ * rights: `onTestBegin`/`onTestEnd` write lifecycle events (testBegin/
47
+ * testEnd with the observed outcome) to the run-state SPOOL
48
+ * (`<stateDir>/spool/<runId>/events.jsonl`, NUL-safe JSON lines) and the
49
+ * TRUSTED CLI drains the spool, performing the witness's session
50
+ * open/close with supervisor credentials that exist only in the CLI
51
+ * process. Session RPC failures are therefore impossible here by
52
+ * construction; a missing drain leaves no sessions open, and the
53
+ * witness rejects every submission fail-closed.
54
+ */
55
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
56
+ import { join, relative, sep } from 'node:path';
57
+ import { canonicalOf } from '../json.js';
58
+ import { CLAIM_ANNOTATION_TYPE, CLAIM_INJECTIONS_FILE, ENV_OBLIGATIONS, ENV_OUTCOMES_FILE, ENV_REPORTER_FAIL_RUN, ENV_RUN_ID, ENV_STATE_DIR, ENV_WITNESS_URL, } from '../constants.js';
59
+ import { appendSpoolEvent, spoolPathFor } from '../supervisor/spool.js';
60
+ import { WitnessClient } from '../fixture/witness-client.js';
61
+ import { resolveWitnessUrl } from '../fixture/witness-client.js';
62
+ import { claimOf, isBlocking, ledgerRowFor, parseObligationsDocument, } from './ledger.js';
63
+ /**
64
+ * Computes the honest aggregate line (plan Phase 4 item 7, §2 row): the
65
+ * reporter NEVER prints an overall gate pass based only on claimed rows
66
+ * while unclaimed obligations block. Pure and deterministic — exported
67
+ * so the honesty contract is directly testable.
68
+ *
69
+ * Args:
70
+ * rows: the per-claim ledger rows.
71
+ * unclaimedObligations: count of run obligations with no claim row.
72
+ *
73
+ * Returns:
74
+ * string: the aggregate summary line.
75
+ */
76
+ export function gateSummaryLine(rows, unclaimedObligations) {
77
+ const blocking = rows.filter((row) => isBlocking(row.verdict));
78
+ const authority = 'final gate result: the gateforge CLI (test-gates/check), never this reporter';
79
+ if (blocking.length > 0) {
80
+ return `GATEFORGE GATE: FAIL (${String(blocking.length)}/${String(rows.length)} claimed obligations not satisfied; ${authority})`;
81
+ }
82
+ if (unclaimedObligations > 0) {
83
+ return (`GATEFORGE GATE: NOT PASSED (${String(rows.length)} claimed obligation(s) satisfied, ` +
84
+ `${String(unclaimedObligations)} unclaimed obligation(s) still block; ${authority})`);
85
+ }
86
+ if (rows.length === 0) {
87
+ return `GATEFORGE GATE: NO CLAIMS (no gateforge claims found in this run; ${authority})`;
88
+ }
89
+ return `GATEFORGE GATE: PASS (${String(rows.length)}/${String(rows.length)} claimed obligations satisfied; ${authority})`;
90
+ }
91
+ /**
92
+ * The gateforge reporter. No options today; the constructor signature is
93
+ * the Playwright reporter contract (`(options: object)`).
94
+ */
95
+ export class GateforgeReporter {
96
+ rows = [];
97
+ /**
98
+ * Phase 4 claim injections (reconciliation key → obligation ids),
99
+ * loaded once from the CLI-written run-state document. Declarations
100
+ * only — they never satisfy anything by themselves.
101
+ */
102
+ injections;
103
+ /** Phase 4 runner-outcome capture (supervision input, ADR 0005 D2). */
104
+ runnerOutcomes = [];
105
+ runStatus = null;
106
+ runnerErrors = [];
107
+ /** The lifecycle spool file (null when the run has no state dir). */
108
+ spoolFile;
109
+ /** Resolved run-state paths (options win, env is the legacy fallback). */
110
+ resolved;
111
+ constructor(options = {}) {
112
+ const stateDir = options.stateDir ?? process.env[ENV_STATE_DIR];
113
+ const runId = options.runId ?? process.env[ENV_RUN_ID];
114
+ const outcomesPath = options.outcomesPath ?? process.env[ENV_OUTCOMES_FILE];
115
+ const obligationsPath = options.obligationsPath ?? process.env[ENV_OBLIGATIONS];
116
+ this.resolved = {
117
+ stateDir: stateDir !== undefined && stateDir !== '' ? stateDir : null,
118
+ runId: runId !== undefined && runId !== '' ? runId : null,
119
+ outcomesPath: outcomesPath !== undefined && outcomesPath !== '' ? outcomesPath : null,
120
+ obligationsPath: obligationsPath !== undefined && obligationsPath !== '' ? obligationsPath : null,
121
+ };
122
+ const wired = (process.env[ENV_WITNESS_URL] ?? '') !== '' ||
123
+ (this.resolved.stateDir ?? '') !== '';
124
+ if (!wired) {
125
+ console.warn(`[gateforge] no witness/state wiring (${ENV_WITNESS_URL} or ${ENV_STATE_DIR}); ` +
126
+ 'claims/records will not be written and evidence primitives fail closed');
127
+ }
128
+ this.injections = readClaimInjections(this.resolved.stateDir ?? undefined);
129
+ // The lifecycle spool (enforcement-review fix 3): the trusted CLI
130
+ // drains these events and drives the witness supervisor channel.
131
+ // Without a state dir or run id there is nothing to drain — the
132
+ // supervisor then never opens sessions and submissions fail closed.
133
+ this.spoolFile =
134
+ this.resolved.stateDir !== null && this.resolved.runId !== null
135
+ ? spoolPathFor(this.resolved.stateDir, this.resolved.runId)
136
+ : null;
137
+ }
138
+ /**
139
+ * Lifecycle spool write (enforcement-review fix 3): announces the
140
+ * STARTED test to the trusted CLI's drain — which opens the witness
141
+ * session on the supervisor channel, carrying the mapped obligation
142
+ * claims for sidecar/native-mapped tests (Phase 4 claim injection) —
143
+ * and binds worker → session there. The runner itself performs no
144
+ * privileged call. Synchronous API: the CLI's poll cadence absorbs
145
+ * the dispatch latency (the fixture's session resolve waits up to 5s).
146
+ */
147
+ onTestBegin(test, result) {
148
+ if (this.spoolFile === null)
149
+ return;
150
+ const workerIndex = typeof result.workerIndex === 'number' ? result.workerIndex : 0;
151
+ const claims = [...new Set([...this.annotationClaimsOf(test), ...this.injectedClaimsFor(test)])].sort();
152
+ appendSpoolEvent(this.spoolFile, {
153
+ kind: 'testBegin',
154
+ testId: test.id,
155
+ workerIndex,
156
+ file: this.repoRelativeOf(test),
157
+ titlePath: this.titlePathOf(test),
158
+ project: this.projectOf(test),
159
+ ...(claims.length > 0 ? { claims } : {}),
160
+ });
161
+ }
162
+ /** Runner-level error hook: a global setup/teardown/runner error. */
163
+ onError(error) {
164
+ this.runnerErrors.push(typeof error?.message === 'string' ? error.message : String(error));
165
+ }
166
+ /** Collects claims + test identity + the outcome row at test end (synchronous). */
167
+ onTestEnd(test, result) {
168
+ // Phase 4: capture the outcome row for trusted runner supervision
169
+ // (every test, claimed or not — the expected set includes them all).
170
+ const titlePath = this.titlePathOf(test);
171
+ const file = this.repoRelativeOf(test);
172
+ this.runnerOutcomes.push({
173
+ testId: test.id,
174
+ ...(file !== null ? { file } : { file: '' }),
175
+ titlePath,
176
+ project: this.projectOf(test),
177
+ status: result.status,
178
+ attempt: (typeof result.retry === 'number' ? result.retry : 0) + 1,
179
+ expectedFailure: (test.annotations ?? []).some((annotation) => annotation.type === 'fail'),
180
+ });
181
+ // Lifecycle spool write (fix 3): the CLI drains this and seals the
182
+ // session with the OBSERVED outcome on the supervisor channel.
183
+ // Sealing is final, so records cannot be injected after the test.
184
+ if (this.spoolFile !== null) {
185
+ appendSpoolEvent(this.spoolFile, {
186
+ kind: 'testEnd',
187
+ testId: test.id,
188
+ workerIndex: typeof result.workerIndex === 'number' ? result.workerIndex : 0,
189
+ file,
190
+ titlePath,
191
+ project: this.projectOf(test),
192
+ outcome: result.status,
193
+ attempt: (typeof result.retry === 'number' ? result.retry : 0) + 1,
194
+ });
195
+ }
196
+ // Claims: native annotations keep working unchanged; Phase 4 adds
197
+ // the injected claims (reconciled by file + title path) so a mapped
198
+ // test's evidence lands on the right claims. Both merge into one row
199
+ // (deduplicated, sorted) with the runner's own testId.
200
+ const claims = [...new Set([...this.annotationClaimsOf(test), ...this.injectedClaimsFor(test)])].sort();
201
+ if (claims.length === 0)
202
+ return;
203
+ this.rows.push({
204
+ testId: test.id,
205
+ testFile: file ?? '',
206
+ location: test.location === undefined || test.location === null
207
+ ? null
208
+ : { file: test.location.file, line: test.location.line, col: test.location.column },
209
+ claims,
210
+ status: result.status,
211
+ });
212
+ }
213
+ /** Playswright `onEnd`: seal the run-level outcome + write artifacts. */
214
+ async onEnd(result) {
215
+ this.runStatus = typeof result?.status === 'string' ? result.status : null;
216
+ // (Session settlement moved to the trusted CLI's spool drain —
217
+ // enforcement-review fix 3; nothing awaits here.)
218
+ this.writeRunnerOutcomes();
219
+ const stateDir = this.resolved.stateDir;
220
+ if (stateDir === null) {
221
+ if (this.rows.length > 0) {
222
+ console.warn('[gateforge] claims collected but no state dir: artifacts not written');
223
+ }
224
+ return;
225
+ }
226
+ // claims.json — the claim registry (GF-24's left side). Empty when
227
+ // the suite bypassed the fixture (GF-24) — the CLI still grades the
228
+ // run's obligations `missing`.
229
+ const claims = this.rows.flatMap((row) => row.claims.map((obligationId) => ({
230
+ schemaVersion: 1,
231
+ obligationId,
232
+ testId: row.testId,
233
+ ...(row.testFile === '' ? {} : { testFile: row.testFile }),
234
+ ...(row.location === null ? {} : { location: row.location }),
235
+ })));
236
+ writeJson(stateDir, 'claims.json', claims);
237
+ // records.json — ONLY the witness-issued ledger (GF-23 enforcement).
238
+ let records = [];
239
+ try {
240
+ const client = new WitnessClient(resolveWitnessUrl());
241
+ const ledger = (await client.listRecords());
242
+ records = Array.isArray(ledger.records) ? ledger.records : [];
243
+ }
244
+ catch (error) {
245
+ console.warn(`[gateforge] cannot fetch the witness ledger: ${error.message}`);
246
+ }
247
+ writeJson(stateDir, 'records.json', records);
248
+ const obligationsRaw = readObligationsRaw(this.resolved.obligationsPath);
249
+ const obligations = obligationsRaw === null ? null : parseObligationsDocument(obligationsRaw);
250
+ const classifications = await this.fetchClassifications();
251
+ const now = this.runInstant(stateDir);
252
+ // Advisory route inventory (plan §9): the CLI-derived
253
+ // `http-routes.json` when present. Absent → null, and the core
254
+ // resolver returns its blocking missing-context result for HTTP
255
+ // rows. Never authoritative: the CLI recomputes from source.
256
+ const httpRoutes = readHttpRoutes(stateDir);
257
+ const ledger = this.ledgerRows(obligations, classifications, records, now, httpRoutes);
258
+ writeJson(stateDir, 'ledger.json', ledger);
259
+ const unclaimed = obligations === null
260
+ ? 0
261
+ : new Set(obligations.obligations
262
+ .filter((entry) => !this.rows.some((row) => row.claims.includes(entry.id)))
263
+ .map((entry) => entry.id)).size;
264
+ this.printLedger(ledger, unclaimed);
265
+ this.printRegistryMismatches(obligations, records);
266
+ const blocking = ledger.some((row) => isBlocking(row.verdict));
267
+ if (blocking && process.env[ENV_REPORTER_FAIL_RUN] === '1') {
268
+ // Playwright overrides `process.exitCode` after reporters run, so
269
+ // setting it here does not fail the run (1.58.2 observed; the
270
+ // spike documented the same). GATEFORGE_REPORTER_FAIL_RUN=1 is an
271
+ // explicit opt-in to hard-exit: the gate ledger above is already
272
+ // printed, so exiting here loses nothing but the runner summary.
273
+ process.exit(1);
274
+ }
275
+ }
276
+ /** Full title path of a test (runner API when present, else [title]). */
277
+ /**
278
+ * The catalog-identity title path: describe titles + the test title —
279
+ * NOT the runner's full path. Playwright's suite hierarchy is
280
+ * root → project → file → describe; identity joins (supervision,
281
+ * claim injections) use the SAME shape the native `--list` enumeration
282
+ * reports (describes + title), so the root/project/file suites are
283
+ * stripped here — by suite `type` when exposed, else by the location
284
+ * heuristic (root/project suites carry no source location; the file
285
+ * suite is the outermost suite that does). A project name or file name
286
+ * must never leak into the identity. Without a parent chain, the bare
287
+ * title keeps the row visible (never misjoined).
288
+ */
289
+ titlePathOf(test) {
290
+ const title = test.title ?? test.id;
291
+ const describes = [];
292
+ let suite = test.parent ?? null;
293
+ while (suite !== null) {
294
+ if (this.isDescribeBoundary(suite))
295
+ break;
296
+ if (typeof suite.title === 'string' && suite.title.length > 0)
297
+ describes.unshift(suite.title);
298
+ suite = suite.parent ?? null;
299
+ }
300
+ return [...describes, title];
301
+ }
302
+ /**
303
+ * The runner project name for the supervision identity: the
304
+ * `project`-typed ancestor's title (reporter-API `TestCase` carries no
305
+ * `project` member), else the first location-less ancestor, else the
306
+ * runner-provided `project.name` best-effort. Absence stays `null` —
307
+ * never a fabricated project.
308
+ */
309
+ projectOf(test) {
310
+ let suite = test.parent ?? null;
311
+ while (suite !== null) {
312
+ if (suite.type !== undefined) {
313
+ if (suite.type === 'project') {
314
+ return typeof suite.title === 'string' && suite.title.length > 0 ? suite.title : null;
315
+ }
316
+ if (suite.type === 'root')
317
+ break;
318
+ }
319
+ else if (suite.location === undefined || suite.location === null) {
320
+ return typeof suite.title === 'string' && suite.title.length > 0 ? suite.title : null;
321
+ }
322
+ suite = suite.parent ?? null;
323
+ }
324
+ return typeof test.project?.name === 'string' && test.project.name.length > 0 ? test.project.name : null;
325
+ }
326
+ /**
327
+ * Whether this suite is at or above the describe level (root, project,
328
+ * or the file suite itself) — walking must stop here. By `type` when
329
+ * exposed; otherwise the file suite is the OUTERMOST suite with a
330
+ * source location (its parent is root/project or absent).
331
+ */
332
+ isDescribeBoundary(suite) {
333
+ if (suite.type !== undefined) {
334
+ return suite.type !== 'describe';
335
+ }
336
+ const locationless = suite.location === undefined || suite.location === null;
337
+ if (locationless)
338
+ return true;
339
+ const parent = suite.parent ?? null;
340
+ if (parent === null)
341
+ return true;
342
+ if (parent.type !== undefined)
343
+ return parent.type !== 'describe';
344
+ return parent.location === undefined || parent.location === null;
345
+ }
346
+ /** Repo-relative posix file of a test (null when unknown). */
347
+ repoRelativeOf(test) {
348
+ if (test.location?.file === undefined || test.location.file === null)
349
+ return null;
350
+ const raw = test.location.file;
351
+ if (raw.length === 0)
352
+ return null;
353
+ return relative(process.cwd(), raw).split(sep).join('/');
354
+ }
355
+ /** Native annotation claims of one test (`{type: 'gateforge'}`). */
356
+ annotationClaimsOf(test) {
357
+ return (test.annotations ?? [])
358
+ .filter((annotation) => annotation.type === CLAIM_ANNOTATION_TYPE)
359
+ .map((annotation) => annotation.description ?? '')
360
+ .filter((description) => description.length > 0);
361
+ }
362
+ /** Injection-key lookup for one test: `<file>#<titlePath.join('>')>`. */
363
+ injectedClaimsFor(test) {
364
+ const file = this.repoRelativeOf(test);
365
+ if (file === null || this.injections.size === 0)
366
+ return [];
367
+ const key = `${file}#${this.titlePathOf(test).join('>')}`;
368
+ return this.injections.get(key) ?? [];
369
+ }
370
+ /** Writes the runner-outcomes document when the supervisor asked for it. */
371
+ writeRunnerOutcomes() {
372
+ const outcomesPath = this.resolved.outcomesPath;
373
+ if (outcomesPath === null)
374
+ return;
375
+ const shardRaw = process.env['TEST_SHARD'] ?? '';
376
+ const shardMatch = /^(\d+)\/(\d+)$/.exec(shardRaw);
377
+ const document = {
378
+ schemaVersion: 1,
379
+ runStatus: this.runStatus,
380
+ runnerErrors: [...this.runnerErrors],
381
+ outcomes: [...this.runnerOutcomes].sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0) ||
382
+ (a.titlePath.join('>') < b.titlePath.join('>') ? -1 : 1) ||
383
+ (a.testId < b.testId ? -1 : 1)),
384
+ shard: shardMatch !== null
385
+ ? { index: Number(shardMatch[1]), total: Number(shardMatch[2]) }
386
+ : null,
387
+ };
388
+ try {
389
+ mkdirSync(join(outcomesPath, '..'), { recursive: true });
390
+ writeFileSync(outcomesPath, `${canonicalOf(document)}\n`, 'utf8');
391
+ }
392
+ catch (error) {
393
+ // The outcomes document is supervision input; failing to write it
394
+ // must not crash the run — supervision then sees NO outcomes
395
+ // document and fails closed on its own.
396
+ console.warn(`[gateforge] cannot write runner outcomes: ${error.message}`);
397
+ }
398
+ }
399
+ /** The witness classification projection (real lifecycle + primaryKey). */
400
+ async fetchClassifications() {
401
+ try {
402
+ const client = new WitnessClient(resolveWitnessUrl());
403
+ const response = (await authenticatedFetch(client.url, '/classifications', client.token));
404
+ if (typeof response !== 'object' || response === null)
405
+ return {};
406
+ const resources = response['resources'];
407
+ if (typeof resources !== 'object' || resources === null)
408
+ return {};
409
+ const out = {};
410
+ for (const [resourceId, view] of Object.entries(resources)) {
411
+ const entry = view;
412
+ const lifecycle = (entry['lifecycle'] ?? {});
413
+ out[resourceId] = {
414
+ primaryKey: Array.isArray(entry['primaryKey'])
415
+ ? entry['primaryKey'].filter((key) => typeof key === 'string')
416
+ : ['id'],
417
+ exposure: typeof entry['exposure'] === 'string' ? entry['exposure'] : 'user-facing',
418
+ plane: typeof entry['plane'] === 'string' ? entry['plane'] : 'tenant',
419
+ ...(typeof entry['evidenceAdapter'] === 'string'
420
+ ? { evidenceAdapter: entry['evidenceAdapter'] }
421
+ : {}),
422
+ // The claims lane (http.endpoint resources) MUST reach the
423
+ // engine: without it a user-facing adapter-free entry fails the
424
+ // engine's classification validation and grades unclassified.
425
+ ...(entry['evidenceLane'] === 'adapter' || entry['evidenceLane'] === 'claims'
426
+ ? { evidenceLane: entry['evidenceLane'] }
427
+ : {}),
428
+ lifecycle: {
429
+ create: lifecycle['create'] === true,
430
+ read: lifecycle['read'] === true,
431
+ update: lifecycle['update'] === true,
432
+ delete: lifecycle['delete'] === true,
433
+ ...(lifecycle['deleteSemantics'] === 'hard' || lifecycle['deleteSemantics'] === 'archive'
434
+ ? { deleteSemantics: lifecycle['deleteSemantics'] }
435
+ : {}),
436
+ // Owner-owned archived state must reach the engine: it grades
437
+ // archive postconditions against it (audit round 5).
438
+ ...(typeof lifecycle['archiveFields'] === 'object' &&
439
+ lifecycle['archiveFields'] !== null &&
440
+ !Array.isArray(lifecycle['archiveFields'])
441
+ ? {
442
+ archiveFields: lifecycle['archiveFields'],
443
+ }
444
+ : {}),
445
+ ...(Array.isArray(lifecycle['updateableFields'])
446
+ ? {
447
+ updateableFields: lifecycle['updateableFields'].filter((field) => typeof field === 'string'),
448
+ }
449
+ : {}),
450
+ },
451
+ };
452
+ }
453
+ return out;
454
+ }
455
+ catch {
456
+ return {};
457
+ }
458
+ }
459
+ ledgerRows(obligations, classifications, records, now, httpRoutes) {
460
+ const classMap = {};
461
+ for (const [resourceId, view] of Object.entries(classifications)) {
462
+ const primaryKey = view.primaryKey.length > 0 ? view.primaryKey : ['id'];
463
+ classMap[resourceId] = {
464
+ exposure: view.exposure === 'internal' ? 'internal' : 'user-facing',
465
+ plane: view.plane === 'master' ? 'master' : view.plane === 'global' ? 'global' : 'tenant',
466
+ ...(view.evidenceAdapter === undefined ? {} : { evidenceAdapter: view.evidenceAdapter }),
467
+ ...(view.evidenceLane === undefined ? {} : { evidenceLane: view.evidenceLane }),
468
+ lifecycle: view.lifecycle,
469
+ primaryKey,
470
+ };
471
+ }
472
+ const rows = [];
473
+ for (const row of this.rows) {
474
+ for (const obligationId of row.claims) {
475
+ rows.push(ledgerRowFor(claimOf(obligationId, {
476
+ id: row.testId,
477
+ location: row.location === null
478
+ ? null
479
+ : {
480
+ file: row.location.file,
481
+ line: row.location.line,
482
+ column: row.location.col,
483
+ },
484
+ }), obligations, classMap, records, now, httpRoutes));
485
+ }
486
+ }
487
+ return rows.sort((a, b) => a.claim === b.claim ? (a.testId < b.testId ? -1 : 1) : a.claim < b.claim ? -1 : 1);
488
+ }
489
+ /** Deterministic instant: the run manifest's injected `startedAt`. */
490
+ runInstant(stateDir) {
491
+ try {
492
+ const manifest = JSON.parse(readFileSync(join(stateDir, 'manifest.json'), 'utf8'));
493
+ if (typeof manifest.startedAt === 'string')
494
+ return manifest.startedAt;
495
+ }
496
+ catch {
497
+ // fall through
498
+ }
499
+ return new Date().toISOString();
500
+ }
501
+ /**
502
+ * Prints the per-claim verdict ledger + the HONEST aggregate line
503
+ * (plan Phase 4 item 7): never an overall pass while unclaimed
504
+ * obligations block; the authoritative CLI remains the only final
505
+ * gate result.
506
+ */
507
+ printLedger(rows, unclaimedObligations) {
508
+ const width = Math.max('CLAIMED OBLIGATION'.length, ...rows.map((row) => row.claim.length));
509
+ console.log('\n=== GATEFORGE VERDICTS ===');
510
+ if (rows.length === 0) {
511
+ console.log('no gateforge claims found in this run');
512
+ }
513
+ for (const row of rows) {
514
+ console.log(`${String(row.verdict).toUpperCase().padEnd(10)} ${row.claim.padEnd(width)} ${row.testFile}`);
515
+ if (row.reason !== null) {
516
+ console.log(` - ${row.reason}`);
517
+ }
518
+ if (row.recordIds.length > 0) {
519
+ console.log(` - records: ${row.recordIds.join(', ')}`);
520
+ }
521
+ }
522
+ console.log(gateSummaryLine(rows, unclaimedObligations));
523
+ console.log('==========================\n');
524
+ }
525
+ /** GF-24 observability: obligations nobody claimed + records nobody claimed. */
526
+ printRegistryMismatches(obligations, records) {
527
+ const claimed = new Set(this.rows.flatMap((row) => row.claims));
528
+ if (obligations !== null) {
529
+ const unclaimed = obligations.obligations
530
+ .filter((entry) => !claimed.has(entry.id))
531
+ .map((entry) => entry.id)
532
+ .sort();
533
+ if (unclaimed.length > 0) {
534
+ console.warn(`[gateforge] obligations without any claim (will grade missing): ${unclaimed.join(', ')}`);
535
+ }
536
+ }
537
+ const claimedPairs = new Set(this.rows.flatMap((row) => row.claims.map((claim) => `${claim}\u0000${row.testId}`)));
538
+ const orphans = records
539
+ .filter((record) => !claimedPairs.has(`${record.obligationId}\u0000${record.testId}`))
540
+ .map((record) => `${record.obligationId} (${record.testId})`)
541
+ .sort();
542
+ if (orphans.length > 0) {
543
+ console.warn(`[gateforge] witness records with no matching claim (claim-registry mismatch, GF-24): ${orphans.join(', ')}`);
544
+ }
545
+ }
546
+ }
547
+ // Playwright custom reporters MUST be the module's default export.
548
+ export default GateforgeReporter;
549
+ /**
550
+ * Reads the CLI-derived advisory route inventory (`http-routes.json`,
551
+ * written by `test-gates` beside the obligations document). Returns
552
+ * null when absent or malformed — the core resolver then returns its
553
+ * blocking missing-context result for HTTP rows. Advisory only: the
554
+ * authoritative CLI recomputes this list from source.
555
+ *
556
+ * Args:
557
+ * stateDir: absolute run-state directory.
558
+ *
559
+ * Returns:
560
+ * readonly HttpRouteCandidate[] | null: the advisory inventory or null.
561
+ */
562
+ function readHttpRoutes(stateDir) {
563
+ let raw;
564
+ try {
565
+ raw = readFileSync(join(stateDir, 'http-routes.json'), 'utf8');
566
+ }
567
+ catch {
568
+ return null;
569
+ }
570
+ let document;
571
+ try {
572
+ document = JSON.parse(raw);
573
+ }
574
+ catch {
575
+ return null;
576
+ }
577
+ if (typeof document !== 'object' || document === null || Array.isArray(document))
578
+ return null;
579
+ const routes = document['routes'];
580
+ if (!Array.isArray(routes))
581
+ return null;
582
+ const candidates = [];
583
+ for (const entry of routes) {
584
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry))
585
+ return null;
586
+ const candidate = entry;
587
+ if (typeof candidate['resourceId'] !== 'string' ||
588
+ typeof candidate['method'] !== 'string' ||
589
+ typeof candidate['canonicalPath'] !== 'string') {
590
+ return null;
591
+ }
592
+ candidates.push({
593
+ resourceId: candidate['resourceId'],
594
+ method: candidate['method'],
595
+ canonicalPath: candidate['canonicalPath'],
596
+ });
597
+ }
598
+ return candidates;
599
+ }
600
+ /** Reads the obligations document path from options/env (null when unset/broken). */
601
+ function readObligationsRaw(obligationsPath) {
602
+ const path = obligationsPath ?? process.env[ENV_OBLIGATIONS] ?? null;
603
+ if (path === null || path === '')
604
+ return null;
605
+ try {
606
+ return readFileSync(path, 'utf8');
607
+ }
608
+ catch {
609
+ return null;
610
+ }
611
+ }
612
+ /**
613
+ * Loads the CLI-written claim-injections document (Phase 4): a
614
+ * reconciliation key → obligation ids map for sidecar/native-mapped
615
+ * tests. Malformed documents contribute NOTHING (never a partial map) —
616
+ * injected claims are declarations, and a broken declaration must not
617
+ * silently redirect evidence.
618
+ *
619
+ * Args:
620
+ * stateDir: absolute run-state directory, when wired.
621
+ *
622
+ * Returns:
623
+ * Map<string, string[]>: reconciliation key → sorted obligation ids.
624
+ */
625
+ function readClaimInjections(stateDir) {
626
+ const out = new Map();
627
+ if (stateDir === undefined || stateDir === '')
628
+ return out;
629
+ let raw;
630
+ try {
631
+ raw = readFileSync(join(stateDir, CLAIM_INJECTIONS_FILE), 'utf8');
632
+ }
633
+ catch {
634
+ return out; // absent: annotation-only run (the common case)
635
+ }
636
+ let document;
637
+ try {
638
+ document = JSON.parse(raw);
639
+ }
640
+ catch {
641
+ return out;
642
+ }
643
+ if (typeof document !== 'object' || document === null || Array.isArray(document))
644
+ return out;
645
+ const injections = document['injections'];
646
+ if (typeof injections !== 'object' || injections === null || Array.isArray(injections))
647
+ return out;
648
+ for (const [key, value] of Object.entries(injections)) {
649
+ if (!Array.isArray(value))
650
+ continue;
651
+ const claims = value.filter((claim) => typeof claim === 'string' && claim.length > 0);
652
+ if (claims.length === 0)
653
+ continue;
654
+ out.set(key, [...new Set(claims)].sort());
655
+ }
656
+ return out;
657
+ }
658
+ /** Writes one GF-canonical JSON state artifact. */
659
+ function writeJson(stateDir, name, value) {
660
+ mkdirSync(stateDir, { recursive: true });
661
+ writeFileSync(join(stateDir, name), `${canonicalOf(value)}\n`, 'utf8');
662
+ }
663
+ /** Authenticated GET helper for reporter-side witness calls. */
664
+ async function authenticatedFetch(url, path, token) {
665
+ const response = await fetch(`${url}${path}`, {
666
+ headers: { 'x-gateforge-run': token, accept: 'application/json' },
667
+ });
668
+ if (!response.ok) {
669
+ throw new Error(`witness ${path} answered HTTP ${response.status}`);
670
+ }
671
+ return response.json();
672
+ }
673
+ //# sourceMappingURL=reporter.js.map