canary-test-cli 5.15.0 → 6.0.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 (79) hide show
  1. package/agent/frameworks/registry.json +655 -0
  2. package/bin/canary.js +20 -15
  3. package/dist/doctor-manifest.d.ts +94 -0
  4. package/dist/doctor.d.ts +67 -0
  5. package/dist/engine/analysis/cli.js +270 -0
  6. package/dist/engine/analysis/engine.js +146 -0
  7. package/dist/engine/analysis/reports.js +0 -0
  8. package/dist/engine/analysis/rows.js +9 -0
  9. package/dist/engine/cli-commands.js +618 -0
  10. package/dist/engine/cli-common.js +60 -0
  11. package/dist/engine/cli.core.js +208 -0
  12. package/dist/engine/cli.js +31 -0
  13. package/dist/engine/company-knowledge-cli.js +201 -0
  14. package/dist/engine/core/ci-env.js +33 -0
  15. package/dist/engine/core/classifier.js +192 -0
  16. package/dist/engine/core/company-knowledge.js +765 -0
  17. package/dist/engine/core/config-validation.js +74 -0
  18. package/dist/engine/core/detection.js +48 -0
  19. package/dist/engine/core/domain-scanner.js +212 -0
  20. package/dist/engine/core/environment-detect.js +410 -0
  21. package/dist/engine/core/executor.js +181 -0
  22. package/dist/engine/core/feedback.js +93 -0
  23. package/dist/engine/core/fixture-scanner.js +173 -0
  24. package/dist/engine/core/framework-registry.js +123 -0
  25. package/dist/engine/core/mcp-validator.js +218 -0
  26. package/dist/engine/core/metadata-scanner.js +147 -0
  27. package/dist/engine/core/migrator.js +1112 -0
  28. package/dist/engine/core/overlays.js +176 -0
  29. package/dist/engine/core/pattern-healer.js +147 -0
  30. package/dist/engine/core/pattern-matcher.js +255 -0
  31. package/dist/engine/core/quality-scorer.js +213 -0
  32. package/dist/engine/core/recommender.js +152 -0
  33. package/dist/engine/core/reporter.js +211 -0
  34. package/dist/engine/core/scaffolder.js +236 -0
  35. package/dist/engine/core/skill-registry.js +522 -0
  36. package/dist/engine/core/static-linter.js +237 -0
  37. package/dist/engine/core/ticket-updater.js +639 -0
  38. package/dist/engine/core/workflow-discovery.js +693 -0
  39. package/dist/engine/guardian/agent-tier.js +338 -0
  40. package/dist/engine/guardian/analysis-emit.js +201 -0
  41. package/dist/engine/guardian/cli.js +787 -0
  42. package/dist/engine/guardian/coverage.js +1055 -0
  43. package/dist/engine/guardian/delta-emitter.js +46 -0
  44. package/dist/engine/guardian/diff-extractor.js +257 -0
  45. package/dist/engine/guardian/hard-gate.js +373 -0
  46. package/dist/engine/guardian/impact-mapper.js +121 -0
  47. package/dist/engine/guardian/pr-check.js +975 -0
  48. package/dist/engine/guardian/pr-comment.js +200 -0
  49. package/dist/engine/guardian/summary-emitter.js +94 -0
  50. package/dist/engine/guardian/tier.js +58 -0
  51. package/dist/engine/history/cli.js +303 -0
  52. package/dist/engine/history/detector.js +68 -0
  53. package/dist/engine/history/ndjson-store.js +177 -0
  54. package/dist/engine/history/record.js +14 -0
  55. package/dist/engine/history/schema.js +59 -0
  56. package/dist/engine/history/store.js +47 -0
  57. package/dist/engine/history/supabase-store.js +113 -0
  58. package/dist/engine/main-deps.js +105 -0
  59. package/dist/engine/mcp-server.js +647 -0
  60. package/dist/engine/package.json +4 -0
  61. package/dist/engine/skills-cli.js +181 -0
  62. package/dist/engine/ui/banner.js +50 -0
  63. package/dist/engine/util/coalesce.js +12 -0
  64. package/dist/engine/util/round.js +43 -0
  65. package/dist/engine/workflow-cli.js +242 -0
  66. package/dist/engine-checks.d.ts +49 -0
  67. package/dist/overlay-commands.d.ts +81 -0
  68. package/dist/overlay-conflicts.d.ts +33 -0
  69. package/dist/overlay-lint.d.ts +19 -0
  70. package/dist/overlays-registry.d.ts +74 -0
  71. package/dist/reporters/testtracker.d.ts +89 -0
  72. package/dist/reporters/testtracker.js +195 -0
  73. package/dist/router.d.ts +12 -0
  74. package/dist/router.js +4 -4
  75. package/dist/skill-requirements.d.ts +57 -0
  76. package/dist/source-spec.d.ts +20 -0
  77. package/package.json +30 -6
  78. package/bin/canary +0 -0
  79. package/scripts/install.js +0 -104
@@ -0,0 +1,765 @@
1
+ /**
2
+ * CompanyKnowledge -- load and validate `.canary/company.json`.
3
+ *
4
+ * Faithful TypeScript port of `agent/core/company_knowledge.py`. Stores
5
+ * *pointers only* (Confluence space keys, Jira project keys, internal URLs, MCP
6
+ * server identifiers, Claude Code skill slugs, free-text notes). No proprietary
7
+ * content is ever committed here; AI agents retrieve actual content at runtime
8
+ * via configured MCP servers or authenticated tooling.
9
+ *
10
+ * ## Merge cascade (lowest -> highest priority)
11
+ *
12
+ * 1. ~/.canary/company.json -- org-wide defaults
13
+ * 2. .canary/company.json -- project-local config
14
+ * 3. .canary/company.<env>.json -- environment override (CANARY_ENV or explicit)
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.
19
+ *
20
+ * Python->TS nuances:
21
+ * - Python patches `Path.home()` in its tests to isolate the home tier. There
22
+ * is no global monkeypatch in JS, so `load` takes an injectable `home`
23
+ * argument (defaults to `os.homedir()`), mirroring the `home?` seam the
24
+ * skill-registry / overlays ports added.
25
+ * - `re.match` is anchored at string start; JS `RegExp.test` is not, so every
26
+ * ported `^`-anchored `_match`-style check keeps its explicit `^`. The one
27
+ * `re.IGNORECASE` secret-prefix regex maps to the `i` flag. None of the
28
+ * regexes here use `re.MULTILINE`, so no `^`/`$` anchor-trap applies -- the
29
+ * fence-stripper `_FENCE_RE` uses `re.DOTALL` (`[^`]*` already spans
30
+ * newlines identically in JS, so no `s` flag is needed).
31
+ * - Notes are capped by CODE POINT (`str[:2048]`) and the brand-text cap by
32
+ * code point (`str[:200]`), so both use a code-point slice helper rather
33
+ * than a UTF-16 `.slice`.
34
+ * - Python truthiness (`""`/`None`/`{}`/`[]` falsy) via {@link pyTruthy}.
35
+ * - `type(x).__name__` in warning text maps to {@link pyTypeName} so a
36
+ * non-list / non-string / non-dict value is named `str`/`list`/`dict`/...
37
+ * exactly as Python renders it.
38
+ * - `_warn` printed rich-markup to stderr; this port keeps the warning strings
39
+ * (they are asserted) but does not re-emit them to stderr, matching how the
40
+ * other ports drop Python's `rich` console side effects.
41
+ */
42
+ import { existsSync } from 'node:fs';
43
+ import { homedir } from 'node:os';
44
+ import { isAbsolute, join } from 'node:path';
45
+ import { readJsonWithWarning } from './config-validation.js';
46
+ // ---------------------------------------------------------------------------
47
+ // Python-compatibility helpers (copied locally per-module, matching reporter.ts)
48
+ // ---------------------------------------------------------------------------
49
+ /**
50
+ * Python-truthiness: `null`/`undefined`/`false`/`0`/`""` and an empty array or
51
+ * object are falsy (mirrors `if x:`).
52
+ */
53
+ function pyTruthy(value) {
54
+ if (value === null || value === undefined || value === false)
55
+ return false;
56
+ if (value === 0 || value === '')
57
+ return false;
58
+ if (Array.isArray(value))
59
+ return value.length > 0;
60
+ if (typeof value === 'object')
61
+ return Object.keys(value).length > 0;
62
+ return Boolean(value);
63
+ }
64
+ /** Python `type(x).__name__` for JSON-derived values. */
65
+ function pyTypeName(value) {
66
+ if (value === null || value === undefined)
67
+ return 'NoneType';
68
+ if (typeof value === 'boolean')
69
+ return 'bool';
70
+ if (typeof value === 'number')
71
+ return Number.isInteger(value) ? 'int' : 'float';
72
+ if (typeof value === 'string')
73
+ return 'str';
74
+ if (Array.isArray(value))
75
+ return 'list';
76
+ return 'dict';
77
+ }
78
+ /** `str[:n]` by code point (Python slices by code point, JS `.slice` by UTF-16). */
79
+ function codePointSlice(s, n) {
80
+ return [...s].slice(0, n).join('');
81
+ }
82
+ // em-dash (U+2014) kept out of the source text as an escape, emitted verbatim.
83
+ const EMDASH = '\u{2014}';
84
+ // ---------------------------------------------------------------------------
85
+ // secret heuristic
86
+ // ---------------------------------------------------------------------------
87
+ const _SECRET_PREFIX = /^(sk-|api[_-]?key|token|secret|bearer)/i;
88
+ const _MAX_NON_NOTES_LEN = 128;
89
+ function looksLikeSecret(value) {
90
+ // Python `len(value)` counts code points; use the spread length to match.
91
+ return _SECRET_PREFIX.test(value) || [...value].length > _MAX_NON_NOTES_LEN;
92
+ }
93
+ // ---------------------------------------------------------------------------
94
+ // field validators
95
+ // ---------------------------------------------------------------------------
96
+ const _SPACE_OR_PROJECT_RE = /^[A-Z0-9]{1,32}$/;
97
+ const _DOMAIN_RE = /^[a-z0-9.-]+\.[a-z]{2,}$/;
98
+ const _MCP_SERVER_RE = /^[A-Za-z0-9_-]+$/;
99
+ const _SKILL_RE = /^[a-z0-9][a-z0-9_-]*(?::[a-z0-9][a-z0-9_-]*)?$/;
100
+ const _ENV_VAR_RE = /^[A-Z][A-Z0-9_]*$/;
101
+ const _NOTES_MAX = 2048;
102
+ const _FENCE_RE = /```[^`]*```/g;
103
+ const _KNOWN_KEYS = new Set([
104
+ 'confluence_spaces',
105
+ 'jira_projects',
106
+ 'internal_doc_urls',
107
+ 'internal_domains',
108
+ 'mcp_servers',
109
+ 'claude_code_skills',
110
+ 'dashboard_url',
111
+ 'dashboard_token_env',
112
+ 'otel_exporter_endpoint',
113
+ 'notes',
114
+ 'brand',
115
+ ]);
116
+ const _HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
117
+ const _BRAND_TEXT_MAX = 200;
118
+ /** Raised (as an exception) when a secret-like value is detected. */
119
+ class SecretDetected extends Error {
120
+ fieldName;
121
+ value;
122
+ constructor(fieldName, value) {
123
+ super(`secret-like value in '${fieldName}'`);
124
+ this.fieldName = fieldName;
125
+ this.value = value;
126
+ }
127
+ }
128
+ /**
129
+ * Parse a URL the way Python's `urllib.parse.urlparse` exposes `scheme` and
130
+ * `netloc`. The stdlib parser is permissive (no host validation); we only need
131
+ * the scheme and whether a network location is present, matching the two checks
132
+ * the Python performs (`scheme in (...)` and `not netloc`).
133
+ */
134
+ function parseSchemeNetloc(raw) {
135
+ // urlsplit: scheme is the run of `[a-zA-Z][a-zA-Z0-9+.-]*` before the first
136
+ // ":" -- but only when what follows begins "//", or the remainder is a valid
137
+ // scheme form. urllib is lenient; for the schemes we accept (http/https/grpc/
138
+ // grpcs) the "://" form is what real endpoints use. Mirror urlparse closely
139
+ // enough for the validators: split scheme at the first ":", then netloc is the
140
+ // authority between "//" and the next "/", "?" or "#".
141
+ // Python's urlsplit strips leading C0-control/space bytes and removes any
142
+ // \t\r\n throughout BEFORE parsing, so " http://x.com" is a valid http URL.
143
+ // Mirror that front-strip so a value with accidental leading whitespace is
144
+ // accepted (not dropped) the same as the oracle.
145
+ const cleaned = raw.replace(/[\t\r\n]/g, '').replace(/^[\x00-\x20]+/, '');
146
+ const m = /^([a-zA-Z][a-zA-Z0-9+.-]*):(.*)$/s.exec(cleaned);
147
+ let scheme = '';
148
+ let rest = cleaned;
149
+ if (m) {
150
+ scheme = m[1].toLowerCase();
151
+ rest = m[2];
152
+ }
153
+ let netloc = '';
154
+ if (rest.startsWith('//')) {
155
+ const after = rest.slice(2);
156
+ const end = after.search(/[/?#]/);
157
+ netloc = end === -1 ? after : after.slice(0, end);
158
+ }
159
+ else if (!m) {
160
+ // No scheme parsed at all -> urlparse would leave scheme empty and netloc
161
+ // empty (the whole thing is a path).
162
+ scheme = '';
163
+ }
164
+ return { scheme, netloc };
165
+ }
166
+ function validateStrings(raw, fieldName, validate, transform, warnings) {
167
+ if (!Array.isArray(raw)) {
168
+ if (warnings !== null) {
169
+ warnings.push(`${fieldName}: expected list, got ${pyTypeName(raw)} ${EMDASH} skipped`);
170
+ }
171
+ return [];
172
+ }
173
+ const out = [];
174
+ const seen = new Set();
175
+ for (const item of raw) {
176
+ if (typeof item !== 'string')
177
+ continue;
178
+ const val = transform ? transform(item) : item;
179
+ if (looksLikeSecret(val))
180
+ throw new SecretDetected(fieldName, val);
181
+ if (!validate(val)) {
182
+ if (warnings !== null) {
183
+ warnings.push(`${fieldName}: dropped invalid entry ${pyRepr(item)}`);
184
+ }
185
+ continue;
186
+ }
187
+ if (!seen.has(val)) {
188
+ seen.add(val);
189
+ out.push(val);
190
+ }
191
+ }
192
+ return out;
193
+ }
194
+ /** Python `repr()` of a string: single-quoted, with `'`/`\` escaped. */
195
+ function pyRepr(s) {
196
+ // Python prefers single quotes unless the string contains a single quote and
197
+ // no double quote (then it uses double quotes). Matches the common case used
198
+ // in these warnings (identifiers, URLs -- no embedded quotes).
199
+ const hasSingle = s.includes("'");
200
+ const hasDouble = s.includes('"');
201
+ if (hasSingle && !hasDouble) {
202
+ return `"${s.replace(/\\/g, '\\\\')}"`;
203
+ }
204
+ return `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
205
+ }
206
+ function validateUrl(raw, fieldName, warnings) {
207
+ if (typeof raw !== 'string') {
208
+ warnings.push(`${fieldName}: expected string, got ${pyTypeName(raw)} ${EMDASH} skipped`);
209
+ return '';
210
+ }
211
+ if (looksLikeSecret(raw))
212
+ throw new SecretDetected(fieldName, raw);
213
+ const { scheme, netloc } = parseSchemeNetloc(raw);
214
+ if ((scheme !== 'http' && scheme !== 'https') || !netloc) {
215
+ warnings.push(`${fieldName}: dropped invalid URL ${pyRepr(raw)}`);
216
+ return '';
217
+ }
218
+ return raw;
219
+ }
220
+ const _OTEL_SCHEMES = ['http', 'https', 'grpc', 'grpcs'];
221
+ function validateOtelEndpoint(raw, fieldName, warnings) {
222
+ if (typeof raw !== 'string') {
223
+ warnings.push(`${fieldName}: expected string, got ${pyTypeName(raw)} ${EMDASH} skipped`);
224
+ return '';
225
+ }
226
+ if (!raw.trim())
227
+ return '';
228
+ if (looksLikeSecret(raw))
229
+ throw new SecretDetected(fieldName, raw);
230
+ const { scheme, netloc } = parseSchemeNetloc(raw);
231
+ if (!_OTEL_SCHEMES.includes(scheme) || !netloc) {
232
+ warnings.push(`${fieldName}: dropped invalid endpoint ${pyRepr(raw)}`);
233
+ return '';
234
+ }
235
+ return raw;
236
+ }
237
+ /** Accept #RGB / #RRGGBB (any case); drop anything else with a warning. */
238
+ function validateHexColor(raw, fieldName, warnings) {
239
+ if (typeof raw !== 'string' || !raw)
240
+ return '';
241
+ if (_HEX_COLOR_RE.test(raw))
242
+ return raw;
243
+ warnings.push(`${fieldName}: dropped invalid hex color ${pyRepr(raw)}`);
244
+ return '';
245
+ }
246
+ /** A short free-text brand field. Rejects secret-prefixed values; caps length. */
247
+ function brandText(raw, fieldName, warnings) {
248
+ if (typeof raw !== 'string') {
249
+ if (raw !== null && raw !== undefined) {
250
+ warnings.push(`${fieldName}: expected string, got ${pyTypeName(raw)} ${EMDASH} skipped`);
251
+ }
252
+ return '';
253
+ }
254
+ if (_SECRET_PREFIX.test(raw))
255
+ throw new SecretDetected(fieldName, raw);
256
+ return codePointSlice(raw.trim(), _BRAND_TEXT_MAX);
257
+ }
258
+ // ---------------------------------------------------------------------------
259
+ // brand assets (customer-facing report theming, #340c)
260
+ // ---------------------------------------------------------------------------
261
+ const _BRAND_COLOR_KEYS = new Set([
262
+ 'primary_color',
263
+ 'secondary_color',
264
+ 'text_color',
265
+ 'background_color',
266
+ 'badge_label_color',
267
+ 'badge_accent',
268
+ ]);
269
+ const _BRAND_TEXT_KEYS = new Set(['company_name', 'footer_note', 'logo_path']);
270
+ /**
271
+ * Brand assets a customer-facing report generator may consult (Python: `Brand`).
272
+ *
273
+ * An **open** map, not a fixed record: recognized keys are validated/typed and
274
+ * any other key is passed through. Pointers/styling only -- colors, logo
275
+ * paths/URLs, text -- never binary assets or secrets.
276
+ */
277
+ export class Brand {
278
+ assets;
279
+ constructor(assets = {}) {
280
+ this.assets = assets;
281
+ }
282
+ get isEmpty() {
283
+ return !pyTruthy(this.assets);
284
+ }
285
+ toDict() {
286
+ return { ...this.assets };
287
+ }
288
+ }
289
+ function looksLikeColor(val) {
290
+ return val.startsWith('#');
291
+ }
292
+ function looksLikeUrl(val) {
293
+ return val.includes('://');
294
+ }
295
+ /** A list of hex colors; invalid entries dropped with a warning. */
296
+ function cleanAccents(raw, warnings) {
297
+ if (!Array.isArray(raw)) {
298
+ warnings.push('brand.accents: expected a list ' + EMDASH + ' skipped');
299
+ return [];
300
+ }
301
+ const out = [];
302
+ raw.forEach((item, i) => {
303
+ const color = validateHexColor(item, `brand.accents[${i}]`, warnings);
304
+ if (color)
305
+ out.push(color);
306
+ });
307
+ return out;
308
+ }
309
+ /** A name->path map of logo variants (paths kept as strings). */
310
+ function cleanVariants(raw, warnings) {
311
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
312
+ warnings.push('brand.logo_variants: expected an object ' + EMDASH + ' skipped');
313
+ return {};
314
+ }
315
+ const out = {};
316
+ for (const [name, path] of Object.entries(raw)) {
317
+ const text = brandText(path, `brand.logo_variants.${name}`, warnings);
318
+ if (text)
319
+ out[String(name)] = text;
320
+ }
321
+ return out;
322
+ }
323
+ /**
324
+ * An unrecognized brand key: validate as a color/URL when it looks like one,
325
+ * else keep the string (secret-rejected, length-capped).
326
+ */
327
+ function cleanBrandExtra(val, fieldName, warnings) {
328
+ if (typeof val !== 'string') {
329
+ warnings.push(`${fieldName}: expected string ${EMDASH} skipped`);
330
+ return '';
331
+ }
332
+ if (looksLikeColor(val))
333
+ return validateHexColor(val, fieldName, warnings);
334
+ if (looksLikeUrl(val))
335
+ return validateUrl(val, fieldName, warnings);
336
+ return brandText(val, fieldName, warnings);
337
+ }
338
+ function cleanBrandValue(key, val, warnings) {
339
+ const fieldName = `brand.${key}`;
340
+ if (_BRAND_COLOR_KEYS.has(key))
341
+ return validateHexColor(val, fieldName, warnings);
342
+ if (key === 'accents')
343
+ return cleanAccents(val, warnings);
344
+ if (key === 'logo_variants')
345
+ return cleanVariants(val, warnings);
346
+ if (key === 'logo_url') {
347
+ return typeof val === 'string' ? validateUrl(val, fieldName, warnings) : '';
348
+ }
349
+ if (_BRAND_TEXT_KEYS.has(key))
350
+ return brandText(val, fieldName, warnings);
351
+ return cleanBrandExtra(val, fieldName, warnings);
352
+ }
353
+ /** Whether a cleaned brand value should be omitted (Python `not in (None,"",[],{})`). */
354
+ function omittedBrandValue(v) {
355
+ if (v === null || v === undefined || v === '')
356
+ return true;
357
+ if (Array.isArray(v))
358
+ return v.length === 0;
359
+ if (typeof v === 'object')
360
+ return Object.keys(v).length === 0;
361
+ return false;
362
+ }
363
+ function parseBrand(raw, warnings) {
364
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
365
+ if (raw !== null && raw !== undefined) {
366
+ warnings.push(`brand: expected object, got ${pyTypeName(raw)} ${EMDASH} skipped`);
367
+ }
368
+ return new Brand();
369
+ }
370
+ const assets = {};
371
+ for (const [key, val] of Object.entries(raw)) {
372
+ const cleaned = cleanBrandValue(String(key), val, warnings);
373
+ if (!omittedBrandValue(cleaned))
374
+ assets[String(key)] = cleaned;
375
+ }
376
+ return new Brand(assets);
377
+ }
378
+ function mergeBrand(layers) {
379
+ const merged = {};
380
+ for (const layer of layers)
381
+ Object.assign(merged, layer.brand.assets);
382
+ return new Brand(merged);
383
+ }
384
+ /** `dict.get(key, default)`: default only when the key is absent. */
385
+ function dictGet(data, key, fallback) {
386
+ return Object.prototype.hasOwnProperty.call(data, key) ? data[key] : fallback;
387
+ }
388
+ /** Validate a raw JSON dict into a Layer. Throws SecretDetected on secrets. */
389
+ function parseLayer(data, source) {
390
+ const warns = [];
391
+ const confluence_spaces = validateStrings(dictGet(data, 'confluence_spaces', []), 'confluence_spaces', (v) => _SPACE_OR_PROJECT_RE.test(v), (v) => v.toUpperCase(), warns);
392
+ const jira_projects = validateStrings(dictGet(data, 'jira_projects', []), 'jira_projects', (v) => _SPACE_OR_PROJECT_RE.test(v), (v) => v.toUpperCase(), warns);
393
+ const rawUrls = dictGet(data, 'internal_doc_urls', []);
394
+ const internal_doc_urls = [];
395
+ if (Array.isArray(rawUrls)) {
396
+ for (const u of rawUrls) {
397
+ if (typeof u !== 'string')
398
+ continue;
399
+ const validated = validateUrl(u, 'internal_doc_urls', warns);
400
+ if (validated)
401
+ internal_doc_urls.push(validated);
402
+ }
403
+ }
404
+ const internal_domains = validateStrings(dictGet(data, 'internal_domains', []), 'internal_domains', (v) => _DOMAIN_RE.test(v), (v) => v.toLowerCase(), warns);
405
+ const mcp_servers = validateStrings(dictGet(data, 'mcp_servers', []), 'mcp_servers', (v) => _MCP_SERVER_RE.test(v), null, warns);
406
+ const claude_code_skills = validateStrings(dictGet(data, 'claude_code_skills', []), 'claude_code_skills', (v) => _SKILL_RE.test(v), (v) => v.toLowerCase(), warns);
407
+ let dashboard_url = '';
408
+ if (Object.prototype.hasOwnProperty.call(data, 'dashboard_url')) {
409
+ dashboard_url = validateUrl(data['dashboard_url'], 'dashboard_url', warns);
410
+ }
411
+ let dashboard_token_env = '';
412
+ if (Object.prototype.hasOwnProperty.call(data, 'dashboard_token_env')) {
413
+ const rawEnv = data['dashboard_token_env'];
414
+ if (typeof rawEnv === 'string') {
415
+ if (looksLikeSecret(rawEnv))
416
+ throw new SecretDetected('dashboard_token_env', rawEnv);
417
+ if (_ENV_VAR_RE.test(rawEnv)) {
418
+ dashboard_token_env = rawEnv;
419
+ }
420
+ else {
421
+ warns.push(`dashboard_token_env: dropped invalid env-var name ${pyRepr(rawEnv)}`);
422
+ }
423
+ }
424
+ }
425
+ let otel_exporter_endpoint = '';
426
+ if (Object.prototype.hasOwnProperty.call(data, 'otel_exporter_endpoint')) {
427
+ otel_exporter_endpoint = validateOtelEndpoint(data['otel_exporter_endpoint'], 'otel_exporter_endpoint', warns);
428
+ }
429
+ let notes = '';
430
+ if (Object.prototype.hasOwnProperty.call(data, 'notes')) {
431
+ const rawNotes = data['notes'];
432
+ if (typeof rawNotes === 'string') {
433
+ notes = codePointSlice(rawNotes.replace(_FENCE_RE, '').trim(), _NOTES_MAX);
434
+ }
435
+ }
436
+ const brand = parseBrand(dictGet(data, 'brand', undefined), warns);
437
+ const unknown = [...Object.keys(data)]
438
+ .filter((k) => !_KNOWN_KEYS.has(k))
439
+ .sort();
440
+ for (const k of unknown)
441
+ warns.push(`ignored unknown field: ${k}`);
442
+ return {
443
+ confluence_spaces,
444
+ jira_projects,
445
+ internal_doc_urls,
446
+ internal_domains,
447
+ mcp_servers,
448
+ claude_code_skills,
449
+ dashboard_url,
450
+ dashboard_token_env,
451
+ otel_exporter_endpoint,
452
+ notes,
453
+ brand,
454
+ warnings: warns,
455
+ source,
456
+ };
457
+ }
458
+ /** Read and parse one source file. Returns `[layer, errorMsg]`. */
459
+ function loadLayer(path, label) {
460
+ // `read_json_with_warning` distinguishes absent from malformed and never
461
+ // raises; it returns `[data, warning]`. Python read the file directly with
462
+ // `json.loads` and mapped OSError / JSONDecodeError to an error message. We
463
+ // reuse the shared reader: an absent file -> `[null, null]` -> silent skip;
464
+ // a malformed file -> `[null, warning]` -> that warning as the error message.
465
+ const [data, warning] = readJsonWithWarning(path);
466
+ if (data === null) {
467
+ if (warning === null) {
468
+ // read_json_with_warning collapses BOTH an absent file and a file whose
469
+ // content is the bare literal `null` to [null, null]. Python's raw
470
+ // json.loads path distinguishes them: an absent file -> ("") silent skip;
471
+ // a present `null` -> `not isinstance(dict)` -> "expected JSON object at
472
+ // root". Re-derive that split with an existence check.
473
+ if (existsSync(path)) {
474
+ return [null, `${label}: expected JSON object at root`];
475
+ }
476
+ return [null, ''];
477
+ }
478
+ return [null, `${label}: ${warning}`];
479
+ }
480
+ if (Array.isArray(data) || typeof data !== 'object') {
481
+ return [null, `${label}: expected JSON object at root`];
482
+ }
483
+ try {
484
+ return [parseLayer(data, label), ''];
485
+ }
486
+ catch (exc) {
487
+ if (exc instanceof SecretDetected) {
488
+ const msg = `[red]![/red] ${label} contains a secret-like value in ` +
489
+ `${pyRepr(exc.fieldName)} ${EMDASH} remove it and store secrets in environment variables`;
490
+ return [null, msg];
491
+ }
492
+ throw exc;
493
+ }
494
+ }
495
+ function union(a, b) {
496
+ const seen = new Set(a);
497
+ const out = [...a];
498
+ for (const v of b) {
499
+ if (!seen.has(v)) {
500
+ seen.add(v);
501
+ out.push(v);
502
+ }
503
+ }
504
+ return out;
505
+ }
506
+ 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 = [];
518
+ const sources = [];
519
+ 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);
535
+ if (layer.source)
536
+ sources.push(layer.source);
537
+ }
538
+ 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,
549
+ brand: mergeBrand(layers),
550
+ warnings: warns,
551
+ sources,
552
+ };
553
+ }
554
+ // ---------------------------------------------------------------------------
555
+ // internal helpers (flavor / attribution)
556
+ // ---------------------------------------------------------------------------
557
+ const _ATTRIBUTION = 'made with Canary';
558
+ // Voice is garnish, never load-bearing (#340). One tasteful Oracle line.
559
+ const _VOICE_LINE = 'Oracle: eyes on every test.';
560
+ const _FLAVOR_OFF_ENV = ['CANARY_NO_FLAVOR', 'NO_FLAVOR'];
561
+ const _FALSEY = new Set(['', '0', 'false', 'no', 'off']);
562
+ function envTruthy(val) {
563
+ return val !== undefined && !_FALSEY.has(val.trim().toLowerCase());
564
+ }
565
+ function resolveFlavor(flavor) {
566
+ if (flavor !== null && flavor !== undefined)
567
+ return flavor;
568
+ if (_FLAVOR_OFF_ENV.some((v) => envTruthy(process.env[v])))
569
+ return false;
570
+ return true;
571
+ }
572
+ export class CompanyKnowledge {
573
+ confluence_spaces;
574
+ jira_projects;
575
+ internal_doc_urls;
576
+ internal_domains;
577
+ mcp_servers;
578
+ claude_code_skills;
579
+ dashboard_url;
580
+ dashboard_token_env;
581
+ otel_exporter_endpoint;
582
+ notes;
583
+ brand;
584
+ warnings;
585
+ sources;
586
+ error;
587
+ constructor(init = {}) {
588
+ this.confluence_spaces = init.confluence_spaces ?? [];
589
+ this.jira_projects = init.jira_projects ?? [];
590
+ this.internal_doc_urls = init.internal_doc_urls ?? [];
591
+ this.internal_domains = init.internal_domains ?? [];
592
+ this.mcp_servers = init.mcp_servers ?? [];
593
+ this.claude_code_skills = init.claude_code_skills ?? [];
594
+ this.dashboard_url = init.dashboard_url ?? '';
595
+ this.dashboard_token_env = init.dashboard_token_env ?? '';
596
+ this.otel_exporter_endpoint = init.otel_exporter_endpoint ?? '';
597
+ this.notes = init.notes ?? '';
598
+ this.brand = init.brand ?? new Brand();
599
+ this.warnings = init.warnings ?? [];
600
+ this.sources = init.sources ?? [];
601
+ this.error = init.error ?? '';
602
+ }
603
+ get isEmpty() {
604
+ // Python `not any([... , not self.brand.is_empty])`.
605
+ const signals = [
606
+ this.confluence_spaces,
607
+ this.jira_projects,
608
+ this.internal_doc_urls,
609
+ this.internal_domains,
610
+ this.mcp_servers,
611
+ this.claude_code_skills,
612
+ this.dashboard_url,
613
+ this.notes,
614
+ !this.brand.isEmpty,
615
+ ];
616
+ return !signals.some((x) => pyTruthy(x));
617
+ }
618
+ // -- factory --------------------------------------------------------------
619
+ /**
620
+ * Load and merge the company-knowledge cascade.
621
+ *
622
+ * Sources (lowest -> highest priority):
623
+ * 1. ~/.canary/company.json -- org-wide defaults
624
+ * 2. <root>/.canary/company.json -- project-local
625
+ * 3. <root>/.canary/company.<env>.json -- environment override
626
+ *
627
+ * `env` defaults to the `CANARY_ENV` environment variable when not passed
628
+ * explicitly. If neither is set, the env layer is skipped.
629
+ *
630
+ * `home` is a TS-only test seam (Python patches `Path.home()`); it defaults
631
+ * to `os.homedir()`.
632
+ *
633
+ * Returns an empty instance when no source files exist. Returns an instance
634
+ * with `.error` set when a secret is detected in any layer (that layer is
635
+ * skipped; earlier layers are still merged).
636
+ */
637
+ static load(root, env, home) {
638
+ const base = root ?? process.cwd();
639
+ const resolvedEnv = env ?? process.env['CANARY_ENV'] ?? '';
640
+ const homeDir = home ?? homedir();
641
+ const candidates = [
642
+ [join(homeDir, '.canary', 'company.json'), '~/.canary/company.json'],
643
+ [join(base, '.canary', 'company.json'), '.canary/company.json'],
644
+ ];
645
+ if (resolvedEnv) {
646
+ candidates.push([
647
+ join(base, '.canary', `company.${resolvedEnv}.json`),
648
+ `.canary/company.${resolvedEnv}.json`,
649
+ ]);
650
+ }
651
+ const layers = [];
652
+ const errors = [];
653
+ for (const [path, label] of candidates) {
654
+ const [layer, err] = loadLayer(path, label);
655
+ if (err) {
656
+ errors.push(err);
657
+ continue;
658
+ }
659
+ if (layer !== null)
660
+ layers.push(layer);
661
+ }
662
+ if (layers.length === 0) {
663
+ const instance = new CompanyKnowledge();
664
+ if (errors.length > 0)
665
+ instance.error = errors[0];
666
+ return instance;
667
+ }
668
+ const merged = mergeLayers(layers);
669
+ const instance = new CompanyKnowledge(merged);
670
+ if (errors.length > 0)
671
+ instance.error = errors[0];
672
+ return instance;
673
+ }
674
+ // -- prompt injection -----------------------------------------------------
675
+ /**
676
+ * Return the '--- COMPANY KNOWLEDGE ---' section for prompt injection.
677
+ *
678
+ * Returns an empty string when isEmpty is true.
679
+ */
680
+ promptBlock() {
681
+ if (this.isEmpty)
682
+ return '';
683
+ const lines = [
684
+ '--- COMPANY KNOWLEDGE ---',
685
+ 'Consult these company-internal sources when generating:',
686
+ ];
687
+ let mcpHint = '';
688
+ if (pyTruthy(this.mcp_servers)) {
689
+ mcpHint = ` (via ${this.mcp_servers.join(', ')} MCP)`;
690
+ }
691
+ if (pyTruthy(this.confluence_spaces)) {
692
+ lines.push(`- Confluence spaces${mcpHint}: ${this.confluence_spaces.join(', ')}`);
693
+ }
694
+ if (pyTruthy(this.jira_projects)) {
695
+ lines.push(`- Jira projects${mcpHint}: ${this.jira_projects.join(', ')}`);
696
+ }
697
+ if (pyTruthy(this.internal_doc_urls)) {
698
+ lines.push('- Reference docs (fetch via MCP / authenticated tool):');
699
+ for (const url of this.internal_doc_urls)
700
+ lines.push(` - ${url}`);
701
+ }
702
+ if (pyTruthy(this.internal_domains)) {
703
+ lines.push(`- Internal domains: ${this.internal_domains.join(', ')}`);
704
+ }
705
+ if (pyTruthy(this.claude_code_skills)) {
706
+ const skillList = this.claude_code_skills.map((s) => `/${s}`).join(', ');
707
+ lines.push(`- Claude Code skills available for this project: ${skillList}. ` +
708
+ 'Invoke the relevant skill when its scope matches the task.');
709
+ }
710
+ if (pyTruthy(this.notes)) {
711
+ lines.push(`- Notes from the project owner: ${this.notes}`);
712
+ }
713
+ lines.push('Do not invent internal URLs, project keys, or hostnames. If a piece of\n' +
714
+ "context isn't covered above, say so in a comment rather than guessing.");
715
+ return lines.join('\n');
716
+ }
717
+ // -- serialisation --------------------------------------------------------
718
+ toDict() {
719
+ const out = {
720
+ is_empty: this.isEmpty,
721
+ confluence_spaces: this.confluence_spaces,
722
+ jira_projects: this.jira_projects,
723
+ internal_doc_urls: this.internal_doc_urls,
724
+ internal_domains: this.internal_domains,
725
+ mcp_servers: this.mcp_servers,
726
+ claude_code_skills: this.claude_code_skills,
727
+ dashboard_url: this.dashboard_url,
728
+ dashboard_token_env: this.dashboard_token_env,
729
+ otel_exporter_endpoint: this.otel_exporter_endpoint,
730
+ notes: this.notes,
731
+ brand: this.brand.toDict(),
732
+ sources: this.sources,
733
+ warnings: this.warnings,
734
+ };
735
+ if (pyTruthy(this.error))
736
+ out['error'] = this.error;
737
+ return out;
738
+ }
739
+ // -- report branding hook (#340c) -----------------------------------------
740
+ /**
741
+ * Brand assets + attribution for a customer-facing report generator.
742
+ *
743
+ * Returns every brand asset that is present. When `logo_path` is set,
744
+ * `logo_path_resolved` is added, resolved against the consuming repo (cwd).
745
+ * `attribution` is always present. `voice_line` is optional garnish, included
746
+ * only when *flavor* is on (explicit arg wins; else a truthy
747
+ * CANARY_NO_FLAVOR / NO_FLAVOR turns it off; default on).
748
+ */
749
+ reportBranding(flavor) {
750
+ const on = resolveFlavor(flavor);
751
+ const out = { ...this.brand.assets };
752
+ const logoPath = out['logo_path'];
753
+ if (typeof logoPath === 'string' && logoPath) {
754
+ const cwd = process.cwd();
755
+ out['logo_path_resolved'] = isAbsolute(logoPath)
756
+ ? logoPath
757
+ : join(cwd, logoPath);
758
+ }
759
+ out['attribution'] = _ATTRIBUTION;
760
+ out['voice_line'] = on ? _VOICE_LINE : '';
761
+ out['flavor'] = on;
762
+ return out;
763
+ }
764
+ }
765
+ //# sourceMappingURL=company-knowledge.js.map