Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions apps/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ function runGraphExportCommand(args: string[]): void {
console.log(`Files: ${files.length}`);
}

function runGraphViewCommand(args: string[]): void {
async function runGraphViewCommand(args: string[]): Promise<void> {
const options = parseGraphViewArgs(args);
const { repo, service } = createCommandContext();
const graph = service.readGraph(repo);
Expand All @@ -299,7 +299,7 @@ function runGraphViewCommand(args: string[]): void {

const outputPath = options.outputPath ?? defaultGraphViewOutputPath(repo.repo_name);
mkdirSync(dirname(outputPath), { recursive: true });
const html = service.buildGraphView(repo);
const html = await service.buildGraphView(repo);
writeFileSync(outputPath, html, "utf8");
console.log(`Wrote graph view to ${outputPath}`);

Expand Down Expand Up @@ -350,14 +350,16 @@ function printAnchorAudit(result: ClaimAnchorAuditResult): void {
printAuditSection("Missing symbols", result.missing_symbols, (issue) => `${issue.claim_id} -> ${formatAuditAnchor(issue.anchor)}`);
printAuditSection("Ambiguous symbols", result.ambiguous_symbols, (issue) => `${issue.claim_id} -> ${formatAuditAnchor(issue.anchor)}`);
printAuditSection("Unsupported languages", result.unsupported_languages, (issue) => `${issue.claim_id} -> ${formatAuditAnchor(issue.anchor)}`);
printAuditSection("Stale content", result.stale_content, (issue) => `${issue.claim_id} -> ${formatAuditAnchor(issue.anchor)}`);
}

function anchorAuditIssueCount(result: ClaimAnchorAuditResult): number {
return result.missing_anchors.length +
result.missing_files.length +
result.missing_symbols.length +
result.ambiguous_symbols.length +
result.unsupported_languages.length;
result.unsupported_languages.length +
result.stale_content.length;
}

function printAuditSection<T>(title: string, items: T[], render: (item: T) => string): void {
Expand Down
2 changes: 2 additions & 0 deletions evals/lib/code-anchor-quality.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,8 @@ function anchorStatusMessage(anchor: ResolvedCodeAnchor): string {
return "Anchor symbol matches multiple declarations; use the fully qualified symbol path.";
case "unsupported_language":
return "Anchor language cannot currently be parsed; use a file-only anchor only for non-code artifacts.";
case "stale_content":
return "Anchor content has changed since the claim was recorded.";
case "resolved":
case "file_only":
return "Anchor resolved.";
Expand Down
3 changes: 2 additions & 1 deletion libs/install/platforms/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import Database from "better-sqlite3";
import { hookCommand, hookEvents, mergeHookConfig, readJsonObject, writeJson } from "../hook-config.js";
import { copyBundledSkills } from "../skills.js";
import { runOpenCodeAgent } from "../../agent-runner/opencode.js";
import { hookSessionId, type HookInput } from "../../hooks/hook-input.js";
import { hookSessionId } from "../../hooks/hook-input.js";
import type { HookInput } from "../../hooks/types.js";
import {
isRecord,
parseJsonLine,
Expand Down
1 change: 1 addition & 0 deletions libs/knowledge-graph/claim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export type ClaimIntent = "intended" | "accidental" | "unknown";
export interface ClaimCodeAnchor {
file: string;
symbol?: string;
content_hash?: string;
}

export interface Claim {
Expand Down
4 changes: 4 additions & 0 deletions libs/knowledge-graph/code-anchors/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export async function auditClaimCodeAnchors(
missing_symbols: [],
ambiguous_symbols: [],
unsupported_languages: [],
stale_content: [],
};

for (const claim of claims) {
Expand All @@ -37,6 +38,9 @@ export async function auditClaimCodeAnchors(
case "unsupported_language":
result.unsupported_languages.push({ claim_id: claim.id, anchor, status: "unsupported_language" });
break;
case "stale_content":
result.stale_content.push({ claim_id: claim.id, anchor, status: "stale_content" });
break;
case "resolved":
case "file_only":
break;
Expand Down
43 changes: 35 additions & 8 deletions libs/knowledge-graph/code-anchors/resolver.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { existsSync, readFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { createRequire } from "node:module";
import { extname, join, normalize, relative } from "node:path";
import Parser from "web-tree-sitter";
Expand All @@ -10,6 +11,7 @@ interface SymbolCandidate {
symbol: string;
start_line: number;
end_line: number;
content_hash: string;
}

const require = createRequire(import.meta.url);
Expand Down Expand Up @@ -113,18 +115,21 @@ export class CodeAnchorResolver {
}

if (anchor.symbol === undefined) {
return { ...anchor, status: "file_only" };
const currentContentHash = contentHash(readFileSync(filePath, "utf8"));
return resolvedAnchor({ ...anchor, current_content_hash: currentContentHash, status: "file_only" });
}

const symbols = await this.symbolsForFile(filePath);
if (symbols === undefined) {
const fallback = fallbackSymbolForFile(filePath, anchor.symbol);
if (fallback !== undefined) {
return {
return resolvedAnchor({
...anchor,
start_line: fallback.start_line,
end_line: fallback.end_line,
current_content_hash: fallback.content_hash,
status: "resolved",
};
});
}
return { ...anchor, status: "unsupported_language" };
}
Expand All @@ -133,11 +138,13 @@ export class CodeAnchorResolver {
if (matches.length === 0) {
const fallback = fallbackSymbolForFile(filePath, anchor.symbol);
if (fallback !== undefined) {
return {
return resolvedAnchor({
...anchor,
start_line: fallback.start_line,
end_line: fallback.end_line,
current_content_hash: fallback.content_hash,
status: "resolved",
};
});
}
return { ...anchor, status: "missing_symbol" };
}
Expand All @@ -146,12 +153,13 @@ export class CodeAnchorResolver {
}

const match = matches[0];
return {
return resolvedAnchor({
...anchor,
start_line: match.start_line,
end_line: match.end_line,
current_content_hash: match.content_hash,
status: "resolved",
};
});
}

async resolveMany(repoRoot: string | undefined, anchors: ClaimCodeAnchor[] | undefined): Promise<ResolvedCodeAnchor[]> {
Expand Down Expand Up @@ -209,14 +217,17 @@ export class CodeAnchorResolver {
function fallbackSymbolForFile(filePath: string, symbol: string): SymbolCandidate | undefined {
if (!cFamilyExtensions.has(extname(filePath).toLowerCase())) return undefined;

const line = findCFamilySymbolLine(readFileSync(filePath, "utf8"), symbol);
const source = readFileSync(filePath, "utf8");
const line = findCFamilySymbolLine(source, symbol);
if (line === undefined) return undefined;
const sourceLine = source.split(/\r?\n/)[line - 1] ?? "";

return {
name: symbol,
symbol,
start_line: line,
end_line: line,
content_hash: contentHash(sourceLine),
};
}

Expand Down Expand Up @@ -343,6 +354,7 @@ function walk(node: Parser.SyntaxNode, containers: string[], symbols: SymbolCand
symbol,
start_line: node.startPosition.row + 1,
end_line: Math.max(node.startPosition.row + 1, node.endPosition.row + 1),
content_hash: contentHash(node.text),
});
}

Expand Down Expand Up @@ -387,3 +399,18 @@ function isRepoRelative(repoRoot: string, filePath: string): boolean {
const relativePath = normalize(relative(repoRoot, filePath));
return relativePath.length === 0 || (!relativePath.startsWith("..") && !relativePath.startsWith("/"));
}

function resolvedAnchor(anchor: ResolvedCodeAnchor): ResolvedCodeAnchor {
if (
anchor.content_hash !== undefined &&
anchor.current_content_hash !== undefined &&
anchor.content_hash !== anchor.current_content_hash
) {
return { ...anchor, status: "stale_content" };
}
return anchor;
}

function contentHash(value: string): string {
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
}
5 changes: 4 additions & 1 deletion libs/knowledge-graph/code-anchors/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { ClaimId } from "../schema.js";
export type ResolvedCodeAnchorStatus =
| "resolved"
| "file_only"
| "stale_content"
| "missing_file"
| "missing_symbol"
| "ambiguous_symbol"
Expand All @@ -12,13 +13,14 @@ export type ResolvedCodeAnchorStatus =
export interface ResolvedCodeAnchor extends ClaimCodeAnchor {
start_line?: number;
end_line?: number;
current_content_hash?: string;
status: ResolvedCodeAnchorStatus;
}

export interface ClaimAnchorAuditIssue {
claim_id: ClaimId;
anchor?: ClaimCodeAnchor;
status: "missing_anchors" | "missing_file" | "missing_symbol" | "ambiguous_symbol" | "unsupported_language";
status: "missing_anchors" | "missing_file" | "missing_symbol" | "ambiguous_symbol" | "unsupported_language" | "stale_content";
}

export interface ClaimAnchorAuditResult {
Expand All @@ -27,4 +29,5 @@ export interface ClaimAnchorAuditResult {
missing_symbols: ClaimAnchorAuditIssue[];
ambiguous_symbols: ClaimAnchorAuditIssue[];
unsupported_languages: ClaimAnchorAuditIssue[];
stale_content: ClaimAnchorAuditIssue[];
}
33 changes: 22 additions & 11 deletions libs/knowledge-graph/graph-view/build-graph-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export interface GraphViewClaimRow {
kind: string;
session: string;
source: "code" | "session";
freshness: "active" | "superseded";
freshness: "active" | "stale_content" | "superseded";
componentIds: string[];
flowIds: string[];
createdAt: string | null;
Expand All @@ -49,6 +49,7 @@ export interface GraphViewData {
components: number;
flows: number;
claims: number;
stale_content: number;
superseded: number;
};
components: GraphViewComponentRow[];
Expand All @@ -63,6 +64,7 @@ export interface GraphViewData {

export interface BuildGraphViewOptions {
repoName?: string;
staleClaimIds?: ReadonlySet<string>;
}

const CLAIM_KIND_ORDER = ["fact", "decision", "requirement", "task", "risk", "question"];
Expand All @@ -80,7 +82,9 @@ export function buildGraphViewData(
graph: GraphReadResult,
provenance: ClaimProvenanceRecord[],
supersededClaims: Claim[],
options: BuildGraphViewOptions = {},
): GraphViewData {
const staleClaimIds = options.staleClaimIds ?? new Set<string>();
const provenanceByClaimId = new Map(provenance.map((row) => [row.claim_id, row]));
const sourceById = new Map(graph.sources.map((source) => [source.id, source]));
const topLevelComponents = selectTopLevelComponents(graph.components, graph.edges);
Expand Down Expand Up @@ -108,7 +112,7 @@ export function buildGraphViewData(
};
});

const toClaimRow = (claim: Claim, freshness: "active" | "superseded"): GraphViewClaimRow => {
const toClaimRow = (claim: Claim, freshness: GraphViewClaimRow["freshness"]): GraphViewClaimRow => {
const record = provenanceByClaimId.get(claim.id);
const session = sessionLabelForClaim(claim.id, graph.edges, sourceById);
return {
Expand All @@ -131,7 +135,9 @@ export function buildGraphViewData(
return rightTime - leftTime;
};

const claims = graph.claims.map((claim) => toClaimRow(claim, "active")).sort(byCreatedDesc);
const claims = graph.claims
.map((claim) => toClaimRow(claim, staleClaimIds.has(claim.id) ? "stale_content" : "active"))
.sort(byCreatedDesc);
const superseded = supersededClaims.map((claim) => toClaimRow(claim, "superseded")).sort(byCreatedDesc);

return {
Expand All @@ -140,6 +146,7 @@ export function buildGraphViewData(
components: components.length,
flows: flows.length,
claims: claims.length,
stale_content: claims.filter((claim) => claim.freshness === "stale_content").length,
superseded: superseded.length,
},
components,
Expand All @@ -156,7 +163,7 @@ export function buildGraphViewHtml(
supersededClaims: Claim[],
options: BuildGraphViewOptions = {},
): string {
const data = buildGraphViewData(graph, provenance, supersededClaims);
const data = buildGraphViewData(graph, provenance, supersededClaims, options);
const title = options.repoName ? `Greplica graph view — ${options.repoName}` : "Greplica graph view";
return renderHtml(data, title);
}
Expand Down Expand Up @@ -412,7 +419,8 @@ function renderHtml(data: GraphViewData, title: string): string {
})
.join("\n");

const defaultClaimsMeta = `${data.claims.length} active claims · session from evidenced_by source, otherwise from code`;
const activeClaimCount = data.counts.claims - data.counts.stale_content;
const defaultClaimsMeta = `${data.claims.length} current claims · ${activeClaimCount} active${data.counts.stale_content > 0 ? ` · ${data.counts.stale_content} stale content` : ""} · session from evidenced_by source, otherwise from code`;
const graphDataJson = jsonForScriptTag(data);

return `<!DOCTYPE html>
Expand Down Expand Up @@ -834,7 +842,7 @@ ${timelineEvents}
</section>
<section id="view-claims-overview" class="view" data-view="claims-overview">
<h2>Claims - Overview</h2>
<p class="meta">Summary of ${data.counts.claims} active claims · click to see claims</p>
<p class="meta">Summary of ${data.counts.claims} current claims · click to see claims</p>
<div class="overview-grid">
<div class="overview-card">
<h3>By Type</h3>
Expand Down Expand Up @@ -879,7 +887,7 @@ ${timelineEvents}
const CLAIM_KIND_ORDER = ${JSON.stringify(CLAIM_KIND_ORDER)};
const CLAIM_KIND_COLORS = ${JSON.stringify(CLAIM_KIND_COLORS)};
const SOURCE_COLORS = { code: "#4e79a7", session: "#f28e2b" };
const FRESHNESS_COLORS = { active: "#59a14f", superseded: "#bab0ac" };
const FRESHNESS_COLORS = { active: "#59a14f", stale_content: "#e15759", superseded: "#bab0ac" };

const allClaims = graphData.claims.concat(graphData.supersededClaims);
const claimTextById = new Map(allClaims.map((claim) => [claim.id, claim.text]));
Expand Down Expand Up @@ -1069,7 +1077,8 @@ ${timelineEvents}
]);

renderOverviewChart("chart-freshness", "legend-freshness", [
{ label: "active", count: graphData.counts.claims, color: FRESHNESS_COLORS.active, href: "#claims?freshness=active" },
{ label: "active", count: graphData.counts.claims - graphData.counts.stale_content, color: FRESHNESS_COLORS.active, href: "#claims?freshness=active" },
{ label: "stale content", count: graphData.counts.stale_content, color: FRESHNESS_COLORS.stale_content, href: "#claims?freshness=stale_content" },
{ label: "superseded", count: graphData.counts.superseded, color: FRESHNESS_COLORS.superseded, href: "#claims?freshness=superseded" },
]);
}
Expand Down Expand Up @@ -1114,7 +1123,9 @@ ${timelineEvents}
case "source":
return filter.value === "session" ? "from session" : "from code";
case "freshness":
return filter.value === "superseded" ? "superseded" : "active";
if (filter.value === "superseded") return "superseded";
if (filter.value === "stale_content") return "with stale anchor content";
return "active";
case "commit": {
const event = graphData.claimsTimeline.events.find((item) => item.memoryCommitId === filter.value);
if (event && event.createdAt) return "from commit on " + formatDateTimeClient(event.createdAt);
Expand All @@ -1127,9 +1138,9 @@ ${timelineEvents}

function rowMatchesFilter(row, filter) {
const freshness = row.dataset.freshness;
if (!filter) return freshness === "active";
if (!filter) return freshness !== "superseded";
if (filter.type === "freshness") return freshness === filter.value;
if (freshness !== "active") return false;
if (freshness === "superseded") return false;
if (filter.type === "kind") return row.dataset.kind === filter.value;
if (filter.type === "source") return row.dataset.source === filter.value;
if (filter.type === "commit") return row.dataset.memoryCommitId === filter.value;
Expand Down
Loading