Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
7 changes: 7 additions & 0 deletions config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@
"maxStateNotes": 20,
"includeStaleNotes": false
},
"_repoWikiContextComment": "Optional advisory repo-wiki packet surfaced to review prompts. Disabled means no packet is read and prompts stay unchanged.",
"repoWikiContext": {
"enabled": false,
"packetPath": ".neondiff/repo-wiki-packet.json",
"maxPacketBytes": 12000,
"includeStaleContext": false
},
"_workRootComment": "workRoot must resolve outside this checkout (enforced at config load: config.workRoot must be outside the current repository checkout). /tmp/neondiff is a placeholder that satisfies that check on any machine without edits; point it at a durable path for real use.",
"workRoot": "/tmp/neondiff/runtime",
"statePath": "/tmp/neondiff/state/reviews.sqlite",
Expand Down
17 changes: 17 additions & 0 deletions docs/neondiff-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,3 +277,20 @@ recurrence of a previously-suppressed false positive, not just a byte-exact repe
- **Coarse fallback only on exact-miss.** When the exact fingerprint doesn't match, the gate falls back to matching `coarsePath` + normalized category + a line window (`|Δ| <= 3`) + a near-duplicate normalized title, reusing the same near-match helpers as same-run deduplication (#294) so the two matching semantics can never drift apart.
- **`confirmedByHuman` is honor-only — nothing auto-sets it.** This PR/field only *honors* a note that already carries `confirmedByHuman: true`; no code path in this surface automatically promotes an auto-learned note to human-confirmed.
- **Severity invariant: auto-learned notes stay P2/P3-only.** A false-positive note without `confirmedByHuman: true` may only suppress `P2`/`P3` findings, exact or coarse. Only a note with `confirmedByHuman: true` may suppress a finding of *any* severity, including `P0`/`P1`. This means a mistakenly-learned auto false positive can never silently suppress a high-severity finding — only an explicit human confirmation can widen suppression to `P0`/`P1`.

### `repoWikiContext`

`config.repoWikiContext` controls an optional advisory repo-wiki packet surfaced to review prompts (`src/repo-wiki-context.ts`). It is disabled by default, reads a prebuilt packet from the PR worktree, and never edits repository files.

| Key | Type | Default | What it does |
| --- | --- | --- | --- |
| `enabled` | `boolean` | `false` | Master switch for reading and including repo-wiki context. When disabled or absent, prompts are unchanged. |
| `packetPath` | `string` | `.neondiff/repo-wiki-packet.json` | JSON or Markdown packet path, resolved relative to the prepared PR worktree unless absolute. |
| `maxPacketBytes` | `integer` (`>= 1`) | `12000` | Byte budget for the rendered packet. Over-budget packets are omitted and recorded in evidence. |
| `includeStaleContext` | `boolean` | `false` | Whether stale packets may still be included as degraded advisory context. |

Safety invariants:

- The packet is advisory only. The PR diff, checkout files, GitHub metadata, and configured repo policy remain authoritative.
- Missing, stale, invalid, over-budget, or secret-like packet content degrades by omission and writes redacted evidence instead of blocking review.
- This flag accepts packet output; it does not vendor OpenWiki source code, run OpenWiki during live reviews, enable scheduled docs updates, or permit edits outside `openwiki/**`.
22 changes: 14 additions & 8 deletions docs/repo-wiki-packet.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# Repo Wiki Packet

Repo wiki packets are deterministic, evidence-safe context bundles for future
codebase-map and repo-wiki review prompts. The MVP is library-only and dry-run:
it does not change live worker behavior, prompt construction, GitHub comments,
checks, queueing, or runtime state.
Repo wiki packets are deterministic, evidence-safe context bundles for
codebase-map and repo-wiki review prompts. Packet generation remains usable as a
dry-run artifact, and live prompt inclusion is gated behind
`repoWikiContext.enabled`.

This context is advisory. GitHub diff, current checkout files, current review
evidence, and configured repo policy remain truth. A stale or missing wiki
Expand Down Expand Up @@ -96,7 +96,13 @@ over-budget packet.

## Runtime Boundary

This MVP intentionally does not wire packets into `src/worker.ts`,
`src/walkthrough.ts`, `src/config.ts`, `src/cli.ts`, or `src/state.ts`.
Future integration should add a separate feature flag, evidence files, and
review-prompt caps before any live prompt use.
Prompt integration is disabled by default through `config.repoWikiContext`.
When enabled, `src/worker.ts` reads a prebuilt packet from the prepared PR
worktree, records redacted evidence, and includes it in the review prompt only
if it is fresh or explicitly allowed stale, within budget, and free of
secret-like text.

This integration does not run OpenWiki during live review, mutate repository
files, change GitHub comment posting behavior, alter checks, or make repo-wiki
context authoritative. OpenWiki-generated files remain confined to
`openwiki/**`; suggestions for other docs are report-only.
24 changes: 23 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
import { isApiKeyEnvName, isProviderId, isProviderStructuredOutputMode, PROVIDER_STRUCTURED_OUTPUT_MODES, SCHEMA_FEEDBACK_RETRY_MAX, type ProviderRegistryConfig } from "./providers.js";
import { REGRESSION_CATEGORIES, type CategoryPrecisionFloors, type RequestChangesConfidenceFloors } from "./regression-taxonomy.js";
import type { ReviewMode, ReviewModeDefinition, ReviewModesConfig } from "./review-mode-types.js";
import type { RepoWikiContextConfig } from "./repo-wiki-context.js";
import { containsSecretLikeText } from "./secrets.js";
import type { SkillPackContextConfig } from "./skill-packs.js";

Expand Down Expand Up @@ -77,6 +78,7 @@ export interface BotConfig {
};
reviewGate?: ReviewGateConfig;
repoMemory?: RepoMemoryConfig;
repoWikiContext?: RepoWikiContextConfig;
gitnexusContext?: GitNexusContextConfig;
githubRelatedContext?: GitHubRelatedContextConfig;
skillPacks?: SkillPackContextConfig;
Expand Down Expand Up @@ -349,6 +351,12 @@ const DEFAULT_CONFIG: BotConfig = {
maxStateNotes: 20,
includeStaleNotes: false
},
repoWikiContext: {
enabled: false,
packetPath: ".neondiff/repo-wiki-packet.json",
maxPacketBytes: 12_000,
includeStaleContext: false
},
gitnexusContext: {
enabled: false,
packetVersion: "gitnexus-context-packet-v0.1",
Expand Down Expand Up @@ -643,6 +651,9 @@ function validateConfig(config: BotConfig): void {
const repoMemory = config.repoMemory ?? DEFAULT_CONFIG.repoMemory!;
config.repoMemory = repoMemory;
validateRepoMemoryConfig(repoMemory, "config.repoMemory");
const repoWikiContext = config.repoWikiContext ?? DEFAULT_CONFIG.repoWikiContext!;
config.repoWikiContext = repoWikiContext;
validateRepoWikiContextConfig(repoWikiContext, "config.repoWikiContext");
const gitnexusContext = config.gitnexusContext ?? DEFAULT_CONFIG.gitnexusContext!;
config.gitnexusContext = gitnexusContext;
validateGitNexusContextConfig(gitnexusContext, "config.gitnexusContext");
Expand Down Expand Up @@ -909,7 +920,7 @@ function validateReviewModesConfig(value: unknown, label: string, config: BotCon
}
}
const baseSelfConsistency = config.reviewGate?.selfConsistency?.enabled ?? false;
const baseContextAddons = (config.gitnexusContext?.enabled ?? false) || (config.githubRelatedContext?.enabled ?? false);
const baseContextAddons = (config.repoWikiContext?.enabled ?? false) || (config.gitnexusContext?.enabled ?? false) || (config.githubRelatedContext?.enabled ?? false);
const modes = value.modes as Record<string, unknown>;
for (const mode of REVIEW_MODES) {
// Distinguish an entirely ABSENT required mode key from a present-but-wrong-type value, so the
Expand Down Expand Up @@ -974,6 +985,17 @@ function validateRepoMemoryConfig(value: unknown, label: string): void {
validateBoolean(value.includeStaleNotes, `${label}.includeStaleNotes`);
}

function validateRepoWikiContextConfig(value: unknown, label: string): void {
if (!isRecord(value)) throw new Error(`${label} must be an object`);
validateBoolean(value.enabled, `${label}.enabled`);
validateOptionalString(value.packetPath, `${label}.packetPath`);
if (typeof value.packetPath !== "string" || value.packetPath.trim().length === 0) {
throw new Error(`${label}.packetPath must be a non-empty string`);
}
validatePositiveInteger(value.maxPacketBytes, `${label}.maxPacketBytes`);
validateBoolean(value.includeStaleContext, `${label}.includeStaleContext`);
}

function validateGitNexusContextConfig(value: unknown, label: string): void {
if (!isRecord(value)) throw new Error(`${label} must be an object`);
validateBoolean(value.enabled, `${label}.enabled`);
Expand Down
262 changes: 262 additions & 0 deletions src/repo-wiki-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
import { createHash } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { isAbsolute, resolve } from "node:path";
import { formatRepoWikiPacketMarkdown, type RepoWikiPacket, type RepoWikiSourceStatus } from "./repo-wiki-packet.js";
import { containsSecretLikeText, redactSecrets } from "./secrets.js";

export interface RepoWikiContextConfig {
enabled: boolean;
packetPath: string;
maxPacketBytes: number;
includeStaleContext: boolean;
}

export interface RepoWikiContextPacket {
sha256: string;
byteEstimate: number;
tokenEstimate: number;
markdown: string;
repoWiki: {
freshness: RepoWikiSourceStatus | "unknown";
degradedMode: boolean;
degradedReason?: string;
sourcePath?: string;
packetVersion?: string;
};
}

export type RepoWikiContextOmittedReason =
"disabled" | "missing_packet" | "stale_packet" | "budget_exceeded" | "secret_detected" | "invalid_packet";

export type RepoWikiContextBuildResult =
| { packet: RepoWikiContextPacket; omitted?: never }
| {
packet?: never;
omitted: {
reason: RepoWikiContextOmittedReason;
detail: string;
sourcePath?: string;
};
};

export function buildRepoWikiContextPacket(input: {
repo: string;
worktreePath: string;
config: RepoWikiContextConfig;
}): RepoWikiContextBuildResult {
if (!input.config.enabled) {
return {
omitted: {
reason: "disabled",
detail: "repoWikiContext.enabled is false"
}
};
}

const sourcePath = resolvePacketPath(input.worktreePath, input.config.packetPath);
const evidenceSourcePath = formatPacketPathForEvidence(input.config.packetPath);
if (!existsSync(sourcePath)) {
return {
omitted: {
reason: "missing_packet",
detail: "Repo wiki packet not found",
sourcePath: evidenceSourcePath
}
};
}

const raw = readFileSync(sourcePath, "utf8");
Comment thread
100yenadmin marked this conversation as resolved.
Outdated
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
if (containsSecretLikeText(raw)) {
return {
omitted: {
reason: "secret_detected",
detail: "Repo wiki packet contains secret-like text",
sourcePath: evidenceSourcePath
}
};
}

const parsed = parseRepoWikiContextRaw(raw, evidenceSourcePath);
if (!parsed.ok) {
return {
omitted: { reason: "invalid_packet", detail: parsed.error, sourcePath: evidenceSourcePath }
};
}

const byteEstimate = Buffer.byteLength(parsed.packet.markdown, "utf8");
if (byteEstimate > input.config.maxPacketBytes) {
Comment thread
100yenadmin marked this conversation as resolved.
Outdated
return {
omitted: {
reason: "budget_exceeded",
detail: `Repo wiki packet exceeded maxPacketBytes (${byteEstimate} > ${input.config.maxPacketBytes})`,
sourcePath: evidenceSourcePath
}
};
}

if (containsSecretLikeText(parsed.packet.markdown)) {
return {
omitted: {
reason: "secret_detected",
detail: "Rendered repo wiki packet contains secret-like text",
sourcePath: evidenceSourcePath
}
};
}

const freshness = parsed.packet.repoWiki.freshness;
if ((freshness === "stale" || freshness === "missing") && !input.config.includeStaleContext) {
return {
omitted: {
reason: "stale_packet",
detail: "Repo wiki packet is not fresh and includeStaleContext is false",
sourcePath: evidenceSourcePath
}
};
}

return {
packet: {
...parsed.packet,
byteEstimate,
tokenEstimate: Math.max(1, Math.ceil(byteEstimate / 4)),
repoWiki: {
...parsed.packet.repoWiki,
sourcePath: evidenceSourcePath
}
}
};
}

function resolvePacketPath(worktreePath: string, packetPath: string): string {
return isAbsolute(packetPath) ? packetPath : resolve(worktreePath, packetPath);
Comment thread
100yenadmin marked this conversation as resolved.
Outdated
}

function formatPacketPathForEvidence(packetPath: string): string {
return isAbsolute(packetPath) ? "[absolute-packet-path]" : packetPath;
}

function parseRepoWikiContextRaw(
raw: string,
sourcePath: string
): { ok: true; packet: RepoWikiContextPacket } | { ok: false; error: string } {
const trimmed = raw.trimStart();
if (!trimmed) return { ok: false, error: "Repo wiki packet is empty" };
if (!trimmed.startsWith("{")) return packetFromMarkdown(raw);

try {
const parsed = JSON.parse(raw) as unknown;
if (!isRecord(parsed)) return { ok: false, error: "Repo wiki packet JSON must be an object" };
if (typeof parsed.markdown === "string") return packetFromGenericJson(parsed);
if (looksLikeRepoWikiPacket(parsed)) return packetFromRepoWikiPacket(parsed);
return {
ok: false,
error: `Unsupported repo wiki packet shape at ${redactSecrets(sourcePath)}`
};
} catch (error) {
return {
ok: false,
error: `Repo wiki packet JSON did not parse: ${error instanceof Error ? error.message : String(error)}`
};
}
}

function packetFromMarkdown(markdown: string): {
ok: true;
packet: RepoWikiContextPacket;
} {
const byteEstimate = Buffer.byteLength(markdown, "utf8");
return {
ok: true,
packet: {
sha256: sha256(markdown),
byteEstimate,
tokenEstimate: Math.max(1, Math.ceil(byteEstimate / 4)),
markdown,
repoWiki: {
freshness: "unknown",
degradedMode: false
}
}
};
}

function packetFromGenericJson(parsed: Record<string, unknown>): {
ok: true;
packet: RepoWikiContextPacket;
} {
const markdown = String(parsed.markdown);
Comment thread
100yenadmin marked this conversation as resolved.
const byteEstimate = Buffer.byteLength(markdown, "utf8");
const freshness =
readFreshness(parsed.freshness) ?? readFreshness(readNested(parsed, "repoWiki", "freshness")) ?? "unknown";
const degradedMode =
typeof readNested(parsed, "repoWiki", "degradedMode") === "boolean"
? Boolean(readNested(parsed, "repoWiki", "degradedMode"))
: freshness === "stale" || freshness === "missing";
Comment thread
100yenadmin marked this conversation as resolved.
Outdated

return {
ok: true,
packet: {
sha256: sha256(markdown),
byteEstimate,
tokenEstimate: Math.max(1, Math.ceil(byteEstimate / 4)),
markdown,
repoWiki: {
Comment thread
100yenadmin marked this conversation as resolved.
freshness,
degradedMode,
...(typeof parsed.packetVersion === "string" ? { packetVersion: parsed.packetVersion } : {})
}
}
};
}

function packetFromRepoWikiPacket(packet: RepoWikiPacket): {
ok: true;
packet: RepoWikiContextPacket;
} {
const markdown = formatRepoWikiPacketMarkdown(packet);
const byteEstimate = Buffer.byteLength(markdown, "utf8");
return {
ok: true,
packet: {
sha256: sha256(markdown),
byteEstimate,
tokenEstimate: Math.max(1, Math.ceil(byteEstimate / 4)),
markdown,
repoWiki: {
freshness: packet.source.status,
Comment thread
100yenadmin marked this conversation as resolved.
Outdated
degradedMode: packet.degraded,
...(packet.source.staleReason ? { degradedReason: packet.source.staleReason } : {}),
packetVersion: packet.packetVersion
}
}
};
}

function looksLikeRepoWikiPacket(input: unknown): input is RepoWikiPacket {
Comment thread
100yenadmin marked this conversation as resolved.
Outdated
if (!isRecord(input)) return false;
return (
isRecord(input.repo) &&
isRecord(input.source) &&
typeof input.source.status === "string" &&
Array.isArray(input.includedSections) &&
typeof input.packetSha === "string"
);
}

function readFreshness(value: unknown): RepoWikiSourceStatus | "unknown" | undefined {
return value === "fresh" || value === "stale" || value === "missing" || value === "unknown" ? value : undefined;
}

function readNested(input: Record<string, unknown>, objectKey: string, valueKey: string): unknown {
Comment thread
100yenadmin marked this conversation as resolved.
Outdated
const nested = input[objectKey];
return isRecord(nested) ? nested[valueKey] : undefined;
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

function sha256(text: string): string {
return createHash("sha256").update(text).digest("hex");
}
Loading
Loading