canary-test-cli 5.11.0 → 5.12.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.
@@ -1,4 +1,4 @@
1
- "use strict";
1
+ 'use strict';
2
2
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
3
  if (k2 === undefined) k2 = k;
4
4
  var desc = Object.getOwnPropertyDescriptor(m, k);
@@ -39,6 +39,7 @@ exports.skillCount = skillCount;
39
39
  exports.workingTreeStatus = workingTreeStatus;
40
40
  exports.freshness = freshness;
41
41
  exports.list = list;
42
+ exports.lint = lint;
42
43
  exports.update = update;
43
44
  exports.remove = remove;
44
45
  const node_child_process_1 = require("node:child_process");
@@ -48,13 +49,23 @@ const path = __importStar(require("node:path"));
48
49
  const source_spec_js_1 = require("./source-spec.js");
49
50
  const registry = __importStar(require("./overlays-registry.js"));
50
51
  const doctor_manifest_js_1 = require("./doctor-manifest.js");
52
+ const overlay_conflicts_js_1 = require("./overlay-conflicts.js");
53
+ const overlay_lint_js_1 = require("./overlay-lint.js");
51
54
  const defaultGit = (args, opts = {}) => {
52
- const r = (0, node_child_process_1.spawnSync)("git", args, { cwd: opts.cwd, encoding: "utf8" });
55
+ const r = (0, node_child_process_1.spawnSync)('git', args, { cwd: opts.cwd, encoding: 'utf8' });
53
56
  if (r.error) {
54
57
  const code = r.error.code;
55
- return { status: code === "ENOENT" ? 127 : 1, stdout: "", stderr: String(r.error.message) };
58
+ return {
59
+ status: code === 'ENOENT' ? 127 : 1,
60
+ stdout: '',
61
+ stderr: String(r.error.message),
62
+ };
56
63
  }
57
- return { status: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
64
+ return {
65
+ status: r.status ?? 1,
66
+ stdout: r.stdout ?? '',
67
+ stderr: r.stderr ?? '',
68
+ };
58
69
  };
59
70
  function today() {
60
71
  return new Date().toISOString().slice(0, 10);
@@ -68,8 +79,8 @@ function defaultConfirm(question) {
68
79
  const buf = Buffer.alloc(256);
69
80
  try {
70
81
  const n = fs.readSync(0, buf, 0, buf.length, null);
71
- const answer = buf.toString("utf8", 0, n).trim().toLowerCase();
72
- return answer === "y" || answer === "yes";
82
+ const answer = buf.toString('utf8', 0, n).trim().toLowerCase();
83
+ return answer === 'y' || answer === 'yes';
73
84
  }
74
85
  catch {
75
86
  return false;
@@ -90,10 +101,10 @@ function collectConsent(dest, name, confirm, out) {
90
101
  if (hash === null) {
91
102
  return { consent: null, consentCommandsHash: null };
92
103
  }
93
- const cmds = load.checks.filter((c) => c.type === "command-succeeds");
104
+ const cmds = load.checks.filter((c) => c.type === 'command-succeeds');
94
105
  out.write(`Overlay "${name}" ships ${cmds.length} command check(s) that 'canary doctor' can run:\n`);
95
106
  for (const c of cmds) {
96
- out.write(` - ${c.id}: ${(c.command ?? []).join(" ")}\n`);
107
+ out.write(` - ${c.id}: ${(c.command ?? []).join(' ')}\n`);
97
108
  }
98
109
  const granted = confirm(`Allow 'canary doctor' to run these commands for "${name}"? [y/N] `);
99
110
  if (!granted) {
@@ -103,29 +114,43 @@ function collectConsent(dest, name, confirm, out) {
103
114
  }
104
115
  /** Ordered stderr-substring signatures for `git clone` failure classification. */
105
116
  const CLONE_FAILURE_SIGNATURES = [
106
- { reason: "git not found on PATH", needles: ["enoent", "not found: git"] },
117
+ { reason: 'git not found on PATH', needles: ['enoent', 'not found: git'] },
118
+ {
119
+ reason: 'network unreachable',
120
+ needles: [
121
+ 'could not resolve host',
122
+ 'network is unreachable',
123
+ 'failed to connect',
124
+ 'timed out',
125
+ ],
126
+ },
107
127
  {
108
- reason: "network unreachable",
109
- needles: ["could not resolve host", "network is unreachable", "failed to connect", "timed out"],
128
+ reason: 'authentication denied',
129
+ needles: [
130
+ 'authentication failed',
131
+ 'permission denied',
132
+ 'could not read username',
133
+ 'access denied',
134
+ '403 forbidden',
135
+ ],
110
136
  },
111
137
  {
112
- reason: "authentication denied",
113
- needles: ["authentication failed", "permission denied", "could not read username", "access denied", "403 forbidden"],
138
+ reason: 'repository not found',
139
+ needles: ['repository not found', 'does not exist', 'not found'],
114
140
  },
115
- { reason: "repository not found", needles: ["repository not found", "does not exist", "not found"] },
116
141
  ];
117
142
  /** Best-effort classification of a failed `git clone`, for a useful remedy. */
118
143
  function classifyCloneFailure(res) {
119
144
  const s = res.stderr.toLowerCase();
120
145
  if (res.status === 127) {
121
- return "git not found on PATH";
146
+ return 'git not found on PATH';
122
147
  }
123
148
  for (const { reason, needles } of CLONE_FAILURE_SIGNATURES) {
124
149
  if (needles.some((needle) => s.includes(needle))) {
125
150
  return reason;
126
151
  }
127
152
  }
128
- return "unknown error";
153
+ return 'unknown error';
129
154
  }
130
155
  /**
131
156
  * `canary overlay add <source> [--ref <tag>]` — clone a tracked overlay into
@@ -169,9 +194,9 @@ function add(source, options = {}, deps = {}) {
169
194
  return 1;
170
195
  }
171
196
  fs.mkdirSync(registry.overlaysDir(homeDir), { recursive: true });
172
- const args = ["clone", "--quiet"];
197
+ const args = ['clone', '--quiet'];
173
198
  if (ref) {
174
- args.push("--branch", ref);
199
+ args.push('--branch', ref);
175
200
  }
176
201
  args.push(parsed.cloneUrl, dest);
177
202
  const res = git(args);
@@ -179,7 +204,7 @@ function add(source, options = {}, deps = {}) {
179
204
  fs.rmSync(dest, { recursive: true, force: true }); // never leave a partial clone
180
205
  const reason = classifyCloneFailure(res);
181
206
  err.write(`canary overlay add: clone failed (${reason}).\n` +
182
- (res.stderr.trim() ? `${res.stderr.trim()}\n` : "") +
207
+ (res.stderr.trim() ? `${res.stderr.trim()}\n` : '') +
183
208
  `Nothing was registered. Check the overlay's access docs and your git credentials.\n`);
184
209
  return 1;
185
210
  }
@@ -193,6 +218,9 @@ function add(source, options = {}, deps = {}) {
193
218
  addedDate: stamp(),
194
219
  consent,
195
220
  consentCommandsHash,
221
+ // Undeclared precedence (#333). Set a number in overlays.json to declare
222
+ // which overlay wins a skill-name collision; higher wins.
223
+ precedence: null,
196
224
  };
197
225
  try {
198
226
  registry.write(registry.add(reg, entry), homeDir);
@@ -202,12 +230,12 @@ function add(source, options = {}, deps = {}) {
202
230
  err.write(`canary overlay add: ${e.message}\n`);
203
231
  return 1;
204
232
  }
205
- out.write(`Added overlay "${parsed.name}"${ref ? ` @ ${ref}` : ""} → ${dest}\n`);
233
+ out.write(`Added overlay "${parsed.name}"${ref ? ` @ ${ref}` : ''} → ${dest}\n`);
206
234
  return 0;
207
235
  }
208
236
  /** Count `.canary/skills/<name>/SKILL.md` entries in a clone. */
209
237
  function skillCount(dest) {
210
- const skillsDir = path.join(dest, ".canary", "skills");
238
+ const skillsDir = path.join(dest, '.canary', 'skills');
211
239
  let entries;
212
240
  try {
213
241
  entries = fs.readdirSync(skillsDir, { withFileTypes: true });
@@ -215,7 +243,8 @@ function skillCount(dest) {
215
243
  catch {
216
244
  return 0;
217
245
  }
218
- return entries.filter((d) => d.isDirectory() && fs.existsSync(path.join(skillsDir, d.name, "SKILL.md"))).length;
246
+ return entries.filter((d) => d.isDirectory() &&
247
+ fs.existsSync(path.join(skillsDir, d.name, 'SKILL.md'))).length;
219
248
  }
220
249
  /**
221
250
  * Whether a clone's working tree is clean, dirty (local modifications), or its
@@ -223,11 +252,11 @@ function skillCount(dest) {
223
252
  * the `doctor` engine check ("no local overlay modifications").
224
253
  */
225
254
  function workingTreeStatus(dest, git) {
226
- const status = git(["status", "--porcelain"], { cwd: dest });
255
+ const status = git(['status', '--porcelain'], { cwd: dest });
227
256
  if (status.status !== 0) {
228
- return "unreadable";
257
+ return 'unreadable';
229
258
  }
230
- return status.stdout.trim() === "" ? "clean" : "dirty";
259
+ return status.stdout.trim() === '' ? 'clean' : 'dirty';
231
260
  }
232
261
  /**
233
262
  * Freshness of a clone against its LOCAL knowledge of the upstream — no fetch
@@ -238,22 +267,20 @@ function freshness(dest, entry, git) {
238
267
  if (!fs.existsSync(dest)) {
239
268
  return "missing — clone not found (run 'canary overlay update' or re-add)";
240
269
  }
241
- const behind = git(["rev-list", "--count", "HEAD..@{u}"], { cwd: dest });
270
+ const behind = git(['rev-list', '--count', 'HEAD..@{u}'], { cwd: dest });
242
271
  if (behind.status !== 0) {
243
272
  // No upstream tracking ref — typically a pinned tag/detached HEAD.
244
- return entry.ref ? `pinned @ ${entry.ref}` : "unknown (no upstream tracking ref)";
273
+ return entry.ref
274
+ ? `pinned @ ${entry.ref}`
275
+ : 'unknown (no upstream tracking ref)';
245
276
  }
246
277
  const n = Number.parseInt(behind.stdout.trim(), 10);
247
278
  if (!Number.isFinite(n) || n === 0) {
248
- return "up to date";
279
+ return 'up to date';
249
280
  }
250
- return `${n} commit${n === 1 ? "" : "s"} behind`;
281
+ return `${n} commit${n === 1 ? '' : 's'} behind`;
251
282
  }
252
- /**
253
- * `canary overlay list` — one block per registered overlay: name, source, ref,
254
- * freshness, and skill count.
255
- */
256
- function list(deps = {}) {
283
+ function list(deps = {}, opts = {}) {
257
284
  const git = deps.git ?? defaultGit;
258
285
  const homeDir = deps.homeDir ?? os.homedir();
259
286
  const out = deps.out ?? process.stdout;
@@ -266,6 +293,9 @@ function list(deps = {}) {
266
293
  err.write(`canary overlay list: ${e.message}\n`);
267
294
  return 1;
268
295
  }
296
+ if (opts.conflicts) {
297
+ return listConflicts(reg, out);
298
+ }
269
299
  if (reg.overlays.length === 0) {
270
300
  out.write("No overlays added. Add one with 'canary overlay add <source>'.\n");
271
301
  return 0;
@@ -273,12 +303,93 @@ function list(deps = {}) {
273
303
  for (const o of reg.overlays) {
274
304
  out.write(`${o.name}\n`);
275
305
  out.write(` source: ${o.source}\n`);
276
- out.write(` ref: ${o.ref ?? "(default branch)"}\n`);
306
+ out.write(` ref: ${o.ref ?? '(default branch)'}\n`);
277
307
  out.write(` status: ${freshness(o.path, o, git)}\n`);
278
308
  out.write(` skills: ${skillCount(o.path)}\n`);
279
309
  }
280
310
  return 0;
281
311
  }
312
+ /**
313
+ * Print the skill-name conflict report (`overlay list --conflicts`, #333).
314
+ * Returns 0 when there are no *unresolved* conflicts, 1 when at least one skill
315
+ * name collides with no declared precedence winner — so the flag doubles as a
316
+ * scriptable gate.
317
+ */
318
+ function listConflicts(reg, out) {
319
+ const conflicts = (0, overlay_conflicts_js_1.detectSkillConflicts)(reg);
320
+ if (conflicts.length === 0) {
321
+ out.write('No skill-name conflicts across registered overlays.\n');
322
+ return 0;
323
+ }
324
+ let unresolved = 0;
325
+ for (const c of conflicts) {
326
+ const sources = c.contenders
327
+ .map((ct) => `${ct.overlay} (precedence ${ct.precedence})`)
328
+ .join(', ');
329
+ if (c.resolved) {
330
+ out.write(`✓ ${c.skill}: ${sources}\n → resolved: ${c.winner} wins\n`);
331
+ }
332
+ else {
333
+ unresolved += 1;
334
+ out.write(`✗ ${c.skill}: ${sources}\n → UNRESOLVED (tie) — set a higher ` +
335
+ `precedence on the overlay you want to win in overlays.json\n`);
336
+ }
337
+ }
338
+ out.write(`\n${conflicts.length} conflict(s), ${unresolved} unresolved.\n`);
339
+ return unresolved === 0 ? 0 : 1;
340
+ }
341
+ /** Resolve an overlay `<name|path>` to a clone directory, or null if absent. */
342
+ function resolveOverlayDir(nameOrPath, homeDir) {
343
+ // A value with a path separator, or `.`/`..`/a dot-relative form, is a path;
344
+ // a bare token is a tracked overlay name (mirrors agent/core/overlays.py's
345
+ // disambiguation, extended so the natural CI form `overlay lint .` works).
346
+ const looksLikePath = nameOrPath.includes('/') ||
347
+ nameOrPath.includes(path.sep) ||
348
+ nameOrPath === '.' ||
349
+ nameOrPath === '..' ||
350
+ nameOrPath.startsWith('.');
351
+ const dir = looksLikePath
352
+ ? path.resolve(nameOrPath)
353
+ : registry.clonePath(nameOrPath, homeDir);
354
+ return fs.existsSync(dir) ? dir : null;
355
+ }
356
+ /**
357
+ * `canary overlay lint <name|path>` (#332) — validate an overlay against the
358
+ * authoring contract. Exits 0 when there are no errors (warnings are advisory),
359
+ * 1 on any error or an unresolvable target.
360
+ */
361
+ function lint(nameOrPath, deps = {}, opts = {}) {
362
+ const homeDir = deps.homeDir ?? os.homedir();
363
+ const out = deps.out ?? process.stdout;
364
+ const err = deps.err ?? process.stderr;
365
+ if (!nameOrPath) {
366
+ err.write('usage: canary overlay lint <name|path> [--json]\n');
367
+ return 1;
368
+ }
369
+ const dir = resolveOverlayDir(nameOrPath, homeDir);
370
+ if (dir === null) {
371
+ err.write(`canary overlay lint: no overlay found for "${nameOrPath}" (not a tracked name or an existing path).\n`);
372
+ return 1;
373
+ }
374
+ const result = (0, overlay_lint_js_1.lintOverlay)(dir);
375
+ const errors = result.findings.filter((f) => f.level === 'error');
376
+ if (opts.json) {
377
+ out.write(`${JSON.stringify(result, null, 2)}\n`);
378
+ return errors.length === 0 ? 0 : 1;
379
+ }
380
+ const symbol = (f) => (f.level === 'error' ? '✗' : '⚠');
381
+ out.write(`canary overlay lint: ${nameOrPath}\n`);
382
+ if (result.findings.length === 0) {
383
+ out.write(`\n✓ ${result.skillsChecked} skill(s) — no issues.\n`);
384
+ return 0;
385
+ }
386
+ for (const f of result.findings) {
387
+ out.write(` ${symbol(f)} ${f.skill}: ${f.message}\n`);
388
+ }
389
+ const warnings = result.findings.length - errors.length;
390
+ out.write(`\n${result.skillsChecked} skill(s) checked — ${errors.length} error(s), ${warnings} warning(s).\n`);
391
+ return errors.length === 0 ? 0 : 1;
392
+ }
282
393
  /** Update one overlay clone. Returns 0 on success, 1 on refusal/failure. */
283
394
  function updateOne(o, git, out, err) {
284
395
  if (!fs.existsSync(o.path)) {
@@ -286,23 +397,25 @@ function updateOne(o, git, out, err) {
286
397
  return 1;
287
398
  }
288
399
  const clean = workingTreeStatus(o.path, git);
289
- if (clean === "unreadable") {
400
+ if (clean === 'unreadable') {
290
401
  err.write(`overlay "${o.name}": cannot read git status at ${o.path}.\n`);
291
402
  return 1;
292
403
  }
293
- if (clean === "dirty") {
404
+ if (clean === 'dirty') {
294
405
  err.write(`overlay "${o.name}": local modifications in ${o.path} — refusing to update. ` +
295
406
  `Commit/stash them, or 'canary overlay remove ${o.name}' and re-add.\n`);
296
407
  return 1;
297
408
  }
298
409
  if (o.ref) {
299
410
  // Pinned to a tag/branch: fetch (incl. tags), then re-checkout the ref.
300
- const fetch = git(["fetch", "--quiet", "--tags", "origin"], { cwd: o.path });
411
+ const fetch = git(['fetch', '--quiet', '--tags', 'origin'], {
412
+ cwd: o.path,
413
+ });
301
414
  if (fetch.status !== 0) {
302
415
  err.write(`overlay "${o.name}": fetch failed.\n${fetch.stderr.trim()}\n`);
303
416
  return 1;
304
417
  }
305
- const co = git(["checkout", "--quiet", o.ref], { cwd: o.path });
418
+ const co = git(['checkout', '--quiet', o.ref], { cwd: o.path });
306
419
  if (co.status !== 0) {
307
420
  err.write(`overlay "${o.name}": checkout ${o.ref} failed.\n${co.stderr.trim()}\n`);
308
421
  return 1;
@@ -310,11 +423,11 @@ function updateOne(o, git, out, err) {
310
423
  out.write(`overlay "${o.name}": fetched, pinned @ ${o.ref}.\n`);
311
424
  return 0;
312
425
  }
313
- const pull = git(["pull", "--ff-only", "--quiet"], { cwd: o.path });
426
+ const pull = git(['pull', '--ff-only', '--quiet'], { cwd: o.path });
314
427
  if (pull.status !== 0) {
315
428
  err.write(`overlay "${o.name}": cannot fast-forward ${o.path} ` +
316
429
  `(diverged or rewritten history) — 'canary overlay remove ${o.name}' and re-add.\n` +
317
- (pull.stderr.trim() ? `${pull.stderr.trim()}\n` : ""));
430
+ (pull.stderr.trim() ? `${pull.stderr.trim()}\n` : ''));
318
431
  return 1;
319
432
  }
320
433
  out.write(`overlay "${o.name}": updated.\n`);
@@ -348,7 +461,7 @@ function update(name, deps = {}) {
348
461
  }
349
462
  else {
350
463
  if (reg.overlays.length === 0) {
351
- out.write("No overlays to update.\n");
464
+ out.write('No overlays to update.\n');
352
465
  return 0;
353
466
  }
354
467
  targets = reg.overlays;
@@ -0,0 +1,121 @@
1
+ 'use strict';
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.readOverlaySkillNames = readOverlaySkillNames;
37
+ exports.detectSkillConflicts = detectSkillConflicts;
38
+ /**
39
+ * Overlay skill-name conflict detection (#333).
40
+ *
41
+ * When two registered overlays ship a skill of the same name, which definition
42
+ * wins was previously undefined and undocumented. This module detects those
43
+ * collisions and decides — from each overlay's declared `precedence` — whether
44
+ * the winner is *declared* (resolved) or *accidental* (unresolved).
45
+ *
46
+ * Winner rule (must match the Python skill loader, `agent/core/skill_registry`):
47
+ * among the overlays contending for a skill name, the one with the highest
48
+ * `precedence` wins; a null/absent precedence counts as 0. The collision is
49
+ * only **resolved** when exactly one overlay holds that highest value —
50
+ * otherwise the winner is arbitrary (directory-name order) and `doctor` flags
51
+ * it so the operator declares a precedence.
52
+ */
53
+ const fs = __importStar(require("node:fs"));
54
+ const path = __importStar(require("node:path"));
55
+ /** Effective precedence: null/absent is 0. */
56
+ function effectivePrecedence(entry) {
57
+ return typeof entry.precedence === 'number' ? entry.precedence : 0;
58
+ }
59
+ /**
60
+ * Skill (directory) names an overlay ships, read from
61
+ * `<overlay>/.canary/skills/<name>/SKILL.md`. A missing or unreadable skills
62
+ * dir yields no names (never throws) — mirrors the Python loader's tolerance.
63
+ */
64
+ function readOverlaySkillNames(entry) {
65
+ // A hand-edited overlays.json (which #333 invites, to add `precedence`) may
66
+ // carry an entry with no `path`. Tolerate it rather than throwing — the
67
+ // "never throws" contract must hold before path.join, not only around readdir.
68
+ if (typeof entry.path !== 'string' || entry.path === '')
69
+ return [];
70
+ const skillsDir = path.join(entry.path, '.canary', 'skills');
71
+ let dirents;
72
+ try {
73
+ dirents = fs.readdirSync(skillsDir, { withFileTypes: true });
74
+ }
75
+ catch {
76
+ return [];
77
+ }
78
+ const names = [];
79
+ for (const d of dirents) {
80
+ if (!d.isDirectory())
81
+ continue;
82
+ if (fs.existsSync(path.join(skillsDir, d.name, 'SKILL.md'))) {
83
+ names.push(d.name);
84
+ }
85
+ }
86
+ return names;
87
+ }
88
+ /**
89
+ * Detect skill-name collisions across the registered overlays. Returns one
90
+ * {@link SkillConflict} per skill name shipped by two or more overlays, sorted
91
+ * by skill name.
92
+ */
93
+ function detectSkillConflicts(reg, deps = {}) {
94
+ const readSkillNames = deps.readSkillNames ?? readOverlaySkillNames;
95
+ // skill name -> contending overlays
96
+ const bySkill = new Map();
97
+ for (const entry of reg.overlays) {
98
+ const precedence = effectivePrecedence(entry);
99
+ for (const skill of readSkillNames(entry)) {
100
+ const list = bySkill.get(skill) ?? [];
101
+ list.push({ overlay: entry.name, precedence });
102
+ bySkill.set(skill, list);
103
+ }
104
+ }
105
+ const conflicts = [];
106
+ for (const [skill, contenders] of bySkill) {
107
+ if (contenders.length < 2)
108
+ continue;
109
+ const ordered = [...contenders].sort((a, b) => b.precedence - a.precedence || a.overlay.localeCompare(b.overlay));
110
+ const top = ordered[0].precedence;
111
+ const topHolders = ordered.filter((c) => c.precedence === top);
112
+ const resolved = topHolders.length === 1;
113
+ conflicts.push({
114
+ skill,
115
+ contenders: ordered,
116
+ winner: resolved ? topHolders[0].overlay : null,
117
+ resolved,
118
+ });
119
+ }
120
+ return conflicts.sort((a, b) => a.skill.localeCompare(b.skill));
121
+ }