@remnic/core 9.3.670 → 9.3.672
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/access-cli.js +22 -22
- package/dist/access-http.d.ts +4 -4
- package/dist/access-http.js +13 -13
- package/dist/access-mcp.d.ts +4 -4
- package/dist/access-mcp.js +12 -12
- package/dist/{access-service-BCuaiNHa.d.ts → access-service-S9oGKPZc.d.ts} +2 -2
- package/dist/access-service.d.ts +4 -4
- package/dist/access-service.js +11 -11
- package/dist/action-confidence.d.ts +1 -1
- package/dist/active-memory-bridge.d.ts +1 -1
- package/dist/active-recall.d.ts +1 -1
- package/dist/active-recall.js +1 -1
- package/dist/behavior-learner.d.ts +1 -1
- package/dist/behavior-signals.d.ts +1 -1
- package/dist/bootstrap.d.ts +3 -3
- package/dist/briefing.d.ts +1 -1
- package/dist/briefing.js +3 -3
- package/dist/buffer-surprise-report.d.ts +1 -1
- package/dist/buffer.d.ts +1 -1
- package/dist/calibration.d.ts +1 -1
- package/dist/capabilities.d.ts +1 -1
- package/dist/causal-behavior.d.ts +1 -1
- package/dist/causal-consolidation.d.ts +1 -1
- package/dist/causal-consolidation.js +4 -4
- package/dist/{chunk-UOBLE67F.js → chunk-3IE22DJ2.js} +4 -4
- package/dist/{chunk-BQJUPECT.js → chunk-3OKWZT7F.js} +2 -2
- package/dist/{chunk-XS2CWEHZ.js → chunk-4QZ7H6FN.js} +2 -2
- package/dist/chunk-52LZ42LI.js +25 -0
- package/dist/chunk-52LZ42LI.js.map +1 -0
- package/dist/{chunk-2MXEVL75.js → chunk-6VP3YUCS.js} +2 -2
- package/dist/{chunk-UVUTV7CM.js → chunk-7O5CFNN4.js} +8 -8
- package/dist/{chunk-WXXLSZHA.js → chunk-ATRB6Q25.js} +2 -2
- package/dist/{chunk-46WUVFOD.js → chunk-AYGT6VBC.js} +4 -4
- package/dist/{chunk-KOXGLQS7.js → chunk-B6IUW76R.js} +2 -2
- package/dist/{chunk-AARDBQTA.js → chunk-CTCPB57O.js} +2 -2
- package/dist/{chunk-Q2LQZYQ7.js → chunk-CXKETYZ7.js} +3 -3
- package/dist/{chunk-23EBQ27U.js → chunk-EJYFPRED.js} +2 -2
- package/dist/{chunk-WKMCC4NQ.js → chunk-EUM7CZFM.js} +2 -2
- package/dist/{chunk-AZBV4RRY.js → chunk-FMSDA2D3.js} +1 -1
- package/dist/chunk-FMSDA2D3.js.map +1 -0
- package/dist/{chunk-QHWJG5C5.js → chunk-FP4ISXI3.js} +5 -5
- package/dist/{chunk-SXYCVRLK.js → chunk-GSTYVG5L.js} +3 -3
- package/dist/{chunk-CRO4LCQ6.js → chunk-KJOYHNS7.js} +5 -5
- package/dist/{chunk-XMWF6AU3.js → chunk-LVTTO3VC.js} +2 -2
- package/dist/{chunk-TJ7HH5LB.js → chunk-LZSMQHXC.js} +2 -2
- package/dist/{chunk-PIA4C3AJ.js → chunk-M3WF2AB6.js} +14 -14
- package/dist/{chunk-CTAV55JM.js → chunk-MLVMBV2C.js} +75 -1
- package/dist/chunk-MLVMBV2C.js.map +1 -0
- package/dist/{chunk-QZ7ODIVL.js → chunk-MNUPGYIV.js} +2 -2
- package/dist/{chunk-MR4PJ277.js → chunk-MTJ2LFAJ.js} +2 -2
- package/dist/{chunk-2TCHDANJ.js → chunk-NXBXM7Q6.js} +2 -2
- package/dist/{chunk-TIJYQXDI.js → chunk-OHX52AOS.js} +2 -2
- package/dist/{chunk-PEPHBH2W.js → chunk-PYTATYUV.js} +2 -2
- package/dist/{chunk-JTPXSXHC.js → chunk-V4ZHKCGA.js} +2 -2
- package/dist/{chunk-OI4BXFSB.js → chunk-VL5JJOOY.js} +2 -2
- package/dist/{chunk-A5TEHAR4.js → chunk-ZCVPFDHB.js} +3 -3
- package/dist/{chunk-MPXYHC35.js → chunk-ZQJHKN7J.js} +20 -20
- package/dist/{chunk-4T7P2HLJ.js → chunk-ZUPFMHJA.js} +3 -3
- package/dist/{cli-C98xlwYA.d.ts → cli-B2Ve7R22.d.ts} +3 -3
- package/dist/cli.d.ts +5 -5
- package/dist/cli.js +26 -26
- package/dist/coding/optional-coding-graph.d.ts +63 -0
- package/dist/coding/optional-coding-graph.js +119 -0
- package/dist/coding/optional-coding-graph.js.map +1 -0
- package/dist/coding-graph-types-Dd2tGrnm.d.ts +106 -0
- package/dist/compounding/engine.d.ts +1 -1
- package/dist/compounding/engine.js +3 -3
- package/dist/compounding/preference-consolidator.d.ts +1 -1
- package/dist/compression-optimizer.d.ts +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.js +1 -1
- package/dist/connectors/codex-materialize-runner.d.ts +1 -1
- package/dist/connectors/codex-materialize-runner.js +3 -3
- package/dist/connectors/codex-materialize.d.ts +1 -1
- package/dist/connectors/index.d.ts +1 -1
- package/dist/connectors/index.js +3 -3
- package/dist/consolidation-provenance-check.d.ts +1 -1
- package/dist/consolidation-undo.d.ts +1 -1
- package/dist/contradiction/index.d.ts +1 -1
- package/dist/conversation-index/backend.d.ts +1 -1
- package/dist/conversation-index/backend.js +2 -2
- package/dist/conversation-index/chunker.d.ts +1 -1
- package/dist/conversation-index/faiss-adapter.d.ts +1 -1
- package/dist/conversation-index/indexer.d.ts +1 -1
- package/dist/conversation-index/search.d.ts +1 -1
- package/dist/day-summary.d.ts +1 -1
- package/dist/delinearize.d.ts +1 -1
- package/dist/direct-answer-wiring.d.ts +1 -1
- package/dist/direct-answer.d.ts +1 -1
- package/dist/embedding-fallback.d.ts +1 -1
- package/dist/enrichment/index.d.ts +1 -1
- package/dist/entity-retrieval.d.ts +1 -1
- package/dist/entity-retrieval.js +3 -3
- package/dist/entity-schema.d.ts +1 -1
- package/dist/explicit-capture.d.ts +3 -3
- package/dist/extraction-judge-telemetry.d.ts +1 -1
- package/dist/extraction-judge-training.d.ts +1 -1
- package/dist/extraction-judge.d.ts +1 -1
- package/dist/extraction.d.ts +1 -1
- package/dist/fallback-llm.d.ts +1 -1
- package/dist/identity-continuity.d.ts +1 -1
- package/dist/importance.d.ts +1 -1
- package/dist/index.d.ts +9 -8
- package/dist/index.js +39 -33
- package/dist/index.js.map +1 -1
- package/dist/intent.d.ts +1 -1
- package/dist/lcm/engine.d.ts +1 -1
- package/dist/lcm/engine.js +2 -2
- package/dist/lcm/index.d.ts +1 -1
- package/dist/lcm/index.js +2 -2
- package/dist/lcm/tools.d.ts +1 -1
- package/dist/lifecycle.d.ts +1 -1
- package/dist/live-connectors-runner.d.ts +1 -1
- package/dist/local-llm.d.ts +1 -1
- package/dist/maintenance/memory-governance.d.ts +1 -1
- package/dist/maintenance/memory-governance.js +3 -3
- package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +3 -3
- package/dist/maintenance/rebuild-memory-projection.js +4 -4
- package/dist/mcp-memory-inspector-app.d.ts +4 -4
- package/dist/memory-action-policy.d.ts +1 -1
- package/dist/memory-cache.d.ts +1 -1
- package/dist/memory-lifecycle-ledger-utils.d.ts +1 -1
- package/dist/memory-projection-store.d.ts +1 -1
- package/dist/memory-provenance.d.ts +1 -1
- package/dist/memory-worth-outcomes.d.ts +1 -1
- package/dist/models-json.d.ts +1 -1
- package/dist/namespaces/migrate.d.ts +1 -1
- package/dist/namespaces/migrate.js +8 -8
- package/dist/namespaces/principal.d.ts +1 -1
- package/dist/namespaces/search.d.ts +1 -1
- package/dist/namespaces/search.js +4 -4
- package/dist/namespaces/storage.d.ts +1 -1
- package/dist/namespaces/storage.js +3 -3
- package/dist/native-knowledge.d.ts +1 -1
- package/dist/operator-toolkit.d.ts +1 -1
- package/dist/operator-toolkit.js +11 -11
- package/dist/{orchestrator-DyP9QYsh.d.ts → orchestrator-7zPqGupX.d.ts} +2 -2
- package/dist/orchestrator.d.ts +3 -3
- package/dist/orchestrator.js +18 -18
- package/dist/patterns-cli.d.ts +1 -1
- package/dist/policy-runtime.d.ts +1 -1
- package/dist/qmd-recall-cache.d.ts +1 -1
- package/dist/qmd.d.ts +1 -1
- package/dist/recall-disclosure-escalation.d.ts +1 -1
- package/dist/recall-explain-renderer.d.ts +1 -1
- package/dist/recall-explain-renderer.js +3 -3
- package/dist/recall-planner-llm.d.ts +1 -1
- package/dist/recall-state.d.ts +1 -1
- package/dist/recall-tag-filter.d.ts +1 -1
- package/dist/recall-xray-cli.d.ts +1 -1
- package/dist/recall-xray-cli.js +4 -4
- package/dist/recall-xray-renderer.d.ts +1 -1
- package/dist/recall-xray-renderer.js +3 -3
- package/dist/recall-xray.d.ts +1 -1
- package/dist/recall-xray.js +2 -2
- package/dist/resolve-auth-token.d.ts +1 -1
- package/dist/resume-bundles.js +2 -2
- package/dist/retrieval-agents.d.ts +1 -1
- package/dist/retrieval-tiers.d.ts +1 -1
- package/dist/routing/engine.d.ts +1 -1
- package/dist/routing/store.d.ts +1 -1
- package/dist/schemas.d.ts +22 -22
- package/dist/search/embed-helper.d.ts +1 -1
- package/dist/search/factory.d.ts +1 -1
- package/dist/search/factory.js +3 -3
- package/dist/search/index.d.ts +1 -1
- package/dist/search/index.js +3 -3
- package/dist/search/lancedb-backend.d.ts +1 -1
- package/dist/search/meilisearch-backend.d.ts +1 -1
- package/dist/search/noop-backend.d.ts +1 -1
- package/dist/search/orama-backend.d.ts +1 -1
- package/dist/search/port.d.ts +1 -1
- package/dist/search/remote-backend.d.ts +1 -1
- package/dist/{semantic-consolidation-BICZvQ3C.d.ts → semantic-consolidation-BX9Z9_aK.d.ts} +1 -1
- package/dist/semantic-consolidation.d.ts +2 -2
- package/dist/semantic-consolidation.js +4 -4
- package/dist/semantic-rule-promotion.js +3 -3
- package/dist/semantic-rule-verifier.d.ts +1 -1
- package/dist/semantic-rule-verifier.js +3 -3
- package/dist/session-observer-bands.d.ts +1 -1
- package/dist/session-observer-state.d.ts +1 -1
- package/dist/shared-context/manager.d.ts +1 -1
- package/dist/signal.d.ts +1 -1
- package/dist/storage.d.ts +1 -1
- package/dist/storage.js +2 -2
- package/dist/summarizer.d.ts +1 -1
- package/dist/summary-snapshot.d.ts +1 -1
- package/dist/temporal-supersession.d.ts +1 -1
- package/dist/temporal-validity.d.ts +1 -1
- package/dist/threading.d.ts +1 -1
- package/dist/tier-migration.d.ts +1 -1
- package/dist/tier-routing.d.ts +1 -1
- package/dist/topics.d.ts +1 -1
- package/dist/transcript.d.ts +1 -1
- package/dist/transfer/types.d.ts +12 -12
- package/dist/{types-D96bCB3C.d.ts → types-D3pm4NhH.d.ts} +30 -1
- package/dist/types.d.ts +1 -1
- package/dist/types.js +1 -1
- package/dist/utility-runtime.d.ts +1 -1
- package/dist/verified-recall.js +3 -3
- package/package.json +19 -1
- package/src/coding/coding-graph-types.ts +180 -0
- package/src/coding/coding-knowledge-config.ts +111 -0
- package/src/coding/decision-records.test.ts +434 -0
- package/src/coding/decision-records.ts +507 -0
- package/src/coding/optional-coding-graph-cache.test.ts +86 -0
- package/src/coding/optional-coding-graph-cacheread.test.ts +78 -0
- package/src/coding/optional-coding-graph-concurrent.test.ts +48 -0
- package/src/coding/optional-coding-graph-incompatible.test.ts +98 -0
- package/src/coding/optional-coding-graph-probe.test.ts +34 -0
- package/src/coding/optional-coding-graph.test.ts +117 -0
- package/src/coding/optional-coding-graph.ts +370 -0
- package/src/config.test.ts +118 -0
- package/src/config.ts +3 -2
- package/src/index.ts +22 -0
- package/src/types.ts +35 -0
- package/dist/chunk-AZBV4RRY.js.map +0 -1
- package/dist/chunk-CTAV55JM.js.map +0 -1
- /package/dist/{chunk-UOBLE67F.js.map → chunk-3IE22DJ2.js.map} +0 -0
- /package/dist/{chunk-BQJUPECT.js.map → chunk-3OKWZT7F.js.map} +0 -0
- /package/dist/{chunk-XS2CWEHZ.js.map → chunk-4QZ7H6FN.js.map} +0 -0
- /package/dist/{chunk-2MXEVL75.js.map → chunk-6VP3YUCS.js.map} +0 -0
- /package/dist/{chunk-UVUTV7CM.js.map → chunk-7O5CFNN4.js.map} +0 -0
- /package/dist/{chunk-WXXLSZHA.js.map → chunk-ATRB6Q25.js.map} +0 -0
- /package/dist/{chunk-46WUVFOD.js.map → chunk-AYGT6VBC.js.map} +0 -0
- /package/dist/{chunk-KOXGLQS7.js.map → chunk-B6IUW76R.js.map} +0 -0
- /package/dist/{chunk-AARDBQTA.js.map → chunk-CTCPB57O.js.map} +0 -0
- /package/dist/{chunk-Q2LQZYQ7.js.map → chunk-CXKETYZ7.js.map} +0 -0
- /package/dist/{chunk-23EBQ27U.js.map → chunk-EJYFPRED.js.map} +0 -0
- /package/dist/{chunk-WKMCC4NQ.js.map → chunk-EUM7CZFM.js.map} +0 -0
- /package/dist/{chunk-QHWJG5C5.js.map → chunk-FP4ISXI3.js.map} +0 -0
- /package/dist/{chunk-SXYCVRLK.js.map → chunk-GSTYVG5L.js.map} +0 -0
- /package/dist/{chunk-CRO4LCQ6.js.map → chunk-KJOYHNS7.js.map} +0 -0
- /package/dist/{chunk-XMWF6AU3.js.map → chunk-LVTTO3VC.js.map} +0 -0
- /package/dist/{chunk-TJ7HH5LB.js.map → chunk-LZSMQHXC.js.map} +0 -0
- /package/dist/{chunk-PIA4C3AJ.js.map → chunk-M3WF2AB6.js.map} +0 -0
- /package/dist/{chunk-QZ7ODIVL.js.map → chunk-MNUPGYIV.js.map} +0 -0
- /package/dist/{chunk-MR4PJ277.js.map → chunk-MTJ2LFAJ.js.map} +0 -0
- /package/dist/{chunk-2TCHDANJ.js.map → chunk-NXBXM7Q6.js.map} +0 -0
- /package/dist/{chunk-TIJYQXDI.js.map → chunk-OHX52AOS.js.map} +0 -0
- /package/dist/{chunk-PEPHBH2W.js.map → chunk-PYTATYUV.js.map} +0 -0
- /package/dist/{chunk-JTPXSXHC.js.map → chunk-V4ZHKCGA.js.map} +0 -0
- /package/dist/{chunk-OI4BXFSB.js.map → chunk-VL5JJOOY.js.map} +0 -0
- /package/dist/{chunk-A5TEHAR4.js.map → chunk-ZCVPFDHB.js.map} +0 -0
- /package/dist/{chunk-MPXYHC35.js.map → chunk-ZQJHKN7J.js.map} +0 -0
- /package/dist/{chunk-4T7P2HLJ.js.map → chunk-ZUPFMHJA.js.map} +0 -0
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decision records — pure storage contract (issue #1548 Track A PR 1).
|
|
3
|
+
*
|
|
4
|
+
* The four-memory-shape memory layer stores architectural decisions as
|
|
5
|
+
* markdown files with YAML frontmatter under the coding namespace. This
|
|
6
|
+
* module is the **pure contract**: data shape, parse, serialise, validate,
|
|
7
|
+
* and the supersede mutation. No filesystem, no orchestrator — callers
|
|
8
|
+
* (PR 2's MCP/HTTP/CLI surfaces and the orchestrator's normal persist
|
|
9
|
+
* pipeline) hang these helpers onto real storage.
|
|
10
|
+
*
|
|
11
|
+
* Why markdown + frontmatter?
|
|
12
|
+
* - QMD searches the body for free (rule 43 — storage chokepoint means the
|
|
13
|
+
* orchestrator persist pipeline fires for catalog + reindex + dedup).
|
|
14
|
+
* - Human-reviewable diff via the same tooling as any other memory page.
|
|
15
|
+
* - The body carries prose; the frontmatter carries the searchable,
|
|
16
|
+
* structured fields.
|
|
17
|
+
*
|
|
18
|
+
* Why this parser/serialiser and not a YAML dep?
|
|
19
|
+
* - The surface used here is intentionally narrow: scalar strings and
|
|
20
|
+
* flow-style arrays of strings. A full YAML dependency is heavier than
|
|
21
|
+
* the parser we need and would unlock a footgun (block-scalar
|
|
22
|
+
* representations, anchors, multiple-document streams) we deliberately
|
|
23
|
+
* want to be impossible.
|
|
24
|
+
*
|
|
25
|
+
* Supersede ordering (rule 25): the replacement record MUST land on disk
|
|
26
|
+
* BEFORE the superseded record's `status` flips, so a process crash between
|
|
27
|
+
* the two writes leaves the new decision discoverable rather than nothing.
|
|
28
|
+
*
|
|
29
|
+
* Pure module — type-only import of the plugin config interface keeps this
|
|
30
|
+
* dependency-free (the entry types live in `../types.ts`).
|
|
31
|
+
*/
|
|
32
|
+
import type { CodingKnowledgeConfig } from "../types.js";
|
|
33
|
+
|
|
34
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
35
|
+
// Public types
|
|
36
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
export type DecisionStatus = "proposed" | "accepted" | "superseded" | "rejected";
|
|
39
|
+
|
|
40
|
+
export const DECISION_STATUSES = [
|
|
41
|
+
"proposed",
|
|
42
|
+
"accepted",
|
|
43
|
+
"superseded",
|
|
44
|
+
"rejected",
|
|
45
|
+
] as const satisfies readonly DecisionStatus[];
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The "active" statuses callers surface in standing-decisions lists, briefing
|
|
49
|
+
* text, and the search-by-default index. Exported as a frozen set so the
|
|
50
|
+
* decision-records tests, the briefing helper, and the future list surface
|
|
51
|
+
* share one declaration (rule 53 — single source of truth for classification).
|
|
52
|
+
*
|
|
53
|
+
* Stand-up note: superseded + rejected are intentionally excluded. A supersede
|
|
54
|
+
* edge (`b.supersedes = "a"`) tells callers what to fall back to.
|
|
55
|
+
*/
|
|
56
|
+
export const ACTIVE_DECISION_STATUSES: ReadonlySet<DecisionStatus> = new Set<DecisionStatus>([
|
|
57
|
+
"proposed",
|
|
58
|
+
"accepted",
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Least-privileged default for a parsed record whose frontmatter omits
|
|
63
|
+
* `status` (rule 48). "Proposed" — never "accepted" — because accepting a
|
|
64
|
+
* decision is a deliberate operator action.
|
|
65
|
+
*/
|
|
66
|
+
export const DEFAULT_DECISION_STATUS: DecisionStatus = "proposed";
|
|
67
|
+
|
|
68
|
+
export interface DecisionRecord {
|
|
69
|
+
/** Stable identifier — typically `ADR-XXXX` or `MADR-XXXX`. Must be unique. */
|
|
70
|
+
id: string;
|
|
71
|
+
/** One-line summary surfaced in briefings and `list` output. */
|
|
72
|
+
title: string;
|
|
73
|
+
/** Status lifecycle (rule 51: only the four declared values). */
|
|
74
|
+
status: DecisionStatus;
|
|
75
|
+
/** The problem / context the decision addresses. */
|
|
76
|
+
context: string;
|
|
77
|
+
/** The decision itself. */
|
|
78
|
+
decision: string;
|
|
79
|
+
/** Trade-offs, follow-ups, consequences. Optional — free-form. */
|
|
80
|
+
consequences: string | undefined;
|
|
81
|
+
/** Entity references (entity IDs, doc anchors, code paths). May be empty. */
|
|
82
|
+
entityRefs: string[];
|
|
83
|
+
/** The record this one supersedes — set by `applySupersede`, never by hand. */
|
|
84
|
+
supersedes?: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Input shape for `serializeDecisionRecord`. Mirrors `DecisionRecord` but
|
|
89
|
+
* keeps `supersedes` reachable so callers writing the replacement before
|
|
90
|
+
* applying supersede can serialise the new record alone.
|
|
91
|
+
*/
|
|
92
|
+
export type DecisionRecordInput = Omit<DecisionRecord, "supersedes"> & {
|
|
93
|
+
supersedes?: string;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
97
|
+
// Gate / type guard
|
|
98
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
const DECISION_STATUS_BY_VALUE: Record<string, DecisionStatus> = Object.freeze({
|
|
101
|
+
proposed: "proposed",
|
|
102
|
+
accepted: "accepted",
|
|
103
|
+
superseded: "superseded",
|
|
104
|
+
rejected: "rejected",
|
|
105
|
+
}) as Record<string, DecisionStatus>;
|
|
106
|
+
|
|
107
|
+
export function isDecisionStatus(value: unknown): value is DecisionStatus {
|
|
108
|
+
return (
|
|
109
|
+
typeof value === "string"
|
|
110
|
+
&& Object.prototype.hasOwnProperty.call(DECISION_STATUS_BY_VALUE, value)
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const FRONTMATTER_KEYS = Object.freeze([
|
|
115
|
+
"id",
|
|
116
|
+
"title",
|
|
117
|
+
"status",
|
|
118
|
+
"context",
|
|
119
|
+
"decision",
|
|
120
|
+
"consequences",
|
|
121
|
+
"entityRefs",
|
|
122
|
+
"supersedes",
|
|
123
|
+
] as const satisfies readonly (keyof DecisionRecord | "id")[]);
|
|
124
|
+
|
|
125
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
126
|
+
// Serialise
|
|
127
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Render a decision record as markdown with a YAML frontmatter block.
|
|
131
|
+
*
|
|
132
|
+
* Frontmatter keys are sorted ascending (rule 38). Strings are double-quoted
|
|
133
|
+
* so reserved YAML characters (`#`, `:`, leading-digits, list-like prefixes)
|
|
134
|
+
* survive a round-trip without the caller caring.
|
|
135
|
+
*/
|
|
136
|
+
export function serializeDecisionRecord(record: DecisionRecordInput): string {
|
|
137
|
+
const lines: string[] = ["---"];
|
|
138
|
+
for (const key of FRONTMATTER_KEYS) {
|
|
139
|
+
const present =
|
|
140
|
+
key === "supersedes"
|
|
141
|
+
? record.supersedes !== undefined
|
|
142
|
+
: key === "consequences"
|
|
143
|
+
? record.consequences !== undefined
|
|
144
|
+
: true;
|
|
145
|
+
if (!present) continue;
|
|
146
|
+
lines.push(`${key}: ${encodeFrontmatterValue(key, record)}`);
|
|
147
|
+
}
|
|
148
|
+
lines.push("---", "");
|
|
149
|
+
if (record.context) lines.push(record.context);
|
|
150
|
+
lines.push("");
|
|
151
|
+
lines.push("# Decision");
|
|
152
|
+
lines.push("");
|
|
153
|
+
if (record.decision) lines.push(record.decision);
|
|
154
|
+
if (record.consequences !== undefined && record.consequences.length > 0) {
|
|
155
|
+
lines.push("");
|
|
156
|
+
lines.push("# Consequences");
|
|
157
|
+
lines.push("");
|
|
158
|
+
lines.push(record.consequences);
|
|
159
|
+
}
|
|
160
|
+
if (record.entityRefs.length > 0) {
|
|
161
|
+
lines.push("");
|
|
162
|
+
lines.push("# Entity references");
|
|
163
|
+
for (const ref of record.entityRefs) lines.push(`- ${ref}`);
|
|
164
|
+
}
|
|
165
|
+
// Trailing newline keeps the file POSIX-well-formed for `cat`/editors.
|
|
166
|
+
lines.push("");
|
|
167
|
+
return lines.join("\n");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function encodeFrontmatterValue(
|
|
171
|
+
key: (typeof FRONTMATTER_KEYS)[number],
|
|
172
|
+
record: DecisionRecordInput,
|
|
173
|
+
): string {
|
|
174
|
+
if (key === "entityRefs") {
|
|
175
|
+
const refs = record.entityRefs;
|
|
176
|
+
if (refs.length === 0) return "[]";
|
|
177
|
+
const inner = refs.map((ref) => `"${escapeYamlString(ref)}"`).join(", ");
|
|
178
|
+
return `[${inner}]`;
|
|
179
|
+
}
|
|
180
|
+
if (key === "status") return `"${record.status}"`;
|
|
181
|
+
if (key === "supersedes") return `"${record.supersedes ?? ""}"`;
|
|
182
|
+
if (key === "consequences") return `"${escapeYamlString(record.consequences ?? "")}"`;
|
|
183
|
+
const value = record[key];
|
|
184
|
+
return `"${escapeYamlString(typeof value === "string" ? value : String(value))}"`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function escapeYamlString(value: string): string {
|
|
188
|
+
// Inside a double-quoted YAML scalar, escape backslashes and double-quotes
|
|
189
|
+
// AND emit real newlines as a `\n` escape (the parser below mirrors this).
|
|
190
|
+
// Keeping all scalars single-line makes the line-based frontmatter parser
|
|
191
|
+
// safe and the file diff-friendly.
|
|
192
|
+
return value
|
|
193
|
+
.replace(/\\/g, "\\\\")
|
|
194
|
+
.replace(/"/g, '\\"')
|
|
195
|
+
.replace(/\n/g, "\\n");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
199
|
+
// Parse
|
|
200
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
interface ParsedFrontmatter {
|
|
203
|
+
readonly fields: Readonly<Record<string, unknown>>;
|
|
204
|
+
readonly body: string;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Parse a decision record from a markdown string with a YAML frontmatter
|
|
209
|
+
* block. Throws (with a message listing the valid statuses, rule 51) when
|
|
210
|
+
* the document does not match the contract.
|
|
211
|
+
*
|
|
212
|
+
* Missing / undefined `status` defaults to `"proposed"` (rule 48 — least
|
|
213
|
+
* privileged).
|
|
214
|
+
*/
|
|
215
|
+
export function parseDecisionRecord(raw: string): DecisionRecord {
|
|
216
|
+
const { fields, body } = extractFrontmatter(raw);
|
|
217
|
+
|
|
218
|
+
// Surface unexpected keys early — silent drift is rule 51's worst case.
|
|
219
|
+
for (const fieldKey of Object.keys(fields)) {
|
|
220
|
+
if (!FRONTMATTER_KEYS.includes(fieldKey as (typeof FRONTMATTER_KEYS)[number])) {
|
|
221
|
+
throw new Error(
|
|
222
|
+
`decision record frontmatter contains unknown key '${fieldKey}'; valid keys are ${FRONTMATTER_KEYS.join(", ")}.`,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const id = requireString(fields.id, "id");
|
|
228
|
+
const title = requireString(fields.title, "title");
|
|
229
|
+
const context = requireString(fields.context, "context");
|
|
230
|
+
const decision = requireString(fields.decision, "decision");
|
|
231
|
+
const consequences =
|
|
232
|
+
fields.consequences === undefined ? undefined : requireString(fields.consequences, "consequences");
|
|
233
|
+
|
|
234
|
+
let status: DecisionStatus = DEFAULT_DECISION_STATUS;
|
|
235
|
+
if (fields.status !== undefined && fields.status !== null) {
|
|
236
|
+
if (!isDecisionStatus(fields.status)) {
|
|
237
|
+
throw new Error(
|
|
238
|
+
`decision '${id}' has invalid status '${JSON.stringify(fields.status)}'; valid statuses are ${DECISION_STATUSES.join(", ")}.`,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
status = fields.status;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const entityRefs = parseEntityRefs(fields.entityRefs);
|
|
245
|
+
const supersedes =
|
|
246
|
+
fields.supersedes === undefined ? undefined : requireString(fields.supersedes, "supersedes");
|
|
247
|
+
|
|
248
|
+
// The parser is intentionally permissive about the on-disk shape. A
|
|
249
|
+
// record with `status: "superseded"` and no `supersedes` field is the
|
|
250
|
+
// shape `applySupersede` writes for the replaced record — the
|
|
251
|
+
// `supersedes` edge lives on the *replacement*, not the superseded one,
|
|
252
|
+
// so this branch must accept the canonical serialised form and stay
|
|
253
|
+
// round-trippable. Excluding superseded records from "active" listings is
|
|
254
|
+
// `listActive`'s job (rule 53, single classification source), not the
|
|
255
|
+
// parser's.
|
|
256
|
+
|
|
257
|
+
const record: DecisionRecord = {
|
|
258
|
+
id,
|
|
259
|
+
title,
|
|
260
|
+
status,
|
|
261
|
+
context,
|
|
262
|
+
decision,
|
|
263
|
+
consequences,
|
|
264
|
+
entityRefs,
|
|
265
|
+
...(supersedes !== undefined ? { supersedes } : {}),
|
|
266
|
+
};
|
|
267
|
+
// Body is preserved byte-for-byte on serialise; we don't introspect it
|
|
268
|
+
// here so format-neutral round-trips stay open (rule 38).
|
|
269
|
+
void body;
|
|
270
|
+
return record;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function requireString(field: unknown, key: string): string {
|
|
274
|
+
if (field === undefined || field === null) {
|
|
275
|
+
throw new Error(`decision record frontmatter is missing required field '${key}'.`);
|
|
276
|
+
}
|
|
277
|
+
if (typeof field !== "string") {
|
|
278
|
+
throw new Error(
|
|
279
|
+
`decision record frontmatter field '${key}' must be a string; got ${JSON.stringify(field)}.`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
return field;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function parseEntityRefs(field: unknown): string[] {
|
|
286
|
+
if (field === undefined) return [];
|
|
287
|
+
if (!Array.isArray(field)) {
|
|
288
|
+
throw new Error(
|
|
289
|
+
`decision record frontmatter field 'entityRefs' must be an array of strings; got ${JSON.stringify(field)}.`,
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
for (const entry of field) {
|
|
293
|
+
if (typeof entry !== "string") {
|
|
294
|
+
throw new Error(
|
|
295
|
+
`decision record frontmatter 'entityRefs' entries must be strings; got ${JSON.stringify(entry)}.`,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return [...field];
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
303
|
+
// YAML frontmatter subset (intentionally narrow)
|
|
304
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
function extractFrontmatter(raw: string): ParsedFrontmatter {
|
|
307
|
+
const text = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
|
|
308
|
+
if (!text.startsWith("---\n")) {
|
|
309
|
+
throw new Error(
|
|
310
|
+
"decision record must start with a YAML frontmatter fence (`---\\n`); got a document with no leading fence.",
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
const closeAt = text.indexOf("\n---", 4);
|
|
314
|
+
if (closeAt === -1) {
|
|
315
|
+
throw new Error(
|
|
316
|
+
"decision record frontmatter is missing its closing fence (`\\n---`); the document is truncated.",
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
const yamlBody = text.slice(4, closeAt);
|
|
320
|
+
const after = text.slice(closeAt + 4);
|
|
321
|
+
const body = after.startsWith("\n") ? after.slice(1) : after;
|
|
322
|
+
return { fields: parseYamlMapping(yamlBody), body };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function parseYamlMapping(input: string): Record<string, unknown> {
|
|
326
|
+
const fields: Record<string, unknown> = {};
|
|
327
|
+
for (const rawLine of input.split("\n")) {
|
|
328
|
+
const line = rawLine.trimEnd();
|
|
329
|
+
if (line.length === 0 || line.startsWith("#")) continue;
|
|
330
|
+
const colonAt = line.indexOf(":");
|
|
331
|
+
if (colonAt === -1) {
|
|
332
|
+
throw new Error(`decision record frontmatter line is not a key:value pair: "${line}".`);
|
|
333
|
+
}
|
|
334
|
+
const key = line.slice(0, colonAt).trim();
|
|
335
|
+
const valueText = line.slice(colonAt + 1).trim();
|
|
336
|
+
if (key.length === 0) {
|
|
337
|
+
throw new Error(`decision record frontmatter has an empty key in line: "${line}".`);
|
|
338
|
+
}
|
|
339
|
+
fields[key] = decodeScalar(valueText);
|
|
340
|
+
}
|
|
341
|
+
return fields;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function decodeScalar(valueText: string): unknown {
|
|
345
|
+
if (valueText.length === 0) return "";
|
|
346
|
+
const lower = valueText.toLowerCase();
|
|
347
|
+
if (lower === "true") return true;
|
|
348
|
+
if (lower === "false") return false;
|
|
349
|
+
if (lower === "null" || lower === "~") return null;
|
|
350
|
+
if (/^-?\d+$/.test(valueText)) return Number(valueText);
|
|
351
|
+
if (/^".*"$/.test(valueText)) {
|
|
352
|
+
// Restore escapes — order matters: handle the backslash-pair first so
|
|
353
|
+
// the literal `\\n` we emit from `escapeYamlString` decodes to a real
|
|
354
|
+
// newline (NOT a backslash-n).
|
|
355
|
+
return valueText
|
|
356
|
+
.slice(1, -1)
|
|
357
|
+
.replace(/\\\\/g, "\u0000")
|
|
358
|
+
.replace(/\\n/g, "\n")
|
|
359
|
+
.replace(/\\"/g, '"')
|
|
360
|
+
.replace(/\u0000/g, "\\");
|
|
361
|
+
}
|
|
362
|
+
if (/^\[.*\]$/.test(valueText)) return parseFlowList(valueText);
|
|
363
|
+
return valueText;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function parseFlowList(valueText: string): unknown[] {
|
|
367
|
+
const inner = valueText.slice(1, -1).trim();
|
|
368
|
+
if (inner.length === 0) return [];
|
|
369
|
+
const out: string[] = [];
|
|
370
|
+
let buf = "";
|
|
371
|
+
let inQuotes = false;
|
|
372
|
+
// Track whether we are sitting on a backslash so that `\"` inside a
|
|
373
|
+
// quoted element is recognised as an escaped quote rather than a
|
|
374
|
+
// closing quote. Without this flag, a ref containing a literal `"`
|
|
375
|
+
// (e.g. an entity id like `org/dept/\"lead\"`) would close its own
|
|
376
|
+
// element early and the next comma would split a string in half
|
|
377
|
+
// (cursor bug d16b2a18, review round on PR #1590).
|
|
378
|
+
let escaped = false;
|
|
379
|
+
for (let i = 0; i < inner.length; i += 1) {
|
|
380
|
+
const c = inner[i]!;
|
|
381
|
+
if (escaped) {
|
|
382
|
+
buf += c;
|
|
383
|
+
escaped = false;
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
if (c === "\\") {
|
|
387
|
+
buf += c;
|
|
388
|
+
escaped = true;
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
if (c === '"') {
|
|
392
|
+
inQuotes = !inQuotes;
|
|
393
|
+
buf += c;
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
if (c === "," && !inQuotes) {
|
|
397
|
+
out.push(stripFlowStringQuotes(buf.trim()));
|
|
398
|
+
buf = "";
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
buf += c;
|
|
402
|
+
}
|
|
403
|
+
if (buf.length > 0) out.push(stripFlowStringQuotes(buf.trim()));
|
|
404
|
+
return out;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function stripFlowStringQuotes(value: string): string {
|
|
408
|
+
if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
|
|
409
|
+
return value
|
|
410
|
+
.slice(1, -1)
|
|
411
|
+
.replace(/\\\\/g, "\u0000")
|
|
412
|
+
.replace(/\\n/g, "\n")
|
|
413
|
+
.replace(/\\"/g, '"')
|
|
414
|
+
.replace(/\u0000/g, "\\");
|
|
415
|
+
}
|
|
416
|
+
return value;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
420
|
+
// Supersede
|
|
421
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Pure supersede mutation: given the current record set, the id of a record
|
|
425
|
+
* to supersede, and the replacement record (already accepted), returns a new
|
|
426
|
+
* list with the replacement appended and the old record flipped to
|
|
427
|
+
* `superseded`.
|
|
428
|
+
*
|
|
429
|
+
* The hook fires `event: "write:<replacementId>"` BEFORE
|
|
430
|
+
* `event: "mutate:<oldId>:superseded"` so storage callers can guarantee
|
|
431
|
+
* disk-order matches the rule-25 requirement (the replacement lands before
|
|
432
|
+
* the old record's status flips).
|
|
433
|
+
*/
|
|
434
|
+
export function applySupersede(
|
|
435
|
+
records: readonly DecisionRecord[],
|
|
436
|
+
targetId: string,
|
|
437
|
+
replacement: DecisionRecord,
|
|
438
|
+
hook?: (event: string) => void,
|
|
439
|
+
): DecisionRecord[] {
|
|
440
|
+
const target = records.find((r) => r.id === targetId);
|
|
441
|
+
if (!target) {
|
|
442
|
+
throw new Error(
|
|
443
|
+
`applySupersede cannot find target record '${targetId}' in the current record set.`,
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
// Reject a collision where `replacement.id` already exists in the record
|
|
447
|
+
// set. Without this guard, an operator typo or MCP rename can silently
|
|
448
|
+
// overwrite an unrelated decision with a record that `supersedes` itself,
|
|
449
|
+
// corrupting the decision history (chatgpt-codex-connector review on
|
|
450
|
+
// PR #1590). The replacement must either be a NEW id (append) or the
|
|
451
|
+
// same id as the target (in-place replacement — supported as a separate
|
|
452
|
+
// edge case below).
|
|
453
|
+
const existing = records.find((r) => r.id === replacement.id);
|
|
454
|
+
if (existing && replacement.id !== targetId) {
|
|
455
|
+
throw new Error(
|
|
456
|
+
`applySupersede replacement id '${replacement.id}' already exists in the record set; ` +
|
|
457
|
+
`pick a new id to avoid silently overwriting an unrelated decision.`,
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
const next: DecisionRecord[] = records.map((r) =>
|
|
461
|
+
r.id === targetId
|
|
462
|
+
? { ...r, status: "superseded" as DecisionStatus, supersedes: undefined }
|
|
463
|
+
: r,
|
|
464
|
+
);
|
|
465
|
+
const replacementWithEdge: DecisionRecord = { ...replacement, supersedes: targetId };
|
|
466
|
+
// Same-id replacement path: a single record swaps its body in place. The
|
|
467
|
+
// entry keeps its place in the array (so the listing order is preserved)
|
|
468
|
+
// and the new record still carries the supersede edge for traceability.
|
|
469
|
+
if (replacement.id === targetId) {
|
|
470
|
+
const idx = next.findIndex((r) => r.id === targetId);
|
|
471
|
+
next[idx] = replacementWithEdge;
|
|
472
|
+
} else {
|
|
473
|
+
next.push(replacementWithEdge);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
hook?.(`write:${replacement.id}`);
|
|
477
|
+
hook?.(`mutate:${targetId}:superseded`);
|
|
478
|
+
|
|
479
|
+
return next;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
483
|
+
// Listing
|
|
484
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Filter a record set down to "standing" decisions (proposed + accepted).
|
|
488
|
+
* Uses the explicitly-exported `ACTIVE_DECISION_STATUSES` set (rule 53 — one
|
|
489
|
+
* classification source) so callers cannot drift.
|
|
490
|
+
*/
|
|
491
|
+
export function listActive(records: readonly DecisionRecord[]): DecisionRecord[] {
|
|
492
|
+
return records.filter((r) => ACTIVE_DECISION_STATUSES.has(r.status));
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
496
|
+
// Coding-knowledge feature gate (Track A PR 1 wiring boundary)
|
|
497
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Single source of truth for "are decision records active for this operator?"
|
|
501
|
+
* (rule 39 — every behaviour dependent on Track A consults this, not raw
|
|
502
|
+
* config reads). The master gate is `codingKnowledge.enabled`; the
|
|
503
|
+
* decision-record subsystem has its own on-switch.
|
|
504
|
+
*/
|
|
505
|
+
export function isDecisionRecordsEnabled(config: CodingKnowledgeConfig): boolean {
|
|
506
|
+
return config.enabled === true && config.decisionRecords === true;
|
|
507
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Regression tests for Cursor Bugbot P2 round 3 on PR #1588:
|
|
2
|
+
// "Probe poisons loader error path" and the round-5 "Loader skips
|
|
3
|
+
// success cache" follow-up.
|
|
4
|
+
//
|
|
5
|
+
// These tests must work both when @remnic/coding-graph is installed
|
|
6
|
+
// (workspace dev scenario) AND when it is absent (CI base install —
|
|
7
|
+
// the optional peer is not symlinked). Conditional branches keep the
|
|
8
|
+
// tests durable across install scenarios (P2 P2 P2 chatgpt-codex on
|
|
9
|
+
// PR #1588 round 6).
|
|
10
|
+
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import test from "node:test";
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
isCodingGraphInstalled,
|
|
16
|
+
loadCodingGraphEngineFactory,
|
|
17
|
+
tryLoadCodingGraphModule,
|
|
18
|
+
} from "./optional-coding-graph.js";
|
|
19
|
+
|
|
20
|
+
test("loadCodingGraphEngineFactory fresh-attempts regardless of probe state", async () => {
|
|
21
|
+
// Probe and load side-by-side. Both must agree on the package's
|
|
22
|
+
// presence at this instant. When @remnic/coding-graph is absent
|
|
23
|
+
// (CI base install) the probe returns null and the loader throws
|
|
24
|
+
// the install hint. When present (dev workspace) both succeed.
|
|
25
|
+
const probeResult = await tryLoadCodingGraphModule();
|
|
26
|
+
const installed = await isCodingGraphInstalled();
|
|
27
|
+
|
|
28
|
+
if (probeResult === null) {
|
|
29
|
+
assert.equal(installed, false);
|
|
30
|
+
let threw = false;
|
|
31
|
+
let message = "";
|
|
32
|
+
try {
|
|
33
|
+
await loadCodingGraphEngineFactory();
|
|
34
|
+
} catch (err) {
|
|
35
|
+
threw = true;
|
|
36
|
+
message = err instanceof Error ? err.message : String(err);
|
|
37
|
+
}
|
|
38
|
+
assert.equal(threw, true, "loader must throw install hint when probe returned null");
|
|
39
|
+
assert.match(message, /@remnic\/coding-graph/);
|
|
40
|
+
assert.match(message, /npm install @remnic\/coding-graph/);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Package present.
|
|
45
|
+
assert.equal(installed, true);
|
|
46
|
+
const factory = await loadCodingGraphEngineFactory();
|
|
47
|
+
assert.equal(typeof factory, "function");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("probe short-circuits to the same answer on repeated calls", async () => {
|
|
51
|
+
// Probe must be deterministic — repeated calls return the same
|
|
52
|
+
// answer. Both branches are valid (present → module, absent → null).
|
|
53
|
+
const a = await tryLoadCodingGraphModule();
|
|
54
|
+
const b = await tryLoadCodingGraphModule();
|
|
55
|
+
const aKind = a === null ? "null" : "module";
|
|
56
|
+
const bKind = b === null ? "null" : "module";
|
|
57
|
+
assert.equal(aKind, bKind, "consecutive probe calls must agree");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("loader path is independent of the probe result", async () => {
|
|
61
|
+
// The Bugbot P2 round 3 bug was: a probe that caught a non-specifier
|
|
62
|
+
// import error was caching `null` into the load-path. That poisoned
|
|
63
|
+
// the user-facing loader path. This test asserts the load-path
|
|
64
|
+
// ALWAYS attempts a fresh import — proven by exercising the path
|
|
65
|
+
// for both present and absent packages.
|
|
66
|
+
const probe = await tryLoadCodingGraphModule();
|
|
67
|
+
if (probe === null) {
|
|
68
|
+
// Absent: loader throws install hint.
|
|
69
|
+
let threw = false;
|
|
70
|
+
try {
|
|
71
|
+
await loadCodingGraphEngineFactory();
|
|
72
|
+
} catch {
|
|
73
|
+
threw = true;
|
|
74
|
+
}
|
|
75
|
+
assert.equal(threw, true, "loader throws when package is absent");
|
|
76
|
+
} else {
|
|
77
|
+
// Present: loader returns a callable factory.
|
|
78
|
+
const factory = await loadCodingGraphEngineFactory();
|
|
79
|
+
assert.equal(typeof factory, "function");
|
|
80
|
+
assert.throws(() => factory(), (err: unknown) => {
|
|
81
|
+
if (!err || typeof err !== "object") return false;
|
|
82
|
+
const e = err as { name?: unknown; code?: unknown };
|
|
83
|
+
return e.name === "CodingGraphError" && e.code === "not_implemented";
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Regression tests for Cursor Bugbot P3 round 5 on PR #1588:
|
|
2
|
+
// "Loader skips success cache".
|
|
3
|
+
//
|
|
4
|
+
// These tests must work both when @remnic/coding-graph is installed
|
|
5
|
+
// (workspace dev scenario) AND when it is absent (CI base install —
|
|
6
|
+
// the optional peer is not symlinked). chatgpt-codex round 6 P2:
|
|
7
|
+
// the original test assumed the package is installed and would fail
|
|
8
|
+
// in a core-only install. Every assertion below is conditional.
|
|
9
|
+
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import test from "node:test";
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
isCodingGraphInstalled,
|
|
15
|
+
loadCodingGraphEngineFactory,
|
|
16
|
+
tryLoadCodingGraphModule,
|
|
17
|
+
} from "./optional-coding-graph.js";
|
|
18
|
+
|
|
19
|
+
test("loadCodingGraphEngineFactory returns a callable factory (install present)", async () => {
|
|
20
|
+
const installed = await isCodingGraphInstalled();
|
|
21
|
+
if (!installed) {
|
|
22
|
+
// Skip when package is absent (CI base install).
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const factory = await loadCodingGraphEngineFactory();
|
|
26
|
+
assert.equal(typeof factory, "function");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("loadCodingGraphEngineFactory throws install hint when package absent", async () => {
|
|
30
|
+
const installed = await isCodingGraphInstalled();
|
|
31
|
+
if (installed) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
let threw = false;
|
|
35
|
+
let message = "";
|
|
36
|
+
try {
|
|
37
|
+
await loadCodingGraphEngineFactory();
|
|
38
|
+
} catch (err) {
|
|
39
|
+
threw = true;
|
|
40
|
+
message = err instanceof Error ? err.message : String(err);
|
|
41
|
+
}
|
|
42
|
+
assert.equal(threw, true);
|
|
43
|
+
assert.match(message, /@remnic\/coding-graph/);
|
|
44
|
+
assert.match(message, /npm install @remnic\/coding-graph/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("after a successful load, tryLoadCodingGraphModule returns the same module (cache fast-path)", async () => {
|
|
48
|
+
const installed = await isCodingGraphInstalled();
|
|
49
|
+
if (!installed) return;
|
|
50
|
+
// Seed the success cache by calling loadCodingGraphEngineFactory.
|
|
51
|
+
const factory = await loadCodingGraphEngineFactory();
|
|
52
|
+
assert.equal(typeof factory, "function");
|
|
53
|
+
// A fresh probe call should return the cached module reference.
|
|
54
|
+
const probeResult = await tryLoadCodingGraphModule();
|
|
55
|
+
assert.ok(probeResult !== null);
|
|
56
|
+
// Invoking the factory throws the placeholder (PR1 contract).
|
|
57
|
+
assert.throws(() => factory(), (err: unknown) => {
|
|
58
|
+
if (!err || typeof err !== "object") return false;
|
|
59
|
+
const e = err as { name?: unknown; code?: unknown };
|
|
60
|
+
return e.name === "CodingGraphError" && e.code === "not_implemented";
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("loader fresh-attempts after the package is detected as missing", async () => {
|
|
65
|
+
// If present, loader returns a factory.
|
|
66
|
+
// If absent, loader throws the install hint.
|
|
67
|
+
try {
|
|
68
|
+
const factory = await loadCodingGraphEngineFactory();
|
|
69
|
+
assert.equal(typeof factory, "function");
|
|
70
|
+
} catch (err) {
|
|
71
|
+
if (err instanceof Error) {
|
|
72
|
+
assert.match(err.message, /@remnic\/coding-graph/);
|
|
73
|
+
assert.match(err.message, /npm install @remnic\/coding-graph/);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
throw err;
|
|
77
|
+
}
|
|
78
|
+
});
|