From 3d871b2c382aa3881c55976f0836069c1d2b359d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?He=CC=84sperus?= Date: Mon, 22 Jun 2026 11:40:56 +0800 Subject: [PATCH] feat(context): add post-construction assign ability to context --- AGENTS.md | 16 ++++++++++++++++ package.json | 4 ++-- skill/example/CoreLogging.ts | 1 - skill/example/index.ts | 5 ++--- src/context.ts | 22 +++++++++++++--------- src/reactive.ts | 27 +++++++++++---------------- test/reactive.test.ts | 26 -------------------------- 7 files changed, 44 insertions(+), 57 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a9b11e2 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,16 @@ +SynthKernel is an experimental TypeScript software architecture. + +## Files + +- `skill/` SynthKernel agent skill (stale) +- `skill/example/` SynthKernel implementation example (up-to-date) +- `src/` SynthKernel package (up-to-date) +- `whitepaper.ipynb` SynthKernel whitepaper (stale) + +## Commands + +- `pnpm lint`: format and fix fixable lint errors (always run before `pnpm check`) +- `pnpm check`: check types, lint and format (no fix) +- `pnpm build`: build package +- `pnpm test`: run tests +- `bun ./skill/example/main.ts`: run example diff --git a/package.json b/package.json index c4b7a50..9514bb1 100644 --- a/package.json +++ b/package.json @@ -38,8 +38,8 @@ "scripts": { "build": "tsdown && oxfmt package.json", "test": "vitest run", - "lint": "oxlint --fix && oxfmt", - "check": "tsc && oxlint && oxfmt --check" + "lint": "oxlint --fix --silent && oxfmt", + "check": "oxlint --type-check --format agent && oxfmt --check" }, "devDependencies": { "@types/node": "^25.9.1", diff --git a/skill/example/CoreLogging.ts b/skill/example/CoreLogging.ts index e4524bb..202dc60 100644 --- a/skill/example/CoreLogging.ts +++ b/skill/example/CoreLogging.ts @@ -46,7 +46,6 @@ export default class CoreLogging { onDispose(): void { this.log('INFO', 'CoreLogging disposed'); this.logs = []; - this.onOverflow.dispose(); } root = { log: this.log, diff --git a/skill/example/index.ts b/skill/example/index.ts index e837ccb..d4d55e4 100644 --- a/skill/example/index.ts +++ b/skill/example/index.ts @@ -20,9 +20,8 @@ class PolisAlert { logs: Array; constructor(public options: MergeSingleKey) { - this.ctx = createContext(allModules, { - assign: { options }, - mergeKeys: ['options', 'root'], + this.ctx = createContext(allModules, { mergeKeys: ['options', 'root'] }).__assign__({ + options, }); for (const ctor of allModules) this.ctx.__getModule__(ctor).onStart(); diff --git a/src/context.ts b/src/context.ts index ab4958f..cf83f92 100644 --- a/src/context.ts +++ b/src/context.ts @@ -79,6 +79,7 @@ export type Context< __addModule__: >>( newModule: N, ) => Context<[...M, N], K, Pr, Po>; + __assign__: (obj: Partial>) => Context; }; const ROOT_KEY = 'root'; @@ -91,12 +92,10 @@ function isPlainObject(value: unknown): value is Record { function assignShallow(target: Record, key: string, value: unknown) { if (value === undefined) return target; const current = target[key]; - target[key] = - isPlainObject(current) && isPlainObject(value) - ? { ...current, ...value } - : isPlainObject(value) - ? { ...value } - : value; + if (isPlainObject(current) && isPlainObject(value)) { + Object.assign(current, value); + target[key] = current; + } else target[key] = value; return target; } @@ -145,7 +144,6 @@ export default function createContext< options: { preMerge?: Pr; postMerge?: Po; - assign?: Partial>; mergeKeys: ReadonlyArray; injectKeys?: ReadonlyArray; }, @@ -169,7 +167,6 @@ export default function createContext< const finalizeContext = () => { mergeShallow(context, options.postMerge); - mergeShallow(context, options.assign); injectContext(); }; @@ -183,7 +180,10 @@ export default function createContext< const injectContext = () => { for (const instance of instances) - for (const key of injectKeys) instance[key] = key === ROOT_KEY ? context : context[key]; + for (const key of injectKeys) { + if (context[key] === undefined) context[key] = {}; + instance[key] = key === ROOT_KEY ? context : context[key]; + } }; Object.defineProperties(context, { @@ -197,6 +197,10 @@ export default function createContext< return context as Context<[...M, N], K, Pr, Po>; }, }, + __assign__: { + enumerable: false, + value: (obj: Partial>) => mergeShallow(context, obj), + }, __getModule__: { enumerable: false, value: (ctor: C) => { diff --git a/src/reactive.ts b/src/reactive.ts index e23252d..a11d0fc 100644 --- a/src/reactive.ts +++ b/src/reactive.ts @@ -1,10 +1,11 @@ +// oxlint-disable unicorn/no-useless-spread : spread is needed to avoid mutating the set while iterating + type RefMatchingFunc = (newValue: T, oldValue: T) => void; export type Ref = { (): T; (newValue: T): void; - subscribe(func: RefMatchingFunc): () => void; + subscribe(func: RefMatchingFunc, options?: { immediate?: boolean }): () => void; unsubscribe(func: RefMatchingFunc): void; - dispose(): void; }; type RefOptions = { equals?: (newValue: T, oldValue: T) => boolean }; @@ -20,16 +21,14 @@ export function ref(initial: T, options?: RefOptions): Ref { if (equals(newValue, value)) return; const oldValue = value; value = newValue; - // Spread is needed to avoid mutating the set while iterating - // oxlint-disable-next-line unicorn/no-useless-spread for (const callback of [...subs]) callback(newValue, oldValue); }) as Ref; - result.subscribe = (callback: RefMatchingFunc) => { + result.subscribe = (callback, ops) => { subs.add(callback); + if (ops?.immediate) callback(value, value); return () => result.unsubscribe(callback); }; - result.unsubscribe = (callback: RefMatchingFunc) => subs.delete(callback); - result.dispose = () => subs.clear(); + result.unsubscribe = (callback) => subs.delete(callback); return result; } @@ -39,22 +38,18 @@ export type Hook = { (...args: Args): void; subscribe(callback: HookMatchingFunc): () => void; unsubscribe(callback: HookMatchingFunc): void; - dispose(): void; }; export function hook(): Hook { const subs = new Set>(); const result: Hook = (...args: Args) => { - // Spread is needed to avoid mutating the set while iterating - // oxlint-disable-next-line unicorn/no-useless-spread for (const callback of [...subs]) callback(...args); }; - result.subscribe = (callback: HookMatchingFunc) => { + result.subscribe = (callback) => { subs.add(callback); return () => result.unsubscribe(callback); }; - result.unsubscribe = (callback: HookMatchingFunc) => subs.delete(callback); - result.dispose = () => subs.clear(); + result.unsubscribe = (callback) => subs.delete(callback); return result; } @@ -62,7 +57,7 @@ let activeTracker: ((ref: Trackable) => void) | undefined; type Trackable = Ref | Computed; export type Computed = { (): T; - subscribe(func: RefMatchingFunc): () => void; + subscribe(func: RefMatchingFunc, options?: { immediate?: boolean }): () => void; unsubscribe(func: RefMatchingFunc): void; dispose(): void; }; @@ -99,14 +94,14 @@ export function computed(getter: () => T, options?: ComputedOptions): Comp activeTracker = prev; } const cleanup = deps.map((dep) => dep.subscribe(update)); - result.subscribe = (cb) => { + result.subscribe = (cb, ops) => { subs.add(cb); + if (ops?.immediate) cb(value, value); return () => result.unsubscribe(cb); }; result.unsubscribe = (cb) => subs.delete(cb); result.dispose = () => { while (cleanup.length) cleanup.pop()!(); - subs.clear(); }; return result; } diff --git a/test/reactive.test.ts b/test/reactive.test.ts index a04f3f1..0e8bcfb 100644 --- a/test/reactive.test.ts +++ b/test/reactive.test.ts @@ -28,19 +28,6 @@ test('ref skips equal updates and supports custom equality', () => { expect(events).toStrictEqual([[2, 1]]); }); -test('ref dispose clears all subscribers', () => { - const value = ref(0); - let calls = 0; - value.subscribe(() => { - calls += 1; - }); - - value.dispose(); - value(1); - - expect(calls).toBe(0); -}); - test('hook publishes to subscribers', () => { const emit = hook<[string, number]>(); const events: Array<[string, number]> = []; @@ -61,19 +48,6 @@ test('hook publishes to subscribers', () => { ]); }); -test('hook dispose clears all subscribers', () => { - const emit = hook(); - let calls = 0; - emit.subscribe(() => { - calls += 1; - }); - - emit.dispose(); - emit(); - - expect(calls).toBe(0); -}); - test('computed tracks refs automatically', () => { const count = ref(1); const double = computed(() => count() * 2);