import { spawnSync } from 'node:child_process'; import { join } from 'node:path'; import { classifyModulePath } from '../../module/packaging/package-rules'; import { collectGitInfo, makeRealGitRunner } from '../../module/packaging/release-metadata'; import type { Check } from './types'; export interface StalenessIssue { moduleDir: string; lastSrcCommit: string; lastManifestCommit: string; } /** * SHA of the last commit touching the given pathspecs, or null on any * git failure (not a repo, never committed, etc.). Callers treat null * as "can't determine — skip the check." * * Runs git from `cwd` so module-check works regardless of the operator's * shell CWD (e.g. `celilo module check ~/hobby/lunacycle` invoked from * anywhere should still talk to lunacycle's git history). */ function lastCommitTouching(cwd: string, pathspec: string[]): string | null { // timeout: a hung git degrades to the "can't determine — skip" path (status // null) instead of hanging `module check` forever. 30s is enormous against // the milliseconds a bounded `git log -1` takes on any real repo. const r = spawnSync('git', ['log', '-1', '--format=%H', '--', ...pathspec], { cwd, stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8', timeout: 30_000, }); if (r.status !== 0) return null; const sha = r.stdout.trim(); return sha || null; } function isAncestor(cwd: string, maybeAncestor: string, descendant: string): boolean { const r = spawnSync('git', ['merge-base', '--is-ancestor', maybeAncestor, descendant], { cwd, stdio: 'ignore', timeout: 30_000, }); return r.status === 0; } /** * One commit from `git log --name-only`: its SHA and the repo-root-relative * paths it changed. A merge commit carries an empty file list (git log omits * diffs for merges), so it never qualifies as a src commit — which is right, * a merge changes no authored byte of its own. */ interface CommitWithPaths { sha: string; paths: string[]; } /** * The commits touching `pathspec`, newest first, with the repo-root-relative * paths each one changed. `git log --name-only` prints repo-root-relative * paths even when cwd is a subdirectory (measured: it does not honor cwd for * output), so the caller strips the prefix itself via `show-prefix`. */ function listCommitsTouching(cwd: string, pathspec: string): CommitWithPaths[] { const r = spawnSync( 'git', ['-c', 'core.quotePath=false', 'log', '--format=%H', '--name-only', '--', pathspec], { cwd, stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8' }, ); if (r.status !== 0) return []; const commits: CommitWithPaths[] = []; let current: CommitWithPaths | null = null; for (const line of (r.stdout ?? '').split('\n')) { const trimmed = line.trim(); if (!trimmed) continue; if (/^[0-9a-f]{40}$/.test(trimmed)) { current = { sha: trimmed, paths: [] }; commits.push(current); } else if (current) { current.paths.push(trimmed); } } return commits; } /** * SHA of the last commit that touched SHIPPED module source, or null when no * commit qualifies (no history, or only non-shipped paths ever committed). * * The staleness scan's reach is the PACKAGER's rule, not a second list beside * it (celilo#1270). A path counts as source exactly when `classifyModulePath` * calls it `package` — the same answer that decides whether the path lands in * the .netapp. So a commit touching only `e2e/**`, `*.test.ts`, or * `tsconfig.json` changes no installed byte and must not read as drift. * * Excluding manifest.yml from the "src" scan is the gate's own semantics, not * packaging: we want to know if anything else shipped past it. node_modules, * dist, and other build outputs are gitignored and so never appear here. */ function lastShippedSourceCommit(moduleDir: string): string | null { const prefixR = spawnSync('git', ['rev-parse', '--show-prefix'], { cwd: moduleDir, stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8', }); if (prefixR.status !== 0) return null; const repoPrefix = (prefixR.stdout ?? '').trim(); for (const commit of listCommitsTouching(moduleDir, moduleDir)) { for (const p of commit.paths) { if (repoPrefix && !p.startsWith(repoPrefix)) continue; const rel = repoPrefix ? p.slice(repoPrefix.length) : p; if (rel === 'manifest.yml') continue; if (classifyModulePath(rel) === 'package') return commit.sha; } } return null; } /** * Detect "I edited module src but forgot to bump (or touch) manifest.yml." * * Returns null when the manifest is the most recently-touched shipped file in * the dir (or when neither side has any commit history — e.g. brand-new module * not yet committed). Returns a StalenessIssue when shipped src has commits * AFTER the last manifest.yml change — the operator must bump the manifest * (semver change → reset +N to 1) or just touch it (release-only change → * auto-bump +N), then re-publish. * * Used by both `module publish` (where it refuses the publish) and * `module check` (where it surfaces as a fail before the operator goes * through publish at all). */ export function checkModuleStale(moduleDir: string): StalenessIssue | null { const manifestPath = join(moduleDir, 'manifest.yml'); const lastManifest = lastCommitTouching(moduleDir, [manifestPath]); const lastSrc = lastShippedSourceCommit(moduleDir); if (!lastManifest || !lastSrc) return null; if (lastSrc === lastManifest) return null; if (!isAncestor(moduleDir, lastManifest, lastSrc)) return null; return { moduleDir, lastSrcCommit: lastSrc, lastManifestCommit: lastManifest }; } /** * Publish-readiness checks against the module's git state: * * - Stale-version drift: src commits past the last manifest.yml commit. * Surfaced as fail. Same shape `module publish` enforces. * - Working tree dirty: uncommitted changes in the module dir. Surfaced * as warn — `--allow-dirty` overrides at publish time but it's still * the kind of thing an operator usually wants to know. * * Both quietly degrade to ok when the module isn't in a git repo at all * (brand-new uncommitted module, third-party module shipped as a * directory tarball, etc.) — git operations return null/empty and we * skip the check. */ export function checkGitHygiene(modulePath: string, versionSourceKind?: string): Check[] { const checks: Check[] = []; // The source-after-manifest stale gate guards a HAND-MAINTAINED module's // version (the default — `recipe` kind / unset version_source). For // changeset-versioned modules the version is authored via .changeset/ + // `celilo module version` and ordered by the +N revision, so source moving // past the manifest is the NORMAL case, not drift; for pin modules the version // is checked against the upstream resolver, not git ancestry. Skip the gate for // both (openspec/changes/module-version-semantics/proposal.md / ISS-0151). if (versionSourceKind === 'changeset' || versionSourceKind === 'pin') { checks.push({ category: 'git_hygiene', name: 'stale-version drift', status: 'ok', message: versionSourceKind === 'changeset' ? 'version_source: changeset — changeset-authored version, ordered by +N; source-after-manifest gate N/A' : 'version_source: pin — version checked against the upstream resolver, not git ancestry', }); } else { const stale = checkModuleStale(modulePath); if (stale) { checks.push({ category: 'git_hygiene', name: 'stale-version drift', status: 'fail', message: [ 'src commits past last manifest.yml change', ` src commit: ${stale.lastSrcCommit.slice(0, 12)}`, ` manifest.yml commit: ${stale.lastManifestCommit.slice(0, 12)}`, ' Bump manifest.yml#version (semver change), or touch it', ' (release-only — auto-revision picks the next +N), then commit.', ].join('\n'), }); } else { checks.push({ category: 'git_hygiene', name: 'stale-version drift', status: 'ok', message: 'manifest.yml is current with respect to module src', }); } } try { const gitInfo = collectGitInfo(modulePath, makeRealGitRunner()); if (gitInfo.dirty) { checks.push({ category: 'git_hygiene', name: 'working tree', status: 'warn', message: 'working tree has uncommitted changes; publish refuses unless --allow-dirty is passed', }); } else { checks.push({ category: 'git_hygiene', name: 'working tree', status: 'ok', message: 'working tree clean', }); } } catch { // Not in a git repo, or git unavailable. Stale-check above already // returned ok in that case; we mirror it here for consistency. checks.push({ category: 'git_hygiene', name: 'working tree', status: 'ok', message: 'not a git repo — skipping working-tree check', }); } return checks; }