@skanl/brambo-projection 0.1.1

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/engine.js ADDED
@@ -0,0 +1,235 @@
1
+ import { readFile, stat } from 'node:fs/promises';
2
+ import { BramboError, BRAMBO_ERROR_CODES, REGISTRY_ENTRY_TYPES, isRegistryEntryType, projectionTargetLocation, } from '@skanl/brambo-contracts';
3
+ import { atomicWriteText } from './atomic-write.js';
4
+ import { resolveOwnedPath, sameOwnedPath } from './ledger.js';
5
+ import { materialiseTarget } from './materialise.js';
6
+ // Projection engine (FR-12): reads the ownership ledger once, then runs every
7
+ // target SEQUENTIALLY — targets are never executed concurrently. Every failure
8
+ // — malformed native file, unclaimable container, mid-projection external
9
+ // modification, anything else — is CONTAINED per target: it becomes a typed
10
+ // failure for that target alone and never affects sibling targets or escapes
11
+ // runProjection.
12
+ //
13
+ // Each target records its claims IMMEDIATELY after its own file lands, and a
14
+ // ledger write that fails FAILS THAT TARGET. Deferring the ledger to the end of
15
+ // the run leaves a window where the file already holds new bytes while the
16
+ // ledger still holds the old hash — and a warning there would leave brambo
17
+ // permanently locked out of an entry it owns.
18
+ //
19
+ // An UNREADABLE ledger turns every target into an INSPECTION, and REFUSES the
20
+ // ones that would have written. This paragraph used to say the opposite -- that
21
+ // brambo wrote anyway
22
+ // because "under-claiming for one run is recoverable (brambo reports its own
23
+ // entries as foreign and touches nothing)" -- and that recovery does not exist
24
+ // on the config path. DRIVEN: `brambo init` over an unreadable ledger exits 0 and
25
+ // lands the bytes; repairing the ledger by hand afterwards leaves `brambo doctor`
26
+ // at exit 0 with ZERO findings, and `brambo remove` + `brambo init` leaves the
27
+ // server in the user's `.claude.json` permanently. `formats.ts`'s
28
+ // ALREADY-SATISFIED branch swallows it precisely because brambo wrote it
29
+ // CORRECTLY, so the orphan is invisible exactly when it is brambo's own doing.
30
+ //
31
+ // `ingest` already refuses this state before it lists a single vendor document,
32
+ // for the reason written at `ingest.ts:106-119`: without the ledger brambo cannot
33
+ // tell its own projections from the user's servers. `init` is the command that
34
+ // WRITES into that config, so it cannot be the lenient one.
35
+ /**
36
+ * The registry as the projection engine reads it: one bucket per DECLARED entry
37
+ * type.
38
+ *
39
+ * Derived from `REGISTRY_ENTRY_TYPES` rather than listed — a second table here
40
+ * is how the engine comes to project a word the contract no longer has, or to
41
+ * miss one it just gained. It is also the boundary that keeps a RETIRED type out
42
+ * of projection: an entry whose type is not declared has no bucket, so it is
43
+ * dropped here and no target is ever asked to express it. The store still reads
44
+ * it, `brambo list` still shows it and `brambo remove` still takes it out.
45
+ */
46
+ export function groupByKind(entries) {
47
+ const grouped = Object.fromEntries(REGISTRY_ENTRY_TYPES.map((kind) => [kind, []]));
48
+ for (const entry of entries) {
49
+ // A type with no bucket is skipped, never crashed on, and there are two of
50
+ // them: a RETIRED word, dropped deliberately because no target renders it,
51
+ // and an unknown one, which post-validation means a hand-corrupted entry.
52
+ if (!isRegistryEntryType(entry.type))
53
+ continue;
54
+ grouped[entry.type].push(entry);
55
+ }
56
+ return grouped;
57
+ }
58
+ /**
59
+ * FAIL CLOSED. Not `mode !== 'inspect'`: that writes for `'Inspect'`,
60
+ * `'inspect '`, `'dry-run'` and `null`, and the one thing this field decides is
61
+ * whether brambo writes into files it does not own. A no-op run is visible in its
62
+ * own output; a write into a user's config on the say-so of a typo is not. So
63
+ * both failures are loud. `=== undefined`, not `??`: `null` is a value a caller
64
+ * PASSED, not an omission, and coalescing it into the writing default is the
65
+ * same silent accept this guard exists to remove.
66
+ *
67
+ * Shared by `runProjection` and `runRemediation` so the two commands that can
68
+ * write cannot disagree about what "do not touch this machine" means.
69
+ */
70
+ export function resolveProjectionMode(mode) {
71
+ const resolved = mode === undefined ? 'apply' : mode;
72
+ if (resolved !== 'apply' && resolved !== 'inspect') {
73
+ throw new BramboError(BRAMBO_ERROR_CODES.projectionModeInvalid, `projection mode ${JSON.stringify(resolved)} is not recognised; use 'apply' or 'inspect' (omitted means 'apply')`);
74
+ }
75
+ return resolved;
76
+ }
77
+ async function readNativeFile(filePath) {
78
+ try {
79
+ const [text, stats] = await Promise.all([readFile(filePath, 'utf8'), stat(filePath)]);
80
+ return { text, snapshot: { mtimeMs: stats.mtimeMs, size: stats.size } };
81
+ }
82
+ catch (error) {
83
+ const code = error?.code;
84
+ if (code === 'ENOENT')
85
+ return { text: '', snapshot: undefined };
86
+ // A directory where the vendor's config file belongs, an unreadable mode, a
87
+ // dangling link: all reach here as a bare errno naming neither the path nor
88
+ // what brambo wanted with it. Coded, and both facts in the message.
89
+ throw new BramboError(BRAMBO_ERROR_CODES.projectionNativeUnclaimable, `native config file '${filePath}' cannot be read (${code ?? 'unknown error'}), so brambo cannot place entries there`, { cause: error });
90
+ }
91
+ }
92
+ /**
93
+ * Defense against a read-write race: if the native file changed on disk between
94
+ * the read and the write, the projection is stale and MUST NOT land. An ABSENT
95
+ * snapshot is not "nothing to compare": the merge was computed against an empty
96
+ * document, so the file appearing in the meantime — a vendor CLI creating
97
+ * `~/.claude.json` — is exactly the case where landing would overwrite it
98
+ * wholesale.
99
+ */
100
+ export async function hasFileChangedSince(filePath, snapshot) {
101
+ if (snapshot === undefined) {
102
+ return await stat(filePath).then(() => true, (error) => error.code !== 'ENOENT');
103
+ }
104
+ const current = await stat(filePath);
105
+ return current.mtimeMs !== snapshot.mtimeMs || current.size !== snapshot.size;
106
+ }
107
+ function toTargetFailure(targetId, error) {
108
+ if (error instanceof BramboError)
109
+ return { targetId, error };
110
+ const detail = error instanceof Error ? error.message : String(error);
111
+ return {
112
+ targetId,
113
+ error: new BramboError(BRAMBO_ERROR_CODES.projectionTargetFailed, `projection target '${targetId}' failed: ${detail}`, { cause: error }),
114
+ };
115
+ }
116
+ async function projectTarget(target, entries, records, apply) {
117
+ const { text: nativeText, snapshot } = await readNativeFile(target.filePath);
118
+ const outcome = await target.merge({ entries, records, nativeText });
119
+ const written = outcome.text !== nativeText;
120
+ if (written) {
121
+ // Checked in BOTH modes, and that is the point rather than an oversight. It
122
+ // is true that an inspection has no write window to lose — but the
123
+ // PREDICTION is doctor's whole artifact, and a mode that skipped this would
124
+ // answer "this file would be rewritten" for a target where applying returns
125
+ // no result row and a failure instead. `~/.claude.json` is rewritten by
126
+ // Claude Code itself, so this is the machine doctor gets run on.
127
+ if (await hasFileChangedSince(target.filePath, snapshot)) {
128
+ throw new BramboError(BRAMBO_ERROR_CODES.projectionTargetFailed, `projection target '${target.targetId}' failed: file modified during projection: '${target.filePath}'`);
129
+ }
130
+ if (apply)
131
+ await atomicWriteText(target.filePath, outcome.text);
132
+ }
133
+ return {
134
+ result: {
135
+ targetId: target.targetId,
136
+ written,
137
+ byteDelta: written
138
+ ? Math.abs(Buffer.byteLength(outcome.text, 'utf8') - Buffer.byteLength(nativeText, 'utf8'))
139
+ : 0,
140
+ drift: outcome.drift,
141
+ skippedEntryIds: outcome.skippedEntryIds ?? [],
142
+ },
143
+ records: outcome.records,
144
+ };
145
+ }
146
+ export async function runProjection(options) {
147
+ // Every caller-controlled field read ONCE, here, before the first await. A
148
+ // caller object whose `mode` getter answers `'inspect'` now and `'apply'` on
149
+ // the second read would land bytes on a machine the caller was promised would
150
+ // not be touched, and `brambo doctor` is precisely the command that promises it.
151
+ const { entries, targets, ledger: store } = options;
152
+ const apply = resolveProjectionMode(options.mode) === 'apply';
153
+ const ledger = await store.read();
154
+ const warnings = [...ledger.warnings];
155
+ const results = [];
156
+ const failures = [];
157
+ for (const target of targets) {
158
+ // EVERYTHING derived from the target lives inside the try, including the
159
+ // ownership scope. Computing it outside was a hole in the engine's own
160
+ // containment promise: a target that is neither kind — a plain object
161
+ // reaching a published port — threw out of `runProjection` and took every
162
+ // sibling target with it.
163
+ let projected;
164
+ let scope;
165
+ // Reaches the write below, which is the whole reason it is declared out
166
+ // here: `store.update` drops exactly the entries this run TOOK A POSITION
167
+ // ON, and this snapshot is the record of which ones those were. Left inside
168
+ // the try, the write had no way to say "these are mine to retire" and said
169
+ // "the scope is mine to replace" instead — which erased whatever a
170
+ // concurrent run had claimed in between.
171
+ let claimed;
172
+ try {
173
+ // One ownership scope for both kinds: a config target's file, or a
174
+ // materialisation target's root. The ledger keys on it either way, so a
175
+ // target's claims are exactly the ones taken under the location it owns.
176
+ scope = {
177
+ targetId: target.targetId,
178
+ filePath: resolveOwnedPath(projectionTargetLocation(target)),
179
+ };
180
+ claimed = ledger.records.filter((record) => record.targetId === scope.targetId &&
181
+ sameOwnedPath(resolveOwnedPath(record.filePath), scope.filePath));
182
+ // An unreadable ledger runs the target as an INSPECTION even in apply
183
+ // mode. The merge, the drift classification and the verdict are all still
184
+ // computed — a caller learns exactly what it would have learned — but no
185
+ // byte lands, because a byte brambo cannot claim is a byte brambo can never
186
+ // take back.
187
+ const claimable = !apply || ledger.state !== 'unreadable';
188
+ projected =
189
+ target.kind === 'materialise'
190
+ ? await materialiseTarget(target, entries, claimed, apply && claimable)
191
+ : await projectTarget(target, entries, claimed, apply && claimable);
192
+ // `written` under an inspection reads as "these bytes WOULD have changed",
193
+ // so this fires ONLY when the run had something to write. A target whose
194
+ // location already holds exactly what brambo would write has nothing to
195
+ // orphan and is reported as the no-op it is, which is what the damaged
196
+ // ledger left behind by a successful earlier run looks like.
197
+ if (!claimable && projected.result.written) {
198
+ // Deliberately not opened with the word `brambo`: `test/printed-commands.ts`
199
+ // treats a backtick-quoted string that starts that way as a COMMAND, and
200
+ // this is a sentence.
201
+ throw new BramboError(BRAMBO_ERROR_CODES.projectionLedgerUnavailable, `refusing to write '${scope.filePath}' without the ownership ledger, because brambo could not then tell those bytes from yours: ${ledger.warnings.map((warning) => warning.detail).join('; ')}`);
202
+ }
203
+ }
204
+ catch (error) {
205
+ failures.push(toTargetFailure(target.targetId, error));
206
+ continue;
207
+ }
208
+ // The result is reported EVEN IF the ledger write below fails, and that
209
+ // ordering is the whole point: by this line the vendor's file already holds
210
+ // the new bytes. Reporting `written: false` for them — which is what
211
+ // dropping the result on a ledger failure amounts to at every caller — makes
212
+ // brambo accuse the user of editing bytes brambo wrote on the very next run,
213
+ // after which the entry never tracks the registry again. The failure still
214
+ // travels, so a caller learns the projection did not COMPLETE; what it no
215
+ // longer learns is a falsehood about what landed on disk.
216
+ results.push(projected.result);
217
+ // The SECOND of the two writes, and inspection skips it here rather than
218
+ // inside the ledger: a diagnosis that recorded claims for entries it did not
219
+ // write would tell the next real run that brambo owns bytes it never placed.
220
+ // NOT dead code, and driving it is what proved that. The guard above throws
221
+ // only when the target WOULD have written; a target with nothing to write
222
+ // falls through to here, and without the unreadable clause it reached
223
+ // `store.update` and failed the target on a ledger it was never going to
224
+ // touch — turning a harmless no-op into `brambo project init` exit 1.
225
+ if (!apply || ledger.state === 'unreadable')
226
+ continue;
227
+ try {
228
+ await store.update(scope, projected.records, claimed.map((record) => record.entryId));
229
+ }
230
+ catch (error) {
231
+ failures.push(toTargetFailure(target.targetId, error));
232
+ }
233
+ }
234
+ return { results, failures, warnings };
235
+ }
@@ -0,0 +1,132 @@
1
+ import type { ProjectionMcpEntry, ProjectionConfigTarget } from '@skanl/brambo-contracts';
2
+ export type FileFormat = 'jsonc' | 'toml';
3
+ /** A vendor-native entry: keys and values in the vendor's own vocabulary. */
4
+ export type NativeEntryShape = Readonly<Record<string, string | readonly string[]>>;
5
+ /**
6
+ * What ONE vendor entry means in brambo's vocabulary, or why it means nothing
7
+ * brambo can hold — a typed absence rather than a bare `undefined` (AD-5), so a
8
+ * caller has to say what it does about an entry it cannot represent.
9
+ */
10
+ export type ReadMcpEntry = {
11
+ readonly ok: true;
12
+ readonly command: string;
13
+ readonly args: readonly string[];
14
+ } | {
15
+ readonly ok: false;
16
+ readonly detail: string;
17
+ };
18
+ /** The data that fully describes a projection target (FR-8). */
19
+ export interface ProjectionTargetTraits {
20
+ readonly targetId: string;
21
+ readonly fileFormat: FileFormat;
22
+ /** Absolute path used when the caller injects no filePath override. */
23
+ readonly defaultPath: string;
24
+ /** The vendor's OWN container for MCP servers: JSON key or TOML table prefix. */
25
+ readonly mcpContainerKey: string;
26
+ /** The vendor's OWN entry shape. Its keys are the only keys brambo writes. */
27
+ readonly renderMcpEntry: (entry: ProjectionMcpEntry) => NativeEntryShape;
28
+ /**
29
+ * The exact inverse of {@link renderMcpEntry}, and REQUIRED for the same
30
+ * reason it sits here rather than in a reader module: the three vendors
31
+ * disagree about the shape, and OpenCode's `command` IS the argv, so the
32
+ * un-join belongs beside the join and nowhere else. Optional, it would permit
33
+ * a target that can be projected into and never read back — precisely the
34
+ * asymmetry M11.A exists to remove — so the type system carries the rule and
35
+ * a fourth trait record cannot forget it.
36
+ */
37
+ readonly readMcpEntry: (native: NativeEntryShape) => ReadMcpEntry;
38
+ /** JSON family only: treat comments/trailing commas as malformed native input. */
39
+ readonly strictJson?: boolean;
40
+ }
41
+ /**
42
+ * The keys THIS vendor's renderer emits, which are exactly the keys its reader
43
+ * consumes — asked of the renderer rather than written down beside it.
44
+ *
45
+ * A hand-written list here was a THIRD spelling of the same fact, and a key
46
+ * added to a renderer and forgotten in that list would be reported to the user
47
+ * as `dropped` while brambo was writing it. The sample is arbitrary: every
48
+ * renderer emits a fixed key set, which `vendor-conformance.test.ts` pins.
49
+ */
50
+ export declare function renderedKeys(traits: ProjectionTargetTraits): readonly string[];
51
+ /**
52
+ * The reading Claude Code and Codex SHARE: a `command` string beside an optional
53
+ * `args` array of strings.
54
+ *
55
+ * OpenCode deliberately does NOT use it. Its `command` is the whole argv, and
56
+ * D1 keeps that un-join in `opencode-config.ts` alone; what is shared here is
57
+ * only the vocabulary the other two already spell identically, so the sentence a
58
+ * user reads for a missing command cannot differ between two vendors that failed
59
+ * the same way.
60
+ */
61
+ export declare function readNativeCommand(native: NativeEntryShape): ReadMcpEntry;
62
+ export interface TraitTargetOptions {
63
+ /** Overrides the trait record's defaultPath (default paths are injectable). */
64
+ readonly filePath?: string;
65
+ }
66
+ /** One vendor MCP entry, read back into the vocabulary the registry stores. */
67
+ export interface NativeMcpEntry {
68
+ readonly id: string;
69
+ readonly command: string;
70
+ readonly args: readonly string[];
71
+ /** Vendor keys the registry envelope cannot carry; reported, never lost (D10). */
72
+ readonly dropped: readonly string[];
73
+ }
74
+ /** An id that is present and out of which brambo can read no entry, and why. */
75
+ export interface UnreadableNativeMcpEntry {
76
+ readonly id: string;
77
+ readonly detail: string;
78
+ }
79
+ export interface NativeMcpRead {
80
+ /** The document these were read from — the same path every detail names. */
81
+ readonly filePath: string;
82
+ readonly entries: readonly NativeMcpEntry[];
83
+ readonly unreadable: readonly UnreadableNativeMcpEntry[];
84
+ /**
85
+ * The file is THERE and brambo could not read it, in the OS's own errno.
86
+ *
87
+ * Distinct from `undefined` (absent, AD-5) and from a throw (malformed, D8):
88
+ * an `EACCES` on one vendor's config is neither "this executor is not
89
+ * installed" nor "this document is broken", and collapsing it into either
90
+ * makes one unreadable file either invisible or fatal to the whole run —
91
+ * including the skills half, which has nothing to do with it.
92
+ */
93
+ readonly unreadableFile?: string;
94
+ }
95
+ /**
96
+ * Every MCP server one vendor's own config file declares, or `undefined` when
97
+ * there is no such file.
98
+ *
99
+ * ABSENCE IS NOT FAILURE (AD-5): an executor is allowed not to be installed, so
100
+ * a missing `~/.codex/config.toml` contributes nothing and is not an error.
101
+ * Everything else is coded and names the path — a malformed document, a
102
+ * container holding something brambo cannot address, a file brambo may not read.
103
+ */
104
+ export declare function readNativeMcpEntries(traits: ProjectionTargetTraits, options?: TraitTargetOptions): Promise<NativeMcpRead | undefined>;
105
+ export interface LegacyBramboBlock {
106
+ /** Half-open range of the ORIGINAL text, byte-order mark included in the offsets. */
107
+ readonly start: number;
108
+ readonly end: number;
109
+ readonly detail: string;
110
+ }
111
+ /**
112
+ * `block` — brambo's own prior output, and exactly what removing it takes out.
113
+ * `refusal` — something that looks like it and brambo will not touch, with why.
114
+ * Neither — the file holds none.
115
+ */
116
+ export interface LegacyBramboScan {
117
+ readonly block?: LegacyBramboBlock;
118
+ readonly refusal?: string;
119
+ }
120
+ /**
121
+ * Brambo's own prior output in one vendor file, if any is there.
122
+ *
123
+ * READS ONLY. `brambo doctor` calls it to report the state and the `discard`
124
+ * remediation calls it to remove exactly the region it returns.
125
+ */
126
+ export declare function scanLegacyBramboBlock(nativeText: string, fileFormat: FileFormat): LegacyBramboScan;
127
+ /**
128
+ * The ONE factory every target flows through: adding a target means writing a
129
+ * trait record — no engine or strategy code changes. An unknown file format is
130
+ * a coded configuration error, not a crash at splice time.
131
+ */
132
+ export declare function createProjectionTargetFromTraits(traits: ProjectionTargetTraits, options?: TraitTargetOptions): ProjectionConfigTarget;