mirror of
https://github.com/affaan-m/ECC.git
synced 2026-08-20 22:42:22 +02:00
639 lines
18 KiB
JavaScript
639 lines
18 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Bounded, project-local loader for `.claude/hookify.*.local.md` rules.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { TextDecoder } = require('util');
|
|
|
|
const LIMITS = Object.freeze({
|
|
maxConditionCount: 16,
|
|
maxDirectoryEntries: 256,
|
|
maxFileBytes: 64 * 1024,
|
|
maxMessageBytes: 4096,
|
|
maxPatternLength: 512,
|
|
maxRuleFiles: 64,
|
|
maxToolMatcherLength: 256,
|
|
maxTotalBytes: 512 * 1024,
|
|
});
|
|
|
|
const FILE_NAME_PATTERN = /^hookify\.[A-Za-z0-9][A-Za-z0-9._-]{0,95}\.local\.md$/;
|
|
const RULE_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
const TOOL_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_.:-]{0,63}$/;
|
|
const TOP_LEVEL_FIELDS = new Set([
|
|
'name',
|
|
'enabled',
|
|
'event',
|
|
'pattern',
|
|
'conditions',
|
|
'action',
|
|
'tool_matcher',
|
|
]);
|
|
const CONDITION_FIELDS = new Set(['field', 'operator', 'pattern']);
|
|
const EVENTS = new Set(['bash', 'file', 'stop', 'prompt', 'all']);
|
|
const ACTIONS = new Set(['warn', 'block']);
|
|
const OPERATORS = new Set([
|
|
'regex_match',
|
|
'contains',
|
|
'equals',
|
|
'not_contains',
|
|
'starts_with',
|
|
'ends_with',
|
|
]);
|
|
const EVENT_FIELDS = Object.freeze({
|
|
bash: new Set(['command']),
|
|
file: new Set(['file_path', 'new_text', 'old_text', 'content']),
|
|
prompt: new Set(['user_prompt']),
|
|
stop: new Set(['content']),
|
|
all: new Set(['command', 'file_path', 'new_text', 'old_text', 'content', 'user_prompt']),
|
|
});
|
|
const MODEL_FACING_FORMAT_CONTROL = /[\u200B-\u200F\u2028\u2029\u202A-\u202E\u2066-\u2069\uFEFF]/u;
|
|
|
|
function diagnostic(code, fileName, detail) {
|
|
const label = fileName ? ` ${fileName}` : '';
|
|
const suffix = detail ? `: ${detail}` : '.';
|
|
return {
|
|
code,
|
|
message: `Hookify skipped${label}${suffix}`,
|
|
};
|
|
}
|
|
|
|
function hasUnsafeControlCharacters(value, allowNewlines = false) {
|
|
for (let index = 0; index < value.length; index += 1) {
|
|
const code = value.charCodeAt(index);
|
|
if (code === 0x7f) return true;
|
|
if (code >= 0x20) continue;
|
|
if (allowNewlines && (code === 0x09 || code === 0x0a || code === 0x0d)) {
|
|
continue;
|
|
}
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function hasUnsafeModelFacingCharacters(value) {
|
|
return MODEL_FACING_FORMAT_CONTROL.test(value);
|
|
}
|
|
|
|
function parseQuotedScalar(rawValue) {
|
|
if (rawValue.startsWith('"')) {
|
|
if (!rawValue.endsWith('"')) {
|
|
throw new Error('unterminated double-quoted value');
|
|
}
|
|
try {
|
|
return JSON.parse(rawValue);
|
|
} catch {
|
|
throw new Error('invalid double-quoted escape');
|
|
}
|
|
}
|
|
|
|
if (rawValue.startsWith("'")) {
|
|
if (!rawValue.endsWith("'")) {
|
|
throw new Error('unterminated single-quoted value');
|
|
}
|
|
return rawValue.slice(1, -1).replace(/''/g, "'");
|
|
}
|
|
|
|
return rawValue;
|
|
}
|
|
|
|
function parseScalar(rawValue) {
|
|
const value = rawValue.trim();
|
|
if (!value) throw new Error('empty scalar');
|
|
if (value === 'true') return true;
|
|
if (value === 'false') return false;
|
|
if (/^[&*!][A-Za-z0-9_-]+(?:\s|$)/.test(value)) {
|
|
throw new Error('YAML tags, anchors, and aliases are not supported');
|
|
}
|
|
return parseQuotedScalar(value);
|
|
}
|
|
|
|
function setUnique(target, key, value) {
|
|
if (Object.prototype.hasOwnProperty.call(target, key)) {
|
|
throw new Error(`duplicate field ${key}`);
|
|
}
|
|
target[key] = value;
|
|
}
|
|
|
|
function parseFrontmatter(frontmatterText) {
|
|
const result = {};
|
|
const lines = frontmatterText.split('\n');
|
|
let conditions = null;
|
|
let currentCondition = null;
|
|
let insideConditions = false;
|
|
|
|
for (const originalLine of lines) {
|
|
const line = originalLine.endsWith('\r')
|
|
? originalLine.slice(0, -1)
|
|
: originalLine;
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
|
|
const topLevel = line.match(/^([a-z_]+):(?:[ \t]*(.*))?$/);
|
|
if (topLevel) {
|
|
const [, key, rawValue = ''] = topLevel;
|
|
if (!TOP_LEVEL_FIELDS.has(key)) throw new Error(`unknown field ${key}`);
|
|
if (key === 'conditions') {
|
|
if (rawValue.trim()) throw new Error('conditions must be a list');
|
|
if (conditions !== null) throw new Error('duplicate field conditions');
|
|
conditions = [];
|
|
result.conditions = conditions;
|
|
currentCondition = null;
|
|
insideConditions = true;
|
|
} else {
|
|
setUnique(result, key, parseScalar(rawValue));
|
|
currentCondition = null;
|
|
insideConditions = false;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const listStart = line.match(/^ {2}- ([a-z_]+):(?:[ \t]*(.*))?$/);
|
|
if (listStart && insideConditions && conditions) {
|
|
const [, key, rawValue = ''] = listStart;
|
|
if (!CONDITION_FIELDS.has(key)) throw new Error(`unknown condition field ${key}`);
|
|
currentCondition = {};
|
|
conditions.push(currentCondition);
|
|
setUnique(currentCondition, key, parseScalar(rawValue));
|
|
continue;
|
|
}
|
|
|
|
const continuation = line.match(/^ {4}([a-z_]+):(?:[ \t]*(.*))?$/);
|
|
if (continuation && insideConditions && currentCondition) {
|
|
const [, key, rawValue = ''] = continuation;
|
|
if (!CONDITION_FIELDS.has(key)) throw new Error(`unknown condition field ${key}`);
|
|
setUnique(currentCondition, key, parseScalar(rawValue));
|
|
continue;
|
|
}
|
|
|
|
throw new Error('unsupported YAML structure');
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function extractDocument(source) {
|
|
const normalized = source.replace(/\r\n/g, '\n');
|
|
const lines = normalized.split('\n');
|
|
if (lines[0] !== '---') throw new Error('missing opening frontmatter delimiter');
|
|
const closingIndex = lines.indexOf('---', 1);
|
|
if (closingIndex < 0) throw new Error('missing closing frontmatter delimiter');
|
|
|
|
return {
|
|
frontmatter: parseFrontmatter(lines.slice(1, closingIndex).join('\n')),
|
|
message: lines.slice(closingIndex + 1).join('\n').trim(),
|
|
};
|
|
}
|
|
|
|
function validateString(value, field, options = {}) {
|
|
if (typeof value !== 'string' || !value.trim()) {
|
|
throw new Error(`${field} must be a non-empty string`);
|
|
}
|
|
if (hasUnsafeControlCharacters(value, options.allowNewlines === true)) {
|
|
throw new Error(`${field} contains control characters`);
|
|
}
|
|
if (options.modelFacing === true && hasUnsafeModelFacingCharacters(value)) {
|
|
throw new Error(`${field} contains unsafe invisible or bidirectional characters`);
|
|
}
|
|
if (options.maxLength && value.length > options.maxLength) {
|
|
throw new Error(`${field} exceeds its length limit`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function validateToolMatcher(value) {
|
|
if (value === undefined) return null;
|
|
validateString(value, 'tool_matcher', { maxLength: LIMITS.maxToolMatcherLength });
|
|
if (value === '*') return value;
|
|
|
|
const tools = value.split('|');
|
|
if (
|
|
tools.length === 0 ||
|
|
tools.some(tool => tool !== tool.trim() || !TOOL_NAME_PATTERN.test(tool))
|
|
) {
|
|
throw new Error('tool_matcher must contain exact pipe-separated tool names');
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function inferredField(event) {
|
|
if (event === 'bash') return 'command';
|
|
if (event === 'file') return 'file_path';
|
|
if (event === 'prompt') return 'user_prompt';
|
|
return 'content';
|
|
}
|
|
|
|
function validateCondition(condition, event) {
|
|
if (
|
|
!condition ||
|
|
typeof condition !== 'object' ||
|
|
Array.isArray(condition) ||
|
|
Object.keys(condition).length !== CONDITION_FIELDS.size ||
|
|
![...CONDITION_FIELDS].every(field => Object.prototype.hasOwnProperty.call(condition, field))
|
|
) {
|
|
throw new Error('each condition requires only field, operator, and pattern');
|
|
}
|
|
|
|
const field = validateString(condition.field, 'condition field');
|
|
const operator = validateString(condition.operator, 'condition operator');
|
|
const pattern = validateString(condition.pattern, 'condition pattern', {
|
|
maxLength: LIMITS.maxPatternLength,
|
|
});
|
|
if (!EVENT_FIELDS[event].has(field)) {
|
|
throw new Error(`condition field ${field} is not valid for event ${event}`);
|
|
}
|
|
if (!OPERATORS.has(operator)) throw new Error(`unsupported condition operator ${operator}`);
|
|
return { field, operator, pattern };
|
|
}
|
|
|
|
function validateRule(frontmatter, message, source) {
|
|
const keys = Object.keys(frontmatter);
|
|
for (const field of ['name', 'enabled', 'event']) {
|
|
if (!keys.includes(field)) throw new Error(`missing required field ${field}`);
|
|
}
|
|
|
|
const name = validateString(frontmatter.name, 'name', { maxLength: 80 });
|
|
if (!RULE_NAME_PATTERN.test(name)) {
|
|
throw new Error('name must be lower-case kebab-case');
|
|
}
|
|
if (typeof frontmatter.enabled !== 'boolean') {
|
|
throw new Error('enabled must be true or false');
|
|
}
|
|
|
|
const event = validateString(frontmatter.event, 'event');
|
|
if (!EVENTS.has(event)) throw new Error(`unsupported event ${event}`);
|
|
const action = frontmatter.action === undefined
|
|
? 'warn'
|
|
: validateString(frontmatter.action, 'action');
|
|
if (!ACTIONS.has(action)) throw new Error(`unsupported action ${action}`);
|
|
|
|
const hasPattern = Object.prototype.hasOwnProperty.call(frontmatter, 'pattern');
|
|
const hasConditions = Object.prototype.hasOwnProperty.call(frontmatter, 'conditions');
|
|
if (hasPattern === hasConditions) {
|
|
throw new Error('define exactly one of pattern or conditions');
|
|
}
|
|
|
|
let conditions;
|
|
let pattern = null;
|
|
if (hasPattern) {
|
|
pattern = validateString(frontmatter.pattern, 'pattern', {
|
|
maxLength: LIMITS.maxPatternLength,
|
|
});
|
|
conditions = [{
|
|
field: inferredField(event),
|
|
operator: 'regex_match',
|
|
pattern,
|
|
}];
|
|
} else {
|
|
if (
|
|
!Array.isArray(frontmatter.conditions) ||
|
|
frontmatter.conditions.length === 0 ||
|
|
frontmatter.conditions.length > LIMITS.maxConditionCount
|
|
) {
|
|
throw new Error(`conditions must contain 1-${LIMITS.maxConditionCount} items`);
|
|
}
|
|
conditions = frontmatter.conditions.map(condition => validateCondition(condition, event));
|
|
}
|
|
|
|
validateString(message, 'message', {
|
|
allowNewlines: true,
|
|
modelFacing: true,
|
|
});
|
|
if (Buffer.byteLength(message, 'utf8') > LIMITS.maxMessageBytes) {
|
|
throw new Error('message exceeds its byte limit');
|
|
}
|
|
|
|
return Object.freeze({
|
|
name,
|
|
enabled: frontmatter.enabled,
|
|
event,
|
|
action,
|
|
pattern,
|
|
conditions: Object.freeze(conditions.map(condition => Object.freeze(condition))),
|
|
toolMatcher: validateToolMatcher(frontmatter.tool_matcher),
|
|
message,
|
|
source,
|
|
});
|
|
}
|
|
|
|
function readFileBounded(fileDescriptor, maxBytes) {
|
|
const buffer = Buffer.alloc(maxBytes + 1);
|
|
let offset = 0;
|
|
while (offset < buffer.length) {
|
|
const bytesRead = fs.readSync(
|
|
fileDescriptor,
|
|
buffer,
|
|
offset,
|
|
buffer.length - offset,
|
|
null
|
|
);
|
|
if (bytesRead === 0) break;
|
|
offset += bytesRead;
|
|
}
|
|
return {
|
|
buffer: buffer.subarray(0, Math.min(offset, maxBytes)),
|
|
exceeded: offset > maxBytes,
|
|
};
|
|
}
|
|
|
|
function isRuleFileReadError(error) {
|
|
return ['EACCES', 'EIO', 'EISDIR', 'EPERM'].includes(error?.code);
|
|
}
|
|
|
|
function loadRuleFile({
|
|
claudeDir,
|
|
fileName,
|
|
remainingTotalBytes,
|
|
expectedRealDirectory,
|
|
}) {
|
|
const remainingBytes =
|
|
Number.isInteger(remainingTotalBytes) && remainingTotalBytes >= 0
|
|
? Math.min(remainingTotalBytes, LIMITS.maxTotalBytes)
|
|
: LIMITS.maxTotalBytes;
|
|
let consumedBytes = 0;
|
|
if (
|
|
typeof claudeDir !== 'string' ||
|
|
typeof fileName !== 'string' ||
|
|
path.basename(fileName) !== fileName ||
|
|
!FILE_NAME_PATTERN.test(fileName)
|
|
) {
|
|
return {
|
|
rule: null,
|
|
diagnostic: diagnostic(
|
|
'HOOKIFY_RULE_FILE_UNSAFE',
|
|
FILE_NAME_PATTERN.test(String(fileName || '')) ? fileName : null,
|
|
'unsafe rule path rejected'
|
|
),
|
|
bytesRead: 0,
|
|
};
|
|
}
|
|
|
|
const filePath = path.join(claudeDir, fileName);
|
|
let fileDescriptor;
|
|
try {
|
|
const noFollow = fs.constants.O_NOFOLLOW || 0;
|
|
try {
|
|
fileDescriptor = fs.openSync(filePath, fs.constants.O_RDONLY | noFollow);
|
|
} catch (error) {
|
|
if (
|
|
error &&
|
|
['ELOOP', 'EMLINK', 'ENOENT', 'ENOTDIR'].includes(error.code)
|
|
) {
|
|
return {
|
|
rule: null,
|
|
diagnostic: diagnostic('HOOKIFY_RULE_FILE_UNSAFE', fileName, 'not a regular file'),
|
|
bytesRead: 0,
|
|
};
|
|
}
|
|
throw error;
|
|
}
|
|
const fileStat = fs.fstatSync(fileDescriptor);
|
|
if (!fileStat.isFile()) {
|
|
return {
|
|
rule: null,
|
|
diagnostic: diagnostic('HOOKIFY_RULE_FILE_UNSAFE', fileName, 'not a regular file'),
|
|
bytesRead: 0,
|
|
};
|
|
}
|
|
|
|
const linkStat = fs.lstatSync(filePath);
|
|
if (linkStat.isSymbolicLink() || !linkStat.isFile()) {
|
|
return {
|
|
rule: null,
|
|
diagnostic: diagnostic('HOOKIFY_RULE_FILE_UNSAFE', fileName, 'not a regular file'),
|
|
bytesRead: 0,
|
|
};
|
|
}
|
|
if (
|
|
fileStat.dev !== linkStat.dev ||
|
|
fileStat.ino !== linkStat.ino ||
|
|
fileStat.mode !== linkStat.mode ||
|
|
fileStat.size !== linkStat.size ||
|
|
fileStat.mtimeMs !== linkStat.mtimeMs
|
|
) {
|
|
return {
|
|
rule: null,
|
|
diagnostic: diagnostic(
|
|
'HOOKIFY_RULE_FILE_UNSAFE',
|
|
fileName,
|
|
'rule identity changed during evaluation'
|
|
),
|
|
bytesRead: 0,
|
|
};
|
|
}
|
|
|
|
const realDirectory = fs.realpathSync(claudeDir);
|
|
if (expectedRealDirectory && realDirectory !== expectedRealDirectory) {
|
|
return {
|
|
rule: null,
|
|
diagnostic: diagnostic(
|
|
'HOOKIFY_RULE_FILE_UNSAFE',
|
|
fileName,
|
|
'project .claude changed during evaluation'
|
|
),
|
|
bytesRead: 0,
|
|
};
|
|
}
|
|
const realFile = fs.realpathSync(filePath);
|
|
if (
|
|
path.dirname(realFile) !== realDirectory ||
|
|
realFile !== path.join(realDirectory, fileName)
|
|
) {
|
|
return {
|
|
rule: null,
|
|
diagnostic: diagnostic(
|
|
'HOOKIFY_RULE_FILE_UNSAFE',
|
|
fileName,
|
|
'resolved outside project .claude'
|
|
),
|
|
bytesRead: 0,
|
|
};
|
|
}
|
|
if (fileStat.size > LIMITS.maxFileBytes) {
|
|
return {
|
|
rule: null,
|
|
diagnostic: diagnostic('HOOKIFY_RULE_LIMIT', fileName, 'file exceeds byte limit'),
|
|
bytesRead: 0,
|
|
};
|
|
}
|
|
if (fileStat.size > remainingBytes) {
|
|
return {
|
|
rule: null,
|
|
diagnostic: diagnostic('HOOKIFY_RULE_LIMIT', fileName, 'total rule byte limit reached'),
|
|
bytesRead: 0,
|
|
};
|
|
}
|
|
|
|
const read = readFileBounded(
|
|
fileDescriptor,
|
|
Math.min(LIMITS.maxFileBytes, remainingBytes)
|
|
);
|
|
consumedBytes = read.buffer.length;
|
|
if (read.exceeded) {
|
|
return {
|
|
rule: null,
|
|
diagnostic: diagnostic('HOOKIFY_RULE_LIMIT', fileName, 'rule byte limit reached'),
|
|
bytesRead: consumedBytes,
|
|
};
|
|
}
|
|
|
|
const source = new TextDecoder('utf-8', { fatal: true }).decode(read.buffer);
|
|
const document = extractDocument(source);
|
|
return {
|
|
rule: validateRule(document.frontmatter, document.message, fileName),
|
|
diagnostic: null,
|
|
bytesRead: consumedBytes,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
rule: null,
|
|
diagnostic: isRuleFileReadError(error)
|
|
? diagnostic('HOOKIFY_RULE_READ_FAILED', fileName, 'could not read rule file')
|
|
: diagnostic('HOOKIFY_RULE_INVALID', fileName, 'invalid rule schema or encoding'),
|
|
bytesRead: consumedBytes,
|
|
};
|
|
} finally {
|
|
if (fileDescriptor !== undefined) {
|
|
try {
|
|
fs.closeSync(fileDescriptor);
|
|
} catch {
|
|
// The descriptor is already unusable; the rule still fails open.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function listRuleNames(claudeDir) {
|
|
const names = [];
|
|
let scanned = 0;
|
|
let exceededDirectoryLimit = false;
|
|
const directory = fs.opendirSync(claudeDir);
|
|
try {
|
|
let entry;
|
|
while ((entry = directory.readSync()) !== null) {
|
|
scanned += 1;
|
|
if (scanned > LIMITS.maxDirectoryEntries) {
|
|
exceededDirectoryLimit = true;
|
|
break;
|
|
}
|
|
if (FILE_NAME_PATTERN.test(entry.name)) names.push(entry.name);
|
|
}
|
|
} finally {
|
|
directory.closeSync();
|
|
}
|
|
|
|
names.sort();
|
|
return {
|
|
names: names.slice(0, LIMITS.maxRuleFiles),
|
|
exceeded: exceededDirectoryLimit || names.length > LIMITS.maxRuleFiles,
|
|
};
|
|
}
|
|
|
|
function loadRules(options = {}) {
|
|
const projectRoot = path.resolve(options.projectRoot || process.cwd());
|
|
const claudeDir = path.join(projectRoot, '.claude');
|
|
const diagnostics = [];
|
|
const rules = [];
|
|
let totalBytes = 0;
|
|
let realClaudeDir;
|
|
|
|
try {
|
|
const directoryStat = fs.lstatSync(claudeDir);
|
|
if (directoryStat.isSymbolicLink() || !directoryStat.isDirectory()) {
|
|
return {
|
|
rules,
|
|
diagnostics: [
|
|
diagnostic(
|
|
'HOOKIFY_RULE_DIRECTORY_UNSAFE',
|
|
null,
|
|
'project .claude must be a real directory'
|
|
),
|
|
],
|
|
totalBytes,
|
|
};
|
|
}
|
|
// Parent project paths can themselves have platform aliases (for example
|
|
// `/var` -> `/private/var` on macOS). Compare real paths while still
|
|
// requiring `.claude` itself to be the direct child checked by lstat.
|
|
const realProjectRoot = fs.realpathSync(projectRoot);
|
|
realClaudeDir = fs.realpathSync(claudeDir);
|
|
if (realClaudeDir !== path.join(realProjectRoot, '.claude')) {
|
|
return {
|
|
rules,
|
|
diagnostics: [
|
|
diagnostic(
|
|
'HOOKIFY_RULE_DIRECTORY_UNSAFE',
|
|
null,
|
|
'project .claude resolved outside the project'
|
|
),
|
|
],
|
|
totalBytes,
|
|
};
|
|
}
|
|
} catch (error) {
|
|
if (error && error.code === 'ENOENT') return { rules, diagnostics, totalBytes };
|
|
return {
|
|
rules,
|
|
diagnostics: [
|
|
diagnostic(
|
|
'HOOKIFY_RULE_DIRECTORY_UNSAFE',
|
|
null,
|
|
'project .claude could not be inspected'
|
|
),
|
|
],
|
|
totalBytes,
|
|
};
|
|
}
|
|
|
|
let listed;
|
|
try {
|
|
listed = listRuleNames(claudeDir);
|
|
} catch {
|
|
return {
|
|
rules,
|
|
diagnostics: [
|
|
diagnostic('HOOKIFY_RULE_DIRECTORY_UNSAFE', null, 'project .claude could not be read'),
|
|
],
|
|
totalBytes,
|
|
};
|
|
}
|
|
if (listed.exceeded) {
|
|
diagnostics.push(
|
|
diagnostic('HOOKIFY_RULE_LIMIT', null, 'rule or directory entry count limit reached')
|
|
);
|
|
}
|
|
|
|
for (const fileName of listed.names) {
|
|
const loaded = loadRuleFile({
|
|
claudeDir,
|
|
fileName,
|
|
remainingTotalBytes: LIMITS.maxTotalBytes - totalBytes,
|
|
expectedRealDirectory: realClaudeDir,
|
|
});
|
|
totalBytes += loaded.bytesRead;
|
|
if (loaded.diagnostic) {
|
|
diagnostics.push(loaded.diagnostic);
|
|
continue;
|
|
}
|
|
const rule = loaded.rule;
|
|
if (!rule.enabled) continue;
|
|
if (options.event && rule.event !== 'all' && rule.event !== options.event) continue;
|
|
if (!options.event && rule.event !== 'all') continue;
|
|
rules.push(rule);
|
|
}
|
|
|
|
return { rules, diagnostics, totalBytes };
|
|
}
|
|
|
|
module.exports = {
|
|
LIMITS,
|
|
extractDocument,
|
|
loadRuleFile,
|
|
loadRules,
|
|
parseFrontmatter,
|
|
validateRule,
|
|
};
|