mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* 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>
419 lines
17 KiB
TypeScript
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);
|
|
}
|
|
|