diff --git a/CHANGELOG.md b/CHANGELOG.md index 51b8551..78f8a19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `.gitattributes`). The default remains `{ kind: "jsdoc" }`, preserving the existing JSDoc/C-style output byte-for-byte. - `CommentSyntax` is re-exported from the package root. +- New `verify-codelock` CLI (`npx -p @elg/tscodegen verify-codelock`). Accepts + eslint- / prettier-style glob patterns, auto-detects the comment syntax of + each matched file, and exits non-zero if any tscodegen-generated file has + been modified outside its manual sections. Files that are not tscodegen + output are silently skipped, so the CLI can be pointed at a broad glob like + `**/*` without an allowlist. Intended for use in CI and pre-commit hooks, + and a replacement for the previously planned ESLint rule (which could not + cover non-`.ts` generated files). +- `detectCommentSyntax` helper and the `codelock` module are re-exported from + the package root so consumers can build their own verification tooling on + top of them. ### Changed diff --git a/README.md b/README.md index d910637..08b0a29 100644 --- a/README.md +++ b/README.md @@ -27,9 +27,9 @@ code, with optional manually editable sections and tamper detection. - Codelock tamper detection. A hash is added to the docblock of every generated file to make it easy to detect if a file has been changed outside - the generated manual sections. In the future, this can be [enforced with an - ESLint rule](https://github.com/taneliang/tscodegen/issues/1) (PRs - welcome!). + the generated manual sections. The [bundled `verify-codelock` + CLI](#verifying-codelocks-in-ci-and-pre-commit-hooks) enforces this for you + across TypeScript _and_ non-TypeScript generated files. - No template files. tscodegen uses [template literals](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals) @@ -164,3 +164,76 @@ path/to/generated.ts linguist-generated=true Everything else works the same: `verify()` detects tampering outside the manual sections, `lock()` adds the hash, and `saveToFile()` writes the result. Manual-section keys still must be non-empty and whitespace-free. + +## Verifying codelocks in CI and pre-commit hooks + +tscodegen ships with a `verify-codelock` CLI that walks a set of files and +confirms that every tscodegen-generated file still has a valid codelock hash. +It accepts eslint- / prettier-style glob patterns and automatically detects +the comment syntax of each file (JSDoc for `.ts`, line comments for +`.gitattributes`, etc.), so a single invocation covers both TypeScript and +non-TypeScript generated output. + +Files that are _not_ tscodegen-generated (i.e. do not contain an +`@generated Codelock<<...>>` or `@generated-editable Codelock<<...>>` marker) +are silently skipped, so it's safe to point `verify-codelock` at a broad +glob like `**/*` without having to maintain a separate allowlist. + +### Usage + +Run it through `npx` without installing anything: + +```sh +npx -p @elg/tscodegen verify-codelock 'src/**' 'packages/**/*.gen.ts' +``` + +Or install tscodegen as a dev dependency and call it from a script: + +```json +{ + "scripts": { + "verify:codegen": "verify-codelock 'src/**' '.gitattributes'" + } +} +``` + +### CLI reference + +``` +verify-codelock [options] [...] + +Options: + --ignore Exclude files matching . May be repeated. + --quiet, -q Suppress per-file output and the summary line. + --verbose Log the status of every file, including skipped ones. + --no-color Disable colored output. + --help, -h Print help and exit. + --version, -v Print the installed tscodegen version and exit. + +Exit codes: + 0 All matched tscodegen-generated files have valid codelocks. + 1 One or more files were tampered with, matched no files, or could not be + read. + 2 Invalid invocation (e.g. missing patterns, unknown option). +``` + +### Example: CI check + +```yaml +- run: npx -p @elg/tscodegen verify-codelock '**/*' --ignore '**/node_modules/**' --ignore '**/dist/**' +``` + +### Example: pre-commit hook + +With [Husky](https://typicode.github.io/husky/) + [lint-staged](https://github.com/okonet/lint-staged): + +```json +{ + "lint-staged": { + "*": "verify-codelock" + } +} +``` + +`lint-staged` passes the staged file paths as positional arguments, and +`verify-codelock` will skip any non-generated files automatically. diff --git a/bin/verify-codelock.js b/bin/verify-codelock.js new file mode 100755 index 0000000..5dfb5e4 --- /dev/null +++ b/bin/verify-codelock.js @@ -0,0 +1,6 @@ +#!/usr/bin/env node +"use strict"; + +const { main } = require("../dist/cli/verifyCodelock"); + +process.exitCode = main(process.argv.slice(2)); diff --git a/eslint.config.mjs b/eslint.config.mjs index 9ecaa3a..8e01353 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -7,7 +7,13 @@ import globals from "globals"; export default [ { - ignores: ["dist/**", "coverage/**", "test-reports/**", "node_modules/**"], + ignores: [ + "bin/**", + "dist/**", + "coverage/**", + "test-reports/**", + "node_modules/**", + ], }, js.configs.recommended, ...tseslint.configs.recommended, diff --git a/package.json b/package.json index 5913765..ea7d17d 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,16 @@ "description": "TypeScript string-based code generation tool, with hashed files and manually editable sections", "main": "dist/index.js", "types": "dist/index.d.ts", + "bin": { + "verify-codelock": "bin/verify-codelock.js" + }, + "files": [ + "dist", + "bin", + "CHANGELOG.md", + "LICENSE", + "README.md" + ], "repository": "git@github.com:taneliang/tscodegen.git", "author": "E-Liang Tan ", "license": "MIT", @@ -25,7 +35,9 @@ "prepare": "yarn clean && yarn build" }, "dependencies": { - "@prettier/sync": "^0.6.1" + "@prettier/sync": "^0.6.1", + "picocolors": "^1.1.1", + "tinyglobby": "^0.2.16" }, "peerDependencies": { "prettier": "3.x" diff --git a/src/cli/verifyCodelock.test.ts b/src/cli/verifyCodelock.test.ts new file mode 100644 index 0000000..11fa111 --- /dev/null +++ b/src/cli/verifyCodelock.test.ts @@ -0,0 +1,358 @@ +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { Writable } from "stream"; +import { CodeFile } from "../CodeFile"; +import { + HELP_TEXT, + main, + parseArgs, + resolveFiles, + run, + verifyFile, +} from "./verifyCodelock"; + +/** + * Collects everything written to a stream into an in-memory string. Satisfies + * the `NodeJS.WritableStream` subset of `process.stdout` / `process.stderr` + * that the CLI actually uses (`.write(chunk)`). + */ +class StringStream extends Writable { + chunks: string[] = []; + override _write( + chunk: Buffer | string, + _encoding: BufferEncoding, + callback: (error?: Error | null) => void, + ): void { + this.chunks.push( + typeof chunk === "string" ? chunk : chunk.toString("utf-8"), + ); + callback(); + } + get value(): string { + return this.chunks.join(""); + } +} + +function createTempDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "verify-codelock-")); +} + +function writeEditableTsFile(filePath: string): void { + new CodeFile(filePath) + .build((b) => + b + .addLine("export const foo = 1;") + .addManualSection("m", (m) => m.addLine("// custom")), + ) + .lock() + .saveToFile(); +} + +function writeLineCommentGitattributes(filePath: string): void { + new CodeFile(filePath, { + commentSyntax: { kind: "line", prefix: "# " }, + }) + .build((b) => b.addLine("path/to/generated.ts linguist-generated=true")) + .lock() + .saveToFile(); +} + +describe(parseArgs, () => { + test("collects positional patterns", () => { + const parsed = parseArgs(["src/**/*.ts", "packages/**/*.graphql"]); + expect(parsed.patterns).toEqual(["src/**/*.ts", "packages/**/*.graphql"]); + expect(parsed.error).toBeUndefined(); + }); + + test("parses --ignore with space and equals forms", () => { + const parsed = parseArgs([ + "--ignore", + "**/dist/**", + "--ignore=**/build/**", + "src/**/*.ts", + ]); + expect(parsed.ignore).toEqual(["**/dist/**", "**/build/**"]); + expect(parsed.patterns).toEqual(["src/**/*.ts"]); + }); + + test("reports an error when --ignore is missing its argument", () => { + const parsed = parseArgs(["--ignore"]); + expect(parsed.error).toContain("--ignore"); + }); + + test("parses boolean flags", () => { + const parsed = parseArgs([ + "--quiet", + "--verbose", + "--no-color", + "-h", + "-v", + "src/**", + ]); + expect(parsed.quiet).toBe(true); + expect(parsed.verbose).toBe(true); + expect(parsed.noColor).toBe(true); + expect(parsed.help).toBe(true); + expect(parsed.version).toBe(true); + }); + + test("rejects unknown options", () => { + const parsed = parseArgs(["--unknown"]); + expect(parsed.error).toContain("unknown option"); + }); + + test("treats everything after `--` as a pattern", () => { + const parsed = parseArgs(["--", "--looks-like-flag", "src/**"]); + expect(parsed.patterns).toEqual(["--looks-like-flag", "src/**"]); + }); +}); + +describe("verifyFile / resolveFiles / run", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = createTempDir(); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test("verifyFile classifies ok, tampered, skipped, and error", () => { + const okPath = path.join(tmpDir, "Ok.ts"); + writeEditableTsFile(okPath); + expect(verifyFile(okPath, "Ok.ts").status).toBe("ok"); + + const tamperedPath = path.join(tmpDir, "Tampered.ts"); + writeEditableTsFile(tamperedPath); + fs.appendFileSync(tamperedPath, "export const evil = true;\n"); + expect(verifyFile(tamperedPath, "Tampered.ts").status).toBe("tampered"); + + const plainPath = path.join(tmpDir, "Plain.ts"); + fs.writeFileSync(plainPath, "export const y = 2;\n"); + expect(verifyFile(plainPath, "Plain.ts").status).toBe("skipped"); + + const missingPath = path.join(tmpDir, "missing.ts"); + const result = verifyFile(missingPath, "missing.ts"); + expect(result.status).toBe("error"); + expect(result.message).toBeTruthy(); + }); + + test("resolveFiles expands a glob to matched files only", () => { + writeEditableTsFile(path.join(tmpDir, "a.ts")); + writeEditableTsFile(path.join(tmpDir, "b.ts")); + fs.writeFileSync(path.join(tmpDir, "c.md"), "hello\n"); + + const files = resolveFiles(["**/*.ts"], { cwd: tmpDir }); + expect(files).toEqual(["a.ts", "b.ts"]); + }); + + test("resolveFiles accepts literal file paths", () => { + const filePath = path.join(tmpDir, "Explicit.ts"); + writeEditableTsFile(filePath); + expect(resolveFiles(["Explicit.ts"], { cwd: tmpDir })).toEqual([ + "Explicit.ts", + ]); + }); + + test("resolveFiles expands a directory argument to its contents", () => { + const sub = path.join(tmpDir, "gen"); + fs.mkdirSync(sub); + writeEditableTsFile(path.join(sub, "a.ts")); + writeEditableTsFile(path.join(sub, "b.ts")); + + const files = resolveFiles(["gen"], { cwd: tmpDir }); + expect(files.sort()).toEqual(["gen/a.ts", "gen/b.ts"]); + }); + + test("resolveFiles honours the --ignore option", () => { + writeEditableTsFile(path.join(tmpDir, "keep.ts")); + writeEditableTsFile(path.join(tmpDir, "drop.ts")); + + const files = resolveFiles(["**/*.ts"], { + cwd: tmpDir, + ignore: ["**/drop.ts"], + }); + expect(files).toEqual(["keep.ts"]); + }); + + test("run returns exit code 0 when every matched file is ok or skipped", () => { + writeEditableTsFile(path.join(tmpDir, "a.ts")); + writeLineCommentGitattributes(path.join(tmpDir, ".gitattributes")); + fs.writeFileSync(path.join(tmpDir, "plain.md"), "nothing here\n"); + + const stdout = new StringStream(); + const stderr = new StringStream(); + const result = run(["**/*"], { + cwd: tmpDir, + stdout, + stderr, + noColor: true, + }); + expect(result.exitCode).toBe(0); + expect(result.okCount).toBe(2); + expect(result.skippedCount).toBe(1); + expect(result.tamperedCount).toBe(0); + expect(stderr.value).toBe(""); + expect(stdout.value).toContain("2 ok"); + expect(stdout.value).toContain("1 skipped"); + }); + + test("run returns exit code 1 when a matched file is tampered", () => { + const tamperedPath = path.join(tmpDir, "Tampered.ts"); + writeEditableTsFile(tamperedPath); + fs.appendFileSync(tamperedPath, "export const evil = true;\n"); + + const stdout = new StringStream(); + const stderr = new StringStream(); + const result = run(["**/*.ts"], { + cwd: tmpDir, + stdout, + stderr, + noColor: true, + }); + expect(result.exitCode).toBe(1); + expect(result.tamperedCount).toBe(1); + expect(stdout.value).toContain("tampered Tampered.ts"); + expect(stderr.value).toContain( + "tscodegen-generated files have been modified", + ); + }); + + test("run exits non-zero when no files match the patterns", () => { + const stdout = new StringStream(); + const stderr = new StringStream(); + const result = run(["**/*.nonexistent"], { + cwd: tmpDir, + stdout, + stderr, + noColor: true, + }); + expect(result.exitCode).toBe(1); + expect(stderr.value).toContain("no files matched"); + }); + + test("run is quiet about skipped files by default", () => { + writeEditableTsFile(path.join(tmpDir, "a.ts")); + fs.writeFileSync(path.join(tmpDir, "plain.md"), "nothing\n"); + + const stdout = new StringStream(); + const stderr = new StringStream(); + run(["**/*"], { + cwd: tmpDir, + stdout, + stderr, + noColor: true, + }); + expect(stdout.value).not.toContain("a.ts"); + expect(stdout.value).not.toContain("plain.md"); + expect(stdout.value).toContain("1 ok"); + expect(stdout.value).toContain("1 skipped"); + }); + + test("--verbose prints every file's status", () => { + writeEditableTsFile(path.join(tmpDir, "a.ts")); + fs.writeFileSync(path.join(tmpDir, "plain.md"), "nothing\n"); + + const stdout = new StringStream(); + const stderr = new StringStream(); + run(["**/*"], { + cwd: tmpDir, + stdout, + stderr, + noColor: true, + verbose: true, + }); + expect(stdout.value).toContain("ok a.ts"); + expect(stdout.value).toContain("skipped plain.md"); + }); + + test("--quiet suppresses all non-error output", () => { + const tamperedPath = path.join(tmpDir, "Tampered.ts"); + writeEditableTsFile(tamperedPath); + fs.appendFileSync(tamperedPath, "export const evil = true;\n"); + + const stdout = new StringStream(); + const stderr = new StringStream(); + const result = run(["**/*.ts"], { + cwd: tmpDir, + stdout, + stderr, + noColor: true, + quiet: true, + }); + expect(result.exitCode).toBe(1); + expect(stdout.value).toBe(""); + expect(stderr.value).toBe(""); + }); +}); + +describe(main, () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = createTempDir(); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test("prints help text and exits 0 for --help", () => { + const stdout = new StringStream(); + const stderr = new StringStream(); + const code = main(["--help"], { stdout, stderr }); + expect(code).toBe(0); + expect(stdout.value).toBe(HELP_TEXT); + expect(stderr.value).toBe(""); + }); + + test("prints the installed version and exits 0 for --version", () => { + const stdout = new StringStream(); + const stderr = new StringStream(); + const code = main(["--version"], { + stdout, + stderr, + version: "9.9.9", + }); + expect(code).toBe(0); + expect(stdout.value.trim()).toBe("9.9.9"); + }); + + test("exits 2 when no patterns are supplied", () => { + const stdout = new StringStream(); + const stderr = new StringStream(); + const code = main([], { stdout, stderr }); + expect(code).toBe(2); + expect(stderr.value).toContain("no patterns"); + }); + + test("exits 2 on unknown options", () => { + const stdout = new StringStream(); + const stderr = new StringStream(); + const code = main(["--nope", "src"], { stdout, stderr }); + expect(code).toBe(2); + expect(stderr.value).toContain("unknown option"); + }); + + test("runs end-to-end against a temp directory and returns the exit code", () => { + writeEditableTsFile(path.join(tmpDir, "a.ts")); + const tampered = path.join(tmpDir, "b.ts"); + writeEditableTsFile(tampered); + fs.appendFileSync(tampered, "export const evil = true;\n"); + writeLineCommentGitattributes(path.join(tmpDir, ".gitattributes")); + + const stdout = new StringStream(); + const stderr = new StringStream(); + const code = main(["--no-color", "**/*"], { + stdout, + stderr, + cwd: tmpDir, + }); + expect(code).toBe(1); + expect(stdout.value).toContain("tampered b.ts"); + expect(stdout.value).toContain("2 ok"); + }); +}); diff --git a/src/cli/verifyCodelock.ts b/src/cli/verifyCodelock.ts new file mode 100644 index 0000000..3489676 --- /dev/null +++ b/src/cli/verifyCodelock.ts @@ -0,0 +1,453 @@ +import fs from "fs"; +import path from "path"; +import pc from "picocolors"; +import { globSync } from "tinyglobby"; +import { verifyLock } from "../codelock"; +import { detectCommentSyntax } from "../detectCommentSyntax"; + +export type FileStatus = + /** File is not generated by tscodegen. Skipped, but not a failure. */ + | "skipped" + /** File is locked and the hash matches its contents. */ + | "ok" + /** File has a codelock marker but the contents have been tampered with. */ + | "tampered" + /** File was listed on the command line but could not be read. */ + | "error"; + +export interface FileResult { + file: string; + status: FileStatus; + /** Populated for `error` statuses. */ + message?: string; +} + +export interface VerifyOptions { + /** Working directory used to resolve patterns. Defaults to `process.cwd()`. */ + cwd?: string; + /** Additional glob patterns to exclude. */ + ignore?: readonly string[]; + /** Whether to include dotfiles when matching globs. Defaults to `true`. */ + dot?: boolean; +} + +const DEFAULT_IGNORE = ["**/node_modules/**", "**/.git/**"]; + +/** + * Verify a single file on disk. Returns the result without throwing — I/O and + * parse errors are surfaced via {@link FileResult.status} === `"error"`. + */ +export function verifyFile(absolutePath: string, relPath: string): FileResult { + let contents: string; + try { + contents = fs.readFileSync(absolutePath, "utf-8"); + } catch (err) { + return { + file: relPath, + status: "error", + message: err instanceof Error ? err.message : String(err), + }; + } + + const syntax = detectCommentSyntax(contents); + if (!syntax) { + return { file: relPath, status: "skipped" }; + } + + return { + file: relPath, + status: verifyLock(contents, syntax) ? "ok" : "tampered", + }; +} + +/** + * Expand the supplied {@link patterns} into a sorted list of files, relative + * to {@link VerifyOptions.cwd}. Literal (non-glob) paths are included verbatim + * if they exist on disk, even when they would not normally be matched by the + * glob engine (e.g. because they live outside the default ignore set). + */ +export function resolveFiles( + patterns: readonly string[], + options: VerifyOptions = {}, +): string[] { + const cwd = options.cwd ?? process.cwd(); + const ignore = [...DEFAULT_IGNORE, ...(options.ignore ?? [])]; + const dot = options.dot ?? true; + + const files = new Set(); + + const globPatterns: string[] = []; + for (const pattern of patterns) { + const absolute = path.resolve(cwd, pattern); + let stat: fs.Stats | undefined; + try { + stat = fs.statSync(absolute); + } catch { + stat = undefined; + } + if (stat?.isFile()) { + files.add(path.relative(cwd, absolute) || path.basename(absolute)); + continue; + } + if (stat?.isDirectory()) { + globPatterns.push(path.posix.join(toPosix(pattern), "**/*")); + continue; + } + globPatterns.push(toPosix(pattern)); + } + + if (globPatterns.length > 0) { + const matched = globSync(globPatterns, { + cwd, + absolute: false, + dot, + onlyFiles: true, + ignore, + }); + for (const file of matched) { + files.add(file); + } + } + + return [...files].sort(); +} + +function toPosix(p: string): string { + return p.split(path.sep).join(path.posix.sep); +} + +export interface VerifyRunResult { + results: FileResult[]; + okCount: number; + skippedCount: number; + tamperedCount: number; + errorCount: number; + /** + * Process exit code. `0` when no files were tampered with and no errors + * occurred, `1` otherwise. + */ + exitCode: number; +} + +export interface RunOptions extends VerifyOptions { + /** Stream used for informational output. Defaults to `process.stdout`. */ + stdout?: NodeJS.WritableStream; + /** Stream used for errors. Defaults to `process.stderr`. */ + stderr?: NodeJS.WritableStream; + /** Suppress per-file output; only print the summary and errors. */ + quiet?: boolean; + /** Disable ANSI colors even if the stream is a TTY. */ + noColor?: boolean; + /** Print every file's status, including skipped ones. */ + verbose?: boolean; +} + +/** + * Run the CLI with the given patterns and options. Intended to be called + * directly from tests as well as from the `bin/verify-codelock.js` wrapper. + */ +export function run( + patterns: readonly string[], + options: RunOptions = {}, +): VerifyRunResult { + const stdout = options.stdout ?? process.stdout; + const stderr = options.stderr ?? process.stderr; + const cwd = options.cwd ?? process.cwd(); + const colors = options.noColor ? plainColors : pc; + + if (patterns.length === 0) { + stderr.write( + "verify-codelock: no patterns provided. Pass one or more glob patterns or file paths.\n", + ); + return emptyResult(2); + } + + const files = resolveFiles(patterns, { + cwd, + ignore: options.ignore, + dot: options.dot, + }); + + if (files.length === 0) { + stderr.write( + `verify-codelock: no files matched the supplied patterns: ${patterns.join(", ")}\n`, + ); + return emptyResult(1); + } + + const results: FileResult[] = []; + let okCount = 0; + let skippedCount = 0; + let tamperedCount = 0; + let errorCount = 0; + + for (const file of files) { + const absolute = path.resolve(cwd, file); + const result = verifyFile(absolute, file); + results.push(result); + + switch (result.status) { + case "ok": + okCount += 1; + if (options.verbose && !options.quiet) { + stdout.write(`${colors.green("ok")} ${file}\n`); + } + break; + case "skipped": + skippedCount += 1; + if (options.verbose && !options.quiet) { + stdout.write(`${colors.dim("skipped")} ${file}\n`); + } + break; + case "tampered": + tamperedCount += 1; + if (!options.quiet) { + stdout.write(`${colors.red("tampered")} ${file}\n`); + } + break; + case "error": + errorCount += 1; + stderr.write( + `${colors.red("error")} ${file}: ${result.message ?? "unknown error"}\n`, + ); + break; + } + } + + if (!options.quiet) { + const parts = [ + `${colors.green(`${okCount} ok`)}`, + `${colors.dim(`${skippedCount} skipped`)}`, + ]; + if (tamperedCount > 0) { + parts.push(colors.red(`${tamperedCount} tampered`)); + } + if (errorCount > 0) { + parts.push( + colors.red(`${errorCount} error${errorCount === 1 ? "" : "s"}`), + ); + } + stdout.write( + `\nverify-codelock: checked ${files.length} file${files.length === 1 ? "" : "s"} (${parts.join(", ")}).\n`, + ); + + if (tamperedCount > 0) { + stderr.write( + `\n${colors.red( + "Some tscodegen-generated files have been modified outside their manual sections.", + )}\nRe-run the codegen script that produced each file, or restore the file's previous contents.\n`, + ); + } + } + + const exitCode = tamperedCount > 0 || errorCount > 0 ? 1 : 0; + return { + results, + okCount, + skippedCount, + tamperedCount, + errorCount, + exitCode, + }; +} + +function emptyResult(exitCode: number): VerifyRunResult { + return { + results: [], + okCount: 0, + skippedCount: 0, + tamperedCount: 0, + errorCount: 0, + exitCode, + }; +} + +/** A `picocolors`-compatible no-op palette used when color output is disabled. */ +const plainColors: { + green: (s: string) => string; + red: (s: string) => string; + dim: (s: string) => string; +} = { + green: (s) => s, + red: (s) => s, + dim: (s) => s, +}; + +export interface ParsedArgs { + patterns: string[]; + ignore: string[]; + quiet: boolean; + verbose: boolean; + noColor: boolean; + help: boolean; + version: boolean; + /** + * Set if argument parsing failed. When set, callers should print the + * message to stderr and exit with a non-zero status. + */ + error?: string; +} + +/** + * Parse command-line arguments. Designed to mirror the shape of common + * lint-style CLIs: + * + * ``` + * verify-codelock [options] [...] + * --ignore Exclude files matching . May be repeated. + * --quiet Suppress per-file output and the summary line. + * --verbose Log the status of every file, including skipped ones. + * --no-color Disable colored output. + * -h, --help Print help and exit. + * -v, --version Print the installed tscodegen version and exit. + * ``` + */ +export function parseArgs(argv: readonly string[]): ParsedArgs { + const parsed: ParsedArgs = { + patterns: [], + ignore: [], + quiet: false, + verbose: false, + noColor: false, + help: false, + version: false, + }; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "--") { + parsed.patterns.push(...argv.slice(i + 1)); + break; + } + if (arg === "--help" || arg === "-h") { + parsed.help = true; + continue; + } + if (arg === "--version" || arg === "-v") { + parsed.version = true; + continue; + } + if (arg === "--quiet" || arg === "-q") { + parsed.quiet = true; + continue; + } + if (arg === "--verbose") { + parsed.verbose = true; + continue; + } + if (arg === "--no-color") { + parsed.noColor = true; + continue; + } + if (arg === "--ignore") { + const next = argv[i + 1]; + if (next === undefined) { + parsed.error = "--ignore requires a pattern argument"; + return parsed; + } + parsed.ignore.push(next); + i += 1; + continue; + } + if (arg.startsWith("--ignore=")) { + parsed.ignore.push(arg.slice("--ignore=".length)); + continue; + } + if (arg.startsWith("-") && arg !== "-") { + parsed.error = `unknown option: ${arg}`; + return parsed; + } + parsed.patterns.push(arg); + } + + return parsed; +} + +export const HELP_TEXT = `Usage: verify-codelock [options] [...] + +Verifies that every tscodegen-generated file matched by the supplied glob +patterns still has a valid codelock hash. Files that are not generated by +tscodegen (i.e. do not contain an \`@generated Codelock<<...>>\` or +\`@generated-editable Codelock<<...>>\` marker) are silently skipped. + +Intended for use in CI and pre-commit hooks. + +Arguments: + One or more glob patterns or file paths, in the style + of eslint / prettier (e.g. \`src/**/*\`, \`./file.ts\`). + Directories are expanded to \`/**/*\`. + +Options: + --ignore Exclude files matching . May be repeated. + --quiet, -q Suppress per-file output and the summary line. + --verbose Log the status of every file, including skipped ones. + --no-color Disable colored output. + --help, -h Print this help message and exit. + --version, -v Print the installed tscodegen version and exit. + +Exit codes: + 0 All matched tscodegen-generated files have valid codelocks. + 1 One or more files were tampered with, matched no files, or could not be + read. + 2 Invalid invocation (e.g. missing patterns, unknown option). +`; + +/** + * Entrypoint used by `bin/verify-codelock.js`. + */ +export function main( + argv: readonly string[], + options: { + stdout?: NodeJS.WritableStream; + stderr?: NodeJS.WritableStream; + cwd?: string; + version?: string; + } = {}, +): number { + const stdout = options.stdout ?? process.stdout; + const stderr = options.stderr ?? process.stderr; + const parsed = parseArgs(argv); + + if (parsed.error) { + stderr.write(`verify-codelock: ${parsed.error}\n\n${HELP_TEXT}`); + return 2; + } + + if (parsed.help) { + stdout.write(HELP_TEXT); + return 0; + } + + if (parsed.version) { + stdout.write(`${options.version ?? readPackageVersion()}\n`); + return 0; + } + + if (parsed.patterns.length === 0) { + stderr.write(`verify-codelock: no patterns provided.\n\n${HELP_TEXT}`); + return 2; + } + + const result = run(parsed.patterns, { + cwd: options.cwd, + ignore: parsed.ignore, + quiet: parsed.quiet, + verbose: parsed.verbose, + noColor: parsed.noColor, + stdout, + stderr, + }); + return result.exitCode; +} + +function readPackageVersion(): string { + try { + const pkgPath = path.resolve(__dirname, "..", "..", "package.json"); + const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { + version?: string; + }; + return pkg.version ?? "unknown"; + } catch { + return "unknown"; + } +} diff --git a/src/detectCommentSyntax.test.ts b/src/detectCommentSyntax.test.ts new file mode 100644 index 0000000..73a80ab --- /dev/null +++ b/src/detectCommentSyntax.test.ts @@ -0,0 +1,71 @@ +import { describe, test, expect } from "vitest"; +import { lockCode } from "./codelock"; +import { detectCommentSyntax } from "./detectCommentSyntax"; + +describe(detectCommentSyntax, () => { + test("returns undefined for the empty string", () => { + expect(detectCommentSyntax("")).toBeUndefined(); + }); + + test("returns undefined for a plain source file without a codelock", () => { + expect(detectCommentSyntax(`export const answer = 42;\n`)).toBeUndefined(); + }); + + test("returns undefined for a file with a JSDoc docblock that isn't a codelock", () => { + const code = `/** + * A plain docblock that happens to start the file. + */ + +export const foo = 1; +`; + expect(detectCommentSyntax(code)).toBeUndefined(); + }); + + test("detects JSDoc syntax on an editable codelock", () => { + const locked = lockCode("export const foo = 1;\n", true); + expect(detectCommentSyntax(locked)).toEqual({ kind: "jsdoc" }); + }); + + test("detects JSDoc syntax on an uneditable codelock", () => { + const locked = lockCode("export const foo = 1;\n", false); + expect(detectCommentSyntax(locked)).toEqual({ kind: "jsdoc" }); + }); + + test("detects '# ' line syntax on a gitattributes-style locked file", () => { + const body = `path/to/generated.ts linguist-generated=true\n`; + const locked = lockCode(body, false, "", { kind: "line", prefix: "# " }); + expect(detectCommentSyntax(locked)).toEqual({ + kind: "line", + prefix: "# ", + }); + }); + + test("detects '// ' line syntax on a locked .ts file", () => { + const locked = lockCode( + "export const apiBaseUrl = 'https://api.example.com';\n", + true, + "", + { kind: "line", prefix: "// " }, + ); + expect(detectCommentSyntax(locked)).toEqual({ + kind: "line", + prefix: "// ", + }); + }); + + test("ignores a non-codelock `@generated` line in the body of a file", () => { + const code = `import x from "./x"; + +// @generated Codelock<> but not really, I'm just a comment below code + +export const y = 1; +`; + expect(detectCommentSyntax(code)).toBeUndefined(); + }); + + test("skipped files are not falsely detected when the hash would be invalid", () => { + const locked = lockCode("export const foo = 1;\n", true); + const corrupted = `${locked}\nconst extra = true;\n`; + expect(detectCommentSyntax(corrupted)).toEqual({ kind: "jsdoc" }); + }); +}); diff --git a/src/detectCommentSyntax.ts b/src/detectCommentSyntax.ts new file mode 100644 index 0000000..f4634a0 --- /dev/null +++ b/src/detectCommentSyntax.ts @@ -0,0 +1,56 @@ +import { getCodelockInfo } from "./codelock"; +import { CommentSyntax } from "./types/CommentSyntax"; + +const codelockLineRegExp = + /^(?.*?)@generated(?:-editable)? Codelock<<\S+?>>\s*$/; + +const DETECTION_LINE_LOOKAHEAD = 200; + +/** + * Attempt to detect the {@link CommentSyntax} used when tscodegen locked this + * file. + * + * Returns `undefined` if the file does not appear to be a tscodegen-locked + * file — i.e. there is no `@generated Codelock<<...>>` or + * `@generated-editable Codelock<<...>>` marker near the top of the file that + * parses as a valid docblock. In that case, callers should treat the file as + * "not a tscodegen file" and skip it. + * + * Detection strategy: + * + * 1. If the file starts with a `/**`-style docblock, assume JSDoc syntax. + * 2. Otherwise, scan the first few lines for a line-comment codelock marker + * (e.g. `# @generated-editable Codelock<<...>>` or + * `// @generated Codelock<<...>>`) and use its leading prefix as the + * line-comment prefix. + * + * In both cases, detection is only considered successful if {@link + * getCodelockInfo} also returns a match for the detected syntax, so + * false-positive prefixes (e.g. ordinary source lines that happen to mention + * `@generated`) do not masquerade as tscodegen files. + */ +export function detectCommentSyntax(code: string): CommentSyntax | undefined { + if (code.startsWith("/**\n")) { + if (getCodelockInfo(code, { kind: "jsdoc" })) { + return { kind: "jsdoc" }; + } + return undefined; + } + + const lines = code.split("\n", DETECTION_LINE_LOOKAHEAD); + for (const line of lines) { + const match = codelockLineRegExp.exec(line); + if (!match?.groups) { + continue; + } + const syntax: CommentSyntax = { + kind: "line", + prefix: match.groups.prefix, + }; + if (getCodelockInfo(code, syntax)) { + return syntax; + } + } + + return undefined; +} diff --git a/src/index.ts b/src/index.ts index b9b4ad4..d496e1e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,6 @@ export * from "./CodeFile"; export * from "./CodeBuilder"; +export * from "./codelock"; +export * from "./detectCommentSyntax"; export * from "./types/ManualSectionMap"; export * from "./types/CommentSyntax";