canary-test-cli 6.1.0 → 6.3.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.
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Reachability sweep — the generic crawl primitive from #452.
3
+ *
4
+ * Enumerate every link on a surface and assert it resolves. Cheap, broad, and
5
+ * high-yield: it catches dangling routes and 404s that targeted tests never
6
+ * look for, because a targeted test asserts what a page *does*, not that the
7
+ * things it points at still exist.
8
+ *
9
+ * This module owns the deterministic half — normalization, scoping, and
10
+ * classification. **Fetching is injected** ({@link LinkProbe}), so the logic is
11
+ * unit-testable with no network and a caller can supply whatever transport it
12
+ * already has (plain `fetch`, an authenticated client, a browser page).
13
+ *
14
+ * ## The load-bearing distinction: dead vs. slow
15
+ *
16
+ * The reason this is not simply "fetch and check the status": **a dead link and
17
+ * a slow link must never be confusable.** A 404 is a defect the sweep is
18
+ * entitled to assert on. A timeout, DNS failure, or refused connection is
19
+ * *inconclusive* — the link may be perfectly fine while the network, a cold
20
+ * container, or a rate limiter is not.
21
+ *
22
+ * Conflating them is how a broad sweep turns into a flaky test that teams learn
23
+ * to ignore, which costs more than the check was ever worth. So the two are
24
+ * structurally different outcomes ({@link ReachabilityStatus.Broken} vs
25
+ * {@link ReachabilityStatus.Unreachable}) with different {@link isDefect}
26
+ * verdicts, and only the first is ever asserted on.
27
+ *
28
+ * The same principle decides the other ambiguous cases:
29
+ * - **5xx** — the target exists and is unwell. A server problem, not a
30
+ * dangling reference; blaming the link sends someone to fix the wrong
31
+ * thing.
32
+ * - **401/403** — auth-walled, not missing. The link is fine; the sweep just
33
+ * is not logged in.
34
+ *
35
+ * Only an unambiguous "this target does not exist" (4xx that is not auth)
36
+ * counts as a defect.
37
+ */
38
+ /** How a probed link resolved. Only {@link Broken} is asserted on. */
39
+ export var ReachabilityStatus;
40
+ (function (ReachabilityStatus) {
41
+ /** Resolved (2xx/3xx), or exists but is auth-walled (401/403). */
42
+ ReachabilityStatus["Ok"] = "ok";
43
+ /** The target does not exist (4xx other than auth). The one real defect. */
44
+ ReachabilityStatus["Broken"] = "broken";
45
+ /** The target exists but errored (5xx) — a server problem, not a bad link. */
46
+ ReachabilityStatus["ServerError"] = "server-error";
47
+ /** No verdict possible: timeout, DNS failure, refused connection. */
48
+ ReachabilityStatus["Unreachable"] = "unreachable";
49
+ /** Off-site and not allowlisted, so deliberately not probed. */
50
+ ReachabilityStatus["SkippedExternal"] = "skipped-external";
51
+ })(ReachabilityStatus || (ReachabilityStatus = {}));
52
+ // Schemes that are not fetchable resources. A `mailto:`/`tel:` is a valid link
53
+ // and probing it is meaningless; `javascript:` is a control, not a destination.
54
+ const NON_FETCHABLE_SCHEMES = new Set([
55
+ 'mailto:',
56
+ 'tel:',
57
+ 'javascript:',
58
+ 'data:',
59
+ 'blob:',
60
+ 'sms:',
61
+ 'file:',
62
+ ]);
63
+ /** Auth-walled: the target exists, the sweep just cannot see it. */
64
+ const AUTH_STATUSES = new Set([401, 403]);
65
+ /**
66
+ * Resolve `href` against `base` into an absolute, fragment-free URL.
67
+ *
68
+ * Returns `null` for anything that is not a fetchable destination: an empty or
69
+ * fragment-only href (it points at the current page), a non-fetchable scheme,
70
+ * or an href malformed enough that `URL` rejects it.
71
+ *
72
+ * The fragment is dropped deliberately — `/p#a` and `/p#b` are the same
73
+ * request, and keeping them apart would probe the same page repeatedly and
74
+ * report one dangling route as several.
75
+ */
76
+ export function normalizeLink(href, base) {
77
+ const trimmed = href.trim();
78
+ // Empty, or a pure fragment: this is the current page, not a destination.
79
+ if (!trimmed || trimmed.startsWith('#'))
80
+ return null;
81
+ let url;
82
+ try {
83
+ url = new URL(trimmed, base);
84
+ }
85
+ catch {
86
+ return null;
87
+ }
88
+ if (NON_FETCHABLE_SCHEMES.has(url.protocol))
89
+ return null;
90
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
91
+ return null;
92
+ url.hash = '';
93
+ return { url: url.href, host: url.hostname };
94
+ }
95
+ /** True iff `host` is `allowed` or a subdomain of it (never a lookalike). */
96
+ function hostMatches(host, allowed) {
97
+ const h = host.toLowerCase();
98
+ const a = allowed.toLowerCase();
99
+ // The leading dot is what stops `nottrusted.example` matching
100
+ // `trusted.example` — a bare `endsWith` would wrongly accept it.
101
+ return h === a || h.endsWith(`.${a}`);
102
+ }
103
+ /** True iff `link` is in scope: same host as the base, or allowlisted. */
104
+ function inScope(link, baseHost, allowExternal) {
105
+ if (hostMatches(link.host, baseHost))
106
+ return true;
107
+ return allowExternal.some((allowed) => hostMatches(link.host, allowed));
108
+ }
109
+ /** Map an HTTP status to a verdict. See the module docstring for the rationale. */
110
+ function classifyStatus(status) {
111
+ if (status >= 200 && status < 400)
112
+ return ReachabilityStatus.Ok;
113
+ // Auth-walled is not dangling: the target exists.
114
+ if (AUTH_STATUSES.has(status))
115
+ return ReachabilityStatus.Ok;
116
+ if (status >= 400 && status < 500)
117
+ return ReachabilityStatus.Broken;
118
+ if (status >= 500)
119
+ return ReachabilityStatus.ServerError;
120
+ // 1xx as a final status is nonsensical; refuse to guess.
121
+ return ReachabilityStatus.Unreachable;
122
+ }
123
+ /**
124
+ * True iff `status` is a finding worth failing a sweep on.
125
+ *
126
+ * Only {@link ReachabilityStatus.Broken}. `Unreachable` is inconclusive,
127
+ * `ServerError` belongs to whoever owns the server, and a skipped link was
128
+ * never claimed to be checked. Asserting on any of those would make the sweep
129
+ * fail for reasons the author of the link cannot fix.
130
+ */
131
+ export function isDefect(status) {
132
+ return status === ReachabilityStatus.Broken;
133
+ }
134
+ /**
135
+ * Sweep `hrefs`, probing each unique in-scope URL exactly once.
136
+ *
137
+ * Results are sorted by URL so a sweep produces a stable, diffable report
138
+ * regardless of the order links appeared on the page.
139
+ */
140
+ export function sweepLinks(hrefs, options) {
141
+ const { base, probe, allowExternal = [] } = options;
142
+ const baseHost = new URL(base).hostname;
143
+ // Dedup by normalized URL: a nav link repeated on every page is one check,
144
+ // and one dangling route should be reported once, not once per referrer.
145
+ const unique = new Map();
146
+ for (const href of hrefs) {
147
+ const link = normalizeLink(href, base);
148
+ if (link !== null && !unique.has(link.url))
149
+ unique.set(link.url, link);
150
+ }
151
+ const results = [];
152
+ for (const { link, scoped } of planSweep(hrefs, base, allowExternal)) {
153
+ results.push(scoped ? resolveOutcome(link, probe(link.url)) : skippedResult(link));
154
+ }
155
+ return { results, summary: summarize(results) };
156
+ }
157
+ /**
158
+ * Normalize, dedup, sort, and scope `hrefs` — everything the sync and async
159
+ * drivers must agree on. Shared so the two can never drift apart on which
160
+ * links get probed or in what order.
161
+ *
162
+ * Dedup by normalized URL: a nav link repeated on every page is one check, and
163
+ * one dangling route should be reported once, not once per referrer. Sorted by
164
+ * URL so a sweep produces a stable, diffable report regardless of the order
165
+ * links appeared on the page.
166
+ */
167
+ function planSweep(hrefs, base, allowExternal) {
168
+ const baseHost = new URL(base).hostname;
169
+ const unique = new Map();
170
+ for (const href of hrefs) {
171
+ const link = normalizeLink(href, base);
172
+ if (link !== null && !unique.has(link.url))
173
+ unique.set(link.url, link);
174
+ }
175
+ return [...unique.values()]
176
+ .sort((a, b) => a.url.localeCompare(b.url))
177
+ .map((link) => ({
178
+ link,
179
+ scoped: inScope(link, baseHost, [...allowExternal]),
180
+ }));
181
+ }
182
+ function skippedResult(link) {
183
+ return {
184
+ url: link.url,
185
+ status: ReachabilityStatus.SkippedExternal,
186
+ httpStatus: null,
187
+ detail: 'off-site and not allowlisted',
188
+ };
189
+ }
190
+ /** Turn a probe outcome into a verdict, keeping dead and slow apart. */
191
+ function resolveOutcome(link, outcome) {
192
+ if (outcome.kind === 'failed') {
193
+ return {
194
+ url: link.url,
195
+ status: ReachabilityStatus.Unreachable,
196
+ httpStatus: null,
197
+ // The reason is carried verbatim: an inconclusive result is only
198
+ // actionable if the reader can see WHY no verdict was reached.
199
+ detail: `no response (${outcome.reason})`,
200
+ };
201
+ }
202
+ return {
203
+ url: link.url,
204
+ status: classifyStatus(outcome.status),
205
+ httpStatus: outcome.status,
206
+ detail: `HTTP ${outcome.status}`,
207
+ };
208
+ }
209
+ /**
210
+ * {@link sweepLinks} over an async transport — the shape real network use takes.
211
+ *
212
+ * Probes sequentially on purpose. A sweep enumerates *every* link on a surface,
213
+ * so firing them concurrently at one host is indistinguishable from a small
214
+ * load test and invites the rate limiting that would then be misreported as
215
+ * `unreachable`. A slow, correct sweep beats a fast, self-poisoning one.
216
+ */
217
+ export async function sweepLinksAsync(hrefs, options) {
218
+ const { base, probe, allowExternal = [] } = options;
219
+ const results = [];
220
+ for (const { link, scoped } of planSweep(hrefs, base, allowExternal)) {
221
+ results.push(scoped
222
+ ? resolveOutcome(link, await probe(link.url))
223
+ : skippedResult(link));
224
+ }
225
+ return { results, summary: summarize(results) };
226
+ }
227
+ /**
228
+ * A `fetch`-backed {@link AsyncLinkProbe} that keeps dead and slow apart.
229
+ *
230
+ * Any outcome where the server answered — including a 404 — is a `status`. Any
231
+ * outcome where no response arrived (timeout, DNS failure, refused connection)
232
+ * is a `failed`, carrying its reason. Callers should use this rather than
233
+ * hand-rolling a probe: deciding what counts as "no response" IS the guarantee
234
+ * this module exists to provide, and re-implementing it per caller is how the
235
+ * distinction gets quietly lost.
236
+ *
237
+ * Uses `HEAD`, falling back to `GET` when a server rejects `HEAD` outright
238
+ * (405/501) — some servers do, and treating that as a broken link would be a
239
+ * false positive.
240
+ */
241
+ export function createHttpProbe(options = {}) {
242
+ const { timeoutMs = 10_000, fetchImpl = fetch } = options;
243
+ return async (url) => {
244
+ const attempt = async (method) => {
245
+ const controller = new AbortController();
246
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
247
+ try {
248
+ const res = await fetchImpl(url, {
249
+ method,
250
+ redirect: 'follow',
251
+ signal: controller.signal,
252
+ });
253
+ return { kind: 'status', status: res.status };
254
+ }
255
+ catch (err) {
256
+ const reason = controller.signal.aborted
257
+ ? `timeout after ${timeoutMs}ms`
258
+ : err instanceof Error
259
+ ? err.message
260
+ : String(err);
261
+ return { kind: 'failed', reason };
262
+ }
263
+ finally {
264
+ clearTimeout(timer);
265
+ }
266
+ };
267
+ const head = await attempt('HEAD');
268
+ if (head.kind === 'status' &&
269
+ (head.status === 405 || head.status === 501)) {
270
+ return attempt('GET');
271
+ }
272
+ return head;
273
+ };
274
+ }
275
+ function summarize(results) {
276
+ const summary = {
277
+ total: results.length,
278
+ ok: 0,
279
+ broken: 0,
280
+ unreachable: 0,
281
+ skipped: 0,
282
+ defects: 0,
283
+ };
284
+ for (const r of results) {
285
+ switch (r.status) {
286
+ case ReachabilityStatus.Broken:
287
+ summary.broken += 1;
288
+ break;
289
+ case ReachabilityStatus.Unreachable:
290
+ summary.unreachable += 1;
291
+ break;
292
+ case ReachabilityStatus.SkippedExternal:
293
+ summary.skipped += 1;
294
+ break;
295
+ // A 5xx is counted with `ok` for the accounting identity (it is not a
296
+ // link defect); it stays distinguishable via each result's own status.
297
+ default:
298
+ summary.ok += 1;
299
+ }
300
+ if (isDefect(r.status))
301
+ summary.defects += 1;
302
+ }
303
+ return summary;
304
+ }
305
+ //# sourceMappingURL=reachability.js.map
@@ -344,6 +344,7 @@ export function migrateCmd(opts, deps) {
344
344
  dryRun,
345
345
  framework: opts.framework || null,
346
346
  overlayPath,
347
+ force: opts.force ?? false,
347
348
  });
348
349
  }
349
350
  catch (e) {
@@ -367,6 +368,7 @@ export function migrateCmd(opts, deps) {
367
368
  status: r.status,
368
369
  note: r.note,
369
370
  })),
371
+ installed_workflows: report.installed_workflows.map((r) => r.to_dict()),
370
372
  }));
371
373
  return;
372
374
  }
@@ -107,6 +107,7 @@ export function createCanaryCommand(depsInit = {}) {
107
107
  .option('-o, --overlay <path>', '[deprecated: use --from] Path to an overlay repo whose .canary/skills/ are deployed.')
108
108
  .option('--apply', 'Write files. Without this flag the command is a dry run.')
109
109
  .option('--check', 'Freshness gate: report drift without writing.')
110
+ .option('--force', 'Overwrite a .github/workflows/ file that differs from the overlay template. Without this flag a difference is only reported -- your CI is never rewritten behind your back.')
110
111
  .option('--json', 'Emit the report as JSON.')
111
112
  .action((opts) => {
112
113
  migrateCmd(opts, deps);
@@ -13,9 +13,11 @@
13
13
  * 2. .canary/company.json -- project-local config
14
14
  * 3. .canary/company.<env>.json -- environment override (CANARY_ENV or explicit)
15
15
  *
16
- * List fields are unioned across sources; scalar fields (dashboard_url,
17
- * dashboard_token_env, notes) are replaced by the highest-priority source that
18
- * sets them.
16
+ * List fields ({@link _LIST_FIELDS}) are unioned across sources; scalar fields
17
+ * ({@link _SCALAR_FIELDS}) are replaced by the highest-priority source that
18
+ * sets a non-empty value. Those two arrays are the single place a new field
19
+ * opts into a merge rule, and both are checked for exhaustiveness at compile
20
+ * time.
19
21
  *
20
22
  * Python->TS nuances:
21
23
  * - Python patches `Path.home()` in its tests to isolate the home tier. There
@@ -112,6 +114,18 @@ const _KNOWN_KEYS = new Set([
112
114
  'otel_exporter_endpoint',
113
115
  'notes',
114
116
  'brand',
117
+ // #459: read by the migrator (`_detectFramework` / overlay `deploy_to`
118
+ // matching) to decide which overlay skills deploy. It is NOT parsed into a
119
+ // `CompanyKnowledge` field, but it is emphatically not "ignored" either --
120
+ // warning that it is told anyone adopting an overlay that the single field
121
+ // driving their adoption does nothing.
122
+ 'canary_shape',
123
+ // #459: repo-relative pointers a generated workflow interpolates (the
124
+ // coverage report the guardian reads; the controllers dir it scopes SUT
125
+ // analysis to). See `validateRepoRelativePath` for why they are validated
126
+ // rather than stored verbatim.
127
+ 'coverage_report_path',
128
+ 'sut_controllers_path',
115
129
  ]);
116
130
  const _HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
117
131
  const _BRAND_TEXT_MAX = 200;
@@ -234,6 +248,50 @@ function validateOtelEndpoint(raw, fieldName, warnings) {
234
248
  }
235
249
  return raw;
236
250
  }
251
+ // A path that is absolute in any flavour git checkouts run under: POSIX
252
+ // (`/x`), UNC / Windows-separator (`\x`), or drive-qualified (`C:x`, `C:/x`).
253
+ // Drive-relative `C:x` is included deliberately -- it is not repo-relative.
254
+ const _ABSOLUTE_PATH_RE = /^(?:[/\\]|[A-Za-z]:)/;
255
+ // Newlines would break out of the scalar these values are interpolated into
256
+ // when a workflow template is generated, so they are refused outright rather
257
+ // than escaped -- no legitimate repo path contains one.
258
+ const _PATH_CONTROL_RE = /[\r\n\t\0]/;
259
+ /**
260
+ * A repo-relative path pointer (#459: `coverage_report_path`,
261
+ * `sut_controllers_path`).
262
+ *
263
+ * These are interpolated into generated GitHub Actions YAML, so validation is
264
+ * a safety boundary, not tidiness: an absolute path aims the generated CI at
265
+ * something outside the checkout, and a `..` segment escapes the repo. Both are
266
+ * dropped with a warning (the module's degrade-never-throw convention); a
267
+ * secret-like value raises so the whole layer is refused, exactly as every
268
+ * other non-notes field does.
269
+ *
270
+ * `..` is rejected as a SUBSTRING, not just as a path component. A component
271
+ * check would have to agree with the separator handling of whatever consumes
272
+ * the value later (shell, Actions expression, node `path`); refusing the two
273
+ * characters outright cannot disagree with anything. The cost is rejecting the
274
+ * vanishingly rare legitimate `report..xml`.
275
+ */
276
+ function validateRepoRelativePath(raw, fieldName, warnings) {
277
+ if (typeof raw !== 'string') {
278
+ warnings.push(`${fieldName}: expected string, got ${pyTypeName(raw)} ${EMDASH} skipped`);
279
+ return '';
280
+ }
281
+ const value = raw.trim();
282
+ if (!value)
283
+ return '';
284
+ if (looksLikeSecret(value))
285
+ throw new SecretDetected(fieldName, value);
286
+ if (_ABSOLUTE_PATH_RE.test(value) ||
287
+ value.includes('..') ||
288
+ _PATH_CONTROL_RE.test(value)) {
289
+ warnings.push(`${fieldName}: dropped invalid repo-relative path ${pyRepr(raw)} ` +
290
+ `${EMDASH} must stay inside the repo (no absolute path, no '..')`);
291
+ return '';
292
+ }
293
+ return value;
294
+ }
237
295
  /** Accept #RGB / #RRGGBB (any case); drop anything else with a warning. */
238
296
  function validateHexColor(raw, fieldName, warnings) {
239
297
  if (typeof raw !== 'string' || !raw)
@@ -426,6 +484,14 @@ function parseLayer(data, source) {
426
484
  if (Object.prototype.hasOwnProperty.call(data, 'otel_exporter_endpoint')) {
427
485
  otel_exporter_endpoint = validateOtelEndpoint(data['otel_exporter_endpoint'], 'otel_exporter_endpoint', warns);
428
486
  }
487
+ let coverage_report_path = '';
488
+ if (Object.prototype.hasOwnProperty.call(data, 'coverage_report_path')) {
489
+ coverage_report_path = validateRepoRelativePath(data['coverage_report_path'], 'coverage_report_path', warns);
490
+ }
491
+ let sut_controllers_path = '';
492
+ if (Object.prototype.hasOwnProperty.call(data, 'sut_controllers_path')) {
493
+ sut_controllers_path = validateRepoRelativePath(data['sut_controllers_path'], 'sut_controllers_path', warns);
494
+ }
429
495
  let notes = '';
430
496
  if (Object.prototype.hasOwnProperty.call(data, 'notes')) {
431
497
  const rawNotes = data['notes'];
@@ -449,6 +515,8 @@ function parseLayer(data, source) {
449
515
  dashboard_url,
450
516
  dashboard_token_env,
451
517
  otel_exporter_endpoint,
518
+ coverage_report_path,
519
+ sut_controllers_path,
452
520
  notes,
453
521
  brand,
454
522
  warnings: warns,
@@ -503,51 +571,66 @@ function union(a, b) {
503
571
  }
504
572
  return out;
505
573
  }
574
+ const _LIST_FIELDS = [
575
+ 'confluence_spaces',
576
+ 'jira_projects',
577
+ 'internal_doc_urls',
578
+ 'internal_domains',
579
+ 'mcp_servers',
580
+ 'claude_code_skills',
581
+ ];
582
+ const _SCALAR_FIELDS = [
583
+ 'dashboard_url',
584
+ 'dashboard_token_env',
585
+ 'otel_exporter_endpoint',
586
+ 'coverage_report_path',
587
+ 'sut_controllers_path',
588
+ 'notes',
589
+ ];
590
+ // Compile-time exhaustiveness: adding a field to `Layer`/`MergedFields` without
591
+ // adding it to the matching array above fails the build here (the assertion
592
+ // type collapses to `never`) rather than silently dropping the field at merge.
593
+ const _LIST_FIELDS_EXHAUSTIVE = true;
594
+ const _SCALAR_FIELDS_EXHAUSTIVE = true;
595
+ void _LIST_FIELDS_EXHAUSTIVE;
596
+ void _SCALAR_FIELDS_EXHAUSTIVE;
597
+ function mergeListFields(layers) {
598
+ const out = {};
599
+ for (const field of _LIST_FIELDS) {
600
+ let merged = [];
601
+ for (const layer of layers)
602
+ merged = union(merged, layer[field]);
603
+ out[field] = merged;
604
+ }
605
+ return out;
606
+ }
607
+ function mergeScalarFields(layers) {
608
+ const out = {};
609
+ for (const field of _SCALAR_FIELDS) {
610
+ let merged = '';
611
+ // Highest-priority non-empty wins. A layer whose value was DROPPED as
612
+ // invalid contributes '' and therefore leaves the lower layer's valid value
613
+ // standing (degrade, never blank out) -- load-bearing, and pinned by tests.
614
+ for (const layer of layers)
615
+ if (layer[field])
616
+ merged = layer[field];
617
+ out[field] = merged;
618
+ }
619
+ return out;
620
+ }
506
621
  function mergeLayers(layers) {
507
- let confluence_spaces = [];
508
- let jira_projects = [];
509
- let internal_doc_urls = [];
510
- let internal_domains = [];
511
- let mcp_servers = [];
512
- let claude_code_skills = [];
513
- let dashboard_url = '';
514
- let dashboard_token_env = '';
515
- let otel_exporter_endpoint = '';
516
- let notes = '';
517
- const warns = [];
622
+ const warnings = [];
518
623
  const sources = [];
519
624
  for (const layer of layers) {
520
- confluence_spaces = union(confluence_spaces, layer.confluence_spaces);
521
- jira_projects = union(jira_projects, layer.jira_projects);
522
- internal_doc_urls = union(internal_doc_urls, layer.internal_doc_urls);
523
- internal_domains = union(internal_domains, layer.internal_domains);
524
- mcp_servers = union(mcp_servers, layer.mcp_servers);
525
- claude_code_skills = union(claude_code_skills, layer.claude_code_skills);
526
- if (layer.dashboard_url)
527
- dashboard_url = layer.dashboard_url;
528
- if (layer.dashboard_token_env)
529
- dashboard_token_env = layer.dashboard_token_env;
530
- if (layer.otel_exporter_endpoint)
531
- otel_exporter_endpoint = layer.otel_exporter_endpoint;
532
- if (layer.notes)
533
- notes = layer.notes;
534
- warns.push(...layer.warnings);
625
+ warnings.push(...layer.warnings);
535
626
  if (layer.source)
536
627
  sources.push(layer.source);
537
628
  }
538
629
  return {
539
- confluence_spaces,
540
- jira_projects,
541
- internal_doc_urls,
542
- internal_domains,
543
- mcp_servers,
544
- claude_code_skills,
545
- dashboard_url,
546
- dashboard_token_env,
547
- otel_exporter_endpoint,
548
- notes,
630
+ ...mergeListFields(layers),
631
+ ...mergeScalarFields(layers),
549
632
  brand: mergeBrand(layers),
550
- warnings: warns,
633
+ warnings,
551
634
  sources,
552
635
  };
553
636
  }
@@ -579,6 +662,10 @@ export class CompanyKnowledge {
579
662
  dashboard_url;
580
663
  dashboard_token_env;
581
664
  otel_exporter_endpoint;
665
+ /** Repo-relative path to the coverage report a generated workflow reads. */
666
+ coverage_report_path;
667
+ /** Repo-relative path to the SUT controllers dir analysis is scoped to. */
668
+ sut_controllers_path;
582
669
  notes;
583
670
  brand;
584
671
  warnings;
@@ -594,6 +681,8 @@ export class CompanyKnowledge {
594
681
  this.dashboard_url = init.dashboard_url ?? '';
595
682
  this.dashboard_token_env = init.dashboard_token_env ?? '';
596
683
  this.otel_exporter_endpoint = init.otel_exporter_endpoint ?? '';
684
+ this.coverage_report_path = init.coverage_report_path ?? '';
685
+ this.sut_controllers_path = init.sut_controllers_path ?? '';
597
686
  this.notes = init.notes ?? '';
598
687
  this.brand = init.brand ?? new Brand();
599
688
  this.warnings = init.warnings ?? [];
@@ -727,6 +816,8 @@ export class CompanyKnowledge {
727
816
  dashboard_url: this.dashboard_url,
728
817
  dashboard_token_env: this.dashboard_token_env,
729
818
  otel_exporter_endpoint: this.otel_exporter_endpoint,
819
+ coverage_report_path: this.coverage_report_path,
820
+ sut_controllers_path: this.sut_controllers_path,
730
821
  notes: this.notes,
731
822
  brand: this.brand.toDict(),
732
823
  sources: this.sources,