mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 22:58:42 -10:00
feat(gateway): human-fragment schema + generated registry projection [M1 A3 pt1]
M1 gateway A3 (part 1: the fragment/registry CONTRACT + proof-5). Schema sealed b2a2594b
(supersedes e499dab6): prefs PER-ENTITY (loudness once per human), role PER-BINDING (routing;
exactly-one-primary = default channel) — two distinct axes; deliveryClass+away only.
- Fragment = one YAML per human at <home>/gateway/humans/<entityId>.yaml. Closed enums
(class 'human', kind 'slack', role primary|secondary, deliveryClass A-D); entityId is a
slug = filename key; secretsRef is a vault POINTER (never the secret). validateHumanFragment
enforces >=1 binding + EXACTLY-ONE-primary-per-entity; add-time == load-time.
- Registry = GENERATED projection (humans.generated.yaml, DO-NOT-EDIT header) via the ONE
projectHumans generator; addHumanFragment is the verb-add-only atomic writer (tmp+rename,
re-projects). loadHumanRegistry REFUSES a hand-edited/drifted projection (fragment is truth)
— the runtime drift pin. operator-human@kernel = the fallback slot, NOT a fragment.
- Mirrors two precedents: hosts-registry addHostEntry (atomic verb-add + add==load validation)
+ the codegen-mirror DO-NOT-EDIT/drift convention.
PROOF-5 GREEN (16/16, hermetic tmp-home): two fragments -> sorted projection; a fragment edit
re-projects; a projection hand-edit is REFUSED at load; invalid fragment + filename!=entityId
fail LOUD. tsc 0. Based on local main (A2 fold-33 3e688f63). Never push.
PART 2 (next, noted for review): the `rig gateway human add` CLI verb (nested-binding ergonomics)
+ the daemon-side reader A4 resolves against. FLAG-1/FLAG-2 ruled per b2a2594b (per-entity prefs,
per-binding role). operator-human@kernel default-slot resolution wiring rides A6/A4.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
3e688f6347
commit
87bcbfa6a6
2 files changed
+394
No files matched your search
@@ -0,0 +1,236 @@
|
||||
// OPR gateway M1 A3 — human specs: file-per-human FRAGMENTS + a GENERATED registry
|
||||
// projection. Schema sealed in GATEWAY-M1-A3-ENTITY-SCHEMA (b2a2594b, supersedes
|
||||
// e499dab6): prefs are PER-ENTITY (loudness once per human) and `role` is PER-BINDING
|
||||
// (routing / default delivery channel) — two DISTINCT axes.
|
||||
//
|
||||
// TRUTH MODEL (design-record §7 / plan §1): each human is one YAML fragment at
|
||||
// <home>/gateway/humans/<entityId>.yaml. The registry is a GENERATED PROJECTION of
|
||||
// the fragments (humans.generated.yaml) — NEVER hand-edited; the fragment is truth.
|
||||
// The projection carries a DO-NOT-EDIT header and a drift pin (mirrors the
|
||||
// attestation-lineage.generated codegen convention). Admission/resolution is the
|
||||
// A1/A4 gateway's job — this module only owns the fragment + projection contract.
|
||||
//
|
||||
// Validation is add-time == load-time (the hosts-registry entry pattern): the SAME
|
||||
// validateHumanFragment runs on `add` and on load, so a present-but-invalid fragment
|
||||
// is a loud error, never silently projected.
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync, readdirSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
||||
import { getOpenRigHome } from "../openrig-compat.js";
|
||||
|
||||
// ── Schema (closed enums; extend only additively behind the contract) ──
|
||||
export const HUMAN_ENTITY_CLASSES = new Set(["human"]); // M1's only class
|
||||
export const HUMAN_CONNECTOR_KINDS = new Set(["slack"]); // M1's only kind
|
||||
export const HUMAN_BINDING_ROLES = new Set(["primary", "secondary"]);
|
||||
export const HUMAN_DELIVERY_CLASSES = new Set(["A", "B", "C", "D"]);
|
||||
// entityId is the fragment key + filename: a stable slug that survives platform
|
||||
// renames. Lowercase alnum + separators, no path chars (it is spliced into a path).
|
||||
export const ENTITY_ID_PATTERN = /^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/;
|
||||
|
||||
export interface HumanConnectorBinding {
|
||||
kind: "slack";
|
||||
connectorRef: string;
|
||||
/** POINTER at the connector's vault (secrets-on-connector) — NEVER the secret. */
|
||||
secretsRef: string;
|
||||
/** Per-BINDING routing: EXACTLY ONE primary per entity = the default channel. */
|
||||
role: "primary" | "secondary";
|
||||
}
|
||||
|
||||
export interface HumanPrefs {
|
||||
/** Per-ENTITY loudness — a SELECTION from the notifications register (spec §6);
|
||||
* A3 carries it, never redefines it. Forward-compatible if the register grows. */
|
||||
deliveryClass: "A" | "B" | "C" | "D";
|
||||
/** The AWAY preset (optional). */
|
||||
away?: boolean;
|
||||
}
|
||||
|
||||
export interface HumanFragment {
|
||||
entityId: string;
|
||||
class: "human";
|
||||
displayName: string;
|
||||
/** The registered @external ref, = <entityId>@external by convention. */
|
||||
address: string;
|
||||
connectorBindings: HumanConnectorBinding[];
|
||||
prefs: HumanPrefs;
|
||||
}
|
||||
|
||||
export type ValidateResult =
|
||||
| { ok: true; fragment: HumanFragment }
|
||||
| { ok: false; error: string };
|
||||
|
||||
function isObj(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
/** add-time == load-time validation. Returns the typed fragment or a loud error.
|
||||
* Structural + closed-enum + the two cross-field invariants (>=1 binding,
|
||||
* exactly-one-primary-per-entity). Does NOT touch the filesystem. */
|
||||
export function validateHumanFragment(raw: unknown): ValidateResult {
|
||||
if (!isObj(raw)) return { ok: false, error: "human fragment must be a mapping" };
|
||||
const { entityId, class: cls, displayName, address, connectorBindings, prefs } = raw;
|
||||
|
||||
if (typeof entityId !== "string" || !ENTITY_ID_PATTERN.test(entityId)) {
|
||||
return { ok: false, error: `entityId "${String(entityId)}" must be a lowercase slug (a-z0-9._- , no leading/trailing separator)` };
|
||||
}
|
||||
if (cls !== "human") {
|
||||
return { ok: false, error: `class "${String(cls)}" is not a known entity class (M1: ${[...HUMAN_ENTITY_CLASSES].join(", ")})` };
|
||||
}
|
||||
if (typeof displayName !== "string" || displayName.length === 0) {
|
||||
return { ok: false, error: "displayName must be a non-empty string" };
|
||||
}
|
||||
if (typeof address !== "string" || address.length === 0) {
|
||||
return { ok: false, error: "address must be a non-empty string (the registered @external ref)" };
|
||||
}
|
||||
if (!Array.isArray(connectorBindings) || connectorBindings.length < 1) {
|
||||
return { ok: false, error: "connectorBindings must be a non-empty list (>=1)" };
|
||||
}
|
||||
|
||||
const bindings: HumanConnectorBinding[] = [];
|
||||
let primaryCount = 0;
|
||||
for (let i = 0; i < connectorBindings.length; i++) {
|
||||
const b = connectorBindings[i];
|
||||
if (!isObj(b)) return { ok: false, error: `connectorBindings[${i}] must be a mapping` };
|
||||
if (!HUMAN_CONNECTOR_KINDS.has(String(b.kind))) {
|
||||
return { ok: false, error: `connectorBindings[${i}].kind "${String(b.kind)}" is not a known connector kind (M1: ${[...HUMAN_CONNECTOR_KINDS].join(", ")})` };
|
||||
}
|
||||
if (typeof b.connectorRef !== "string" || b.connectorRef.length === 0) {
|
||||
return { ok: false, error: `connectorBindings[${i}].connectorRef must be a non-empty string` };
|
||||
}
|
||||
if (typeof b.secretsRef !== "string" || b.secretsRef.length === 0) {
|
||||
return { ok: false, error: `connectorBindings[${i}].secretsRef must be a non-empty vault POINTER (never the secret)` };
|
||||
}
|
||||
if (!HUMAN_BINDING_ROLES.has(String(b.role))) {
|
||||
return { ok: false, error: `connectorBindings[${i}].role "${String(b.role)}" must be primary|secondary` };
|
||||
}
|
||||
if (b.role === "primary") primaryCount++;
|
||||
bindings.push({ kind: "slack", connectorRef: b.connectorRef, secretsRef: b.secretsRef, role: b.role as "primary" | "secondary" });
|
||||
}
|
||||
// EXACTLY ONE primary binding per entity = the default delivery channel.
|
||||
if (primaryCount !== 1) {
|
||||
return { ok: false, error: `exactly one connectorBinding must have role "primary" (found ${primaryCount}) — it is the default delivery channel` };
|
||||
}
|
||||
|
||||
if (!isObj(prefs)) return { ok: false, error: "prefs must be a mapping { deliveryClass, away? }" };
|
||||
if (!HUMAN_DELIVERY_CLASSES.has(String(prefs.deliveryClass))) {
|
||||
return { ok: false, error: `prefs.deliveryClass "${String(prefs.deliveryClass)}" must be one of A|B|C|D (the notifications register)` };
|
||||
}
|
||||
if (prefs.away !== undefined && typeof prefs.away !== "boolean") {
|
||||
return { ok: false, error: "prefs.away must be a boolean when present" };
|
||||
}
|
||||
const validatedPrefs: HumanPrefs = { deliveryClass: prefs.deliveryClass as HumanPrefs["deliveryClass"] };
|
||||
if (prefs.away !== undefined) validatedPrefs.away = prefs.away;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
fragment: { entityId, class: "human", displayName, address, connectorBindings: bindings, prefs: validatedPrefs },
|
||||
};
|
||||
}
|
||||
|
||||
// ── Paths (under getOpenRigHome(); `home` injectable for hermetic tests) ──
|
||||
export function humansDir(home: string = getOpenRigHome()): string {
|
||||
return join(home, "gateway", "humans");
|
||||
}
|
||||
export function projectionPath(home: string = getOpenRigHome()): string {
|
||||
return join(home, "gateway", "humans.generated.yaml");
|
||||
}
|
||||
|
||||
/** The default inbound slot when no addressable entity resolves (slice-11 default,
|
||||
* unchanged). NOT a fragment — the fallback target, never an entities[] entry. */
|
||||
export const OPERATOR_HUMAN_DEFAULT_SLOT = "operator-human@kernel";
|
||||
|
||||
const PROJECTION_HEADER =
|
||||
"# GENERATED FILE — DO NOT EDIT.\n" +
|
||||
"# Projection of the human fragments under gateway/humans/<entityId>.yaml.\n" +
|
||||
"# The fragment is truth: add/edit a human via its fragment (or `rig gateway human\n" +
|
||||
"# add`), then re-project. A hand-edit here is REFUSED at load.\n";
|
||||
|
||||
export type ProjectResult =
|
||||
| { ok: true; body: string; entities: HumanFragment[] }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/** Generate the canonical registry projection from the fragments (the ONE generator
|
||||
* used by both the write path and the drift/load check). Load-time validation is the
|
||||
* SAME validateHumanFragment as add-time; the filename must equal <entityId>.yaml
|
||||
* (collision-free key). Entities are sorted by entityId for a stable, diffable body. */
|
||||
export function projectHumans(home: string = getOpenRigHome()): ProjectResult {
|
||||
const dir = humansDir(home);
|
||||
const entities: HumanFragment[] = [];
|
||||
if (existsSync(dir)) {
|
||||
const files = readdirSync(dir).filter((f) => f.endsWith(".yaml") && !f.startsWith(".")).sort();
|
||||
for (const f of files) {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = parseYaml(readFileSync(join(dir, f), "utf8"));
|
||||
} catch (err) {
|
||||
return { ok: false, error: `failed to parse human fragment ${f}: ${(err as Error).message}` };
|
||||
}
|
||||
const v = validateHumanFragment(raw);
|
||||
if (!v.ok) return { ok: false, error: `invalid human fragment ${f}: ${v.error}` };
|
||||
if (`${v.fragment.entityId}.yaml` !== f) {
|
||||
return { ok: false, error: `human fragment ${f} declares entityId "${v.fragment.entityId}" — the filename must be <entityId>.yaml` };
|
||||
}
|
||||
entities.push(v.fragment);
|
||||
}
|
||||
}
|
||||
entities.sort((a, b) => (a.entityId < b.entityId ? -1 : a.entityId > b.entityId ? 1 : 0));
|
||||
return { ok: true, body: PROJECTION_HEADER + stringifyYaml({ entities }), entities };
|
||||
}
|
||||
|
||||
function atomicWrite(path: string, body: string): { ok: true } | { ok: false; error: string } {
|
||||
try {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const tmp = `${path}.tmp-${process.pid}`;
|
||||
writeFileSync(tmp, body, { mode: 0o600 });
|
||||
renameSync(tmp, path);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, error: `failed to write ${path}: ${(err as Error).message}` };
|
||||
}
|
||||
}
|
||||
|
||||
/** (Re)write the generated projection from the current fragments. Atomic. */
|
||||
export function writeProjection(home: string = getOpenRigHome()): { ok: true; path: string } | { ok: false; error: string } {
|
||||
const proj = projectHumans(home);
|
||||
if (!proj.ok) return { ok: false, error: proj.error };
|
||||
const path = projectionPath(home);
|
||||
const w = atomicWrite(path, proj.body);
|
||||
return w.ok ? { ok: true, path } : w;
|
||||
}
|
||||
|
||||
export type AddHumanResult =
|
||||
| { ok: true; path: string; fragment: HumanFragment }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/** The verb-add writer: validate (add-time) -> atomic write the ONE fragment file ->
|
||||
* re-project. Operators never hand-create the fragment YAML; the verb owns it. */
|
||||
export function addHumanFragment(raw: unknown, home: string = getOpenRigHome()): AddHumanResult {
|
||||
const v = validateHumanFragment(raw);
|
||||
if (!v.ok) return { ok: false, error: v.error };
|
||||
const file = join(humansDir(home), `${v.fragment.entityId}.yaml`);
|
||||
const w = atomicWrite(file, stringifyYaml(v.fragment));
|
||||
if (!w.ok) return { ok: false, error: w.error };
|
||||
const proj = writeProjection(home);
|
||||
if (!proj.ok) return { ok: false, error: `fragment written but re-projection failed: ${proj.error}` };
|
||||
return { ok: true, path: file, fragment: v.fragment };
|
||||
}
|
||||
|
||||
export type LoadResult =
|
||||
| { ok: true; entities: HumanFragment[] }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/** Load the registry via the projection, but REFUSE a hand-edited/drifted projection:
|
||||
* the fragments are truth, so the on-disk projection must byte-match a fresh one.
|
||||
* This is the drift pin's runtime half (the vitest parity pin is the CI half). */
|
||||
export function loadHumanRegistry(home: string = getOpenRigHome()): LoadResult {
|
||||
const proj = projectHumans(home);
|
||||
if (!proj.ok) return { ok: false, error: proj.error };
|
||||
const path = projectionPath(home);
|
||||
if (!existsSync(path)) {
|
||||
return { ok: false, error: `registry projection missing at ${path} — re-project from the fragments` };
|
||||
}
|
||||
if (readFileSync(path, "utf8") !== proj.body) {
|
||||
return { ok: false, error: `registry projection at ${path} is HAND-EDITED or DRIFTED from the fragments — the fragment is truth; re-project, never hand-edit` };
|
||||
}
|
||||
return { ok: true, entities: proj.entities };
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { stringify as stringifyYaml } from "yaml";
|
||||
import {
|
||||
validateHumanFragment,
|
||||
addHumanFragment,
|
||||
projectHumans,
|
||||
writeProjection,
|
||||
loadHumanRegistry,
|
||||
humansDir,
|
||||
projectionPath,
|
||||
OPERATOR_HUMAN_DEFAULT_SLOT,
|
||||
type HumanFragment,
|
||||
} from "../src/gateway/human-registry.js";
|
||||
|
||||
// M1 A3 — human fragments + generated registry projection. Schema b2a2594b
|
||||
// (prefs per-ENTITY, role per-BINDING). Proof-5: two fragments -> projection;
|
||||
// a fragment edit re-projects; the projection hand-edit refuses.
|
||||
|
||||
function fragment(over: Partial<HumanFragment> = {}): Record<string, unknown> {
|
||||
return {
|
||||
entityId: "mike",
|
||||
class: "human",
|
||||
displayName: "Mike",
|
||||
address: "mike@external",
|
||||
connectorBindings: [
|
||||
{ kind: "slack", connectorRef: "slack-main", secretsRef: "vault://slack/mike", role: "primary" },
|
||||
],
|
||||
prefs: { deliveryClass: "B" },
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("A3 human-fragment validation (add-time == load-time)", () => {
|
||||
it("accepts a well-formed fragment", () => {
|
||||
const r = validateHumanFragment(fragment());
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an unknown class (closed enum)", () => {
|
||||
const r = validateHumanFragment(fragment({ class: "agent" as HumanFragment["class"] }));
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an unknown connector kind (closed enum)", () => {
|
||||
const r = validateHumanFragment({ ...fragment(), connectorBindings: [{ kind: "telegram", connectorRef: "x", secretsRef: "v", role: "primary" }] });
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects zero connectorBindings (>=1)", () => {
|
||||
const r = validateHumanFragment({ ...fragment(), connectorBindings: [] });
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an entity with NO primary binding (exactly-one-primary)", () => {
|
||||
const r = validateHumanFragment({ ...fragment(), connectorBindings: [{ kind: "slack", connectorRef: "x", secretsRef: "v", role: "secondary" }] });
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an entity with TWO primary bindings (exactly-one-primary)", () => {
|
||||
const r = validateHumanFragment({ ...fragment(), connectorBindings: [
|
||||
{ kind: "slack", connectorRef: "a", secretsRef: "v1", role: "primary" },
|
||||
{ kind: "slack", connectorRef: "b", secretsRef: "v2", role: "primary" },
|
||||
] });
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts multiple bindings with exactly one primary (per-binding routing)", () => {
|
||||
const r = validateHumanFragment({ ...fragment(), connectorBindings: [
|
||||
{ kind: "slack", connectorRef: "a", secretsRef: "v1", role: "primary" },
|
||||
{ kind: "slack", connectorRef: "b", secretsRef: "v2", role: "secondary" },
|
||||
] });
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a bad deliveryClass (A-D closed set)", () => {
|
||||
const r = validateHumanFragment(fragment({ prefs: { deliveryClass: "E" } as HumanFragment["prefs"] }));
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an empty secretsRef (pointer required, never the secret)", () => {
|
||||
const r = validateHumanFragment({ ...fragment(), connectorBindings: [{ kind: "slack", connectorRef: "x", secretsRef: "", role: "primary" }] });
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a non-slug entityId", () => {
|
||||
expect(validateHumanFragment(fragment({ entityId: "Mike Jones" })).ok).toBe(false);
|
||||
expect(validateHumanFragment(fragment({ entityId: "../evil" })).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("operator-human@kernel is the fallback slot, NOT a fragment", () => {
|
||||
expect(OPERATOR_HUMAN_DEFAULT_SLOT).toBe("operator-human@kernel");
|
||||
});
|
||||
});
|
||||
|
||||
describe("A3 proof-5 — fragments -> generated projection; re-project; hand-edit refuses", () => {
|
||||
let home: string;
|
||||
beforeEach(() => { home = mkdtempSync(join(tmpdir(), "a3-humans-")); });
|
||||
afterEach(() => { rmSync(home, { recursive: true, force: true }); });
|
||||
|
||||
it("two fragments (founder + one) -> projection lists BOTH, sorted, DO-NOT-EDIT header", () => {
|
||||
expect(addHumanFragment(fragment({ entityId: "mike", address: "mike@external" }), home).ok).toBe(true);
|
||||
expect(addHumanFragment(fragment({ entityId: "founder", displayName: "Founder", address: "founder@external" }), home).ok).toBe(true);
|
||||
|
||||
// both fragment files exist
|
||||
expect(existsSync(join(humansDir(home), "mike.yaml"))).toBe(true);
|
||||
expect(existsSync(join(humansDir(home), "founder.yaml"))).toBe(true);
|
||||
|
||||
const proj = readFileSync(projectionPath(home), "utf8");
|
||||
expect(proj).toContain("GENERATED FILE — DO NOT EDIT");
|
||||
|
||||
const loaded = loadHumanRegistry(home);
|
||||
expect(loaded.ok).toBe(true);
|
||||
if (loaded.ok) {
|
||||
expect(loaded.entities.map((e) => e.entityId)).toEqual(["founder", "mike"]); // sorted
|
||||
}
|
||||
});
|
||||
|
||||
it("a fragment EDIT re-projects (projection tracks the fragment = truth)", () => {
|
||||
addHumanFragment(fragment({ entityId: "mike", prefs: { deliveryClass: "B" } }), home);
|
||||
const before = readFileSync(projectionPath(home), "utf8");
|
||||
// edit the fragment via the verb (deliveryClass B -> D) + re-project
|
||||
addHumanFragment(fragment({ entityId: "mike", prefs: { deliveryClass: "D" } }), home);
|
||||
const after = readFileSync(projectionPath(home), "utf8");
|
||||
expect(after).not.toBe(before);
|
||||
expect(after).toContain("deliveryClass: D");
|
||||
expect(loadHumanRegistry(home).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("a HAND-EDIT of the projection is REFUSED at load (fragment is truth)", () => {
|
||||
addHumanFragment(fragment({ entityId: "mike" }), home);
|
||||
expect(loadHumanRegistry(home).ok).toBe(true); // clean
|
||||
// hand-edit the generated projection
|
||||
const p = projectionPath(home);
|
||||
writeFileSync(p, readFileSync(p, "utf8") + "\n# sneaky hand edit\n");
|
||||
const loaded = loadHumanRegistry(home);
|
||||
expect(loaded.ok).toBe(false);
|
||||
if (!loaded.ok) expect(loaded.error).toMatch(/HAND-EDITED|DRIFT/i);
|
||||
});
|
||||
|
||||
it("an INVALID fragment on disk fails projection LOUD (load-time == add-time)", () => {
|
||||
addHumanFragment(fragment({ entityId: "mike" }), home);
|
||||
// plant a structurally-invalid fragment beside the valid one
|
||||
writeFileSync(join(humansDir(home), "bad.yaml"), "entityId: bad\nclass: human\n"); // missing bindings/prefs
|
||||
const proj = projectHumans(home);
|
||||
expect(proj.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("a filename != <entityId>.yaml is refused (collision-free key)", () => {
|
||||
addHumanFragment(fragment({ entityId: "mike" }), home); // creates humansDir + mike.yaml
|
||||
// a valid-content fragment under the WRONG filename (entityId "mike" in wrongname.yaml)
|
||||
writeFileSync(join(humansDir(home), "wrongname.yaml"), stringifyYaml(fragment({ entityId: "mike" })));
|
||||
const proj = projectHumans(home);
|
||||
expect(proj.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user