feat(core): trait registry, 14 built-in traits, composition validator (U2)
This commit is contained in:
102
packages/core/src/__tests__/builtin-traits.test.ts
Normal file
102
packages/core/src/__tests__/builtin-traits.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
BUILTIN_TRAIT_DEFINITIONS,
|
||||
BUILTIN_TRAIT_IDS,
|
||||
registerBuiltinTraits,
|
||||
} from "../builtin-traits.js";
|
||||
import { TraitRegistry } from "../trait-registry.js";
|
||||
import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js";
|
||||
import type { WorkflowIrV2 } from "../workflow-ir-types.js";
|
||||
|
||||
function freshRegistry(): TraitRegistry {
|
||||
const r = new TraitRegistry();
|
||||
registerBuiltinTraits(r);
|
||||
return r;
|
||||
}
|
||||
|
||||
describe("built-in traits", () => {
|
||||
it("ships exactly the 14 vocabulary traits", () => {
|
||||
expect(BUILTIN_TRAIT_IDS).toHaveLength(14);
|
||||
expect(BUILTIN_TRAIT_DEFINITIONS.map((d) => d.id).sort()).toEqual([...BUILTIN_TRAIT_IDS].sort());
|
||||
});
|
||||
|
||||
it("all built-ins are flagged builtin: true and register cleanly", () => {
|
||||
const r = freshRegistry();
|
||||
for (const id of BUILTIN_TRAIT_IDS) {
|
||||
const def = r.getTrait(id);
|
||||
expect(def, `missing built-in trait ${id}`).toBeDefined();
|
||||
expect(def?.builtin).toBe(true);
|
||||
}
|
||||
expect(r.listTraits()).toHaveLength(14);
|
||||
});
|
||||
|
||||
it("only built-in traits carry restricted capabilities", () => {
|
||||
const r = freshRegistry();
|
||||
expect(r.getTrait("complete")?.flags.complete).toBe(true);
|
||||
expect(r.getTrait("archived")?.flags.archived).toBe(true);
|
||||
// Sync guards live only on built-ins (merge-blocker, human-review).
|
||||
expect(r.getTrait("merge-blocker")?.hooks?.guard).toBe(true);
|
||||
expect(r.getTrait("human-review")?.hooks?.guard).toBe(true);
|
||||
// The plugin-facing gate trait uses the async gate hook, not a sync guard.
|
||||
expect(r.getTrait("gate")?.hooks?.guard).toBeUndefined();
|
||||
expect(r.getTrait("gate")?.hooks?.gate).toBe(true);
|
||||
});
|
||||
|
||||
it("merge trait ships a config STUB shape (behavior is U7)", () => {
|
||||
const r = freshRegistry();
|
||||
const keys = (r.getTrait("merge")?.configSchema?.fields ?? []).map((f) => f.key).sort();
|
||||
expect(keys).toEqual(["conflictStrategy", "fileScope", "squash", "strategy"]);
|
||||
expect(r.getTrait("merge")?.flags.mergeOrchestration).toBe(true);
|
||||
});
|
||||
|
||||
it("hold trait's release config matches WorkflowHoldRelease kinds", () => {
|
||||
const r = freshRegistry();
|
||||
const release = r.getTrait("hold")?.configSchema?.fields.find((f) => f.key === "release");
|
||||
expect(release?.enumValues).toEqual([
|
||||
"manual",
|
||||
"timer",
|
||||
"capacity",
|
||||
"dependency",
|
||||
"external-event",
|
||||
]);
|
||||
});
|
||||
|
||||
it("registering built-ins twice into the same registry is idempotent", () => {
|
||||
const r = freshRegistry();
|
||||
expect(() => registerBuiltinTraits(r)).not.toThrow();
|
||||
expect(r.listTraits()).toHaveLength(14);
|
||||
});
|
||||
});
|
||||
|
||||
describe("default workflow columns validate cleanly", () => {
|
||||
it("BUILTIN_CODING_WORKFLOW_IR columns pass the composition validator", () => {
|
||||
const r = freshRegistry();
|
||||
const ir = BUILTIN_CODING_WORKFLOW_IR as WorkflowIrV2;
|
||||
const violations = r.validateColumnTraits(ir.columns, "save");
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
it("the default workflow has exactly one intake column (triage)", () => {
|
||||
const r = freshRegistry();
|
||||
const ir = BUILTIN_CODING_WORKFLOW_IR as WorkflowIrV2;
|
||||
const intakeCols = ir.columns.filter((c) => r.resolveColumnFlags(c).intake);
|
||||
expect(intakeCols.map((c) => c.id)).toEqual(["triage"]);
|
||||
});
|
||||
|
||||
it("the default workflow's done column resolves the complete flag", () => {
|
||||
const r = freshRegistry();
|
||||
const ir = BUILTIN_CODING_WORKFLOW_IR as WorkflowIrV2;
|
||||
const done = ir.columns.find((c) => c.id === "done")!;
|
||||
expect(r.resolveColumnFlags(done).complete).toBe(true);
|
||||
});
|
||||
|
||||
it("the default workflow's in-progress column resolves wip+abort+timing flags", () => {
|
||||
const r = freshRegistry();
|
||||
const ir = BUILTIN_CODING_WORKFLOW_IR as WorkflowIrV2;
|
||||
const inProgress = ir.columns.find((c) => c.id === "in-progress")!;
|
||||
const flags = r.resolveColumnFlags(inProgress);
|
||||
expect(flags.countsTowardWip).toBe(true);
|
||||
expect(flags.abortOnExit).toBe(true);
|
||||
expect(flags.timing).toBe(true);
|
||||
});
|
||||
});
|
||||
222
packages/core/src/__tests__/trait-registry.test.ts
Normal file
222
packages/core/src/__tests__/trait-registry.test.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
TraitRegistry,
|
||||
TraitRegistrationError,
|
||||
} from "../trait-registry.js";
|
||||
import type { TraitDefinition } from "../trait-types.js";
|
||||
import type { WorkflowIrColumn } from "../workflow-ir-types.js";
|
||||
|
||||
function col(id: string, traits: string[]): WorkflowIrColumn {
|
||||
return { id, name: id, traits: traits.map((t) => ({ trait: t })) };
|
||||
}
|
||||
|
||||
function builtin(id: string, def: Partial<TraitDefinition>): TraitDefinition {
|
||||
return { id, name: id, builtin: true, flags: {}, ...def };
|
||||
}
|
||||
|
||||
function plugin(id: string, def: Partial<TraitDefinition>): TraitDefinition {
|
||||
return { id, name: id, flags: {}, ...def };
|
||||
}
|
||||
|
||||
/** A registry seeded with a representative built-in set used across tests. */
|
||||
function seeded(): TraitRegistry {
|
||||
const r = new TraitRegistry();
|
||||
r.register(builtin("intake", { flags: { intake: true } }));
|
||||
r.register(builtin("complete", { flags: { complete: true } }));
|
||||
r.register(builtin("archived", { flags: { archived: true, hiddenFromBoard: true } }));
|
||||
r.register(builtin("wip", { flags: { countsTowardWip: true } }));
|
||||
r.register(builtin("wip2", { flags: { countsTowardWip: true } }));
|
||||
r.register(builtin("merge-blocker", { flags: { mergeBlocker: true }, hooks: { guard: true } }));
|
||||
r.register(builtin("timing", { flags: { timing: true }, hooks: { onEnter: true, onExit: true } }));
|
||||
return r;
|
||||
}
|
||||
|
||||
describe("TraitRegistry — registration", () => {
|
||||
it("rejects a duplicate trait id", () => {
|
||||
const r = new TraitRegistry();
|
||||
r.register(builtin("intake", { flags: { intake: true } }));
|
||||
expect(() => r.register(builtin("intake", { flags: { intake: true } }))).toThrowError(
|
||||
TraitRegistrationError,
|
||||
);
|
||||
try {
|
||||
r.register(builtin("intake", { flags: { intake: true } }));
|
||||
} catch (err) {
|
||||
expect((err as TraitRegistrationError).reason).toBe("duplicate-id");
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks a non-builtin from overriding a built-in namespace id", () => {
|
||||
const r = new TraitRegistry();
|
||||
r.register(builtin("complete", { flags: { complete: true } }));
|
||||
let caught: TraitRegistrationError | undefined;
|
||||
try {
|
||||
r.register(plugin("complete", { flags: {} }));
|
||||
} catch (err) {
|
||||
caught = err as TraitRegistrationError;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(TraitRegistrationError);
|
||||
expect(caught?.reason).toBe("builtin-namespace-protected");
|
||||
});
|
||||
|
||||
it("rejects a non-builtin declaring the restricted `complete` flag", () => {
|
||||
const r = new TraitRegistry();
|
||||
let caught: TraitRegistrationError | undefined;
|
||||
try {
|
||||
r.register(plugin("my-plugin:done", { flags: { complete: true } }));
|
||||
} catch (err) {
|
||||
caught = err as TraitRegistrationError;
|
||||
}
|
||||
expect(caught?.reason).toBe("restricted-flag");
|
||||
});
|
||||
|
||||
it("rejects a non-builtin declaring the restricted `archived` flag", () => {
|
||||
const r = new TraitRegistry();
|
||||
let caught: TraitRegistrationError | undefined;
|
||||
try {
|
||||
r.register(plugin("my-plugin:arch", { flags: { archived: true } }));
|
||||
} catch (err) {
|
||||
caught = err as TraitRegistrationError;
|
||||
}
|
||||
expect(caught?.reason).toBe("restricted-flag");
|
||||
});
|
||||
|
||||
it("rejects a non-builtin declaring a sync `guard` hook (built-in only)", () => {
|
||||
const r = new TraitRegistry();
|
||||
let caught: TraitRegistrationError | undefined;
|
||||
try {
|
||||
r.register(plugin("my-plugin:guard", { flags: {}, hooks: { guard: true } }));
|
||||
} catch (err) {
|
||||
caught = err as TraitRegistrationError;
|
||||
}
|
||||
expect(caught?.reason).toBe("restricted-guard-hook");
|
||||
});
|
||||
|
||||
it("allows a non-builtin declaring async-only hooks", () => {
|
||||
const r = new TraitRegistry();
|
||||
expect(() =>
|
||||
r.register(
|
||||
plugin("my-plugin:gate", {
|
||||
flags: { gate: true },
|
||||
hooks: { gate: true, onEnter: true, onExit: true, releaseCondition: true },
|
||||
}),
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TraitRegistry — flag resolution", () => {
|
||||
it("merges effective flags across a column's traits (OR)", () => {
|
||||
const r = seeded();
|
||||
const flags = r.resolveColumnFlags(col("in-progress", ["wip", "timing"]));
|
||||
expect(flags.countsTowardWip).toBe(true);
|
||||
expect(flags.timing).toBe(true);
|
||||
expect(flags.complete).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores unknown trait ids in flag resolution", () => {
|
||||
const r = seeded();
|
||||
const flags = r.resolveColumnFlags(col("x", ["wip", "nope"]));
|
||||
expect(flags.countsTowardWip).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TraitRegistry — composition validator", () => {
|
||||
it("rejects complete + countsTowardWip with its reason code", () => {
|
||||
const r = seeded();
|
||||
const v = r.validateColumnTraits([col("c", ["complete", "wip"])]);
|
||||
expect(v.find((x) => x.code === "complete-with-wip")?.severity).toBe("error");
|
||||
});
|
||||
|
||||
it("rejects two capacity (wip) traits on one column", () => {
|
||||
const r = seeded();
|
||||
const v = r.validateColumnTraits([col("c", ["wip", "wip2"])]);
|
||||
const hit = v.find((x) => x.code === "two-capacity-traits");
|
||||
expect(hit?.severity).toBe("error");
|
||||
expect(hit?.traitIds.sort()).toEqual(["wip", "wip2"]);
|
||||
});
|
||||
|
||||
it("rejects complete + intake", () => {
|
||||
const r = seeded();
|
||||
const v = r.validateColumnTraits([col("c", ["complete", "intake"])]);
|
||||
expect(v.find((x) => x.code === "complete-with-intake")?.severity).toBe("error");
|
||||
});
|
||||
|
||||
it("rejects archived + countsTowardWip", () => {
|
||||
const r = seeded();
|
||||
const v = r.validateColumnTraits([col("c", ["archived", "wip"])]);
|
||||
expect(v.find((x) => x.code === "archived-with-wip")?.severity).toBe("error");
|
||||
});
|
||||
|
||||
it("rejects more than one intake column per workflow", () => {
|
||||
const r = seeded();
|
||||
const v = r.validateColumnTraits([col("a", ["intake"]), col("b", ["intake"])]);
|
||||
expect(v.find((x) => x.code === "multiple-intake-columns")?.severity).toBe("error");
|
||||
});
|
||||
|
||||
it("a valid single-intake / clean column set passes", () => {
|
||||
const r = seeded();
|
||||
const v = r.validateColumnTraits([
|
||||
col("triage", ["intake"]),
|
||||
col("in-progress", ["wip", "timing"]),
|
||||
col("done", ["complete"]),
|
||||
]);
|
||||
expect(v).toEqual([]);
|
||||
});
|
||||
|
||||
it("conflicting boolean flags reject at save (validator), not at runtime", () => {
|
||||
const r = seeded();
|
||||
// The conflict is surfaced by the validator (save-time), not on flag merge.
|
||||
const flags = r.resolveColumnFlags(col("c", ["complete", "wip"]));
|
||||
expect(flags.complete && flags.countsTowardWip).toBe(true); // merge does not throw
|
||||
const v = r.validateColumnTraits([col("c", ["complete", "wip"])]);
|
||||
expect(v.some((x) => x.code === "complete-with-wip" && x.severity === "error")).toBe(true);
|
||||
});
|
||||
|
||||
it("unknown trait is a save-blocking error in save mode", () => {
|
||||
const r = seeded();
|
||||
const v = r.validateColumnTraits([col("c", ["totally-unknown"])], "save");
|
||||
const hit = v.find((x) => x.code === "unknown-trait");
|
||||
expect(hit?.severity).toBe("error");
|
||||
});
|
||||
|
||||
it("load-time re-validation degrades unknown trait to advisory, not error", () => {
|
||||
const r = seeded();
|
||||
const v = r.validateColumnTraits([col("c", ["totally-unknown"])], "load");
|
||||
const hit = v.find((x) => x.code === "unknown-trait");
|
||||
expect(hit?.severity).toBe("degraded");
|
||||
// The definition still "loads" — there is no error-severity violation.
|
||||
expect(v.some((x) => x.severity === "error")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TraitRegistry — hook implementation DI", () => {
|
||||
it("resolves a declared hook with no registered impl to a no-op + audit warning", () => {
|
||||
const r = seeded();
|
||||
const { impl, warning } = r.resolveTraitHook("merge-blocker", "guard");
|
||||
expect(typeof impl).toBe("function");
|
||||
expect(impl?.()).toBeUndefined(); // no-op
|
||||
expect(warning?.kind).toBe("missing-hook-impl");
|
||||
expect(warning?.traitId).toBe("merge-blocker");
|
||||
expect(warning?.hookKind).toBe("guard");
|
||||
});
|
||||
|
||||
it("resolves a registered impl without a warning", () => {
|
||||
const r = seeded();
|
||||
let called = false;
|
||||
r.registerTraitHookImpl("merge-blocker", "guard", () => {
|
||||
called = true;
|
||||
return "ok";
|
||||
});
|
||||
const { impl, warning } = r.resolveTraitHook("merge-blocker", "guard");
|
||||
expect(warning).toBeUndefined();
|
||||
expect(impl?.()).toBe("ok");
|
||||
expect(called).toBe(true);
|
||||
});
|
||||
|
||||
it("returns no impl and no warning when the trait does not declare the hook", () => {
|
||||
const r = seeded();
|
||||
const { impl, warning } = r.resolveTraitHook("wip", "onEnter");
|
||||
expect(impl).toBeUndefined();
|
||||
expect(warning).toBeUndefined();
|
||||
});
|
||||
});
|
||||
257
packages/core/src/builtin-traits.ts
Normal file
257
packages/core/src/builtin-traits.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* The 14 built-in traits (U2, R7) from the Trait Vocabulary table. Behavior for
|
||||
* each trait lands in later units; here we ship the definitions (flags + config
|
||||
* schema + hook descriptors) and register them into the shared trait registry.
|
||||
*
|
||||
* The `merge` trait's config schema is a STUB here (shape only — strategy /
|
||||
* fileScope / squash / conflictStrategy); its behavior is U7.
|
||||
*
|
||||
* Registration is idempotent at module scope (registered once on import). Tests
|
||||
* that need a clean slate use `__resetTraitRegistryForTests()` +
|
||||
* `registerBuiltinTraits(registry)`.
|
||||
*/
|
||||
|
||||
import type { TraitDefinition } from "./trait-types.js";
|
||||
import { TraitRegistry, getTraitRegistry } from "./trait-registry.js";
|
||||
|
||||
/** The ids of the 14 built-in traits, in vocabulary-table order. */
|
||||
export const BUILTIN_TRAIT_IDS = [
|
||||
"intake",
|
||||
"complete",
|
||||
"archived",
|
||||
"merge-blocker",
|
||||
"wip",
|
||||
"hold",
|
||||
"human-review",
|
||||
"gate",
|
||||
"merge",
|
||||
"abort-on-exit",
|
||||
"reset-on-entry",
|
||||
"timing",
|
||||
"stall-detection",
|
||||
"notify",
|
||||
] as const;
|
||||
|
||||
export type BuiltinTraitId = (typeof BUILTIN_TRAIT_IDS)[number];
|
||||
|
||||
/** The built-in trait definitions. */
|
||||
export const BUILTIN_TRAIT_DEFINITIONS: TraitDefinition[] = [
|
||||
{
|
||||
id: "intake",
|
||||
name: "Intake",
|
||||
description: "Where new cards land; exactly one per workflow.",
|
||||
builtin: true,
|
||||
flags: { intake: true },
|
||||
configSchema: {
|
||||
fields: [{ key: "autoTriage", type: "boolean", description: "Auto-triage new cards" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "complete",
|
||||
name: "Complete",
|
||||
description: "Terminal success; satisfies dependencies. Restricted flag.",
|
||||
builtin: true,
|
||||
flags: { complete: true },
|
||||
},
|
||||
{
|
||||
id: "archived",
|
||||
name: "Archived",
|
||||
description: "Hidden from board; global semantics. Restricted flag.",
|
||||
builtin: true,
|
||||
flags: { archived: true, hiddenFromBoard: true },
|
||||
},
|
||||
{
|
||||
id: "merge-blocker",
|
||||
name: "Merge blocker",
|
||||
description:
|
||||
"Generalized FN-5147: entry to complete-bound columns blocked until the merge-class node completed.",
|
||||
builtin: true,
|
||||
flags: { mergeBlocker: true },
|
||||
hooks: { guard: true },
|
||||
},
|
||||
{
|
||||
id: "wip",
|
||||
name: "WIP / capacity",
|
||||
description: "Substrate-enforced in-txn capacity limit; never bypassable (KTD-10).",
|
||||
builtin: true,
|
||||
flags: { countsTowardWip: true },
|
||||
configSchema: {
|
||||
fields: [
|
||||
{ key: "limit", type: "number", required: true, description: "Max concurrent cards" },
|
||||
{ key: "countPending", type: "boolean", description: "Count mid-transition cards" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "hold",
|
||||
name: "Hold",
|
||||
description: "Passive dwell; released by a configured condition.",
|
||||
builtin: true,
|
||||
flags: { hold: true },
|
||||
hooks: { releaseCondition: true },
|
||||
configSchema: {
|
||||
fields: [
|
||||
{
|
||||
key: "release",
|
||||
type: "enum",
|
||||
required: true,
|
||||
enumValues: ["manual", "timer", "capacity", "dependency", "external-event"],
|
||||
description: "Release condition kind (matches WorkflowHoldRelease)",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "human-review",
|
||||
name: "Human review",
|
||||
description:
|
||||
"Card cannot leave until explicit human approval (approval state is a DB read — sync-safe). Not on the default workflow.",
|
||||
builtin: true,
|
||||
flags: { humanReview: true },
|
||||
hooks: { guard: true },
|
||||
configSchema: {
|
||||
fields: [
|
||||
{ key: "approvers", type: "array", description: "Allowed approver ids" },
|
||||
{ key: "checklist", type: "array", description: "Required checklist items" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "gate",
|
||||
name: "Gate",
|
||||
description:
|
||||
"Workflow-step gate semantics generalized to columns; the plugin-facing gate surface. Blocking gates fail closed.",
|
||||
builtin: true,
|
||||
flags: { gate: true },
|
||||
hooks: { gate: true },
|
||||
configSchema: {
|
||||
fields: [
|
||||
{
|
||||
key: "gateMode",
|
||||
type: "enum",
|
||||
required: true,
|
||||
enumValues: ["blocking", "advisory"],
|
||||
description: "Blocking gates fail closed; advisory gates record and allow",
|
||||
},
|
||||
{ key: "prompt", type: "string", description: "Gate prompt" },
|
||||
{ key: "script", type: "string", description: "Gate script" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "merge",
|
||||
name: "Merge",
|
||||
description:
|
||||
"Enqueues onto the merge-request queue; configures merge policy. Behavior is U7 — this is a config STUB.",
|
||||
builtin: true,
|
||||
flags: { mergeOrchestration: true },
|
||||
hooks: { onEnter: true, onExit: true },
|
||||
configSchema: {
|
||||
fields: [
|
||||
{
|
||||
key: "strategy",
|
||||
type: "enum",
|
||||
enumValues: ["squash", "merge-commit", "rebase", "pr-only"],
|
||||
description: "Merge strategy",
|
||||
},
|
||||
{
|
||||
key: "fileScope",
|
||||
type: "enum",
|
||||
enumValues: ["strict", "warn", "off", "custom"],
|
||||
description: "File-scope enforcement mode",
|
||||
},
|
||||
{ key: "squash", type: "boolean", description: "Squash posture" },
|
||||
{
|
||||
key: "conflictStrategy",
|
||||
type: "string",
|
||||
description: "Conflict resolution strategy",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "abort-on-exit",
|
||||
name: "Abort on exit",
|
||||
description: "Generalized hard-cancel; bypassed by engine-sourced moves (KTD-9).",
|
||||
builtin: true,
|
||||
flags: { abortOnExit: true },
|
||||
hooks: { onExit: true },
|
||||
configSchema: {
|
||||
fields: [
|
||||
{
|
||||
key: "direction",
|
||||
type: "enum",
|
||||
enumValues: ["backward", "any"],
|
||||
description: "Which exits trigger abort",
|
||||
},
|
||||
{ key: "confirm", type: "boolean", description: "Require user confirmation" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "reset-on-entry",
|
||||
name: "Reset on entry",
|
||||
description: "Legacy reopen-to-todo field/step resets.",
|
||||
builtin: true,
|
||||
flags: { resetOnEntry: true },
|
||||
hooks: { onEnter: true },
|
||||
configSchema: {
|
||||
fields: [
|
||||
{ key: "preserveProgress", type: "boolean", description: "Keep progress fields" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "timing",
|
||||
name: "Timing",
|
||||
description: "cumulativeActiveMs accounting generalized.",
|
||||
builtin: true,
|
||||
flags: { timing: true },
|
||||
hooks: { onEnter: true, onExit: true },
|
||||
},
|
||||
{
|
||||
id: "stall-detection",
|
||||
name: "Stall detection",
|
||||
description: "In-review stall signals generalized to any column (sweep-evaluated).",
|
||||
builtin: true,
|
||||
flags: { stallDetection: true },
|
||||
configSchema: {
|
||||
fields: [
|
||||
{ key: "timeoutMs", type: "number", required: true, description: "Stall threshold" },
|
||||
{
|
||||
key: "action",
|
||||
type: "enum",
|
||||
enumValues: ["annotate", "notify", "move"],
|
||||
description: "Action on stall",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "notify",
|
||||
name: "Notify",
|
||||
description: "Basic notifications; richer notification traits are the canonical plugin example.",
|
||||
builtin: true,
|
||||
flags: { notify: true },
|
||||
hooks: { onEnter: true, onExit: true },
|
||||
configSchema: {
|
||||
fields: [
|
||||
{ key: "events", type: "array", description: "Events to notify on" },
|
||||
{ key: "channel", type: "string", description: "Notification channel" },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** Register all 14 built-in traits into the given registry (defaults to the
|
||||
* shared registry). Idempotent guard for the shared instance lives in the
|
||||
* module-scope registration below. */
|
||||
export function registerBuiltinTraits(registry: TraitRegistry = getTraitRegistry()): void {
|
||||
for (const def of BUILTIN_TRAIT_DEFINITIONS) {
|
||||
if (registry.has(def.id)) continue;
|
||||
registry.register(def);
|
||||
}
|
||||
}
|
||||
|
||||
// Register into the shared registry on import (idempotent via `has`).
|
||||
registerBuiltinTraits();
|
||||
375
packages/core/src/trait-registry.ts
Normal file
375
packages/core/src/trait-registry.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* Trait registry (U2, R6/R8/R22).
|
||||
*
|
||||
* One registry resolving trait ids to definitions (flags + hook descriptors)
|
||||
* for both built-ins and (later) plugins. Provides:
|
||||
* - registration with `builtin:`-style namespace protection and
|
||||
* restricted-capability enforcement (R22);
|
||||
* - hook-implementation DI (engine registers impls; unregistered hooks
|
||||
* resolve to a no-op + audit warning — degraded, not crashed);
|
||||
* - effective-flag resolution for a column's trait set;
|
||||
* - the save-time / load-time composition validator returning typed
|
||||
* violations with named reason codes, distinguishing `error`
|
||||
* (save-blocked) from `degraded` (load-time advisory).
|
||||
*
|
||||
* Core stays engine-free: no `@fusion/engine` import. Hook implementations are
|
||||
* wired in via `registerTraitHookImpl` (mirrors `setCreateFnAgent`).
|
||||
*/
|
||||
|
||||
import type {
|
||||
TraitDefinition,
|
||||
TraitFlags,
|
||||
TraitHookImpl,
|
||||
TraitHookKind,
|
||||
} from "./trait-types.js";
|
||||
import { RESTRICTED_TRAIT_FLAGS, traitHookKey } from "./trait-types.js";
|
||||
import type { WorkflowIrColumn, WorkflowIrColumnTrait } from "./workflow-ir-types.js";
|
||||
|
||||
// ── Registration error ──────────────────────────────────────────────────────
|
||||
|
||||
/** Named reason codes for a rejected trait registration. */
|
||||
export type TraitRegistrationReason =
|
||||
| "duplicate-id"
|
||||
| "builtin-namespace-protected"
|
||||
| "restricted-flag"
|
||||
| "restricted-guard-hook"
|
||||
| "invalid-definition";
|
||||
|
||||
export class TraitRegistrationError extends Error {
|
||||
readonly reason: TraitRegistrationReason;
|
||||
readonly traitId: string;
|
||||
constructor(reason: TraitRegistrationReason, traitId: string, message: string) {
|
||||
super(message);
|
||||
this.name = "TraitRegistrationError";
|
||||
this.reason = reason;
|
||||
this.traitId = traitId;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Composition violation contract ──────────────────────────────────────────
|
||||
|
||||
/** Named reason codes for a composition violation. */
|
||||
export type TraitViolationCode =
|
||||
| "complete-with-wip"
|
||||
| "two-capacity-traits"
|
||||
| "complete-with-intake"
|
||||
| "archived-with-wip"
|
||||
| "multiple-intake-columns"
|
||||
| "unknown-trait";
|
||||
|
||||
/** Severity: `error` blocks the save; `degraded` is a load-time advisory — the
|
||||
* definition still loads (per U2's load-time re-validation requirement). */
|
||||
export type TraitViolationSeverity = "error" | "degraded";
|
||||
|
||||
export interface TraitViolation {
|
||||
code: TraitViolationCode;
|
||||
severity: TraitViolationSeverity;
|
||||
/** Column id the violation applies to, or null for workflow-wide violations. */
|
||||
columnId: string | null;
|
||||
/** The trait ids implicated (for actionable messaging). */
|
||||
traitIds: string[];
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** A simple audit-warning record returned by hook resolution / load-time
|
||||
* re-validation. Modeled as a returned value (not a thrown error and not an
|
||||
* engine logger) so core stays engine-free; callers may forward it to audit. */
|
||||
export interface TraitAuditWarning {
|
||||
kind: "missing-hook-impl" | "degraded-composition";
|
||||
traitId?: string;
|
||||
hookKind?: TraitHookKind;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// ── The registry ────────────────────────────────────────────────────────────
|
||||
|
||||
export class TraitRegistry {
|
||||
private readonly traits = new Map<string, TraitDefinition>();
|
||||
private readonly hookImpls = new Map<string, TraitHookImpl>();
|
||||
|
||||
/** Register a trait. Rejects duplicates, builtin-namespace overrides by
|
||||
* non-builtins, and restricted-capability declarations by non-builtins (R22). */
|
||||
register(def: TraitDefinition): void {
|
||||
if (!def.id || typeof def.id !== "string") {
|
||||
throw new TraitRegistrationError(
|
||||
"invalid-definition",
|
||||
String(def.id),
|
||||
"Trait definition must have a non-empty string id",
|
||||
);
|
||||
}
|
||||
|
||||
const existing = this.traits.get(def.id);
|
||||
if (existing) {
|
||||
// A built-in id (or any already-registered id) cannot be overridden.
|
||||
if (!def.builtin && existing.builtin) {
|
||||
throw new TraitRegistrationError(
|
||||
"builtin-namespace-protected",
|
||||
def.id,
|
||||
`Trait id '${def.id}' is a built-in trait and cannot be overridden by a non-builtin registration`,
|
||||
);
|
||||
}
|
||||
throw new TraitRegistrationError(
|
||||
"duplicate-id",
|
||||
def.id,
|
||||
`Trait id '${def.id}' is already registered`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!def.builtin) {
|
||||
// Non-builtin (plugin) traits cannot declare restricted flags (R22).
|
||||
for (const flag of RESTRICTED_TRAIT_FLAGS) {
|
||||
if (def.flags?.[flag]) {
|
||||
throw new TraitRegistrationError(
|
||||
"restricted-flag",
|
||||
def.id,
|
||||
`Non-builtin trait '${def.id}' may not declare the restricted flag '${flag}'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Non-builtin traits cannot declare the sync `guard` hook (KTD-2/R22).
|
||||
if (def.hooks?.guard) {
|
||||
throw new TraitRegistrationError(
|
||||
"restricted-guard-hook",
|
||||
def.id,
|
||||
`Non-builtin trait '${def.id}' may not declare a sync 'guard' hook (built-in only)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.traits.set(def.id, def);
|
||||
}
|
||||
|
||||
getTrait(id: string): TraitDefinition | undefined {
|
||||
return this.traits.get(id);
|
||||
}
|
||||
|
||||
/** Catalog of all registered traits (for the dashboard endpoint, later). */
|
||||
listTraits(): TraitDefinition[] {
|
||||
return [...this.traits.values()];
|
||||
}
|
||||
|
||||
has(id: string): boolean {
|
||||
return this.traits.has(id);
|
||||
}
|
||||
|
||||
// ── Hook implementation DI (engine wires impls in) ────────────────────────
|
||||
|
||||
/** Register a hook implementation for a (traitId, hookKind). Called by the
|
||||
* engine (mirrors `setCreateFnAgent`); core never supplies impls. */
|
||||
registerTraitHookImpl(traitId: string, hookKind: TraitHookKind, impl: TraitHookImpl): void {
|
||||
this.hookImpls.set(traitHookKey(traitId, hookKind), impl);
|
||||
}
|
||||
|
||||
/** Resolve a hook implementation. If the trait declares the hook but no impl
|
||||
* is registered, returns a no-op plus an audit warning (degraded, not
|
||||
* crashed). Returns `{ impl: undefined }` with no warning if the trait does
|
||||
* not declare the hook at all. */
|
||||
resolveTraitHook(
|
||||
traitId: string,
|
||||
hookKind: TraitHookKind,
|
||||
): { impl: TraitHookImpl | undefined; warning?: TraitAuditWarning } {
|
||||
const def = this.traits.get(traitId);
|
||||
const declared = Boolean(def?.hooks?.[hookKind]);
|
||||
const impl = this.hookImpls.get(traitHookKey(traitId, hookKind));
|
||||
if (impl) return { impl };
|
||||
if (declared) {
|
||||
const noop: TraitHookImpl = () => undefined;
|
||||
return {
|
||||
impl: noop,
|
||||
warning: {
|
||||
kind: "missing-hook-impl",
|
||||
traitId,
|
||||
hookKind,
|
||||
message: `Trait '${traitId}' declares a '${hookKind}' hook but no implementation is registered; resolving to a no-op`,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { impl: undefined };
|
||||
}
|
||||
|
||||
// ── Flag resolution ───────────────────────────────────────────────────────
|
||||
|
||||
/** Merged effective flags of a column's traits (OR across booleans). Unknown
|
||||
* trait ids are ignored here (validation surfaces them via
|
||||
* validateColumnTraits). */
|
||||
resolveColumnFlags(column: WorkflowIrColumn): TraitFlags {
|
||||
const merged: TraitFlags = {};
|
||||
for (const ct of column.traits) {
|
||||
const def = this.traits.get(ct.trait);
|
||||
if (!def) continue;
|
||||
for (const [key, value] of Object.entries(def.flags) as [keyof TraitFlags, boolean][]) {
|
||||
if (value) merged[key] = true;
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
// ── Composition validation ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate a workflow's columns' trait composition. Returns typed violations
|
||||
* with named reason codes. `mode: "save"` produces `error` severities that
|
||||
* block the save; `mode: "load"` degrades the *unknown-trait* violation to an
|
||||
* advisory so definitions predating a newly added trait still load (per U2's
|
||||
* load-time re-validation requirement). Hard structural conflicts remain
|
||||
* errors in both modes (they reflect genuine nonsense, not vocabulary drift).
|
||||
*/
|
||||
validateColumnTraits(
|
||||
columns: WorkflowIrColumn[],
|
||||
mode: "save" | "load" = "save",
|
||||
): TraitViolation[] {
|
||||
const violations: TraitViolation[] = [];
|
||||
|
||||
let intakeColumnCount = 0;
|
||||
|
||||
for (const column of columns) {
|
||||
const knownDefs: TraitDefinition[] = [];
|
||||
|
||||
// Unknown trait ids: degradable. In save mode it's an error; in load mode
|
||||
// it degrades to an advisory (the rule/vocabulary may have changed under
|
||||
// a persisted definition).
|
||||
for (const ct of column.traits) {
|
||||
const def = this.traits.get(ct.trait);
|
||||
if (!def) {
|
||||
violations.push({
|
||||
code: "unknown-trait",
|
||||
severity: mode === "load" ? "degraded" : "error",
|
||||
columnId: column.id,
|
||||
traitIds: [ct.trait],
|
||||
message: `Column '${column.id}' references unknown trait '${ct.trait}'`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
knownDefs.push(def);
|
||||
}
|
||||
|
||||
const flags = this.mergeFlags(knownDefs);
|
||||
|
||||
// Capacity traits on this column (traits whose flags set countsTowardWip).
|
||||
const capacityTraitIds = knownDefs
|
||||
.filter((d) => d.flags.countsTowardWip)
|
||||
.map((d) => d.id);
|
||||
|
||||
if (flags.complete && flags.countsTowardWip) {
|
||||
violations.push({
|
||||
code: "complete-with-wip",
|
||||
severity: "error",
|
||||
columnId: column.id,
|
||||
traitIds: this.traitIdsWithFlags(knownDefs, ["complete", "countsTowardWip"]),
|
||||
message: `Column '${column.id}' is both a completion column and counts toward WIP — a terminal column cannot hold a capacity slot`,
|
||||
});
|
||||
}
|
||||
|
||||
if (capacityTraitIds.length > 1) {
|
||||
violations.push({
|
||||
code: "two-capacity-traits",
|
||||
severity: "error",
|
||||
columnId: column.id,
|
||||
traitIds: capacityTraitIds,
|
||||
message: `Column '${column.id}' has more than one capacity (WIP) trait: ${capacityTraitIds.join(", ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (flags.complete && flags.intake) {
|
||||
violations.push({
|
||||
code: "complete-with-intake",
|
||||
severity: "error",
|
||||
columnId: column.id,
|
||||
traitIds: this.traitIdsWithFlags(knownDefs, ["complete", "intake"]),
|
||||
message: `Column '${column.id}' is both a completion column and an intake column`,
|
||||
});
|
||||
}
|
||||
|
||||
if (flags.archived && flags.countsTowardWip) {
|
||||
violations.push({
|
||||
code: "archived-with-wip",
|
||||
severity: "error",
|
||||
columnId: column.id,
|
||||
traitIds: this.traitIdsWithFlags(knownDefs, ["archived", "countsTowardWip"]),
|
||||
message: `Column '${column.id}' is archived but counts toward WIP — archived cards must not hold capacity`,
|
||||
});
|
||||
}
|
||||
|
||||
if (flags.intake) intakeColumnCount += 1;
|
||||
}
|
||||
|
||||
if (intakeColumnCount > 1) {
|
||||
violations.push({
|
||||
code: "multiple-intake-columns",
|
||||
severity: "error",
|
||||
columnId: null,
|
||||
traitIds: [],
|
||||
message: `Workflow has ${intakeColumnCount} intake columns; exactly one is allowed`,
|
||||
});
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
private mergeFlags(defs: TraitDefinition[]): TraitFlags {
|
||||
const merged: TraitFlags = {};
|
||||
for (const def of defs) {
|
||||
for (const [key, value] of Object.entries(def.flags) as [keyof TraitFlags, boolean][]) {
|
||||
if (value) merged[key] = true;
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
private traitIdsWithFlags(defs: TraitDefinition[], flagKeys: (keyof TraitFlags)[]): string[] {
|
||||
return defs
|
||||
.filter((d) => flagKeys.some((k) => d.flags[k]))
|
||||
.map((d) => d.id);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Module-level default registry ────────────────────────────────────────────
|
||||
//
|
||||
// A single shared registry instance the built-ins register into and the engine
|
||||
// wires hook impls into. Tests can construct fresh `new TraitRegistry()`
|
||||
// instances for isolation.
|
||||
|
||||
let defaultRegistry: TraitRegistry | undefined;
|
||||
|
||||
export function getTraitRegistry(): TraitRegistry {
|
||||
if (!defaultRegistry) defaultRegistry = new TraitRegistry();
|
||||
return defaultRegistry;
|
||||
}
|
||||
|
||||
/** Test-only: reset the shared registry (so built-in registration can be
|
||||
* re-exercised in isolation). */
|
||||
export function __resetTraitRegistryForTests(): void {
|
||||
defaultRegistry = undefined;
|
||||
}
|
||||
|
||||
// ── Convenience pass-throughs to the default registry ────────────────────────
|
||||
|
||||
export function getTrait(id: string): TraitDefinition | undefined {
|
||||
return getTraitRegistry().getTrait(id);
|
||||
}
|
||||
|
||||
export function listTraits(): TraitDefinition[] {
|
||||
return getTraitRegistry().listTraits();
|
||||
}
|
||||
|
||||
export function resolveColumnFlags(column: WorkflowIrColumn): TraitFlags {
|
||||
return getTraitRegistry().resolveColumnFlags(column);
|
||||
}
|
||||
|
||||
export function validateColumnTraits(
|
||||
columns: WorkflowIrColumn[],
|
||||
mode: "save" | "load" = "save",
|
||||
): TraitViolation[] {
|
||||
return getTraitRegistry().validateColumnTraits(columns, mode);
|
||||
}
|
||||
|
||||
export function registerTraitHookImpl(
|
||||
traitId: string,
|
||||
hookKind: TraitHookKind,
|
||||
impl: TraitHookImpl,
|
||||
): void {
|
||||
getTraitRegistry().registerTraitHookImpl(traitId, hookKind, impl);
|
||||
}
|
||||
|
||||
/** Re-export for callers that only need the column-trait shape. */
|
||||
export type { WorkflowIrColumnTrait };
|
||||
125
packages/core/src/trait-types.ts
Normal file
125
packages/core/src/trait-types.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Trait model (U2). A trait is declarative flags + optional config schema +
|
||||
* optional executable lifecycle hook *descriptors*. Per KTD-2 there are two
|
||||
* guard classes:
|
||||
* - `guard` — sync, in-lock, fast/pure (DB reads only). BUILT-IN ONLY.
|
||||
* - `gate` — async, pre-evaluated outside the lock; the plugin-facing
|
||||
* surface. The verdict is recorded and re-checked cheaply in-lock.
|
||||
*
|
||||
* Hooks here are *descriptors* (what the trait declares it participates in);
|
||||
* the executable implementations are registered separately by the engine via
|
||||
* the core→engine DI seam (mirrors `setCreateFnAgent`). This keeps core
|
||||
* engine-free: core never imports `@fusion/engine`.
|
||||
*/
|
||||
|
||||
/** The set of hook points a trait can declare (KTD-2). */
|
||||
export type TraitHookKind = "guard" | "gate" | "onEnter" | "onExit" | "releaseCondition";
|
||||
|
||||
/** All declarative trait flags. Derived from the Trait Vocabulary table.
|
||||
* Every flag is optional; an absent flag means `false`. Flags compose by OR
|
||||
* across a column's traits (see resolveColumnFlags). */
|
||||
export interface TraitFlags {
|
||||
/** Cards in this column count against a WIP/capacity limit (substrate-enforced). */
|
||||
countsTowardWip?: boolean;
|
||||
/** Terminal-success column; satisfies dependencies. RESTRICTED (built-in only). */
|
||||
complete?: boolean;
|
||||
/** Globally archived; hidden from the board. RESTRICTED (built-in only). */
|
||||
archived?: boolean;
|
||||
/** Hidden from the board lane (e.g. archived columns). */
|
||||
hiddenFromBoard?: boolean;
|
||||
/** Leaving this column hard-cancels in-flight work (abort-on-exit). */
|
||||
abortOnExit?: boolean;
|
||||
/** Cards cannot leave until explicit human approval. */
|
||||
humanReview?: boolean;
|
||||
/** Where new cards land; exactly one per workflow (validated). */
|
||||
intake?: boolean;
|
||||
/** Passive dwell column with a release condition. */
|
||||
hold?: boolean;
|
||||
/** Participates in merge/PR orchestration (enqueues onto the merge queue). */
|
||||
mergeOrchestration?: boolean;
|
||||
/** Entry to this column is blocked until the merge-class node completed. */
|
||||
mergeBlocker?: boolean;
|
||||
/** Card progress/fields are reset on entry (reopen semantics). */
|
||||
resetOnEntry?: boolean;
|
||||
/** Cumulative active-time accounting runs on enter/exit. */
|
||||
timing?: boolean;
|
||||
/** Stall detection is evaluated by the sweep for cards dwelling here. */
|
||||
stallDetection?: boolean;
|
||||
/** Emits notifications on enter/exit. */
|
||||
notify?: boolean;
|
||||
/** A gate (advisory or blocking) is evaluated before entry. */
|
||||
gate?: boolean;
|
||||
}
|
||||
|
||||
/** The flag keys that are restricted to built-in traits (R22, KTD-7). A
|
||||
* non-builtin (plugin) trait declaring any of these is rejected at
|
||||
* registration. The sync `guard` hook descriptor is restricted separately. */
|
||||
export const RESTRICTED_TRAIT_FLAGS = ["complete", "archived"] as const;
|
||||
export type RestrictedTraitFlag = (typeof RESTRICTED_TRAIT_FLAGS)[number];
|
||||
|
||||
/** A trait's hook descriptors — *what* the trait declares it participates in.
|
||||
* `true` means "this trait has a hook of this kind"; the implementation is
|
||||
* registered separately via the engine DI seam. */
|
||||
export interface TraitHookDescriptors {
|
||||
/** Sync, in-lock guard. BUILT-IN ONLY (KTD-2/R22). */
|
||||
guard?: boolean;
|
||||
/** Async, pre-evaluated gate. Plugin-facing surface. */
|
||||
gate?: boolean;
|
||||
/** Post-commit, async, idempotent enter effect. */
|
||||
onEnter?: boolean;
|
||||
/** Post-commit, async, idempotent exit effect. */
|
||||
onExit?: boolean;
|
||||
/** Release-condition evaluation for hold columns (sweep-driven). */
|
||||
releaseCondition?: boolean;
|
||||
}
|
||||
|
||||
/** A declarative description of a trait's config schema. Lightweight by design
|
||||
* (U2 ships the shapes; richer validation lands with each behavior unit). */
|
||||
export interface TraitConfigField {
|
||||
key: string;
|
||||
type: "string" | "number" | "boolean" | "enum" | "object" | "array";
|
||||
required?: boolean;
|
||||
/** For `enum` fields: the allowed values. */
|
||||
enumValues?: readonly string[];
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface TraitConfigSchema {
|
||||
fields: TraitConfigField[];
|
||||
}
|
||||
|
||||
/** A trait definition: declarative flags + optional config schema + optional
|
||||
* hook descriptors. Built-in traits set `builtin: true`. */
|
||||
export interface TraitDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
flags: TraitFlags;
|
||||
configSchema?: TraitConfigSchema;
|
||||
hooks?: TraitHookDescriptors;
|
||||
/** True for the 14 built-in traits; plugin/custom traits leave this falsy.
|
||||
* Restricted capabilities (R22) are allowed only when `builtin` is true. */
|
||||
builtin?: boolean;
|
||||
}
|
||||
|
||||
// ── Hook implementation DI seam (core→engine) ───────────────────────────────
|
||||
//
|
||||
// Implementations of trait hooks are NOT defined in core (core is engine-free).
|
||||
// The engine registers them via `registerTraitHookImpl` the way it wires
|
||||
// `setCreateFnAgent`. Core resolves an implementation through
|
||||
// `getTraitHookImpl`; an unregistered hook resolves to a no-op (the registry's
|
||||
// `resolveTraitHook` returns a no-op + an audit warning, see trait-registry).
|
||||
//
|
||||
// The impl signature is intentionally opaque here: core never invokes hooks
|
||||
// directly (the store/sweep do, in engine-adjacent code), so core only needs to
|
||||
// store/retrieve the registration. Using `unknown` keeps core free of engine
|
||||
// types while remaining type-safe at the registration boundary.
|
||||
|
||||
/** A registered hook implementation. Opaque to core; the engine supplies a
|
||||
* concrete callable and casts at its own call sites. */
|
||||
export type TraitHookImpl = (...args: unknown[]) => unknown;
|
||||
|
||||
/** Stable key for a (traitId, hookKind) implementation registration. */
|
||||
export function traitHookKey(traitId: string, hookKind: TraitHookKind): string {
|
||||
return `${traitId}::${hookKind}`;
|
||||
}
|
||||
Reference in New Issue
Block a user