Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion packages/recheck/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,13 @@ The standalone `recheck` binary and the `recheck.yaml` file are not part of this
### Opt-in prose assertions

`conditional`, `metric`, and `spelling` have no single right default.
Add them explicitly under `recheck.rules`:
Add them explicitly under `recheck.rules`.

`metric` scores the prose of a whole file and reports once when the score is outside `min` and `max`.
Its `formula` is a readability formula (`flesch-reading-ease`, `flesch-kincaid-grade`, `gunning-fog`, `smog`, `coleman-liau`, `automated-readability`) or a size formula (`word-count`, `sentence-count`, `reading-time`).
`reading-time` is minutes at `wordsPerMinute`, default 200, rounded to one decimal.
Size formulas count what a person reads: code blocks, front matter, headings, and Markdoc tags are left out.
Use one as a page-size budget, such as a word cap on agent skills:

```yaml
extends:
Expand All @@ -61,6 +67,15 @@ recheck:
metric:
formula: flesch-reading-ease
min: 30
recheck/skill-size-budget:
severity: error
message: 'Document %s is %s; expected between %s and %s.'
appliesTo:
- '.claude/skills/**/SKILL.md'
assertions:
metric:
formula: word-count
max: 1500
recheck/us-spelling-check:
severity: warn
message: 'Unknown word "%s"%s'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,19 +64,47 @@ describe('README "Opt-in prose assertions" snippet', () => {
expect(result.success).toBe(true);
if (!result.success) return;

const metricRules = result.config.rules.filter((rule) => 'metric' in rule.assertions);
expect(metricRules).toHaveLength(1);
const readabilityRules = result.config.rules.filter(
(rule) => rule.name === 'recheck/readability-floor'
);
expect(readabilityRules).toHaveLength(1);

// Dense prose that scores below the snippet's `min: 30`, so the rule fires.
const content =
'Extraordinarily sophisticated organizational considerations necessitate ' +
'comprehensive interdisciplinary collaboration methodologies throughout ' +
'multinational institutional infrastructures.\n';
const { problems } = await runRules([{ path: 'dense.md', content }], metricRules);
const { problems } = await runRules([{ path: 'dense.md', content }], readabilityRules);

expect(problems).toHaveLength(1);
expect(problems[0].message).toMatch(
/^Readability \(flesch-reading-ease\) is -?\d+(\.\d+)?; expected between 30 and ∞\.$/
);
});

it('renders the size-budget snippet message for a skill file over the word cap', async () => {
const snippet = extractOptInSnippet();
const result = await resolveOptInSnippet(snippet);

expect(result.success).toBe(true);
if (!result.success) return;

const sizeRules = result.config.rules.filter(
(rule) => rule.name === 'recheck/skill-size-budget'
);
expect(sizeRules).toHaveLength(1);

const content = 'word '.repeat(1501).trim() + '.\n';
const { problems } = await runRules(
[
{ path: '.claude/skills/demo/SKILL.md', content },
{ path: 'docs/long-page.md', content },
],
sizeRules
);

expect(problems).toHaveLength(1);
expect(problems[0].file).toBe('.claude/skills/demo/SKILL.md');
expect(problems[0].message).toBe('Document word-count is 1501; expected between -∞ and 1500.');
});
});
31 changes: 28 additions & 3 deletions packages/recheck/src/config/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -481,15 +481,21 @@ function validateCapitalizationOptions(
}
}

/** Validates the `metric` options. `formula` is required, and `min` or `max` is needed. */
const METRIC_OPTION_KEYS = new Set(['formula', 'min', 'max']);
/**
* Validates the `metric` options. `formula` is required, and `min` or `max` is needed.
* `wordsPerMinute` is only valid with `reading-time`.
*/
const METRIC_OPTION_KEYS = new Set(['formula', 'min', 'max', 'wordsPerMinute']);
const METRIC_FORMULAS = new Set([
'flesch-reading-ease',
'flesch-kincaid-grade',
'gunning-fog',
'smog',
'coleman-liau',
'automated-readability',
'word-count',
'sentence-count',
'reading-time',
]);

function validateMetricOptions(rule: BaseRule, name: string, errors: ValidationError[]): void {
Expand All @@ -505,7 +511,12 @@ function validateMetricOptions(rule: BaseRule, name: string, errors: ValidationE
}
}

const { formula, min, max } = metricConfig as { formula?: unknown; min?: unknown; max?: unknown };
const { formula, min, max, wordsPerMinute } = metricConfig as {
formula?: unknown;
min?: unknown;
max?: unknown;
wordsPerMinute?: unknown;
};

if (typeof formula !== 'string' || !METRIC_FORMULAS.has(formula)) {
errors.push({
Expand All @@ -514,6 +525,20 @@ function validateMetricOptions(rule: BaseRule, name: string, errors: ValidationE
});
}

if (wordsPerMinute !== undefined) {
if (typeof wordsPerMinute !== 'number' || !(wordsPerMinute > 0)) {
errors.push({
message: 'Metric option "wordsPerMinute" must be a positive number',
path: `${name}.assertions.metric.wordsPerMinute`,
});
} else if (formula !== 'reading-time') {
errors.push({
message: 'Metric option "wordsPerMinute" only applies to formula "reading-time"',
path: `${name}.assertions.metric.wordsPerMinute`,
});
}
}

if (min === undefined && max === undefined) {
errors.push({
message: 'Metric requires at least one of "min" or "max"',
Expand Down
139 changes: 134 additions & 5 deletions packages/recheck/src/rules/scope/__tests__/metric.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,21 +502,24 @@ describe('metric assertion', () => {
'smog',
'coleman-liau',
'automated-readability',
'word-count',
'sentence-count',
'reading-time',
].map((formula): [string, Record<string, unknown>] => [
`formula ${formula}`,
{ formula, min: 1 },
]),
[
'reading-time with a positive wordsPerMinute and a fractional bound',
{ formula: 'reading-time', max: 2.5, wordsPerMinute: 180 },
],
])('accepts %s', async (_label, options) => {
await expectValidOptions('metric', options);
});

it.each<[string, Record<string, unknown>, ...string[]]>([
['a missing formula', { min: 1 }, 'formula'],
[
'a formula outside the six recognized values',
{ formula: 'bogus-formula', min: 1 },
'formula',
],
['an unrecognized formula', { formula: 'bogus-formula', min: 1 }, 'formula'],
['neither min nor max', { formula: 'flesch-reading-ease' }, 'min', 'max'],
[
'an unknown option',
Expand All @@ -527,6 +530,24 @@ describe('metric assertion', () => {
['a non-number max', { formula: 'flesch-reading-ease', max: '30' }, 'max'],
// An inverted range flags every file.
['min greater than max', { formula: 'flesch-reading-ease', min: 60, max: 30 }, 'min', 'max'],
[
'a zero wordsPerMinute',
{ formula: 'reading-time', max: 1, wordsPerMinute: 0 },
'wordsPerMinute',
'positive number',
],
[
'a non-number wordsPerMinute',
{ formula: 'reading-time', max: 1, wordsPerMinute: 'fast' },
'wordsPerMinute',
'positive number',
],
[
'wordsPerMinute with a formula other than reading-time',
{ formula: 'word-count', max: 1, wordsPerMinute: 200 },
'wordsPerMinute',
'only applies to formula "reading-time"',
],
])('rejects %s', async (_label, options, ...mentions) => {
await expectInvalidOptions('metric', options, ...mentions);
});
Expand Down Expand Up @@ -608,3 +629,111 @@ describe('metric prose extraction (readability-standard view)', () => {
expect(score).toBeLessThan(32);
});
});

describe('metric size formulas (word-count, sentence-count)', () => {
it('flags a document whose prose word count exceeds max, with the size fallback message', async () => {
const content = 'One two three four.\n\n- five six\n';
const rule = metricRule(undefined, { formula: 'word-count', max: 5 });

const problems = await metric.execute(rule, 'test.md', buildMetricContext(content));

expect(problems).toHaveLength(1);
expect(problems[0]).toMatchObject({ line: 1, column: 1 });
expect(problems[0].message).toBe('Document word-count is 6; expected between -∞ and 5.');
});

it('does not flag a document at max', async () => {
const rule = metricRule(undefined, { formula: 'word-count', max: 5 });

const problems = await metric.execute(
rule,
'test.md',
buildMetricContext('One two three four five.\n')
);

expect(problems).toEqual([]);
});

it('counts only prose: headings, code, frontmatter, and inline code are excluded', async () => {
const content = [
'---',
'title: Alpha beta gamma delta',
'---',
'',
'# Heading words',
'',
'One `two three four` five.',
'',
'```',
'code code code code code code',
'```',
'',
].join('\n');
const rule = metricRule('%s is %s', { formula: 'word-count', max: 1 });

const problems = await metric.execute(rule, 'test.md', buildMetricContext(content));

expect(problems[0].message).toBe('word-count is 2');
});

it('counts sentences, with every block ending a sentence', async () => {
const content = 'First one. Second one.\n\n- an unpunctuated item\n- another item\n';
const rule = metricRule('%s is %s', { formula: 'sentence-count', max: 3 });

const problems = await metric.execute(rule, 'test.md', buildMetricContext(content));

expect(problems[0].message).toBe('sentence-count is 4');
});

it('never flags a document with no prose', async () => {
const rule = metricRule(undefined, { formula: 'word-count', min: 10 });

const problems = await metric.execute(rule, 'test.md', buildMetricContext('```\ncode\n```\n'));

expect(problems).toEqual([]);
});

it('forces scope summary on a size formula rule', async () => {
const result = await validate({
'recheck/size': {
severity: 'error',
message: '%s is %s',
assertions: { metric: { formula: 'word-count', max: 10 } },
},
});
expect(result.errors).toEqual([]);
expect(result.rules[0].scope).toBe('summary');
});
});

describe('metric reading-time formula', () => {
const tenWords = 'one two three four five six seven eight nine ten.';
const paragraphs = (count: number) =>
Array.from({ length: count }, () => tenWords).join('\n\n') + '\n';

it('reports minutes at wordsPerMinute', async () => {
const rule = metricRule('%s is %s', { formula: 'reading-time', max: 1, wordsPerMinute: 100 });

const problems = await metric.execute(rule, 'test.md', buildMetricContext(paragraphs(25)));

expect(problems[0].message).toBe('reading-time is 2.5');
});

it('defaults wordsPerMinute to 200', async () => {
const rule = metricRule('%s is %s', { formula: 'reading-time', max: 1 });

const problems = await metric.execute(rule, 'test.md', buildMetricContext(paragraphs(40)));

expect(problems[0].message).toBe('reading-time is 2');
});

// 201 words at 200 words per minute is 1.005 minutes, which rounds to 1 and passes `max: 1`.
it('rounds to one decimal before comparing, so the message never contradicts the verdict', async () => {
const content = `${tenWords}\n`.repeat(20) + 'eleven.\n';
const rule = metricRule(undefined, { formula: 'reading-time', max: 1 });

const problems = await metric.execute(rule, 'test.md', buildMetricContext(content));

expect(problems).toEqual([]);
});
});
36 changes: 31 additions & 5 deletions packages/recheck/src/rules/scope/metric.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,33 @@
import { extractProse } from '../../core/prose-extract.js';
import { computeTextStatistics, computeReadability } from '../../metrics/index.js';
import type { NormalizedRule, Problem, MetricAssertion } from '../../types/index.js';
import {
computeTextStatistics,
computeReadability,
type TextStatistics,
} from '../../metrics/index.js';
import type { NormalizedRule, Problem, MetricAssertion, SizeFormula } from '../../types/index.js';
import { formatTemplate } from '../token/messages.js';
import type { ScopeRule, ScopeRuleContext } from '../types.js';

const FALLBACK_MESSAGE = 'Readability (%s) is %s; expected between %s and %s.';
const SIZE_FALLBACK_MESSAGE = 'Document %s is %s; expected between %s and %s.';

const DEFAULT_WORDS_PER_MINUTE = 200;

// Reading time is rounded to one decimal before the bounds check, so the number in the
// message is the number that was compared.
const SIZE_FORMULAS: Record<
SizeFormula,
(stats: TextStatistics, options: MetricAssertion) => number
> = {
'word-count': (stats) => stats.words,
'sentence-count': (stats) => stats.sentences,
'reading-time': (stats, options) =>
Math.round((stats.words / (options.wordsPerMinute ?? DEFAULT_WORDS_PER_MINUTE)) * 10) / 10,
};

function isSizeFormula(formula: MetricAssertion['formula']): formula is SizeFormula {
return formula in SIZE_FORMULAS;
}

const execute = async (
rule: NormalizedRule,
Expand Down Expand Up @@ -33,7 +56,10 @@ const execute = async (
// so a file without prose must not be flagged as too low.
if (stats.words === 0 || stats.sentences === 0) return [];

const score = computeReadability(options.formula, stats);
const { formula } = options;
const score = isSizeFormula(formula)
? SIZE_FORMULAS[formula](stats, options)
: computeReadability(formula, stats);
const tooLow = options.min !== undefined && score < options.min;
const tooHigh = options.max !== undefined && score > options.max;
if (!tooLow && !tooHigh) return [];
Expand All @@ -49,8 +75,8 @@ const execute = async (
ruleName: rule.name,
severity: rule.severity,
message: formatTemplate(
rule.message ?? FALLBACK_MESSAGE,
options.formula,
rule.message ?? (isSizeFormula(formula) ? SIZE_FALLBACK_MESSAGE : FALLBACK_MESSAGE),
formula,
String(score),
options.min !== undefined ? String(options.min) : '-∞',
options.max !== undefined ? String(options.max) : '∞'
Expand Down
12 changes: 9 additions & 3 deletions packages/recheck/src/types/assertions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,18 @@ export interface CapitalizationAssertion {
builtinVocabulary?: boolean;
}

// Scores the whole document's prose with a readability formula and flags the file once when
// the score is outside `[min, max]`. At least one of `min` and `max` is required.
// Size formulas measure how much prose a document has instead of how readable it is.
// `reading-time` is minutes at `wordsPerMinute`, rounded to one decimal.
export type SizeFormula = 'word-count' | 'sentence-count' | 'reading-time';

// Scores the whole document's prose with a readability or size formula and flags the file once
// when the score is outside `[min, max]`. At least one of `min` and `max` is required.
export interface MetricAssertion {
formula: ReadabilityFormula;
formula: ReadabilityFormula | SizeFormula;
min?: number;
max?: number;
/** Reading speed for `reading-time`, default 200. Not valid with any other formula. */
wordsPerMinute?: number;
}

// Flags words that nspell (Hunspell) does not recognize, with up to three suggestions.
Expand Down
Loading