From 041115c1737c97d3837ef08459cbdeffefcd3743 Mon Sep 17 00:00:00 2001 From: Ralph Chang Date: Tue, 19 May 2026 15:05:48 +0800 Subject: [PATCH] chore(code-health): prepare v1.6.5 --- AGENTS.md | 4 +- CHANGELOG.md | 31 +++ RELEASE_NOTES.md | 38 +++ package.json | 3 +- scripts/dev/check-package-integrity.ts | 91 +++++++ scripts/memory-diag/formatters/status.ts | 2 +- scripts/memory-diag/inspection-model.ts | 2 +- scripts/memory-diag/quality-review-model.ts | 263 +++----------------- scripts/memory-diag/quality-versioning.ts | 236 ++++++++++++++++++ src/evidence-log.ts | 6 + src/extractors.ts | 12 - src/memory-kind-policy.ts | 14 ++ src/memory-visibility.ts | 4 +- src/plugin.ts | 1 - src/retention.ts | 3 +- src/storage.ts | 6 + src/tui-plugin.ts | 3 +- src/workspace-memory.ts | 14 +- tests/evidence-log.test.ts | 24 ++ tests/memory-visibility.test.ts | 4 + tests/package-integrity.test.ts | 38 +++ tests/plugin.test.ts | 2 + tests/storage.test.ts | 21 +- tests/workspace-memory.test.ts | 37 +++ tsconfig.unused.json | 7 + 25 files changed, 599 insertions(+), 267 deletions(-) create mode 100644 scripts/dev/check-package-integrity.ts create mode 100644 scripts/memory-diag/quality-versioning.ts create mode 100644 src/memory-kind-policy.ts create mode 100644 tests/package-integrity.test.ts create mode 100644 tsconfig.unused.json diff --git a/AGENTS.md b/AGENTS.md index 586ff7b..b628f31 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -208,8 +208,8 @@ const typedData = data as WorkspaceMemoryStore; // Explicit cast after validati // ============================================================================ // ✅ REQUIRED: Block comments for complex logic -// Quality gate: Reject candidates that are git hashes, errors, or path-heavy -function shouldAcceptWorkspaceMemoryCandidate(candidate: string): boolean { +// Quality gate: return accepted/reasons so rejection evidence stays explainable +function evaluateWorkspaceMemoryCandidate(candidate: WorkspaceMemoryCandidate): CandidateEvaluation { // ... } diff --git a/CHANGELOG.md b/CHANGELOG.md index d7f19c4..614ad56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,37 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.6.5] - 2026-05-19 + +### Added + +- Added `check:package-integrity` to verify `package.json` and on-disk `package-lock.json` root versions stay aligned even though the lockfile remains ignored by git. +- Added `tsconfig.unused.json` as a strict unused-symbol audit gate for development and release checks. +- Added package-integrity tests covering matching versions, mismatch reporting, and missing-lockfile guidance. +- Added storage/evidence contract tests for full-state JSON overwrites and concurrent evidence JSONL appends. +- Added workspace-memory render-order characterization and memory-visibility order coverage for the shared memory type order. + +### Changed + +- Centralized the current memory type ordering (`feedback`, `project`, `decision`, `reference`) in a narrow `memory-kind-policy` seam used by workspace rendering, TUI grouping, and memory visibility. +- Extracted diagnostics producer-version grouping and inference helpers from `memory-diag quality` into a pure diagnostics-only module while preserving the existing JSON and human output contracts. +- Documented storage write-path contracts in code: `updateJSON` is the locked read-modify-write path, `atomicWriteJSON` is the full-state overwrite primitive, and evidence logs remain append-only JSONL with bounded pruning. +- Marked legacy parser fixtures and retention caps as intentional compatibility/policy-contract test coverage. +- Updated developer docs to reference `evaluateWorkspaceMemoryCandidate` instead of the removed private acceptance wrapper. + +### Deprecated + +- Marked `REINFORCEMENT_MIN_INTERVAL_MS` with JSDoc `@deprecated`; the rolling reinforcement policy uses `REINFORCEMENT_MIN_ELAPSED_MS`. + +### Removed + +- Removed unused imports and private unused helpers discovered by the new unused-symbol audit, including the private `shouldAcceptWorkspaceMemoryCandidate` wrapper. + +### Fixed + +- Fixed release hygiene drift detection for the ignored lockfile by adding an explicit package integrity check. +- Reduced future diagnostics and memory-kind change risk by extracting small behavior-preserving seams without changing runtime memory behavior. + ## [1.6.4] - 2026-05-15 ### Changed diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index d0efd71..a72c513 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,43 @@ # Release Notes +## 1.6.5 (2026-05-19) + +### Code Health and Release Hygiene + +This patch release is an internal health release before the next feature wave. It does not change memory extraction, reinforcement policy, TUI behavior, or the `memory-diag` CLI contract. Instead, it makes the codebase easier to audit and safer to modify. + +The release adds package-version integrity checks, a clean unused-symbol audit, focused characterization tests, storage/evidence contract coverage, a narrow shared memory-type ordering seam, and a small diagnostics versioning extraction. + +### What Changed + +- **Package integrity check**: added `npm run check:package-integrity` to verify `package.json` and the on-disk `package-lock.json` root versions match, with a clear `run npm install first` message when the ignored lockfile is missing. +- **Unused-symbol audit**: added `tsconfig.unused.json` and cleaned the existing unused imports/private helpers so the audit now passes cleanly. +- **Memory type order seam**: centralized the current order (`feedback`, `project`, `decision`, `reference`) for workspace rendering, memory visibility, and TUI grouping without creating a broader policy registry. +- **Storage/evidence contracts**: documented write-path semantics and added tests for full-state JSON overwrite behavior and concurrent evidence JSONL appends. +- **Diagnostics containment**: extracted producer-version grouping and inference helpers from `memory-diag quality` into a pure diagnostics-only module while preserving existing output shape and wording. +- **Characterization coverage**: added render-order coverage and labeled compatibility/policy-contract tests so future refactors can distinguish intentional legacy behavior from brittle fixtures. + +### Upgrade Notes + +- No configuration changes are required. +- Existing workspace memory files and evidence logs remain compatible. +- The `memory-diag` CLI JSON shape and human output wording are intended to be unchanged. +- `package-lock.json` remains ignored by git in this repository; run `npm install` before `npm run check:package-integrity` if the lockfile is missing locally. +- `REINFORCEMENT_MIN_INTERVAL_MS` remains exported for compatibility but is now marked `@deprecated`; use `REINFORCEMENT_MIN_ELAPSED_MS` for the rolling reinforcement policy. + +### Validation + +- `npm run check:package-integrity` — `PACKAGE_INTEGRITY_PASS version=1.6.5` +- `node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/package-integrity.test.ts` — 3 tests passing +- `./node_modules/.bin/tsc -p tsconfig.unused.json` — no unused-symbol errors +- `node --test --experimental-strip-types tests/memory-diag-quality.test.ts tests/memory-diag.test.ts` — 93 tests passing +- `node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/storage.test.ts tests/evidence-log.test.ts` — 22 tests passing +- `npm run typecheck` — `TYPECHECK_PASS` +- `npm test` — 504 tests passing, `TEST_PASS` +- `npm run build` — `BUILD_PASS` + +--- + ## 1.6.4 (2026-05-15) ### Rolling Weekly Reinforcement diff --git a/package.json b/package.json index c4c1044..d83e56b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-working-memory", - "version": "1.6.4", + "version": "1.6.5", "description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state", "type": "module", "main": "index.ts", @@ -29,6 +29,7 @@ "prepack": "npm run build", "diag": "npm run --silent build:memory-diag && node ./scripts/memory-diag-bin.cjs", "test:pack:memory-diag": "node --test --experimental-strip-types tests/smoke/memory-diag-packaging.test.ts", + "check:package-integrity": "node --experimental-strip-types scripts/dev/check-package-integrity.ts", "typecheck": "tsc --noEmit && node -e \"console.log('TYPECHECK_PASS')\"", "test": "node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/*.test.ts && node -e \"console.log('TEST_PASS')\"", "check:compat": "npm install --no-save @opencode-ai/plugin@latest && npm run typecheck && npm test" diff --git a/scripts/dev/check-package-integrity.ts b/scripts/dev/check-package-integrity.ts new file mode 100644 index 0000000..b15a603 --- /dev/null +++ b/scripts/dev/check-package-integrity.ts @@ -0,0 +1,91 @@ +#!/usr/bin/env node + +import { readFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +type PackageManifest = { + version?: unknown; +}; + +type PackageLock = { + version?: unknown; + packages?: Record; +}; + +export type PackageVersionMismatch = { + field: "package-lock.json version" | "package-lock.json packages[\"\"].version"; + expected: string; + actual: unknown; +}; + +export function packageVersionMismatches( + packageJson: PackageManifest, + packageLock: PackageLock, +): PackageVersionMismatch[] { + if (typeof packageJson.version !== "string" || packageJson.version.length === 0) { + throw new Error("package.json version must be a non-empty string"); + } + + const expected = packageJson.version; + const rootLockVersion = packageLock.version; + const rootPackageVersion = packageLock.packages?.[""]?.version; + + const candidates = [ + { field: "package-lock.json version" as const, actual: rootLockVersion }, + { field: "package-lock.json packages[\"\"].version" as const, actual: rootPackageVersion }, + ]; + + return candidates + .filter(candidate => candidate.actual !== expected) + .map(candidate => ({ ...candidate, expected })); +} + +export function formatPackageVersionMismatch(mismatch: PackageVersionMismatch): string { + return `${mismatch.field} (${String(mismatch.actual)}) does not match package.json version (${mismatch.expected})`; +} + +export function packageLockReadErrorMessage(error: unknown): string { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + if (code === "ENOENT") return "package-lock.json not found; run npm install first"; + + const message = error instanceof Error ? error.message : String(error); + return `Unable to read package-lock.json; run npm install first. ${message}`; +} + +async function readJsonFile(path: string): Promise { + return JSON.parse(await readFile(path, "utf8")) as T; +} + +async function main(): Promise { + const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../.."); + const packageJson = await readJsonFile(join(repoRoot, "package.json")); + let packageLock: PackageLock; + try { + packageLock = await readJsonFile(join(repoRoot, "package-lock.json")); + } catch (error) { + console.error(packageLockReadErrorMessage(error)); + process.exit(1); + } + + const mismatches = packageVersionMismatches(packageJson, packageLock); + + if (mismatches.length > 0) { + console.error("Package integrity check failed:"); + for (const mismatch of mismatches) { + console.error(`- ${formatPackageVersionMismatch(mismatch)}`); + } + process.exit(1); + } + + console.log(`PACKAGE_INTEGRITY_PASS version=${packageJson.version}`); +} + +function isMainModule(): boolean { + const invokedPath = process.argv[1]; + return invokedPath ? import.meta.url === pathToFileURL(resolve(invokedPath)).href : false; +} + +if (isMainModule()) { + await main(); +} diff --git a/scripts/memory-diag/formatters/status.ts b/scripts/memory-diag/formatters/status.ts index 75d9914..289643e 100644 --- a/scripts/memory-diag/formatters/status.ts +++ b/scripts/memory-diag/formatters/status.ts @@ -3,7 +3,7 @@ import { RETENTION_TYPE_MAX, } from "../../../src/retention.ts"; import { TYPES } from "../constants.ts"; -import { daysSinceIso, formatStrength } from "../retention-model.ts"; +import { formatStrength } from "../retention-model.ts"; import { cleanText, truncate } from "../text.ts"; import type { MemoryInspectionReadModel, RetentionDiagItem } from "../types.ts"; diff --git a/scripts/memory-diag/inspection-model.ts b/scripts/memory-diag/inspection-model.ts index 01eed6b..377aa37 100644 --- a/scripts/memory-diag/inspection-model.ts +++ b/scripts/memory-diag/inspection-model.ts @@ -1,6 +1,6 @@ import type { EvidenceEventV1 } from "../../src/evidence-log.ts"; import type { LongTermType } from "../../src/types.ts"; -import { countBy, objectFromCounts, uniqueStrings } from "./text.ts"; +import { countBy, objectFromCounts } from "./text.ts"; import { groupEvidenceByMemoryId } from "./evidence-model.ts"; import { loadRejectionRecords } from "./rejections-model.ts"; import { snapshotForOptions } from "./workspace-snapshot.ts"; diff --git a/scripts/memory-diag/quality-review-model.ts b/scripts/memory-diag/quality-review-model.ts index 6abad27..38035a2 100644 --- a/scripts/memory-diag/quality-review-model.ts +++ b/scripts/memory-diag/quality-review-model.ts @@ -5,75 +5,42 @@ import { RETENTION_TYPE_MAX } from "../../src/retention.ts"; import type { LongTermMemoryEntry, LongTermType } from "../../src/types.ts"; import { TYPES } from "./constants.ts"; import { disappearanceRows } from "./inspection-model.ts"; +import { + VERSION_ANALYSIS_SAMPLE_THRESHOLD, + VERSION_GROUPS, + buildVersionBuckets, + buildVersionCoverage, + computeVersionedInference, + hasKnownProducerVersion, + hasProducerFields, + producerVersionGroupFor, +} from "./quality-versioning.ts"; +import type { + AnswerabilityLevel, + ProducerBearingRecord, + ProducerVersionGroup, + VersionCoverage, + VersionedMechanismDiagnosticQuestion, + VersionedMechanismFacts, +} from "./quality-versioning.ts"; import { hasWorkspaceScope, rejectionQualitySummary, uniqueByCanonicalText } from "./rejections-model.ts"; import { canonicalMemoryText, cleanText, countBy, objectFromCounts, truncate, uniqueStrings, workspaceRootHash } from "./text.ts"; import type { MemoryInspectionReadModel, NormalizedRejection } from "./types.ts"; -export type AnswerabilityLevel = "supported" | "partial" | "inventory_only" | "not_instrumented"; +export type { + AnswerabilityLevel, + ProducerBearingRecord, + ProducerVersionGroup, + VersionAvailability, + VersionBucketFacts, + VersionCoverage, + VersionedMechanismDiagnosticQuestion, + VersionedMechanismFacts, + VersionedMechanismInference, + VersionSampleAssessment, +} from "./quality-versioning.ts"; -export type ProducerVersionGroup = "current" | "previous" | "unknown_unversioned"; - -export type VersionSampleAssessment = - | "observed" - | "not_observed_but_sample_small" - | "not_observed_with_sufficient_sample" - | "no_current_version_opportunities"; - -export type VersionAvailability = { - noProducerFields: number; - unknownProducerVersion: number; - emptyProducerVersion: number; - knownProducerVersion: number; -}; - -export type VersionCoverage = { - totalEvents: number; - currentVersionEvents: number; - previousVersionEvents: number; - unknownVersionEvents: number; - coveragePercent: number; - isTransitional: boolean; -}; - -export type VersionedMechanismInference = { - status: - | "current_recurrence_detected" - | "pattern_persists_across_versions" - | "no_current_evidence_observed" - | "no_current_evidence_sample_small" - | "no_current_version_opportunities" - | "no_previous_pattern_observed"; - message: string; - caveat: "Version grouping is based only on producerVersion strings in evidence"; -}; - -export type VersionBucketFacts = { - group: ProducerVersionGroup; - label: string; - opportunityCount: number; - observedPatternCount: number; - producerVersions: Record; - versionAvailability: VersionAvailability; - answerabilityLevel: AnswerabilityLevel; - sampleAssessment: VersionSampleAssessment; - facts: TFacts; -}; - -export type VersionedMechanismDiagnosticQuestion = { - mechanism: "reinforcement_rule"; - group: ProducerVersionGroup; - question: string; - evidence: string[]; -}; - -export type VersionedMechanismFacts = { - currentPackageVersion: string; - opportunityName: string; - sampleThreshold: number; - buckets: Record>; - inference: VersionedMechanismInference; - diagnosticQuestions?: VersionedMechanismDiagnosticQuestion[]; -}; +export { hasKnownProducerVersion, producerVersionGroupFor } from "./quality-versioning.ts"; export type RejectionVersionFacts = { totalRecords: number; @@ -314,9 +281,6 @@ export type HeuristicFlag = { const ACTIVE_MEMORY_FULL_TEXT_THRESHOLD = 40; const REPRESENTATIVE_CANDIDATE_LIMIT = 10; const RECENT_EVICTION_DAYS = 7; -const VERSION_ANALYSIS_SAMPLE_THRESHOLD = 5; -const VERSION_GROUPS: ProducerVersionGroup[] = ["current", "previous", "unknown_unversioned"]; -const VERSION_GROUPING_CAVEAT = "Version grouping is based only on producerVersion strings in evidence" as const; const KNOWN_MIGRATION_IDS = [ "2026-04-26-p0-cleanup", @@ -617,7 +581,7 @@ function buildVersionedSystemMechanismFacts( currentPackageVersion: string, generatedAt: string, ): VersionedSystemMechanismFacts { - const versionCoverage = buildVersionCoverage(events, rejections, currentPackageVersion); + const versionCoverage = buildVersionCoverage([...events, ...rejections], currentPackageVersion); return { currentPackageVersion, versionCoverage, @@ -761,30 +725,6 @@ function buildVersionedEvictionFacts( }; } -function buildVersionBuckets( - records: TRecord[], - currentPackageVersion: string, - summarize: (records: TRecord[]) => { facts: TFacts; opportunityCount: number; observedPatternCount: number }, -): Record> { - const grouped = Object.fromEntries(VERSION_GROUPS.map(group => [group, []])) as Record; - for (const record of records) grouped[producerVersionGroupFor(record, currentPackageVersion)].push(record); - return Object.fromEntries(VERSION_GROUPS.map(group => { - const bucketRecords = grouped[group]; - const summary = summarize(bucketRecords); - return [group, { - group, - label: versionGroupLabel(group, currentPackageVersion), - opportunityCount: summary.opportunityCount, - observedPatternCount: summary.observedPatternCount, - producerVersions: producerVersionCounts(bucketRecords), - versionAvailability: buildVersionAvailability(bucketRecords), - answerabilityLevel: group === "current" && summary.opportunityCount > 0 ? "partial" : "inventory_only", - sampleAssessment: sampleAssessmentFor(group, summary.opportunityCount, summary.observedPatternCount, currentPackageVersion), - facts: summary.facts, - } satisfies VersionBucketFacts]; - })) as Record>; -} - function buildEvictionVersionFacts(capacityEvents: EvidenceEventV1[], generatedAt: string): EvictionVersionFacts { const recentCapacityEvents = capacityEvents.filter(event => isWithinDaysOf(event.createdAt, generatedAt, RECENT_EVICTION_DAYS)); const capacityEventsWithSnapshot = capacityEvents.filter(hasCapacitySnapshot); @@ -808,145 +748,6 @@ function isReviewableRejectionCandidate(record: NormalizedRejection): boolean { return label === "architecture_like_rejected_candidate" || label === "ambiguous_rejected_candidate"; } -function producerVersionCounts(records: ProducerBearingRecord[]): Record { - const counts: Record = {}; - for (const record of records) { - if (!hasKnownProducerVersion(record)) continue; - const version = String(record.producerVersion).trim(); - counts[version] = (counts[version] ?? 0) + 1; - } - return counts; -} - -function versionGroupLabel(group: ProducerVersionGroup, currentPackageVersion: string): string { - if (group === "current") return `current version ${currentPackageVersion}`; - if (group === "previous") return "previous versions"; - return "unknown/unversioned"; -} - -function sampleAssessmentFor( - group: ProducerVersionGroup, - opportunityCount: number, - observedPatternCount: number, - currentPackageVersion: string, -): VersionSampleAssessment { - if (observedPatternCount > 0) return "observed"; - if (group === "current" && (!isAssessableCurrentPackageVersion(currentPackageVersion) || opportunityCount === 0)) return "no_current_version_opportunities"; - if (opportunityCount < VERSION_ANALYSIS_SAMPLE_THRESHOLD) return "not_observed_but_sample_small"; - return "not_observed_with_sufficient_sample"; -} - -function isAssessableCurrentPackageVersion(currentPackageVersion: string): boolean { - const trimmed = currentPackageVersion.trim(); - return trimmed.length > 0 && trimmed !== "unknown"; -} - -function computeVersionedInference( - mechanism: Omit, "inference">, - text: { observedPattern: string; patternName: string }, -): VersionedMechanismInference { - const current = mechanism.buckets.current; - const previous = mechanism.buckets.previous; - const currentFact = `Current version: ${current.observedPatternCount} ${text.observedPattern} in ${current.opportunityCount} ${mechanism.opportunityName}.`; - const previousFact = `Previous versions: ${previous.observedPatternCount} ${text.observedPattern} in ${previous.opportunityCount} ${mechanism.opportunityName}.`; - const unknownUnversioned = mechanism.buckets.unknown_unversioned; - if (!isAssessableCurrentPackageVersion(mechanism.currentPackageVersion) || current.opportunityCount === 0) { - return inference("no_current_version_opportunities", "Current package version is unknown or has no events; cannot assess recurrence."); - } - if (current.observedPatternCount > 0 && previous.observedPatternCount === 0 && unknownUnversioned.observedPatternCount === 0) { - return inference("no_previous_pattern_observed", `${currentFact} No previous pattern observed — this is a new pattern, not a recurrence.`); - } - if (current.observedPatternCount > 0) { - if (previous.observedPatternCount > 0) { - return inference("pattern_persists_across_versions", `${currentFact} ${previousFact} Current recurrence detected — ${text.patternName} observed in current version. Pattern persists across versions.`); - } - // Current has signal, previous has none, but unknown/unversioned has signal - return inference("current_recurrence_detected", `${currentFact} No known previous-version pattern observed, but unknown/unversioned evidence shows ${unknownUnversioned.observedPatternCount} ${text.observedPattern}. Pattern may persist — version grouping cannot confirm or deny.`); - } - if (current.opportunityCount < mechanism.sampleThreshold) { - return inference("no_current_evidence_sample_small", `${currentFact} ${previousFact} No current evidence observed, but current-version opportunity count is ${current.opportunityCount} (<${mechanism.sampleThreshold}); do not infer absence.`); - } - return inference("no_current_evidence_observed", `${currentFact} ${previousFact} No recurrence observed with sufficient current-version sample.`); -} - -function inference(status: VersionedMechanismInference["status"], message: string): VersionedMechanismInference { - return { status, message, caveat: VERSION_GROUPING_CAVEAT }; -} - -function hasProducerFields(record: Pick | Pick): boolean { - return typeof record.producerName === "string" - && record.producerName.length > 0 - && typeof record.producerVersion === "string" - && record.producerVersion.length > 0 - && typeof record.instrumentationVersion === "number"; -} - -type ProducerBearingRecord = Pick; - -export function hasKnownProducerVersion(record: ProducerBearingRecord): boolean { - if (typeof record.producerVersion !== "string") return false; - const producerVersion = record.producerVersion.trim(); - return producerVersion.length > 0 && producerVersion !== "unknown"; -} - -export function producerVersionGroupFor(record: ProducerBearingRecord, currentPackageVersion: string): ProducerVersionGroup { - if (!hasKnownProducerVersion(record)) return "unknown_unversioned"; - const producerVersion = String(record.producerVersion).trim(); - const currentVersion = currentPackageVersion.trim(); - if (currentVersion.length > 0 && currentVersion !== "unknown" && producerVersion === currentVersion) return "current"; - return "previous"; -} - -function buildVersionAvailability(records: ProducerBearingRecord[]): VersionAvailability { - const availability: VersionAvailability = { - noProducerFields: 0, - unknownProducerVersion: 0, - emptyProducerVersion: 0, - knownProducerVersion: 0, - }; - for (const record of records) { - const hasAnyProducerField = typeof record.producerName === "string" - || typeof record.producerVersion === "string" - || typeof record.instrumentationVersion === "number"; - if (!hasAnyProducerField) { - availability.noProducerFields += 1; - continue; - } - if (typeof record.producerVersion !== "string" || record.producerVersion.trim().length === 0) { - availability.emptyProducerVersion += 1; - continue; - } - if (record.producerVersion.trim() === "unknown") { - availability.unknownProducerVersion += 1; - continue; - } - availability.knownProducerVersion += 1; - } - return availability; -} - -function buildVersionCoverage(events: EvidenceEventV1[], rejections: NormalizedRejection[], currentPackageVersion: string): VersionCoverage { - const coverage: VersionCoverage = { - totalEvents: events.length + rejections.length, - currentVersionEvents: 0, - previousVersionEvents: 0, - unknownVersionEvents: 0, - coveragePercent: 0, - isTransitional: true, - }; - for (const record of [...events, ...rejections]) { - const group = producerVersionGroupFor(record, currentPackageVersion); - if (group === "current") coverage.currentVersionEvents += 1; - if (group === "previous") coverage.previousVersionEvents += 1; - if (group === "unknown_unversioned") coverage.unknownVersionEvents += 1; - } - coverage.coveragePercent = coverage.totalEvents === 0 - ? 0 - : Math.round(((coverage.currentVersionEvents + coverage.previousVersionEvents) / coverage.totalEvents) * 1000) / 10; - coverage.isTransitional = coverage.coveragePercent < 50; - return coverage; -} - function typeCountsFor(entries: LongTermMemoryEntry[]): Record { return Object.fromEntries(TYPES.map(type => [type, entries.filter(entry => entry.type === type).length])); } diff --git a/scripts/memory-diag/quality-versioning.ts b/scripts/memory-diag/quality-versioning.ts new file mode 100644 index 0000000..595c5f1 --- /dev/null +++ b/scripts/memory-diag/quality-versioning.ts @@ -0,0 +1,236 @@ +export type AnswerabilityLevel = "supported" | "partial" | "inventory_only" | "not_instrumented"; + +export type ProducerVersionGroup = "current" | "previous" | "unknown_unversioned"; + +export type ProducerBearingRecord = { + producerName?: string; + producerVersion?: string; + instrumentationVersion?: number; +}; + +export type VersionSampleAssessment = + | "observed" + | "not_observed_but_sample_small" + | "not_observed_with_sufficient_sample" + | "no_current_version_opportunities"; + +export type VersionAvailability = { + noProducerFields: number; + unknownProducerVersion: number; + emptyProducerVersion: number; + knownProducerVersion: number; +}; + +export type VersionCoverage = { + totalEvents: number; + currentVersionEvents: number; + previousVersionEvents: number; + unknownVersionEvents: number; + coveragePercent: number; + isTransitional: boolean; +}; + +export const VERSION_ANALYSIS_SAMPLE_THRESHOLD = 5; +export const VERSION_GROUPS: ProducerVersionGroup[] = ["current", "previous", "unknown_unversioned"]; +export const VERSION_GROUPING_CAVEAT = "Version grouping is based only on producerVersion strings in evidence" as const; + +export type VersionedMechanismInference = { + status: + | "current_recurrence_detected" + | "pattern_persists_across_versions" + | "no_current_evidence_observed" + | "no_current_evidence_sample_small" + | "no_current_version_opportunities" + | "no_previous_pattern_observed"; + message: string; + caveat: typeof VERSION_GROUPING_CAVEAT; +}; + +export type VersionBucketFacts = { + group: ProducerVersionGroup; + label: string; + opportunityCount: number; + observedPatternCount: number; + producerVersions: Record; + versionAvailability: VersionAvailability; + answerabilityLevel: AnswerabilityLevel; + sampleAssessment: VersionSampleAssessment; + facts: TFacts; +}; + +export type VersionedMechanismDiagnosticQuestion = { + mechanism: "reinforcement_rule"; + group: ProducerVersionGroup; + question: string; + evidence: string[]; +}; + +export type VersionedMechanismFacts = { + currentPackageVersion: string; + opportunityName: string; + sampleThreshold: number; + buckets: Record>; + inference: VersionedMechanismInference; + diagnosticQuestions?: VersionedMechanismDiagnosticQuestion[]; +}; + +export function buildVersionBuckets( + records: TRecord[], + currentPackageVersion: string, + summarize: (records: TRecord[]) => { facts: TFacts; opportunityCount: number; observedPatternCount: number }, +): Record> { + const grouped = Object.fromEntries(VERSION_GROUPS.map(group => [group, []])) as Record; + for (const record of records) grouped[producerVersionGroupFor(record, currentPackageVersion)].push(record); + return Object.fromEntries(VERSION_GROUPS.map(group => { + const bucketRecords = grouped[group]; + const summary = summarize(bucketRecords); + return [group, { + group, + label: versionGroupLabel(group, currentPackageVersion), + opportunityCount: summary.opportunityCount, + observedPatternCount: summary.observedPatternCount, + producerVersions: producerVersionCounts(bucketRecords), + versionAvailability: buildVersionAvailability(bucketRecords), + answerabilityLevel: group === "current" && summary.opportunityCount > 0 ? "partial" : "inventory_only", + sampleAssessment: sampleAssessmentFor(group, summary.opportunityCount, summary.observedPatternCount, currentPackageVersion), + facts: summary.facts, + } satisfies VersionBucketFacts]; + })) as Record>; +} + +export function computeVersionedInference( + mechanism: Omit, "inference">, + text: { observedPattern: string; patternName: string }, +): VersionedMechanismInference { + const current = mechanism.buckets.current; + const previous = mechanism.buckets.previous; + const currentFact = `Current version: ${current.observedPatternCount} ${text.observedPattern} in ${current.opportunityCount} ${mechanism.opportunityName}.`; + const previousFact = `Previous versions: ${previous.observedPatternCount} ${text.observedPattern} in ${previous.opportunityCount} ${mechanism.opportunityName}.`; + const unknownUnversioned = mechanism.buckets.unknown_unversioned; + if (!isAssessableCurrentPackageVersion(mechanism.currentPackageVersion) || current.opportunityCount === 0) { + return inference("no_current_version_opportunities", "Current package version is unknown or has no events; cannot assess recurrence."); + } + if (current.observedPatternCount > 0 && previous.observedPatternCount === 0 && unknownUnversioned.observedPatternCount === 0) { + return inference("no_previous_pattern_observed", `${currentFact} No previous pattern observed — this is a new pattern, not a recurrence.`); + } + if (current.observedPatternCount > 0) { + if (previous.observedPatternCount > 0) { + return inference("pattern_persists_across_versions", `${currentFact} ${previousFact} Current recurrence detected — ${text.patternName} observed in current version. Pattern persists across versions.`); + } + // Current has signal, previous has none, but unknown/unversioned has signal + return inference("current_recurrence_detected", `${currentFact} No known previous-version pattern observed, but unknown/unversioned evidence shows ${unknownUnversioned.observedPatternCount} ${text.observedPattern}. Pattern may persist — version grouping cannot confirm or deny.`); + } + if (current.opportunityCount < mechanism.sampleThreshold) { + return inference("no_current_evidence_sample_small", `${currentFact} ${previousFact} No current evidence observed, but current-version opportunity count is ${current.opportunityCount} (<${mechanism.sampleThreshold}); do not infer absence.`); + } + return inference("no_current_evidence_observed", `${currentFact} ${previousFact} No recurrence observed with sufficient current-version sample.`); +} + +export function hasProducerFields(record: ProducerBearingRecord): boolean { + return typeof record.producerName === "string" + && record.producerName.length > 0 + && typeof record.producerVersion === "string" + && record.producerVersion.length > 0 + && typeof record.instrumentationVersion === "number"; +} + +export function hasKnownProducerVersion(record: ProducerBearingRecord): boolean { + if (typeof record.producerVersion !== "string") return false; + const producerVersion = record.producerVersion.trim(); + return producerVersion.length > 0 && producerVersion !== "unknown"; +} + +export function producerVersionGroupFor(record: ProducerBearingRecord, currentPackageVersion: string): ProducerVersionGroup { + if (!hasKnownProducerVersion(record)) return "unknown_unversioned"; + const producerVersion = String(record.producerVersion).trim(); + const currentVersion = currentPackageVersion.trim(); + if (currentVersion.length > 0 && currentVersion !== "unknown" && producerVersion === currentVersion) return "current"; + return "previous"; +} + +export function buildVersionCoverage(records: ProducerBearingRecord[], currentPackageVersion: string): VersionCoverage { + const coverage: VersionCoverage = { + totalEvents: records.length, + currentVersionEvents: 0, + previousVersionEvents: 0, + unknownVersionEvents: 0, + coveragePercent: 0, + isTransitional: true, + }; + for (const record of records) { + const group = producerVersionGroupFor(record, currentPackageVersion); + if (group === "current") coverage.currentVersionEvents += 1; + if (group === "previous") coverage.previousVersionEvents += 1; + if (group === "unknown_unversioned") coverage.unknownVersionEvents += 1; + } + coverage.coveragePercent = coverage.totalEvents === 0 + ? 0 + : Math.round(((coverage.currentVersionEvents + coverage.previousVersionEvents) / coverage.totalEvents) * 1000) / 10; + coverage.isTransitional = coverage.coveragePercent < 50; + return coverage; +} + +function producerVersionCounts(records: ProducerBearingRecord[]): Record { + const counts: Record = {}; + for (const record of records) { + if (!hasKnownProducerVersion(record)) continue; + const version = String(record.producerVersion).trim(); + counts[version] = (counts[version] ?? 0) + 1; + } + return counts; +} + +function versionGroupLabel(group: ProducerVersionGroup, currentPackageVersion: string): string { + if (group === "current") return `current version ${currentPackageVersion}`; + if (group === "previous") return "previous versions"; + return "unknown/unversioned"; +} + +function sampleAssessmentFor( + group: ProducerVersionGroup, + opportunityCount: number, + observedPatternCount: number, + currentPackageVersion: string, +): VersionSampleAssessment { + if (observedPatternCount > 0) return "observed"; + if (group === "current" && (!isAssessableCurrentPackageVersion(currentPackageVersion) || opportunityCount === 0)) return "no_current_version_opportunities"; + if (opportunityCount < VERSION_ANALYSIS_SAMPLE_THRESHOLD) return "not_observed_but_sample_small"; + return "not_observed_with_sufficient_sample"; +} + +function isAssessableCurrentPackageVersion(currentPackageVersion: string): boolean { + const trimmed = currentPackageVersion.trim(); + return trimmed.length > 0 && trimmed !== "unknown"; +} + +function inference(status: VersionedMechanismInference["status"], message: string): VersionedMechanismInference { + return { status, message, caveat: VERSION_GROUPING_CAVEAT }; +} + +function buildVersionAvailability(records: ProducerBearingRecord[]): VersionAvailability { + const availability: VersionAvailability = { + noProducerFields: 0, + unknownProducerVersion: 0, + emptyProducerVersion: 0, + knownProducerVersion: 0, + }; + for (const record of records) { + const hasAnyProducerField = typeof record.producerName === "string" + || typeof record.producerVersion === "string" + || typeof record.instrumentationVersion === "number"; + if (!hasAnyProducerField) { + availability.noProducerFields += 1; + continue; + } + if (typeof record.producerVersion !== "string" || record.producerVersion.trim().length === 0) { + availability.emptyProducerVersion += 1; + continue; + } + if (record.producerVersion.trim() === "unknown") { + availability.unknownProducerVersion += 1; + continue; + } + availability.knownProducerVersion += 1; + } + return availability; +} diff --git a/src/evidence-log.ts b/src/evidence-log.ts index e39ea73..c991565 100644 --- a/src/evidence-log.ts +++ b/src/evidence-log.ts @@ -284,6 +284,9 @@ function buildEvidenceEvent( } async function safeAppendEvidenceLine(path: string, line: string): Promise { + // Evidence logs are JSONL append streams, not JSON store read-modify-write + // documents. Appends intentionally use appendFile so independent evidence + // writers do not need to share the JSON store lock path. try { await mkdir(dirname(path), { recursive: true }); await appendFile(path, `${line}\n`, "utf8"); @@ -294,6 +297,9 @@ async function safeAppendEvidenceLine(path: string, line: string): Promise } async function maybePruneEvidenceLog(path: string): Promise { + // Bounded pruning is a separate best-effort compaction of the append-only log. + // It rewrites the JSONL file only at configured append intervals and never + // routes through updateJSON because evidence is not a single JSON document. const nextCount = (appendCounts.get(path) ?? 0) + 1; appendCounts.set(path, nextCount); if (nextCount % EVIDENCE_LOG_LIMITS.pruneEveryAppendCount !== 0) return; diff --git a/src/extractors.ts b/src/extractors.ts index 67f6b7a..8f305ad 100644 --- a/src/extractors.ts +++ b/src/extractors.ts @@ -397,18 +397,6 @@ function evaluateWorkspaceMemoryCandidate( return { accepted: true, reasons: ["quality_gate_passed"] }; } -function shouldAcceptWorkspaceMemoryCandidate( - entry: { - type: LongTermType; - text: string; - }, - options: { - fromMemoryTrigger?: boolean; - } & WorkspaceMemoryCandidateParseOptions = {}, -): boolean { - return evaluateWorkspaceMemoryCandidate(entry, options).accepted; -} - function commandAttemptReason(line: string): string { const normalized = line.replace(/^\s*-\s*/, "").trim(); const reinforceMatch = normalized.match(/^REINFORCE\s+(.+)$/i); diff --git a/src/memory-kind-policy.ts b/src/memory-kind-policy.ts new file mode 100644 index 0000000..a28db9f --- /dev/null +++ b/src/memory-kind-policy.ts @@ -0,0 +1,14 @@ +import type { LongTermType } from "./types.ts"; + +// Current workspace-memory display/render order. This is intentionally a narrow +// shared constant, not a broader memory-kind policy registry. +export const MEMORY_TYPE_ORDER = ["feedback", "project", "decision", "reference"] as const satisfies readonly LongTermType[]; + +export function emptyMemoryTypeGroups(): Record { + return { + feedback: [], + project: [], + decision: [], + reference: [], + }; +} diff --git a/src/memory-visibility.ts b/src/memory-visibility.ts index e277ebe..85d8303 100644 --- a/src/memory-visibility.ts +++ b/src/memory-visibility.ts @@ -6,6 +6,7 @@ import { redactCredentials } from "./redaction.ts"; import type { LongTermMemoryEntry, PendingMemoryJournalStore, SessionState, WorkspaceMemoryStore } from "./types.ts"; import { LONG_TERM_LIMITS } from "./types.ts"; import { accountWorkspaceMemoryCompactionRefs, accountWorkspaceMemoryRender } from "./workspace-memory.ts"; +import { MEMORY_TYPE_ORDER, emptyMemoryTypeGroups } from "./memory-kind-policy.ts"; export type MemoryVisibilityCommand = "status" | "list" | "help"; @@ -33,7 +34,6 @@ export type MemoryListModel = { }; const MAX_PREVIEW_CHARS = 120; -const MEMORY_TYPE_ORDER = ["feedback", "project", "decision", "reference"] as const satisfies readonly LongTermMemoryEntry["type"][]; function safePreview(text: string | undefined, maxChars = MAX_PREVIEW_CHARS): string { const clean = redactCredentials(text ?? "").replace(/\s+/g, " ").trim(); @@ -211,7 +211,7 @@ export function formatMemoryStatus(model: MemoryStatusModel): string { } function emptyMemoryListGroups(): MemoryListModel["groups"] { - return { feedback: [], project: [], decision: [], reference: [] }; + return emptyMemoryTypeGroups(); } export async function getMemoryList(root: string): Promise { diff --git a/src/plugin.ts b/src/plugin.ts index 2c7d836..bbea75e 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -37,7 +37,6 @@ import { import { assessMemoryQuality } from "./memory-quality.ts"; import { loadWorkspaceMemory, - updateWorkspaceMemory, updateWorkspaceMemoryWithAccounting, accountWorkspaceMemoryRender, accountWorkspaceMemoryCompactionRefs, diff --git a/src/retention.ts b/src/retention.ts index e373c8d..ba86c65 100644 --- a/src/retention.ts +++ b/src/retention.ts @@ -44,7 +44,8 @@ export const REINFORCEMENT_HALFLIFE_FACTOR = 0.85; export const REINFORCEMENT_MAX_COUNT = 6; export const DAY_MS = 24 * 60 * 60 * 1000; export const REINFORCEMENT_MIN_ELAPSED_MS = 7 * DAY_MS; -export const REINFORCEMENT_MIN_INTERVAL_MS = 60 * 60 * 1000; // Deprecated compatibility constant; new policy uses REINFORCEMENT_MIN_ELAPSED_MS. +/** @deprecated Compatibility constant; new policy uses REINFORCEMENT_MIN_ELAPSED_MS. */ +export const REINFORCEMENT_MIN_INTERVAL_MS = 60 * 60 * 1000; export const WORKSPACE_DORMANT_AFTER_DAYS = 14; export const DORMANT_DECAY_MULTIPLIER = 0.25; diff --git a/src/storage.ts b/src/storage.ts index b1e8cc9..c041cea 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -164,6 +164,9 @@ async function withFileLock(path: string, fn: () => Promise): Promise { } export async function atomicWriteJSON(path: string, data: unknown): Promise { + // Full-state overwrite primitive: callers must already own the complete next + // JSON document. Do not use this for read-modify-write updates that must + // preserve concurrent changes; use updateJSON for that contract instead. await mkdir(dirname(path), { recursive: true }); const tmp = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`; await writeFile(tmp, JSON.stringify(data, null, 2), { encoding: "utf8", mode: 0o600 }); @@ -175,6 +178,9 @@ export async function updateJSON( fallback: () => T, updater: (current: T) => T | Promise, ): Promise { + // Locked read-modify-write path: serializes in-process callers and uses a + // filesystem lock for cross-process callers before reading, updating, and + // atomically replacing the JSON document. const previous = fileLocks.get(path) ?? Promise.resolve(); let release: () => void = () => {}; const currentLock = new Promise(resolve => { diff --git a/src/tui-plugin.ts b/src/tui-plugin.ts index e47c4de..efefe79 100644 --- a/src/tui-plugin.ts +++ b/src/tui-plugin.ts @@ -5,6 +5,7 @@ import { renderMemoryCommand, type MemoryVisibilityCommand, } from "./memory-visibility.ts"; +import { MEMORY_TYPE_ORDER } from "./memory-kind-policy.ts"; type DialogContext = { clear?: () => void; @@ -245,8 +246,6 @@ function showMemoryHelp(api: TuiPluginApi): void { showAlertFromMarkdown(api, formatMemoryHelp(), "Memory help", "medium"); } -const MEMORY_TYPE_ORDER = ["feedback", "project", "decision", "reference"] as const; - async function showMemoryList(api: TuiPluginApi): Promise { const dialogApi = getDialogApi(api); if (!dialogApi) return; diff --git a/src/workspace-memory.ts b/src/workspace-memory.ts index ca5139e..d214c14 100644 --- a/src/workspace-memory.ts +++ b/src/workspace-memory.ts @@ -15,6 +15,7 @@ import { } from "./retention.ts"; import type { EvidenceEventInput, MemoryEvidenceRef } from "./evidence-log.ts"; import { appendEvidenceEvents } from "./evidence-log.ts"; +import { MEMORY_TYPE_ORDER } from "./memory-kind-policy.ts"; // Minimum length for workspace_memory envelope: \n...\n const MIN_ENVELOPE_LENGTH = 80; @@ -933,15 +934,6 @@ function compareLongTermMemoryForRetention( return a.id.localeCompare(b.id); } -function wouldFit( - lines: string[], - nextLine: string, - closingLine: string, - maxChars: number -): boolean { - return [...lines, nextLine, closingLine].join("\n").length <= maxChars; -} - export function renderWorkspaceMemory(store: WorkspaceMemoryStore): string { return accountWorkspaceMemoryRender(store).prompt; } @@ -993,7 +985,7 @@ export function accountWorkspaceMemoryRender(store: WorkspaceMemoryStore): Works ]; const rendered: LongTermMemoryEntry[] = []; - for (const type of ["feedback", "project", "decision", "reference"] as const) { + for (const type of MEMORY_TYPE_ORDER) { const items = active.filter(entry => entry.type === type); if (items.length === 0) continue; @@ -1037,7 +1029,7 @@ export function accountWorkspaceMemoryCompactionRefs(store: WorkspaceMemoryStore const refs: CompactionMemoryRef[] = []; const capturedAt = Date.now(); - for (const type of ["feedback", "project", "decision", "reference"] as const) { + for (const type of MEMORY_TYPE_ORDER) { const items = active.filter(entry => entry.type === type); if (items.length === 0) continue; diff --git a/tests/evidence-log.test.ts b/tests/evidence-log.test.ts index 188c7b7..843dbee 100644 --- a/tests/evidence-log.test.ts +++ b/tests/evidence-log.test.ts @@ -109,6 +109,30 @@ test("appendEvidenceEvent redacts text previews before writing", async () => { } }); +test("concurrent evidence appends preserve independent JSONL records", async () => { + const root = await tempRoot(); + try { + const count = 40; + + await Promise.all(Array.from({ length: count }, (_, index) => + appendEvidenceEvent(root, eventInput({ memory: { memoryId: `concurrent-${index}` } })) + )); + + const raw = await readLog(root); + const lines = raw.trim().split("\n"); + const events = await queryEvidenceEvents(root); + const memoryIds = new Set(events.map(event => event.memory?.memoryId)); + + assert.equal(lines.length, count); + assert.equal(events.length, count); + for (let index = 0; index < count; index += 1) { + assert.equal(memoryIds.has(`concurrent-${index}`), true); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test("queryEvidenceEvents filters by type outcome and memory id", async () => { const root = await tempRoot(); try { diff --git a/tests/memory-visibility.test.ts b/tests/memory-visibility.test.ts index 6d67bf5..66c3f25 100644 --- a/tests/memory-visibility.test.ts +++ b/tests/memory-visibility.test.ts @@ -5,6 +5,7 @@ import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; import { appendPendingMemories } from "../src/pending-journal.ts"; import { saveSessionState } from "../src/session-state.ts"; +import { MEMORY_TYPE_ORDER } from "../src/memory-kind-policy.ts"; import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "../src/types.ts"; import { workspaceMemoryPath } from "../src/paths.ts"; import { saveWorkspaceMemory } from "../src/workspace-memory.ts"; @@ -154,6 +155,9 @@ test("formats current workspace memories grouped by type with display-local refs assert.match(output, /project:\n- \[M\d+\]/); assert.match(output, /decision:\n- \[M\d+\]/); assert.match(output, /reference:\n- \[M\d+\]/); + const groupIndexes = MEMORY_TYPE_ORDER.map(type => output.indexOf(`${type}:`)); + assert.equal(groupIndexes.every(index => index >= 0), true, "all memory type groups should render"); + assert.deepEqual(groupIndexes, [...groupIndexes].sort((a, b) => a - b), "memory list groups should follow shared memory type order"); assert.match(output, /Shown: \d+ of \d+ active memories\./); assert.match(output, /Shown: 4 of 4 active memories\./); assert.match(output, /Omitted active memories: 0\./); diff --git a/tests/package-integrity.test.ts b/tests/package-integrity.test.ts new file mode 100644 index 0000000..28f6c4a --- /dev/null +++ b/tests/package-integrity.test.ts @@ -0,0 +1,38 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + formatPackageVersionMismatch, + packageLockReadErrorMessage, + packageVersionMismatches, +} from "../scripts/dev/check-package-integrity.ts"; + +test("package integrity accepts matching package and lockfile versions", () => { + const mismatches = packageVersionMismatches( + { version: "1.6.4" }, + { version: "1.6.4", packages: { "": { version: "1.6.4" } } }, + ); + + assert.deepEqual(mismatches, []); +}); + +test("package integrity reports both lockfile version mismatches", () => { + const mismatches = packageVersionMismatches( + { version: "1.6.4" }, + { version: "1.6.3", packages: { "": { version: "1.6.2" } } }, + ); + + assert.deepEqual( + mismatches.map(formatPackageVersionMismatch), + [ + "package-lock.json version (1.6.3) does not match package.json version (1.6.4)", + "package-lock.json packages[\"\"].version (1.6.2) does not match package.json version (1.6.4)", + ], + ); +}); + +test("package integrity explains missing package-lock.json", () => { + assert.equal( + packageLockReadErrorMessage(Object.assign(new Error("missing"), { code: "ENOENT" })), + "package-lock.json not found; run npm install first", + ); +}); diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts index 190247d..8a9e4f9 100644 --- a/tests/plugin.test.ts +++ b/tests/plugin.test.ts @@ -569,6 +569,8 @@ Next steps: continue development. assert.equal(candidates[1].type, "project"); }); +// Compatibility-contract characterization: legacy compaction parser formats are +// still supported intentionally and should not be removed as brittle fixtures. test("parseWorkspaceMemoryCandidates accepts legacy Workspace Memory Candidates section", async () => { const summary = ` ## Summary diff --git a/tests/storage.test.ts b/tests/storage.test.ts index 8ba4a46..f71cbf9 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -1,11 +1,11 @@ import test from "node:test"; import assert from "node:assert/strict"; import { existsSync } from "node:fs"; -import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; import { spawn } from "node:child_process"; -import { readJSON, updateJSON } from "../src/storage.ts"; +import { atomicWriteJSON, readJSON, updateJSON } from "../src/storage.ts"; import { queryEvidenceEvents } from "../src/evidence-log.ts"; import { workspaceMemoryPath } from "../src/paths.ts"; @@ -24,6 +24,23 @@ test("updateJSON serializes concurrent increments", async () => { } }); +test("atomicWriteJSON is a full-state overwrite primitive", async () => { + const root = await mkdtemp(join(tmpdir(), "wm-storage-atomic-overwrite-")); + try { + const path = join(root, "store.json"); + + await atomicWriteJSON(path, { retained: true, removed: true }); + await atomicWriteJSON(path, { retained: true }); + + const raw = await readFile(path, "utf8"); + assert.deepEqual(JSON.parse(raw), { retained: true }); + assert.equal(raw.includes("removed"), false, "atomic overwrite should not merge with previous state"); + assert.equal(existsSync(`${path}.lock`), false, "atomic overwrite should not create the RMW lock file"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test("updateJSON does not replace corrupt JSON with fallback", async () => { const root = await mkdtemp(join(tmpdir(), "wm-storage-corrupt-")); try { diff --git a/tests/workspace-memory.test.ts b/tests/workspace-memory.test.ts index c4bd86e..c5c450e 100644 --- a/tests/workspace-memory.test.ts +++ b/tests/workspace-memory.test.ts @@ -46,6 +46,8 @@ test("default prompt budgets use calibrated conservative character caps", () => }); test("retention type caps use v1.6 decision headroom without changing other caps", () => { + // Policy-contract characterization: these caps are intentionally brittle so + // retention policy changes must update the expected values deliberately. assert.equal(RETENTION_TYPE_MAX.feedback, 10); assert.equal(RETENTION_TYPE_MAX.decision, 12); assert.equal(RETENTION_TYPE_MAX.project, 8); @@ -179,6 +181,41 @@ test("renderWorkspaceMemory returns empty for no entries", () => { assert.equal(rendered, ""); }); +test("renderWorkspaceMemory groups active entries in current prompt order", () => { + // Wave 2 characterization: lock the externally visible prompt grouping before + // any future memory-kind policy extraction or render-order refactor. + const now = "2026-05-15T12:00:00.000Z"; + const store: WorkspaceMemoryStore = { + version: 1, + workspace: { root: "/repo", key: "abc" }, + limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries }, + entries: [ + { ...entry("mem-reference", "Docs live under docs/.", "reference"), createdAt: now, updatedAt: now }, + { ...entry("mem-decision", "Keep health waves behavior-preserving.", "decision"), createdAt: now, updatedAt: now }, + { ...entry("mem-project", "This repository uses Node's built-in test runner.", "project"), createdAt: now, updatedAt: now }, + { ...entry("mem-feedback", "Prefer concise verification summaries.", "feedback"), createdAt: now, updatedAt: now }, + { ...entry("mem-superseded", "Superseded entries stay out of prompts.", "feedback"), createdAt: now, updatedAt: now, status: "superseded" as const }, + ], + updatedAt: now, + lastActivityAt: now, + }; + + const rendered = renderWorkspaceMemory(store); + + assert.equal(rendered, [ + "Workspace memory (cross-session, verify if stale):", + "feedback:", + "- Prefer concise verification summaries.", + "project:", + "- This repository uses Node's built-in test runner.", + "decision:", + "- Keep health waves behavior-preserving.", + "reference:", + "- Docs live under docs/.", + ].join("\n")); + assert.equal(rendered.includes("Superseded entries stay out of prompts."), false); +}); + test("accountWorkspaceMemoryCompactionRefs returns empty prompt and refs for no entries", () => { const store: WorkspaceMemoryStore = { version: 1, diff --git a/tsconfig.unused.json b/tsconfig.unused.json new file mode 100644 index 0000000..2b9cd7e --- /dev/null +++ b/tsconfig.unused.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noUnusedLocals": true, + "noUnusedParameters": true + } +}