Skip to content
Merged
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
26 changes: 21 additions & 5 deletions docs/memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,22 +72,34 @@ report data than hand-authored memory.

```bash
npx codedecay memory-learn --input ci-failure.json
npx codedecay memory-learn --input incidents/auth-outage.md
npx codedecay memory-learn --input codedecay-report.json --apply --format json
npx codedecay memory-learn --input .codedecay/local/product-runs/latest.json --apply
```

Accepted inputs include:

- `ciFailures`: failing workflow, job, message, command, files, and areas
- `pullRequests`: title, body, commit messages, changed files, checks, and areas
- `pullRequests`: title, body, labels, commit messages, changed files, checks,
and areas
- `incidents` or `incidentMarkdowns`: structured incident/postmortem entries
that become invariant and past-regression proposals
- direct `.md` or `.markdown` incident/postmortem files
- `incidentMarkdownFiles`: paths in a JSON input file, read relative to the
input file
- `reports`, `codeDecayReports`, `failOnReports`, or `blockedReports`
- a single CodeDecay JSON report with `tool: "CodeDecay"` and `findings`
- product verification reports with `tool: "CodeDecay"` and `targets`
- `productReports`, `productVerificationReports`, or `productTargetReports`

The learner converts those signals into flows, commands, architecture notes,
and past regressions. It infers impacted areas from file paths and text such as
`auth`, `api`, `schema`, `migration`, `workflow`, or `coverage`.
The learner converts those signals into reviewable proposals for flows,
commands, invariants, architecture notes, and past regressions. Each proposal
includes the source type/path, confidence, timestamp, and why the learning
matters. The preview also shows the merged memory that would be written if
`--apply` is passed.

It infers impacted areas from file paths, PR labels such as `area: auth`, and
text such as `auth`, `api`, `schema`, `migration`, `workflow`, or `coverage`.

For CodeDecay report inputs, `memory-learn` keeps only actionable findings that
include concrete evidence such as a file, impacted area, or recommended check.
Expand All @@ -101,7 +113,9 @@ stderr, screenshots, traces, request bodies, headers, cookies, or full URLs with
query strings.

`memory-learn` is deterministic and local. It does not query GitHub, inspect
remote CI, call a model, or write anything unless `--apply` is passed.
remote CI, call a model, upload telemetry, or write anything unless `--apply`
is passed. GitHub PR and CI data must be provided as local JSON input if you
want CodeDecay to learn from it.

## File Format

Expand Down Expand Up @@ -201,6 +215,8 @@ Recommended review workflow:
`codedecay product --generate-api-tests --run-generated-api-tests --output .codedecay/local/product-runs/latest.json --format json`.
- Preview learned memory with
`codedecay memory-learn --input .codedecay/local/product-runs/latest.json`.
- Review the `proposals` section for source, confidence, timestamp, and why
each entry matters.
- Re-run with `--apply` only after reviewing the preview.
- Commit `.codedecay/memory.json` like source code so changes are visible in PRs.

Expand Down
61 changes: 59 additions & 2 deletions packages/cli/src/commands/memory.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { dirname, extname, resolve } from "node:path";
import {
importCodeDecayMemory,
learnCodeDecayMemory,
Expand Down Expand Up @@ -79,7 +79,7 @@ export function runMemoryLearnCommand(context: CliCommandContext, dependencies:
const rootDir = dependencies.resolveRepoRoot(cwd, { format: "markdown" });
const loadedMemory = loadCodeDecayMemory(rootDir);
const inputPath = resolve(context.runtimeCwd, options.input);
const rawLearning = JSON.parse(readFileSync(inputPath, "utf8"));
const rawLearning = parseMemoryLearningInput(inputPath);
const learned = learnCodeDecayMemory(loadedMemory.memory, rawLearning, inputPath);
const writtenPath = options.apply ? writeCodeDecayMemory(rootDir, learned.memory) : undefined;

Expand All @@ -93,3 +93,60 @@ export function runMemoryLearnCommand(context: CliCommandContext, dependencies:
})
);
}

function parseMemoryLearningInput(inputPath: string): unknown {
const raw = readFileSync(inputPath, "utf8");
if (isMarkdownPath(inputPath)) {
return {
incidentMarkdowns: [
{
path: inputPath,
markdown: raw
}
]
};
}

let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Invalid memory-learn input at ${inputPath}: ${message}`);
}

return expandIncidentMarkdownFiles(parsed, inputPath);
}

function expandIncidentMarkdownFiles(value: unknown, inputPath: string): unknown {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return value;
}

const object = value as Record<string, unknown>;
if (!Array.isArray(object.incidentMarkdownFiles)) {
return value;
}

const incidentMarkdowns = [
...(Array.isArray(object.incidentMarkdowns) ? object.incidentMarkdowns : []),
...object.incidentMarkdownFiles
.filter((item): item is string => typeof item === "string" && item.trim().length > 0)
.map((filePath) => {
const resolved = resolve(dirname(inputPath), filePath);
return {
path: filePath,
markdown: readFileSync(resolved, "utf8")
};
})
];

return {
...object,
incidentMarkdowns
};
}

function isMarkdownPath(inputPath: string): boolean {
return [".md", ".markdown"].includes(extname(inputPath).toLowerCase());
}
10 changes: 6 additions & 4 deletions packages/cli/src/docs/command-docs/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,23 +58,25 @@ export const STATE_COMMAND_DOCS: Record<string, CommandDoc> = {
},
"memory-learn": {
name: "memory-learn",
summary: "Learn local repo memory from CI, PR, and CodeDecay report signals.",
summary: "Learn local repo memory proposals from CI, PR, incident, and CodeDecay report signals.",
usage: ["codedecay memory-learn --input <path> [options]"],
description: [
"Convert raw-ish CI failures, merged PR descriptions, commit messages, and CodeDecay fail-on reports into reviewable `.codedecay/memory.json` entries."
"Convert raw-ish CI failures, merged PR descriptions, incident markdown, commit messages, and CodeDecay fail-on reports into reviewable `.codedecay/memory.json` proposals."
],
options: [
{ flag: "--input <path>", description: "JSON file containing ciFailures, pullRequests, reports, failOnReports, or a CodeDecay report" },
{ flag: "--input <path>", description: "JSON or markdown file containing ciFailures, pullRequests, incidents, reports, failOnReports, or a CodeDecay report" },
{ flag: "--cwd <path>", description: "Repository working directory (default: current directory)" },
{ flag: "--format <format>", description: "json or markdown preview format (default: markdown)" },
{ flag: "--apply", description: "Write the learned memory file instead of only printing the preview" }
],
examples: [
"codedecay memory-learn --input ci-failure.json",
"codedecay memory-learn --input incidents/auth-outage.md",
"codedecay memory-learn --input codedecay-report.json --apply"
],
notes: [
"Learning is deterministic and local. CodeDecay does not inspect remote CI, PRs, or GitHub automatically."
"Learning is deterministic and local. CodeDecay does not inspect remote CI, PRs, or GitHub automatically.",
"Preview output includes proposals with source, confidence, timestamp, and why before --apply writes memory."
]
}
};
34 changes: 33 additions & 1 deletion packages/cli/src/renderers/memory.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { CODEDECAY_VERSION } from "@submuxhq/codedecay-core";
import type { LoadedCodeDecayMemory, MemoryImportResult, MemoryLearnResult } from "@submuxhq/codedecay-memory";
import type { LoadedCodeDecayMemory, MemoryImportResult, MemoryLearnResult, MemoryLearningProposal } from "@submuxhq/codedecay-memory";
import type { ConfigFormat } from "../types";

export function renderMemory(loadedMemory: LoadedCodeDecayMemory, format: ConfigFormat): string {
Expand Down Expand Up @@ -86,6 +86,7 @@ export function renderMemoryLearnResult(input: {
learned: input.result.learned,
added: input.result.added,
merged: input.result.merged,
proposals: input.result.proposals,
memory: input.result.memory
},
null,
Expand All @@ -108,9 +109,40 @@ export function renderMemoryLearnResult(input: {
`| Architecture notes | ${input.result.learned.architecture} | ${input.result.added.architecture} | ${input.result.merged.architecture} |`,
`| Past regressions | ${input.result.learned.regressions} | ${input.result.added.regressions} | ${input.result.merged.regressions} |`,
"",
...renderLearningProposalsMarkdown(input.result.proposals),
renderMemory({ memory: input.result.memory, sourcePath: input.writtenPath }, "markdown").trim(),
""
];

return `${lines.join("\n")}\n`;
}

function renderLearningProposalsMarkdown(proposals: MemoryLearningProposal[]): string[] {
if (proposals.length === 0) {
return ["### Proposals", "", "No memory proposals were generated.", ""];
}

return [
"### Proposals",
"",
"| Section | Title | Confidence | Source | Why |",
"| --- | --- | --- | --- | --- |",
...proposals.slice(0, 20).map((proposal) =>
`| ${proposal.section} | ${proposal.title} | ${proposal.confidence} | ${formatProposalSource(proposal)} | ${proposal.why} |`
),
proposals.length > 20 ? `| ... | ${proposals.length - 20} more proposal(s) omitted from markdown | | | |` : undefined,
""
].filter((line): line is string => line !== undefined);
}

function formatProposalSource(proposal: MemoryLearningProposal): string {
const parts = [
proposal.source.type,
proposal.source.title ? `title: ${proposal.source.title}` : undefined,
proposal.source.id ? `id: ${proposal.source.id}` : undefined,
proposal.source.labels && proposal.source.labels.length > 0 ? `labels: ${proposal.source.labels.join(", ")}` : undefined,
`path: ${proposal.source.path}`,
`timestamp: ${proposal.timestamp}`
].filter((item): item is string => item !== undefined);
return parts.join("<br>");
}
113 changes: 113 additions & 0 deletions packages/cli/test/memory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,119 @@ describe("codedecay memory CLI contract", () => {
expect.objectContaining({ title: "CodeDecay: Risky source changes without changed tests" })
])
);
expect(parsed.proposals).toEqual(
expect.arrayContaining([
expect.objectContaining({
section: "regressions",
title: "Auth smoke failed",
source: expect.objectContaining({ type: "ci-failure" }),
confidence: "high",
why: expect.stringContaining("CI failure")
})
])
);
});

it("learns reviewable memory proposals from incident markdown without applying by default", async () => {
const repo = createLowRiskRepo();
const inputPath = join(repo, "auth-incident.md");
writeFile(
repo,
"auth-incident.md",
[
"# Auth cache outage",
"",
"Incident: stale auth cache allowed a forbidden session after deploy.",
"Prevention: auth cache invalidation must be verified after session changes."
].join("\n")
);

const preview = await run(["memory-learn", "--input", inputPath, "--format", "json"], repo);
const previewJson = JSON.parse(preview.stdout);

expect(preview.exitCode).toBe(0);
expect(previewJson.writtenPath).toBeUndefined();
expect(previewJson.memory.invariants).toEqual(
expect.arrayContaining([expect.objectContaining({ name: "Auth cache outage", severity: "high" })])
);
expect(previewJson.proposals).toEqual(
expect.arrayContaining([
expect.objectContaining({
section: "invariants",
title: "Auth cache outage",
source: expect.objectContaining({
type: "incident-markdown",
path: inputPath
}),
confidence: "high",
why: expect.stringContaining("durable rule")
})
])
);
expect(existsSync(join(repo, ".codedecay/memory.json"))).toBe(false);

const applied = await run(["memory-learn", "--input", inputPath, "--apply", "--format", "json"], repo);
const appliedJson = JSON.parse(applied.stdout);

expect(applied.exitCode).toBe(0);
expect(appliedJson.writtenPath).toContain(".codedecay/memory.json");
expect(JSON.parse(readFileSync(join(repo, ".codedecay/memory.json"), "utf8")).invariants).toEqual(
expect.arrayContaining([expect.objectContaining({ name: "Auth cache outage" })])
);
});

it("reports malformed memory-learn inputs without crashing", async () => {
const repo = createLowRiskRepo();
const inputPath = join(repo, "broken-learn.json");
writeFile(repo, "broken-learn.json", "{");

const result = await run(["memory-learn", "--input", inputPath, "--format", "json"], repo);

expect(result.exitCode).toBe(2);
expect(result.stdout).toBe("");
expect(result.stderr).toContain("Invalid memory-learn input");
});

it("matches learned past-regression memory in redteam reports after explicit apply", async () => {
const repo = createRepo({
"src/auth/session.ts": "export function session() { return { ok: true }; }\n"
});
const inputPath = join(repo, "memory-learn.json");
writeFile(
repo,
"memory-learn.json",
JSON.stringify(
{
ciFailures: [
{
title: "Auth smoke failed",
message: "Token refresh returned 401 after deploy.",
command: "pnpm test auth",
files: ["src/auth/session.ts"]
}
]
},
null,
2
)
);

const applied = await run(["memory-learn", "--input", inputPath, "--apply", "--format", "json"], repo);
expect(applied.exitCode).toBe(0);

writeFile(repo, "src/auth/session.ts", "export function session() { return { ok: false }; }\n");
const redteam = await run(["redteam", "--format", "json"], repo);
const report = JSON.parse(redteam.stdout);

expect(redteam.exitCode).toBe(0);
expect(report.analysis.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({
ruleId: "memory-past-regression-area",
file: "src/auth/session.ts"
})
])
);
});

it("learns memory from product verification reports", async () => {
Expand Down
5 changes: 5 additions & 0 deletions packages/memory/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ export type {
MemoryImportCounts,
MemoryImportResult,
MemoryInvariant,
MemoryLearningProposal,
MemoryLearningProposalConfidence,
MemoryLearningProposalSection,
MemoryLearningProposalSource,
MemoryLearningSourceType,
MemoryLearnResult,
MemoryMatcher,
MemoryProvider,
Expand Down
13 changes: 9 additions & 4 deletions packages/memory/src/learn-memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,24 @@ import {
countMemoryEntries,
importCodeDecayMemory
} from "./import-memory";
import { normalizeLearnedMemory } from "./learn-memory/normalize";
import { normalizeLearnedMemoryWithProposals } from "./learn-memory/normalize";
import { createMemoryLearningContext, finalizeMemoryProposals } from "./learn-memory/proposals";
import type { CodeDecayMemory, MemoryLearnResult } from "./types";

export function learnCodeDecayMemory(
baseMemory: CodeDecayMemory,
learnedValue: unknown,
sourceName: string = "memory learn"
sourceName: string = "memory learn",
options: { timestamp?: string | undefined } = {}
): MemoryLearnResult {
const learnedMemory = normalizeLearnedMemory(learnedValue, sourceName);
const context = createMemoryLearningContext(sourceName, options.timestamp ?? new Date().toISOString());
const learned = normalizeLearnedMemoryWithProposals(learnedValue, sourceName, context);
const learnedMemory = learned.memory;
const result = importCodeDecayMemory(baseMemory, learnedMemory, sourceName);

return {
...result,
learned: countMemoryEntries(learnedMemory)
learned: countMemoryEntries(learnedMemory),
proposals: finalizeMemoryProposals(learned.proposals)
};
}
Loading