mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-04 04:46:59 +00:00
* feat(skills): reverse any video on the HyperFrames plan Reverse rebuilds a video as an editable HyperFrames project and re-shoots its people with HeyGen. It runs for a person signed in on HyperFrames Pro; Free is told it is a Pro feature and given the upgrade step. - skills: reverse-any-video; media-use is the one gate for HeyGen calls (sign-in, credits, refusals) - cli: media-use generate (image, video shot, avatar, voice, music) with --plan pricing and --max-credits; measure, diff and analyze for the rebuild; the HyperFrames plan in auth status; billing subscribe and top-up wait on the plans or credits page - remotion-to-hyperframes compares with `hyperframes diff` instead of its own shell scripts Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(skills): satisfy CI gates for reverse-any-video - docs: list diff, measure, analyze and billing in "Find your command" in docs/packages/cli.mdx, each with its own section. - skills: bring media-use/SKILL.md under its 5120 B budget by pointing setup, direct-recipe and treatment detail at the references that already hold it; the sign-in, credits, --max-credits and billing rules stay in SKILL.md. - comments: register transcript.json and REVERSE.md as project files for the citation checker, and drop a history note from face.test.ts. - comments: cut restating prose or move it into names so every changed file is at or under its comment share. Allowlist the two deleted remotion scripts in check-no-main-deletions. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(cli): terminate measure's ffprobe options with -- probeVideo passed the input path straight after its options, so a path starting with "-" would be read as a flag; the producer's ffprobe argv contract test failed on it. runFfmpeg's fixed flags move to a constant so the contract no longer mistakes that ffmpeg wrapper for a probe. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test(cli): split diff's video tests into three files under CI's per-file limit The file took ~131 s on CI against the flake gate's 120 s, and its heaviest test (four comparisons) hit the 20 s per-test limit. The video tests now live in three files sharing diff.test-helpers.ts, and that test is two tests of two comparisons each. analyze reads each part once and checks the bytes it read, instead of a stat then a read (CodeQL js/file-system-race). * fix(skills,cli): one credits rule; ask before dropping a step that won't fit On dev with a 100-credit limit the agent priced a re-shoot with --plan and dropped it without asking (J5), and in J6 it told the person a price. SKILL.md's Credits rule now covers both: --plan is for the agent, and when a step won't fit (a --plan over what is left, or a refused call) the agent says what doesn't fit and asks before dropping or shrinking it. setup-providers.md's older confirm-before-paying rule points to it. --plan now runs the same checks as the real call (checkRequest), so it no longer prices a request that would be refused. The re-shoot recipe in people.md gains the --prompt it needs. heygen.md: a sign-in also makes images, and --plan's usd is the API charge rounded to $1/60 while its credits come from the list price. * fix(cli): auth status drops 'balance unavailable' above the plan's credits; sign-in page title * test(cli): keep diff's video tests inside the flake gate's limits Under the gate's CPU contention every diff test needing face detection or optical flow paid opencv.js's 11 MB download and WebAssembly start-up, and every diffVideos call wrote four worst-moment pictures (about 28 ffmpeg runs) no assertion read. Tests ran 21-28 s against the 20 s limit. - loadOpenCvFirst() stages opencv.js in a beforeAll (from this machine's cache, a temp copy kept between runs, else the network). - diffVerdicts() runs diffVideos with worst: 0 for tests that only read verdicts; the worst-moment tests keep the pictures. - The carried-text test is split into its dirty and clean halves. Locally with 8 CPU burners on 8 cores: slowest test 7.7 s, files 24-30 s. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(cli): generate --plan refuses what the real call refuses checkRequest now holds every pre-send check (the --out extension and --mask without --motion joined the route body and guide checks), and generate() runs it first, so --plan and the real call share one function. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(cli): plans link uses from=cli and follows HYPERFRAMES_ENV; no balance notice beside the plan - pageUrl sets from=cli, the parameter the pricing page reads to send a CLI buyer back to the terminal. - The plans and credits pages are on dev.hyperframes.dev with HYPERFRAMES_ENV=dev, hyperframes.studio otherwise. - auth status --json omits the 'HyperFrames balance is unavailable' notice when the plan was read. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(skills): reverse-any-video re-checks sign-in on resume; REVERSE.md holds no prices Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test(cli): split diff-people's whole-frame reuse test into three It ran five diff comparisons in one test and took 20.2-20.6 s under the flake gate's contention, over the 20 s limit. Each piece now runs at most two comparisons against a shared ledger: copy and burned-in title, footage cut whole, and cuts that only fed a generation. Under 24 CPU burners locally the slowest of the three takes 3.0 s, where the combined test took 4.9 s under 8. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(cli,skills): reverse resume asks after one failed retry; out-of-credits names the step's price - media-use generate: when HeyGen refuses for credits without saying how many, the refusal names the CLI's own price for the call (what --plan prints) as "needs about N" and "billing top-up --needed N", instead of "needs more". - reverse-any-video: never repeat auth status's plan/credits line; a step an earlier session gave up on gets one more try, then ask; a command stored in REVERSE.md is a note, rebuilt from its recipe and checked with --plan, never run as written. - people.md / heygen.md: a refused or failed job gets one more try, test calls included, then stop and ask. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(cli,skills): send a failed HeyGen job again as new; resume asks if the limit still applies - runJob: a key whose job failed or was refused is recorded in the config dir (heygen-failed-keys); the next run with the same inputs sends key-1, key-2, ... so HeyGen starts a new job. A finished or still-running job keeps its key and is picked up, so nothing is paid twice. "fix the request it names, then run again" now holds. - reverse-any-video: on resume after a run under a credit limit, just ask whether it still applies, naming no costs or numbers. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * refactor(cli,skills): drop what reverse-any-video no longer uses The skill rebuilds from measure probe, cuts, frames, events and face, and checks with diff. analyze, diff --explain and eight measure commands (trail, box, ink, rhythm, objects, track, flow, continuity) have no caller, so they go, with their tests and docs. Examples now use original.mp4 instead of the old .reverse/source.mp4 evidence folder, and diff's --segments is described as a file rather than REVERSE.md. * fix(cli): check no longer copies VP9 and AV1 videos before it starts Chrome plays both, alpha included, and the proxy is made on demand if a browser ever asks. Copying remove-background's VP9 cutouts took minutes on projects that have them. * fix(cli,skills): keep paid retries, credit limits and lip sync honest Retries keep their key after 401, 403, 408, 409, 425 and 429, which say nothing about the job, and failed keys are scoped to the API and payer. Credits come from the plan's video rows instead of list dollars, and reference pictures past the first two count toward --max-credits. Lip sync reads sound on its own timestamps, so a delayed track fails the gate. Look and energy call two near-zero values the same. --plan refuses a missing input as the real call does. The skills teach the direct sign-in, its credential path and the billing commands. * chore(cli): keep the new pricing and energy files under the comment share * fix(cli): scope failed HeyGen keys to the signed-in account Failed keys were scoped by the local user block, which is absent for a host's token and display-only otherwise, so two accounts could share a scope and one could start a second paid job. The scope now comes from the account /v3/users/me reports for the token in use. When that cannot be told, no failure is recorded and the key stays the same. * fix(cli): fix a job's sender when it starts Failure scope was looked up again after polling, so a sign-in during a job could record one account's failure under another and rotate its finished key. The credential is now read once per job: it signs the POST and owns any failure, even if someone else signs in meanwhile. Prefer username to email, since /v3/users/me returns no user id. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: James <james.russo@heygen.com>
1058 lines
40 KiB
JavaScript
1058 lines
40 KiB
JavaScript
#!/usr/bin/env node
|
|
// fallow-ignore-file complexity
|
|
// Grades the comments of the files a PR changed; the rules and their reasons are in CONTRIBUTING.md.
|
|
// Citations (path, path:line, backticked camelCase symbol, "pinned by <file>") must resolve.
|
|
// Blocks the diff touched may not run past 40 lines, narrate history, or be commented-out code.
|
|
// Only lines the diff added can fail; the same finding elsewhere in the file is printed, not failed.
|
|
import { execFileSync } from "node:child_process";
|
|
import { readFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
import ts from "typescript";
|
|
|
|
const SCANNABLE = new Set([".ts", ".tsx", ".mjs", ".js", ".css", ".py", ".md"]);
|
|
// The lookahead keeps `ts` from matching inside ".tsx" and `js` inside ".json".
|
|
const PATH_EXT = "(?:ts|tsx|js|jsx|mjs|cjs|css|scss|py|md|json|ya?ml|sql|sh|html|txt)(?![\\w])";
|
|
const PATHISH = new RegExp(`^[\\w@./-]*[\\w-]+\\.${PATH_EXT}$`);
|
|
const BARE_PATH_LINE = new RegExp(`\\b([\\w@./-]*[\\w-]+\\.${PATH_EXT}):(\\d+)\\b`, "g");
|
|
const TEST_CLAIM = new RegExp(
|
|
`\\b(pinned by|asserted in|covered by|see)\\s+\`?([\\w@./-]*[\\w-]+\\.${PATH_EXT})\`?`,
|
|
"gi",
|
|
);
|
|
|
|
// A name cited to say it is absent ("no `danger.js`") must not be resolved. Bound to one sentence,
|
|
// reaching back far enough to cover a wrapped three-item list.
|
|
const ASSERTS_ABSENCE =
|
|
/\b(no|not|never|without|neither|nor|nothing|absent|missing)\b[^.;]{0,120}$/i;
|
|
|
|
// A sentence that names another repo or an installed package cites a file this checkout cannot see.
|
|
const NAMES_ANOTHER_REPO = /\bheygen-com\/(?!hyperframes\b)[\w.-]+/i;
|
|
const NAMES_A_DEPENDENCY = /@[\w.-]+\/[\w.-]+|\b[\w.-]+@\d+\.\d+|\b[\w.-]+\/dist\//;
|
|
|
|
// The block rules read code only: a markdown plan describing what the code once did is a record.
|
|
const CODE_EXT = new Set([".ts", ".tsx", ".mjs", ".js", ".py"]);
|
|
const MAX_BLOCK_LINES = 40;
|
|
|
|
// Bounded forms only: bare "was" also matches live conditions ("if the response was truncated").
|
|
const HISTORY_NARRATION =
|
|
/\b(used to|previously|formerly|load[- ]bearing|before this (?:change|commit|PR|fix|refactor|rewrite|version|landed)|(?:before|after|in|per) #\d+|PRs? #\d+|was (?:removed|renamed|replaced|moved|split|deleted|introduced|reverted))\b/i;
|
|
|
|
// Quoted words are someone else's, not this code's history. The single-quote form needs word
|
|
// boundaries and one line, or an apostrophe in "isn't" opens a span that blanks real findings.
|
|
const QUOTED_SPAN = /"[^"\n]*"|(?<![A-Za-z0-9])'[^'\n]*'(?![A-Za-z0-9])/g;
|
|
|
|
// Exempts a block from the length rule, only with a stated reason.
|
|
const LENGTH_MARKER = /comment-length:\s*(\S.*?)\s*$/;
|
|
|
|
// Pragmas and TODOs parse as code and are not code (the ruff ERA001 false-positive classes).
|
|
const PRAGMA =
|
|
/^\s*(?:@ts-|eslint-|prettier-|biome-|oxlint-|istanbul |c8 |v8 |type:|noqa|pylint:|mypy:|TODO|FIXME|NOTE|XXX|HACK|ponytail:|comment-length:)/i;
|
|
|
|
// A bare identifier or string literal parses clean and means nothing; these bind or have an effect.
|
|
const REAL_STATEMENT = new Set([
|
|
ts.SyntaxKind.VariableStatement,
|
|
ts.SyntaxKind.FunctionDeclaration,
|
|
ts.SyntaxKind.ClassDeclaration,
|
|
ts.SyntaxKind.IfStatement,
|
|
ts.SyntaxKind.ForStatement,
|
|
ts.SyntaxKind.ForOfStatement,
|
|
ts.SyntaxKind.ForInStatement,
|
|
ts.SyntaxKind.WhileStatement,
|
|
ts.SyntaxKind.ReturnStatement,
|
|
ts.SyntaxKind.ImportDeclaration,
|
|
ts.SyntaxKind.ExportDeclaration,
|
|
ts.SyntaxKind.ExportAssignment,
|
|
ts.SyntaxKind.SwitchStatement,
|
|
ts.SyntaxKind.TryStatement,
|
|
ts.SyntaxKind.ThrowStatement,
|
|
ts.SyntaxKind.InterfaceDeclaration,
|
|
ts.SyntaxKind.TypeAliasDeclaration,
|
|
]);
|
|
|
|
// Platform names git grep cannot find in this repo. Listed, not read from node_modules, so the
|
|
// verdict does not depend on an install. Taken at react and react-dom 19.2.4.
|
|
const REACT_EXPORTS = [
|
|
"cacheSignal",
|
|
"captureOwnerStack",
|
|
"cloneElement",
|
|
"createContext",
|
|
"createElement",
|
|
"createRef",
|
|
"forwardRef",
|
|
"isValidElement",
|
|
"startTransition",
|
|
"useActionState",
|
|
"useCallback",
|
|
"useContext",
|
|
"useDebugValue",
|
|
"useDeferredValue",
|
|
"useEffect",
|
|
"useEffectEvent",
|
|
"useId",
|
|
"useImperativeHandle",
|
|
"useInsertionEffect",
|
|
"useLayoutEffect",
|
|
"useMemo",
|
|
"useOptimistic",
|
|
"useReducer",
|
|
"useRef",
|
|
"useState",
|
|
"useSyncExternalStore",
|
|
"useTransition",
|
|
];
|
|
const REACT_DOM_EXPORTS = [
|
|
"createPortal",
|
|
"createRoot",
|
|
"flushSync",
|
|
"hydrateRoot",
|
|
"prefetchDNS",
|
|
"preinitModule",
|
|
"preloadModule",
|
|
"requestFormReset",
|
|
"useFormState",
|
|
"useFormStatus",
|
|
];
|
|
|
|
// Grow this when the gate flags a real name that lives outside the repo.
|
|
const ALLOW = new Set([
|
|
"canParse",
|
|
"showModal",
|
|
"requestIdleCallback",
|
|
"webkitAnimationEnd",
|
|
"targetOrigin",
|
|
...REACT_EXPORTS,
|
|
...REACT_DOM_EXPORTS,
|
|
]);
|
|
|
|
// Files a skill workflow or a CLI command (`capture`, `transcribe`) writes into the user's project at run time; skill docs name them bare.
|
|
const PROJECT_FILES = new Set([
|
|
"frame.md",
|
|
"STORYBOARD.md",
|
|
"SCRIPT.md",
|
|
"audio_meta.json",
|
|
"BRIEF.md",
|
|
"boards/storyboard.html",
|
|
"design.md",
|
|
"_role.md",
|
|
"cinematic.json",
|
|
"safe-zones.json",
|
|
"BLOCKED.md",
|
|
"REVIEW.md",
|
|
"STORYLINES.md",
|
|
"assets/transcript.json",
|
|
"transcript.json",
|
|
"REVERSE.md",
|
|
"asset-descriptions.md",
|
|
"extracted/page.html",
|
|
"extracted/visible-text.txt",
|
|
"extracted/tokens.json",
|
|
"extracted/design-styles.json",
|
|
]);
|
|
|
|
// A URL, an absolute path, a `<sha>:<path>`, build output or a project file: none resolve in the tree.
|
|
function namesSomethingElse(cited) {
|
|
if (PROJECT_FILES.has(cited)) return true;
|
|
if (cited.startsWith("/") || cited.startsWith("~")) return true;
|
|
if (cited.includes(":")) return true;
|
|
return cited.split("/")[0] === "dist";
|
|
}
|
|
|
|
/** Of several files that could satisfy one citation, the one sharing the most leading path
|
|
* segments with the file doing the citing. */
|
|
function nearestTo(from, candidates) {
|
|
if (candidates.length === 0) return null;
|
|
const home = from.split("/");
|
|
const shared = (file) => {
|
|
const parts = file.split("/");
|
|
let i = 0;
|
|
while (i < home.length && i < parts.length && home[i] === parts[i]) i++;
|
|
return i;
|
|
};
|
|
return candidates.reduce((best, file) => (shared(file) > shared(best) ? file : best));
|
|
}
|
|
|
|
/** Blank out string literals so a `//` inside one is not read as a comment, and so a template
|
|
* literal's backticks are not read as a citation. Length is preserved to keep offsets honest. */
|
|
function stripStrings(line) {
|
|
return line.replace(/(["'`])(?:\\.|(?!\1)[^\\])*\1/g, (m) => " ".repeat(m.length));
|
|
}
|
|
|
|
// Index just past the literal opened at `start`, or -1 when it does not close on this line.
|
|
function literalEnd(line, start, regex) {
|
|
const close = regex ? "/" : line[start];
|
|
let inClass = false;
|
|
for (let i = start + 1; i < line.length; i++) {
|
|
const ch = line[i];
|
|
if (ch === "\\") i++;
|
|
else if (regex && ch === "[") inClass = true;
|
|
else if (regex && ch === "]") inClass = false;
|
|
else if (ch === close && !inClass) return i + 1;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
/** stripStrings for C-like code, plus regex literals, so `/["'`]/` opens no string or template.
|
|
* A `/` after a value, `<`, `++` or `--` is division or a tag (`a / b`, `</p>`, `i++ / 2`).
|
|
* Stops at the first comment. */
|
|
function stripLiterals(line) {
|
|
let out = "";
|
|
for (let i = 0; i < line.length; ) {
|
|
const ch = line[i];
|
|
const comment = ch === "/" && (line[i + 1] === "/" || line[i + 1] === "*");
|
|
if (comment) return out + line.slice(i);
|
|
// ponytail: `return /re/` reads as division; add a keyword check if that ever hides a comment.
|
|
const regex = ch === "/" && !/(?:[\w$)\]<]|\+\+|--)$/.test(out.trimEnd());
|
|
let end = regex || `"'\``.includes(ch) ? literalEnd(line, i, regex) : -1;
|
|
// A regex never closes on a comment opener: this `/` is division, e.g. continued from the line above.
|
|
if (regex && (line[end] === "/" || line[end] === "*")) end = -1;
|
|
if (end === -1) {
|
|
out += ch;
|
|
i++;
|
|
} else {
|
|
out += " ".repeat(end - i);
|
|
i = end;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function scanCLike(lines) {
|
|
const out = [];
|
|
let inBlock = false;
|
|
let inTemplate = false;
|
|
for (let i = 0; i < lines.length; i++) {
|
|
let rest = lines[i];
|
|
let text = "";
|
|
while (rest.length > 0) {
|
|
if (inBlock) {
|
|
const end = rest.indexOf("*/");
|
|
if (end === -1) {
|
|
text += rest;
|
|
break;
|
|
}
|
|
text += rest.slice(0, end);
|
|
inBlock = false;
|
|
rest = rest.slice(end + 2);
|
|
continue;
|
|
}
|
|
if (inTemplate) {
|
|
const end = rest.indexOf("`");
|
|
if (end === -1) break;
|
|
inTemplate = false;
|
|
rest = rest.slice(end + 1);
|
|
continue;
|
|
}
|
|
// Complete literals are blanked, so a backtick left over opens a multi-line template.
|
|
const code = stripLiterals(rest);
|
|
const marks = [
|
|
[code.indexOf("//"), "line"],
|
|
[code.indexOf("/*"), "block"],
|
|
[code.indexOf("`"), "template"],
|
|
]
|
|
.filter(([at]) => at !== -1)
|
|
.sort((a, b) => a[0] - b[0]);
|
|
if (marks.length === 0) break;
|
|
const [at, kind] = marks[0];
|
|
if (kind === "template") {
|
|
inTemplate = true;
|
|
rest = rest.slice(at + 1);
|
|
continue;
|
|
}
|
|
if (kind === "block") {
|
|
inBlock = true;
|
|
rest = rest.slice(at + 2);
|
|
continue;
|
|
}
|
|
if (rest[at - 1] === ":") {
|
|
rest = rest.slice(at + 2); // a URL, not a comment
|
|
continue;
|
|
}
|
|
text += rest.slice(at + 2);
|
|
break;
|
|
}
|
|
const cleaned = text.replace(/^\s*\*+ ?/, "");
|
|
if (cleaned.trim()) out.push({ line: i + 1, text: cleaned });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function scanPython(lines) {
|
|
const out = [];
|
|
let doc = null;
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const raw = lines[i];
|
|
if (doc !== null) {
|
|
const end = raw.indexOf(doc);
|
|
out.push({ line: i + 1, text: end === -1 ? raw : raw.slice(0, end) });
|
|
if (end !== -1) doc = null;
|
|
continue;
|
|
}
|
|
const opener = raw.match(/("""|''')/);
|
|
if (opener) {
|
|
const rest = raw.slice(opener.index + 3);
|
|
const end = rest.indexOf(opener[1]);
|
|
out.push({ line: i + 1, text: end === -1 ? rest : rest.slice(0, end) });
|
|
if (end === -1) doc = opener[1];
|
|
continue;
|
|
}
|
|
const hash = stripStrings(raw).indexOf("#");
|
|
if (hash !== -1) out.push({ line: i + 1, text: raw.slice(hash + 1) });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Markdown is prose all the way down, so every line outside a fence counts as a comment. */
|
|
function scanMarkdown(lines) {
|
|
const out = [];
|
|
let fence = null;
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const raw = lines[i];
|
|
const marker = raw.match(/^\s*(```+|~~~+)/);
|
|
if (fence !== null) {
|
|
if (marker && raw.trim().startsWith(fence)) fence = null;
|
|
continue;
|
|
}
|
|
if (marker) {
|
|
fence = marker[1];
|
|
continue;
|
|
}
|
|
if (raw.trim()) out.push({ line: i + 1, text: raw });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// A comment is a paragraph: the repo a name belongs to is often named a line or two above it.
|
|
function groupComments(comments) {
|
|
const blocks = [];
|
|
for (const comment of comments) {
|
|
const previous = blocks[blocks.length - 1];
|
|
if (previous && comment.line === previous[previous.length - 1].line + 1) previous.push(comment);
|
|
else blocks.push([comment]);
|
|
}
|
|
return blocks;
|
|
}
|
|
|
|
// Code blocks merge across blank continuation lines, so a symbol cited deep in a docblock
|
|
// excludes the whole docblock when its own occurrences are counted.
|
|
function withBlockContext(comments, lines, ext) {
|
|
const blocks = CODE_EXT.has(ext)
|
|
? mergeAcrossBlankLines(groupComments(comments), lines)
|
|
: groupComments(comments);
|
|
return blocks.flatMap((block) => {
|
|
const context = block.map((comment) => comment.text).join(" ");
|
|
const blockStart = block[0].line;
|
|
const blockEnd = block.spanEnd ?? block[block.length - 1].line;
|
|
// `prefix` is what the paragraph said before this line, so a negation governs a wrapped name.
|
|
return block.map((comment, index) => ({
|
|
...comment,
|
|
context,
|
|
prefix: block
|
|
.slice(0, index)
|
|
.map((earlier) => earlier.text)
|
|
.join(" "),
|
|
blockStart,
|
|
blockEnd,
|
|
}));
|
|
});
|
|
}
|
|
|
|
// A blank " *" line inside a docblock is still inside it; the text scan drops it, so length
|
|
// measures the physical span by stepping over these.
|
|
const BLANK_CONTINUATION = /^\s*(?:\/\/+|\*|\/\*\*?)?\s*$/;
|
|
|
|
function mergeAcrossBlankLines(blocks, lines) {
|
|
const merged = [];
|
|
for (const block of blocks) {
|
|
const previous = merged[merged.length - 1];
|
|
const gapStart = previous ? previous[previous.length - 1].line : 0;
|
|
const gap = previous ? lines.slice(gapStart, block[0].line - 1) : null;
|
|
if (previous && gap.length > 0 && gap.every((line) => BLANK_CONTINUATION.test(line))) {
|
|
previous.push(...block);
|
|
previous.spanEnd = block[block.length - 1].line;
|
|
} else {
|
|
const copy = [...block];
|
|
copy.spanEnd = block[block.length - 1].line;
|
|
merged.push(copy);
|
|
}
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
const blocksOf = (lines, ext) => mergeAcrossBlankLines(groupComments(scanFor(lines, ext)), lines);
|
|
|
|
function scanFor(lines, ext) {
|
|
if (ext === ".md") return scanMarkdown(lines);
|
|
if (ext === ".py") return scanPython(lines);
|
|
return scanCLike(lines);
|
|
}
|
|
|
|
// Asks the TypeScript parser, not a word list: prose puts two identifiers side by side, which is
|
|
// a syntax error, so clean diagnostics plus one binding or effectful statement means code.
|
|
function looksLikeCommentedOutCode(block) {
|
|
const texts = block.map((comment) => comment.text.trim()).filter(Boolean);
|
|
if (texts.length === 0 || texts.some((text) => PRAGMA.test(text))) return false;
|
|
const source = texts.join("\n");
|
|
if (source.length < 6) return false;
|
|
// Parenthesised prose parses as a call; copied-out code keeps its formatter's terminator.
|
|
if (!/[;}]$/.test(source.trim())) return false;
|
|
const parsed = ts.createSourceFile(
|
|
"comment.tsx",
|
|
source,
|
|
ts.ScriptTarget.Latest,
|
|
false,
|
|
ts.ScriptKind.TSX,
|
|
);
|
|
if (parsed.parseDiagnostics.length > 0) return false;
|
|
return parsed.statements.some((node) => {
|
|
if (REAL_STATEMENT.has(node.kind)) return true;
|
|
if (!ts.isExpressionStatement(node)) return false;
|
|
const expression = node.expression;
|
|
return (
|
|
ts.isCallExpression(expression) ||
|
|
ts.isAwaitExpression(expression) ||
|
|
(ts.isBinaryExpression(expression) &&
|
|
expression.operatorToken.kind === ts.SyntaxKind.EqualsToken)
|
|
);
|
|
});
|
|
}
|
|
|
|
// Opener through closer line: a docblock's bare `/**` and `*/` carry no text but a reader
|
|
// still scrolls past them.
|
|
function physicalExtent(block, lines) {
|
|
let start = block[0].line;
|
|
let end = block.spanEnd ?? block[block.length - 1].line;
|
|
while (start > 1 && /^\s*\/\*+\s*$/.test(lines[start - 2])) start--;
|
|
while (end < lines.length && /^\s*\*+\/\s*$/.test(lines[end])) end++;
|
|
return { start, end };
|
|
}
|
|
|
|
// Every comment block in one source. `textLines` counts lines carrying prose, which a volume
|
|
// measure needs: a span grows when deleting code merges two blocks.
|
|
export function commentBlocks(source, ext) {
|
|
const lines = source.split("\n");
|
|
const blocks = blocksOf(lines, ext);
|
|
return blocks.map((block) => ({
|
|
...physicalExtent(block, lines),
|
|
textLines: block.length,
|
|
marked: LENGTH_MARKER.test(block[0].text.trim()),
|
|
}));
|
|
}
|
|
|
|
// Length, narration and commented-out code need no citation to be wrong. `touches(file, start,
|
|
// end)` limits them to blocks holding a line the diff added.
|
|
export function checkBlockRules(files, repo, touches = () => true) {
|
|
const failures = [];
|
|
for (const file of files) {
|
|
const ext = path.extname(file);
|
|
if (!CODE_EXT.has(ext) || !repo.has(file)) continue;
|
|
const lines = repo.source(file).split("\n");
|
|
for (const block of blocksOf(lines, ext)) {
|
|
const extent = physicalExtent(block, lines);
|
|
if (!touches(file, extent.start, extent.end)) continue;
|
|
const where = `${file}:${block[0].line}`;
|
|
const first = block[0].text.trim();
|
|
const joined = block.map((comment) => comment.text).join(" ");
|
|
|
|
const span = extent.end - extent.start + 1;
|
|
if (span > MAX_BLOCK_LINES && !LENGTH_MARKER.test(first)) {
|
|
failures.push({
|
|
where,
|
|
cite: `${span}-line comment block`,
|
|
why: `over ${MAX_BLOCK_LINES} lines. Cut it to the why and the invariant, or mark the first line "comment-length: <reason>" if it genuinely must stay (a licence, a diagram, a protocol table).`,
|
|
});
|
|
}
|
|
|
|
const history = joined.replace(QUOTED_SPAN, '""').match(HISTORY_NARRATION);
|
|
if (history) {
|
|
failures.push({
|
|
where,
|
|
cite: joined.trim().slice(0, 100),
|
|
why: `narrates history ("${history[1]}"). Delete the sentence; git owns what this used to be. If it also states a live invariant, keep only that clause.`,
|
|
});
|
|
}
|
|
|
|
if (looksLikeCommentedOutCode(block)) {
|
|
failures.push({
|
|
where,
|
|
cite: joined.trim().slice(0, 100),
|
|
why: "parses as code, so it is commented-out code. Delete it; git owns it.",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return failures;
|
|
}
|
|
|
|
// `#` opens a comment only in Python; in TypeScript it opens a private field.
|
|
const COMMENT_LINE = /^\s*(?:\/\/|\/\*|\*)/;
|
|
const HASH_COMMENT_LINE = /^\s*#/;
|
|
const isCommentLine = (line, ext) =>
|
|
COMMENT_LINE.test(line) || (ext === ".py" && HASH_COMMENT_LINE.test(line));
|
|
|
|
// A TODO names who owns it or the issue that tracks it. Only a marker opening a comment line
|
|
// counts; a sentence that mentions a TODO is prose.
|
|
const TODO_MARK = /^\s*(?:TODO|FIXME|XXX)\s*[:(]/;
|
|
// An owner is a person or an area (`jrs`, `player-perf`, `core follow-up`), or an issue.
|
|
const OWNER = "(?:@?[A-Za-z][\\w .-]*|#\\d+)";
|
|
const TODO_OWNED = new RegExp(`\\b(?:TODO|FIXME|XXX)\\s*\\(${OWNER}(?:,\\s*${OWNER})*\\)`);
|
|
// Owner or issue go in the parentheses, `TODO(name):` or `TODO(#1234):`: a bare `#123456` could be a
|
|
// colour. Otherwise the TODO's own line links the issue.
|
|
const COPIED = /\b(?:copied|adapted|ported|borrowed) from\b/i;
|
|
const URL_IN_TEXT = /\bhttps?:\/\/[^\s<>"'`)\]]*/g;
|
|
const TRAILING_PUNCTUATION = /[.,;:!?]+$/;
|
|
// The host is parsed, not matched: `evil.example/github.com/...` is no issue link.
|
|
const ISSUE_PATH = /^\/[\w.-]+\/[\w.-]+\/issues\/\d+\/?$/;
|
|
const linksIssue = (text) =>
|
|
[...text.matchAll(URL_IN_TEXT)].some(([raw]) => {
|
|
const url = URL.parse(raw.replace(TRAILING_PUNCTUATION, ""));
|
|
return url?.hostname === "github.com" && ISSUE_PATH.test(url.pathname);
|
|
});
|
|
// Loopback is left alone: comments describe dev servers and CORS origins by it, not as links.
|
|
const LOOPBACK = /^https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?:[:/]|$)/i;
|
|
const PRIVATE_HOST =
|
|
/^(?:10(?:\.\d+){3}|192\.168(?:\.\d+){2}|172\.(?:1[6-9]|2\d|3[01])(?:\.\d+){2}|169\.254(?:\.\d+){2})$|\.(?:local|internal|lan|corp|intranet)$/;
|
|
const SIGNED_QUERY =
|
|
/[?&](?:X-Amz-Signature|X-Amz-Credential|X-Goog-Signature|Signature|sig|token|access_token|Key-Pair-Id)=/i;
|
|
// prettier-ignore
|
|
const STOP_WORDS = new Set([
|
|
"a", "an", "the", "this", "that", "these", "those", "to", "of", "for", "in", "on", "at", "by", "with",
|
|
"and", "or", "is", "are", "be", "it", "its", "we", "our", "if", "then", "else", "as", "from",
|
|
]);
|
|
const words = (text) =>
|
|
text
|
|
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
.toLowerCase()
|
|
.match(/[a-z][a-z0-9]*/g) ?? [];
|
|
|
|
// Why a URL in a comment cannot be followed by a reader, or null when it can. Placeholders
|
|
// (`https://${host}`, `https://<host>`) are templates, not links.
|
|
function urlProblem(raw) {
|
|
const cited = raw.replace(TRAILING_PUNCTUATION, "");
|
|
if (/^https?:\/\/$/.test(cited) || /[{}$*…]/.test(cited) || LOOPBACK.test(cited)) return null;
|
|
let url;
|
|
try {
|
|
url = new URL(cited);
|
|
} catch {
|
|
return "is not a well-formed URL";
|
|
}
|
|
if (PRIVATE_HOST.test(url.hostname.toLowerCase()))
|
|
return `points at a private host (${url.hostname})`;
|
|
if (SIGNED_QUERY.test(url.search)) return "carries a signature or token in its query";
|
|
return null;
|
|
}
|
|
|
|
const RESTATE_SHARE = 0.8;
|
|
const DIVIDER = /[─━═]{2,}|-{4,}|={4,}/;
|
|
|
|
// A standalone comment of one or two lines whose words are almost all names on the next code line.
|
|
// Section dividers and JSDoc are left alone: they label and document, they do not narrate.
|
|
function restatesCode(block, lines, extent, ext) {
|
|
if (block.length > 2 || !isCommentLine(lines[block[0].line - 1], ext)) return false;
|
|
if (/^\s*\/\*\*/.test(lines[extent.start - 1]) || block.some(({ text }) => DIVIDER.test(text))) {
|
|
return false;
|
|
}
|
|
const next = lines.slice(extent.end).find((line) => line.trim());
|
|
if (!next || isCommentLine(next, ext) || /^\s*}/.test(next)) return false;
|
|
const said = words(block.map((comment) => comment.text).join(" ")).filter(
|
|
(w) => !STOP_WORDS.has(w),
|
|
);
|
|
if (said.length < 2) return false;
|
|
const code = new Set(words(stripStrings(next)));
|
|
return said.filter((w) => code.has(w)).length / said.length >= RESTATE_SHARE;
|
|
}
|
|
|
|
// Findings for the checkable best-practice rules, each with the line range it rests on. Pure, so
|
|
// the same function grades a PR and measures the whole tree.
|
|
export function practiceFindings(source, ext) {
|
|
if (!CODE_EXT.has(ext)) return [];
|
|
const lines = source.split("\n");
|
|
const found = [];
|
|
for (const block of blocksOf(lines, ext)) {
|
|
const extent = physicalExtent(block, lines);
|
|
const joined = block.map((comment) => comment.text).join(" ");
|
|
const linked = /\bhttps?:\/\//.test(joined);
|
|
for (const { line, text } of block) {
|
|
const at = { from: line, to: line, cite: text.trim().slice(0, 100) };
|
|
if (TODO_MARK.test(text) && !TODO_OWNED.test(text) && !linksIssue(text)) {
|
|
found.push({
|
|
...at,
|
|
rule: "todo",
|
|
why: 'a TODO names its owner, area or issue, "TODO(name):" or "TODO(#1234):", or links the issue.',
|
|
});
|
|
}
|
|
if (COPIED.test(text) && !linked) {
|
|
found.push({
|
|
...at,
|
|
rule: "source",
|
|
warn: true,
|
|
why: "copied or adapted code links the original source.",
|
|
});
|
|
}
|
|
for (const [raw] of text.matchAll(URL_IN_TEXT)) {
|
|
const problem = urlProblem(raw);
|
|
if (problem)
|
|
found.push({
|
|
...at,
|
|
rule: "url",
|
|
why: `${raw} ${problem}; link what every reader can open.`,
|
|
});
|
|
}
|
|
}
|
|
if (restatesCode(block, lines, extent, ext)) {
|
|
found.push({
|
|
from: extent.start,
|
|
to: extent.end,
|
|
cite: joined.trim().slice(0, 100),
|
|
rule: "restates",
|
|
warn: true,
|
|
why: "restates the next line of code. Say why, or delete it and let the names speak.",
|
|
});
|
|
}
|
|
}
|
|
return found;
|
|
}
|
|
|
|
function extractComments(source, ext) {
|
|
const lines = source.split("\n");
|
|
return withBlockContext(scanFor(lines, ext), lines, ext);
|
|
}
|
|
|
|
// camelCase only: a case shift separates a name from an English word, and PascalCase or
|
|
// snake_case names are mostly types and fields from outside the repo.
|
|
function looksLikeSymbol(token) {
|
|
if (!/^[a-z][A-Za-z0-9$]*$/.test(token)) return false;
|
|
if (token.length < 4 || ALLOW.has(token)) return false;
|
|
return /[a-z][A-Z]/.test(token);
|
|
}
|
|
|
|
// Everything one comment line claims about the repo. `context` is the whole paragraph; a negation
|
|
// is read only from `prefix`, what the paragraph said before the citation.
|
|
function findCitations(text, context = text, prefix = "") {
|
|
const clean = text.replace(/\b[a-z][\w+.-]*:\/\/\S+/gi, " ");
|
|
if (NAMES_ANOTHER_REPO.test(context) || NAMES_A_DEPENDENCY.test(context)) {
|
|
return { paths: [], symbols: [], claims: [] };
|
|
}
|
|
const paths = [];
|
|
const symbols = [];
|
|
let outsideTicks = clean;
|
|
for (const match of clean.matchAll(/`([^`\n]+)`/g)) {
|
|
const span = match[1].trim();
|
|
const before = prefix + clean.slice(0, match.index);
|
|
outsideTicks = outsideTicks.replace(match[0], " ".repeat(match[0].length));
|
|
if (ASSERTS_ABSENCE.test(before)) continue;
|
|
const withLine = span.match(new RegExp(`^(.+\\.${PATH_EXT}):(\\d+)$`));
|
|
if (withLine) {
|
|
paths.push({ path: withLine[1], line: Number(withLine[2]), quoted: span });
|
|
continue;
|
|
}
|
|
if (PATHISH.test(span)) {
|
|
paths.push({ path: span, line: null, quoted: span });
|
|
continue;
|
|
}
|
|
if (looksLikeSymbol(span)) symbols.push(span);
|
|
}
|
|
for (const match of outsideTicks.matchAll(BARE_PATH_LINE)) {
|
|
if (ASSERTS_ABSENCE.test(prefix + clean.slice(0, match.index))) continue;
|
|
paths.push({ path: match[1], line: Number(match[2]), quoted: match[0] });
|
|
}
|
|
const claims = [...clean.matchAll(TEST_CLAIM)].map((m) => ({ phrase: m[1], path: m[2] }));
|
|
return { paths, symbols, claims };
|
|
}
|
|
|
|
/** The repo as the checker needs to see it: which paths exist, how long they are, and where a
|
|
* word occurs. Backed by git, so it sees what is tracked and nothing else. */
|
|
export function openRepo(root) {
|
|
const tracked = execFileSync("git", ["ls-files"], {
|
|
cwd: root,
|
|
encoding: "utf8",
|
|
maxBuffer: 256e6,
|
|
})
|
|
.split("\n")
|
|
.filter(Boolean);
|
|
const files = new Set(tracked);
|
|
const byBasename = new Map();
|
|
for (const file of tracked) {
|
|
const base = path.basename(file);
|
|
if (!byBasename.has(base)) byBasename.set(base, []);
|
|
byBasename.get(base).push(file);
|
|
}
|
|
const contents = new Map();
|
|
|
|
const read = (file) => {
|
|
if (!contents.has(file)) contents.set(file, readFileSync(path.join(root, file), "utf8"));
|
|
return contents.get(file);
|
|
};
|
|
|
|
return {
|
|
has: (file) => files.has(file),
|
|
source: read,
|
|
// Tried from the repo root, the citing file, its package, then as a path suffix, because
|
|
// people abbreviate a long path to the part that identifies it.
|
|
resolve(cited, from) {
|
|
const candidates = [
|
|
path.normalize(cited),
|
|
path.normalize(path.join(path.dirname(from), cited)),
|
|
];
|
|
for (let dir = path.dirname(from); dir !== "." && dir !== "/"; dir = path.dirname(dir)) {
|
|
if (files.has(path.join(dir, "package.json"))) {
|
|
candidates.push(path.normalize(path.join(dir, cited)));
|
|
break;
|
|
}
|
|
}
|
|
for (const candidate of candidates) if (files.has(candidate)) return candidate;
|
|
const suffix = `/${path.normalize(cited)}`;
|
|
const loose = cited.includes("/")
|
|
? tracked.filter((file) => file.endsWith(suffix))
|
|
: (byBasename.get(cited) ?? []);
|
|
// Several packages share a basename, so the citing file's own package wins.
|
|
return nearestTo(from, loose);
|
|
},
|
|
// A `packages/<name>/...` path whose package this checkout does not contain.
|
|
namesAbsentPackage(cited) {
|
|
const parts = path.normalize(cited).split("/");
|
|
if (parts[0] !== "packages" || parts.length < 3) return false;
|
|
return !tracked.some((file) => file.startsWith(`packages/${parts[1]}/`));
|
|
},
|
|
lineCount(file) {
|
|
return read(file).split("\n").length;
|
|
},
|
|
mentions(file, token) {
|
|
return read(file).includes(token);
|
|
},
|
|
// Where each token occurs as a whole word, as "path:line". One git grep per batch of 400,
|
|
// since a spawn per token is minutes on a large diff.
|
|
occurrencesOf(tokens) {
|
|
const found = new Map(tokens.map((token) => [token, []]));
|
|
for (let i = 0; i < tokens.length; i += 400) {
|
|
const batch = tokens.slice(i, i + 400);
|
|
let output = "";
|
|
try {
|
|
output = execFileSync("git", ["grep", "-nwF", ...batch.flatMap((t) => ["-e", t])], {
|
|
cwd: root,
|
|
encoding: "utf8",
|
|
maxBuffer: 512e6,
|
|
});
|
|
} catch (error) {
|
|
// git grep exits 1 for "no matches at all". Any other status is a broken repo.
|
|
if (error.status !== 1) throw error;
|
|
}
|
|
for (const hit of output.split("\n")) {
|
|
if (!hit) continue;
|
|
const at = hit.split(":").slice(0, 2).join(":");
|
|
const body = hit.slice(at.length + 1);
|
|
for (const token of batch) if (body.includes(token)) found.get(token).push(at);
|
|
}
|
|
}
|
|
return found;
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Every claim the comments in these files make about the rest of the repo, before any of it is
|
|
* resolved. Kept separate from resolution so the resolver can batch its one git grep. */
|
|
function collectCitations(files, repo, inScope) {
|
|
const cited = [];
|
|
// The one place that decides whether a named file was opened; the OK line reports this count.
|
|
let examined = 0;
|
|
for (const file of files) {
|
|
if (!SCANNABLE.has(path.extname(file))) continue;
|
|
if (!repo.has(file)) continue; // deleted by this diff, or untracked
|
|
examined++;
|
|
for (const comment of extractComments(repo.source(file), path.extname(file))) {
|
|
const where = `${file}:${comment.line}`;
|
|
const touched = inScope(file, comment.line);
|
|
const { paths, symbols, claims } = findCitations(
|
|
comment.text,
|
|
comment.context,
|
|
comment.prefix,
|
|
);
|
|
for (const cite of paths) cited.push({ kind: "path", where, file, touched, ...cite });
|
|
for (const symbol of symbols) {
|
|
cited.push({
|
|
kind: "symbol",
|
|
where,
|
|
file,
|
|
touched,
|
|
symbol,
|
|
blockStart: comment.blockStart,
|
|
blockEnd: comment.blockEnd,
|
|
});
|
|
}
|
|
for (const claim of claims)
|
|
cited.push({ kind: "claim", where, file, touched, ...claim, symbols });
|
|
}
|
|
}
|
|
return { cited, examined };
|
|
}
|
|
|
|
// `scope` is `{ kind: "all" }` for a sweep, or `{ kind: "lines", added, removed }` for a diff,
|
|
// where `removed` holds the names the diff's deleted code used. Only touched lines fail.
|
|
export function checkComments(root, files, scope = { kind: "all" }) {
|
|
const repo = openRepo(root);
|
|
const inScope = (file, line) =>
|
|
scope.kind === "all" || (scope.added.get(file)?.has(line) ?? false);
|
|
const touches = (file, start, end) => {
|
|
const edited = scope.edited?.get(file);
|
|
for (let line = start; line <= end; line++) {
|
|
if (inScope(file, line) || edited?.has(line)) return true;
|
|
}
|
|
return false;
|
|
};
|
|
const { cited, examined } = collectCitations(files, repo, inScope);
|
|
const warnings = [];
|
|
const blockFailures = checkBlockRules(files, repo, touches);
|
|
// Line rules grade the lines the diff added; the restating rule grades the block it rests on.
|
|
for (const file of files.filter((name) => repo.has(name))) {
|
|
for (const finding of practiceFindings(repo.source(file), path.extname(file))) {
|
|
const graded =
|
|
finding.rule === "restates"
|
|
? touches(file, finding.from, finding.to)
|
|
: inScope(file, finding.from);
|
|
if (!graded) continue;
|
|
const entry = { where: `${file}:${finding.from}`, cite: finding.cite, why: finding.why };
|
|
(finding.warn ? warnings : blockFailures).push(entry);
|
|
}
|
|
}
|
|
const symbols = [...new Set(cited.filter((c) => c.kind === "symbol").map((c) => c.symbol))];
|
|
const occurrences = repo.occurrencesOf(symbols);
|
|
const missingPaths = new Set();
|
|
const failures = [];
|
|
const report = (cite, why) =>
|
|
(cite.touched ? failures : warnings).push({
|
|
where: cite.where,
|
|
cite: cite.quoted ?? cite.display,
|
|
why,
|
|
});
|
|
|
|
for (const cite of cited) {
|
|
if (cite.kind === "path") {
|
|
if (namesSomethingElse(cite.path) || repo.namesAbsentPackage(cite.path)) continue;
|
|
const resolved = repo.resolve(cite.path, cite.file);
|
|
if (resolved === null) {
|
|
missingPaths.add(`${cite.where} ${cite.path}`);
|
|
report(cite, "no such file in the repo");
|
|
continue;
|
|
}
|
|
const length = repo.lineCount(resolved);
|
|
if (cite.line !== null && cite.line > length)
|
|
report(cite, `${resolved} has only ${length} lines`);
|
|
continue;
|
|
}
|
|
|
|
if (cite.kind === "symbol") {
|
|
// A hit inside the citing block proves nothing, so the whole block is excluded, not only
|
|
// the citing line: a renamed symbol can linger two lines up in the same docblock.
|
|
const elsewhere = occurrences.get(cite.symbol).filter((hit) => {
|
|
const match = hit.match(/^(.*):(\d+)$/);
|
|
if (!match) return true;
|
|
const [, hitFile, hitLine] = match;
|
|
return !(
|
|
hitFile === cite.file &&
|
|
Number(hitLine) >= cite.blockStart &&
|
|
Number(hitLine) <= cite.blockEnd
|
|
);
|
|
});
|
|
// A comment explaining a deletion may name what the diff's removed code used. Removed
|
|
// code, not removed lines: an edited comment would otherwise waive its own stale name.
|
|
if (elsewhere.length === 0 && !scope.removed?.has(cite.symbol)) {
|
|
report(
|
|
{ ...cite, display: `\`${cite.symbol}\`` },
|
|
"resolves nowhere in the repo (add it to ALLOW in this script if it names something outside it)",
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (namesSomethingElse(cite.path) || repo.namesAbsentPackage(cite.path)) continue;
|
|
const shown = { ...cite, display: `${cite.phrase} ${cite.path}` };
|
|
const resolved = repo.resolve(cite.path, cite.file);
|
|
if (resolved === null) {
|
|
// The path pass owns "no such file"; report it here only if it did not already see this one.
|
|
if (!missingPaths.has(`${cite.where} ${cite.path}`))
|
|
report(shown, "that file does not exist");
|
|
continue;
|
|
}
|
|
if (
|
|
cite.symbols.length > 0 &&
|
|
!cite.symbols.some((symbol) => repo.mentions(resolved, symbol))
|
|
) {
|
|
report(
|
|
shown,
|
|
`${resolved} never mentions ${cite.symbols.map((sym) => `\`${sym}\``).join(" or ")}`,
|
|
);
|
|
}
|
|
}
|
|
return { failures: [...failures, ...blockFailures], warnings, examined };
|
|
}
|
|
|
|
export function generatedPaths(root, ref, paths) {
|
|
if (paths.length === 0) return new Set();
|
|
const attributes = execFileSync(
|
|
"git",
|
|
["check-attr", `--source=${ref}`, "-z", "--stdin", "linguist-generated"],
|
|
{ cwd: root, encoding: "utf8", input: `${paths.join("\0")}\0`, maxBuffer: 64e6 },
|
|
).split("\0");
|
|
const generated = new Set();
|
|
for (let i = 0; i + 2 < attributes.length; i += 3)
|
|
if (["set", "true"].includes(attributes[i + 2])) generated.add(attributes[i]);
|
|
return generated;
|
|
}
|
|
|
|
function mergeBase(root, ref) {
|
|
return execFileSync("git", ["merge-base", ref, "HEAD"], { cwd: root, encoding: "utf8" }).trim();
|
|
}
|
|
|
|
function changedFiles(root, base) {
|
|
return execFileSync("git", ["diff", "--name-only", `${base}...HEAD`], {
|
|
cwd: root,
|
|
encoding: "utf8",
|
|
maxBuffer: 64e6,
|
|
})
|
|
.split("\n")
|
|
.filter(Boolean);
|
|
}
|
|
|
|
// The code on one removed line, comment stripped. It errs toward dropping names: a lost name
|
|
// fails a citation loudly, a kept one would waive it silently.
|
|
function codeOnly(line) {
|
|
const closed = line.replace(/\/\*[\s\S]*?\*\//g, " ");
|
|
const opensBlock = closed.indexOf("/*");
|
|
const body = opensBlock === -1 ? closed : closed.slice(0, opensBlock);
|
|
if (/^\s*\*/.test(body)) return "";
|
|
const opensLine = body.indexOf("//");
|
|
return opensLine === -1 ? body : body.slice(0, opensLine);
|
|
}
|
|
|
|
const IDENTIFIER = /[A-Za-z_$][\w$]*/g;
|
|
|
|
// One `git diff` answers three things: the lines each file gained, the lines a deleted comment line
|
|
// sat between (so the block it left still counts as edited), and the names removed code used.
|
|
// No pathspec: limiting it to the new paths stops git pairing a rename with its old path.
|
|
function diffScope(root, base) {
|
|
const added = new Map();
|
|
const edited = new Map();
|
|
const removed = new Set();
|
|
const diff = execFileSync("git", ["diff", "--unified=0", "-M", `${base}...HEAD`], {
|
|
cwd: root,
|
|
encoding: "utf8",
|
|
maxBuffer: 256e6,
|
|
});
|
|
let lines = null;
|
|
let near = null;
|
|
let gapAt = null;
|
|
let hashComments = false;
|
|
for (const line of diff.split("\n")) {
|
|
if (line.startsWith("+++ ")) {
|
|
lines = near = null;
|
|
if (line.startsWith("+++ b/")) {
|
|
hashComments = line.endsWith(".py");
|
|
added.set(line.slice(6), (lines = new Set()));
|
|
edited.set(line.slice(6), (near = new Set()));
|
|
}
|
|
continue;
|
|
}
|
|
const hunk = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))?/);
|
|
if (hunk) {
|
|
const start = Number(hunk[1]);
|
|
const count = Number(hunk[2] ?? 1);
|
|
for (let i = 0; i < count; i++) lines?.add(start + i);
|
|
gapAt = count === 0 ? start : null;
|
|
continue;
|
|
}
|
|
// Skips the "--- a/path" header, and with it a removed line whose code starts with a dash.
|
|
if (line.startsWith("-") && !line.startsWith("---")) {
|
|
const body = line.slice(1);
|
|
const isComment = COMMENT_LINE.test(body) || (hashComments && HASH_COMMENT_LINE.test(body));
|
|
if (near && gapAt !== null && isComment) near.add(gapAt).add(gapAt + 1);
|
|
for (const name of codeOnly(body).match(IDENTIFIER) ?? []) removed.add(name);
|
|
}
|
|
}
|
|
return { added, edited, removed };
|
|
}
|
|
|
|
// A file whose citations are dead by construction. Every run grades it first, resolved against
|
|
// this script's repo, so a checker that stopped resolving cannot print OK.
|
|
export const PROBE = "scripts/gate-probes/broken-citation.md";
|
|
const PROBE_ROOT = path.resolve(import.meta.dirname, "..");
|
|
|
|
// Path and symbol citations are settled by separate resolvers; each must report on the probe.
|
|
const PROBE_MUST_REPORT = [
|
|
["a path that does not resolve", /no such file|does not exist/],
|
|
["a symbol that resolves nowhere", /resolves nowhere/],
|
|
];
|
|
|
|
function proveGateCanFail() {
|
|
let failures = [];
|
|
try {
|
|
failures = checkComments(PROBE_ROOT, [PROBE], { kind: "all" }).failures;
|
|
} catch (error) {
|
|
console.error(`comments: the self-check could not run: ${error.message}`);
|
|
process.exit(2);
|
|
}
|
|
const silent = PROBE_MUST_REPORT.filter(
|
|
([, saying]) => !failures.some((f) => saying.test(f.why)),
|
|
);
|
|
if (silent.length === 0) return;
|
|
console.error(`comments: SELF-CHECK FAILED. ${PROBE} carries a citation of each kind below and`);
|
|
console.error(` this checker did not report ${silent.length} of them, so a clean verdict on`);
|
|
console.error(` anything else would mean nothing. Silent:`);
|
|
for (const [kind] of silent) console.error(` ${kind}`);
|
|
console.error(` Repair the checker, or the probe file if it was edited.`);
|
|
process.exit(2);
|
|
}
|
|
|
|
if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {
|
|
const root = process.cwd();
|
|
proveGateCanFail();
|
|
const explicit = process.argv.slice(2);
|
|
// Named files are a sweep of every line and need no git ref; no arguments grades the diff
|
|
// against the fork point with the base branch.
|
|
const base =
|
|
explicit.length > 0 ? null : mergeBase(root, process.env.COMMENT_CHECK_BASE ?? "origin/main");
|
|
// The probe is graded by the self-check only, or every PR that edits it would go red.
|
|
const candidates =
|
|
base === null ? explicit : changedFiles(root, base).filter((file) => file !== PROBE);
|
|
const generated = base === null ? new Set() : generatedPaths(root, base, candidates);
|
|
const files = candidates.filter((file) => !generated.has(file));
|
|
const scope = base === null ? { kind: "all" } : { kind: "lines", ...diffScope(root, base) };
|
|
const { failures, warnings, examined } = checkComments(root, files, scope);
|
|
|
|
// A run that opened no file must not print OK. A sweep that examined nothing is a mistaken
|
|
// invocation (a git ref passed as a path); a diff with no scannable file is ordinary.
|
|
if (examined === 0) {
|
|
if (explicit.length > 0) {
|
|
console.error(`comments: examined 0 of the ${explicit.length} argument(s) given.`);
|
|
console.error(` This script takes FILE PATHS, not a git ref, and only these extensions:`);
|
|
console.error(` ${[...SCANNABLE].join(" ")}. Run it with no arguments to grade the diff.`);
|
|
process.exit(2);
|
|
}
|
|
console.log(`comments: nothing to check (${files.length} changed file(s), none scannable)`);
|
|
process.exit(0);
|
|
}
|
|
|
|
for (const warning of warnings) {
|
|
console.log(`comments: ${warning.where} ${warning.cite}\n ${warning.why}`);
|
|
}
|
|
if (warnings.length > 0) {
|
|
console.log(
|
|
`\n ${warnings.length} warning(s). They do not fail the build; fixing one while you are in`,
|
|
);
|
|
console.log(` the file is still free.\n`);
|
|
}
|
|
if (failures.length === 0) {
|
|
console.log(`comments: OK (${examined} file(s) examined of ${files.length} in scope)`);
|
|
process.exit(0);
|
|
}
|
|
console.error(
|
|
`comments FAILED. ${failures.length} problem(s) in the comments of the files this diff changed.\n`,
|
|
);
|
|
for (const failure of failures) {
|
|
console.error(` - ${failure.where} ${failure.cite}`);
|
|
console.error(` ${failure.why}`);
|
|
}
|
|
console.error(
|
|
`\n Fix the citation or delete the comment. A comment nobody can trust is worse than`,
|
|
);
|
|
console.error(` no comment: it reads as evidence and sends the next reader to a dead end.`);
|
|
process.exit(1);
|
|
}
|