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.
- package/dist/engine/analysis/reachability.js +305 -0
- package/dist/engine/cli-commands.js +2 -0
- package/dist/engine/cli.core.js +1 -0
- package/dist/engine/core/company-knowledge.js +131 -40
- package/dist/engine/core/migrator.js +317 -20
- package/dist/engine/guardian/agent-tier.js +7 -2
- package/dist/engine/guardian/cli.js +232 -9
- package/dist/engine/guardian/coverage.js +90 -0
- package/dist/engine/guardian/pr-check.js +194 -20
- package/package.json +1 -1
|
@@ -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
|
}
|
package/dist/engine/cli.core.js
CHANGED
|
@@ -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
|
|
17
|
-
*
|
|
18
|
-
* sets
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
540
|
-
|
|
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
|
|
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,
|