GST-850: choice A, drop the allowlist docs paths (UI text and SVGs stay code); repin under GST-850

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-11-02 00:42:46 +00:00
1 parent 1a62151a2c
commit c1eea4cdc2
9 files changed
+90 -384

No files matched your search

+5 -5
View File
@@ -1,5 +1,5 @@
- **A write-up with one clean chart now gets one reader, and a translation fix gets the docs tier** (GST-850). `lib/review-allowlist.ts` adds two allowlisted docs paths to `/ship`'s review depth: a new SVG whose whole file is built from presentational elements and attributes (no prefixes, handlers, bindings, entities, doctype, scripts or outside references), and a locale resource edit (JSON, ARB, YAML, PO, `.strings`, XLIFF, Fluent or `.properties` under a locale directory) that changes only literal string values with the same keys and placeholders. Both read the whole input in linear time and keep anything they cannot parse as code. Every bypass the R51 review rounds found (string-concat HTML, i18n `.ts`, edits inside `<script>`, multi-line SVG attributes, namespaced `<s:script>`, Vue and Alpine bindings, the backtracking input) is a test row that stays code.
- **The security class reaches outbound URLs, archives and every `child_process` spelling.** `lib/review-risk.ts` now flags `fetch`, `http(s).request`, axios, got, undici, `requests` and `urlopen` on non-literal URLs, `new URL` from input, redirect toggles, proxy settings and host allowlists; tar, zip and unzip extraction, `path.join` or `resolve` on request or entry input and `'..'` checks; and Node `exec` through named, default, namespace, split-line, `require` and dynamic imports.
- **The money class reads metering and charge code.** Paths and lines naming meter, metering, metered, charge, billable, billed or credits, plus token usage, a billing proxy and cost or charge clients, now route to the money reader. gbrain-evals #289, which changed a metering proxy and billing clients and read `required=none`, is a regression test that reads money. "costume", a CLI's usage text and a plain proxy do not.
- **When a diff publishes numbers, the batched specialist reads the write-up's text files** instead of leaving docs out of its diff.
- Replayed on the 26 GSTA-167 no-loss units, no unit loses a reviewer role, so no saved blocker can be lost. Over the last 100 main commits, 2 move from light to full and no commit with a gate or risk class drops depth.
- **The security class reaches outbound URLs, archives and every `child_process` spelling** (GST-850). `lib/review-risk.ts` now flags `fetch`, `http(s).request`, axios, got, undici, `requests` and `urlopen` on non-literal URLs, `new URL` from input, redirect toggles, proxy settings and host allowlists; tar, zip and unzip extraction, `path.join` or `resolve` on request or entry input, and `'..'` checks; and Node `exec` through named, default, namespace, split-line, `require` and dynamic imports. The split-line import scan runs in one forward pass.
- **The money class reads metering and charge code.** Paths and lines naming meter, metering, metered, charge, billable, billed or credits, plus token usage, a billing proxy and cost or charge clients, now route to the money reader. gbrain-evals #289, which changed a metering proxy and billing clients and read `required=none`, is now a regression test that reads money. "costume", a CLI's usage text and a plain proxy do not count.
- **When a diff publishes numbers, the batched specialist reads the write-up's text files** instead of leaving docs out of its diff. The collector records the files a diff creates.
- **UI text and SVGs stay code (light).** An allowlist docs path for chart SVGs and locale values was built and dropped for review cost: its first review round found a new bypass class in each version. Every bypass found so far is a test row that classes as code.
- Replayed on the 26 GSTA-167 no-loss units, no unit loses a reviewer role. The replay checked which reviewers get dispatched, not R51's re-scored findings. Over the last 100 main commits, 2 move from light to full and no commit with a gate or risk class drops depth.
+8 -34
View File
@@ -181,8 +181,7 @@ follows that file, four files deep. Precedence: agent prose (`.gstack/**`), a te
fixture path, a gate path, a config file (`.github/**`, anything under a `config/`
directory, dotfiles, `*.config.*`, `package.json`, lockfiles, `tsconfig*.json`,
`manifest.json` and the other tool manifests `lib/review-depth.ts` lists) and a named
consumer each keep the path code, and YAML, TOML and SVG are never data (an SVG can
still be an [allowlisted docs path](#allowlisted-docs-paths)). More than
consumer each keep the path code, and YAML, TOML and SVG are always code. More than
2,000 data paths keep every one code; a `git grep` failure is `consumer_scan_failed`
and fails the depth closed. Only `classifyDiff` applies the rule (`lib/review-collect.ts`
finds the consumers, `gstack-ship-preflight`'s `RUNTIME_LINES` reads the same
@@ -224,37 +223,12 @@ that drops a table or column, deletes rows or bulk-deletes records. Auth scope,
migrations and deleted code paths were already gate class. `RISK_RULES` hashes
the patterns and `DEPTH_RULES` includes it.
<a id="allowlisted-docs-paths"></a>
**Allowlisted docs paths (GST-850).** Two kinds of path are docs when
`lib/review-allowlist.ts` accepts them, and code otherwise; three rounds of R51
review found bypasses in line-pattern versions, so both are allowlists that read
the whole input in linear time and fail closed on anything they do not parse.
- **A new SVG** whose whole file is one `svg` root built from presentational
elements (`svg`, `g`, `path`, `rect`, `circle`, `ellipse`, `line`, `polyline`,
`polygon`, `text`, `tspan`, `title`, `desc`, `defs`, gradients and `stop`,
`clipPath`, `use`, `symbol`, `marker`, `pattern`, `mask`) and presentational
attributes. No namespace prefixes, `on*` or binding attributes (`:x`, `v-bind:`,
`x-bind:`, `@x`), entities other than the five predefined named ones (no
numeric references), doctype, CDATA or processing instructions other than the
XML declaration, and no comment HTML would close early (`<!-->`, `<!--->`) or
that holds `<` or `>`; `href` only on `use` and only `#fragment`; `url(#id)`
only in presentation attributes and no `url(` in `style`; no backslash escapes,
braces or `${` in attribute values; no `{{` or `${` in text. An edited SVG
stays code.
- **A locale resource edit**: a JSON, ARB, YAML, PO, `.strings`, XLIFF, Fluent
or `.properties` file under a `locale(s)`, `i18n`, `l10n`, `translation(s)` or
`lang` directory (JSON, YAML and `.properties` also need a locale code such as
`en` or `pt-BR` as the parent directory or the file name), where every deleted
line pairs with an added line with the same key and a changed literal string
value, with no markup, escape other than `\n` or `\t`, script, link scheme or
protocol-relative link (read after escapes and whitespace are removed), no YAML
value that loads as null, a boolean or a number, and the same placeholders
(`{x}`, `%s`, `%(name)s` and the rest). Added, removed or moved keys, UI
component files (`.tsx`, `.vue` and the rest), i18n source files and anything
under `config/` stay code.
A path any risk class matches stays code whatever its shape.
**UI text and SVGs** stay code (light unless a risk class applies). A docs path
for UI copy, locale values and chart SVGs was built as an allowlist in GST-850 and
dropped for review cost: its first review round found a new bypass class in each
version, the same pattern as three rounds of R51. When a diff publishes numbers,
the batched specialist's diff keeps the write-up's text files instead of leaving
docs out.
A write-up with one clean chart and no code is one reader (the outside voice on
its numbers). When a diff publishes numbers, the batched specialist's diff keeps
@@ -300,7 +274,7 @@ and lockfiles stay runtime. To adopt it, commit an entry like this on the base b
**Gate paths for the machinery itself.** An edit to `lib/review-depth.ts`,
`lib/review-collect.ts`, `bin/gstack-review-plan`, `lib/review-scope.ts`,
`lib/review-targeted.ts`, `lib/review-risk.ts`, `lib/review-allowlist.ts`, `review/specialists/targeted.md`, `lib/ship-queue*.ts`, `lib/regen.ts`, `.gstack/generated.json`,
`lib/review-targeted.ts`, `lib/review-risk.ts`, `review/specialists/targeted.md`, `lib/ship-queue*.ts`, `lib/regen.ts`, `.gstack/generated.json`,
`ship/sections/review-army.md.tmpl`, `ship/sections/adversarial.md.tmpl` or
`scripts/resolvers/review-army.ts` is gate class: changing how much review runs gets
the most review. `rules=` (`DEPTH_RULES`) hashes the deciding constants and patterns,
-229
View File
@@ -1,229 +0,0 @@
/**
* review-allowlist — the two allowlisted docs paths of a /ship review depth (GST-850, docs/review-policy.md#allowlisted-docs-paths):
* a new SVG built only from presentational elements and attributes, and a locale resource edit that changes only literal
* string values. Both read the whole input with index scanners that only move forward, so time is linear in the input,
* and both fail closed: anything they do not recognize keeps the path code. A leaf module: lib/review-depth.ts decides
* which paths reach it and folds ALLOWLIST_RULES into DEPTH_RULES.
*/
import { createHash } from 'node:crypto';
export const SVG_ELEMENTS = ['svg', 'g', 'path', 'rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon', 'text', 'tspan', 'title', 'desc', 'defs', 'linearGradient', 'radialGradient', 'stop', 'clipPath', 'use', 'symbol', 'marker', 'pattern', 'mask'];
export const SVG_ATTRIBUTES = [
'xmlns', 'version', 'id', 'class', 'style', 'role', 'lang', 'aria-label', 'aria-hidden', 'aria-labelledby', 'aria-describedby',
'x', 'y', 'x1', 'y1', 'x2', 'y2', 'cx', 'cy', 'r', 'rx', 'ry', 'fx', 'fy', 'fr', 'dx', 'dy', 'width', 'height', 'd', 'points', 'pathLength',
'viewBox', 'preserveAspectRatio', 'transform', 'overflow', 'display', 'visibility', 'opacity', 'color',
'fill', 'fill-opacity', 'fill-rule', 'stroke', 'stroke-width', 'stroke-opacity', 'stroke-linecap', 'stroke-linejoin', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-miterlimit', 'paint-order', 'vector-effect', 'shape-rendering', 'text-rendering',
'font-family', 'font-size', 'font-weight', 'font-style', 'font-variant', 'text-anchor', 'dominant-baseline', 'alignment-baseline', 'letter-spacing', 'word-spacing', 'text-decoration', 'rotate', 'textLength', 'lengthAdjust',
'offset', 'stop-color', 'stop-opacity', 'gradientUnits', 'gradientTransform', 'spreadMethod', 'clip-path', 'clip-rule', 'clipPathUnits', 'mask', 'maskUnits', 'maskContentUnits',
'patternUnits', 'patternContentUnits', 'patternTransform', 'markerWidth', 'markerHeight', 'markerUnits', 'refX', 'refY', 'orient', 'marker-start', 'marker-mid', 'marker-end', 'href',
];
const SVG_NS = 'http://www.w3.org/2000/svg';
const XML_DECL = /^<\?xml version="1\.[01]"(?: encoding="[A-Za-z0-9._-]+")?(?: standalone="(?:yes|no)")?\s*\?>$/;
const NAMED_ENTITY = /^&(?:amp|lt|gt|quot|apos);/;
const FRAGMENT = /^#[A-Za-z_][\w.-]*$/;
const LOCAL_URL = /url\(#[A-Za-z_][\w.-]*\)/g;
const STYLE_VALUE = /^[A-Za-z0-9\s#%.,:;()_+-]*$/;
const isWs = (c: string | undefined) => c === ' ' || c === '\t' || c === '\n' || c === '\r';
const isNameChar = (c: string | undefined) => !!c && ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c === '-');
/** Every & starts one of the five predefined entities: a numeric reference could spell an interpolation or a scheme. */
function entitiesOk(s: string): boolean {
for (let k = s.indexOf('&'); k >= 0; k = s.indexOf('&', k + 1)) if (!NAMED_ENTITY.test(s.slice(k, k + 8))) return false;
return true;
}
function textOk(s: string, inside: boolean): boolean {
if (!inside) { for (const c of s) if (!isWs(c)) return false; return true; }
return !/[\0-\x08\x0b\x0c\x0e-\x1f]|\{\{|\$\{|\]\]>/.test(s) && entitiesOk(s);
}
function valueOk(element: string, name: string, v: string): boolean {
if (/[<\\`{}\0-\x1f]|\$\{/.test(v) || !entitiesOk(v)) return false;
if (name === 'xmlns') return v === SVG_NS;
if (name === 'href') return element === 'use' && FRAGMENT.test(v);
if (name === 'style') return STYLE_VALUE.test(v) && !/url\(|expression/i.test(v);
return !/url\(/i.test(v.replace(LOCAL_URL, ''));
}
/** One start tag's body (between < and >, without a trailing /): the element name, or null when anything is off the allowlist. */
function tagOk(body: string): string | null {
let k = 0;
while (k < body.length && isNameChar(body[k]) && body[k] !== '-') k++;
const name = body.slice(0, k);
if (!SVG_ELEMENTS.includes(name)) return null;
const seen = new Set<string>();
while (k < body.length) {
if (!isWs(body[k])) return null;
while (isWs(body[k])) k++;
if (k >= body.length) break;
const start = k;
while (isNameChar(body[k])) k++;
const attr = body.slice(start, k);
while (isWs(body[k])) k++;
if (!SVG_ATTRIBUTES.includes(attr) || seen.has(attr) || body[k] !== '=') return null;
k++;
while (isWs(body[k])) k++;
const q = body[k];
if (q !== '"' && q !== "'") return null;
const end = body.indexOf(q, k + 1);
if (end < 0 || !valueOk(name, attr, body.slice(k + 1, end))) return null;
seen.add(attr);
k = end + 1;
}
return name;
}
/** Whether a whole SVG file is presentational only: one svg root, allowlisted elements and attributes, no prefixes, scripts, entities, doctype or outside references. */
export function presentationalSvg(text: string): boolean {
let i = text.charCodeAt(0) === 0xfeff ? 1 : 0;
if (text.startsWith('<?xml', i)) {
const end = text.indexOf('?>', i);
if (end < 0 || !XML_DECL.test(text.slice(i, end + 2))) return false;
i = end + 2;
}
const stack: string[] = [];
let roots = 0;
while (i < text.length) {
const lt = text.indexOf('<', i);
if (!textOk(text.slice(i, lt < 0 ? text.length : lt), stack.length > 0)) return false;
if (lt < 0) break;
if (text.startsWith('<!--', lt)) {
const end = text.indexOf('-->', lt + 4);
const body = text.slice(lt + 4, end);
if (end < 0 || body.includes('--') || /[<>]/.test(body) || body.startsWith('-')) return false;
i = end + 3;
continue;
}
const gt = text.indexOf('>', lt);
if (gt < 0) return false;
if (text[lt + 1] === '/') {
let end = gt;
while (end > lt + 2 && isWs(text[end - 1])) end--;
if (!stack.length || stack.pop() !== text.slice(lt + 2, end)) return false;
} else {
const selfClosing = text[gt - 1] === '/';
const name = tagOk(text.slice(lt + 1, selfClosing ? gt - 1 : gt));
if (!name || (!stack.length && (roots++ > 0 || name !== 'svg'))) return false;
if (!selfClosing) stack.push(name);
else if (!stack.length) return false;
}
i = gt + 1;
}
return roots === 1 && stack.length === 0;
}
export const LOCALE_DIR = /(^|\/)(locales?|i18n|l10n|translations?|lang)\//i;
export const LOCALE_FORMATS = ['json', 'arb', 'yaml', 'yml', 'po', 'strings', 'xliff', 'xlf', 'ftl', 'properties'];
const PLACEHOLDER = /\{\{[^{}]*\}\}|\{[^{}]*\}|%(?:[1-9]\d*\$|\([^()]*\))?[-+ #0]*(?:[1-9]\d*)?(?:\.\d+)?[a-zA-Z@]|\$\{[^{}]*\}|\$t\([^()]*\)|@[:.][\w.:-]+|\$[A-Za-z_]\w*/g;
/** Never UI text: markup, an encoded character that could spell markup, a script or link scheme, a template literal. */
const NOT_TEXT = /[<>`]|&(?:lt|gt|#)|\\[^nt"']|%\(/i;
/** A link scheme or a protocol-relative link, read after escapes, whitespace and control characters are removed (a URL parser drops them). */
const LINK = /(?:javascript|vbscript|data):|\/\//i;
/** YAML plain scalars that load as null, a boolean or a number, not a string. */
const YAML_NOT_STRING = /^(?:null|~|true|false|yes|no|on|off|y|n|[-+]?(?:\d[\d_]*(?:\.\d*)?|\.\d+)(?:e[-+]?\d+)?|0x[0-9a-f]+|0o[0-7]+|[-+]?\.(?:inf|nan))$/i;
/** A locale code as a path segment or a part of the file name: en, fr, pt-BR, zh_Hant. */
const LOCALE_CODE = /^[a-z]{2}(?:[-_][A-Za-z]{2,4})?$/;
const LOCALE_ONLY_FORMATS = ['po', 'strings', 'xliff', 'xlf', 'arb', 'ftl'];
const YAML_PLAIN = /^[A-Za-z0-9][^#:{}[\]&*!|>%@`"'<\\]*$/;
const XLIFF_TARGET = /^(\s*<target(?:\s+state="[a-z-]+")?>)([^<]*)(<\/target>\s*)$/;
const FTL_ENTRY = /^(\s*[.-]?[A-Za-z][\w-]*\s*=\s*)(\S.*)$/;
const PROPERTIES_ENTRY = /^(\s*[A-Za-z0-9_.-]+\s*[=:]\s*)(\S.*)$/;
const skipWs = (s: string, k: number) => { while (s[k] === ' ' || s[k] === '\t') k++; return k; };
/** The index of the quote that closes a string opened before k, or -1; a backslash escapes the next character, or, doubled, two quotes stand for one (YAML single quotes). */
function closeQuote(s: string, k: number, q: string, doubled = false): number {
while (k < s.length) {
if (doubled && s[k] === q && s[k + 1] === q) k += 2;
else if (s[k] === q) return k;
else k += !doubled && s[k] === '\\' ? 2 : 1;
}
return -1;
}
type Entry = { key: string; value: string } | null;
/** "<key>" <sep> "<value>" <tail>: the JSON, ARB and Apple .strings line shape. */
function quotedPair(line: string, sep: string, tail: RegExp): Entry {
let k = skipWs(line, 0);
if (line[k] !== '"') return null;
k = closeQuote(line, k + 1, '"');
if (k < 0) return null;
k = skipWs(line, k + 1);
if (line[k] !== sep) return null;
k = skipWs(line, k + 1);
if (line[k] !== '"') return null;
const end = closeQuote(line, k + 1, '"');
if (end < 0 || !tail.test(line.slice(end + 1))) return null;
return { key: line.slice(0, k + 1) + '\0' + line.slice(end), value: line.slice(k + 1, end) };
}
function yamlPair(line: string): Entry {
const start = skipWs(line, 0);
let k = start;
const q = line[k];
if (q === '"' || q === "'") k = closeQuote(line, k + 1, q, q === "'") + 1;
else while (k < line.length && /[\w.-]/.test(line[k]!)) k++;
if (k <= start || line[k] !== ':' || (line[k + 1] !== ' ' && line[k + 1] !== '\t')) return null;
k = skipWs(line, k + 1);
const v = line[k];
let end = line.length;
while (end > k && isWs(line[end - 1])) end--;
if (v === '"' || v === "'") {
const close = closeQuote(line, k + 1, v, v === "'");
if (close !== end - 1) return null;
return { key: line.slice(0, k + 1) + '\0' + line.slice(close), value: line.slice(k + 1, close) };
}
const plain = line.slice(k, end);
return YAML_PLAIN.test(plain) && !YAML_NOT_STRING.test(plain) ? { key: line.slice(0, k), value: plain } : null;
}
function poPair(line: string): Entry {
const m = /^msgstr(?:\[\d+\])? "/.exec(line);
if (!m) return null;
const end = closeQuote(line, m[0].length, '"');
return end >= 0 && /^\s*$/.test(line.slice(end + 1)) ? { key: m[0] + '\0' + line.slice(end), value: line.slice(m[0].length, end) } : null;
}
function regexPair(line: string, r: RegExp): Entry {
const m = r.exec(line);
return m ? { key: m[1]! + '\0' + (m[3] ?? ''), value: m[2]! } : null;
}
function entryOf(format: string, line: string): Entry {
switch (format) {
case 'json': case 'arb': return quotedPair(line, ':', /^\s*,?\s*$/);
case 'strings': return quotedPair(line, '=', /^\s*;\s*$/);
case 'yaml': case 'yml': return yamlPair(line);
case 'po': return poPair(line);
case 'xliff': case 'xlf': return regexPair(line, XLIFF_TARGET);
case 'ftl': return regexPair(line, FTL_ENTRY);
case 'properties': return /\\\s*$/.test(line) ? null : regexPair(line, PROPERTIES_ENTRY);
}
return null;
}
/** The value's placeholders, sorted, or null when it is not plain text (markup, an encoded character, a scheme, an unpaired brace). */
function placeholders(value: string): string | null {
if (NOT_TEXT.test(value) || LINK.test(value.replace(/\\[nt]|[\s\0-\x1f]/g, ''))) return null;
const found = value.match(PLACEHOLDER) ?? [];
if (/[{}]/.test(value.replace(PLACEHOLDER, ''))) return null;
return found.sort().join('\0');
}
/** The resource format of a path under a locale directory, or null; JSON, YAML and .properties also need a locale code as the parent directory, the file stem or the stem's last . or _ part. */
export function localeFormat(p: string): string | null {
const ext = /\.([A-Za-z]+)$/.exec(p)?.[1]?.toLowerCase();
if (!ext || !LOCALE_FORMATS.includes(ext) || !LOCALE_DIR.test(p)) return null;
const parts = p.split('/');
const stem = parts[parts.length - 1]!.slice(0, -ext.length - 1);
return LOCALE_ONLY_FORMATS.includes(ext) || [parts[parts.length - 2] ?? '', stem, stem.split(/[._]/).pop()!].some(s => LOCALE_CODE.test(s)) ? ext : null;
}
/** Whether an edit to a locale resource changes only literal string values: lines pair up with the same key and a changed value (an unchanged pair is a moved key), and each value is plain text with the same placeholders. */
export function localeTextOnly(p: string, removed: readonly string[] = [], added: readonly string[] = []): boolean {
const format = localeFormat(p);
if (!format || !added.length || removed.length !== added.length) return false;
return removed.every((line, n) => {
const a = entryOf(format, line), b = entryOf(format, added[n]!);
if (!a || !b || a.key !== b.key || a.value === b.value) return false;
const ha = placeholders(a.value);
return ha !== null && ha === placeholders(b.value);
});
}
/** A hash of the lists and patterns that decide an allowlisted docs path; lib/review-depth.ts folds it into DEPTH_RULES. */
export const ALLOWLIST_RULES = createHash('sha256').update(JSON.stringify({
SVG_ELEMENTS, SVG_ATTRIBUTES, SVG_NS, LOCALE_FORMATS,
LOCALE_ONLY_FORMATS,
patterns: [XML_DECL, NAMED_ENTITY, LINK, YAML_NOT_STRING, LOCALE_CODE, FRAGMENT, LOCAL_URL, STYLE_VALUE, LOCALE_DIR, PLACEHOLDER, NOT_TEXT, YAML_PLAIN, XLIFF_TARGET, FTL_ENTRY, PROPERTIES_ENTRY].map(r => '/' + r.source + '/' + r.flags),
})).digest('hex').slice(0, 16);
+5 -21
View File
@@ -13,7 +13,6 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import type { ResultCodeName } from './result-codes';
import { matchesAny } from './ship-policy';
import { ALLOWLIST_RULES, localeTextOnly, presentationalSvg } from './review-allowlist';
import { matchRisk, RISK_RULES, type RiskClass } from './review-risk';
export type DiffClass = 'docs' | 'tests' | 'code' | 'gate';
@@ -56,7 +55,7 @@ const RELEASE_METADATA = [/^CHANGELOG\.md$/, /^VERSION$/, /^agents-digest\//];
/** Gate paths, the review-depth machinery included: an edit to how much review runs gets the most review. */
const GATE_PATH = [
/(^|\/)\.github\/workflows\//, /(^|\/)(secrets?|credentials?|keychain|token[s]?)([._/-]|$)/i, /state-root/, /(^|\/)(pregate|gate|ship-receipt|ship-policy)[^/]*\.(ts|sh|json)$/, /(^|\/)bin\/gstack-(pregate|gate|ship-receipt|verify-gate|ci-gate)$/,
/^lib\/review-(depth|collect|scope|targeted|risk|allowlist)\.ts$/, /^review\/specialists\/targeted\.md$/, /^bin\/gstack-review-plan$/, /^lib\/ship-queue[^/]*\.ts$/, /^lib\/regen\.ts$/, /^\.gstack\/generated\.json$/, /^ship\/sections\/(review-army|adversarial)\.md\.tmpl$/, /^scripts\/resolvers\/review-army\.ts$/,
/^lib\/review-(depth|collect|scope|targeted|risk)\.ts$/, /^review\/specialists\/targeted\.md$/, /^bin\/gstack-review-plan$/, /^lib\/ship-queue[^/]*\.ts$/, /^lib\/regen\.ts$/, /^\.gstack\/generated\.json$/, /^ship\/sections\/(review-army|adversarial)\.md\.tmpl$/, /^scripts\/resolvers\/review-army\.ts$/,
];
/** Data files: JSON, CSV, TSV and raster images. YAML, TOML and SVG are never data here: deploy configs, API specs and executable SVG stay code. */
@@ -69,7 +68,7 @@ const DATA_CONFIG = [
/** A hash of the constants and patterns that decide a depth, not of this file's bytes: a comment edit keeps it, a threshold edit changes it. */
export const DEPTH_RULES = createHash('sha256').update(JSON.stringify({
RISK_RULES, ALLOWLIST_RULES, SMALL_DIFF_LINES, RED_TEAM_LINES, LARGE_DIFF_LINES, FULL_PASS_CAP, LIGHT_FIXED, BATCH_SCOPED, SCOPED_RANGE_LINES, ROLE_EFFORT,
RISK_RULES, SMALL_DIFF_LINES, RED_TEAM_LINES, LARGE_DIFF_LINES, FULL_PASS_CAP, LIGHT_FIXED, BATCH_SCOPED, SCOPED_RANGE_LINES, ROLE_EFFORT,
patterns: Object.entries({ GATE_PATH, AGENT_PROSE, TEST_PATH, RELEASE_METADATA, DOC_PATH: [DOC_PATH], DATA_PATH: [DATA_PATH], DATA_CONFIG }).map(([k, v]) => [k, v.map(r => `/${r.source}/${r.flags}`)]),
})).digest('hex').slice(0, 16);
@@ -132,9 +131,9 @@ export interface ClassInput {
* (lib/review-collect.ts). A candidate not listed counts as docs; 'all' or no scan keeps every data file code.
*/
consumed?: readonly string[] | 'all';
/** Paths the diff creates (untracked files included): a new SVG is an allowlist candidate only when its added lines are the whole file. */
/** Paths the diff creates (untracked files included): their added lines are the whole file. */
created?: readonly string[];
/** Added and deleted lines per path (lib/review-collect.ts): the allowlisted docs paths and the risk classes read them; without them neither applies. */
/** Added and deleted lines per path (lib/review-collect.ts): the risk classes read them; without them no line signal applies. */
added?: Record<string, readonly string[]>;
removed?: Record<string, readonly string[]>;
}
@@ -143,18 +142,7 @@ export interface ClassResult { class: DiffClass; reasons: string[]; errors: Dept
const sanitize = (s: string) => s.replace(/[\0-\x1f]+/g, ' ').slice(0, 200);
const isOutput = (i: ClassInput, p: string) => !!i.registry?.outputs.length && matchesAny(i.registry.outputs, p);
const unconsumedData = (i: ClassInput, p: string) => Array.isArray(i.consumed) && !i.consumed.includes(p) && dataCandidate(p, i.root);
/**
* An allowlisted docs path (GST-850, lib/review-allowlist.ts): a new SVG whose whole file is presentational, or a locale resource
* edit that changes only literal string values. Agent prose, gate paths, config files and any path a risk class matches never qualify;
* an edited SVG stays code.
*/
const allowlisted = (i: ClassInput, p: string): 'svg' | 'locale' | null => {
if (!i.added?.[p]?.length || classifyPath(p, i.root) !== 'code' || [...AGENT_PROSE, ...GATE_PATH, ...DATA_CONFIG].some(r => r.test(p))) return null;
if (matchRisk({ codePaths: [p], added: i.added, removed: i.removed, scopeAuth: false }).size) return null;
if (/\.svg$/i.test(p)) return i.created?.includes(p) && !i.removed?.[p]?.length && presentationalSvg(i.added[p]!.join('\n')) ? 'svg' : null;
return localeTextOnly(p, i.removed?.[p], i.added[p]) ? 'locale' : null;
};
const pathClass = (i: ClassInput, p: string): PathClass => ((i.stampOnly ?? []).includes(p) || unconsumedData(i, p) || allowlisted(i, p) ? 'docs' : classifyPath(p, i.root));
const pathClass = (i: ClassInput, p: string): PathClass => ((i.stampOnly ?? []).includes(p) || unconsumedData(i, p) ? 'docs' : classifyPath(p, i.root));
/** The diff's class, raised to gate by any gate signal; `errors` lists every input that fails the depth closed; `runtimeLines` counts code-class lines outside the base registry's outputs. */
export function classifyDiff(i: ClassInput): ClassResult {
@@ -166,10 +154,6 @@ export function classifyDiff(i: ClassInput): ClassResult {
for (const p of i.paths) { const c = pathClass(i, p); if (CLASS_RANK[c] > CLASS_RANK[cls]) raise(c, `${c}: ${p}`); }
const data = i.paths.filter(p => unconsumedData(i, p));
if (data.length) reasons.push(`docs: ${data.length} data path(s) no code names`);
for (const kind of ['svg', 'locale'] as const) {
const n = i.paths.filter(p => allowlisted(i, p) === kind).length;
if (n) reasons.push(`docs: ${n} ${kind === 'svg' ? 'presentational SVG' : 'locale text'} path(s)`);
}
const s = i.scope ?? {};
if (s.SCOPE_ERROR) {
raise('code', `scope error: ${s.SCOPE_ERROR}`);
-78
View File
@@ -1,78 +0,0 @@
/**
* GST-850: the allowlisted docs paths (lib/review-allowlist.ts) read whole inputs in linear time, and every pattern that decides
* a risk class or an allowlisted path is in DEPTH_RULES (docs/review-policy.md#allowlisted-docs-paths).
*/
import { describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { localeTextOnly, presentationalSvg } from '../lib/review-allowlist';
import { matchRisk } from '../lib/review-risk';
const ROOT = path.join(import.meta.dir, '..');
const N = 10_000;
const fast = (f: () => unknown) => { const t = performance.now(); f(); return performance.now() - t; };
describe('linear time on hostile input', () => {
// Value: protects=gstack-review-plan and the queue verdict never hang on a diff (the R51 resource regex did at about 50
// backslashes); fails_when=any scanner or risk pattern backtracks on a 10k-character input; seam=presentationalSvg,
// localeTextOnly and matchRisk
const svgInputs = ['\\'.repeat(N), '<'.repeat(N), '<g '.repeat(N), '&'.repeat(N), '<svg xmlns="' + '\\'.repeat(N), '<svg ' + ' '.repeat(N) + 'x="' + '"'.repeat(N),
'<svg xmlns="http://www.w3.org/2000/svg">' + '<g>'.repeat(N) + '</g>'.repeat(N) + '</svg>', '</g' + ' '.repeat(N) + '>', '<!--' + '-'.repeat(N), '<svg fill="' + 'url('.repeat(N) + '"/>'];
test.each(svgInputs.map((s, n) => [n, s]))('SVG input %i finishes under 100 ms', (_n, s) => {
expect(fast(() => presentationalSvg(s as string))).toBeLessThan(100);
});
const formats: [string, string, string][] = [
['locales/en.json', '"k": "', '",'], ['locales/en.arb', '"k": "', '",'], ['locales/en.yml', 'k: "', '"'], ['locales/en.yml', "k: '", "'"], ['locales/fr.po', 'msgstr "', '"'],
['locales/en.strings', '"k" = "', '";'], ['locales/fr.xlf', '<target>', '</target>'], ['locales/en.ftl', 'k = ', ''], ['locales/en.properties', 'k=', ''],
];
test.each(formats)('%s with 10k backslashes, quotes, braces and percent signs finishes under 100 ms', (p, open, close) => {
for (const body of ['\\'.repeat(N), '\\'.repeat(N) + 'x', "'".repeat(N), '{'.repeat(N), '%1'.repeat(N / 2), '%' + '0'.repeat(N), '{{'.repeat(N / 2), '$t('.repeat(N / 3)]) {
const line = open + body + close;
expect(fast(() => localeTextOnly(p, [open + 'x' + close], [line]))).toBeLessThan(100);
expect(fast(() => localeTextOnly(p, [line], [line]))).toBeLessThan(100);
}
});
test('a 512 KB comment of unclosed imports and a long path.join chain finish under 200 ms', () => {
const lines = Array.from({ length: 65_536 }, () => 'import {');
expect(fast(() => matchRisk({ codePaths: ['src/a.ts'], added: { 'src/a.ts': lines }, removed: {}, scopeAuth: false }))).toBeLessThan(200);
expect(fast(() => matchRisk({ codePaths: ['src/a.ts'], added: { 'src/a.ts': ['path.join('.repeat(50_000)] }, removed: {}, scopeAuth: false }))).toBeLessThan(200);
});
test('the risk patterns finish under 100 ms on 10k-character lines', () => {
for (const body of ['fetch(' + "'".repeat(N), 'import {' + ' exec'.repeat(N / 5), 'path.join(' + 'a'.repeat(N), 'import ' + 'a '.repeat(N / 2) + 'from', 'require(' + ' '.repeat(N),
' '.repeat(N) + '} = require(', "'..".repeat(N / 3), 'x'.repeat(N) + 'Usage', 'tar ' + '-'.repeat(N), 'import {'.repeat(N / 8)]) {
expect(fast(() => matchRisk({ codePaths: ['src/a.ts'], added: { 'src/a.ts': [body] }, removed: { 'src/a.ts': [body] }, scopeAuth: false }))).toBeLessThan(100);
}
});
});
describe('the rules hashes cover every new pattern', () => {
// Value: protects=a receipt or queue verdict notices an allowlist or risk-pattern edit; fails_when=an edit to an SVG element,
// a locale format, a security pattern or a money word leaves DEPTH_RULES unchanged; seam=DEPTH_RULES over ALLOWLIST_RULES and RISK_RULES
test.each([
['review-allowlist.ts', "'foreignObject'", "'clipPath', 'foreignObject',", "'clipPath',"],
['review-allowlist.ts', "'properties'];", "'properties', 'ini'];", null],
['review-risk.ts', "'billable', 'billed'", "'billable', 'billed', 'tariff'", null],
['review-risk.ts', 'extract-zip|unzipper', 'extract-zip|unzipper|jszip', null],
])('editing %s (%s) changes DEPTH_RULES', (file, _what, to, from) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'allowlist-rules-'));
try {
for (const f of ['review-depth.ts', 'review-risk.ts', 'review-allowlist.ts']) fs.copyFileSync(path.join(ROOT, 'lib', f), path.join(dir, f));
fs.symlinkSync(path.join(ROOT, 'lib/ship-policy.ts'), path.join(dir, 'ship-policy.ts'));
fs.writeFileSync(path.join(dir, 'print.ts'), "import { DEPTH_RULES } from './review-depth';\nconsole.log(DEPTH_RULES);\n");
const hash = () => {
const r = spawnSync(process.execPath, ['--no-env-file', '--config=/dev/null', path.join(dir, 'print.ts')], { encoding: 'utf8', timeout: 30_000 });
expect(r.status, r.stderr).toBe(0);
return r.stdout.trim();
};
const before = hash();
const src = fs.readFileSync(path.join(dir, file), 'utf8');
const needle = from ?? _what;
expect(src).toContain(needle);
fs.writeFileSync(path.join(dir, file), src.replace(needle, to));
expect(before).toMatch(/^[0-9a-f]{16}$/);
expect(hash()).not.toBe(before);
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
});
});
-2
View File
@@ -326,7 +326,6 @@ describe('review depth: diff commands and the rules hash', () => {
const src = fs.readFileSync(path.join(ROOT, 'lib/review-depth.ts'), 'utf8');
fs.symlinkSync(path.join(ROOT, 'lib/ship-policy.ts'), path.join(dir, 'ship-policy.ts'));
fs.symlinkSync(path.join(ROOT, 'lib/review-risk.ts'), path.join(dir, 'review-risk.ts'));
fs.symlinkSync(path.join(ROOT, 'lib/review-allowlist.ts'), path.join(dir, 'review-allowlist.ts'));
const hashOf = (text: string) => {
fs.writeFileSync(path.join(dir, 'review-depth.ts'), text);
fs.writeFileSync(path.join(dir, 'print.ts'), "import { DEPTH_RULES } from './review-depth';\nconsole.log(DEPTH_RULES);\n");
@@ -425,7 +424,6 @@ describe('D6: the scoped second pass after a blocking fix (GSTA-152 PR B)', () =
expect(src).toContain('SCOPED_RANGE_LINES = 150');
fs.symlinkSync(path.join(ROOT, 'lib/ship-policy.ts'), path.join(dir, 'ship-policy.ts'));
fs.symlinkSync(path.join(ROOT, 'lib/review-risk.ts'), path.join(dir, 'review-risk.ts'));
fs.symlinkSync(path.join(ROOT, 'lib/review-allowlist.ts'), path.join(dir, 'review-allowlist.ts'));
fs.writeFileSync(path.join(dir, 'review-depth.ts'), src.replace('SCOPED_RANGE_LINES = 150', 'SCOPED_RANGE_LINES = 151'));
fs.writeFileSync(path.join(dir, 'print.ts'), "import { DEPTH_RULES } from './review-depth';\nconsole.log(DEPTH_RULES);\n");
const res = spawnSync(process.execPath, ['--no-env-file', '--config=/dev/null', path.join(dir, 'print.ts')], { encoding: 'utf8', timeout: 30_000 });
+3 -4
View File
@@ -56,7 +56,7 @@ const NO_LOSS_REPLAYS = {
'G1': "GSTA-167 variant G Arm G1: live no-loss replay of both prose voices with part 1's prompts, 2 passes, normalizer v2, paired rule against pooled C' (parts 1 and 3; specialists at medium as in C and C2)",
'L': "GSTA-167 variant G Arm L: live no-loss replay of one batched reader (testing and security plus scoped checklists) and the Red Team on its findings, 2 passes, scored L∘G1 against G1 on the paired rule (part 2)",
'GST-257': "GST-257 PR 1 dispatch-superset no-loss check (test/review-targeted.test.ts): on every fixture diff the branch dispatches main's reviewers at 8be7f21 unchanged plus only the targeted reader, and over the last 100 non-release commits on main it lost none; adds the targeted role and brief to the G1 and L bytes, whose existing reviewer text is unchanged",
'GST-850': "GST-850 dispatch-superset no-loss check: on the 26 GSTA-167 no-loss units the branch plan (allowlisted docs paths, wider security and money classes) drops no reviewer role main dispatches, so no saved blocker can be lost, and over the last 100 main commits no commit with a gate or risk class drops depth (test/review-risk-routing.test.ts, test/review-allowlist.test.ts); reviewer text is unchanged",
'GST-850': "GST-850 dispatch-superset no-loss check: on the 26 GSTA-167 no-loss units the branch plan (wider security and money classes, write-up text in the batched diff) drops no reviewer role main dispatches, so no saved blocker can be lost, and over the last 100 main commits no commit with a gate or risk class drops depth (test/review-risk-routing.test.ts, test/review-risk-linear.test.ts); reviewer text is unchanged",
'R51': "R51 risk-tier routing no-loss re-score (GSTA-167): the saved panel findings (349 records over 24 of the 26 no-loss units) re-scored against the branch's plan lose no blocker whose only finders are roles it drops, and every gate, security, user-data and money fixture keeps main's reviewers plus the full panel (test/review-targeted.test.ts, test/review-risk-routing.test.ts); reviewer text is unchanged",
} as const;
type Replay = keyof typeof NO_LOSS_REPLAYS;
@@ -79,9 +79,8 @@ const REVIEWER_BRIEFS: Record<string, { sha: string; replay: Replay }> = {
'review/specialists/testing.md': { sha: 'd6cc2bef7be562387d0c0a22fefe646dff9c295f1d34d8453a6cb496584150e4', replay: '#3638' },
'review/specialists/targeted.md': { sha: '7f92a0d0a8319c3c0e076fb3b1a4689c45120b2be2c14dfc7d4cb03aafa9d6a5', replay: 'GST-257' },
'scripts/resolvers/fix-check.ts': { sha: '712bf5b0c439e96bac32f19cbe25394dabb4c94aa66600100bfde9b100e15a08', replay: '#3638' },
'lib/review-depth.ts': { sha: '72123ec27673401cd6d6ec57a2f6ea31bed82352317169a8a16e52c65a8b41c3', replay: 'GST-850' },
'lib/review-risk.ts': { sha: '2826f26be33f13d631bc3dfc6113645cfeb288bf5abcb70f4735aea0fff96bb7', replay: 'GST-850' },
'lib/review-allowlist.ts': { sha: 'ea0cdd5dbe6cda362adb7d50b8c20e6b929c23af4b28b9b1600a4b070149f571', replay: 'GST-850' },
'lib/review-depth.ts': { sha: '839205a409014fb2cca6988a01aa04c98c495d8d24bd94895a86bf7794627338', replay: 'GST-850' },
'lib/review-risk.ts': { sha: 'e0e1986620bf9962f99b9915bba9dd4d818cf997c2a41407b0ac69344e5e8663', replay: 'GST-850' },
};
describe('reviewer briefs stay at their no-loss-tested bytes (GSTA-167)', () => {
+58
View File
@@ -0,0 +1,58 @@
/**
* GST-850: the new security and money patterns (lib/review-risk.ts) finish in linear time on hostile input, and every one of
* them is in RISK_RULES, which DEPTH_RULES folds in (docs/review-policy.md#risk-classes).
*/
import { describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { matchRisk } from '../lib/review-risk';
const ROOT = path.join(import.meta.dir, '..');
const N = 10_000;
const fast = (f: () => unknown) => { const t = performance.now(); f(); return performance.now() - t; };
const scan = (lines: string[]) => matchRisk({ codePaths: ['src/a.ts'], added: { 'src/a.ts': lines }, removed: { 'src/a.ts': lines }, scopeAuth: false });
describe('linear time on hostile input', () => {
// Value: protects=gstack-review-plan and the queue verdict never hang on a diff (the R51 resource regex did at about 50
// backslashes; round 1 of GST-850 found a quadratic split-import scan); fails_when=a risk pattern or the split-import
// scan backtracks on long input; seam=matchRisk
test('a 512 KB comment of unclosed imports and a long path.join chain finish under 200 ms', () => {
expect(fast(() => scan(Array.from({ length: 65_536 }, () => 'import {')))).toBeLessThan(200);
expect(fast(() => scan(['path.join('.repeat(50_000)]))).toBeLessThan(200);
});
test('the risk patterns finish under 100 ms on 10k-character lines', () => {
for (const body of ['\\'.repeat(N), 'fetch(' + "'".repeat(N), 'import {' + ' exec'.repeat(N / 5), 'path.join(' + 'a'.repeat(N), 'import ' + 'a '.repeat(N / 2) + 'from', 'require(' + ' '.repeat(N),
' '.repeat(N) + '} = require(', "'..".repeat(N / 3), 'x'.repeat(N) + 'Usage', 'tar ' + '-'.repeat(N), 'import {'.repeat(N / 8), 'new URL(' + '"'.repeat(N)])
expect(fast(() => scan([body]))).toBeLessThan(100);
});
});
describe('the rules hashes cover every new pattern', () => {
// Value: protects=a receipt or queue verdict notices a risk-pattern edit; fails_when=an edit to a security pattern or a money
// word leaves DEPTH_RULES unchanged; seam=DEPTH_RULES over RISK_RULES
test.each([
["'billable', 'billed'", "'billable', 'billed', 'tariff'"],
['extract-zip|unzipper', 'extract-zip|unzipper|jszip'],
["['proxy', ['billing', 'bill']]", "['proxy', ['billing', 'bill', 'meter']]"],
])('editing review-risk.ts (%s) changes DEPTH_RULES', (needle, to) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'risk-rules-'));
try {
for (const f of ['review-depth.ts', 'review-risk.ts']) fs.copyFileSync(path.join(ROOT, 'lib', f), path.join(dir, f));
fs.symlinkSync(path.join(ROOT, 'lib/ship-policy.ts'), path.join(dir, 'ship-policy.ts'));
fs.writeFileSync(path.join(dir, 'print.ts'), "import { DEPTH_RULES } from './review-depth';\nconsole.log(DEPTH_RULES);\n");
const hash = () => {
const r = spawnSync(process.execPath, ['--no-env-file', '--config=/dev/null', path.join(dir, 'print.ts')], { encoding: 'utf8', timeout: 30_000 });
expect(r.status, r.stderr).toBe(0);
return r.stdout.trim();
};
const before = hash();
const src = fs.readFileSync(path.join(dir, 'review-risk.ts'), 'utf8');
expect(src).toContain(needle);
fs.writeFileSync(path.join(dir, 'review-risk.ts'), src.replace(needle, to));
expect(before).toMatch(/^[0-9a-f]{16}$/);
expect(hash()).not.toBe(before);
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
});
});
+11 -11
View File
@@ -45,7 +45,7 @@ describe('risk classes', () => {
});
});
describe('UI component text, active SVGs and bound locale values stay code (R51; GST-850 allowlist)', () => {
describe('UI text and SVGs stay code (R51: the docs paths are deferred to an allowlist classifier)', () => {
// Value: protects=a copy edit, a Vue binding or a namespaced SVG script never plans as docs; fails_when=any of them leaves the code
// class or its lines skip the risk scan; seam=classifyDiff
test.each([
@@ -131,16 +131,16 @@ const classOf = (p: string, added: string[], removed: string[] = [], created: st
const svgClass = (lines: string[]) => classOf(SVG_PATH, lines, [], [SVG_PATH]);
const swap = (from: string, to: string) => CHART.map(l => l.replace(from, to));
describe('GST-850: allowlisted docs paths (a new presentational SVG, a locale value edit)', () => {
// Value: protects=a write-up with one clean chart, or a translation fix, gets one reader; fails_when=the allowlist rejects a clean
// chart or locale value, or the batched specialist drops a write-up that publishes numbers; seam=classifyDiff and planReview
test('a new chart SVG built from the allowlist is docs, and a write-up with one clean chart gets one reader', () => {
expect(svgClass(CHART)).toBe('docs');
expect(classifyDiff({ paths: [SVG_PATH], created: [SVG_PATH], gateTagged: 0, added: { [SVG_PATH]: CHART }, removed: {} }).reasons).toContain('docs: 1 presentational SVG path(s)');
describe('GST-850 choice A: a clean chart SVG and a locale value edit stay code (light)', () => {
// Value: protects=UI text and SVGs keep one batched reader plus the native adversarial pass (the allowlist docs paths were dropped
// for review cost); fails_when=a clean chart or a locale value edit plans as docs, or the batched specialist drops a write-up
// that publishes numbers; seam=classifyDiff and planReview
test('a new clean chart SVG is code, and a write-up with it plans light with the batched reader', () => {
expect(svgClass(CHART)).toBe('code');
const md = 'docs/evals/run.md';
const added = { [md]: ['Recall rose to 33/41.'], [SVG_PATH]: CHART };
const p = plan({ paths: [md, SVG_PATH], created: [SVG_PATH], added, removed: {}, perPath: [{ path: md, lines: 1, binary: false }, { path: SVG_PATH, lines: CHART.length, binary: false }], numbers: scanNumbers(added) });
expect([p.depth, roles(p)]).toEqual(['light', ['outside-voice']]);
expect([p.depth, roles(p)]).toEqual(['light', ['specialist', 'adversarial-native', 'outside-voice']]);
});
test('the batched specialist reads a write-up that publishes numbers, and leaves docs out when nothing is published', () => {
const md = 'docs/evals/run.md', code = 'scripts/score.ts';
@@ -159,14 +159,14 @@ describe('GST-850: allowlisted docs paths (a new presentational SVG, a locale va
['xliff', 'translations/messages.fr.xlf', ' <target>Enregistrer</target>', ' <target>Sauvegarder</target>'],
['ftl', 'locales/en-US/main.ftl', 'welcome = Welcome, { $user }!', 'welcome = Hello, { $user }!'],
['properties', 'src/main/resources/i18n/messages_en.properties', 'button.save=Save', 'button.save=Save now'],
])('a %s value edit under a locale directory is docs', (_n, p, before, after) => {
expect(classOf(p, [after], [before])).toBe('docs');
])('a %s value edit under a locale directory is code', (_n, p, before, after) => {
expect(classOf(p, [after], [before])).toBe('code');
});
});
describe('GST-850: every bypass of the line-pattern docs paths stays code', () => {
// Value: protects=active or bound content never plans as docs; fails_when=any bypass from GST-850 or a sibling spelling classes docs;
// seam=classifyDiff over lib/review-allowlist.ts
// seam=classifyDiff
test.each([
['namespaced <s:script>', ['<svg xmlns:s="http://www.w3.org/2000/svg"><s:script>window.x=42</s:script></svg>']],
['a <script> element', swap('<title>', '<script>alert(1)</script><title>')],