Make your content guard check new fields by default
I have a side project that teaches vocabulary from songs. The content lives in JSON files, one per song: a list of words and short chunks, a gloss for each, a practice prompt, and an example sentence. There is one rule I care about more than any other in that pipeline: the files are allowed to hold single words and short generic phrases, and nothing that could carry a full line of the original text. That is a licensing line, not a style preference.
A rule like that is easy to write down and easy to break. The interesting part was deciding which fields the check should look at.
The version that goes blind
The obvious first guard checks the fields you know are risky:
function checkDeck(deck: Deck): string[] {
const problems: string[] = [];
for (const item of deck.items) {
if (item.term.length > 40) problems.push(`${item.term} is too long`);
if (item.prompt.length > 40) problems.push(`${item.prompt} is too long`);
}
return problems;
}
This works on the day you write it. Then a few weeks later someone adds alts so a card can
accept alternate spellings, or a hint field, or a grammar section with its own prompts. None
of those are checked. The guard still passes, CI is still green, and the one rule that actually
matters now has a hole in it that nobody chose to open.
The problem is the shape of the rule. It is a denylist of fields to inspect, which means every new field is exempt until someone remembers to add it. For a safety rule, that default is backwards.
Walk everything, exempt on purpose
The version I shipped walks every string in the document and applies the strict limit to all of them. A small, fixed allowlist names the few fields that are allowed to be longer, with a comment saying why:
export const MAX_CHARS = 40;
export const MAX_WORDS = 4;
// Paths use [] for any array index, e.g. items[].example.text
const WORD_LIMIT_EXEMPT = new Set([
'items[].example.text', // our own original sentence
'items[].example.translation', // its translation
'items[].note', // teaching note we wrote
]);
const CHAR_LIMITED = new Set(['items[].term', 'items[].alts[]', 'items[].prompt']);
export function countWords(text: string): number {
return text.split(/\s+/u).filter((t) => /\p{L}/u.test(t)).length;
}
function walk(value: unknown, path: string, pattern: string, visit: Visit): void {
if (typeof value === 'string') {
visit(value, path, pattern);
} else if (Array.isArray(value)) {
value.forEach((v, i) => walk(v, `${path}[${i}]`, `${pattern}[]`, visit));
} else if (value !== null && typeof value === 'object') {
for (const [key, child] of Object.entries(value)) {
walk(child, path ? `${path}.${key}` : key, pattern ? `${pattern}.${key}` : key, visit);
}
}
}
export function guard(deck: unknown): { path: string; message: string }[] {
const out: { path: string; message: string }[] = [];
walk(deck, '', '', (text, path, pattern) => {
if (CHAR_LIMITED.has(pattern) && [...text].length > MAX_CHARS) {
out.push({ path, message: `is over ${MAX_CHARS} characters` });
}
if (!WORD_LIMIT_EXEMPT.has(pattern) && countWords(text) > MAX_WORDS) {
out.push({ path, message: `has over ${MAX_WORDS} words` });
}
});
return out;
}
A few details carry most of the weight.
Two paths per value. The walker tracks the concrete path (items[3].alts[1]) for the error
message and a normalized pattern (items[].alts[]) for the rule lookup. The pattern is what lets
one allowlist entry cover every item without listing indices, and the concrete path is what lets
someone fix the right line in a long file.
The word limit is the default. Any string at any depth gets the four-word cap unless its pattern is on the exempt list. A new field shows up checked. If it legitimately needs longer text, the person adding it has to put it on the allowlist, in a diff someone can see, next to a comment explaining why.
Two different measures. The character cap applies only to the handful of fields that hold
source material directly. The word cap applies almost everywhere. Characters catch one long
phrase squeezed into a term; words catch a sentence hiding in a field nobody thought about.
Counting with [...text].length instead of text.length keeps accented and other non-ASCII
characters from counting twice.
Words need a letter. countWords ignores tokens without a letter in them, so punctuation,
numbers, and stray dashes do not push a short phrase over the limit. That keeps false positives
low, which matters, because a guard people keep overriding is a guard people stop trusting.
Watch for data hiding in keys
One part of the document is a map from word to frequency data. In that object the keys are the content, not the values. A walker that only visits values would sail right past a key that was a whole phrase. So the guard special cases that one map and runs each key through the same check. It is worth asking of any schema: where does user-authored text live that is not a string value?
Make it fail the build
The guard is only useful if it runs without anyone thinking about it. A small script checks every
content file against the JSON schema and then the guard, prints each violation with its file and
path, and exits non-zero on any of them. CI runs it on every pull request. There is also a
--strict flag that turns warnings, like a placeholder section that still needs human curation,
into failures for release builds.
I added one test that feeds the command line script a bad file and asserts it exits with an error. Unit tests on the guard function prove the logic. The CLI test proves the thing CI actually calls still fails when it should, which is the part that silently breaks when someone refactors the script.
The tradeoffs
Default-deny is noisier. Every new text field that needs to be long will fail the first time, and someone has to touch the allowlist. I think that is the point. The cost is one line and a comment; the alternative is a rule that quietly covers less every month.
It is also a blunt instrument. Four words and forty characters will not catch every possible problem, and they are not meant to replace a person reviewing content. What they do is make the common mistake impossible to merge by accident.
The general version of this applies well beyond song files. If you have a rule about what is allowed in your content, configs, or fixtures, such as no secrets, no personal data, or no external URLs, write it so it inspects everything and lists the exceptions. A check that has to be told where to look will eventually be looking in the wrong place.