mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-04 15:57:33 +01:00
train S5: merge #3862 at 97595f41ee v1.93.7.0
This commit is contained in:
commit
86bb621fa2
21 files changed
+247
-88
No files matched your search
+7
-31
@@ -362,36 +362,9 @@ export function stampMentions(text: string, templates: readonly string[] = []):
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* A removed/added line pair that a restamp alone explains: every version the
|
||||
* removed line mentions in a stamp form and the added line no longer does
|
||||
* moves to `newV`, and the rewritten line is the added line byte for byte.
|
||||
* The moved versions are the ones the stamp_paths stage moves, not only the
|
||||
* old version `oldV` (the gated head's): a tentative `*_KEY_VERSION = '<next
|
||||
* minor>'` never equals the base version at the slot and is a stamp all the
|
||||
* same (#3512). The stage's own bound applies: a tentative mention is newer
|
||||
* than the version the branch was written against, so a moved version older
|
||||
* than `oldV` (a released key moved by hand) is not a stamp, and neither is
|
||||
* one of another width (`tentativeMentions`: a 3-digit dependency pin in a
|
||||
* 4-digit repo is never a candidate, so moving it is a hand edit). A line that
|
||||
* moves nothing, or changes anything beyond those mentions, is not one either.
|
||||
*/
|
||||
export function stampOnlyLine(removed: string, added: string, oldV: string, newV: string, templates: readonly string[] = []): boolean {
|
||||
if (removed === added) return false;
|
||||
const floor = parseVersion(oldV);
|
||||
const width = versionWidth(oldV);
|
||||
const after = stampMentions(added, templates);
|
||||
const moved = [...stampMentions(removed, templates)].filter(v => v !== newV && !after.has(v));
|
||||
if (!moved.length || !floor) return false;
|
||||
if (moved.some(v => { const p = parseVersion(v); return !p || versionWidth(v) !== width || cmpVersion(p, floor) < 0; })) return false;
|
||||
let text = removed;
|
||||
for (const v of moved) text = rewriteStamps(text, v, newV, templates).text;
|
||||
return text === added;
|
||||
}
|
||||
|
||||
/** The version at the branch's original fork point: the first parent of its oldest own commit (base when it has none). */
|
||||
export function forkVersion(repoRoot: string, baseRef: string, fallback: string): string {
|
||||
const first = git(repoRoot, ['rev-list', '--reverse', '--first-parent', `${baseRef}..HEAD`]).out.split('\n')[0]?.trim();
|
||||
export function forkVersion(repoRoot: string, baseRef: string, fallback: string, head = 'HEAD'): string {
|
||||
const first = git(repoRoot, ['rev-list', '--reverse', '--first-parent', `${baseRef}..${head}`]).out.split('\n')[0]?.trim();
|
||||
if (!first) return fallback;
|
||||
const v = versionAtRef(repoRoot, `${first}^`);
|
||||
return 'version' in v ? v.version : fallback;
|
||||
@@ -525,7 +498,9 @@ function runStages(o: RestampOptions, plan: Plan, staging: string, since: string
|
||||
/**
|
||||
* The stamp_paths stage. An explicit old version (the branch's own stamp, or
|
||||
* `--was`) is rewritten on every line of every stamp file; a tentative one
|
||||
* (a mention newer than the fork version) only on lines the branch added.
|
||||
* (a mention newer than the fork version, or of the fork version itself: a
|
||||
* row written at the version the branch forked from instead of a next one,
|
||||
* GST-963) only on lines the branch added.
|
||||
* With no explicit version the branch's VERSION equals the base's: either it
|
||||
* never stamped, or it stamped X and the base reached X on its own (a PR that
|
||||
* lost its slot and merged the base). Then a stamp-form mention of the base
|
||||
@@ -548,6 +523,7 @@ function rewriteStampPaths(o: RestampOptions, plan: Plan, staging: string, since
|
||||
const tentative = new Set(tentativeMentions(new Map([...added].map(([rel, own]) => [rel, own.text])), plan.forkVersion, plan.newVersion, templates).filter(v => !explicit.has(v)));
|
||||
const equalBase = !o.was && !plan.branchStamp && plan.baseVersion !== plan.newVersion ? plan.baseVersion : null;
|
||||
if (equalBase) tentative.add(equalBase);
|
||||
if (plan.forkVersion !== plan.newVersion && !explicit.has(plan.forkVersion)) tentative.add(plan.forkVersion);
|
||||
const moved = new Map<string, number>();
|
||||
const baseSide: string[] = [];
|
||||
let touched = 0;
|
||||
@@ -590,7 +566,7 @@ function rewriteStampPaths(o: RestampOptions, plan: Plan, staging: string, since
|
||||
const baseOwnNote = baseOwn.length ? `; ${baseOwn.reduce((n, [, own]) => n + own.notOwn, 0)} line(s) the base added since the fork point are the base's, not this branch's, and stay (${baseOwn.map(([rel, own]) => `${rel}: ${own.notOwn}`).join('; ')})` : '';
|
||||
sp.detail = (olds.length
|
||||
? `${total} mention(s) in ${touched} file(s) (${olds.join(', ')} -> ${plan.newVersion})${equalNote}`
|
||||
: `${searched}no tentative mention newer than the fork version ${plan.forkVersion}${equalBase ? ` and none of base ${equalBase} on a line this branch added` : ''}${equalNote}`) + baseOwnNote;
|
||||
: `${searched}no tentative mention at or newer than the fork version ${plan.forkVersion}${equalBase ? ` and none of base ${equalBase} on a line this branch added` : ''}${equalNote}`) + baseOwnNote;
|
||||
}
|
||||
|
||||
function applyToBranch(o: RestampOptions, staging: string, wtree: string, files: string[], journal: Journal, journalPath: string): JournalWrite[] {
|
||||
|
||||
+6
-22
@@ -10,7 +10,8 @@ import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { fragmentSlug } from './changelog-fragments';
|
||||
import { branchAddedLines, forkVersion, parseUnifiedDiff, stampMentions, stampOnlyLine, versionAtRef } from './restamp';
|
||||
import { parseUnifiedDiff, versionAtRef } from './restamp';
|
||||
import { branchSides, stampOnlyLine } from './stamp-only';
|
||||
import { matchesAny } from './ship-policy';
|
||||
import { extractVersion } from './version-source';
|
||||
import { BIN_DIR, runBashSync, runScriptSync, scriptInvocation } from './script-invocation';
|
||||
@@ -109,8 +110,8 @@ export function releasePaths(policy: Record<string, any> | null, cwd: string, re
|
||||
* old version for the new one under a built-in form or a `stamp_patterns` template (lib/tree-receipt.ts classifyFile).
|
||||
* A moved version is floored at `ref`'s VERSION, except on a line the branch added itself (gstack-restamp's own rule:
|
||||
* added since the merge base with origin/<base>, and not added by the base since): after a lost slot and a merge of the
|
||||
* base its stale stamp is older than `ref`'s VERSION, so it must be newer than the fork point's version instead, the
|
||||
* bound gstack-restamp's stamp_paths stage uses (GST-895).
|
||||
* base its stale stamp is older than `ref`'s VERSION, so it must be at or newer than the fork point's version instead,
|
||||
* the bound gstack-restamp's stamp_paths stage uses (GST-895; the fork version itself since GST-963).
|
||||
*/
|
||||
export function stampedFiles(policy: Record<string, any> | null, cwd: string, ref: string, base: string): string[] {
|
||||
const globs = Array.isArray(policy?.stamp_files) ? policy.stamp_files.filter((s: unknown): s is string => typeof s === 'string') : [];
|
||||
@@ -123,25 +124,8 @@ export function stampedFiles(policy: Record<string, any> | null, cwd: string, re
|
||||
const templates = Array.isArray(policy?.stamp_patterns) ? policy.stamp_patterns.filter((s: unknown): s is string => typeof s === 'string') : [];
|
||||
const diff = git(cwd, ['diff', '--unified=0', '--no-color', '--no-ext-diff', '--no-textconv', '--no-renames', ref]);
|
||||
if (diff.status !== 0) return [];
|
||||
let fork: string | undefined;
|
||||
let since: string | undefined;
|
||||
const own = new Map<string, Set<string>>();
|
||||
const ownLines = (rel: string): Set<string> => {
|
||||
if (own.has(rel)) return own.get(rel)!;
|
||||
since ??= git(cwd, ['merge-base', `origin/${base}`, 'HEAD']).stdout.trim();
|
||||
let lines = new Set<string>();
|
||||
if (since) {
|
||||
try { lines = branchAddedLines(cwd, since, [rel], ref, branchAddedLines(cwd, since, [rel], `origin/${base}`)).get(rel)?.text ?? lines; } catch {}
|
||||
}
|
||||
own.set(rel, lines);
|
||||
return lines;
|
||||
};
|
||||
const stamp = (rel: string, removed: string, added: string): boolean => {
|
||||
if (stampOnlyLine(removed, added, before.version, after, templates)) return true;
|
||||
if (!ownLines(rel).has(removed)) return false;
|
||||
fork ??= forkVersion(cwd, `origin/${base}`, before.version);
|
||||
return stampOnlyLine(removed, added, fork, after, templates) && (!stampMentions(removed, templates).has(fork) || stampMentions(added, templates).has(fork));
|
||||
};
|
||||
const side = branchSides(cwd, `origin/${base}`, 'HEAD', before.version, ref);
|
||||
const stamp = (rel: string, removed: string, added: string): boolean => stampOnlyLine(removed, added, before.version, after, templates, side(rel));
|
||||
return parseUnifiedDiff(diff.stdout).filter(f => f.status === 'M' && !f.binary && !f.modeChanged && matchesAny(globs, f.path) && f.hunks.length > 0 &&
|
||||
f.hunks.every(h => h.removed.length === h.added.length && h.removed.every((line, i) => stamp(f.path, line, h.added[i]!)))).map(f => f.path);
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export function isReleasePath(root: string, policy: ShipPolicy, versionRel: stri
|
||||
* byte for byte, and equals T_i outside release paths. Null when all hold, else why not.
|
||||
*/
|
||||
export function stampMismatch(o: StampChainOptions, merged: string, stamped: string, plainTree: string): string | null {
|
||||
const r = buildReceipt({ repoRoot: o.repoRoot, gated: merged, current: stamped, policy: o.policy, policyLabel: o.policyLabel });
|
||||
const r = buildReceipt({ repoRoot: o.repoRoot, gated: merged, current: stamped, policy: o.policy, policyLabel: o.policyLabel, baseRef: `${merged}^1` });
|
||||
if ('error' in r) return r.error;
|
||||
if (r.tree !== 'same-modulo-stamps') return `the stamp changed more than stamps: ${r.residual.slice(0, 8).join(', ')}`;
|
||||
const cl = o.policy.changelog;
|
||||
|
||||
@@ -34,7 +34,7 @@ import { genericBackstop } from './ship-queue-train-gate';
|
||||
const Q = 'gstack-ship-queue';
|
||||
|
||||
function sameModuloStamps(env: TrainEnv, gated: string, current: string): { same: boolean; detail: string } {
|
||||
const r = buildReceipt({ repoRoot: env.ctx.repoRoot, gated, current, policy: env.policy.policy, policyLabel: env.policy.source.label });
|
||||
const r = buildReceipt({ repoRoot: env.ctx.repoRoot, gated, current, policy: env.policy.policy, policyLabel: env.policy.source.label, baseRef: `${gated}^1` });
|
||||
if ('error' in r) return { same: false, detail: r.error };
|
||||
return { same: r.tree === 'same-modulo-stamps', detail: r.tree === 'same-modulo-stamps' ? `same modulo stamps (${r.files.length} stamp file(s))` : `differs: ${r.residual.slice(0, 8).join(', ')}` };
|
||||
}
|
||||
@@ -79,7 +79,7 @@ function mergedIn(env: TrainEnv, t: TrainRecord, i: number, head: string, main:
|
||||
const after = t.prefixes[i + 1]!;
|
||||
const root = env.ctx.repoRoot;
|
||||
if (!git(root, ['merge-base', '--is-ancestor', main, head]).ok) return { same: false, detail: `does not contain main ${main.slice(0, 12)}` };
|
||||
const r = buildReceipt({ repoRoot: root, gated: after.commit, current: head, policy: env.policy.policy, policyLabel: env.policy.source.label });
|
||||
const r = buildReceipt({ repoRoot: root, gated: after.commit, current: head, policy: env.policy.policy, policyLabel: env.policy.source.label, baseRef: `${after.commit}^1` });
|
||||
if ('error' in r) return { same: false, detail: r.error };
|
||||
const stamps = `${r.files.filter(f => f.verdict === 'stamp').length} stamp file(s)`;
|
||||
if (r.tree === 'same-modulo-stamps') return { same: true, detail: `T${after.index} with main merged in, same modulo stamps (${stamps})` };
|
||||
@@ -105,7 +105,7 @@ function published(env: TrainEnv, t: TrainRecord, i: number, main: string): { sa
|
||||
const post = sameModuloStamps(env, after.commit, main);
|
||||
if (post.same) return post;
|
||||
const cl = env.policy.policy.changelog;
|
||||
const r = buildReceipt({ repoRoot: env.ctx.repoRoot, gated: after.commit, current: main, policy: env.policy.policy, policyLabel: env.policy.source.label });
|
||||
const r = buildReceipt({ repoRoot: env.ctx.repoRoot, gated: after.commit, current: main, policy: env.policy.policy, policyLabel: env.policy.source.label, baseRef: `${after.commit}^1` });
|
||||
if ('error' in r || !cl || r.residual.some(f => f !== cl)) return post;
|
||||
const parent = git(env.ctx.repoRoot, ['rev-parse', '--verify', '-q', `${main}^`]).out;
|
||||
if (!parent || !sameModuloStamps(env, t.prefixes[i]!.commit, parent).same) return { same: false, detail: `${post.detail}; main's parent ${parent.slice(0, 12) || '(none)'} is not T${t.prefixes[i]!.index} modulo stamps` };
|
||||
|
||||
+1
-1
@@ -66,7 +66,7 @@ export function receiptDigest(r: TreeReceipt): string {
|
||||
}
|
||||
|
||||
function treeReceipt(ctx: QueueContext, policy: LoadedPolicy, gated: string, current: string): TreeReceipt | QueueFailure {
|
||||
const r = buildReceipt({ repoRoot: ctx.repoRoot, gated, current, policy: policy.policy, policyLabel: policy.source.label });
|
||||
const r = buildReceipt({ repoRoot: ctx.repoRoot, gated, current, policy: policy.policy, policyLabel: policy.source.label, baseRef: `${ctx.remote ?? 'origin'}/${ctx.base}` });
|
||||
return 'error' in r ? { code: 'QUEUE_PROOF_MISSING', message: r.error } : r;
|
||||
}
|
||||
|
||||
|
||||
+5
-4
@@ -12,7 +12,7 @@ import { FRAGMENT_README } from './changelog-fragments';
|
||||
import { renderIssueLines } from './issue-links';
|
||||
import { matchesAny } from './ship-policy';
|
||||
import { stampFileKind, versionStampOnly } from './pregate/stamp-only';
|
||||
import { stampOnlyLine } from './restamp';
|
||||
import { stampOnlyLine, type BranchSide } from './stamp-only';
|
||||
|
||||
export const RECEIPT_FENCE = 'gstack-ship-receipt';
|
||||
const KEY_ORDER: Array<keyof ShipReceipt | 'docs'> = [
|
||||
@@ -188,13 +188,14 @@ const CARRY_RERUNS = ['CI at head (gstack-ship-queue ready requires it); test an
|
||||
* The file's text at the reviewed head and at HEAD, with the release version each head carries (from `VERSION`, else
|
||||
* `package.json`) and the base policy's `stamp_patterns` templates.
|
||||
*/
|
||||
export interface CarryChange { before: string | null; after: string | null; oldVersion?: string | null; newVersion?: string | null; templates?: readonly string[] }
|
||||
export interface CarryChange { before: string | null; after: string | null; oldVersion?: string | null; newVersion?: string | null; templates?: readonly string[]; side?: BranchSide }
|
||||
|
||||
/**
|
||||
* A policy-named stamp path's change is a stamp when the new text reads as the
|
||||
* old text once every new version in it is the old one, or when every changed
|
||||
* line is a stamp-form rewrite to the new version (`stampOnlyLine`: the old
|
||||
* version or a newer tentative `*_KEY_VERSION` the slot stamp moved, #3512): the rule
|
||||
* version or a newer tentative `*_KEY_VERSION` the slot stamp moved, #3512, and
|
||||
* on a line the branch added itself, `side`, the fork version or newer, GST-963): the rule
|
||||
* the tree receipt applies to the same `stamp_paths`, `stamp_files` and
|
||||
* `release_outputs` before the train accepts a stamp commit. No version
|
||||
* change, or a file missing on either side, is not a stamp.
|
||||
@@ -209,7 +210,7 @@ export function stampOnlyText(change: CarryChange): boolean {
|
||||
const now = after.split('\n');
|
||||
if (was.length !== now.length) return false;
|
||||
const changed = was.map((line, i) => i).filter(i => was[i] !== now[i]);
|
||||
return changed.length > 0 && changed.every(i => stampOnlyLine(was[i]!, now[i]!, oldVersion, newVersion, change.templates ?? []));
|
||||
return changed.length > 0 && changed.every(i => stampOnlyLine(was[i]!, now[i]!, oldVersion, newVersion, change.templates ?? [], change.side));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Stamp-only checks: whether a removed/added line pair is a restamp alone, and
|
||||
* the branch side (its own lines and fork version) that bounds that check for
|
||||
* lines the branch wrote itself. Split from restamp.ts (module-size ratchet).
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { branchAddedLines, forkVersion, rewriteStamps, stampMentions } from './restamp';
|
||||
import { cmpVersion, parseVersion, versionWidth } from './version-source';
|
||||
|
||||
/**
|
||||
* A removed/added line pair that a restamp alone explains: every version the
|
||||
* removed line mentions in a stamp form and the added line no longer does
|
||||
* moves to `newV`, and the rewritten line is the added line byte for byte.
|
||||
* The moved versions are the ones the stamp_paths stage moves, not only the
|
||||
* old version `oldV` (the gated head's): a tentative `*_KEY_VERSION = '<next
|
||||
* minor>'` never equals the base version at the slot and is a stamp all the
|
||||
* same (#3512). The stage's own bound applies: a tentative mention is newer
|
||||
* than the version the branch was written against, so a moved version older
|
||||
* than `oldV` (a released key moved by hand) is not a stamp, and neither is
|
||||
* one of another width (`tentativeMentions`: a 3-digit dependency pin in a
|
||||
* 4-digit repo is never a candidate, so moving it is a hand edit). A line that
|
||||
* moves nothing, or changes anything beyond those mentions, is not one either.
|
||||
* With `side`, a removed line the branch added itself is bounded by its fork
|
||||
* point's version instead, the stage's bound for its own lines (GST-895,
|
||||
* GST-963): finish's stampedFiles, the tree receipt and the receipt carry all
|
||||
* pass the same side.
|
||||
*/
|
||||
export function stampOnlyLine(removed: string, added: string, oldV: string, newV: string, templates: readonly string[] = [], side?: BranchSide): boolean {
|
||||
if (removed === added) return false;
|
||||
return stampOnlyWithin(removed, added, oldV, newV, templates) || (!!side?.added.has(removed) && stampOnlyWithin(removed, added, side.fork, newV, templates));
|
||||
}
|
||||
|
||||
/** Lines the branch added to one file (since its merge base with the base, less the lines the base added since) and its fork point's version. */
|
||||
export interface BranchSide { fork: string; added: ReadonlySet<string> }
|
||||
|
||||
/**
|
||||
* Each file's BranchSide for `head` against `baseRef`, read lazily and cached: the lines come from merge-base..`to`
|
||||
* (`head` unless a reviewed tree stands in for it), the fork version from `head`'s first-parent history
|
||||
* (`fallback` when it has no own commit). A file git cannot diff has no side, so its lines keep the `oldV` bound.
|
||||
*/
|
||||
export function branchSides(repoRoot: string, baseRef: string, head: string, fallback: string, to = head): (rel: string) => BranchSide | undefined {
|
||||
const sides = new Map<string, BranchSide | undefined>();
|
||||
let since: string | undefined;
|
||||
let fork: string | undefined;
|
||||
return rel => {
|
||||
if (sides.has(rel)) return sides.get(rel);
|
||||
since ??= (spawnSync('git', ['merge-base', baseRef, head], { cwd: repoRoot, encoding: 'utf8', timeout: 60_000 }).stdout ?? '').trim();
|
||||
let side: BranchSide | undefined;
|
||||
if (since) {
|
||||
try {
|
||||
const added = branchAddedLines(repoRoot, since, [rel], to, branchAddedLines(repoRoot, since, [rel], baseRef)).get(rel)?.text;
|
||||
if (added?.size) side = { fork: (fork ??= forkVersion(repoRoot, baseRef, fallback, head)), added };
|
||||
} catch {}
|
||||
}
|
||||
sides.set(rel, side);
|
||||
return side;
|
||||
};
|
||||
}
|
||||
|
||||
function stampOnlyWithin(removed: string, added: string, oldV: string, newV: string, templates: readonly string[]): boolean {
|
||||
const floor = parseVersion(oldV);
|
||||
const width = versionWidth(oldV);
|
||||
const after = stampMentions(added, templates);
|
||||
const moved = [...stampMentions(removed, templates)].filter(v => v !== newV && !after.has(v));
|
||||
if (!moved.length || !floor) return false;
|
||||
if (moved.some(v => { const p = parseVersion(v); return !p || versionWidth(v) !== width || cmpVersion(p, floor) < 0; })) return false;
|
||||
let text = removed;
|
||||
for (const v of moved) text = rewriteStamps(text, v, newV, templates).text;
|
||||
return text === added;
|
||||
}
|
||||
+15
-11
@@ -16,7 +16,8 @@ import * as path from 'node:path';
|
||||
import { normalizeChangelog } from './changelog-check';
|
||||
import { foldIntoUnreleased, parseFragment, type Fragment } from './changelog-fragments';
|
||||
import { compareIdentity, executionIdentity, normalizeLockfile, readBundle, type EvidenceBundle, type ExecutionIdentity } from './evidence-bundle';
|
||||
import { parseUnifiedDiff, stampOnlyLine, versionAtRef, type FileDiff, type GateAheadRecord } from './restamp';
|
||||
import { parseUnifiedDiff, versionAtRef, type FileDiff, type GateAheadRecord } from './restamp';
|
||||
import { branchSides, stampOnlyLine, type BranchSide } from './stamp-only';
|
||||
import type { ResultCodeName } from './result-codes';
|
||||
import { matchesAny, type ShipPolicy } from './ship-policy';
|
||||
|
||||
@@ -55,7 +56,7 @@ function blob(cwd: string, treeish: string, rel: string): string | null {
|
||||
* between the two is a stamp only when the CHANGELOG change is exactly those fragments folded under the new
|
||||
* heading. gstack-tree-receipt and gstack-pregate clear both classify through this, so their rules agree.
|
||||
*/
|
||||
export function classifyFiles(repoRoot: string, fileDiffs: FileDiff[], gated: string, current: string, policy: ShipPolicy, versionRel: string, oldV: string | null, newV: string | null): FileVerdict[] {
|
||||
export function classifyFiles(repoRoot: string, fileDiffs: FileDiff[], gated: string, current: string, policy: ShipPolicy, versionRel: string, oldV: string | null, newV: string | null, side?: (rel: string) => BranchSide | undefined): FileVerdict[] {
|
||||
const prefix = policy.changelog_fragments ? policy.changelog_fragments.dir.replace(/\/+$/, '') + '/' : null;
|
||||
const folded = new Map<string, Fragment>();
|
||||
for (const f of fileDiffs) {
|
||||
@@ -65,11 +66,11 @@ export function classifyFiles(repoRoot: string, fileDiffs: FileDiff[], gated: st
|
||||
if (parsed && !('code' in parsed)) folded.set(f.path, parsed);
|
||||
}
|
||||
const fragments = [...folded.values()].sort((a, b) => (a.path < b.path ? -1 : 1));
|
||||
const changelogVerdict = (f: FileDiff) => classifyFile(repoRoot, f, gated, current, policy, versionRel, oldV, newV, fragments);
|
||||
const changelogVerdict = (f: FileDiff) => classifyFile(repoRoot, f, gated, current, policy, versionRel, oldV, newV, fragments, side);
|
||||
const clDiff = fileDiffs.find(f => policy.changelog && f.path === policy.changelog);
|
||||
const clStamp = !!clDiff && changelogVerdict(clDiff).verdict === 'stamp';
|
||||
return fileDiffs.map((f): FileVerdict => {
|
||||
if (!folded.has(f.path)) return f === clDiff ? changelogVerdict(f) : classifyFile(repoRoot, f, gated, current, policy, versionRel, oldV, newV);
|
||||
if (!folded.has(f.path)) return f === clDiff ? changelogVerdict(f) : classifyFile(repoRoot, f, gated, current, policy, versionRel, oldV, newV, [], side);
|
||||
return clStamp ? { path: f.path, rule: 'changelog_fragments', verdict: 'stamp' } : { path: f.path, rule: 'changelog_fragments', verdict: 'changed', residual: `deleted without its text folded under the new heading in ${policy.changelog ?? 'the CHANGELOG'}` };
|
||||
});
|
||||
}
|
||||
@@ -77,15 +78,15 @@ export function classifyFiles(repoRoot: string, fileDiffs: FileDiff[], gated: st
|
||||
/**
|
||||
* Every removed/added pair differs only by stamp rewrites to the new version:
|
||||
* the old version, or a tentative one (newer than it) the stamp_paths stage
|
||||
* moved (#3512), each in a stamp form (`stampOnlyLine`).
|
||||
* moved (#3512), each in a stamp form (`stampOnlyLine`, bounded by the fork version on the branch's own lines).
|
||||
*/
|
||||
function stampOnlyHunks(hunks: FileDiff['hunks'], oldV: string, newV: string, templates: readonly string[]): { why: string; line: number } | null {
|
||||
function stampOnlyHunks(hunks: FileDiff['hunks'], oldV: string, newV: string, templates: readonly string[], side?: BranchSide): { why: string; line: number } | null {
|
||||
for (const h of hunks) {
|
||||
if (h.removed.length !== h.added.length) return { why: `${h.removed.length} line(s) removed, ${h.added.length} added`, line: h.at };
|
||||
for (let i = 0; i < h.removed.length; i++) {
|
||||
const r = h.removed[i]!;
|
||||
const a = h.added[i]!;
|
||||
if (!stampOnlyLine(r, a, oldV, newV, templates)) return { why: `${JSON.stringify(r.trim().slice(0, 60))} -> ${JSON.stringify(a.trim().slice(0, 60))}`, line: h.at + i };
|
||||
if (!stampOnlyLine(r, a, oldV, newV, templates, side)) return { why: `${JSON.stringify(r.trim().slice(0, 60))} -> ${JSON.stringify(a.trim().slice(0, 60))}`, line: h.at + i };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -96,7 +97,7 @@ function jsonEqualModuloVersion(a: string | null, b: string | null, name: string
|
||||
return normalizeLockfile(name, a) === normalizeLockfile(name, b);
|
||||
}
|
||||
|
||||
export function classifyFile(repoRoot: string, f: FileDiff, gated: string, current: string, policy: ShipPolicy, versionRel: string, oldV: string | null, newV: string | null, folded: readonly Fragment[] = []): FileVerdict {
|
||||
export function classifyFile(repoRoot: string, f: FileDiff, gated: string, current: string, policy: ShipPolicy, versionRel: string, oldV: string | null, newV: string | null, folded: readonly Fragment[] = [], side?: (rel: string) => BranchSide | undefined): FileVerdict {
|
||||
const changed = (rule: string, residual: string, line = f.hunks[0]?.at): FileVerdict => ({ path: f.path, rule, verdict: 'changed', residual, line });
|
||||
const stamp = (rule: string): FileVerdict => ({ path: f.path, rule, verdict: 'stamp' });
|
||||
if (f.binary) return changed('binary', 'binary content differs');
|
||||
@@ -127,8 +128,9 @@ export function classifyFile(repoRoot: string, f: FileDiff, gated: string, curre
|
||||
return changed('changelog', folded.length ? 'entry text is not exactly the deleted fragments folded under the new heading' : 'entry text changed (only a heading move or re-heading is a stamp)');
|
||||
}
|
||||
const rule = matchesAny(policy.stamp_paths, f.path) ? 'stamp_paths' : matchesAny(policy.release_outputs, f.path) ? 'release_outputs' : matchesAny(policy.stamp_files, f.path) ? 'stamp_files' : null;
|
||||
if (oldV && newV && oldV !== newV && rule) {
|
||||
const bad = stampOnlyHunks(f.hunks, oldV, newV, policy.stamp_patterns);
|
||||
const own = rule ? side?.(f.path) : undefined;
|
||||
if (oldV && newV && (oldV !== newV || own) && rule) {
|
||||
const bad = stampOnlyHunks(f.hunks, oldV, newV, policy.stamp_patterns, own);
|
||||
return bad ? changed(rule, bad.why, bad.line) : stamp(rule);
|
||||
}
|
||||
return changed('other', `${f.hunks.length} hunk(s) outside every stamp rule`);
|
||||
@@ -136,6 +138,8 @@ export function classifyFile(repoRoot: string, f: FileDiff, gated: string, curre
|
||||
|
||||
export interface ReceiptOptions {
|
||||
repoRoot: string; gated: string; current?: string; policy: ShipPolicy; policyLabel: string; versionRel?: string;
|
||||
/** The base ref the gated head's own lines are read against (the fork-version bound, GST-963); without it every line keeps the old version's bound. */
|
||||
baseRef?: string;
|
||||
predecessor?: { pr: string | null; recordedHead: string | null; ref?: string | null; currentHead?: string | null };
|
||||
bundlePath?: string; gh?: (args: string[]) => { ok: boolean; out: string }; env?: NodeJS.ProcessEnv; rerunCommand?: string;
|
||||
}
|
||||
@@ -176,7 +180,7 @@ export function buildReceipt(o: ReceiptOptions): TreeReceipt | { error: string;
|
||||
return { error: `git diff ${gatedHead.slice(0, 12)} ${currentHead.slice(0, 12)} failed (${why}), so the changed files cannot be classified (GST-448)`, code: 'EVIDENCE_IDENTITY_UNKNOWN' };
|
||||
}
|
||||
const diffText = d.stdout.replace(/\n$/, '');
|
||||
const files = classifyFiles(o.repoRoot, parseUnifiedDiff(diffText), gatedHead, currentHead, o.policy, versionRel, oldV, newV);
|
||||
const files = classifyFiles(o.repoRoot, parseUnifiedDiff(diffText), gatedHead, currentHead, o.policy, versionRel, oldV, newV, o.baseRef && oldV ? branchSides(o.repoRoot, o.baseRef, gatedHead, oldV) : undefined);
|
||||
const residual = files.filter(f => f.verdict === 'changed').map(f => f.path);
|
||||
const stampFiles = files.filter(f => f.verdict === 'stamp').map(f => f.path);
|
||||
const stampDiff = stampFiles.length ? (git(o.repoRoot, ['diff', '--no-color', '--no-ext-diff', gatedHead, currentHead, '--', ...stampFiles]) ?? '') : '';
|
||||
|
||||
Reference in new issue
Block a user