fix(doctor): normalize CRLF in extractTriggers so Windows skill triggers parse (#1149)

On Windows, `core.autocrlf=true` is the default and SKILL.md files are
checked out with CRLF line endings. `extractTriggers` used regexes
anchored to `\n` (`/^---\n.../` and `/^triggers:\s*\n.../`), which
never matched `\r\n`, so the parser returned `[]` for every skill.

Result: `gbrain doctor --fast --json` on Windows reported every skill
not in `OVERLAP_WHITELIST` (39 of 42) as a false `mece_gap` warning —
even though `skill_conformance` in the same run reported "42/42 skills
pass". CI runs Ubuntu-only so the divergence never surfaced.

Fix: normalize CRLF → LF at the top of `extractTriggers`. Single-line
change preserves existing LF behavior. Function is now exported so the
test can target it directly.

Tests: added `describe("extractTriggers")` block covering LF input,
CRLF input (regression case), missing frontmatter, missing triggers
field, and quote-stripping. All 30 tests in `check-resolvable.test.ts`
pass.

Verified locally on Windows: `gbrain doctor --fast --json` now reports
`resolver_health: ok, 42 skills, all reachable` (health_score 90 → 95).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Time Attakc <89218912+time-attack@users.noreply.github.com>
This commit is contained in:
samporter-31
2026-07-22 14:13:54 -07:00
committed by GitHub
co-authored by Claude Opus 4.7 Time Attakc
parent d69f211629
commit 2840734d70
2 changed files with 47 additions and 3 deletions
+13 -3
View File
@@ -217,9 +217,19 @@ export function parseResolverEntries(resolverContent: string): ResolverEntry[] {
// `skillsDir/*/SKILL.md` when manifest.json is missing — the scenario
// needed for AGENTS.md-only OpenClaw deployments. See D-CX-12 / F-ENG-1.
/** Simple YAML frontmatter parser — extracts triggers array if present. */
function extractTriggers(skillContent: string): string[] {
const fmMatch = skillContent.match(/^---\n([\s\S]*?)\n---/);
/**
* Simple YAML frontmatter parser — extracts triggers array if present.
*
* Normalizes CRLF → LF before parsing so Windows checkouts (where
* `core.autocrlf=true` is the default) parse correctly. Without this,
* the `^---\n` and `^triggers:\s*\n` regexes never match because the
* file content is `---\r\n` / `triggers:\r\n`, and every skill on
* Windows is reported as `mece_gap` regardless of its actual content.
* CI runs on Ubuntu-only so the bug only surfaces in user environments.
*/
export function extractTriggers(skillContent: string): string[] {
const content = skillContent.replace(/\r\n/g, '\n');
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (!fmMatch) return [];
const fm = fmMatch[1];
const triggersMatch = fm.match(/^triggers:\s*\n((?:\s+-\s+.+\n?)*)/m);
+34
View File
@@ -6,6 +6,7 @@ import {
checkResolvable,
parseResolverEntries,
extractDelegationTargets,
extractTriggers,
} from "../src/core/check-resolvable.ts";
const SKILLS_DIR = join(import.meta.dir, "..", "skills");
@@ -195,6 +196,39 @@ describe("parseResolverEntries", () => {
});
});
describe("extractTriggers", () => {
const LF_FRONTMATTER =
"---\nname: query\ndescription: Test\ntriggers:\n - \"what do we know\"\n - \"tell me about\"\ntools:\n - search\n---\n\n# Body\n";
test("parses triggers from LF-terminated frontmatter", () => {
const triggers = extractTriggers(LF_FRONTMATTER);
expect(triggers).toEqual(["what do we know", "tell me about"]);
});
test("parses triggers from CRLF-terminated frontmatter (Windows checkouts)", () => {
// Regression: `core.autocrlf=true` is the Windows default. Without
// CRLF→LF normalization, every Windows skill is reported as a false
// mece_gap warning because the `^---\n` regex never matches `---\r\n`.
const crlf = LF_FRONTMATTER.replace(/\n/g, "\r\n");
const triggers = extractTriggers(crlf);
expect(triggers).toEqual(["what do we know", "tell me about"]);
});
test("returns [] when frontmatter is missing", () => {
expect(extractTriggers("# Just a body, no frontmatter\n")).toEqual([]);
});
test("returns [] when triggers field is absent from frontmatter", () => {
const fm = "---\nname: query\ndescription: Test\ntools:\n - search\n---\n";
expect(extractTriggers(fm)).toEqual([]);
});
test("strips surrounding quotes from trigger values", () => {
const fm = "---\nname: x\ntriggers:\n - \"double quoted\"\n - 'single quoted'\n - unquoted\n---\n";
expect(extractTriggers(fm)).toEqual(["double quoted", "single quoted", "unquoted"]);
});
});
describe("checkResolvable — real skills directory", () => {
const report = checkResolvable(SKILLS_DIR);