This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@devrunbook/repository-intel",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"lint": "eslint src --max-warnings=0",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"ajv": "8.20.0",
|
||||
"ajv-formats": "3.0.1",
|
||||
"yaml": "2.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "24.13.3",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "4.1.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,678 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import Ajv2020, { type ErrorObject } from 'ajv/dist/2020.js'
|
||||
import addFormats from 'ajv-formats'
|
||||
import { parseDocument, stringify } from 'yaml'
|
||||
|
||||
import repositoryProfileSchema from '../../../schemas/repository-profile.schema.json'
|
||||
|
||||
export type RepositorySource = 'manual' | 'gitea' | 'imported' | 'mixed'
|
||||
export type RepositoryType =
|
||||
'single-app' | 'monorepo' | 'library' | 'infrastructure' | 'mixed' | 'unknown'
|
||||
export type CommandRole =
|
||||
| 'install'
|
||||
| 'format'
|
||||
| 'format-check'
|
||||
| 'lint'
|
||||
| 'typecheck'
|
||||
| 'unit-test'
|
||||
| 'integration-test'
|
||||
| 'end-to-end-test'
|
||||
| 'build'
|
||||
| 'dev-start'
|
||||
| 'smoke-test'
|
||||
| 'migration-status'
|
||||
| 'migration-apply'
|
||||
| 'security-scan'
|
||||
| 'dependency-audit'
|
||||
|
||||
export type JsonValue =
|
||||
null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
|
||||
|
||||
export interface RepositoryCommand {
|
||||
readonly id: string
|
||||
readonly role: CommandRole
|
||||
readonly command: string
|
||||
readonly workingDirectory: string
|
||||
readonly platform: 'any' | 'linux' | 'windows' | 'macos' | 'container'
|
||||
readonly shell: 'auto' | 'sh' | 'bash' | 'pwsh' | 'cmd'
|
||||
readonly source:
|
||||
'manual' | 'manifest' | 'documentation' | 'gitea' | 'inferred'
|
||||
readonly confirmed: boolean
|
||||
readonly safeForAgentSuggestion: boolean
|
||||
readonly timeoutSeconds?: number
|
||||
readonly notes?: string
|
||||
readonly confidence?: 'low' | 'medium' | 'high'
|
||||
readonly evidence?: readonly string[]
|
||||
readonly observedCommand?: string
|
||||
readonly confirmedAt?: string
|
||||
}
|
||||
|
||||
export interface RepositoryProfile {
|
||||
readonly apiVersion: 'devrunbook.io/v1alpha1'
|
||||
readonly kind: 'RepositoryProfile'
|
||||
readonly metadata: {
|
||||
readonly name: string
|
||||
readonly revision: number
|
||||
readonly source: RepositorySource
|
||||
readonly capturedAt?: string
|
||||
readonly sourceReference?: string
|
||||
readonly contentDigest?: string
|
||||
}
|
||||
readonly spec: {
|
||||
readonly repositoryType: RepositoryType
|
||||
readonly defaultBranch?: string
|
||||
readonly stack: {
|
||||
readonly languages: readonly string[]
|
||||
readonly frameworks: readonly string[]
|
||||
readonly packageManagers: readonly string[]
|
||||
readonly databases: readonly string[]
|
||||
readonly deploymentTypes: readonly string[]
|
||||
readonly testFrameworks: readonly string[]
|
||||
readonly services?: readonly string[]
|
||||
readonly runtimes?: readonly string[]
|
||||
readonly queues?: readonly string[]
|
||||
readonly ciSystems?: readonly string[]
|
||||
}
|
||||
readonly commands: readonly RepositoryCommand[]
|
||||
readonly paths: {
|
||||
readonly applicationRoots: readonly string[]
|
||||
readonly testRoots: readonly string[]
|
||||
readonly documentationRoots: readonly string[]
|
||||
readonly generated: readonly string[]
|
||||
readonly protected: readonly string[]
|
||||
readonly excluded: readonly string[]
|
||||
readonly packageRoots?: readonly string[]
|
||||
readonly serviceRoots?: readonly string[]
|
||||
readonly dataRuntime?: readonly string[]
|
||||
readonly ignored?: readonly string[]
|
||||
}
|
||||
readonly policies: {
|
||||
readonly preserveBackwardCompatibility: boolean
|
||||
readonly newDependencies:
|
||||
'allowed' | 'justify' | 'approval-required' | 'forbidden'
|
||||
readonly gitWrite: 'none' | 'local-commit' | 'push-with-approval'
|
||||
readonly migrations:
|
||||
'forbidden' | 'plan-only' | 'reversible-only' | 'allowed-with-backup'
|
||||
readonly documentationRequired: boolean
|
||||
readonly networkAccess:
|
||||
'forbidden' | 'read-only-approved-hosts' | 'allowed-with-approval'
|
||||
readonly productionDataAccess:
|
||||
'forbidden' | 'read-only-redacted' | 'approval-required'
|
||||
readonly requiredValidationRoles?: readonly CommandRole[]
|
||||
readonly branchConventions?: readonly string[]
|
||||
readonly environmentConstraints?: readonly string[]
|
||||
}
|
||||
readonly sourceFacts?: readonly {
|
||||
readonly path: string
|
||||
readonly value: JsonValue
|
||||
readonly source:
|
||||
| 'manual'
|
||||
| 'manifest'
|
||||
| 'documentation'
|
||||
| 'gitea'
|
||||
| 'inferred'
|
||||
| 'prior-profile'
|
||||
readonly evidence: readonly string[]
|
||||
readonly confidence?: 'low' | 'medium' | 'high'
|
||||
readonly observedAt?: string
|
||||
}[]
|
||||
readonly manualOverrides?: readonly {
|
||||
readonly path: string
|
||||
readonly value: JsonValue
|
||||
readonly observedValue: JsonValue
|
||||
readonly evidence: readonly string[]
|
||||
readonly confirmedAt: string
|
||||
readonly note?: string
|
||||
}[]
|
||||
readonly notes?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface RepositoryProfileValidationIssue {
|
||||
readonly path: string
|
||||
readonly code: string
|
||||
readonly message: string
|
||||
readonly remediation: string
|
||||
}
|
||||
|
||||
export type RepositoryProfileValidationResult =
|
||||
| {
|
||||
readonly valid: true
|
||||
readonly profile: RepositoryProfile
|
||||
readonly contentDigest: string
|
||||
readonly issues: readonly []
|
||||
}
|
||||
| {
|
||||
readonly valid: false
|
||||
readonly issues: readonly RepositoryProfileValidationIssue[]
|
||||
}
|
||||
|
||||
export class RepositoryProfileImportError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly issues: readonly RepositoryProfileValidationIssue[],
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'RepositoryProfileImportError'
|
||||
}
|
||||
}
|
||||
|
||||
const maximumDocumentBytes = 1_048_576
|
||||
const ajv = new Ajv2020({ allErrors: true, strict: true })
|
||||
addFormats(ajv)
|
||||
const validateSchema = ajv.compile(repositoryProfileSchema)
|
||||
|
||||
function pointerSegment(value: string): string {
|
||||
return value.replaceAll('~', '~0').replaceAll('/', '~1')
|
||||
}
|
||||
|
||||
function schemaIssue(error: ErrorObject): RepositoryProfileValidationIssue {
|
||||
let path = error.instancePath
|
||||
if (error.keyword === 'required') {
|
||||
path += `/${pointerSegment(String(error.params.missingProperty))}`
|
||||
} else if (error.keyword === 'additionalProperties') {
|
||||
path += `/${pointerSegment(String(error.params.additionalProperty))}`
|
||||
}
|
||||
return {
|
||||
path: path || '/',
|
||||
code: `schema_${error.keyword}`,
|
||||
message: error.message ?? 'The value does not match the profile schema',
|
||||
remediation:
|
||||
error.keyword === 'additionalProperties'
|
||||
? 'Remove the unsupported field or move the value to a field declared by the RepositoryProfile schema.'
|
||||
: 'Correct the value at this path to match the published RepositoryProfile schema.',
|
||||
}
|
||||
}
|
||||
|
||||
function assertUnicode(value: string, label: string): void {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const code = value.charCodeAt(index)
|
||||
if (code >= 0xd800 && code <= 0xdbff) {
|
||||
const next = value.charCodeAt(index + 1)
|
||||
if (!(next >= 0xdc00 && next <= 0xdfff)) {
|
||||
throw new Error(`${label} contains an unpaired UTF-16 surrogate`)
|
||||
}
|
||||
index += 1
|
||||
} else if (code >= 0xdc00 && code <= 0xdfff) {
|
||||
throw new Error(`${label} contains an unpaired UTF-16 surrogate`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertJson(
|
||||
value: unknown,
|
||||
label = 'profile',
|
||||
): asserts value is JsonValue {
|
||||
if (value === null || typeof value === 'boolean') return
|
||||
if (typeof value === 'string') {
|
||||
assertUnicode(value, label)
|
||||
return
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value)) throw new Error(`${label} is not finite`)
|
||||
return
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, index) => assertJson(item, `${label}[${index}]`))
|
||||
return
|
||||
}
|
||||
if (
|
||||
typeof value === 'object' &&
|
||||
Object.getPrototypeOf(value) === Object.prototype
|
||||
) {
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
assertUnicode(key, `${label} key`)
|
||||
assertJson(item, `${label}.${key}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
throw new Error(`${label} is not JSON-compatible`)
|
||||
}
|
||||
|
||||
function canonicalJson(value: JsonValue): string {
|
||||
if (
|
||||
value === null ||
|
||||
typeof value === 'boolean' ||
|
||||
typeof value === 'number'
|
||||
) {
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
if (typeof value === 'string') return JSON.stringify(value)
|
||||
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`
|
||||
return `{${Object.keys(value)
|
||||
.sort()
|
||||
.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key]!)}`)
|
||||
.join(',')}}`
|
||||
}
|
||||
|
||||
function cloneJson<T extends JsonValue>(value: T): T {
|
||||
return structuredClone(value)
|
||||
}
|
||||
|
||||
function sortObjectKeys(value: JsonValue): JsonValue {
|
||||
if (Array.isArray(value)) return value.map(sortObjectKeys)
|
||||
if (value !== null && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.keys(value)
|
||||
.sort()
|
||||
.map((key) => [key, sortObjectKeys(value[key]!)]),
|
||||
)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function parseError(
|
||||
code: string,
|
||||
message: string,
|
||||
remediation: string,
|
||||
): RepositoryProfileImportError {
|
||||
return new RepositoryProfileImportError('Cannot parse RepositoryProfile', [
|
||||
{ path: '/', code, message, remediation },
|
||||
])
|
||||
}
|
||||
|
||||
function parseJson(source: string): unknown {
|
||||
let value: unknown
|
||||
try {
|
||||
value = JSON.parse(source)
|
||||
} catch (error) {
|
||||
throw parseError(
|
||||
'json_parse_error',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
'Correct the JSON syntax and import the profile again.',
|
||||
)
|
||||
}
|
||||
const duplicateCheck = parseDocument(source, {
|
||||
schema: 'json',
|
||||
uniqueKeys: true,
|
||||
})
|
||||
if (duplicateCheck.errors.length > 0) {
|
||||
throw parseError(
|
||||
'json_duplicate_key',
|
||||
duplicateCheck.errors.map((error) => error.message).join('; '),
|
||||
'Remove duplicate object keys so every profile field has one unambiguous value.',
|
||||
)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function parseYaml(source: string): unknown {
|
||||
const document = parseDocument(source, {
|
||||
merge: false,
|
||||
schema: 'core',
|
||||
uniqueKeys: true,
|
||||
})
|
||||
if (document.errors.length > 0 || document.warnings.length > 0) {
|
||||
const findings = [...document.errors, ...document.warnings]
|
||||
throw parseError(
|
||||
'yaml_parse_error',
|
||||
findings.map((error) => error.message).join('; '),
|
||||
'Correct YAML syntax and remove duplicate keys, aliases, merge keys, or custom tags.',
|
||||
)
|
||||
}
|
||||
try {
|
||||
return document.toJS({ maxAliasCount: 0 })
|
||||
} catch (error) {
|
||||
throw parseError(
|
||||
'yaml_value_invalid',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
'Use only finite JSON-compatible YAML values without aliases or custom tags.',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function parseRepositoryProfile(
|
||||
source: string | Uint8Array,
|
||||
format: 'json' | 'yaml' | 'auto' = 'auto',
|
||||
): unknown {
|
||||
const bytes =
|
||||
typeof source === 'string'
|
||||
? Buffer.byteLength(source, 'utf8')
|
||||
: source.length
|
||||
if (bytes > maximumDocumentBytes) {
|
||||
throw parseError(
|
||||
'profile_too_large',
|
||||
`RepositoryProfile exceeds ${maximumDocumentBytes} bytes`,
|
||||
'Reduce the profile to the documented limits before importing it.',
|
||||
)
|
||||
}
|
||||
let text: string
|
||||
try {
|
||||
text =
|
||||
typeof source === 'string'
|
||||
? source
|
||||
: new TextDecoder('utf-8', { fatal: true }).decode(source)
|
||||
} catch {
|
||||
throw parseError(
|
||||
'profile_utf8_invalid',
|
||||
'RepositoryProfile is not valid UTF-8',
|
||||
'Encode the JSON or YAML document as valid UTF-8.',
|
||||
)
|
||||
}
|
||||
text = text.replace(/^\uFEFF/u, '').replace(/\r\n?/gu, '\n')
|
||||
const firstCharacter = text.trimStart().at(0)
|
||||
const selected =
|
||||
format === 'auto'
|
||||
? firstCharacter === '[' || firstCharacter === '{'
|
||||
? 'json'
|
||||
: 'yaml'
|
||||
: format
|
||||
const value = selected === 'json' ? parseJson(text) : parseYaml(text)
|
||||
try {
|
||||
assertJson(value)
|
||||
} catch (error) {
|
||||
throw parseError(
|
||||
'profile_value_invalid',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
'Use only finite JSON-compatible values and valid Unicode text.',
|
||||
)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function isNormalizedRepositoryPath(
|
||||
value: string,
|
||||
allowRoot: boolean,
|
||||
): boolean {
|
||||
if (allowRoot && value === '.') return true
|
||||
const containsControlCharacter = [...value].some((character) => {
|
||||
const code = character.charCodeAt(0)
|
||||
return code <= 0x1f || code === 0x7f
|
||||
})
|
||||
if (
|
||||
value.length === 0 ||
|
||||
value.includes('\\') ||
|
||||
value.startsWith('/') ||
|
||||
value.startsWith('//') ||
|
||||
/^[A-Za-z]:/u.test(value) ||
|
||||
containsControlCharacter
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const segments = value.split('/')
|
||||
return segments.every(
|
||||
(segment) => segment.length > 0 && segment !== '.' && segment !== '..',
|
||||
)
|
||||
}
|
||||
|
||||
function semanticIssue(
|
||||
path: string,
|
||||
code: string,
|
||||
message: string,
|
||||
remediation: string,
|
||||
): RepositoryProfileValidationIssue {
|
||||
return { path, code, message, remediation }
|
||||
}
|
||||
|
||||
function pathsOverlap(left: string, right: string): boolean {
|
||||
return (
|
||||
left === right ||
|
||||
left.startsWith(`${right}/`) ||
|
||||
right.startsWith(`${left}/`)
|
||||
)
|
||||
}
|
||||
|
||||
function semanticIssues(
|
||||
profile: RepositoryProfile,
|
||||
): RepositoryProfileValidationIssue[] {
|
||||
const issues: RepositoryProfileValidationIssue[] = []
|
||||
const commandIds = new Map<string, number>()
|
||||
profile.spec.commands.forEach((command, index) => {
|
||||
const prior = commandIds.get(command.id)
|
||||
if (prior !== undefined) {
|
||||
issues.push(
|
||||
semanticIssue(
|
||||
`/spec/commands/${index}/id`,
|
||||
'command_id_duplicate',
|
||||
`Command id ${command.id} is already used at /spec/commands/${prior}/id`,
|
||||
'Assign every command a unique stable id.',
|
||||
),
|
||||
)
|
||||
} else commandIds.set(command.id, index)
|
||||
if (!isNormalizedRepositoryPath(command.workingDirectory, true)) {
|
||||
issues.push(
|
||||
semanticIssue(
|
||||
`/spec/commands/${index}/workingDirectory`,
|
||||
'working_directory_invalid',
|
||||
'Working directory must be a normalized repository-relative path',
|
||||
'Use . for the repository root or a slash-separated relative path without traversal.',
|
||||
),
|
||||
)
|
||||
}
|
||||
const commandContainsControlCharacter = [...command.command].some(
|
||||
(character) => {
|
||||
const code = character.charCodeAt(0)
|
||||
return code <= 0x1f || code === 0x7f
|
||||
},
|
||||
)
|
||||
if (
|
||||
command.command.trim().length === 0 ||
|
||||
commandContainsControlCharacter
|
||||
) {
|
||||
issues.push(
|
||||
semanticIssue(
|
||||
`/spec/commands/${index}/command`,
|
||||
'command_text_invalid',
|
||||
'Command text must contain visible text without control characters',
|
||||
'Store one visible command as inert prompt text without NUL, tabs, line breaks, or other control characters.',
|
||||
),
|
||||
)
|
||||
}
|
||||
if (command.safeForAgentSuggestion && !command.confirmed) {
|
||||
issues.push(
|
||||
semanticIssue(
|
||||
`/spec/commands/${index}/safeForAgentSuggestion`,
|
||||
'unsafe_unconfirmed_command',
|
||||
'An unconfirmed command cannot be marked safe for agent suggestion',
|
||||
'Confirm the command from trusted repository evidence before enabling safe suggestions.',
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const pathGroups = profile.spec.paths as Readonly<
|
||||
Record<string, readonly string[] | undefined>
|
||||
>
|
||||
for (const [group, values] of Object.entries(pathGroups)) {
|
||||
values?.forEach((value, index) => {
|
||||
if (!isNormalizedRepositoryPath(value, false)) {
|
||||
issues.push(
|
||||
semanticIssue(
|
||||
`/spec/paths/${pointerSegment(group)}/${index}`,
|
||||
'repository_path_invalid',
|
||||
'Path must be normalized and repository-relative',
|
||||
'Use a slash-separated relative path without ., .., backslashes, drive letters, or a leading slash.',
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const conflictGroups = ['protected', 'generated', 'excluded'] as const
|
||||
for (let leftIndex = 0; leftIndex < conflictGroups.length; leftIndex += 1) {
|
||||
for (
|
||||
let rightIndex = leftIndex + 1;
|
||||
rightIndex < conflictGroups.length;
|
||||
rightIndex += 1
|
||||
) {
|
||||
const leftGroup = conflictGroups[leftIndex]!
|
||||
const rightGroup = conflictGroups[rightIndex]!
|
||||
profile.spec.paths[leftGroup].forEach((left, index) => {
|
||||
profile.spec.paths[rightGroup].forEach((right) => {
|
||||
if (pathsOverlap(left, right)) {
|
||||
issues.push(
|
||||
semanticIssue(
|
||||
`/spec/paths/${leftGroup}/${index}`,
|
||||
'path_class_overlap',
|
||||
`${leftGroup} path ${left} overlaps ${rightGroup} path ${right}`,
|
||||
'Place the path in one class or narrow the entries so protected, generated, and excluded scopes do not overlap.',
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const overridePaths = new Set<string>()
|
||||
profile.spec.manualOverrides?.forEach((override, index) => {
|
||||
if (overridePaths.has(override.path)) {
|
||||
issues.push(
|
||||
semanticIssue(
|
||||
`/spec/manualOverrides/${index}/path`,
|
||||
'manual_override_duplicate',
|
||||
`More than one manual override targets ${override.path}`,
|
||||
'Keep one current manual override per normalized profile field.',
|
||||
),
|
||||
)
|
||||
}
|
||||
overridePaths.add(override.path)
|
||||
})
|
||||
return issues
|
||||
}
|
||||
|
||||
function profileWithoutDigest(profile: RepositoryProfile): JsonValue {
|
||||
const cloned = cloneJson(profile as unknown as JsonValue) as Record<
|
||||
string,
|
||||
JsonValue
|
||||
>
|
||||
const metadata = cloned.metadata as Record<string, JsonValue>
|
||||
delete metadata.contentDigest
|
||||
return cloned
|
||||
}
|
||||
|
||||
export function canonicalizeRepositoryProfile(
|
||||
profile: RepositoryProfile,
|
||||
): string {
|
||||
const payload = profileWithoutDigest(profile)
|
||||
assertJson(payload)
|
||||
return canonicalJson(payload)
|
||||
}
|
||||
|
||||
export function digestRepositoryProfile(profile: RepositoryProfile): string {
|
||||
return createHash('sha256')
|
||||
.update(canonicalizeRepositoryProfile(profile), 'utf8')
|
||||
.digest('hex')
|
||||
}
|
||||
|
||||
export function validateRepositoryProfile(
|
||||
value: unknown,
|
||||
options: { readonly verifyDigest?: boolean } = {},
|
||||
): RepositoryProfileValidationResult {
|
||||
try {
|
||||
assertJson(value)
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
issues: [
|
||||
semanticIssue(
|
||||
'/',
|
||||
'profile_value_invalid',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
'Use only finite JSON-compatible values and valid Unicode text.',
|
||||
),
|
||||
],
|
||||
}
|
||||
}
|
||||
if (!validateSchema(value)) {
|
||||
return {
|
||||
valid: false,
|
||||
issues: (validateSchema.errors ?? []).map(schemaIssue),
|
||||
}
|
||||
}
|
||||
const profile = cloneJson(value as JsonValue) as unknown as RepositoryProfile
|
||||
const issues = semanticIssues(profile)
|
||||
const contentDigest = digestRepositoryProfile(profile)
|
||||
if (
|
||||
options.verifyDigest !== false &&
|
||||
profile.metadata.contentDigest !== undefined &&
|
||||
profile.metadata.contentDigest !== contentDigest
|
||||
) {
|
||||
issues.push(
|
||||
semanticIssue(
|
||||
'/metadata/contentDigest',
|
||||
'content_digest_mismatch',
|
||||
`Declared digest ${profile.metadata.contentDigest} does not match ${contentDigest}`,
|
||||
'Restore the original profile content or replace the digest with one computed from the validated canonical document.',
|
||||
),
|
||||
)
|
||||
}
|
||||
if (issues.length > 0) return { valid: false, issues }
|
||||
return { valid: true, profile, contentDigest, issues: [] }
|
||||
}
|
||||
|
||||
function requireValid(
|
||||
value: unknown,
|
||||
verifyDigest = true,
|
||||
): Extract<RepositoryProfileValidationResult, { valid: true }> {
|
||||
const result = validateRepositoryProfile(value, { verifyDigest })
|
||||
if (!result.valid) {
|
||||
throw new RepositoryProfileImportError(
|
||||
'RepositoryProfile validation failed',
|
||||
result.issues,
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function applyRepositoryProfileServerMetadata(
|
||||
profile: RepositoryProfile,
|
||||
revision: number,
|
||||
): RepositoryProfile {
|
||||
if (!Number.isInteger(revision) || revision < 1) {
|
||||
throw new RangeError(
|
||||
'RepositoryProfile revision must be a positive integer',
|
||||
)
|
||||
}
|
||||
const candidate = cloneJson(
|
||||
profile as unknown as JsonValue,
|
||||
) as unknown as RepositoryProfile
|
||||
const withoutClientDigest = {
|
||||
...candidate,
|
||||
metadata: {
|
||||
...candidate.metadata,
|
||||
revision,
|
||||
contentDigest: undefined,
|
||||
},
|
||||
}
|
||||
const clean = JSON.parse(
|
||||
JSON.stringify(withoutClientDigest),
|
||||
) as RepositoryProfile
|
||||
const contentDigest = digestRepositoryProfile(clean)
|
||||
return {
|
||||
...clean,
|
||||
metadata: { ...clean.metadata, contentDigest },
|
||||
}
|
||||
}
|
||||
|
||||
function withVerifiedDigest(profile: RepositoryProfile): RepositoryProfile {
|
||||
const valid = requireValid(profile)
|
||||
return applyRepositoryProfileServerMetadata(
|
||||
valid.profile,
|
||||
valid.profile.metadata.revision,
|
||||
)
|
||||
}
|
||||
|
||||
export function exportRepositoryProfileJson(
|
||||
profile: RepositoryProfile,
|
||||
): string {
|
||||
const document = withVerifiedDigest(profile) as unknown as JsonValue
|
||||
return `${JSON.stringify(sortObjectKeys(document), null, 2)}\n`
|
||||
}
|
||||
|
||||
export function exportRepositoryProfileYaml(
|
||||
profile: RepositoryProfile,
|
||||
): string {
|
||||
const output = stringify(withVerifiedDigest(profile), {
|
||||
lineWidth: 0,
|
||||
sortMapEntries: true,
|
||||
}).replace(/\r\n?/gu, '\n')
|
||||
return output.endsWith('\n') ? output : `${output}\n`
|
||||
}
|
||||
|
||||
export function importRepositoryProfile(
|
||||
source: string | Uint8Array,
|
||||
format: 'json' | 'yaml' | 'auto' = 'auto',
|
||||
): RepositoryProfile {
|
||||
return requireValid(parseRepositoryProfile(source, format)).profile
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user