/*
RFC-1292: grammar-gate contracts — the site's .author/grammar.yaml config
(language mapping, ignore words, disabled LanguageTool rules with mandatory
reasons, pinned serverVersion) and the shrink-only .author/grammar-baseline.yaml
(frozen finding keys; GRAM-01 fires only on keys absent from it).
- No LanguageTool client — that lives in werkstatt-site checks/text-corpus/languagetool-client.ts.
- RFC-1292: initial — grammarConfigSchema + grammarBaselineSchema + grammarFindingKey.
- RFC-1292: gate rendered-text grammar via self-hosted LanguageTool with shrink-only baseline (RFC-1292)
Adds GRAM-01..05 to text.corpus.validate: LanguageTool findings diff against
an operator-owned .author/grammar-baseline.yaml (init flag freezes, prune flag
shrinks-only), LANGUAGETOOL_URL-configured servers fail closed, absent config
warns GRAM-05. LT results cache under .cache keyed on text+lang+rules+version.
Baseline writes are mutatingFlags-gated and refuse on stale corpus (CORPUS-01).
Extractor fixed to space text nodes across element boundaries. C09 activated
with a stub LtClient. Live LT 6.7 verified (NACH_7_TAGE, GERMAN_SPELLER_RULE);
runbook + .env.example + AGENTS.md updated; no real-site baseline authored.
*/
import { z } from "zod";
import { corpusSegmentKindSchema } from "./schema.ts";
export const grammarConfigSchema = z
.object({
schema: z.literal("werkstatt/grammar-config@1"),
/** Site lang → LanguageTool code, e.g. { de: "de-DE", uk: "uk-UA" }. */
languages: z.record(z.string(), z.string()),
/** Exact-match words ignored as findings (brand/product terms). */
ignoreWords: z.array(z.string()).default([]),
/** LanguageTool rule ids — each needs a reason in disabledRuleReasons. */
disabledRules: z.array(z.string()).default([]),
disabledRuleReasons: z.record(z.string(), z.string()).default({}),
/** Pinned LT image tag; part of the cache key — bump with the runbook tag. */
serverVersion: z.string().optional(),
/** Restrict checking to these segment kinds; default: all kinds. */
segmentKinds: z.array(corpusSegmentKindSchema).optional(),
})
.strict()
.superRefine((v, ctx) => {
for (const r of v.disabledRules) {
if (!v.disabledRuleReasons[r]) {
ctx.addIssue({ code: "custom", message: `disabledRules ${r} needs a reason` });
}
}
});
export type GrammarConfig = z.infer;
export const grammarFindingKeySchema = z
.object({
route: z.string(),
lang: z.string(),
path: z.string(),
/** LanguageTool rule id. */
ruleId: z.string(),
/** Matched substring (exact). */
match: z.string(),
})
.strict();
export type GrammarFindingKey = z.infer;
export const grammarBaselineSchema = z
.object({
schema: z.literal("werkstatt/grammar-baseline@1"),
entries: z.array(grammarFindingKeySchema.extend({ message: z.string() })),
})
.strict();
export type GrammarBaseline = z.infer;
export function grammarFindingKey(k: GrammarFindingKey): string {
return `${k.route}\u0000${k.lang}\u0000${k.path}\u0000${k.ruleId}\u0000${k.match}`;
}