canary-test-cli 7.1.0 → 8.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 (128) hide show
  1. package/agents/skills/README.md +327 -0
  2. package/agents/skills/canary:generate.md +49 -0
  3. package/agents/skills/canary:init.md +37 -0
  4. package/agents/skills/canary:migrate.md +66 -0
  5. package/agents/skills/claude-code/canary-add-framework/SKILL.md +248 -0
  6. package/agents/skills/claude-code/canary-batwoman/SKILL.md +119 -0
  7. package/agents/skills/claude-code/canary-blackhawk/SKILL.md +170 -0
  8. package/agents/skills/claude-code/canary-blackhawk/scripts/cli.mjs +188 -0
  9. package/agents/skills/claude-code/canary-blackhawk/scripts/rules.mjs +120 -0
  10. package/agents/skills/claude-code/canary-blackhawk/scripts/scanner.mjs +244 -0
  11. package/agents/skills/claude-code/canary-blackhawk/scripts/string-literals.mjs +116 -0
  12. package/agents/skills/claude-code/canary-cassandra/SKILL.md +187 -0
  13. package/agents/skills/claude-code/canary-cassandra/scripts/cli.mjs +270 -0
  14. package/agents/skills/claude-code/canary-cassandra/scripts/engine.mjs +95 -0
  15. package/agents/skills/claude-code/canary-ci-ready/SKILL.md +178 -0
  16. package/agents/skills/claude-code/canary-ci-ready/skill.yaml +14 -0
  17. package/agents/skills/claude-code/canary-company-knowledge/SKILL.md +196 -0
  18. package/agents/skills/claude-code/canary-critical-areas/SKILL.md +142 -0
  19. package/agents/skills/claude-code/canary-critical-areas/skill.yaml +16 -0
  20. package/agents/skills/claude-code/canary-edge-case-discovery/SKILL.md +160 -0
  21. package/agents/skills/claude-code/canary-edge-case-discovery/skill.yaml +16 -0
  22. package/agents/skills/claude-code/canary-fail-fast/SKILL.md +75 -0
  23. package/agents/skills/claude-code/canary-fail-fast/scripts/cli.mjs +118 -0
  24. package/agents/skills/claude-code/canary-fail-fast/scripts/digest.mjs +69 -0
  25. package/agents/skills/claude-code/canary-fail-fast/scripts/failures.mjs +60 -0
  26. package/agents/skills/claude-code/canary-fail-fast/scripts/fastfail_check.mjs +43 -0
  27. package/agents/skills/claude-code/canary-fail-fast/scripts/parse.mjs +149 -0
  28. package/agents/skills/claude-code/canary-failure-impact/SKILL.md +153 -0
  29. package/agents/skills/claude-code/canary-failure-impact/skill.yaml +15 -0
  30. package/agents/skills/claude-code/canary-fleet-health/SKILL.md +197 -0
  31. package/agents/skills/claude-code/canary-generate-test/SKILL.md +185 -0
  32. package/agents/skills/claude-code/canary-instrument/SKILL.md +157 -0
  33. package/agents/skills/claude-code/canary-instrument/scripts/cli.mjs +178 -0
  34. package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/instrument.mjs +96 -0
  35. package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/playwright-fixture.ts +44 -0
  36. package/agents/skills/claude-code/canary-instrument/scripts/run_types.mjs +81 -0
  37. package/agents/skills/claude-code/canary-instrument/scripts/span_reader.mjs +187 -0
  38. package/agents/skills/claude-code/canary-katana/SKILL.md +243 -0
  39. package/agents/skills/claude-code/canary-katana/scripts/alarm.mjs +296 -0
  40. package/agents/skills/claude-code/canary-katana/scripts/cli.mjs +247 -0
  41. package/agents/skills/claude-code/canary-katana/scripts/diffscan.mjs +0 -0
  42. package/agents/skills/claude-code/canary-katana/scripts/ledger.mjs +183 -0
  43. package/agents/skills/claude-code/canary-pr-guardian/SKILL.md +144 -0
  44. package/agents/skills/claude-code/canary-pr-guardian/skill.yaml +17 -0
  45. package/agents/skills/claude-code/canary-promote-test/SKILL.md +228 -0
  46. package/agents/skills/claude-code/canary-savant/SKILL.md +233 -0
  47. package/agents/skills/claude-code/canary-savant/scripts/cli.mjs +274 -0
  48. package/agents/skills/claude-code/canary-savant/scripts/restoration.mjs +274 -0
  49. package/agents/skills/claude-code/canary-savant/scripts/rules.mjs +168 -0
  50. package/agents/skills/claude-code/canary-savant/scripts/runner.mjs +572 -0
  51. package/agents/skills/claude-code/canary-savant/scripts/scanner.mjs +374 -0
  52. package/agents/skills/claude-code/canary-savant/scripts/string-literals.mjs +116 -0
  53. package/agents/skills/claude-code/canary-screech/SKILL.md +109 -0
  54. package/agents/skills/claude-code/canary-screech/scripts/blast.mjs +125 -0
  55. package/agents/skills/claude-code/canary-screech/scripts/cli.mjs +128 -0
  56. package/agents/skills/claude-code/canary-screech/scripts/cluster.mjs +97 -0
  57. package/agents/skills/claude-code/canary-screech/scripts/history.mjs +73 -0
  58. package/agents/skills/claude-code/canary-screech/scripts/redness.mjs +94 -0
  59. package/agents/skills/claude-code/canary-setup-harness/SKILL.md +263 -0
  60. package/agents/skills/claude-code/canary-shadow/SKILL.md +131 -0
  61. package/agents/skills/claude-code/canary-shadow/scripts/cases.example.json +32 -0
  62. package/agents/skills/claude-code/canary-shadow/scripts/cli.mjs +195 -0
  63. package/agents/skills/claude-code/canary-ship/SKILL.md +177 -0
  64. package/agents/skills/claude-code/canary-ship/skill.yaml +16 -0
  65. package/agents/skills/claude-code/canary-strix/SKILL.md +130 -0
  66. package/agents/skills/claude-code/canary-strix/scripts/cli.mjs +255 -0
  67. package/agents/skills/claude-code/canary-strix/scripts/scanner.mjs +252 -0
  68. package/agents/skills/claude-code/canary-strix/scripts/terms.mjs +132 -0
  69. package/agents/skills/claude-code/canary-test-pipeline/SKILL.md +159 -0
  70. package/agents/skills/claude-code/canary-test-pipeline/skill.yaml +19 -0
  71. package/agents/skills/claude-code/canary-test-reporter/SKILL.md +138 -0
  72. package/agents/skills/claude-code/canary-test-reporter/scripts/cli.mjs +98 -0
  73. package/agents/skills/claude-code/canary-test-reporter/scripts/json_report.mjs +58 -0
  74. package/agents/skills/claude-code/canary-test-reporter/scripts/parse.mjs +216 -0
  75. package/agents/skills/claude-code/canary-test-reporter/scripts/render.mjs +114 -0
  76. package/agents/skills/lib/parse-args.mjs +275 -0
  77. package/dist/engine/analysis/batwoman/audit.js +39 -0
  78. package/dist/engine/analysis/batwoman/closure.js +159 -0
  79. package/dist/engine/analysis/batwoman/gh-history.js +119 -0
  80. package/dist/engine/analysis/batwoman/probes.js +195 -0
  81. package/dist/engine/analysis/batwoman/registry.js +142 -0
  82. package/dist/engine/analysis/batwoman/render.js +194 -0
  83. package/dist/engine/analysis/batwoman/run-window.js +122 -0
  84. package/dist/engine/analysis/batwoman/text.js +84 -0
  85. package/dist/engine/analysis/batwoman/triggers.js +122 -0
  86. package/dist/engine/analysis/batwoman/verdict.js +64 -0
  87. package/dist/engine/analysis/cli.js +47 -14
  88. package/dist/engine/analysis/gh-flaky/gh-run-attempts.js +206 -0
  89. package/dist/engine/batwoman-cli.js +119 -0
  90. package/dist/engine/ci-ready-cli.js +71 -0
  91. package/dist/engine/cli-commands.js +49 -72
  92. package/dist/engine/cli.core.js +16 -0
  93. package/dist/engine/company-knowledge-cli.js +10 -2
  94. package/dist/engine/core/ci-ready.js +112 -0
  95. package/dist/engine/core/company-knowledge.js +8 -0
  96. package/dist/engine/core/migrator.js +147 -20
  97. package/dist/engine/core/permission-matrix.js +219 -0
  98. package/dist/engine/core/quality-scorer.js +27 -19
  99. package/dist/engine/core/scaling-curve.js +143 -0
  100. package/dist/engine/core/skill-dispatch.js +115 -0
  101. package/dist/engine/core/skill-examples.js +103 -3
  102. package/dist/engine/core/skill-registry.js +59 -4
  103. package/dist/engine/core/string-literals.js +3 -1
  104. package/dist/engine/core/test-files.js +77 -0
  105. package/dist/engine/core/vacuity-scanner.js +330 -15
  106. package/dist/engine/core/workflow-discovery.js +41 -23
  107. package/dist/engine/guardian/adjudication-github.js +136 -0
  108. package/dist/engine/guardian/adjudication.js +119 -340
  109. package/dist/engine/guardian/analysis-emit.js +7 -2
  110. package/dist/engine/guardian/cli.js +277 -249
  111. package/dist/engine/guardian/coverage.js +2 -1
  112. package/dist/engine/guardian/diff-coverage/coverage-delta.js +162 -0
  113. package/dist/engine/guardian/diff-coverage/formats/cobertura.js +45 -1
  114. package/dist/engine/guardian/diff-coverage/orchestrator.js +25 -21
  115. package/dist/engine/guardian/diff-coverage/paths.js +5 -9
  116. package/dist/engine/guardian/diff-coverage/report-tier.js +88 -12
  117. package/dist/engine/guardian/diff-extractor.js +31 -32
  118. package/dist/engine/guardian/pr-check.js +354 -223
  119. package/dist/engine/guardian/pr-comment.js +35 -58
  120. package/dist/engine/guardian/weak-test.js +236 -0
  121. package/dist/engine/mcp-server.js +67 -4
  122. package/dist/engine/permission-matrix-cli.js +51 -0
  123. package/dist/engine/scaling-curve-cli.js +147 -0
  124. package/dist/engine/skills-cli.js +171 -51
  125. package/dist/engine/workflow-cli.js +85 -65
  126. package/dist/reporters/testtracker.d.ts +1 -1
  127. package/dist/reporters/testtracker.js +1 -1
  128. package/package.json +3 -2
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Tier 2 of `canary skills run`: the dispatcher (#756).
3
+ *
4
+ * Canary shipped tier 1 (a skill declaring `cli:`/`entry:` is spawned) and
5
+ * tier 3 (a prose skill is unreachable), and nothing between. 14 of canary's 21
6
+ * skills carry no `cli:`, so no orchestrator, CI step, or sibling skill could
7
+ * invoke them at all -- a have/have-not split that costs far more here than the
8
+ * same split costs harness, where the dispatcher runs the CLI-less majority.
9
+ *
10
+ * ## What "running a prose skill" means here, honestly
11
+ *
12
+ * Canary is a CLI. It has no agent runtime, and it is not going to grow one to
13
+ * close this gap. So the dispatcher does the one thing a CLI can do faithfully:
14
+ * it RESOLVES the skill and hands back its executable contract -- identity,
15
+ * declared runtime requirements, and the workflow text an agent is to apply --
16
+ * with the tier and the determinism stated on the payload. The caller gets a
17
+ * resolved, machine-readable handle to a real skill instead of exit 2.
18
+ *
19
+ * What it deliberately does NOT do is apply the workflow and present the result
20
+ * as canary's. That would be canary claiming an answer it did not compute.
21
+ *
22
+ * ## Determinism labelling (issue design question 2)
23
+ *
24
+ * Every dispatch is stamped `determinism: 'agent-applied'`, against
25
+ * `'deterministic'` for a `cli:` skill. A consumer merging findings across
26
+ * skills must be able to tell a scanner's output from an agent's reading of a
27
+ * ruleset; without the label the two look interchangeable, which is exactly the
28
+ * confusion #755 documents about cassandra.
29
+ *
30
+ * ## Why no `--allow-executable-skills` equivalent (design question 3)
31
+ *
32
+ * That flag exists because a freshly cloned overlay can carry a `cli:` script,
33
+ * and invoking it runs someone else's code on the next CI run. Dispatch runs
34
+ * nothing: it reads a markdown file the registry already read at discovery and
35
+ * prints it. There is no new execution to gate, so gating it would be
36
+ * ceremony -- and ceremony that would keep the 14 skills unreachable in exactly
37
+ * the non-interactive contexts the issue is about. The trust boundary moves to
38
+ * whatever the caller does with the returned text, which is the caller's gate
39
+ * to own, and the payload labels itself so the caller can see what it holds.
40
+ *
41
+ * ## Failure mode (design question 4)
42
+ *
43
+ * A skill that could not be dispatched raises {@link SkillDispatchError}. An
44
+ * unreadable or bodyless SKILL.md is a failure, never an empty success -- a
45
+ * dispatcher that returned "nothing to do" for a skill it could not read would
46
+ * be indistinguishable from one that ran and found nothing.
47
+ */
48
+ import { readFileSync } from 'node:fs';
49
+ import { errnoCode } from './gate-result.js';
50
+ // Written as an escape so this source stays ASCII, matching gate-result.ts.
51
+ const EMDASH = '\u{2014}';
52
+ /** A dispatch that could not be completed. Never degrades to an empty result. */
53
+ export class SkillDispatchError extends Error {
54
+ skill;
55
+ constructor(skill, message) {
56
+ super(message);
57
+ this.name = 'SkillDispatchError';
58
+ this.skill = skill;
59
+ }
60
+ }
61
+ /**
62
+ * Strip a leading `---` frontmatter block, leaving the workflow prose.
63
+ *
64
+ * Mirrors the delimiter handling in `SkillRegistry.parseFrontmatter`: an
65
+ * unterminated block means the whole file was frontmatter, and there is no
66
+ * body to hand back.
67
+ */
68
+ export function skillBody(text) {
69
+ if (!text.startsWith('---'))
70
+ return text.trim();
71
+ const rest = text.split('\n').slice(1);
72
+ const end = rest.findIndex((l) => l.trim() === '---');
73
+ return end === -1
74
+ ? ''
75
+ : rest
76
+ .slice(end + 1)
77
+ .join('\n')
78
+ .trim();
79
+ }
80
+ /**
81
+ * Resolve a prose skill into its dispatch payload.
82
+ *
83
+ * @throws {SkillDispatchError} when SKILL.md cannot be read, or holds no body.
84
+ */
85
+ export function dispatchProseSkill(skill, args) {
86
+ let text;
87
+ try {
88
+ text = readFileSync(skill.path, 'utf-8');
89
+ }
90
+ catch (exc) {
91
+ const code = errnoCode(exc);
92
+ if (code === null)
93
+ throw exc;
94
+ throw new SkillDispatchError(skill.name, `cannot read ${skill.path} (${code}) ${EMDASH} the skill was ` +
95
+ 'discovered but its workflow could not be loaded.');
96
+ }
97
+ const instructions = skillBody(text);
98
+ if (!instructions) {
99
+ throw new SkillDispatchError(skill.name, `${skill.path} carries frontmatter but no workflow body ${EMDASH} ` +
100
+ 'there is nothing to dispatch. Reporting this as an empty run would ' +
101
+ 'be indistinguishable from a skill that ran and found nothing.');
102
+ }
103
+ return {
104
+ skill: skill.name,
105
+ path: skill.path,
106
+ tier: 'dispatcher',
107
+ determinism: 'agent-applied',
108
+ requires_agent_runtime: true,
109
+ requires: skill.requires,
110
+ description: skill.description,
111
+ instructions,
112
+ args,
113
+ };
114
+ }
115
+ //# sourceMappingURL=skill-dispatch.js.map
@@ -59,6 +59,48 @@ const PLACEHOLDER = /[<>${}|`*\\]/;
59
59
  const HELP_FLAGS = new Set(['--help', '-h', '--version', '-V']);
60
60
  /** Non-mutating subcommands worth executing even without a help flag. */
61
61
  const READ_ONLY_COMMANDS = new Set(['canary skills list']);
62
+ /**
63
+ * The author's declaration that a block is illustrative (#707).
64
+ *
65
+ * Placed on its own line immediately above the fence it governs:
66
+ *
67
+ * <!-- canary:illustrative -->
68
+ * ```bash
69
+ * canary katana scan --since HEAD~1
70
+ * ```
71
+ *
72
+ * Two facts land in the same "unverifiable" bucket and they are not the same
73
+ * fact: "nobody could run this" and "this was never meant to be run". The
74
+ * first is a gap in the corpus; the second is a deliberate authoring choice.
75
+ * Collapsing them is what let 88% of the corpus read as coverage debt when
76
+ * some of it was prose doing its job — and, worse, hid the real gaps inside
77
+ * the pile.
78
+ *
79
+ * Marking is NOT an escape hatch from the executable-example rule. It changes
80
+ * the reason on one block; a code-bearing skill still has to carry at least
81
+ * one example that actually runs (`no-executable-example`), so a skill cannot
82
+ * mark its way to green.
83
+ */
84
+ const ILLUSTRATIVE_MARKER = /^\s*<!--\s*canary:illustrative\s*-->\s*$/;
85
+ /**
86
+ * The reason carried by a declared-illustrative example.
87
+ *
88
+ * Exported because the summary line splits the unverifiable bucket on it
89
+ * (see {@link countDeclaredIllustrative}). A string literal compared in two
90
+ * files is a drift waiting to happen, and the drift would be silent: the
91
+ * split would quietly read 0 declared and the distinction this issue exists
92
+ * to draw would be gone with nothing red.
93
+ */
94
+ export const ILLUSTRATIVE_REASON = 'declared illustrative by the author, so it is not run';
95
+ /**
96
+ * How many of a gate's skipped examples were skipped BY DECLARATION.
97
+ *
98
+ * The rest are the honest gap: examples nobody could run and nobody said
99
+ * were prose.
100
+ */
101
+ export function countDeclaredIllustrative(skipped) {
102
+ return skipped.filter((s) => s.reason === ILLUSTRATIVE_REASON).length;
103
+ }
62
104
  /** How an example turned out. */
63
105
  export var ExampleVerdict;
64
106
  (function (ExampleVerdict) {
@@ -75,6 +117,14 @@ export var ExampleFindingKind;
75
117
  ExampleFindingKind["ExampleFailed"] = "example-failed";
76
118
  /** A code-bearing skill's doc offers no command to execute at all. */
77
119
  ExampleFindingKind["NoDocumentedExample"] = "no-documented-example";
120
+ /**
121
+ * A code-bearing skill documents commands, but not one of them can be run
122
+ * (#707). Distinct from {@link NoDocumentedExample}, and it was the larger
123
+ * hole: 5 of 9 `cli:` skills sat here while the corpus looked documented.
124
+ * A skill in this state can break in every documented way and CI stays
125
+ * green, which is the false-green shape the whole check exists to close.
126
+ */
127
+ ExampleFindingKind["NoExecutableExample"] = "no-executable-example";
78
128
  })(ExampleFindingKind || (ExampleFindingKind = {}));
79
129
  /**
80
130
  * Whether `line` closes the currently open fence.
@@ -95,6 +145,11 @@ function fencedShellLines(text) {
95
145
  const lines = text.split('\n');
96
146
  let fence = null;
97
147
  let shell = false;
148
+ let illustrative = false;
149
+ // The marker governs the NEXT fence, so it survives the blank line authors
150
+ // naturally leave between a comment and a block, and is spent by the fence
151
+ // it opens — a marker cannot leak onto a later, unrelated example.
152
+ let pendingMarker = false;
98
153
  for (let i = 0; i < lines.length; i++) {
99
154
  const line = lines[i];
100
155
  const delimiter = /^\s*(`{3,}|~{3,})\s*([A-Za-z0-9_+-]*)/.exec(line);
@@ -104,16 +159,24 @@ function fencedShellLines(text) {
104
159
  if (delimiter) {
105
160
  fence = delimiter[1];
106
161
  shell = SHELL_FENCES.has((delimiter[2] ?? '').toLowerCase());
162
+ illustrative = pendingMarker;
163
+ pendingMarker = false;
164
+ continue;
107
165
  }
166
+ if (ILLUSTRATIVE_MARKER.test(line))
167
+ pendingMarker = true;
168
+ else if (line.trim() !== '')
169
+ pendingMarker = false;
108
170
  continue;
109
171
  }
110
172
  if (closesFence(line, delimiter, fence)) {
111
173
  fence = null;
112
174
  shell = false;
175
+ illustrative = false;
113
176
  continue;
114
177
  }
115
178
  if (shell)
116
- out.push({ line: i + 1, raw: line });
179
+ out.push({ line: i + 1, raw: line, illustrative });
117
180
  }
118
181
  return out;
119
182
  }
@@ -146,7 +209,7 @@ function classify(command) {
146
209
  */
147
210
  export function extractExamples(text, skill, path) {
148
211
  const out = [];
149
- for (const { line, raw } of fencedShellLines(text)) {
212
+ for (const { line, raw, illustrative } of fencedShellLines(text)) {
150
213
  // Strip a `$ ` or `> ` shell prompt; a doc that shows a prompt is still
151
214
  // documenting the command after it.
152
215
  const command = raw
@@ -157,8 +220,31 @@ export function extractExamples(text, skill, path) {
157
220
  continue;
158
221
  if (command !== 'canary' && !command.startsWith('canary '))
159
222
  continue;
223
+ // A declaration beats an inference. The author saying "this is prose"
224
+ // is a better fact than the classifier guessing why it could not run,
225
+ // and it is the fact a reader of the skipped list needs.
226
+ if (illustrative) {
227
+ out.push({
228
+ skill,
229
+ path,
230
+ command,
231
+ line,
232
+ executable: false,
233
+ declaredIllustrative: true,
234
+ reason: ILLUSTRATIVE_REASON,
235
+ });
236
+ continue;
237
+ }
160
238
  const { executable, reason } = classify(command);
161
- out.push({ skill, path, command, line, executable, reason });
239
+ out.push({
240
+ skill,
241
+ path,
242
+ command,
243
+ line,
244
+ executable,
245
+ declaredIllustrative: false,
246
+ reason,
247
+ });
162
248
  }
163
249
  return out;
164
250
  }
@@ -239,6 +325,20 @@ export function checkExamples(surfaces, run, cwd) {
239
325
  }
240
326
  continue;
241
327
  }
328
+ // #707: documenting commands is not the same as documenting a RUNNABLE
329
+ // one. The cheapest fix is the skill's own `--help`, which needs no
330
+ // fixtures, credentials or network — and marking blocks illustrative
331
+ // cannot satisfy this, so the declaration stays honest.
332
+ if (codeBearing(decl) && !examples.some((e) => e.executable)) {
333
+ tally.findings.push({
334
+ kind: ExampleFindingKind.NoExecutableExample,
335
+ skill: decl.name,
336
+ path: decl.path,
337
+ detail: `declares a \`cli:\` and documents ${examples.length} command(s), ` +
338
+ 'but none is executable, so nothing in its doc has ever been run. ' +
339
+ 'Add one placeholder-free help-shaped example (its own `--help`).',
340
+ });
341
+ }
242
342
  tallyDeclaration(decl, runExamples(examples, run, cwd), tally);
243
343
  }
244
344
  return tally;
@@ -67,11 +67,25 @@ function codePointCompare(a, b) {
67
67
  }
68
68
  return ca.length - cb.length;
69
69
  }
70
- /** Bundled skills live at `<repo>/agents/skills`. Python: `_AGENTS_SKILLS_DIR`. */
70
+ /**
71
+ * Bundled skills live at `<root>/agents/skills`, where `<root>` is three
72
+ * directories above this module. Python: `_AGENTS_SKILLS_DIR`.
73
+ *
74
+ * The "three levels up" is a PACKAGING CONTRACT, not an implementation detail
75
+ * (#757). It holds in the source tree (`ts/src/core`), in the compiled tree
76
+ * (`ts/dist/core`), and in the published npm package (`dist/engine/core`) --
77
+ * but only while whatever sits at that root actually ships an `agents/skills`.
78
+ * It did not: `canary-test-cli@7.1.0` published `bin/` and `dist/` only, so an
79
+ * installed CLI resolved this to a directory that has never existed and
80
+ * reported every bundled skill as absent, from any cwd. Exported so
81
+ * `ts/test/skill-packaging.test.ts` can pin both halves of the contract.
82
+ */
83
+ export function bundledSkillsDirFrom(moduleDir) {
84
+ // moduleDir = <root>/<a>/<b>/core -> the root is three levels up.
85
+ return resolve(moduleDir, '..', '..', '..', 'agents', 'skills');
86
+ }
71
87
  function defaultAgentsSkillsDir() {
72
- const here = dirname(fileURLToPath(import.meta.url));
73
- // here = ts/src/core -> repo root is three levels up (Python: parents[2]).
74
- return resolve(here, '..', '..', '..', 'agents', 'skills');
88
+ return bundledSkillsDirFrom(dirname(fileURLToPath(import.meta.url)));
75
89
  }
76
90
  /**
77
91
  * A discovered skill (Python: `SkillInfo` dataclass).
@@ -150,6 +164,47 @@ export class SkillRegistry {
150
164
  }
151
165
  return [...skills.values()].sort((a, b) => codePointCompare(a.name, b.name));
152
166
  }
167
+ /**
168
+ * The directory tiers {@link discover} consults, in precedence order.
169
+ *
170
+ * Exists so an empty discovery can state its denominator (#757). "No skills
171
+ * found." is a claim about the world; what discovery can actually attest is
172
+ * "none of THESE four roots held one", and the two read very differently to
173
+ * someone standing in a directory full of SKILL.md files. The local tier
174
+ * walks cwd up to the git root, so it renders as the range it swept rather
175
+ * than one line per ancestor.
176
+ */
177
+ searchRoots(root) {
178
+ const searchRoot = resolve(root ?? process.cwd());
179
+ const ancestors = SkillRegistry.ancestorsToGitRoot(searchRoot);
180
+ const localDirs = ancestors.map((a) => join(a, '.canary', 'skills'));
181
+ const overlaysRoot = join(this.home, '.canary', 'overlays');
182
+ const globalDir = join(this.home, '.canary', 'skills');
183
+ return [
184
+ {
185
+ tier: 'bundled',
186
+ path: this.agentsSkillsDir,
187
+ exists: existsSync(this.agentsSkillsDir),
188
+ },
189
+ {
190
+ tier: 'overlay',
191
+ path: overlaysRoot,
192
+ exists: SkillRegistry.isDir(overlaysRoot),
193
+ },
194
+ {
195
+ tier: 'global',
196
+ path: globalDir,
197
+ exists: SkillRegistry.isDir(globalDir),
198
+ },
199
+ {
200
+ tier: 'local',
201
+ path: localDirs.length > 1
202
+ ? `${localDirs[0]} (and ${localDirs.length - 1} ancestor(s) up to the git root)`
203
+ : (localDirs[0] ?? join(searchRoot, '.canary', 'skills')),
204
+ exists: localDirs.some((d) => existsSync(d)),
205
+ },
206
+ ];
207
+ }
153
208
  /** Return the SkillInfo for `name` honoring precedence, or null. */
154
209
  find(name, root) {
155
210
  for (const skill of this.discover(root)) {
@@ -59,7 +59,9 @@ export function blankStringContent(code, options = {}) {
59
59
  const spans = literalContentSpans(code, options.python === true);
60
60
  if (spans.length === 0)
61
61
  return code;
62
- const out = [...code];
62
+ // `split('')`, not `[...code]`: spans are UTF-16 offsets, and spreading by
63
+ // code point would collapse each surrogate pair and shift every offset (#861).
64
+ const out = code.split('');
63
65
  for (const [start, end] of spans) {
64
66
  for (let i = start; i < end; i += 1) {
65
67
  // Newlines survive so line numbering is unchanged; everything else goes.
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The one answer to "which files in this tree are tests?" (#755).
3
+ *
4
+ * Extracted from `cli-commands.ts`, where it was private, because
5
+ * `canary-cassandra`'s skill CLI needs the SAME answer as `canary
6
+ * vacuity-check`. The four Tier-0 detectors are meant to be mergeable by a
7
+ * single consumer, and two collectors disagreeing about the denominator is the
8
+ * quietest way for that to stop being true: the same run would report a
9
+ * different `checked` depending on which door it came through.
10
+ *
11
+ * The walk's ignore set is load-bearing (#566): a dependency's own test suite
12
+ * is not the consumer's to fix. One downstream run before that fix produced 254
13
+ * of 256 findings inside `node_modules`, with the only `critical` in vendored
14
+ * code.
15
+ */
16
+ import { readdirSync, statSync } from 'node:fs';
17
+ import { basename, join } from 'node:path';
18
+ import { JS_TEST_EXTENSIONS } from './static-linter.js';
19
+ /** True when `p` is a readable directory. A missing path is not one. */
20
+ export function isDir(p) {
21
+ try {
22
+ return statSync(p).isDirectory();
23
+ }
24
+ catch {
25
+ return false;
26
+ }
27
+ }
28
+ /** Directories never worth walking; see the module docstring for why. */
29
+ const IGNORED_DIRS = new Set([
30
+ 'node_modules',
31
+ '.git',
32
+ '__pycache__',
33
+ '.venv',
34
+ 'venv',
35
+ 'dist',
36
+ 'build',
37
+ '.next',
38
+ '.nuxt',
39
+ ]);
40
+ function walkFiles(dir) {
41
+ const out = [];
42
+ let entries;
43
+ try {
44
+ entries = readdirSync(dir, { withFileTypes: true });
45
+ }
46
+ catch {
47
+ return out;
48
+ }
49
+ for (const e of entries) {
50
+ const full = join(dir, e.name);
51
+ if (e.isDirectory()) {
52
+ if (!IGNORED_DIRS.has(e.name))
53
+ out.push(...walkFiles(full));
54
+ }
55
+ else if (e.isFile())
56
+ out.push(full);
57
+ }
58
+ return out;
59
+ }
60
+ /**
61
+ * `test_*.py` plus `*.test.*` / `*.spec.*` over every extension the scanners
62
+ * can actually read -- `.mjs` and `.cjs` included, which is the half of #566
63
+ * that made a directory of ESM tests collect zero files.
64
+ */
65
+ const JS_TEST_FILE_RE = new RegExp(`\\.(test|spec)\\.(${JS_TEST_EXTENSIONS.map((e) => e.slice(1)).join('|')})$`);
66
+ /** Recursive test-file glob matching Python's `rglob` union, sorted by path. */
67
+ export function collectTestFiles(dir) {
68
+ return walkFiles(dir)
69
+ .filter((p) => {
70
+ const b = basename(p);
71
+ return ((b.startsWith('test_') && b.endsWith('.py')) || JS_TEST_FILE_RE.test(b));
72
+ })
73
+ .sort();
74
+ }
75
+ /** Human-readable list of what {@link collectTestFiles} looks for. */
76
+ export const SCANNABLE_DESC = `test_*.py, *.test|spec.{${JS_TEST_EXTENSIONS.map((e) => e.slice(1)).join(',')}}`;
77
+ //# sourceMappingURL=test-files.js.map