@nanobpm/urban 0.68.1 → 0.69.0
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/context/binding/index.d.ts +1 -1
- package/dist/context/binding/index.js +1 -1
- package/dist/context/git/guard-registry.d.ts +26 -0
- package/dist/context/git/guard-registry.js +52 -0
- package/dist/context/git/index.d.ts +4 -1
- package/dist/context/git/index.js +21 -1
- package/dist/context/git/layout.d.ts +46 -0
- package/dist/context/git/layout.js +96 -0
- package/dist/context/git/substrate.d.ts +95 -0
- package/dist/context/git/substrate.js +190 -0
- package/dist/context/git/writer.d.ts +154 -0
- package/dist/context/git/writer.js +395 -0
- package/package.json +1 -1
- package/src/context/binding/index.ts +1 -0
- package/src/context/git/NOTES.md +98 -0
- package/src/context/git/guard-registry.ts +59 -0
- package/src/context/git/index.ts +51 -2
- package/src/context/git/layout.test.ts +112 -0
- package/src/context/git/layout.ts +110 -0
- package/src/context/git/substrate.ts +287 -0
- package/src/context/git/writer.test.ts +898 -0
- package/src/context/git/writer.ts +543 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Slice S3 (git/governance) — the mandatory pre-commit guard registry.
|
|
2
|
+
//
|
|
3
|
+
// This is the seam that makes PII enforcement NON-OPTIONAL and default-DENY BY
|
|
4
|
+
// CONSTRUCTION on every write path. A registry is ALWAYS seeded with the S6
|
|
5
|
+
// mandatory guard (`preCommitPiiGuard`) as its first, non-removable member:
|
|
6
|
+
//
|
|
7
|
+
// - there is no method to remove or replace the default guard, and
|
|
8
|
+
// - a freshly-constructed registry (the default the writer uses when a caller
|
|
9
|
+
// supplies no wiring at all) already contains it,
|
|
10
|
+
//
|
|
11
|
+
// so a caller cannot silently bypass PII enforcement. Additional guards can only
|
|
12
|
+
// be ADDED (making the policy stricter), never subtracted. The writer calls
|
|
13
|
+
// {@link PreCommitGuardRegistry.assertAll} immediately before it stages/commits
|
|
14
|
+
// any record, so a write carrying PII is rejected before it is ever committed.
|
|
15
|
+
|
|
16
|
+
import type { PiiCandidate, PiiGuard } from "../pii/index.ts";
|
|
17
|
+
import { preCommitPiiGuard } from "../pii/index.ts";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* An append-only registry of pre-commit guards. The mandatory S6 PII guard is
|
|
21
|
+
* pre-registered as the default and cannot be removed; callers may register
|
|
22
|
+
* ADDITIONAL guards, which run in addition to (never instead of) the default.
|
|
23
|
+
*/
|
|
24
|
+
export class PreCommitGuardRegistry {
|
|
25
|
+
// Index 0 is ALWAYS the mandatory S6 PII guard. There is no removal API, so it
|
|
26
|
+
// is impossible to end up with a registry that does not enforce it.
|
|
27
|
+
readonly #guards: PiiGuard[];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Build a registry. It is seeded with the mandatory {@link preCommitPiiGuard}
|
|
31
|
+
* first; any `additional` guards are appended after it. Even with no arguments
|
|
32
|
+
* the resulting registry enforces PII by construction.
|
|
33
|
+
*/
|
|
34
|
+
constructor(additional: readonly PiiGuard[] = []) {
|
|
35
|
+
this.#guards = [preCommitPiiGuard, ...additional];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Register an ADDITIONAL guard. Cannot displace the mandatory default guard. */
|
|
39
|
+
register(guard: PiiGuard): void {
|
|
40
|
+
this.#guards.push(guard);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A snapshot of the registered guards, in run order (mandatory guard first). */
|
|
44
|
+
get guards(): readonly PiiGuard[] {
|
|
45
|
+
return [...this.#guards];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Run EVERY registered guard against `candidate`. The mandatory PII guard runs
|
|
50
|
+
* first. Each guard is default-DENY: the first guard to detect a violation
|
|
51
|
+
* throws (a {@link import("../pii/index.ts").PiiGuardError} for the PII guard),
|
|
52
|
+
* so a candidate carrying PII never reaches the commit that follows this call.
|
|
53
|
+
*/
|
|
54
|
+
assertAll(candidate: PiiCandidate): void {
|
|
55
|
+
for (const guard of this.#guards) {
|
|
56
|
+
guard.assert(candidate);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/context/git/index.ts
CHANGED
|
@@ -1,2 +1,51 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
1
|
+
// @nanobpm/urban/context/git — slice S3 (git substrate + PR-governance).
|
|
2
|
+
//
|
|
3
|
+
// The git-as-system-of-record WRITE + governance layer, built on top of a
|
|
4
|
+
// resolved substrate handle (S1), the validated record schema (S2), and the
|
|
5
|
+
// mandatory PII pre-commit guard (S6). It publishes three things downstream:
|
|
6
|
+
//
|
|
7
|
+
// 1. APPEND / GOVERNANCE — ContextWriter: append-via-commit for
|
|
8
|
+
// human/measured/instance facts, and PR-governance (propose → ratify) for
|
|
9
|
+
// agent-retro hypotheses, with the mandatory PII guard wired into EVERY write
|
|
10
|
+
// path so PII is rejected before any commit, by construction.
|
|
11
|
+
// 2. PATH / NAMESPACE PARTITIONING — the layout helper (recordRelativePath,
|
|
12
|
+
// recordDir, scopeDir, isRecordPath, LAYOUT_ROOT). S4 retrieval and the S6 CI
|
|
13
|
+
// slice READ this exact layout, so it is exported as a stable seam.
|
|
14
|
+
// 3. THE WRITE SUBSTRATE SEAM — WriteSubstrate / GitWriteSubstrate: the write
|
|
15
|
+
// path is abstracted over the substrate (not hard-wired to public git),
|
|
16
|
+
// leaving room for a future PII/mutable backend, and the
|
|
17
|
+
// PreCommitGuardRegistry that makes the PII guard non-bypassable.
|
|
18
|
+
|
|
19
|
+
export {
|
|
20
|
+
LAYOUT_ROOT,
|
|
21
|
+
RECORD_FILE_EXTENSION,
|
|
22
|
+
UNSCOPED_BUCKET,
|
|
23
|
+
isRecordPath,
|
|
24
|
+
recordDir,
|
|
25
|
+
recordRelativePath,
|
|
26
|
+
sanitizeSegment,
|
|
27
|
+
scopeDir,
|
|
28
|
+
} from "./layout.ts";
|
|
29
|
+
|
|
30
|
+
export { PreCommitGuardRegistry } from "./guard-registry.ts";
|
|
31
|
+
|
|
32
|
+
export {
|
|
33
|
+
type CommitAuthor,
|
|
34
|
+
GitWriteSubstrate,
|
|
35
|
+
SubstrateWriteError,
|
|
36
|
+
substratePath,
|
|
37
|
+
type WriteSubstrate,
|
|
38
|
+
} from "./substrate.ts";
|
|
39
|
+
|
|
40
|
+
export {
|
|
41
|
+
type AppendResult,
|
|
42
|
+
ContextWriter,
|
|
43
|
+
type ContextWriterOptions,
|
|
44
|
+
DEFAULT_BOT_AUTHOR,
|
|
45
|
+
DEFAULT_WRITE_AUTHOR,
|
|
46
|
+
GovernanceError,
|
|
47
|
+
type ProposalResult,
|
|
48
|
+
type RatifyResult,
|
|
49
|
+
type ResolvedSubstrateHandle,
|
|
50
|
+
serialiseRecord,
|
|
51
|
+
} from "./writer.ts";
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Slice S3 (git/governance) — unit tests for the path/namespace layout helper.
|
|
2
|
+
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import type { MemoryRecord } from "../schema/index.ts";
|
|
6
|
+
import {
|
|
7
|
+
LAYOUT_ROOT,
|
|
8
|
+
UNSCOPED_BUCKET,
|
|
9
|
+
isRecordPath,
|
|
10
|
+
recordDir,
|
|
11
|
+
recordRelativePath,
|
|
12
|
+
sanitizeSegment,
|
|
13
|
+
scopeDir,
|
|
14
|
+
} from "./layout.ts";
|
|
15
|
+
|
|
16
|
+
function record(overrides: Partial<MemoryRecord>): MemoryRecord {
|
|
17
|
+
const base: MemoryRecord = {
|
|
18
|
+
schemaVersion: 1,
|
|
19
|
+
id: "rec-1",
|
|
20
|
+
scope: "epic",
|
|
21
|
+
mode: "empirical",
|
|
22
|
+
provenance: "measured",
|
|
23
|
+
authority: "authoritative",
|
|
24
|
+
statement: "a measured fact",
|
|
25
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
26
|
+
};
|
|
27
|
+
return { ...base, ...overrides };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test("scopeDir / recordDir partition by scope and scopeRef", () => {
|
|
31
|
+
assert.equal(scopeDir("epic"), `${LAYOUT_ROOT}/epic`);
|
|
32
|
+
assert.equal(recordDir("epic", "issue-303"), `${LAYOUT_ROOT}/epic/issue-303`);
|
|
33
|
+
// No scopeRef → the shared bucket.
|
|
34
|
+
assert.equal(recordDir("corpus"), `${LAYOUT_ROOT}/corpus/${UNSCOPED_BUCKET}`);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("recordRelativePath is deterministic and scoped", () => {
|
|
38
|
+
const r = record({ scope: "epic", scopeRef: "issue-303", id: "rec-42" });
|
|
39
|
+
assert.equal(recordRelativePath(r), `${LAYOUT_ROOT}/epic/issue-303/rec-42.json`);
|
|
40
|
+
// Same record → same path (idempotent write target).
|
|
41
|
+
assert.equal(recordRelativePath(r), recordRelativePath({ ...r }));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("records without scopeRef land in the shared bucket", () => {
|
|
45
|
+
const r = record({ scope: "corpus", id: "global-1" });
|
|
46
|
+
assert.equal(recordRelativePath(r), `${LAYOUT_ROOT}/corpus/${UNSCOPED_BUCKET}/global-1.json`);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("sanitizeSegment neutralises path traversal and separators", () => {
|
|
50
|
+
// A traversal attempt can never produce a `..` or a separator.
|
|
51
|
+
const nasty = sanitizeSegment("../../etc/passwd");
|
|
52
|
+
assert.ok(!nasty.includes("/"));
|
|
53
|
+
assert.ok(!nasty.includes(".."));
|
|
54
|
+
assert.notEqual(nasty, "");
|
|
55
|
+
// A clean value is preserved verbatim.
|
|
56
|
+
assert.equal(sanitizeSegment("rec-1"), "rec-1");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("distinct lossy ids never collide onto one segment", () => {
|
|
60
|
+
const a = sanitizeSegment("a/b");
|
|
61
|
+
const b = sanitizeSegment("a\\b");
|
|
62
|
+
assert.notEqual(a, b);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("a traversal id cannot escape the layout root", () => {
|
|
66
|
+
const r = record({ scope: "epic", scopeRef: "../../escape", id: "../../../root" });
|
|
67
|
+
const path = recordRelativePath(r);
|
|
68
|
+
assert.ok(path.startsWith(`${LAYOUT_ROOT}/epic/`));
|
|
69
|
+
assert.ok(!path.includes(".."));
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("isRecordPath selects record files under the layout root", () => {
|
|
73
|
+
assert.equal(isRecordPath("records/epic/issue-303/rec-42.json"), true);
|
|
74
|
+
assert.equal(isRecordPath("./records/corpus/_/global-1.json"), true);
|
|
75
|
+
assert.equal(isRecordPath("records\\epic\\x\\r.json"), true);
|
|
76
|
+
assert.equal(isRecordPath("records/epic/x/notes.md"), false);
|
|
77
|
+
assert.equal(isRecordPath("other/epic/x/r.json"), false);
|
|
78
|
+
assert.equal(isRecordPath("records/../secrets/r.json"), false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("sanitizeSegment eliminates internal '..' sequences", () => {
|
|
82
|
+
// An internal `..` (e.g. "a..b") must not survive: `isRecordPath` rejects any
|
|
83
|
+
// path containing "..", so a record whose id/scopeRef carried one would be
|
|
84
|
+
// invisible to retrieval/CI. It is disambiguated with a hash, never dropped.
|
|
85
|
+
const seg = sanitizeSegment("a..b");
|
|
86
|
+
assert.ok(!seg.includes(".."));
|
|
87
|
+
assert.notEqual(seg, sanitizeSegment("a.b"));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("a sanitised scopeRef can never collide with UNSCOPED_BUCKET", () => {
|
|
91
|
+
// A raw scopeRef of exactly "_" must NOT map onto the shared unscoped bucket,
|
|
92
|
+
// otherwise a scoped record and an unscoped record share a directory.
|
|
93
|
+
const seg = sanitizeSegment(UNSCOPED_BUCKET);
|
|
94
|
+
assert.notEqual(seg, UNSCOPED_BUCKET);
|
|
95
|
+
assert.notEqual(
|
|
96
|
+
recordDir("epic", UNSCOPED_BUCKET),
|
|
97
|
+
recordDir("epic"),
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("every recordRelativePath output is a valid isRecordPath (no writer/scan drift)", () => {
|
|
102
|
+
// The writer's output and the retrieval/CI scanner must agree by construction:
|
|
103
|
+
// any path recordRelativePath emits must be selected by isRecordPath, for even
|
|
104
|
+
// adversarial ids/scopeRefs.
|
|
105
|
+
const adversarial = ["a..b", "..", ".", "_", "../../escape", "", " ", "a/b\\c"];
|
|
106
|
+
for (const scopeRef of adversarial) {
|
|
107
|
+
for (const id of adversarial) {
|
|
108
|
+
const path = recordRelativePath(record({ scope: "epic", scopeRef, id }));
|
|
109
|
+
assert.equal(isRecordPath(path), true, `not a record path: ${path}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Slice S3 (git/governance) — the on-disk path / namespace partitioning.
|
|
2
|
+
//
|
|
3
|
+
// Records are persisted into the substrate repo under a stable, deterministic
|
|
4
|
+
// layout keyed by the record's scope (and, where present, its `scopeRef`). This
|
|
5
|
+
// helper is the SINGLE SOURCE OF TRUTH for that layout: the write path (this
|
|
6
|
+
// slice) writes to it, and BOTH S4 (retrieval) and the S6 CI slice READ it, so
|
|
7
|
+
// it is exported as a small, stable surface rather than duplicated per-consumer.
|
|
8
|
+
//
|
|
9
|
+
// The scheme is:
|
|
10
|
+
//
|
|
11
|
+
// records/<scope>/<scopeRef-bucket>/<id>.json
|
|
12
|
+
//
|
|
13
|
+
// where `<scope>` is a controlled-vocabulary ladder level (element … corpus),
|
|
14
|
+
// `<scopeRef-bucket>` is the sanitised `scopeRef` (or the shared `_` bucket when
|
|
15
|
+
// a record carries no `scopeRef`), and `<id>` is the sanitised record id. Every
|
|
16
|
+
// path component is sanitised to a filesystem-safe segment that can never
|
|
17
|
+
// contain a path separator or `..`, so an untrusted `id`/`scopeRef` can never
|
|
18
|
+
// escape the layout root (path-traversal safe by construction).
|
|
19
|
+
|
|
20
|
+
import { createHash } from "node:crypto";
|
|
21
|
+
import type { MemoryRecord, MemoryScope } from "../schema/index.ts";
|
|
22
|
+
|
|
23
|
+
/** The directory (relative to the substrate root) all records live under. */
|
|
24
|
+
export const LAYOUT_ROOT = "records" as const;
|
|
25
|
+
|
|
26
|
+
/** The on-disk extension every serialised record file carries. */
|
|
27
|
+
export const RECORD_FILE_EXTENSION = ".json" as const;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The bucket directory used for records that carry no `scopeRef` (e.g. a
|
|
31
|
+
* `corpus`-scoped record). `sanitizeSegment` never returns exactly `"_"` — an
|
|
32
|
+
* input that would sanitise to `"_"` is treated as reserved and gets a `_<hash>`
|
|
33
|
+
* form instead — so a sanitised `scopeRef` can never collide with this bucket.
|
|
34
|
+
*/
|
|
35
|
+
export const UNSCOPED_BUCKET = "_" as const;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Reduce an untrusted string to a single filesystem-safe path segment.
|
|
39
|
+
*
|
|
40
|
+
* The result contains only `[A-Za-z0-9._-]`, never a path separator, never a
|
|
41
|
+
* `..` sequence, and is never `""`, `.`, `..`, or exactly `"_"` — so it can
|
|
42
|
+
* never traverse out of its parent directory nor collide with
|
|
43
|
+
* {@link UNSCOPED_BUCKET}. When sanitisation is LOSSY (the cleaned form differs
|
|
44
|
+
* from the input, or collapses to a reserved value) a short hash of the ORIGINAL
|
|
45
|
+
* value is appended, so two distinct raw values can never silently collide onto
|
|
46
|
+
* one segment while a clean value is preserved verbatim.
|
|
47
|
+
*/
|
|
48
|
+
export function sanitizeSegment(value: string): string {
|
|
49
|
+
const cleaned = value
|
|
50
|
+
.trim()
|
|
51
|
+
.replace(/[^A-Za-z0-9._-]+/g, "-")
|
|
52
|
+
.replace(/\.{2,}/g, ".")
|
|
53
|
+
.replace(/^[-.]+|[-.]+$/g, "")
|
|
54
|
+
.slice(0, 100);
|
|
55
|
+
const reserved =
|
|
56
|
+
cleaned === "" ||
|
|
57
|
+
cleaned === "." ||
|
|
58
|
+
cleaned === ".." ||
|
|
59
|
+
cleaned === UNSCOPED_BUCKET;
|
|
60
|
+
if (reserved) {
|
|
61
|
+
return `_${shortHash(value)}`;
|
|
62
|
+
}
|
|
63
|
+
// Preserve a clean, reversible value verbatim; disambiguate a lossy one so
|
|
64
|
+
// distinct inputs never map to the same segment.
|
|
65
|
+
return cleaned === value ? cleaned : `${cleaned}-${shortHash(value)}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function shortHash(value: string): string {
|
|
69
|
+
return createHash("sha256").update(value, "utf8").digest("hex").slice(0, 12);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The directory (relative to the substrate root) that holds a whole scope. */
|
|
73
|
+
export function scopeDir(scope: MemoryScope): string {
|
|
74
|
+
return `${LAYOUT_ROOT}/${scope}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The directory a record with the given `scope`/`scopeRef` is partitioned into.
|
|
79
|
+
* Records sharing a `(scope, scopeRef)` namespace land in the same directory, so
|
|
80
|
+
* a retrieval slice can list one namespace cheaply.
|
|
81
|
+
*/
|
|
82
|
+
export function recordDir(scope: MemoryScope, scopeRef?: string): string {
|
|
83
|
+
const bucket = scopeRef === undefined ? UNSCOPED_BUCKET : sanitizeSegment(scopeRef);
|
|
84
|
+
return `${scopeDir(scope)}/${bucket}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The full path (relative to the substrate root, POSIX separators) a record is
|
|
89
|
+
* persisted at. Deterministic: the same record id/scope always maps to the same
|
|
90
|
+
* path, so re-appending an updated record with the same id overwrites its file
|
|
91
|
+
* in place while git retains the full append-only history.
|
|
92
|
+
*/
|
|
93
|
+
export function recordRelativePath(record: MemoryRecord): string {
|
|
94
|
+
return `${recordDir(record.scope, record.scopeRef)}/${sanitizeSegment(record.id)}${RECORD_FILE_EXTENSION}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* `true` iff `relPath` looks like a record file under the layout root (used by
|
|
99
|
+
* S4 retrieval and the S6 CI scan to select record files while walking the
|
|
100
|
+
* substrate). Accepts POSIX or native separators and rejects anything that is
|
|
101
|
+
* not a `.json` file beneath `records/`.
|
|
102
|
+
*/
|
|
103
|
+
export function isRecordPath(relPath: string): boolean {
|
|
104
|
+
const normalised = relPath.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
105
|
+
return (
|
|
106
|
+
normalised.startsWith(`${LAYOUT_ROOT}/`) &&
|
|
107
|
+
normalised.endsWith(RECORD_FILE_EXTENSION) &&
|
|
108
|
+
!normalised.includes("..")
|
|
109
|
+
);
|
|
110
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// Slice S3 (git/governance) — the WRITE substrate seam.
|
|
2
|
+
//
|
|
3
|
+
// The write path is deliberately abstracted over a {@link WriteSubstrate}
|
|
4
|
+
// interface rather than hard-wired to public git, leaving a clean seam for a
|
|
5
|
+
// future PII/mutable or self-hosted backend (the same seam S1's read side keeps
|
|
6
|
+
// via `SubstrateBackend`). {@link GitWriteSubstrate} is the default, git-only
|
|
7
|
+
// MVP implementation: it composes append-via-commit, branch creation, checkout,
|
|
8
|
+
// and merge primitives over a local working copy using an injectable
|
|
9
|
+
// {@link GitRunner} (so tests drive a local temp repo without any network).
|
|
10
|
+
|
|
11
|
+
import { execFile } from "node:child_process";
|
|
12
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
13
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
14
|
+
import { promisify } from "node:util";
|
|
15
|
+
import { hardenedGitArgs, redactUrlUserinfo, type GitRunner } from "../binding/index.ts";
|
|
16
|
+
|
|
17
|
+
const execFileAsync = promisify(execFile);
|
|
18
|
+
|
|
19
|
+
/** Error thrown when a substrate write/governance git operation fails. */
|
|
20
|
+
export class SubstrateWriteError extends Error {
|
|
21
|
+
constructor(message: string, options?: { cause?: unknown }) {
|
|
22
|
+
super(message, options);
|
|
23
|
+
this.name = "SubstrateWriteError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const defaultWriteGitRunner: GitRunner = async (args, cwd) => {
|
|
28
|
+
const hardened = hardenedGitArgs(args);
|
|
29
|
+
try {
|
|
30
|
+
const { stdout } = await execFileAsync("git", hardened, {
|
|
31
|
+
cwd,
|
|
32
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
33
|
+
});
|
|
34
|
+
return stdout;
|
|
35
|
+
} catch (cause) {
|
|
36
|
+
const label = `git ${hardened.map(redactUrlUserinfo).join(" ")}`;
|
|
37
|
+
throw new SubstrateWriteError(`substrate git command failed: ${label}`, { cause });
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** The identity a commit/merge is attributed to. */
|
|
42
|
+
export interface CommitAuthor {
|
|
43
|
+
readonly name: string;
|
|
44
|
+
readonly email: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The write seam the governance layer composes. A future mutable/PII backend can
|
|
49
|
+
* implement this interface differently (e.g. against a database with real
|
|
50
|
+
* erasure) without the writer or its callers changing. Every method operates on
|
|
51
|
+
* a single logical substrate working copy rooted at {@link rootPath}.
|
|
52
|
+
*/
|
|
53
|
+
export interface WriteSubstrate {
|
|
54
|
+
/** Absolute path to the substrate working copy. */
|
|
55
|
+
readonly rootPath: string;
|
|
56
|
+
/** Write `content` to `relPath` (relative to {@link rootPath}), creating dirs. */
|
|
57
|
+
writeRecordFile(relPath: string, content: string): Promise<void>;
|
|
58
|
+
/** Read the file at `relPath` as it exists on `ref`, without touching the tree. */
|
|
59
|
+
readFileAtRef(ref: string, relPath: string): Promise<string>;
|
|
60
|
+
/**
|
|
61
|
+
* The substrate-relative paths changed on `branch` relative to its merge-base
|
|
62
|
+
* with `baseRef` — i.e. exactly the set of files a `baseRef`-into-`branch` merge
|
|
63
|
+
* would introduce/modify. Lets a caller assert a merge is limited to expected
|
|
64
|
+
* files before performing it.
|
|
65
|
+
*/
|
|
66
|
+
diffPaths(baseRef: string, branch: string): Promise<readonly string[]>;
|
|
67
|
+
/** The name of the currently checked-out branch. */
|
|
68
|
+
currentBranch(): Promise<string>;
|
|
69
|
+
/** Create (or reset) branch `name` at `from` and check it out. */
|
|
70
|
+
createBranch(name: string, from: string): Promise<void>;
|
|
71
|
+
/** Check out an existing branch/ref. */
|
|
72
|
+
checkout(ref: string): Promise<void>;
|
|
73
|
+
/**
|
|
74
|
+
* Recover the shared working copy to a pristine checkout of `ref`: force-checks
|
|
75
|
+
* out `ref` — moving HEAD and discarding ALL tracked modifications across the
|
|
76
|
+
* whole working copy, not only those under `pathspec` — then removes untracked
|
|
77
|
+
* files/dirs UNDER `pathspec`, leaving untracked files OUTSIDE `pathspec`
|
|
78
|
+
* untouched. The broad tracked-file reset is intended: the substrate working
|
|
79
|
+
* copy is a single-writer, serialised recovery seam, so callers must not keep
|
|
80
|
+
* unrelated tracked edits in it across a write that might fail.
|
|
81
|
+
*/
|
|
82
|
+
restoreClean(ref: string, pathspec: string): Promise<void>;
|
|
83
|
+
/**
|
|
84
|
+
* Stage the changes UNDER `pathspec` (only) and commit them as `author`;
|
|
85
|
+
* returns the new commit sha. Staging is deliberately scoped to the caller's
|
|
86
|
+
* record path(s) — never a blanket `git add -A` — so an unrelated change
|
|
87
|
+
* elsewhere in the shared working copy can never be swept into a commit that
|
|
88
|
+
* the per-record PII guard only inspected the record content for.
|
|
89
|
+
*/
|
|
90
|
+
stageAndCommit(pathspec: string, message: string, author: CommitAuthor): Promise<string>;
|
|
91
|
+
/** Merge `branch` into the current branch as `author`; returns the merge sha. */
|
|
92
|
+
mergeBranch(branch: string, message: string, author: CommitAuthor): Promise<string>;
|
|
93
|
+
/** `true` iff a local branch named `name` exists. */
|
|
94
|
+
branchExists(name: string): Promise<boolean>;
|
|
95
|
+
/**
|
|
96
|
+
* `true` iff `commitish` is an ancestor of (or equal to) `ref` — i.e. landed
|
|
97
|
+
* on it. Both operands are git commit-ish inputs: a raw sha, a branch name, or
|
|
98
|
+
* any other ref resolvable to a commit. `ContextWriter.isRatified` deliberately
|
|
99
|
+
* passes a branch ref (the proposal branch tip) here to bind the check to the
|
|
100
|
+
* real tip rather than a caller-supplied sha, so implementations MUST resolve
|
|
101
|
+
* `commitish` as a general ref, not assume a sha-only input.
|
|
102
|
+
*/
|
|
103
|
+
isMerged(commitish: string, ref: string): Promise<boolean>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The default git-only write substrate. Composes commit/branch/merge primitives
|
|
108
|
+
* over a local working copy. Commit author identity is passed per-commit via
|
|
109
|
+
* `-c user.name=… -c user.email=…`, so a bare temp repo with no configured user
|
|
110
|
+
* still commits cleanly (and every write path can attribute a bot vs. human
|
|
111
|
+
* author independently).
|
|
112
|
+
*/
|
|
113
|
+
export class GitWriteSubstrate implements WriteSubstrate {
|
|
114
|
+
readonly rootPath: string;
|
|
115
|
+
readonly #git: GitRunner;
|
|
116
|
+
|
|
117
|
+
constructor(rootPath: string, gitRunner: GitRunner = defaultWriteGitRunner) {
|
|
118
|
+
if (!isAbsolute(rootPath)) {
|
|
119
|
+
throw new SubstrateWriteError(`substrate rootPath must be absolute: ${rootPath}`);
|
|
120
|
+
}
|
|
121
|
+
this.rootPath = rootPath;
|
|
122
|
+
this.#git = gitRunner;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async writeRecordFile(relPath: string, content: string): Promise<void> {
|
|
126
|
+
const target = this.#resolveWithinRoot(relPath);
|
|
127
|
+
await mkdir(dirname(target), { recursive: true });
|
|
128
|
+
await writeFile(target, content, "utf8");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async readFileAtRef(ref: string, relPath: string): Promise<string> {
|
|
132
|
+
// Read the blob at `ref:relPath` via `git show` — a pure read that never
|
|
133
|
+
// mutates the working tree (no checkout), so a failed read/guard cannot leave
|
|
134
|
+
// the shared working copy on the wrong branch. `#resolveWithinRoot` still
|
|
135
|
+
// rejects any traversal path before it reaches git.
|
|
136
|
+
this.#resolveWithinRoot(relPath);
|
|
137
|
+
return this.#git(["show", "--end-of-options", `${ref}:${relPath}`], this.rootPath);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async diffPaths(baseRef: string, branch: string): Promise<readonly string[]> {
|
|
141
|
+
// `base...branch` (symmetric-difference range) lists files changed on `branch`
|
|
142
|
+
// since it forked from `baseRef` — precisely what a merge of `branch` would
|
|
143
|
+
// bring onto `baseRef`. `-z` NUL-delimits paths so unusual names never split
|
|
144
|
+
// wrong; `--end-of-options` stops a ref that looks like a flag being parsed.
|
|
145
|
+
const out = await this.#git(
|
|
146
|
+
["diff", "--name-only", "-z", "--end-of-options", `${baseRef}...${branch}`],
|
|
147
|
+
this.rootPath,
|
|
148
|
+
);
|
|
149
|
+
return out.split("\0").filter((p) => p.length > 0);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Resolve a caller-supplied substrate-relative path to an absolute path,
|
|
153
|
+
// refusing anything that escapes the substrate root. Defence in depth: the
|
|
154
|
+
// layout helper already produces traversal-safe paths, but no read/write may
|
|
155
|
+
// ever leave the substrate root regardless of the relPath handed in.
|
|
156
|
+
#resolveWithinRoot(relPath: string): string {
|
|
157
|
+
const target = resolve(this.rootPath, relPath);
|
|
158
|
+
const rel = relative(this.rootPath, target);
|
|
159
|
+
if (rel === "" || rel.startsWith(`..${sep}`) || rel === ".." || isAbsolute(rel)) {
|
|
160
|
+
throw new SubstrateWriteError(`record path escapes the substrate root: ${relPath}`);
|
|
161
|
+
}
|
|
162
|
+
return target;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async currentBranch(): Promise<string> {
|
|
166
|
+
const out = await this.#git(["rev-parse", "--abbrev-ref", "HEAD"], this.rootPath);
|
|
167
|
+
return out.trim();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async createBranch(name: string, from: string): Promise<void> {
|
|
171
|
+
// `checkout -B` creates or resets the branch to `from` and checks it out.
|
|
172
|
+
// `--end-of-options` stops an option-style ref from being parsed as a flag.
|
|
173
|
+
await this.#git(["checkout", "-B", name, "--end-of-options", from], this.rootPath);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async checkout(ref: string): Promise<void> {
|
|
177
|
+
await this.#git(["checkout", "--end-of-options", ref], this.rootPath);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async restoreClean(ref: string, pathspec: string): Promise<void> {
|
|
181
|
+
// `checkout --force <ref>` moves HEAD to `ref` and discards modifications to ALL
|
|
182
|
+
// tracked files across the working copy (not just under `pathspec`) — the full
|
|
183
|
+
// reset a post-failure recovery of this single-writer working copy wants. `clean
|
|
184
|
+
// -fd` then removes leftover UNTRACKED files/dirs, scoped to `pathspec` (the
|
|
185
|
+
// record layout root) so it never touches untracked files elsewhere. `--` ends
|
|
186
|
+
// option parsing before the pathspec so a path that looks like a flag is never
|
|
187
|
+
// misread as one.
|
|
188
|
+
await this.#git(["checkout", "--force", "--end-of-options", ref], this.rootPath);
|
|
189
|
+
await this.#git(["clean", "-fd", "--", pathspec], this.rootPath);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async stageAndCommit(
|
|
193
|
+
pathspec: string,
|
|
194
|
+
message: string,
|
|
195
|
+
author: CommitAuthor,
|
|
196
|
+
): Promise<string> {
|
|
197
|
+
// Stage ONLY the caller's record path(s) — `add -A -- <pathspec>` — never a
|
|
198
|
+
// blanket `git add -A`. A blanket stage would sweep ANY unrelated tracked or
|
|
199
|
+
// untracked change in the shared single-writer working copy into this commit,
|
|
200
|
+
// bypassing the per-record PII guard that inspected the record content alone
|
|
201
|
+
// (and risking committing stray residue outside the record layout). `-A` still
|
|
202
|
+
// captures adds/modifies/deletes, but confined to `pathspec`; `--` ends option
|
|
203
|
+
// parsing so a path that looks like a flag is never misread.
|
|
204
|
+
await this.#git(["add", "-A", "--", pathspec], this.rootPath);
|
|
205
|
+
await this.#git(
|
|
206
|
+
[
|
|
207
|
+
"-c",
|
|
208
|
+
`user.name=${author.name}`,
|
|
209
|
+
"-c",
|
|
210
|
+
`user.email=${author.email}`,
|
|
211
|
+
"commit",
|
|
212
|
+
"--no-gpg-sign",
|
|
213
|
+
"--allow-empty",
|
|
214
|
+
"-m",
|
|
215
|
+
message,
|
|
216
|
+
],
|
|
217
|
+
this.rootPath,
|
|
218
|
+
);
|
|
219
|
+
return this.#revParse("HEAD");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async mergeBranch(branch: string, message: string, author: CommitAuthor): Promise<string> {
|
|
223
|
+
// `--no-ff` always records a merge commit, so ratification (a merge) is a
|
|
224
|
+
// distinct, auditable event even when the branch could fast-forward.
|
|
225
|
+
try {
|
|
226
|
+
await this.#git(
|
|
227
|
+
[
|
|
228
|
+
"-c",
|
|
229
|
+
`user.name=${author.name}`,
|
|
230
|
+
"-c",
|
|
231
|
+
`user.email=${author.email}`,
|
|
232
|
+
"merge",
|
|
233
|
+
"--no-ff",
|
|
234
|
+
"--no-gpg-sign",
|
|
235
|
+
"-m",
|
|
236
|
+
message,
|
|
237
|
+
"--end-of-options",
|
|
238
|
+
branch,
|
|
239
|
+
],
|
|
240
|
+
this.rootPath,
|
|
241
|
+
);
|
|
242
|
+
} catch (cause) {
|
|
243
|
+
// A failed merge (e.g. a content conflict) leaves the working tree in a
|
|
244
|
+
// conflicted MERGE_HEAD state. Abort it best-effort so the shared working
|
|
245
|
+
// copy is clean for the next serialised operation, then surface the cause.
|
|
246
|
+
await this.#git(["merge", "--abort"], this.rootPath).catch(() => {});
|
|
247
|
+
throw cause;
|
|
248
|
+
}
|
|
249
|
+
return this.#revParse("HEAD");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async branchExists(name: string): Promise<boolean> {
|
|
253
|
+
try {
|
|
254
|
+
await this.#git(["show-ref", "--verify", "--quiet", `refs/heads/${name}`], this.rootPath);
|
|
255
|
+
return true;
|
|
256
|
+
} catch {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async isMerged(commitish: string, ref: string): Promise<boolean> {
|
|
262
|
+
try {
|
|
263
|
+
// `--end-of-options` (as every other ref-passing call in this file) stops an
|
|
264
|
+
// option-style `commitish`/`ref` (e.g. a caller-supplied baseBranch override)
|
|
265
|
+
// from being parsed as a `git merge-base` flag rather than a revision.
|
|
266
|
+
await this.#git(
|
|
267
|
+
["merge-base", "--is-ancestor", "--end-of-options", commitish, ref],
|
|
268
|
+
this.rootPath,
|
|
269
|
+
);
|
|
270
|
+
return true;
|
|
271
|
+
} catch {
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async #revParse(ref: string): Promise<string> {
|
|
277
|
+
// `git rev-parse` echoes `--end-of-options` back as output, so it is omitted
|
|
278
|
+
// here; `ref` on this path is always an internally-produced value (`HEAD`).
|
|
279
|
+
const out = await this.#git(["rev-parse", ref], this.rootPath);
|
|
280
|
+
return out.trim();
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Join a substrate-relative path onto an absolute root (POSIX-in, native-out). */
|
|
285
|
+
export function substratePath(root: string, relPath: string): string {
|
|
286
|
+
return join(root, relPath);
|
|
287
|
+
}
|