scripts/guard-review-artifacts-ignored.mjs
scripts/guard-review-artifacts-ignored.mjsBrowse 11 files
1,636 tokens
6,380 bytes
Token encoding: o200k_base
Snapshot fac8604
← Back to SKILL.md
1#!/usr/bin/env node2 3import { spawnSync } from 'node:child_process';4import { realpathSync } from 'node:fs';5import { isAbsolute, join, relative, resolve, sep } from 'node:path';6import { fileURLToPath } from 'node:url';7 8const EXIT_SUCCESS = 0;9const EXIT_OPERATIONAL = 1;10const EXIT_CLI = 2;11 12const RELATIVE_ARTIFACT_PATHS = [13 'review-state.json',14 'review-state.md',15 'summary.txt',16 'review-targets.json',17 'review-actions.json',18 'review-actions.md',19];20 21function parseCliArgs(argv) {22 const args = argv.slice(2);23 const result = { outputDir: null, help: false };24 if (args.includes('--help')) {25 result.help = true;26 return result;27 }28 for (let index = 0; index < args.length; index += 1) {29 const arg = args[index];30 if (arg !== '--dir') {31 throw { code: EXIT_CLI, message: `error: unknown flag "${arg}"` };32 }33 index += 1;34 if (index >= args.length) {35 throw { code: EXIT_CLI, message: 'error: --dir requires a value' };36 }37 result.outputDir = args[index];38 }39 if (!result.outputDir) {40 throw { code: EXIT_CLI, message: 'error: --dir is required' };41 }42 return result;43}44 45function getHelpText() {46 return [47 'Usage:',48 ' guard-review-artifacts-ignored.mjs --dir <output-dir>',49 '',50 'Purpose:',51 ' Fail fast if generated review artifacts are not git-ignored.',52 ].join('\n');53}54 55function runGitCheckIgnore(path) {56 const result = spawnSync('git', ['check-ignore', '--quiet', path], { encoding: 'utf8' });57 return result.status === 0;58}59 60function isTracked(path) {61 const result = spawnSync('git', ['ls-files', '--error-unmatch', path], { encoding: 'utf8' });62 return result.status === 0;63}64 65/**66 * The workspace root jj reports for the process working directory, or null67 * when jj is unavailable or the working directory is not in a jj workspace.68 */69function findJjWorkspaceRoot() {70 const result = spawnSync('jj', ['workspace', 'root', '--ignore-working-copy'], {71 encoding: 'utf8',72 });73 if (result.status !== 0) {74 return null;75 }76 const root = result.stdout.trim();77 return root === '' ? null : root;78}79 80function isInside(parentPath, childPath) {81 const relativePath = relative(parentPath, childPath);82 return (83 relativePath !== '' &&84 relativePath !== '..' &&85 !relativePath.startsWith(`..${sep}`) &&86 !isAbsolute(relativePath)87 );88}89 90/**91 * In a Jujutsu workspace with no git directory, git cannot answer whether a92 * path is ignored. The repo ignores its whole `wip/` tree, so an artifact dir93 * under `<workspace-root>/wip/` is covered by construction — that is what this94 * checks, and it is the only case it accepts.95 *96 * The artifact path is resolved to its real path, so it must exist (the97 * workflows create it before this guard runs; a missing path fails with98 * ENOENT) and cannot escape through a symlink. The `wip` boundary is the99 * literal `wip` entry under the real workspace root, so a symlinked `wip`100 * cannot move the boundary to a directory the ignore rule does not cover.101 */102function ensureUnderIgnoredWipTree(path) {103 const absolutePath = resolve(path);104 const workspaceRoot = findJjWorkspaceRoot();105 if (workspaceRoot === null) {106 throw new Error('error: not in a git repository or a jj workspace');107 }108 const canonicalWorkspaceRoot = realpathSync(workspaceRoot);109 const canonicalPath = realpathSync(absolutePath);110 if (!isInside(canonicalWorkspaceRoot, canonicalPath)) {111 throw new Error(112 `error: review artifacts must stay inside the workspace: ${absolutePath} resolves to ${canonicalPath}, outside ${canonicalWorkspaceRoot}`,113 );114 }115 const wipBoundary = join(canonicalWorkspaceRoot, 'wip');116 if (!isInside(wipBoundary, canonicalPath)) {117 throw new Error(118 `error: without git, review artifacts must live under the ignored wip/ tree: ${wipBoundary}`,119 );120 }121 return true;122}123 124function ensureInsideRepo(path) {125 const root = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' });126 if (root.status !== 0) {127 return ensureUnderIgnoredWipTree(path);128 }129 const repoRoot = root.stdout.trim();130 const absolutePath = resolve(path);131 if (!isInside(repoRoot, absolutePath)) {132 throw new Error(`error: output dir must be inside repo: ${repoRoot}`);133 }134 return false;135}136 137async function main() {138 const args = parseCliArgs(process.argv);139 if (args.help) {140 process.stdout.write(`${getHelpText()}\n`);141 process.exit(EXIT_SUCCESS);142 }143 144 const ignoredByWorkspaceLayout = ensureInsideRepo(args.outputDir);145 if (ignoredByWorkspaceLayout) {146 process.stdout.write(147 `ok: review artifacts are under the ignored wip/ tree: ${args.outputDir}\n`,148 );149 process.exit(EXIT_SUCCESS);150 }151 152 const tracked = [];153 const notIgnored = [];154 for (const relativePath of RELATIVE_ARTIFACT_PATHS) {155 const fullPath = join(args.outputDir, relativePath);156 if (isTracked(fullPath)) {157 tracked.push(fullPath);158 continue;159 }160 const ignored = runGitCheckIgnore(fullPath);161 if (!ignored) {162 notIgnored.push(fullPath);163 }164 }165 166 if (tracked.length > 0) {167 process.stderr.write(168 `error: review artifacts are tracked in git and must be untracked first:\n${tracked169 .map((path) => `- ${path}`)170 .join('\n')}\n`,171 );172 process.stderr.write(173 'hint: run `git rm --cached <paths>` once, then keep them ignored via .gitignore.\n',174 );175 process.exit(EXIT_OPERATIONAL);176 }177 178 if (notIgnored.length > 0) {179 process.stderr.write(180 `error: review artifacts must be git-ignored. Missing ignore coverage for:\n${notIgnored181 .map((path) => `- ${path}`)182 .join('\n')}\n`,183 );184 process.exit(EXIT_OPERATIONAL);185 }186 187 process.stdout.write('ok: review artifact paths are ignored by git\n');188}189 190const isMain = (() => {191 try {192 const invokedScriptPath = process.argv[1] ? realpathSync(resolve(process.argv[1])) : null;193 const currentModulePath = realpathSync(fileURLToPath(import.meta.url));194 return invokedScriptPath !== null && invokedScriptPath === currentModulePath;195 } catch {196 return false;197 }198})();199 200if (isMain) {201 main().catch((error) => {202 const code = typeof error?.code === 'number' ? error.code : EXIT_OPERATIONAL;203 const message = error?.message ? String(error.message) : String(error);204 process.stderr.write(`${message}\n`);205 process.exit(code);206 });207}208