@felixgeelhaar/cclint
Version:
Catch CLAUDE.md drift before Claude misbehaves. Lints CLAUDE.md, skills, subagents, and hooks for Claude Code projects.
226 lines • 8.14 kB
JavaScript
/**
* Shared YAML-frontmatter parser for Markdown-based Claude Code config files
* (skills, subagents, …).
*
* @remarks
* This is a deliberately small, dependency-free parser that understands the
* subset of YAML frontmatter that Claude Code config files use in practice:
* a `---`…`---` fenced block containing `key: value` scalars, dash lists, and
* inline `[a, b]` arrays. It is a pure domain module — it takes the already
* split `lines` of a file and returns a typed {@link Frontmatter} view. It
* performs no file or network IO.
*
* It intentionally handles the edge cases that the previous per-rule
* hand-rolled parsers disagreed on: quoted values that contain colons, `#`
* comments (full-line and trailing), multiline dash arrays, inline bracket
* arrays, and CRLF line endings.
*/
/**
* Immutable, typed view over a parsed frontmatter block.
*
* Accessor methods interpret the stored raw values so callers never touch
* quoting, bracket, or comment syntax directly.
*/
export class Frontmatter {
fields;
/** Whether the document contained at least one `---` fence line. */
hasFence;
constructor(fields, hasFence) {
this.fields = fields;
this.hasFence = hasFence;
}
/** Whether a key was present in the frontmatter (even with an empty value). */
has(key) {
return this.fields.has(key);
}
/**
* The scalar value for `key`, with surrounding quotes stripped.
*
* Returns `undefined` if the key is absent or was parsed as an array.
* An empty value (`key:` with nothing after it) returns an empty string.
*/
getString(key) {
const value = this.fields.get(key);
if (typeof value !== 'string') {
return undefined;
}
return stripQuotes(value);
}
/**
* The value for `key` interpreted as a list of strings.
*
* Handles all three array shapes:
* - a multiline dash list (`- item`),
* - an inline bracket array (`[a, b, c]`),
* - a bare comma-separated scalar (`a, b`).
*
* Returns `undefined` if the key is absent. Quotes are stripped and empty
* items are dropped.
*/
getStringArray(key) {
const value = this.fields.get(key);
if (value === undefined) {
return undefined;
}
if (typeof value !== 'string') {
return value
.map(item => stripQuotes(item))
.filter(item => item.length > 0);
}
const scalar = value.trim().replace(/^\[|\]$/g, '');
return scalar
.split(',')
.map(item => stripQuotes(item.trim()))
.filter(item => item.length > 0);
}
/**
* The value for `key` interpreted as a boolean.
*
* Only the literal string `true` is truthy; anything else (including an
* absent key) is `false`.
*/
getBoolean(key) {
return this.getString(key) === 'true';
}
}
/**
* Parse the frontmatter block out of a file's already split lines.
*
* @param lines - File content split on `\n` (as produced by `ContextFile`).
* @returns A typed {@link Frontmatter} view; `hasFence` is `false` when no
* `---` delimiter is present.
*/
export class FrontmatterParser {
static parse(lines) {
const fields = new Map();
let hasFence = false;
let inFrontmatter = false;
// Accumulators for the key currently being built.
let currentKey = null;
let currentScalar = '';
let currentArray = null;
const flush = () => {
if (currentKey === null) {
return;
}
fields.set(currentKey, currentArray ?? currentScalar);
currentKey = null;
currentScalar = '';
currentArray = null;
};
for (const line of lines) {
// `trim` also removes a trailing `\r`, so CRLF files parse correctly.
const trimmed = line.trim();
if (trimmed === '---') {
hasFence = true;
if (!inFrontmatter) {
inFrontmatter = true;
continue;
}
// Closing fence: finalize the last key and stop.
flush();
break;
}
if (!inFrontmatter) {
continue;
}
// Full-line comment.
if (trimmed.startsWith('#')) {
continue;
}
// Blank line: not a continuation, just skip it.
if (trimmed === '') {
continue;
}
// Dash list item (belongs to the current key).
if (trimmed.startsWith('-')) {
if (currentKey !== null) {
const item = stripInlineComment(trimmed.slice(1)).trim();
currentArray ??= [];
if (item.length > 0) {
currentArray.push(item);
}
}
continue;
}
// A new `key: value` line. The key must start at column 0 (no leading
// whitespace) — indented lines are handled as items/continuations above.
// No `$` anchor: `.` does not match `\r`, so anchoring to end-of-string
// would fail on CRLF lines. The unmatched trailing `\r` is trimmed off
// the captured value below.
const keyMatch = /^(\w+):\s*(.*)/.exec(line);
if (keyMatch) {
flush();
currentKey = keyMatch[1] ?? '';
const valuePart = stripInlineComment(keyMatch[2] ?? '').trim();
if (valuePart.startsWith('-')) {
// Inline first array item: `key: - item`.
currentArray = [];
const item = valuePart.slice(1).trim();
if (item.length > 0) {
currentArray.push(item);
}
}
else {
currentScalar = valuePart;
}
continue;
}
// Continuation of a scalar value wrapped onto an indented line.
if (currentKey !== null && currentArray === null) {
const continuation = stripInlineComment(line).trim();
if (continuation.length > 0) {
currentScalar =
currentScalar.length > 0
? `${currentScalar} ${continuation}`
: continuation;
}
}
}
return new Frontmatter(fields, hasFence);
}
}
/**
* Remove a trailing `#` comment from a value, honoring quotes.
*
* A `#` starts a comment only when it is at the start of the string or
* preceded by whitespace and is not inside a quoted span — matching YAML's
* comment rule and avoiding false positives like `color#1` or `16:9#tag`.
*/
function stripInlineComment(value) {
let inSingle = false;
let inDouble = false;
for (let i = 0; i < value.length; i++) {
const ch = value[i];
if (ch === "'" && !inDouble) {
inSingle = !inSingle;
}
else if (ch === '"' && !inSingle) {
inDouble = !inDouble;
}
else if (ch === '#' && !inSingle && !inDouble) {
const prev = value[i - 1];
if (i === 0 || (prev !== undefined && /\s/.test(prev))) {
return value.slice(0, i);
}
}
}
return value;
}
/**
* Strip a single matching pair of surrounding quotes from a scalar.
*
* Only outer quotes are removed, so colons and other characters inside a
* quoted value are preserved.
*/
function stripQuotes(value) {
if (value.length >= 2) {
const first = value[0];
const last = value[value.length - 1];
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
return value.slice(1, -1);
}
}
return value;
}
//# sourceMappingURL=FrontmatterParser.js.map