Publish DevRunbook source
Managed validation / full (push) Successful in 3m18s

This commit is contained in:
DevRunbook release export
2026-09-03 04:09:17 +02:00
commit cfd2804e27
928 changed files with 161642 additions and 0 deletions
+25
View File
@@ -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"
}
}
+319
View File
@@ -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)
})
})
+678
View File
@@ -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
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
},
"include": ["src/**/*.ts"]
}