@binclusive/a11y 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 (80) hide show
  1. package/README.md +285 -0
  2. package/bin/a11y.mjs +36 -0
  3. package/bin/diff-scope.mjs +29 -0
  4. package/data/baseline-rules.json +892 -0
  5. package/package.json +68 -0
  6. package/src/agent-lane.ts +138 -0
  7. package/src/agents-block.ts +157 -0
  8. package/src/baseline/gen-baseline.ts +166 -0
  9. package/src/cli.ts +1026 -0
  10. package/src/collect-dom.ts +119 -0
  11. package/src/collect-liquid.ts +103 -0
  12. package/src/collect-swift.ts +227 -0
  13. package/src/collect-unity.ts +99 -0
  14. package/src/collect.ts +54 -0
  15. package/src/commands.ts +314 -0
  16. package/src/config-scan.ts +177 -0
  17. package/src/contract.ts +355 -0
  18. package/src/core.ts +546 -0
  19. package/src/detect-stack.ts +207 -0
  20. package/src/diff-scope-cli.ts +12 -0
  21. package/src/diff-scope.ts +150 -0
  22. package/src/emit-contract.ts +181 -0
  23. package/src/enforce.ts +1125 -0
  24. package/src/eslint-plugin-jsx-a11y.d.ts +11 -0
  25. package/src/evidence.ts +308 -0
  26. package/src/github-identity.ts +201 -0
  27. package/src/hook.ts +242 -0
  28. package/src/impact-gate.ts +107 -0
  29. package/src/imports-resolve.ts +248 -0
  30. package/src/index.ts +183 -0
  31. package/src/liquid-ast.ts +203 -0
  32. package/src/liquid-rules.ts +691 -0
  33. package/src/mcp.ts +363 -0
  34. package/src/module-scope.ts +137 -0
  35. package/src/phone-home.ts +470 -0
  36. package/src/pr-comment.ts +250 -0
  37. package/src/pr-summary-cli.ts +182 -0
  38. package/src/pr-summary.ts +291 -0
  39. package/src/registry.ts +605 -0
  40. package/src/reporter/contract.ts +154 -0
  41. package/src/reporter/finding.ts +87 -0
  42. package/src/reporter/github-adapter.ts +183 -0
  43. package/src/reporter/null-adapter.ts +51 -0
  44. package/src/reporter/registry.ts +22 -0
  45. package/src/reporter-cli.ts +48 -0
  46. package/src/resolve-components.ts +579 -0
  47. package/src/runner/budget.ts +90 -0
  48. package/src/runner/codegraph-lookup.test.ts +93 -0
  49. package/src/runner/codegraph-lookup.ts +197 -0
  50. package/src/runner/index.ts +86 -0
  51. package/src/runner/lookup.ts +69 -0
  52. package/src/runner/provider.ts +72 -0
  53. package/src/runner/providers/anthropic.ts +168 -0
  54. package/src/runner/providers/openai.ts +191 -0
  55. package/src/runner/reasoner.ts +73 -0
  56. package/src/runner/reasoning/index.ts +53 -0
  57. package/src/runner/reasoning/prompt.ts +200 -0
  58. package/src/runner/reasoning/react.ts +149 -0
  59. package/src/runner/reasoning/shopify.ts +214 -0
  60. package/src/runner/reasoning/skills-reasoner.ts +117 -0
  61. package/src/runner/reasoning/types.ts +99 -0
  62. package/src/runner/runner.ts +203 -0
  63. package/src/sarif.ts +148 -0
  64. package/src/source-identity.ts +0 -0
  65. package/src/source-trace.ts +814 -0
  66. package/src/suggest.ts +328 -0
  67. package/src/suppression-ranges.ts +354 -0
  68. package/src/suppressor-map.ts +189 -0
  69. package/src/suppressors.ts +284 -0
  70. package/src/tsconfig-aliases.ts +155 -0
  71. package/src/unity-ast.ts +331 -0
  72. package/src/unity-findings.ts +91 -0
  73. package/src/unity-guid-registry.ts +82 -0
  74. package/src/unity-label-resolve.ts +249 -0
  75. package/src/unity-rule-color-only.ts +127 -0
  76. package/src/unity-rule-missing-label.ts +156 -0
  77. package/src/unity-rules-baseline.ts +273 -0
  78. package/src/wcag-map.ts +35 -0
  79. package/src/wcag-tags.ts +35 -0
  80. package/src/workspace-resolve.ts +405 -0
@@ -0,0 +1,470 @@
1
+ /**
2
+ * Phone-home — the OPTIONAL, metadata-only ingest step that files a run's
3
+ * findings to the Binclusive dashboard (issue #2108).
4
+ *
5
+ * ONE invariant governs this whole module: **phone-home NEVER blocks.** It is a
6
+ * telemetry side-channel on a local-first tool — a dashboard update is a bonus,
7
+ * never a gate. So every failure mode (no token, unreachable endpoint, timeout,
8
+ * 4xx/5xx, a GraphQL `errors` array, a malformed body) is CAUGHT and returned as
9
+ * a {@link PhoneHomeOutcome} value; nothing throws out of {@link phoneHome}, and
10
+ * the caller always `exit 0`s. The outcome is a discriminated union so "it
11
+ * failed" is a value the caller inspects, never an exception it must remember to
12
+ * catch — impossible states (a "failed" result that also silently threw) are
13
+ * unrepresentable.
14
+ *
15
+ * ABSENCE IS NOT FAILURE. No `B8E_TOKEN` (or no org/project/endpoint) means the
16
+ * customer opted out of the dashboard — the run stays fully local and reports
17
+ * `skipped`, not `failed`. Local-first: the dashboard is an upgrade, not a
18
+ * dependency.
19
+ *
20
+ * SECRETS NEVER LOG. The bearer token is read once into the request header and
21
+ * is never interpolated into any log line — the outcome carries only
22
+ * presence/status/reason, never the token value.
23
+ *
24
+ * METADATA-ONLY ON THE WIRE. A finding rides the engine's SINGLE emit projection
25
+ * (`@binclusive/a11y-contract` `Finding` via `toFindingPayloadLenient`) carrying a
26
+ * `location` — a page `url` OR a source `{path,lineHash,index}` fingerprint — plus a
27
+ * WCAG criterion, a DOM/selector locator, the contract `impact` (ADR 0044 slice v: the
28
+ * engine speaks one impact vocabulary), and human-readable
29
+ * evidence; never a `file:line`, never a snippet, never a raw line (ADR 0042). A
30
+ * source finding sends its fingerprint, NOT `url = file-path`, so the platform stores
31
+ * a real Source(), not a fake page (#2252-B). A page finding's `location.url` is the
32
+ * same repo/URL vocabulary as {@link IngestEnvelope.scannedTargets}, which is what
33
+ * lets platform-side scope-reconcile key membership (issue #2166).
34
+ */
35
+ import type { Finding as ContractFinding, Impact, Provenance as ContractProvenance } from "@binclusive/a11y-contract";
36
+ import type { EnrichedFinding } from "./evidence";
37
+ import { deletedPathsFromEnv, scopeChangedTsxFromEnv } from "./diff-scope";
38
+ import { toFindingPayloadLenient } from "./emit-contract";
39
+ import { repoRelativePath } from "./source-identity";
40
+
41
+ /** The production Kontrol GraphQL gateway. Overridable via `B8E_INGEST_URL`. */
42
+ const DEFAULT_INGEST_URL = "https://kontrol.binclusive.io/graphql";
43
+
44
+ /** Phone-home is a background courtesy — a slow endpoint must never stall CI. */
45
+ const DEFAULT_TIMEOUT_MS = 10_000;
46
+
47
+ /**
48
+ * One occurrence on the wire: the metadata-only contract `Finding` the engine's emit
49
+ * path projects, PLUS `impact` — a Kontrol transport extra the moat contract omits.
50
+ *
51
+ * `impact` carries the contract's `Impact` value (`critical|serious|moderate|minor|unknown`),
52
+ * which Kontrol's `CiFindingInput` and the dashboard's `parseImpact` require. The metadata-only
53
+ * wire `Finding` has no impact field, so a bare `ContractFinding` cannot represent the wire
54
+ * occurrence — the impact travels only here, sourced from the paired enriched finding.
55
+ */
56
+ interface EnvelopeFinding {
57
+ readonly contract: ContractFinding;
58
+ /** The contract `Impact` when the finding carried a concrete axe impact, else `"unknown"`. */
59
+ readonly impact: Impact;
60
+ }
61
+
62
+ /**
63
+ * The ingest envelope — ONE per provenance, because Kontrol's
64
+ * `ingestExternalFindings` declares `provenance` at the envelope level, not
65
+ * per-finding. `scannedTargets` is declared independently of `findings` so
66
+ * scope-reconcile can tell "re-scanned and clean" from "not scanned this run".
67
+ */
68
+ export interface IngestEnvelope {
69
+ readonly orgID: string;
70
+ readonly projectID: string;
71
+ readonly auditID: string;
72
+ readonly provenance: ContractProvenance;
73
+ readonly scope: string;
74
+ readonly scannedTargets: readonly string[];
75
+ /**
76
+ * The source-scan-scope coverage set (ADR 0043) — the repo-relative paths this run
77
+ * SUCCESSFULLY ANALYZED (incl zero-finding, excl parse-failed). 4b's source
78
+ * reconcile resolves a source ticket iff its `path` is in this set AND its
79
+ * fingerprint wasn't re-emitted. Declared independently of `findings` — it
80
+ * describes the RUN's coverage, not the findings — the source-side sibling of
81
+ * `scannedTargets` (which serves the page/url axis).
82
+ *
83
+ * PLATFORM FIELD PENDING #2247 (4b): the engine EMITS this now; the platform adds
84
+ * `scannedPaths` to `IngestExternalFindingsInput` in 4b. Until then Kontrol ignores
85
+ * the extra input field — sending it early is the matched-pair handoff ADR 0043 calls for.
86
+ */
87
+ readonly scannedPaths: readonly string[];
88
+ /**
89
+ * The run's TRUE deletions (ADR 0043 v2) — repo-relative paths whose content is
90
+ * GONE, never MOVED (rename detection pinned ON, so a moved file is excluded).
91
+ * 4b resolves a source ticket whose `path` is in this set: the code is gone, so the
92
+ * issue is gone. Also platform-side pending #2247.
93
+ */
94
+ readonly deletedPaths: readonly string[];
95
+ /**
96
+ * The occurrences on the wire — each pairs the SAME metadata-only
97
+ * `@binclusive/a11y-contract` `Finding` the engine's emit path projects (ONE wire
98
+ * projection, not a phone-home-private second one) with its transport `impact`.
99
+ * Each contract carries a `location` (page `url` OR source `{path,lineHash,index}`
100
+ * fingerprint), never a `file:line` (ADR 0042).
101
+ */
102
+ readonly findings: readonly EnvelopeFinding[];
103
+ /** ISO-8601 timestamp the run observed these occurrences (envelope-level: one run). */
104
+ readonly seenAt: string;
105
+ }
106
+
107
+ /** Why phone-home did nothing — every arm is a NON-error opt-out or empty run. */
108
+ export type SkipReason =
109
+ | "no-token"
110
+ | "no-org"
111
+ | "no-project"
112
+ | "no-audit"
113
+ | "no-endpoint"
114
+ | "no-findings";
115
+
116
+ /**
117
+ * Why a POST failed. A value, never a thrown exception — the caller reads it and
118
+ * still exits 0. `network`/`timeout`/`http`/`graphql-errors`/`malformed-response`
119
+ * are the full closed set of ways a well-formed POST can come back unusable.
120
+ */
121
+ export type FailureReason =
122
+ | { readonly kind: "network"; readonly message: string }
123
+ | { readonly kind: "timeout" }
124
+ | { readonly kind: "http"; readonly status: number }
125
+ | { readonly kind: "graphql-errors"; readonly count: number }
126
+ | { readonly kind: "malformed-response" };
127
+
128
+ /**
129
+ * The result of a whole phone-home attempt. Discriminated so the caller branches
130
+ * on a value; there is no "threw" arm because {@link phoneHome} never throws.
131
+ * `sent` is reported when AT LEAST ONE envelope was accepted (a partial send is
132
+ * still forward progress); `failed` only when every envelope failed.
133
+ */
134
+ export type PhoneHomeOutcome =
135
+ | { readonly status: "skipped"; readonly reason: SkipReason }
136
+ | { readonly status: "sent"; readonly envelopes: number; readonly ingested: number }
137
+ | { readonly status: "failed"; readonly reason: FailureReason };
138
+
139
+ /** Resolved, complete config — every field present (absence became a `skip`). */
140
+ export interface PhoneHomeConfig {
141
+ readonly endpoint: string;
142
+ readonly token: string;
143
+ readonly orgID: string;
144
+ readonly projectID: string;
145
+ readonly auditID: string;
146
+ readonly scope: string;
147
+ readonly timeoutMs: number;
148
+ }
149
+
150
+ /** Injectable seams so the never-blocks behavior is unit-testable end to end. */
151
+ export interface PhoneHomeDeps {
152
+ readonly fetch: typeof fetch;
153
+ readonly now: () => Date;
154
+ readonly log: (message: string) => void;
155
+ /** The targets this run actually scanned — the diff-scope (issue #2166). */
156
+ readonly scanTargets: () => readonly string[];
157
+ /**
158
+ * The source files this run SUCCESSFULLY ANALYZED — ABSOLUTE paths (relativized
159
+ * against `root` at assembly). RUN DATA, not env-derivable, so the CLI injects the
160
+ * real `ScanResult.analyzedFiles` here; the default is the SAFE empty set (an empty
161
+ * `scannedPaths` resolves nothing — never a false-resolve if the CLI forgets it).
162
+ */
163
+ readonly analyzedFiles: () => readonly string[];
164
+ /** The run's TRUE deletions — git diff `--diff-filter=D -M` (ADR 0043 v2). */
165
+ readonly deletedPaths: () => readonly string[];
166
+ }
167
+
168
+ function defaultDeps(env: NodeJS.ProcessEnv): PhoneHomeDeps {
169
+ return {
170
+ fetch: globalThis.fetch,
171
+ now: () => new Date(),
172
+ log: (message) => console.error(`phone-home: ${message}`),
173
+ scanTargets: () => scopeChangedTsxFromEnv(env),
174
+ analyzedFiles: () => [],
175
+ deletedPaths: () => deletedPathsFromEnv(env),
176
+ };
177
+ }
178
+
179
+ // ── Config resolution: absence → a typed skip, never a partial config ──
180
+
181
+ type ConfigResolution =
182
+ | { readonly kind: "ready"; readonly config: PhoneHomeConfig }
183
+ | { readonly kind: "skip"; readonly reason: SkipReason };
184
+
185
+ /**
186
+ * Resolve config from the CI env. A missing credential is an OPT-OUT (`skip`),
187
+ * not an error: no token / no org / no project / no audit id each short-circuit
188
+ * to a distinct reason so the log says exactly why the dashboard stayed dark.
189
+ */
190
+ export function resolveConfig(env: NodeJS.ProcessEnv): ConfigResolution {
191
+ const nonEmpty = (v: string | undefined): v is string => v !== undefined && v.trim() !== "";
192
+
193
+ const token = env.B8E_TOKEN;
194
+ if (!nonEmpty(token)) return { kind: "skip", reason: "no-token" };
195
+ const orgID = env.B8E_ORG_ID;
196
+ if (!nonEmpty(orgID)) return { kind: "skip", reason: "no-org" };
197
+ const projectID = env.B8E_PROJECT_ID;
198
+ if (!nonEmpty(projectID)) return { kind: "skip", reason: "no-project" };
199
+ // The audit run these findings attach to. Explicit `B8E_AUDIT_ID` wins; the CI
200
+ // run id is the honest fallback so a caller need not mint one by hand.
201
+ const auditID = nonEmpty(env.B8E_AUDIT_ID) ? env.B8E_AUDIT_ID : env.GITHUB_RUN_ID;
202
+ if (!nonEmpty(auditID)) return { kind: "skip", reason: "no-audit" };
203
+ const endpoint = nonEmpty(env.B8E_INGEST_URL) ? env.B8E_INGEST_URL : DEFAULT_INGEST_URL;
204
+
205
+ return {
206
+ kind: "ready",
207
+ config: {
208
+ // Presence is checked on the trimmed value, so the STORED value must be
209
+ // trimmed too — a credential with incidental surrounding whitespace passes
210
+ // the presence gate but would be sent verbatim, causing avoidable auth /
211
+ // validation failures downstream.
212
+ endpoint: endpoint.trim(),
213
+ token: token.trim(),
214
+ orgID: orgID.trim(),
215
+ projectID: projectID.trim(),
216
+ auditID: auditID.trim(),
217
+ // Free-form scan-scope label stamped on every finding. Not load-bearing for
218
+ // reconcile (that keys on url + scannedTargets); a human breadcrumb.
219
+ scope: nonEmpty(env.B8E_SCOPE) ? env.B8E_SCOPE.trim() : "ci-diff",
220
+ timeoutMs: DEFAULT_TIMEOUT_MS,
221
+ },
222
+ };
223
+ }
224
+
225
+ // ── Projection: reuse the ONE emit path (resolveLocations + toContractFinding) ──
226
+
227
+ /**
228
+ * Group a run's findings into per-provenance envelopes. Kontrol's provenance is
229
+ * envelope-level, so a mixed run yields up to two envelopes (deterministic +
230
+ * agent); an all-deterministic CI run yields one. `scannedTargets` is stamped on
231
+ * every envelope regardless — it describes the RUN, not the findings.
232
+ *
233
+ * The occurrences come from the engine's SINGLE emit projection
234
+ * ({@link toFindingPayloadLenient} — `resolveLocations` + `toContractFinding`), so
235
+ * phone-home and SARIF can never disagree on a finding's identity or location. Lenient
236
+ * (not the throwing `toFindingPayload`) because phone-home NEVER blocks: a malformed
237
+ * finding is dropped, never thrown out of this call. The projection resolves each
238
+ * finding's `location` — a page `url` or a source `{path,lineHash,index}` fingerprint —
239
+ * so a source finding rides its moat-safe identity, not a `url = file-path` fake page.
240
+ */
241
+ export function assembleEnvelopes(
242
+ findings: readonly EnrichedFinding[],
243
+ root: string,
244
+ config: PhoneHomeConfig,
245
+ scannedTargets: readonly string[],
246
+ scannedPaths: readonly string[],
247
+ deletedPaths: readonly string[],
248
+ seenAt: string,
249
+ ): IngestEnvelope[] {
250
+ const { payload, sources } = toFindingPayloadLenient(findings, config.scope, { root });
251
+
252
+ // Pair each wire finding with its source's axe impact. `sources[i]` is the
253
+ // enriched finding `payload.findings[i]` was projected from (1:1, drops applied). The
254
+ // fallback is the contract's `"unknown"` — a finding with no concrete axe impact (an
255
+ // agent finding) sends `unknown`, always a valid `Impact` (ADR 0044 slice v).
256
+ const occurrences: EnvelopeFinding[] = payload.findings.map((contract, i) => ({
257
+ contract,
258
+ impact: sources[i]?.impact ?? "unknown",
259
+ }));
260
+
261
+ const envelope = (provenance: ContractProvenance, items: readonly EnvelopeFinding[]): IngestEnvelope => ({
262
+ orgID: config.orgID,
263
+ projectID: config.projectID,
264
+ auditID: config.auditID,
265
+ provenance,
266
+ scope: config.scope,
267
+ scannedTargets,
268
+ scannedPaths,
269
+ deletedPaths,
270
+ findings: items,
271
+ seenAt,
272
+ });
273
+
274
+ const deterministic = occurrences.filter((o) => o.contract.provenance === "deterministic");
275
+ const agent = occurrences.filter((o) => o.contract.provenance === "agent");
276
+
277
+ const envelopes: IngestEnvelope[] = [];
278
+ if (deterministic.length > 0) envelopes.push(envelope("deterministic", deterministic));
279
+ if (agent.length > 0) envelopes.push(envelope("agent", agent));
280
+ return envelopes;
281
+ }
282
+
283
+ // ── The POST: parse-don't-validate the response, catch every failure ──
284
+
285
+ const INGEST_MUTATION = `mutation IngestExternalFindings($input: IngestExternalFindingsInput!) {
286
+ ingestExternalFindings(input: $input) { count }
287
+ }`;
288
+
289
+ type SendResult =
290
+ | { readonly ok: true; readonly ingested: number }
291
+ | { readonly ok: false; readonly reason: FailureReason };
292
+
293
+ /**
294
+ * Interpret a GraphQL response body without trusting its shape. A `data.errors`
295
+ * array, a missing payload, or a non-numeric `count` are each a distinct
296
+ * unusable-response outcome — never a throw.
297
+ */
298
+ function readIngestCount(body: unknown): { ok: true; count: number } | { ok: false; errors: number } | { ok: false } {
299
+ if (typeof body !== "object" || body === null) return { ok: false };
300
+ const root = body as Record<string, unknown>;
301
+ if (Array.isArray(root.errors) && root.errors.length > 0) return { ok: false, errors: root.errors.length };
302
+ const data = root.data;
303
+ if (typeof data !== "object" || data === null) return { ok: false };
304
+ const payload = (data as Record<string, unknown>).ingestExternalFindings;
305
+ if (typeof payload !== "object" || payload === null) return { ok: false };
306
+ const count = (payload as Record<string, unknown>).count;
307
+ if (typeof count !== "number") return { ok: false };
308
+ return { ok: true, count };
309
+ }
310
+
311
+ /**
312
+ * Serialize an envelope to the `ingestExternalFindings` variables. The occurrence
313
+ * fields (location/criterion/element/evidence) come STRAIGHT from the contract
314
+ * `Finding` — no re-derivation — and only the transport extras Kontrol's
315
+ * `CiFindingInput` requires but the moat contract omits (`impact`, `description`,
316
+ * `recommendation`, `seenAt`) are added here, mirroring the platform's own
317
+ * `ciFindingOccurrenceSchema` split. The wire sends the single contract `impact` — the
318
+ * engine speaks one impact vocabulary (ADR 0044 slice v). The contract `location` union IS `CiLocationInput`
319
+ * (page `{kind,url}` | source `{kind,path,lineHash,index}`), so a source finding sends
320
+ * its fingerprint and NO top-level `url` — never a `url = path` fake page (ADR 0042, #2252-B).
321
+ */
322
+ function toInputVariables(envelope: IngestEnvelope) {
323
+ return {
324
+ orgID: envelope.orgID,
325
+ projectID: envelope.projectID,
326
+ auditID: envelope.auditID,
327
+ provenance: envelope.provenance,
328
+ scope: envelope.scope,
329
+ scannedTargets: envelope.scannedTargets,
330
+ // The source-scan-scope coverage (analyzed set) + true deletions 4b's reconcile
331
+ // consumes (ADR 0043). Platform-side `IngestExternalFindingsInput` fields land in
332
+ // #2247; sent now as the matched-pair engine half.
333
+ scannedPaths: envelope.scannedPaths,
334
+ deletedPaths: envelope.deletedPaths,
335
+ findings: envelope.findings.map(({ contract, impact }) => ({
336
+ // The location union — page url or source fingerprint — is what the platform
337
+ // stores as a real Page()/Source(); no bare `url` arm, so source never fakes a page.
338
+ location: contract.location,
339
+ criterion: contract.criterion,
340
+ element: contract.element,
341
+ evidence: contract.evidence,
342
+ description: contract.evidence,
343
+ // `impact` is the contract `Impact` (`critical|serious|moderate|minor`) when the
344
+ // finding carried a concrete axe impact, else `"unknown"` — always a valid `Impact`.
345
+ // The engine speaks one impact vocabulary (ADR 0044 slice v).
346
+ impact,
347
+ recommendation: "",
348
+ seenAt: envelope.seenAt,
349
+ })),
350
+ };
351
+ }
352
+
353
+ /**
354
+ * POST one envelope. NEVER throws: every fetch rejection, timeout, non-2xx, and
355
+ * malformed body is folded into a {@link SendResult}. The bearer token is placed
356
+ * in the header and never logged.
357
+ */
358
+ async function sendEnvelope(
359
+ config: PhoneHomeConfig,
360
+ envelope: IngestEnvelope,
361
+ deps: PhoneHomeDeps,
362
+ ): Promise<SendResult> {
363
+ const controller = new AbortController();
364
+ const timer = setTimeout(() => controller.abort(), config.timeoutMs);
365
+ try {
366
+ const response = await deps.fetch(config.endpoint, {
367
+ method: "POST",
368
+ headers: {
369
+ "content-type": "application/json",
370
+ authorization: `Bearer ${config.token}`,
371
+ },
372
+ body: JSON.stringify({ query: INGEST_MUTATION, variables: { input: toInputVariables(envelope) } }),
373
+ signal: controller.signal,
374
+ });
375
+
376
+ if (!response.ok) return { ok: false, reason: { kind: "http", status: response.status } };
377
+
378
+ let body: unknown;
379
+ try {
380
+ body = await response.json();
381
+ } catch {
382
+ return { ok: false, reason: { kind: "malformed-response" } };
383
+ }
384
+
385
+ const read = readIngestCount(body);
386
+ if (read.ok) return { ok: true, ingested: read.count };
387
+ if ("errors" in read) return { ok: false, reason: { kind: "graphql-errors", count: read.errors } };
388
+ return { ok: false, reason: { kind: "malformed-response" } };
389
+ } catch (error) {
390
+ // AbortController fires an AbortError on timeout; everything else is network.
391
+ if (error instanceof Error && error.name === "AbortError") return { ok: false, reason: { kind: "timeout" } };
392
+ return { ok: false, reason: { kind: "network", message: error instanceof Error ? error.message : String(error) } };
393
+ } finally {
394
+ clearTimeout(timer);
395
+ }
396
+ }
397
+
398
+ function describe(reason: FailureReason): string {
399
+ switch (reason.kind) {
400
+ case "network":
401
+ return `network error (${reason.message})`;
402
+ case "timeout":
403
+ return "request timed out";
404
+ case "http":
405
+ return `HTTP ${reason.status}`;
406
+ case "graphql-errors":
407
+ return `${reason.count} GraphQL error(s)`;
408
+ case "malformed-response":
409
+ return "malformed response";
410
+ }
411
+ }
412
+
413
+ /**
414
+ * The public phone-home step. Resolves config, assembles per-provenance
415
+ * envelopes, POSTs each, and returns a {@link PhoneHomeOutcome}. NEVER throws and
416
+ * NEVER touches the process exit code — a dashboard update is a courtesy layered
417
+ * on a local-first run. Absent credentials or an empty run → `skipped`; a
418
+ * transport/server failure → `failed`; at least one accepted envelope → `sent`.
419
+ */
420
+ export async function phoneHome(
421
+ findings: readonly EnrichedFinding[],
422
+ root: string,
423
+ env: NodeJS.ProcessEnv,
424
+ overrides?: Partial<PhoneHomeDeps>,
425
+ ): Promise<PhoneHomeOutcome> {
426
+ const deps: PhoneHomeDeps = { ...defaultDeps(env), ...overrides };
427
+
428
+ const resolved = resolveConfig(env);
429
+ if (resolved.kind === "skip") {
430
+ deps.log(`skipped — ${resolved.reason}`);
431
+ return { status: "skipped", reason: resolved.reason };
432
+ }
433
+ const { config } = resolved;
434
+
435
+ const scannedTargets = deps.scanTargets();
436
+ // Narrow the analyzed set (ABSOLUTE paths) to repo-relative through the SAME
437
+ // function that mints a source finding's wire `path`, so `scannedPaths` membership
438
+ // matches `mf.path` by construction (ADR 0043). Deletions are already repo-relative
439
+ // (git's native output).
440
+ const scannedPaths = deps.analyzedFiles().map((abs) => repoRelativePath(abs, root));
441
+ const deletedPaths = deps.deletedPaths();
442
+ const seenAt = deps.now().toISOString();
443
+ const envelopes = assembleEnvelopes(findings, root, config, scannedTargets, scannedPaths, deletedPaths, seenAt);
444
+
445
+ // Kontrol's schema requires `findings.min(1)`, so a fully-clean run cannot post
446
+ // an empty batch to activate reconcile — a known ceiling (see #2166 follow-up).
447
+ if (envelopes.length === 0) {
448
+ deps.log("skipped — no findings to send");
449
+ return { status: "skipped", reason: "no-findings" };
450
+ }
451
+
452
+ let ingested = 0;
453
+ let accepted = 0;
454
+ let firstFailure: FailureReason | null = null;
455
+ for (const envelope of envelopes) {
456
+ const result = await sendEnvelope(config, envelope, deps);
457
+ if (result.ok) {
458
+ accepted += 1;
459
+ ingested += result.ingested;
460
+ deps.log(`sent ${envelope.provenance} envelope — ${result.ingested} ingested`);
461
+ } else {
462
+ if (firstFailure === null) firstFailure = result.reason;
463
+ deps.log(`${envelope.provenance} envelope failed — ${describe(result.reason)} (ignored; run continues)`);
464
+ }
465
+ }
466
+
467
+ if (accepted > 0) return { status: "sent", envelopes: accepted, ingested };
468
+ // Every envelope failed. Still non-blocking — the caller reads this and exits 0.
469
+ return { status: "failed", reason: firstFailure ?? { kind: "malformed-response" } };
470
+ }