@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.
- package/README.md +285 -0
- package/bin/a11y.mjs +36 -0
- package/bin/diff-scope.mjs +29 -0
- package/data/baseline-rules.json +892 -0
- package/package.json +68 -0
- package/src/agent-lane.ts +138 -0
- package/src/agents-block.ts +157 -0
- package/src/baseline/gen-baseline.ts +166 -0
- package/src/cli.ts +1026 -0
- package/src/collect-dom.ts +119 -0
- package/src/collect-liquid.ts +103 -0
- package/src/collect-swift.ts +227 -0
- package/src/collect-unity.ts +99 -0
- package/src/collect.ts +54 -0
- package/src/commands.ts +314 -0
- package/src/config-scan.ts +177 -0
- package/src/contract.ts +355 -0
- package/src/core.ts +546 -0
- package/src/detect-stack.ts +207 -0
- package/src/diff-scope-cli.ts +12 -0
- package/src/diff-scope.ts +150 -0
- package/src/emit-contract.ts +181 -0
- package/src/enforce.ts +1125 -0
- package/src/eslint-plugin-jsx-a11y.d.ts +11 -0
- package/src/evidence.ts +308 -0
- package/src/github-identity.ts +201 -0
- package/src/hook.ts +242 -0
- package/src/impact-gate.ts +107 -0
- package/src/imports-resolve.ts +248 -0
- package/src/index.ts +183 -0
- package/src/liquid-ast.ts +203 -0
- package/src/liquid-rules.ts +691 -0
- package/src/mcp.ts +363 -0
- package/src/module-scope.ts +137 -0
- package/src/phone-home.ts +470 -0
- package/src/pr-comment.ts +250 -0
- package/src/pr-summary-cli.ts +182 -0
- package/src/pr-summary.ts +291 -0
- package/src/registry.ts +605 -0
- package/src/reporter/contract.ts +154 -0
- package/src/reporter/finding.ts +87 -0
- package/src/reporter/github-adapter.ts +183 -0
- package/src/reporter/null-adapter.ts +51 -0
- package/src/reporter/registry.ts +22 -0
- package/src/reporter-cli.ts +48 -0
- package/src/resolve-components.ts +579 -0
- package/src/runner/budget.ts +90 -0
- package/src/runner/codegraph-lookup.test.ts +93 -0
- package/src/runner/codegraph-lookup.ts +197 -0
- package/src/runner/index.ts +86 -0
- package/src/runner/lookup.ts +69 -0
- package/src/runner/provider.ts +72 -0
- package/src/runner/providers/anthropic.ts +168 -0
- package/src/runner/providers/openai.ts +191 -0
- package/src/runner/reasoner.ts +73 -0
- package/src/runner/reasoning/index.ts +53 -0
- package/src/runner/reasoning/prompt.ts +200 -0
- package/src/runner/reasoning/react.ts +149 -0
- package/src/runner/reasoning/shopify.ts +214 -0
- package/src/runner/reasoning/skills-reasoner.ts +117 -0
- package/src/runner/reasoning/types.ts +99 -0
- package/src/runner/runner.ts +203 -0
- package/src/sarif.ts +148 -0
- package/src/source-identity.ts +0 -0
- package/src/source-trace.ts +814 -0
- package/src/suggest.ts +328 -0
- package/src/suppression-ranges.ts +354 -0
- package/src/suppressor-map.ts +189 -0
- package/src/suppressors.ts +284 -0
- package/src/tsconfig-aliases.ts +155 -0
- package/src/unity-ast.ts +331 -0
- package/src/unity-findings.ts +91 -0
- package/src/unity-guid-registry.ts +82 -0
- package/src/unity-label-resolve.ts +249 -0
- package/src/unity-rule-color-only.ts +127 -0
- package/src/unity-rule-missing-label.ts +156 -0
- package/src/unity-rules-baseline.ts +273 -0
- package/src/wcag-map.ts +35 -0
- package/src/wcag-tags.ts +35 -0
- 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
|
+
}
|