Files
Claude 2a62ad5b95 process: derived artifacts are accepted on dev once per beta (#697)
The screenshot fingerprint and golden baselines stop being a tax on every
task branch:

- Task branches no longer commit docs/images/** or golden baselines. On a
  branch the screenshot freshness stays a preflight warning; the review
  prompt, REVIEWER.md and AUTHOR.md drop check-docs as a per-task gate.
- beta-derived.yml refreshes them on dev in one bot commit before the beta
  candidate: canonical docs capture + docs:accept --reviewed, golden from
  the golden-images artifact of a completed Validate on dev +
  golden:accept --reviewed. A changed frame or scene is accepted only when
  named in the inputs; undeclared differences refuse. Baseline commits carry
  Release: and Baseline-Reviewed:; the subject is not a candidate subject.
- classify-changes: the Release: trailer on an issue/* branch no longer
  switches on the heavy set. ci:full / ci:golden do: process-track emits
  full=true, the review gate dispatches Validate with full=true and does not
  accept a light proof.

Canon: PROCESS.md §3 п.13, §5.1, §8, §11.4; CONTRIBUTING.md.

Issue: #697
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-28 23:38:50 +03:00

144 lines
7.8 KiB
JavaScript

#!/usr/bin/env node
/**
* Трек задачи и рамки `ship` (PROCESS.md §5, #695/#696).
*
* node scripts/process-track.mjs resolve --labels="a,b" --base=<ref> --head=<ref>
* node scripts/process-track.mjs ship-limits --base=<ref> --head=<ref>
*
* `resolve` печатает `track=ship|show|ask`, `mutants=true|false` и
* `full=true|false` — то, что конвейер ревью читает, решая, сколько стоит
* заход: мутанты по диффу нужны только `ask` и метке `ci:mutants`; полный
* набор (смоки, golden, perf) на ветке задачи — только меткам `ci:full` и
* `ci:golden` (#697). Инфраструктурная задача без трековой
* метки — `show` (§5.1); признак инфраструктуры механический, как в §1: в
* диффе ни одного файла класса A.
*
* `ship-limits` печатает `ship=true|false` и по строке `violation=…` на каждое
* нарушение рамок: дифф `src/**` не больше 30 строк, без новых файлов в
* `src/**`, без i18n, без полей конфига и без Python. Рамки механические
* намеренно: по ним конвейер сливает задачу без ревью модели, и решать их
* «на глаз» некому.
*/
import { spawnSync } from 'node:child_process';
import { appendFileSync } from 'node:fs';
import { isMainModule } from './spawn-portable.mjs';
import { classify } from './process-gate.mjs';
export const SHIP_SRC_LINE_LIMIT = 30;
/**
* Трек по меткам: `track:*` главнее прежних меток; `trivial` и `small`
* читаются как `show` (§5.1); задача без трековой метки — `ask`. Метки трека
* не доказывают продуктовый поток: инфраструктурной задаче владелец тоже может
* поставить `track:*`, чтобы задать цену конвейера.
*/
export function trackFromLabels(labels = []) {
if (labels.includes('track:ship')) return 'ship';
if (labels.includes('track:show')) return 'show';
if (labels.includes('track:ask')) return 'ask';
if (labels.includes('trivial') || labels.includes('small')) return 'show';
return 'ask';
}
/** Есть ли у задачи трековая метка вообще — новая или прежняя. */
export const hasTrackLabel = (labels = []) => ['track:ship', 'track:show', 'track:ask', 'trivial', 'small']
.some((label) => labels.includes(label));
/**
* Трек, по которому конвейер оценивает заход. Явная метка решает всё; без неё
* инфраструктурная задача (ни одного файла класса A в диффе) — `show`, прочие —
* `ask`. Мутанты по диффу — только на `ask` или по метке `ci:mutants`.
* Полный набор — по меткам `ci:full` и `ci:golden` на любом треке (#697).
*/
export function resolveTrack({ labels = [], files = [] } = {}) {
const infrastructure = files.length > 0 && files.every((file) => classify(file) !== 'A');
const track = hasTrackLabel(labels) ? trackFromLabels(labels) : (infrastructure ? 'show' : 'ask');
const mutants = track === 'ask' || labels.includes('ci:mutants');
const full = labels.includes('ci:full') || labels.includes('ci:golden');
return { track, mutants, full, infrastructure };
}
const I18N = [/^src\/i18n\//, /^custom_components\/[^/]+\/translations\//];
const CONFIG = [/^src\/types\.ts$/, /^src\/config-[^/]+\.ts$/];
const PYTHON = /\.py$/;
/**
* Нарушения рамок `ship` по `git diff --numstat` и `--name-status` базы и
* вершины. Пустой список — задача укладывается в рамки.
*
* @param {{added:number|null, deleted:number|null, path:string}[]} numstat
* @param {{status:string, path:string}[]} nameStatus
*/
export function shipLimitViolations({ numstat = [], nameStatus = [] } = {}) {
const out = [];
const src = numstat.filter((row) => row.path.startsWith('src/'));
const binary = src.filter((row) => row.added === null || row.deleted === null);
const lines = src.reduce((sum, row) => sum + (row.added ?? 0) + (row.deleted ?? 0), 0);
if (lines > SHIP_SRC_LINE_LIMIT) out.push(`дифф src/** — ${lines} строк при рамке ${SHIP_SRC_LINE_LIMIT}`);
if (binary.length) out.push(`двоичные файлы в src/**: ${binary.map((row) => row.path).join(', ')}`);
const added = nameStatus.filter((row) => row.status.startsWith('A') && row.path.startsWith('src/'));
if (added.length) out.push(`новые файлы в src/**: ${added.map((row) => row.path).join(', ')}`);
const paths = [...new Set([...numstat, ...nameStatus].map((row) => row.path))];
const i18n = paths.filter((path) => I18N.some((re) => re.test(path)));
if (i18n.length) out.push(`ключи i18n: ${i18n.join(', ')}`);
const config = paths.filter((path) => CONFIG.some((re) => re.test(path)));
if (config.length) out.push(`поля конфига: ${config.join(', ')}`);
const python = paths.filter((path) => PYTHON.test(path));
if (python.length) out.push(`Python: ${python.join(', ')}`);
return out;
}
/** `git diff --numstat` → строки; двоичный файл даёт `-\t-`. */
export function parseNumstat(text = '') {
return String(text).split('\n').map((line) => line.trim()).filter(Boolean).map((line) => {
const [added, deleted, ...rest] = line.split('\t');
return { added: added === '-' ? null : Number(added), deleted: deleted === '-' ? null : Number(deleted), path: rest.at(-1) };
});
}
/** `git diff --name-status` → строки; у переименования путь — новый. */
export function parseNameStatus(text = '') {
return String(text).split('\n').map((line) => line.trim()).filter(Boolean).map((line) => {
const [status, ...paths] = line.split('\t');
return { status, path: paths.at(-1) };
});
}
function git(args) {
const r = spawnSync('git', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${(r.stderr || '').trim()}`);
return r.stdout;
}
if (isMainModule(import.meta.url)) {
const [command, ...rest] = process.argv.slice(2);
const value = (name) => rest.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3) ?? '';
const emit = (lines) => {
for (const line of lines) console.log(line);
if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join('\n')}\n`);
};
try {
const base = value('base');
const head = value('head') || 'HEAD';
const range = base ? `${base}...${head}` : null;
if (command === 'resolve') {
const labels = value('labels').split(',').map((s) => s.trim()).filter(Boolean);
const files = range ? git(['diff', '--name-only', range]).split('\n').filter(Boolean) : [];
const { track, mutants, full } = resolveTrack({ labels, files });
emit([`track=${track}`, `mutants=${mutants}`, `full=${full}`]);
} else if (command === 'ship-limits') {
if (!range) throw new Error('--base is required');
const violations = shipLimitViolations({
numstat: parseNumstat(git(['diff', '--numstat', range])),
nameStatus: parseNameStatus(git(['diff', '--name-status', range])),
});
emit([`ship=${violations.length === 0}`, `violations=${violations.join('; ')}`]);
} else {
throw new Error('usage: process-track.mjs resolve --labels=a,b [--base=<ref> --head=<ref>] | ship-limits --base=<ref> [--head=<ref>]');
}
} catch (error) {
console.error(`::error::${error.message}`);
process.exit(1);
}
}