Skip to content
Merged
2 changes: 1 addition & 1 deletion src/frontmatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ function stringNeedsJsonEncoding(value: string): boolean {
if (value.length === 0 || value !== value.trim()) return true
if (value.includes('\n') || value.includes('\r')) return true
if (value === 'true' || value === 'false' || /^-?\d+(?:\.\d+)?$/.test(value)) return true
return /^[\[{"']/.test(value) || /["']$/.test(value)
return /^[[{"']/.test(value) || /["']$/.test(value)
}

function unquote(value: string): string {
Expand Down
126 changes: 125 additions & 1 deletion src/lint.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import {
assertGradeableEvidence,
CHECKABLE_RUNG_THRESHOLD,
type ClaimEvidence,
type EvidenceRung,
UncheckableClaimError,
} from './claim-evidence'
import { KnowledgePageInvalidationSchema } from './schemas'
import { isScaffoldPath } from './store'
import type { KnowledgeIndex, KnowledgeLintFinding } from './types'
import type { KnowledgeIndex, KnowledgeLintFinding, KnowledgePage } from './types'
import { normalizeLinkTarget } from './wikilinks'

const ABSOLUTE_PATH_TOKEN = /(?:^|[\s"'=(])(?:\/(?!dev\/null\b)[^\s"'();]+|[A-Za-z]:\\[^\s"'();]+)/m

export function lintKnowledgeIndex(index: KnowledgeIndex): KnowledgeLintFinding[] {
const findings: KnowledgeLintFinding[] = []
const byTarget = new Set<string>()
Expand Down Expand Up @@ -102,6 +112,9 @@ export function lintKnowledgeIndex(index: KnowledgeIndex): KnowledgeLintFinding[
})
}
}
findings.push(...lintPageEvidence(page))
findings.push(...lintPageContradictions(page, pageIds))
findings.push(...lintPageInvalidation(page))
}

for (const [title, paths] of titles) {
Expand Down Expand Up @@ -137,6 +150,117 @@ export function lintKnowledgeIndex(index: KnowledgeIndex): KnowledgeLintFinding[
return findings
}

function lintPageEvidence(page: KnowledgePage): KnowledgeLintFinding[] {
if (page.frontmatter.rung === undefined) return []
const rung = evidenceRung(page.frontmatter.rung)
if (rung === undefined) {
return [
{
type: 'ungradeable-evidence',
severity: 'error',
page: page.path,
message: 'Evidence rung must be an integer from 1 through 5.',
metadata: { rung: page.frontmatter.rung },
},
]
}
const check = stringValue(page.frontmatter.check)
const expect = stringValue(page.frontmatter.expect)
const evidencePath = stringValue(page.frontmatter.evidencePath)
const evidence: ClaimEvidence = {
rung,
...(check ? { check } : {}),
...(expect ? { expect } : {}),
...(evidencePath ? { evidencePath } : {}),
}
const findings: KnowledgeLintFinding[] = []
try {
assertGradeableEvidence(evidence)
} catch (error) {
if (!(error instanceof UncheckableClaimError)) throw error
findings.push({
type: 'ungradeable-evidence',
severity: 'error',
page: page.path,
message: error.note,
metadata: { rung: error.rung },
})
}
if (rung >= CHECKABLE_RUNG_THRESHOLD && !evidence.evidencePath) {
findings.push({
type: 'missing-evidence-path',
severity: 'warning',
page: page.path,
message: `Rung ${rung} evidence has no evidencePath for a human to inspect.`,
metadata: { rung },
})
}
for (const [field, value] of [
['check', evidence.check],
['evidencePath', evidence.evidencePath],
] as const) {
if (value && ABSOLUTE_PATH_TOKEN.test(value)) {
findings.push({
type: 'nonportable-evidence',
severity: 'warning',
page: page.path,
message: `${field} contains an absolute path and may not re-run outside the author machine.`,
metadata: { rung, field },
})
}
}
return findings
}

function lintPageContradictions(
page: KnowledgePage,
pageIds: ReadonlyMap<string, string[]>,
): KnowledgeLintFinding[] {
const findings: KnowledgeLintFinding[] = []
for (const targetId of page.contradicts ?? []) {
const selfReference = targetId === page.id
if (selfReference || !pageIds.has(targetId)) {
findings.push({
type: 'broken-contradiction',
severity: 'error',
page: page.path,
message: selfReference
? `Page "${page.id}" cannot contradict itself.`
: `Page contradicts unknown page id "${targetId}".`,
metadata: { targetId },
})
}
}
return findings
}

function lintPageInvalidation(page: KnowledgePage): KnowledgeLintFinding[] {
if (page.frontmatter.invalidation === undefined) return []
const parsed = KnowledgePageInvalidationSchema.safeParse(page.frontmatter.invalidation)
if (parsed.success) return []
return [
{
type: 'invalid-invalidation',
severity: 'error',
page: page.path,
message:
'Page invalidation must record verdict=contradicted, an ISO observedAt timestamp, and a non-empty reason.',
metadata: { issues: parsed.error.issues },
},
]
}

function evidenceRung(value: unknown): EvidenceRung | undefined {
const parsed = typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value
return parsed === 1 || parsed === 2 || parsed === 3 || parsed === 4 || parsed === 5
? parsed
: undefined
}

function stringValue(value: unknown): string | undefined {
return typeof value === 'string' && value.trim().length > 0 ? value : undefined
}

function extractSourceRefs(text: string): Array<{ sourceId: string; anchorId?: string }> {
const refs: Array<{ sourceId: string; anchorId?: string }> = []
const regex = /\[\^([A-Za-z0-9_-]+)(?:#([A-Za-z0-9_.:-]+))?\]/g
Expand Down
197 changes: 197 additions & 0 deletions src/page-evidence-integrity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { formatFrontmatter } from './frontmatter'
import { lintKnowledgeIndex } from './lint'
import { initKnowledgeBase, loadKnowledgePages } from './store'
import type { KnowledgeIndex, KnowledgePage } from './types'

function page(
id: string,
frontmatter: Record<string, unknown> = {},
overrides: Partial<KnowledgePage> = {},
): KnowledgePage {
return {
id,
path: `knowledge/${id}.md`,
title: id,
text: 'Measured result.',
frontmatter: { id, ...frontmatter },
sourceIds: [],
tags: [],
outLinks: [],
...overrides,
}
}

function index(pages: KnowledgePage[]): KnowledgeIndex {
return {
root: '/kb',
generatedAt: '2026-08-17T00:00:00.000Z',
sources: [],
pages,
graph: { nodes: [], edges: [] },
}
}

function findingTypes(pages: KnowledgePage[]): string[] {
return lintKnowledgeIndex(index(pages)).map((finding) => finding.type)
}

let root: string

beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'page-evidence-integrity-'))
})

afterEach(async () => {
await rm(root, { recursive: true, force: true })
})

describe('page evidence lint', () => {
it('accepts gradeable, portable rung-four evidence', () => {
const findings = lintKnowledgeIndex(
index([
page('verified', {
rung: 4,
check: 'python3 checks/result.py',
expect: 'value=42',
evidencePath: 'results/value.json',
}),
]),
)

expect(findings.filter((finding) => finding.type.includes('evidence'))).toEqual([])
})

it('reports evidence that claims a checkable rung without gradeable fields', () => {
const findings = lintKnowledgeIndex(index([page('self-graded', { rung: 5 })]))

expect(findings).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: 'ungradeable-evidence', severity: 'error' }),
expect.objectContaining({ type: 'missing-evidence-path', severity: 'warning' }),
]),
)
})

it('refuses an invalid evidence rung instead of ignoring it', () => {
expect(lintKnowledgeIndex(index([page('bad-rung', { rung: 6 })]))).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'ungradeable-evidence',
severity: 'error',
message: expect.stringMatching(/integer from 1 through 5/),
}),
]),
)
})

it('reports author-machine absolute paths without confusing them with a contradiction', () => {
const findings = lintKnowledgeIndex(
index([
page('nonportable', {
rung: 4,
check: 'python3 /Users/example/work/check.py',
expect: 'value=42',
evidencePath: '/tmp/run/result.json',
}),
]),
)

expect(findings.filter((finding) => finding.type === 'nonportable-evidence')).toHaveLength(2)
expect(findings.some((finding) => finding.type === 'ungradeable-evidence')).toBe(false)
})
})

describe('page contradiction and invalidation lint', () => {
it('accepts an existing contradiction target and a calibrated invalidation', () => {
const invalidation = {
verdict: 'contradicted' as const,
observedAt: '2026-08-17T00:00:00.000Z',
reason: 'The independently executed check printed value=43, not value=42.',
evidencePath: 'oracle/claim.json',
grader: 'blind-oracle-v1',
}
const target = page('old-claim', { invalidation }, { invalidation })
const refuter = page(
'new-claim',
{ contradicts: ['old-claim'] },
{ contradicts: ['old-claim'] },
)

const types = findingTypes([target, refuter])

expect(types).not.toContain('broken-contradiction')
expect(types).not.toContain('invalid-invalidation')
})

it('refuses missing and self contradiction targets', () => {
const findings = lintKnowledgeIndex(
index([
page(
'claim-a',
{ contradicts: ['claim-a', 'missing'] },
{ contradicts: ['claim-a', 'missing'] },
),
]),
)

expect(findings.filter((finding) => finding.type === 'broken-contradiction')).toHaveLength(2)
})

it('refuses an invalidation that does not record an actual contradiction', () => {
const findings = lintKnowledgeIndex(
index([
page('unknown-claim', {
invalidation: {
verdict: 'unrunnable',
observedAt: 'yesterday',
reason: '',
},
}),
]),
)

expect(findings).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: 'invalid-invalidation', severity: 'error' }),
]),
)
})
})

describe('page metadata loading', () => {
it('loads string or array contradiction pointers and a typed invalidation', async () => {
await initKnowledgeBase(root)
await mkdir(join(root, 'knowledge', 'line'), { recursive: true })
const invalidation = {
verdict: 'contradicted' as const,
observedAt: '2026-08-17T00:00:00.000Z',
reason: 'Independent check refuted the page.',
grader: 'blind-oracle-v1',
}
await writeFile(
join(root, 'knowledge', 'line', 'claim.md'),
formatFrontmatter(
{
id: 'claim',
title: 'Claim',
contradicts: 'older-claim',
invalidation,
},
'# Claim\n',
),
)

const pages = await loadKnowledgePages(root)

expect(pages).toHaveLength(1)
expect(pages[0]).toMatchObject({
id: 'claim',
contradicts: ['older-claim'],
invalidation,
})
})
})
13 changes: 13 additions & 0 deletions src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,17 @@ export const SourceRecordSchema = z.object({
createdAt: z.string().min(1),
})

export const KnowledgePageInvalidationSchema = z
.object({
verdict: z.literal('contradicted'),
observedAt: z.iso.datetime(),
reason: z.string().trim().min(1),
evidencePath: z.string().trim().min(1).optional(),
grader: z.string().trim().min(1).optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
})
.strict()

export const KnowledgePageSchema = z.object({
id: z.string().min(1),
path: z.string().min(1),
Expand All @@ -43,6 +54,8 @@ export const KnowledgePageSchema = z.object({
sourceIds: z.array(z.string()),
tags: z.array(z.string()),
outLinks: z.array(z.string()),
contradicts: z.array(z.string().min(1)).optional(),
invalidation: KnowledgePageInvalidationSchema.optional(),
})

export const KnowledgeGraphNodeSchema = z.object({
Expand Down
Loading