Files
gbrain/test/check-resolvable.test.ts
T
374deff579 v0.41.7.0 feat: compact list-format resolver + 300-skill scaling tutorial (#1407)
* feat(check-resolvable): parseResolverEntries accepts compact list format

Add the second parser branch alongside the existing markdown-table branch
so RESOLVER.md and AGENTS.md can use the OpenClaw-native list shape:

    - **skill-name**: trigger1 | trigger2 | trigger3
    - skill-name: trigger1 | trigger2

Constraints:
  - Skill names must be kebab-lowercase ([a-z][a-z0-9-]+). Bold names
    starting with an uppercase letter (e.g. **Note**, **Convention**)
    are deliberately skipped so prose bullets in real-world AGENTS.md
    files don't get mis-parsed as fake skill rows.
  - skillPath is always derived as skills/<name>/SKILL.md. An optional
    arrow suffix (Unicode -> or ASCII ->) is stripped from the trigger
    string but NOT honored as a path. Downstream consumers
    (routing-eval.ts skillSlugFromPath, the manifest check at line 367)
    assume the convention. For non-conventional paths, use the table
    format.
  - Multiple triggers fan out to one entry per trigger. checkResolvable
    dedupes by skillPath downstream, so the reachability count counts
    each skill once regardless of trigger fan-out.

The parser body is restructured to an if/else-if shape so the existing
'continue' on non-table rows no longer short-circuits the list branch.

Unit tests cover 11 new cases: bold + plain name shapes, multi-trigger
fan-out, Unicode and ASCII path-suffix strip, ellipsis filter, empty
pipe segments, mixed-shape files, section tracking, and two D4
regression cases (prose-bullet rejection + convention-violation
silent-skip).

Closes #1370 — credit @garrytan-agents for the original PR that flagged
the parser gap.

* test(check-resolvable): integration fixtures + regression suite for compact format

Two fixtures pin the v0.41.7.0 parser fix at the integration layer:

  test/fixtures/openclaw-compact-resolver/
    List-format only RESOLVER.md with 10 fictional skills (gift-advisor,
    flight-tracker, email-triage, etc.), each with valid frontmatter
    triggers. A trailing 'Notes' section embeds 4 prose bullets
    (- **Note**:, - **Convention**:, - **TODO**:, - **Important**:)
    that pin the D4 kebab-lowercase regex tighten: if the regex ever
    regresses to permissive [\w-]+, those prose bullets would surface
    as orphan_trigger warnings and the test fails loudly.

  test/fixtures/openclaw-mixed-merge/
    Tests the v0.31.7 D-CX-14 multi-resolver merge: workspace-root
    AGENTS.md (compact list, 3 skills) + skills/RESOLVER.md (table
    format, 5 skills). The merge dedups by skillPath and counts each
    skill once.

The regression test (test/check-resolvable-openclaw-compact.test.ts)
runs 8 assertions across both fixtures:

  1. unreachable === 0 on the compact fixture (the 'pre-v0.41.7.0
     reported 238 FAILs on a 306-skill OpenClaw, post-fix 0' headline).
  2. zero error-severity issues; report.ok === true.
  3. zero mece_gap warnings (every stub ships valid triggers).
  4. zero orphan_trigger warnings for the 4 prose-bullet names — D4
     regex regression guard at integration level.
  5. zero missing_file warnings.
  6. mixed-merge: total_skills === 8 (5 table + 3 list), all reachable.
  7. mixed-merge: errors.length === 0; report.ok === true.
  8. mixed-merge: each expected skill from BOTH shapes is non-unreachable
     (catches the bug where one shape silently swallows the other via
     dedup-by-skillPath).

* docs(guides): scaling-skills.md walkthrough for 300-skill agents

Three-tier architecture for agents that have outgrown the always-loaded
skill manifest:

  Tier A — always loaded (~35 skills, in the system prompt every turn)
  Tier B — resolver-routed (~85 skills, looked up via RESOLVER.md/AGENTS.md
            only when no Tier A match)
  Tier C — dormant (~180 skills, on disk but not injected into the prompt)

Real numbers from Garry's 306-skill OpenClaw: 25K tokens of skill
descriptions per turn collapsed to 4K tokens (~21K tokens freed per
turn) with zero capability loss. The compact list-format resolver
(v0.41.7.0) is the parser-level enabler for this pattern.

The guide covers:

  - The scaling wall (when the always-loaded manifest stops working)
  - The three tiers + per-turn token math
  - What the resolver actually does (routing-table-but-cheaper pattern)
  - The compact list format (kebab-lowercase contract, optional path
    suffix, mixed-shape support)
  - The 'gbrain doctor' / 'gbrain check-resolvable --strict' safety net
  - Implementation walkthrough (audit → tier → disable → resolver →
    doctor)
  - The scaling curve (50 → 100 → 200 → 300 → 1000, no ceiling)

Voice + privacy cleanup applied per CLAUDE.md rules:
  - Wintermute → 'Garry's OpenClaw' / 'your OpenClaw'
  - Unicode em dashes stripped; ASCII '--' preserved in command flags
  - Made-up 'check_resolvable' invocation replaced with real
    'gbrain doctor' and 'gbrain check-resolvable --json'/'--strict'
  - Blog-style 'Previous in this series' footer dropped

Wiring:
  - scripts/llms-config.ts registers the new guide in the curated
    array so 'bun run build:llms' picks it up. docs/UPGRADING_
    DOWNSTREAM_AGENTS.md excluded from the inlined bundle to stay
    under the 600KB FULL_SIZE_BUDGET after adding the new content.
  - docs/tutorials/README.md gains a one-line entry pointing at the
    guide under Related documentation.
  - llms.txt + llms-full.txt regenerated.

* chore: bump version and changelog (v0.41.7.0)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs: update CLAUDE.md for v0.41.7.0 compact-format resolver

Annotate the src/core/check-resolvable.ts entry with the v0.41.7.0
parseResolverEntries compact list-format support: kebab-lowercase name
gate (closes the prose-bullet false-positive class), path-suffix strip
contract (skillPath always derived as skills/<name>/SKILL.md so
routing-eval and the manifest check don't drift), multi-trigger fan-out
plus checkResolvable downstream dedupe, the 238 FAILs to 0 OpenClaw
headline, the two integration fixtures pinning the regression, and the
docs/guides/scaling-skills.md pointer for the tutorial context.

Regenerate llms-full.txt to match (CLAUDE.md edit chaser, per the
CLAUDE.md own rule about test/build-llms.test.ts catching drift).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 13:58:26 -07:00

419 lines
17 KiB
TypeScript

import { describe, test, expect } from "bun:test";
import { join } from "path";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "fs";
import { tmpdir } from "os";
import {
checkResolvable,
parseResolverEntries,
extractDelegationTargets,
} from "../src/core/check-resolvable.ts";
const SKILLS_DIR = join(import.meta.dir, "..", "skills");
describe("parseResolverEntries", () => {
test("extracts skill paths from markdown table rows", () => {
const content = `## Brain operations
| Trigger | Skill |
|---------|-------|
| "What do we know about" | \`skills/query/SKILL.md\` |
| Creating a person page | \`skills/enrich/SKILL.md\` |`;
const entries = parseResolverEntries(content);
expect(entries.length).toBe(2);
expect(entries[0].skillPath).toBe("skills/query/SKILL.md");
expect(entries[0].section).toBe("Brain operations");
expect(entries[1].skillPath).toBe("skills/enrich/SKILL.md");
});
test("handles GStack entries (external skills)", () => {
const content = `## Thinking skills
| Trigger | Skill |
|---------|-------|
| "Brainstorm" | GStack: office-hours |`;
const entries = parseResolverEntries(content);
expect(entries.length).toBe(1);
expect(entries[0].isGStack).toBe(true);
});
test("handles identity/access rows (non-skill references)", () => {
const content = `## Identity
| Trigger | Skill |
|---------|-------|
| Non-owner sends a message | Check \`ACCESS_POLICY.md\` before responding |`;
const entries = parseResolverEntries(content);
expect(entries.length).toBe(1);
expect(entries[0].isGStack).toBe(true);
});
test("skips separator and header rows", () => {
const content = `| Trigger | Skill |
|---------|-------|
| "query" | \`skills/query/SKILL.md\` |`;
const entries = parseResolverEntries(content);
expect(entries.length).toBe(1);
});
test("tracks section headings", () => {
const content = `## Always-on
| Trigger | Skill |
|---------|-------|
| Every message | \`skills/signal-detector/SKILL.md\` |
## Brain operations
| Trigger | Skill |
|---------|-------|
| "What do we know" | \`skills/query/SKILL.md\` |`;
const entries = parseResolverEntries(content);
expect(entries[0].section).toBe("Always-on");
expect(entries[1].section).toBe("Brain operations");
});
// ──────────────────────────────────────────────────────────────────────
// v0.41.7.0 — Compact list-format support (OpenClaw-native shape)
//
// The list branch was added so `gbrain doctor` no longer reports every
// skill as unreachable on agents that write the compact `- **name**: t1 | t2`
// shape instead of the markdown table. The OpenClaw 306-skill regression
// was 238 FAIL errors → 0 errors after this fix.
// ──────────────────────────────────────────────────────────────────────
test("[list] bold-name single trigger", () => {
const content = `## Personal
- **gift-advisor**: gift idea`;
const entries = parseResolverEntries(content);
expect(entries.length).toBe(1);
expect(entries[0].trigger).toBe("gift idea");
expect(entries[0].skillPath).toBe("skills/gift-advisor/SKILL.md");
expect(entries[0].isGStack).toBe(false);
expect(entries[0].section).toBe("Personal");
});
test("[list] bold-name multi-trigger fan-out", () => {
const content = `- **flight-tracker**: track my flight | flight status | when does my flight land`;
const entries = parseResolverEntries(content);
expect(entries.length).toBe(3);
expect(entries.map(e => e.trigger)).toEqual([
"track my flight",
"flight status",
"when does my flight land",
]);
expect(entries.every(e => e.skillPath === "skills/flight-tracker/SKILL.md")).toBe(true);
});
test("[list] plain-name fallback (no bold markers)", () => {
const content = `- gift-advisor: gift idea | birthday gift`;
const entries = parseResolverEntries(content);
expect(entries.length).toBe(2);
expect(entries[0].skillPath).toBe("skills/gift-advisor/SKILL.md");
expect(entries[1].trigger).toBe("birthday gift");
});
test("[list] D3: path suffix with Unicode → is stripped, not captured", () => {
// D3 walkback: downstream consumers (routing-eval.ts skillSlugFromPath
// and check-resolvable.ts:367 manifest derivation) assume the
// skills/<name>/SKILL.md shape. Capturing the explicit path would
// produce silent orphan_trigger warnings + routing-eval coverage
// gaps for that skill. So the suffix is stripped and the path is
// always derived from the name.
const content = "- **quality**: lint the page → `skills/conventions/quality.md`";
const entries = parseResolverEntries(content);
expect(entries.length).toBe(1);
expect(entries[0].trigger).toBe("lint the page");
expect(entries[0].skillPath).toBe("skills/quality/SKILL.md"); // derived, NOT the captured path
});
test("[list] F4: path suffix with ASCII -> is also stripped", () => {
const content = "- **quality**: lint the page -> `skills/conventions/quality.md`";
const entries = parseResolverEntries(content);
expect(entries.length).toBe(1);
expect(entries[0].trigger).toBe("lint the page");
expect(entries[0].skillPath).toBe("skills/quality/SKILL.md");
});
test("[list] ellipsis placeholder is dropped", () => {
const content = `- **foo-skill**: bar | ... | baz`;
const entries = parseResolverEntries(content);
expect(entries.length).toBe(2);
expect(entries.map(e => e.trigger)).toEqual(["bar", "baz"]);
});
test("[list] empty pipe segments are dropped", () => {
const content = `- **foo-skill**: bar | | baz`;
const entries = parseResolverEntries(content);
expect(entries.length).toBe(2);
expect(entries.map(e => e.trigger)).toEqual(["bar", "baz"]);
});
test("[mixed] table and list rows in the same file", () => {
const content = `## Always-on
| Trigger | Skill |
|---------|-------|
| Every message | \`skills/signal-detector/SKILL.md\` |
## Personal
- **gift-advisor**: gift idea | what should I bring`;
const entries = parseResolverEntries(content);
expect(entries.length).toBe(3);
expect(entries[0].skillPath).toBe("skills/signal-detector/SKILL.md");
expect(entries[0].section).toBe("Always-on");
expect(entries[1].skillPath).toBe("skills/gift-advisor/SKILL.md");
expect(entries[1].section).toBe("Personal");
expect(entries[2].section).toBe("Personal");
});
test("[list] section tracking across list entries", () => {
const content = `## Personal
- **gift-advisor**: gift idea
## Travel
- **flight-tracker**: track my flight`;
const entries = parseResolverEntries(content);
expect(entries[0].section).toBe("Personal");
expect(entries[1].section).toBe("Travel");
});
test("[list] D4 REGRESSION: prose bullets do not match as skill rows", () => {
// The kebab-lowercase name regex deliberately rejects bold names
// starting with an uppercase letter. This kills the noise class where
// real AGENTS.md files have prose bullets like `- **Note**: …` that
// would otherwise be parsed as fake skill rows pointing at
// `skills/Note/SKILL.md`. Codex F2; D4 decision.
const content = `- **Note**: this is a prose bullet
- **Convention**: see [some convention link]
- **TODO**: fix this later
- **Important**: read this carefully`;
const entries = parseResolverEntries(content);
expect(entries.length).toBe(0);
});
test("[list] D4 negative: convention-violating uppercase names are silently skipped", () => {
// Documents the trade: a real skill named `MyTool` would not parse.
// The user would notice on the first `gbrain doctor` run and lowercase it.
const content = `- **MyTool**: this trigger silently disappears
- **camelCase**: also silently dropped`;
const entries = parseResolverEntries(content);
expect(entries.length).toBe(0);
});
});
describe("checkResolvable — real skills directory", () => {
const report = checkResolvable(SKILLS_DIR);
test("produces a report with summary", () => {
expect(report.summary.total_skills).toBeGreaterThan(0);
expect(typeof report.ok).toBe("boolean");
expect(Array.isArray(report.issues)).toBe(true);
});
test("all manifest skills are reachable from RESOLVER.md", () => {
const unreachableIssues = report.issues.filter(i => i.type === "unreachable");
if (unreachableIssues.length > 0) {
const names = unreachableIssues.map(i => i.skill).join(", ");
console.warn(`Unreachable skills: ${names}`);
}
// Currently expect all 24 skills to be reachable
expect(report.summary.unreachable).toBe(0);
});
test("no missing files referenced by RESOLVER.md", () => {
const missingFiles = report.issues.filter(i => i.type === "missing_file");
expect(missingFiles.length).toBe(0);
});
test("no orphan triggers (in resolver but not manifest)", () => {
const orphans = report.issues.filter(i => i.type === "orphan_trigger");
expect(orphans.length).toBe(0);
});
test("action strings are specific (contain file paths)", () => {
for (const issue of report.issues) {
expect(issue.action.length).toBeGreaterThan(10);
// Action should mention a file or a specific fix
expect(
issue.action.includes("RESOLVER.md") ||
issue.action.includes("SKILL.md") ||
issue.action.includes("manifest") ||
issue.action.includes("conventions/")
).toBe(true);
}
});
test("unreachable issues have structured fix objects", () => {
const unreachable = report.issues.filter(i => i.type === "unreachable");
for (const issue of unreachable) {
expect(issue.fix).toBeDefined();
expect(issue.fix!.type).toBe("add_trigger");
expect(issue.fix!.file).toContain("RESOLVER.md");
}
});
test("whitelisted skills (ingest, signal-detector, brain-ops) don't trigger MECE overlap", () => {
const overlaps = report.issues.filter(i => i.type === "mece_overlap");
for (const issue of overlaps) {
// The skill field lists the overlapping skills
expect(issue.skill).not.toContain("signal-detector");
expect(issue.skill).not.toContain("brain-ops");
}
});
test("summary counts are consistent", () => {
expect(report.summary.reachable + report.summary.unreachable).toBe(report.summary.total_skills);
});
});
// ---------------------------------------------------------------------------
// DRY detection — proximity-based suppression
// ---------------------------------------------------------------------------
function makeSkillsFixture(files: Record<string, string>): string {
const dir = mkdtempSync(join(tmpdir(), "gbrain-dry-"));
// Minimal RESOLVER.md and manifest.json so checkResolvable doesn't bail.
const skillNames = Object.keys(files);
const resolverRows = skillNames.map(n => `| "${n}" | \`skills/${n}/SKILL.md\` |`).join("\n");
writeFileSync(join(dir, "RESOLVER.md"), `## Test\n| Trigger | Skill |\n|-----|-----|\n${resolverRows}\n`);
writeFileSync(
join(dir, "manifest.json"),
JSON.stringify({ skills: skillNames.map(n => ({ name: n, path: `${n}/SKILL.md` })) }, null, 2)
);
for (const [name, body] of Object.entries(files)) {
mkdirSync(join(dir, name), { recursive: true });
// Skill conformance tests (elsewhere) check for frontmatter + triggers;
// checkResolvable itself only needs the body.
const frontmatter = `---\nname: ${name}\ndescription: test\ntriggers:\n - "${name}"\n---\n`;
writeFileSync(join(dir, name, "SKILL.md"), frontmatter + body);
}
return dir;
}
describe("extractDelegationTargets", () => {
test("parses > **Convention:** callouts", () => {
const refs = extractDelegationTargets(
"> **Convention:** See `skills/conventions/quality.md` for citation rules.\n"
);
expect(refs).toEqual([{ convention: "conventions/quality.md", line: 1 }]);
});
test("parses > **Filing rule:** callouts", () => {
const refs = extractDelegationTargets(
"> **Filing rule:** Read `skills/_brain-filing-rules.md` before any new page.\n"
);
expect(refs).toEqual([{ convention: "_brain-filing-rules.md", line: 1 }]);
});
test("parses inline backtick references", () => {
const refs = extractDelegationTargets(
"some prose.\nSee `skills/conventions/quality.md` for details.\n"
);
expect(refs).toEqual([{ convention: "conventions/quality.md", line: 2 }]);
});
test("ignores backticks pointing outside known delegation targets", () => {
const refs = extractDelegationTargets(
"See `skills/random/README.md` for unrelated notes.\n"
);
expect(refs).toHaveLength(0);
});
test("handles frontmatter-only skill (no body matches)", () => {
const refs = extractDelegationTargets("---\nname: foo\n---\n");
expect(refs).toHaveLength(0);
});
});
describe("DRY detection — checkResolvable", () => {
let dir: string;
afterEachCleanup(() => dir && rmSync(dir, { recursive: true, force: true }));
test("flags inlined notability rule with no reference", () => {
dir = makeSkillsFixture({
bad: "# BadSkill\n\nCheck the notability gate every time.\n",
});
const report = checkResolvable(dir);
const dry = report.issues.filter(i => i.type === "dry_violation");
expect(dry).toHaveLength(1);
expect(dry[0].skill).toBe("bad");
});
test("suppresses DRY when > **Convention:** callout points at quality.md (notability)", () => {
dir = makeSkillsFixture({
good: `# GoodSkill\n\n> **Convention:** See \`skills/conventions/quality.md\` for rules.\n\nCheck the notability gate.\n`,
});
const report = checkResolvable(dir);
const dry = report.issues.filter(i => i.type === "dry_violation");
expect(dry).toHaveLength(0);
});
test("suppresses DRY when _brain-filing-rules.md is referenced for notability", () => {
dir = makeSkillsFixture({
good: `# GoodSkill\n\n> **Filing rule:** Read \`skills/_brain-filing-rules.md\`.\n\nCheck the notability gate.\n`,
});
const report = checkResolvable(dir);
const dry = report.issues.filter(i => i.type === "dry_violation");
expect(dry).toHaveLength(0);
});
test("does NOT suppress when reference is >40 lines from the match", () => {
const filler = Array(50).fill("padding paragraph with no match.").join("\n");
dir = makeSkillsFixture({
distant: `> **Convention:** See \`skills/conventions/quality.md\`.\n\n${filler}\n\nCheck the notability gate now.\n`,
});
const report = checkResolvable(dir);
const dry = report.issues.filter(i => i.type === "dry_violation");
expect(dry).toHaveLength(1);
});
test("DOES suppress when reference is ~30 lines from the match", () => {
const filler = Array(20).fill("padding paragraph with no match.").join("\n");
dir = makeSkillsFixture({
near: `> **Convention:** See \`skills/conventions/quality.md\`.\n\n${filler}\n\nCheck the notability gate now.\n`,
});
const report = checkResolvable(dir);
const dry = report.issues.filter(i => i.type === "dry_violation");
expect(dry).toHaveLength(0);
});
test("iron-law pattern does NOT accept _brain-filing-rules.md as delegation", () => {
// iron-law's only accepted target is conventions/quality.md
dir = makeSkillsFixture({
filing: `> **Filing rule:** Read \`skills/_brain-filing-rules.md\`.\n\n## Iron Law: Back-Linking (MANDATORY)\n`,
});
const report = checkResolvable(dir);
const dry = report.issues.filter(i => i.type === "dry_violation");
expect(dry.length).toBeGreaterThanOrEqual(1);
});
});
describe("v0.22.4 regression — actual repo skills/ has 0 errors", () => {
test("repo skills/ pass check-resolvable cleanly (errors only)", () => {
// The contract for v0.22.4 (Part A) was: zero warnings AND zero
// errors against the actual checked-in skills/ tree.
//
// v0.25.1 update: warnings of type "routing_miss" are now
// ALLOWED. They surface naturally when routing-eval intents are
// paraphrased per the D-CX-6 rule (intent must paraphrase the
// trigger, not copy it). The structural matcher requires
// substring-match against triggers; natural paraphrases legitimately
// miss. The LLM tie-break layer (placeholder per v0.24.0) is the
// intended fix when it ships. Until then, routing_miss is an
// honest warning rather than a regression signal.
//
// Other warning types (trigger overlap, DRY violations, filing-
// rule misses, etc.) STILL fail this test. The test's regression-
// guard intent against those is preserved.
const report = checkResolvable(SKILLS_DIR);
const errors = report.issues.filter(i => i.severity === "error");
const nonRoutingWarnings = report.issues.filter(
i => i.severity === "warning" && i.type !== "routing_miss",
);
expect(errors).toEqual([]);
expect(nonRoutingWarnings).toEqual([]);
});
});
// bun:test has no beforeEach/afterEach at module scope cleanly interacting
// with closures; a small helper keeps cleanup readable and per-test.
function afterEachCleanup(fn: () => void) {
const { afterEach } = require("bun:test");
afterEach(fn);
}