From 8560fd5d8aab27822c695200939e49433f711f85 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 19 May 2026 10:22:53 +0200 Subject: [PATCH] feat(megarepo): enforce canonical GitHub sources --- nix/devenv-modules/tasks/shared/check.nix | 1 + nix/devenv-modules/tasks/shared/megarepo.nix | 21 + .../megarepo/src/cli/commands/check.ts | 77 +++ .../@overeng/megarepo/src/cli/commands/mod.ts | 1 + packages/@overeng/megarepo/src/cli/mod.ts | 2 + .../megarepo/src/lib/source-policy.ts | 451 ++++++++++++++++++ .../src/lib/source-policy.unit.test.ts | 271 +++++++++++ 7 files changed, 824 insertions(+) create mode 100644 packages/@overeng/megarepo/src/cli/commands/check.ts create mode 100644 packages/@overeng/megarepo/src/lib/source-policy.ts create mode 100644 packages/@overeng/megarepo/src/lib/source-policy.unit.test.ts diff --git a/nix/devenv-modules/tasks/shared/check.nix b/nix/devenv-modules/tasks/shared/check.nix index 705e541f6..62ba687b7 100644 --- a/nix/devenv-modules/tasks/shared/check.nix +++ b/nix/devenv-modules/tasks/shared/check.nix @@ -57,6 +57,7 @@ let megarepoTasks = lib.optionals hasMegarepoCheck [ "mr:check" "mr:lock-sync-check" + "mr:source-policy-check" ]; # Build description parts diff --git a/nix/devenv-modules/tasks/shared/megarepo.nix b/nix/devenv-modules/tasks/shared/megarepo.nix index 7232a8459..bb473721b 100644 --- a/nix/devenv-modules/tasks/shared/megarepo.nix +++ b/nix/devenv-modules/tasks/shared/megarepo.nix @@ -368,6 +368,27 @@ let fi ''; }; + + "mr:source-policy-check" = { + guard = "mr"; + description = "Verify megarepo GitHub source and lock-file policy"; + status = trace.status "mr:source-policy-check" "binary" '' + if [ ! -f ./megarepo.lock ]; then + exit 0 + fi + + mr check --all + ''; + exec = trace.exec "mr:source-policy-check" '' + set -euo pipefail + + if [ ! -f ./megarepo.lock ]; then + exit 0 + fi + + mr check --all + ''; + }; }; in { diff --git a/packages/@overeng/megarepo/src/cli/commands/check.ts b/packages/@overeng/megarepo/src/cli/commands/check.ts new file mode 100644 index 000000000..510d3913f --- /dev/null +++ b/packages/@overeng/megarepo/src/cli/commands/check.ts @@ -0,0 +1,77 @@ +import * as Cli from '@effect/cli' +import { Console, Effect, Option } from 'effect' + +import { EffectPath } from '@overeng/effect-path' + +import { readMegarepoConfig } from '../../lib/config.ts' +import { LOCK_FILE_NAME, readLockFile } from '../../lib/lock.ts' +import { checkSourcePolicy, formatSourcePolicyViolation } from '../../lib/source-policy.ts' +import { Cwd, findMegarepoRoot, jsonOption } from '../context.ts' + +const allOption = Cli.Options.boolean('all').pipe( + Cli.Options.withDescription('Check member source and lock files in repos/ as well as the root'), + Cli.Options.withDefault(false), +) + +/** Check that the megarepo is structurally valid. */ +export const checkCommand = Cli.Command.make( + 'check', + { + all: allOption, + json: jsonOption, + }, + ({ all, json }) => + Effect.gen(function* () { + const cwd = yield* Cwd + const rootOpt = yield* findMegarepoRoot(cwd) + + if (Option.isNone(rootOpt) === true) { + return yield* Effect.fail(new Error('No megarepo config found')) + } + + const root = rootOpt.value + const { config } = yield* readMegarepoConfig(root) + const lockPath = EffectPath.ops.join(root, EffectPath.unsafe.relativeFile(LOCK_FILE_NAME)) + const lockFileOpt = yield* readLockFile(lockPath) + + if (Option.isNone(lockFileOpt) === true) { + return yield* Effect.fail( + new Error('megarepo.lock is required for megarepo checks; run `mr lock` first'), + ) + } + + const sourcePolicy = yield* checkSourcePolicy({ + megarepoRoot: root, + config, + lockFile: lockFileOpt.value, + includeMembers: all, + }) + + const result = { + checks: [ + { + name: 'source-policy', + violations: sourcePolicy.violations, + }, + ], + violations: sourcePolicy.violations, + } + + if (json === true) { + yield* Console.log(JSON.stringify(result, null, 2)) + } else if (result.violations.length === 0) { + yield* Console.log('Megarepo checks OK') + } else { + yield* Console.error('Megarepo check violations:') + for (const violation of result.violations) { + yield* Console.error(`- ${formatSourcePolicyViolation(violation)}`) + } + } + + if (result.violations.length > 0) { + return yield* Effect.fail( + new Error(`Megarepo checks failed with ${result.violations.length} violation(s)`), + ) + } + }), +).pipe(Cli.Command.withDescription('Check that the megarepo is structurally valid')) diff --git a/packages/@overeng/megarepo/src/cli/commands/mod.ts b/packages/@overeng/megarepo/src/cli/commands/mod.ts index ee76d1ef4..ab9192d7d 100644 --- a/packages/@overeng/megarepo/src/cli/commands/mod.ts +++ b/packages/@overeng/megarepo/src/cli/commands/mod.ts @@ -6,6 +6,7 @@ export { addCommand } from './add.ts' export { applyCommand } from './apply.ts' +export { checkCommand } from './check.ts' export { depsCommand } from './deps.ts' export { syncMegarepo } from './engine.ts' export { envCommand } from './env.ts' diff --git a/packages/@overeng/megarepo/src/cli/mod.ts b/packages/@overeng/megarepo/src/cli/mod.ts index d943f74c3..ddf860b13 100644 --- a/packages/@overeng/megarepo/src/cli/mod.ts +++ b/packages/@overeng/megarepo/src/cli/mod.ts @@ -14,6 +14,7 @@ import { MR_VERSION } from '../lib/version.ts' import { addCommand, applyCommand, + checkCommand, configCommand, depsCommand, envCommand, @@ -54,6 +55,7 @@ export const mrCommand = Cli.Command.make('mr', { cwd: cwdOption }).pipe( envCommand, statusCommand, lsCommand, + checkCommand, fetchCommand, applyCommand, lockCommand, diff --git a/packages/@overeng/megarepo/src/lib/source-policy.ts b/packages/@overeng/megarepo/src/lib/source-policy.ts new file mode 100644 index 000000000..7ee76469b --- /dev/null +++ b/packages/@overeng/megarepo/src/lib/source-policy.ts @@ -0,0 +1,451 @@ +import { FileSystem, type Error as PlatformError } from '@effect/platform' +import { Effect, Option } from 'effect' + +import { EffectPath, type AbsoluteDirPath } from '@overeng/effect-path' + +import { getMemberPath, type MegarepoConfig, parseSourceString } from './config.ts' +import type { LockFile, LockedMember } from './lock.ts' +import { getRef, parseNixFlakeUrl, serializeNixFlakeUrl } from './nix-lock/flake-url.ts' +import { + extractDevenvYamlInputs, + extractFlakeNixInputs, + matchUrlToMember, +} from './nix-lock/input-discovery.ts' + +/** Files inspected by the GitHub source policy check. */ +export type SourcePolicyFile = + | 'megarepo config' + | 'flake.nix' + | 'devenv.yaml' + | 'flake.lock' + | 'devenv.lock' + +/** A canonical-source policy violation with enough context for CLI output. */ +export type SourcePolicyViolation = + | { + readonly _tag: 'NonCanonicalGitHubMemberSource' + readonly file: SourcePolicyFile + readonly path: string + readonly memberName: string + readonly actual: string + readonly expected: string + } + | { + readonly _tag: 'NonCanonicalNixInputSource' + readonly file: SourcePolicyFile + readonly path: string + readonly inputName: string + readonly upstreamMember: string + readonly actual: string + readonly expected: string + } + | { + readonly _tag: 'IncompleteGitHubLockMetadata' + readonly file: SourcePolicyFile + readonly path: string + readonly inputName: string + readonly upstreamMember: string + readonly missingFields: ReadonlyArray + } + +/** Result of checking source and lock files for canonical GitHub input shape. */ +export interface SourcePolicyCheckResult { + readonly violations: ReadonlyArray +} + +const isMainRef = (ref: string | undefined): boolean => ref === undefined || ref === 'main' + +const refSuffix = (ref: string | undefined): string => (isMainRef(ref) === true ? '' : `#${ref}`) + +const githubFlakeRefSuffix = (ref: string | undefined): string => + isMainRef(ref) === true ? '' : `/${ref}` + +const normalizeRepoName = (repo: string): string => repo.replace(/\.git$/, '') + +const canonicalMemberSource = ({ + owner, + repo, + ref, +}: { + owner: string + repo: string + ref: string | undefined +}) => `${owner}/${normalizeRepoName(repo)}${refSuffix(ref)}` + +const canonicalFlakeSource = ({ + owner, + repo, + ref, + params = new Map(), +}: { + owner: string + repo: string + ref: string | undefined + params?: ReadonlyMap +}) => { + const canonicalParams = new Map(params) + canonicalParams.delete('ref') + canonicalParams.delete('rev') + + return serializeNixFlakeUrl({ + _tag: 'github', + owner, + repo: normalizeRepoName(repo), + ref: githubFlakeRefSuffix(ref) === '' ? undefined : ref, + params: canonicalParams, + }) +} + +const githubRepoFromUrl = (url: string): { owner: string; repo: string } | undefined => { + const patterns = [ + /^https?:\/\/github\.com\/([^/]+)\/([^/#?]+?)(?:\.git)?(?:[/?#].*)?$/, + /^git@github\.com:([^/]+)\/([^/#?]+?)(?:\.git)?$/, + /^ssh:\/\/git@github\.com\/([^/]+)\/([^/#?]+?)(?:\.git)?(?:[/?#].*)?$/, + ] + + for (const pattern of patterns) { + const match = url.match(pattern) + if (match?.[1] !== undefined && match[2] !== undefined) { + return { owner: match[1], repo: normalizeRepoName(match[2]) } + } + } + + return undefined +} + +const normalizedGithubRepo = (repo: { + owner: string + repo: string +}): { owner: string; repo: string } => ({ + owner: repo.owner.toLowerCase(), + repo: normalizeRepoName(repo.repo).toLowerCase(), +}) + +const memberByGithubRepo = (members: Record) => { + const result = new Map() + + for (const [memberName, member] of Object.entries(members)) { + const repo = githubRepoFromUrl(member.url) + if (repo === undefined) continue + const normalized = normalizedGithubRepo(repo) + result.set(`${normalized.owner}/${normalized.repo}`, { memberName, ...repo }) + } + + return result +} + +const checkConfigMemberSources = ({ + config, + configPath, +}: { + config: MegarepoConfig + configPath: string +}): ReadonlyArray => { + const violations: SourcePolicyViolation[] = [] + + for (const [memberName, sourceString] of Object.entries(config.members)) { + const source = parseSourceString(sourceString) + if (source?.type !== 'url') continue + + const repo = githubRepoFromUrl(source.url) + if (repo === undefined) continue + + violations.push({ + _tag: 'NonCanonicalGitHubMemberSource', + file: 'megarepo config', + path: configPath, + memberName, + actual: sourceString, + expected: canonicalMemberSource({ ...repo, ref: Option.getOrUndefined(source.ref) }), + }) + } + + return violations +} + +const checkSourceInputs = ({ + content, + file, + path, + members, +}: { + content: string + file: 'flake.nix' | 'devenv.yaml' + path: string + members: Record +}): ReadonlyArray => { + const inputs = + file === 'flake.nix' ? extractFlakeNixInputs(content) : extractDevenvYamlInputs(content) + const violations: SourcePolicyViolation[] = [] + + for (const input of inputs) { + const upstreamMember = matchUrlToMember({ url: input.url, members }) + if (upstreamMember === undefined) continue + + const parsed = parseNixFlakeUrl(input.url) + if (parsed === undefined || parsed._tag === 'github') continue + + violations.push({ + _tag: 'NonCanonicalNixInputSource', + file, + path, + inputName: input.inputName, + upstreamMember, + actual: input.url, + expected: canonicalFlakeSource({ + owner: parsed.owner, + repo: parsed.repo, + ref: getRef(parsed), + params: parsed.params, + }), + }) + } + + return violations +} + +const lockSourceRepo = ( + section: Record | undefined, +): + | { + owner: string + repo: string + ref: string | undefined + type: string | undefined + params: ReadonlyMap + } + | undefined => { + if (section === undefined) return undefined + + const type = typeof section['type'] === 'string' ? section['type'] : undefined + const params = lockSourceParams(section) + + if (type === 'github') { + const owner = section['owner'] + const repo = section['repo'] + return typeof owner === 'string' && typeof repo === 'string' + ? { + owner, + repo, + ref: typeof section['ref'] === 'string' ? section['ref'] : undefined, + type, + params, + } + : undefined + } + + if (type === 'git') { + const url = section['url'] + if (typeof url !== 'string') return undefined + const repo = githubRepoFromUrl(url) + if (repo === undefined) return undefined + return { + ...repo, + ref: typeof section['ref'] === 'string' ? section['ref'] : undefined, + type, + params, + } + } + + return undefined +} + +const lockSourceParams = (section: Record): ReadonlyMap => { + const params = new Map() + const url = section['url'] + + if (typeof url === 'string') { + try { + const parsed = new URL(url) + for (const [key, value] of parsed.searchParams.entries()) { + params.set(key, value) + } + } catch { + // Keep lock parsing permissive: malformed or non-standard URLs are handled elsewhere. + } + } + + const dir = section['dir'] + if (typeof dir === 'string') { + params.set('dir', dir) + } + + return params +} + +const checkLockFileInputs = ({ + content, + file, + path, + members, +}: { + content: string + file: 'flake.lock' | 'devenv.lock' + path: string + members: Record +}): ReadonlyArray => { + let parsed: { root?: string; nodes?: Record> } + try { + parsed = JSON.parse(content) as { + root?: string + nodes?: Record> + } + } catch { + return [] + } + + const nodes = parsed.nodes + if (nodes === undefined) return [] + + const root = nodes[parsed.root ?? 'root'] + const rootInputs = root?.['inputs'] as Record | undefined + if (rootInputs === undefined) return [] + + const membersByRepo = memberByGithubRepo(members) + const violations: SourcePolicyViolation[] = [] + + for (const [inputName, nodeName] of Object.entries(rootInputs)) { + const node = nodes[nodeName] + const locked = node?.['locked'] as Record | undefined + if (locked === undefined) continue + + const lockedRepo = lockSourceRepo(locked) + if (lockedRepo === undefined) continue + + const normalized = normalizedGithubRepo(lockedRepo) + const upstream = membersByRepo.get(`${normalized.owner}/${normalized.repo}`) + if (upstream === undefined) continue + + const original = node?.['original'] as Record | undefined + const originalRepo = lockSourceRepo(original) + + if (lockedRepo.type !== 'github' || originalRepo?.type === 'git') { + violations.push({ + _tag: 'NonCanonicalNixInputSource', + file, + path, + inputName, + upstreamMember: upstream.memberName, + actual: JSON.stringify({ original, locked }), + expected: canonicalFlakeSource({ + owner: upstream.owner, + repo: upstream.repo, + ref: originalRepo?.ref ?? lockedRepo.ref, + params: originalRepo?.params ?? lockedRepo.params, + }), + }) + continue + } + + const missingFields = ['rev', 'narHash', 'lastModified'].filter( + (field) => locked[field] === undefined, + ) + if (missingFields.length > 0) { + violations.push({ + _tag: 'IncompleteGitHubLockMetadata', + file, + path, + inputName, + upstreamMember: upstream.memberName, + missingFields, + }) + } + } + + return violations +} + +const readIfExists = (path: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + if ((yield* fs.exists(path)) === false) return undefined + return yield* fs.readFileString(path) + }) + +const checkDirectoryFiles = ({ + dir, + label, + members, +}: { + dir: AbsoluteDirPath + label: string + members: Record +}) => + Effect.gen(function* () { + const violations: SourcePolicyViolation[] = [] + + for (const file of ['flake.nix', 'devenv.yaml'] as const) { + const path = EffectPath.ops.join(dir, EffectPath.unsafe.relativeFile(file)) + const content = yield* readIfExists(path) + if (content !== undefined) { + violations.push(...checkSourceInputs({ content, file, path: `${label}/${file}`, members })) + } + } + + for (const file of ['flake.lock', 'devenv.lock'] as const) { + const path = EffectPath.ops.join(dir, EffectPath.unsafe.relativeFile(file)) + const content = yield* readIfExists(path) + if (content !== undefined) { + violations.push( + ...checkLockFileInputs({ content, file, path: `${label}/${file}`, members }), + ) + } + } + + return violations + }) + +/** Check a megarepo for canonical GitHub member sources and Nix input locks. */ +export const checkSourcePolicy = ({ + megarepoRoot, + config, + lockFile, + includeMembers, +}: { + megarepoRoot: AbsoluteDirPath + config: MegarepoConfig + lockFile: LockFile + includeMembers: boolean +}): Effect.Effect => + Effect.gen(function* () { + const violations: SourcePolicyViolation[] = [ + ...checkConfigMemberSources({ + config, + configPath: 'megarepo config', + }), + ] + + violations.push( + ...(yield* checkDirectoryFiles({ + dir: megarepoRoot, + label: '.', + members: lockFile.members, + })), + ) + + if (includeMembers === true) { + for (const memberName of Object.keys(config.members)) { + violations.push( + ...(yield* checkDirectoryFiles({ + dir: getMemberPath({ megarepoRoot, name: memberName }), + label: `repos/${memberName}`, + members: lockFile.members, + })), + ) + } + } + + return { violations } + }) + +/** Format a source-policy violation for human-readable CLI output. */ +export const formatSourcePolicyViolation = (violation: SourcePolicyViolation): string => { + switch (violation._tag) { + case 'NonCanonicalGitHubMemberSource': + return `${violation.path}: member ${violation.memberName} uses ${violation.actual}; expected ${violation.expected}` + case 'NonCanonicalNixInputSource': + return `${violation.path}: input ${violation.inputName} -> ${violation.upstreamMember} uses ${violation.actual}; expected ${violation.expected}` + case 'IncompleteGitHubLockMetadata': + return `${violation.path}: input ${violation.inputName} -> ${violation.upstreamMember} is missing GitHub lock metadata: ${violation.missingFields.join(', ')}` + } +} diff --git a/packages/@overeng/megarepo/src/lib/source-policy.unit.test.ts b/packages/@overeng/megarepo/src/lib/source-policy.unit.test.ts new file mode 100644 index 000000000..c3805c020 --- /dev/null +++ b/packages/@overeng/megarepo/src/lib/source-policy.unit.test.ts @@ -0,0 +1,271 @@ +import { FileSystem } from '@effect/platform' +import { NodeContext } from '@effect/platform-node' +import { Effect, type Scope } from 'effect' +import { describe, expect, it } from 'vitest' + +import { EffectPath, type AbsoluteDirPath } from '@overeng/effect-path' + +import type { MegarepoConfig } from './config.ts' +import type { LockFile } from './lock.ts' +import { checkSourcePolicy } from './source-policy.ts' + +const commit = '41e0ed18178ecef0afde18a932e24372d5d61b11' + +const makeConfig = (members: Record): MegarepoConfig => + ({ members }) as MegarepoConfig + +const makeLockFile = (): LockFile => + ({ + version: 1, + members: { + 'private-member': { + url: 'https://github.com/overengineeringstudio/private-member', + ref: 'main', + commit, + pinned: false, + lockedAt: '2026-05-19T10:00:00Z', + }, + }, + }) as LockFile + +const runWithContext = (effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.scoped, Effect.provide(NodeContext.layer))) + +const writeFile = ({ + root, + path, + content, +}: { + root: AbsoluteDirPath + path: string + content: string +}) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const fullPath = EffectPath.ops.join(root, EffectPath.unsafe.relativeFile(path)) + yield* fs.makeDirectory(EffectPath.ops.parent(fullPath), { recursive: true }) + yield* fs.writeFileString(fullPath, content) + }) + +const withWorkspace = ( + use: (root: AbsoluteDirPath) => Effect.Effect, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const root = EffectPath.unsafe.absoluteDir(`${yield* fs.makeTempDirectoryScoped()}/`) + return yield* use(root) + }) + +describe('checkSourcePolicy', () => { + it('allows canonical GitHub source shape and complete GitHub lock metadata', () => + runWithContext( + withWorkspace((root) => + Effect.gen(function* () { + yield* writeFile({ + root, + path: 'flake.nix', + content: `{ + inputs.private-member.url = "github:overengineeringstudio/private-member"; +}`, + }) + yield* writeFile({ + root, + path: 'flake.lock', + content: JSON.stringify({ + root: 'root', + nodes: { + root: { inputs: { 'private-member': 'private-member' } }, + 'private-member': { + original: { + owner: 'overengineeringstudio', + repo: 'private-member', + type: 'github', + }, + locked: { + lastModified: 1779125469, + narHash: 'sha256-9GjX7BCxjoimhsxW7zLHuzK+rZS1m3DtiMoo3B0xpxc=', + owner: 'overengineeringstudio', + repo: 'private-member', + rev: commit, + type: 'github', + }, + }, + }, + }), + }) + + const result = yield* checkSourcePolicy({ + megarepoRoot: root, + config: makeConfig({ 'private-member': 'overengineeringstudio/private-member' }), + lockFile: makeLockFile(), + includeMembers: false, + }) + + expect(result.violations).toEqual([]) + }), + ), + )) + + it('rejects GitHub SSH member sources and Nix git inputs for megarepo members', () => + runWithContext( + withWorkspace((root) => + Effect.gen(function* () { + yield* writeFile({ + root, + path: 'flake.nix', + content: `{ + inputs.private-member.url = "git+ssh://git@github.com/overengineeringstudio/private-member?ref=main"; +}`, + }) + yield* writeFile({ + root, + path: 'flake.lock', + content: JSON.stringify({ + root: 'root', + nodes: { + root: { inputs: { 'private-member': 'private-member' } }, + 'private-member': { + original: { + ref: 'main', + type: 'git', + url: 'ssh://git@github.com/overengineeringstudio/private-member', + }, + locked: { + ref: 'main', + rev: commit, + type: 'git', + url: 'ssh://git@github.com/overengineeringstudio/private-member', + }, + }, + }, + }), + }) + + const result = yield* checkSourcePolicy({ + megarepoRoot: root, + config: makeConfig({ + 'private-member': 'git@github.com:overengineeringstudio/private-member.git', + }), + lockFile: makeLockFile(), + includeMembers: false, + }) + + expect(result.violations.map((violation) => violation._tag)).toEqual([ + 'NonCanonicalGitHubMemberSource', + 'NonCanonicalNixInputSource', + 'NonCanonicalNixInputSource', + ]) + }), + ), + )) + + it('preserves flake dir query params in canonical source suggestions', () => + runWithContext( + withWorkspace((root) => + Effect.gen(function* () { + yield* writeFile({ + root, + path: 'flake.nix', + content: `{ + inputs.private-member.url = "git+ssh://git@github.com/overengineeringstudio/private-member?ref=main&dir=nix/flake"; +}`, + }) + yield* writeFile({ + root, + path: 'flake.lock', + content: JSON.stringify({ + root: 'root', + nodes: { + root: { inputs: { 'private-member': 'private-member' } }, + 'private-member': { + original: { + dir: 'nix/flake', + ref: 'main', + type: 'git', + url: 'ssh://git@github.com/overengineeringstudio/private-member', + }, + locked: { + dir: 'nix/flake', + ref: 'main', + rev: commit, + type: 'git', + url: 'ssh://git@github.com/overengineeringstudio/private-member', + }, + }, + }, + }), + }) + + const result = yield* checkSourcePolicy({ + megarepoRoot: root, + config: makeConfig({ 'private-member': 'overengineeringstudio/private-member' }), + lockFile: makeLockFile(), + includeMembers: false, + }) + + expect( + result.violations + .filter((violation) => violation._tag === 'NonCanonicalNixInputSource') + .map((violation) => violation.expected), + ).toEqual([ + 'github:overengineeringstudio/private-member?dir=nix/flake', + 'github:overengineeringstudio/private-member?dir=nix/flake', + ]) + }), + ), + )) + + it('rejects GitHub lock nodes missing metadata required for stable Nix fetches', () => + runWithContext( + withWorkspace((root) => + Effect.gen(function* () { + yield* writeFile({ + root, + path: 'flake.lock', + content: JSON.stringify({ + root: 'root', + nodes: { + root: { inputs: { 'private-member': 'private-member' } }, + 'private-member': { + original: { + owner: 'overengineeringstudio', + repo: 'private-member', + type: 'github', + }, + locked: { + owner: 'overengineeringstudio', + repo: 'private-member', + rev: commit, + type: 'github', + }, + }, + }, + }), + }) + + const result = yield* checkSourcePolicy({ + megarepoRoot: root, + config: makeConfig({ 'private-member': 'overengineeringstudio/private-member' }), + lockFile: makeLockFile(), + includeMembers: false, + }) + + expect(result.violations).toMatchInlineSnapshot(` + [ + { + "_tag": "IncompleteGitHubLockMetadata", + "file": "flake.lock", + "inputName": "private-member", + "missingFields": [ + "narHash", + "lastModified", + ], + "path": "./flake.lock", + "upstreamMember": "private-member", + }, + ] + `) + }), + ), + )) +})