320 lines
11 KiB
TypeScript
320 lines
11 KiB
TypeScript
import { readFile } from 'node:fs/promises'
|
|
import path from 'node:path'
|
|
import { describe, expect, it } from 'vitest'
|
|
|
|
import {
|
|
applyRepositoryProfileServerMetadata,
|
|
canonicalizeRepositoryProfile,
|
|
digestRepositoryProfile,
|
|
exportRepositoryProfileJson,
|
|
exportRepositoryProfileYaml,
|
|
importRepositoryProfile,
|
|
parseRepositoryProfile,
|
|
RepositoryProfileImportError,
|
|
type RepositoryProfile,
|
|
validateRepositoryProfile,
|
|
} from './index'
|
|
|
|
const repositoryRoot = path.resolve(import.meta.dirname, '../../..')
|
|
const examplePath = path.join(
|
|
repositoryRoot,
|
|
'examples/repository-profiles/example-profile.yaml',
|
|
)
|
|
const exampleDigest =
|
|
'041e20f67e299665e85e5f14800a4bbcfa5e6c42ccdd7b22d29206e2c3f6727e'
|
|
|
|
async function example(): Promise<RepositoryProfile> {
|
|
return importRepositoryProfile(await readFile(examplePath), 'yaml')
|
|
}
|
|
|
|
function mutableCopy(profile: RepositoryProfile): Record<string, unknown> {
|
|
return structuredClone(profile) as unknown as Record<string, unknown>
|
|
}
|
|
|
|
describe('RepositoryProfile parsing and canonical digest', () => {
|
|
it('validates the existing fixture with exact Python reference digest parity', async () => {
|
|
const profile = await example()
|
|
const result = validateRepositoryProfile(profile)
|
|
|
|
expect(result).toMatchObject({ valid: true, contentDigest: exampleDigest })
|
|
expect(digestRepositoryProfile(profile)).toBe(exampleDigest)
|
|
expect(canonicalizeRepositoryProfile(profile)).not.toContain(
|
|
'contentDigest',
|
|
)
|
|
})
|
|
|
|
it('preserves array order in the canonical digest', async () => {
|
|
const profile = await example()
|
|
const reversed = structuredClone(profile) as RepositoryProfile
|
|
;(
|
|
reversed.spec.stack as unknown as {
|
|
languages: string[]
|
|
}
|
|
).languages.reverse()
|
|
;(
|
|
reversed.spec.stack as unknown as {
|
|
testFrameworks: string[]
|
|
}
|
|
).testFrameworks.reverse()
|
|
|
|
expect(digestRepositoryProfile(reversed)).not.toBe(exampleDigest)
|
|
})
|
|
|
|
it('round-trips deterministic LF JSON and YAML without data loss', async () => {
|
|
const profile = await example()
|
|
const json = exportRepositoryProfileJson(profile)
|
|
const yaml = exportRepositoryProfileYaml(profile)
|
|
|
|
expect(json.endsWith('\n')).toBe(true)
|
|
expect(yaml.endsWith('\n')).toBe(true)
|
|
expect(json).not.toContain('\r')
|
|
expect(yaml).not.toContain('\r')
|
|
expect(exportRepositoryProfileJson(profile)).toBe(json)
|
|
expect(exportRepositoryProfileYaml(profile)).toBe(yaml)
|
|
expect(importRepositoryProfile(json, 'json')).toEqual(profile)
|
|
expect(importRepositoryProfile(yaml, 'yaml')).toEqual(profile)
|
|
})
|
|
|
|
it('accepts BOM, CRLF, comments, and reordered YAML object keys', async () => {
|
|
const source = await readFile(examplePath, 'utf8')
|
|
const parsed = importRepositoryProfile(
|
|
`\uFEFF# portable profile\r\n${source.replaceAll('\n', '\r\n')}`,
|
|
'yaml',
|
|
)
|
|
|
|
expect(digestRepositoryProfile(parsed)).toBe(exampleDigest)
|
|
const jsonObject = JSON.parse(
|
|
exportRepositoryProfileJson(parsed),
|
|
) as Record<string, unknown>
|
|
const reordered = JSON.stringify({
|
|
spec: jsonObject.spec,
|
|
metadata: jsonObject.metadata,
|
|
kind: jsonObject.kind,
|
|
apiVersion: jsonObject.apiVersion,
|
|
})
|
|
expect(digestRepositoryProfile(importRepositoryProfile(reordered))).toBe(
|
|
exampleDigest,
|
|
)
|
|
})
|
|
|
|
it.each([
|
|
['duplicate YAML keys', 'name: first\nname: second\n', 'yaml'],
|
|
['custom YAML tags', 'value: !unsafe payload\n', 'yaml'],
|
|
['YAML aliases', 'value: &shared payload\ncopy: *shared\n', 'yaml'],
|
|
['duplicate JSON keys', '{"name":"first","name":"second"}', 'json'],
|
|
] as const)('rejects %s', (_label, source, format) => {
|
|
expect(() => parseRepositoryProfile(source, format)).toThrow(
|
|
RepositoryProfileImportError,
|
|
)
|
|
})
|
|
|
|
it('rejects invalid UTF-8 and oversized input before validation', () => {
|
|
for (const [source, code] of [
|
|
[new Uint8Array([0xc3, 0x28]), 'profile_utf8_invalid'],
|
|
['x'.repeat(1_048_577), 'profile_too_large'],
|
|
] as const) {
|
|
try {
|
|
parseRepositoryProfile(source, 'yaml')
|
|
throw new Error('Expected parsing to fail')
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(RepositoryProfileImportError)
|
|
expect((error as RepositoryProfileImportError).issues).toEqual([
|
|
expect.objectContaining({ code }),
|
|
])
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('RepositoryProfile structural and semantic validation', () => {
|
|
it('returns JSON-pointer structural issues with remediation', () => {
|
|
const result = validateRepositoryProfile({
|
|
apiVersion: 'devrunbook.io/v1alpha1',
|
|
kind: 'RepositoryProfile',
|
|
metadata: { name: 'Incomplete' },
|
|
spec: {},
|
|
unexpected: true,
|
|
})
|
|
|
|
expect(result.valid).toBe(false)
|
|
if (result.valid) return
|
|
expect(result.issues).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
path: '/metadata/revision',
|
|
code: 'schema_required',
|
|
remediation: expect.any(String),
|
|
}),
|
|
expect.objectContaining({
|
|
path: '/unexpected',
|
|
code: 'schema_additionalProperties',
|
|
}),
|
|
]),
|
|
)
|
|
})
|
|
|
|
it('detects duplicate command ids and requires confirmation for safe suggestions', async () => {
|
|
const candidate = mutableCopy(await example())
|
|
const spec = candidate.spec as Record<string, unknown>
|
|
const commands = spec.commands as Record<string, unknown>[]
|
|
commands.push({
|
|
...commands[0],
|
|
command: 'printf "$(still inert)"',
|
|
confirmed: false,
|
|
safeForAgentSuggestion: true,
|
|
})
|
|
delete (candidate.metadata as Record<string, unknown>).contentDigest
|
|
|
|
const result = validateRepositoryProfile(candidate)
|
|
expect(result.valid).toBe(false)
|
|
if (result.valid) return
|
|
expect(result.issues).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ code: 'command_id_duplicate' }),
|
|
expect.objectContaining({ code: 'unsafe_unconfirmed_command' }),
|
|
]),
|
|
)
|
|
expect(commands.at(-1)?.command).toBe('printf "$(still inert)"')
|
|
})
|
|
|
|
it.each([' ', 'pnpm test\nrm -rf ignored', 'pnpm\u0000test'])(
|
|
'rejects ambiguous command text %j without interpreting it',
|
|
async (command) => {
|
|
const candidate = mutableCopy(await example())
|
|
const spec = candidate.spec as Record<string, unknown>
|
|
const commands = spec.commands as Record<string, unknown>[]
|
|
commands[0]!.command = command
|
|
delete (candidate.metadata as Record<string, unknown>).contentDigest
|
|
|
|
const result = validateRepositoryProfile(candidate)
|
|
expect(result.valid).toBe(false)
|
|
if (result.valid) return
|
|
expect(result.issues).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
path: '/spec/commands/0/command',
|
|
code: 'command_text_invalid',
|
|
}),
|
|
]),
|
|
)
|
|
expect(commands[0]!.command).toBe(command)
|
|
},
|
|
)
|
|
|
|
it.each(['../outside', '/absolute', 'C:/windows', 'nested\\windows', 'a//b'])(
|
|
'rejects non-normalized repository path %s',
|
|
async (invalidPath) => {
|
|
const candidate = mutableCopy(await example())
|
|
const spec = candidate.spec as Record<string, unknown>
|
|
const paths = spec.paths as Record<string, unknown>
|
|
paths.protected = [invalidPath]
|
|
delete (candidate.metadata as Record<string, unknown>).contentDigest
|
|
|
|
const result = validateRepositoryProfile(candidate)
|
|
expect(result.valid).toBe(false)
|
|
if (result.valid) return
|
|
expect(result.issues).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
path: '/spec/paths/protected/0',
|
|
code: 'repository_path_invalid',
|
|
}),
|
|
]),
|
|
)
|
|
},
|
|
)
|
|
|
|
it('detects exact and ancestor protected/generated/excluded overlap', async () => {
|
|
const candidate = mutableCopy(await example())
|
|
const spec = candidate.spec as Record<string, unknown>
|
|
const paths = spec.paths as Record<string, unknown>
|
|
paths.protected = ['data', 'application']
|
|
paths.generated = ['data/cache']
|
|
paths.excluded = ['data', 'app']
|
|
delete (candidate.metadata as Record<string, unknown>).contentDigest
|
|
|
|
const result = validateRepositoryProfile(candidate)
|
|
expect(result.valid).toBe(false)
|
|
if (result.valid) return
|
|
expect(
|
|
result.issues.filter(({ code }) => code === 'path_class_overlap'),
|
|
).toHaveLength(3)
|
|
expect(result.issues.map(({ message }) => message).join('\n')).not.toMatch(
|
|
/application.*app/u,
|
|
)
|
|
})
|
|
|
|
it('verifies a supplied digest and reports mismatch at its exact path', async () => {
|
|
const candidate = mutableCopy(await example())
|
|
;(candidate.spec as Record<string, unknown>).notes = 'Meaningful change'
|
|
|
|
const result = validateRepositoryProfile(candidate)
|
|
expect(result.valid).toBe(false)
|
|
if (result.valid) return
|
|
expect(result.issues).toEqual([
|
|
expect.objectContaining({
|
|
path: '/metadata/contentDigest',
|
|
code: 'content_digest_mismatch',
|
|
remediation: expect.any(String),
|
|
}),
|
|
])
|
|
})
|
|
|
|
it('supports conservative intelligence and override extensions', async () => {
|
|
const candidate = mutableCopy(await example())
|
|
const metadata = candidate.metadata as Record<string, unknown>
|
|
delete metadata.contentDigest
|
|
const spec = candidate.spec as Record<string, unknown>
|
|
Object.assign(spec.stack as Record<string, unknown>, {
|
|
services: ['web'],
|
|
runtimes: ['Node.js 24'],
|
|
queues: ['PostgreSQL jobs'],
|
|
ciSystems: ['GitHub Actions'],
|
|
})
|
|
Object.assign(spec.paths as Record<string, unknown>, {
|
|
packageRoots: ['packages'],
|
|
serviceRoots: ['apps/web'],
|
|
dataRuntime: ['runtime'],
|
|
ignored: ['tmp'],
|
|
})
|
|
Object.assign(spec.policies as Record<string, unknown>, {
|
|
requiredValidationRoles: ['lint', 'typecheck'],
|
|
branchConventions: ['feature/*'],
|
|
environmentConstraints: ['Node.js 24'],
|
|
})
|
|
spec.sourceFacts = [
|
|
{
|
|
path: '/spec/commands/0/command',
|
|
value: 'npm install',
|
|
source: 'manifest',
|
|
evidence: ['package.json'],
|
|
confidence: 'high',
|
|
},
|
|
]
|
|
spec.manualOverrides = [
|
|
{
|
|
path: '/spec/commands/0/command',
|
|
value: 'pnpm install --frozen-lockfile',
|
|
observedValue: 'npm install',
|
|
evidence: ['pnpm-lock.yaml'],
|
|
confirmedAt: '2026-07-27T00:00:00Z',
|
|
},
|
|
]
|
|
|
|
expect(validateRepositoryProfile(candidate)).toMatchObject({ valid: true })
|
|
})
|
|
|
|
it('applies server revision and digest without mutating client input', async () => {
|
|
const profile = await example()
|
|
const original = structuredClone(profile)
|
|
const revision = applyRepositoryProfileServerMetadata(profile, 2)
|
|
|
|
expect(profile).toEqual(original)
|
|
expect(revision.metadata.revision).toBe(2)
|
|
expect(revision.metadata.contentDigest).toBe(
|
|
digestRepositoryProfile(revision),
|
|
)
|
|
expect(revision.metadata.contentDigest).not.toBe(exampleDigest)
|
|
})
|
|
})
|