Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 0 additions & 1 deletion skill/example/CoreLogging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ export default class CoreLogging {
onDispose(): void {
this.log('INFO', 'CoreLogging disposed');
this.logs = [];
this.onOverflow.dispose();
}
root = {
log: this.log,
Expand Down
5 changes: 2 additions & 3 deletions skill/example/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,8 @@ class PolisAlert {
logs: Array<LogEntry>;

constructor(public options: MergeSingleKey<AllModules, 'options'>) {
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();

Expand Down
22 changes: 13 additions & 9 deletions src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export type Context<
__addModule__: <N extends ModuleConstructor<MergeResult<[...M, N], K, Pr, Po>>>(
newModule: N,
) => Context<[...M, N], K, Pr, Po>;
__assign__: (obj: Partial<MergeResult<M, K, Pr, Po>>) => Context<M, K, Pr, Po>;
};

const ROOT_KEY = 'root';
Expand All @@ -91,12 +92,10 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
function assignShallow(target: Record<string, unknown>, 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;
}

Expand Down Expand Up @@ -145,7 +144,6 @@ export default function createContext<
options: {
preMerge?: Pr;
postMerge?: Po;
assign?: Partial<MergeResult<M, K, Pr, Po>>;
mergeKeys: ReadonlyArray<K>;
injectKeys?: ReadonlyArray<I>;
},
Expand All @@ -169,7 +167,6 @@ export default function createContext<

const finalizeContext = () => {
mergeShallow(context, options.postMerge);
mergeShallow(context, options.assign);
injectContext();
};

Expand All @@ -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, {
Expand All @@ -197,6 +197,10 @@ export default function createContext<
return context as Context<[...M, N], K, Pr, Po>;
},
},
__assign__: {
enumerable: false,
value: (obj: Partial<MergeResult<M, K, Pr, Po>>) => mergeShallow(context, obj),
},
__getModule__: {
enumerable: false,
value: <C extends M[number]>(ctor: C) => {
Expand Down
27 changes: 11 additions & 16 deletions src/reactive.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
// oxlint-disable unicorn/no-useless-spread : spread is needed to avoid mutating the set while iterating

type RefMatchingFunc<T> = (newValue: T, oldValue: T) => void;
export type Ref<T> = {
(): T;
(newValue: T): void;
subscribe(func: RefMatchingFunc<T>): () => void;
subscribe(func: RefMatchingFunc<T>, options?: { immediate?: boolean }): () => void;
unsubscribe(func: RefMatchingFunc<T>): void;
dispose(): void;
};
type RefOptions<T> = { equals?: (newValue: T, oldValue: T) => boolean };

Expand All @@ -20,16 +21,14 @@ export function ref<T>(initial: T, options?: RefOptions<T>): Ref<T> {
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<T>;
result.subscribe = (callback: RefMatchingFunc<T>) => {
result.subscribe = (callback, ops) => {
subs.add(callback);
if (ops?.immediate) callback(value, value);
return () => result.unsubscribe(callback);
};
result.unsubscribe = (callback: RefMatchingFunc<T>) => subs.delete(callback);
result.dispose = () => subs.clear();
result.unsubscribe = (callback) => subs.delete(callback);
return result;
}

Expand All @@ -39,30 +38,26 @@ export type Hook<Args extends GeneralArray = []> = {
(...args: Args): void;
subscribe(callback: HookMatchingFunc<Args>): () => void;
unsubscribe(callback: HookMatchingFunc<Args>): void;
dispose(): void;
};

export function hook<Args extends GeneralArray = []>(): Hook<Args> {
const subs = new Set<HookMatchingFunc<Args>>();
const result: Hook<Args> = (...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<Args>) => {
result.subscribe = (callback) => {
subs.add(callback);
return () => result.unsubscribe(callback);
};
result.unsubscribe = (callback: HookMatchingFunc<Args>) => subs.delete(callback);
result.dispose = () => subs.clear();
result.unsubscribe = (callback) => subs.delete(callback);
return result;
}

let activeTracker: ((ref: Trackable) => void) | undefined;
type Trackable = Ref<any> | Computed<any>;
export type Computed<T> = {
(): T;
subscribe(func: RefMatchingFunc<T>): () => void;
subscribe(func: RefMatchingFunc<T>, options?: { immediate?: boolean }): () => void;
unsubscribe(func: RefMatchingFunc<T>): void;
dispose(): void;
};
Expand Down Expand Up @@ -99,14 +94,14 @@ export function computed<T>(getter: () => T, options?: ComputedOptions<T>): 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;
}
26 changes: 0 additions & 26 deletions test/reactive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]> = [];
Expand All @@ -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);
Expand Down