From b6834489961a1fcdabf7d2c3af9a827e73ca13ae Mon Sep 17 00:00:00 2001 From: Adam Altman Date: Sat, 3 Oct 2026 00:33:36 -0500 Subject: [PATCH 1/2] feat(recheck): add word-count, sentence-count, and reading-time formulas to the metric assertion --- .changeset/recheck-metric-size-formulas.md | 8 + packages/recheck/README.md | 17 ++- .../__tests__/readme-prose-opt-ins.test.ts | 34 ++++- packages/recheck/src/config/validate.ts | 31 +++- .../src/rules/scope/__tests__/metric.test.ts | 139 +++++++++++++++++- packages/recheck/src/rules/scope/metric.ts | 36 ++++- packages/recheck/src/types/assertions.ts | 12 +- 7 files changed, 257 insertions(+), 20 deletions(-) create mode 100644 .changeset/recheck-metric-size-formulas.md diff --git a/.changeset/recheck-metric-size-formulas.md b/.changeset/recheck-metric-size-formulas.md new file mode 100644 index 0000000000..6f2681b956 --- /dev/null +++ b/.changeset/recheck-metric-size-formulas.md @@ -0,0 +1,8 @@ +--- +'@redocly/recheck': minor +'@redocly/cli': minor +--- + +Added the `word-count`, `sentence-count`, and `reading-time` formulas to the `metric` assertion of `recheck`, for per-file content length and reading-time budgets. +They count what a person reads, leaving out code blocks, front matter, headings, and Markdoc tags, and report once per file. +`reading-time` is minutes at the new `wordsPerMinute` option, default 200. diff --git a/packages/recheck/README.md b/packages/recheck/README.md index 2a1d0ba804..01adaa95d8 100644 --- a/packages/recheck/README.md +++ b/packages/recheck/README.md @@ -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: @@ -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' diff --git a/packages/recheck/src/config/__tests__/readme-prose-opt-ins.test.ts b/packages/recheck/src/config/__tests__/readme-prose-opt-ins.test.ts index e98be91e27..4f6373fa7a 100644 --- a/packages/recheck/src/config/__tests__/readme-prose-opt-ins.test.ts +++ b/packages/recheck/src/config/__tests__/readme-prose-opt-ins.test.ts @@ -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.'); + }); }); diff --git a/packages/recheck/src/config/validate.ts b/packages/recheck/src/config/validate.ts index 8a6b2bdd69..50e6560849 100644 --- a/packages/recheck/src/config/validate.ts +++ b/packages/recheck/src/config/validate.ts @@ -459,8 +459,11 @@ 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', @@ -468,6 +471,9 @@ const METRIC_FORMULAS = new Set([ 'smog', 'coleman-liau', 'automated-readability', + 'word-count', + 'sentence-count', + 'reading-time', ]); function validateMetricOptions(rule: BaseRule, name: string, errors: ValidationError[]): void { @@ -483,7 +489,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({ @@ -492,6 +503,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"', diff --git a/packages/recheck/src/rules/scope/__tests__/metric.test.ts b/packages/recheck/src/rules/scope/__tests__/metric.test.ts index 18baf5c798..0c2661b436 100644 --- a/packages/recheck/src/rules/scope/__tests__/metric.test.ts +++ b/packages/recheck/src/rules/scope/__tests__/metric.test.ts @@ -502,21 +502,24 @@ describe('metric assertion', () => { 'smog', 'coleman-liau', 'automated-readability', + 'word-count', + 'sentence-count', + 'reading-time', ].map((formula): [string, Record] => [ `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[]]>([ ['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', @@ -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); }); @@ -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([]); + }); +}); diff --git a/packages/recheck/src/rules/scope/metric.ts b/packages/recheck/src/rules/scope/metric.ts index 0bd200f9ba..4e181d2410 100644 --- a/packages/recheck/src/rules/scope/metric.ts +++ b/packages/recheck/src/rules/scope/metric.ts @@ -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, @@ -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 []; @@ -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) : '∞' diff --git a/packages/recheck/src/types/assertions.ts b/packages/recheck/src/types/assertions.ts index b631007f11..208b7f8242 100644 --- a/packages/recheck/src/types/assertions.ts +++ b/packages/recheck/src/types/assertions.ts @@ -77,12 +77,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. From 1a4971154061e9ba47c33b38064112d9ef173847 Mon Sep 17 00:00:00 2001 From: Albina Blazhko Date: Mon, 5 Oct 2026 12:45:02 +0300 Subject: [PATCH 2/2] chore: remove changeset --- .changeset/recheck-metric-size-formulas.md | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 .changeset/recheck-metric-size-formulas.md diff --git a/.changeset/recheck-metric-size-formulas.md b/.changeset/recheck-metric-size-formulas.md deleted file mode 100644 index 6f2681b956..0000000000 --- a/.changeset/recheck-metric-size-formulas.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -'@redocly/recheck': minor -'@redocly/cli': minor ---- - -Added the `word-count`, `sentence-count`, and `reading-time` formulas to the `metric` assertion of `recheck`, for per-file content length and reading-time budgets. -They count what a person reads, leaving out code blocks, front matter, headings, and Markdoc tags, and report once per file. -`reading-time` is minutes at the new `wordsPerMinute` option, default 200.