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
27 changes: 25 additions & 2 deletions .agents/skills/typescript-as-go/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ compatibility: Designed for Claude Code (or similar products)
metadata:
author: revett
repo: https://github.com/revett/typescript-as-go
version: 0.4.0
version: 0.5.1
---

# TypeScript As Go
Expand All @@ -20,7 +20,7 @@ to read and review, not clever to write. No magic.
## Rules

As an agent working in this project, you must follow the following allowed and banned rules when
writing Typescript.
writing Typescript, as well as the strict file layout rules.

### Allowed

Expand Down Expand Up @@ -99,3 +99,26 @@ writing Typescript.
15. No chained array pipelines (`.filter().map()`, `.map().filter()`) and no `.reduce`; Go has no
map or filter, so write an explicit `for...of` that guards with `continue` and pushes into a
result, one readable loop over a pipeline (a single `.map` on an already clean list is fine)

### File Layout

Every file declares its symbols in the same order, so a reader always knows where to look and a diff
never argues about placement. Sort by kind first, then by export status within a kind, then
alphabetically (case insensitive) within that:

1. Imports, ordered by the formatter
2. Exported constants, A-Z
3. Constants, A-Z
4. Exported types, A-Z
5. Types, A-Z
6. Exported functions, A-Z
7. Functions, A-Z
8. The framework class shell and its default export, last, since it is glue rather than logic

Notes:

- Alphabetical is mechanical on purpose; there is no judgement call to make and no debate to have
- A `DEFAULT_X` constant will therefore sit above the type it is annotated with; this is fine,
types hoist, and predictable placement is worth more than local reading order
- Test files are exempt and never resorted to match their source, reading instead as a narrative
that groups cases by the symbol under test from central to peripheral, happy path first
2 changes: 1 addition & 1 deletion skills-lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"source": "revett/typescript-as-go",
"sourceType": "github",
"skillPath": "skills/typescript-as-go/SKILL.md",
"computedHash": "4b0f12431264d1d9662cfd3a4400f33bda79e818bbaa03dfdf6066dab231a4cb"
"computedHash": "83bd4aa6f247993b957dda43eba2a4c4f63ab673a9c6b4d232b6a73216e8c232"
}
}
}
10 changes: 5 additions & 5 deletions src/log/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ import {
trimLogLines,
} from "./log.ts";

// COMPACT_INTERVAL is how many appends accumulate before the log file is trimmed back down to
// maxLines. Appending is cheap (DataAdapter.append); a full read/trim/write is not, so
// compaction runs in batches rather than after every single line.
const COMPACT_INTERVAL = 50;

// createLogSink returns the real file backed sink, or an in-memory fallback when dir is
// undefined (some embedded/test hosts never set manifest.dir).
export function createLogSink(
Expand All @@ -21,11 +26,6 @@ export function createLogSink(
return createObsidianLogSink(adapter, `${dir}/geode.log`, maxLines);
}

// COMPACT_INTERVAL is how many appends accumulate before the log file is trimmed back down to
// maxLines. Appending is cheap (DataAdapter.append); a full read/trim/write is not, so
// compaction runs in batches rather than after every single line.
const COMPACT_INTERVAL = 50;

// createObsidianLogSink returns a LogSink that persists to a capped file at logPath via the vault
// adapter, appending cheaply and periodically trimming back down to maxLines so the file can't
// grow unbounded over a long running session.
Expand Down
140 changes: 70 additions & 70 deletions src/log/log.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
// LogLevel orders from least to most severe.
export type LogLevel = "debug" | "info" | "warn" | "error";
const LEVEL_ORDER: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2, error: 3 };

// LogEntry is one line of geode's log.
export type LogEntry = {
Expand All @@ -8,6 +7,17 @@ export type LogEntry = {
message: string;
};

// Logger is the interface the rest of the plugin logs through.
export type Logger = {
debug: (message: string) => void;
info: (message: string) => void;
warn: (message: string) => void;
error: (message: string) => void;
};

// LogLevel orders from least to most severe.
export type LogLevel = "debug" | "info" | "warn" | "error";

// LogSink persists log entries and reads them back. The real implementation writes to a capped
// file inside the plugin's own data directory (see adapter.ts); tests, and the fallback used
// when there's nowhere to persist to, use an in-memory sink (see createMemorySink below).
Expand All @@ -17,23 +27,47 @@ export type LogSink = {
clear: () => Promise<void>;
};

// Logger is the interface the rest of the plugin logs through.
export type Logger = {
debug: (message: string) => void;
info: (message: string) => void;
warn: (message: string) => void;
error: (message: string) => void;
};

const LEVEL_ORDER: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2, error: 3 };
// createLogger returns a Logger that mirrors every message to the console and persists it via
// sink, both gated by the same minLevel.
export function createLogger(sink: LogSink, minLevel: LogLevel): Logger {
const log = (level: LogLevel, message: string) => {
if (!levelEnabled(level, minLevel)) {
return;
}
consoleFor(level)(`geode: ${message}`);
// Fire and forget: the Logger API is synchronous, so append can't be awaited. Report a
// failed persist to the console rather than leaving it as an unhandled rejection, and never
// back into sink, which would recurse if the sink itself is what's failing.
sink.append({ time: Date.now(), level, message }).catch((err) => {
console.error(`geode: log sink append failed: ${err}`);
});
};

// levelEnabled reports whether a message at level should be logged when the minimum is minLevel.
export function levelEnabled(level: LogLevel, minLevel: LogLevel): boolean {
return LEVEL_ORDER[level] >= LEVEL_ORDER[minLevel];
return {
debug: (message) => log("debug", message),
info: (message) => log("info", message),
warn: (message) => log("warn", message),
error: (message) => log("error", message),
};
}

function isLogLevel(value: string): value is LogLevel {
return value === "debug" || value === "info" || value === "warn" || value === "error";
// createMemorySink returns a LogSink backed by an in-memory array, capped at maxLines. Used as
// the fallback logger when there's nowhere to persist to, and as the fake sink in tests.
export function createMemorySink(maxLines: number): LogSink {
let entries: LogEntry[] = [];

return {
append: async (entry) => {
entries.push(entry);
if (entries.length > maxLines) {
entries = entries.slice(entries.length - maxLines);
}
},
read: async () => [...entries],
clear: async () => {
entries = [];
},
};
}

// formatLogLine renders one entry as a single persisted line: an ISO timestamp, the level, then
Expand All @@ -43,6 +77,11 @@ export function formatLogLine(entry: LogEntry): string {
return `${new Date(entry.time).toISOString()}\t${entry.level}\t${entry.message}`;
}

// levelEnabled reports whether a message at level should be logged when the minimum is minLevel.
export function levelEnabled(level: LogLevel, minLevel: LogLevel): boolean {
return LEVEL_ORDER[level] >= LEVEL_ORDER[minLevel];
}

// parseLogLine reverses formatLogLine. A malformed line (a corrupt or truncated file) is dropped
// rather than thrown, consistent with how state.json failures fail open elsewhere.
export function parseLogLine(line: string): LogEntry | undefined {
Expand All @@ -57,20 +96,6 @@ export function parseLogLine(line: string): LogEntry | undefined {
return { time, level: rawLevel, message: rest.join("\t") };
}

// linesOf splits a log's text into its lines. Every persisted line ends in "\n", so a naive split
// leaves one trailing empty element; drop only that one rather than filtering every blank line
// generically, so a line that was genuinely empty for some other reason is never silently eaten.
function linesOf(text: string): string[] {
if (text === "") {
return [];
}
const parts = text.split("\n");
if (parts[parts.length - 1] === "") {
return parts.slice(0, -1);
}
return parts;
}

// trimLogLines keeps only the last maxLines lines of a log, dropping the oldest. The result keeps
// the same trailing newline the input had: appending assumes the file already ends in one, and
// dropping it here would glue the next appended line onto the last one still kept.
Expand All @@ -86,25 +111,6 @@ export function trimLogLines(text: string, maxLines: number): string {
return `${kept.join("\n")}\n`;
}

// createMemorySink returns a LogSink backed by an in-memory array, capped at maxLines. Used as
// the fallback logger when there's nowhere to persist to, and as the fake sink in tests.
export function createMemorySink(maxLines: number): LogSink {
let entries: LogEntry[] = [];

return {
append: async (entry) => {
entries.push(entry);
if (entries.length > maxLines) {
entries = entries.slice(entries.length - maxLines);
}
},
read: async () => [...entries],
clear: async () => {
entries = [];
},
};
}

// consoleFor returns the console method matching level, so console and persisted output agree on
// severity.
function consoleFor(level: LogLevel): (message: string) => void {
Expand All @@ -117,26 +123,20 @@ function consoleFor(level: LogLevel): (message: string) => void {
return (message) => console.log(message);
}

// createLogger returns a Logger that mirrors every message to the console and persists it via
// sink, both gated by the same minLevel.
export function createLogger(sink: LogSink, minLevel: LogLevel): Logger {
const log = (level: LogLevel, message: string) => {
if (!levelEnabled(level, minLevel)) {
return;
}
consoleFor(level)(`geode: ${message}`);
// Fire and forget: the Logger API is synchronous, so append can't be awaited. Report a
// failed persist to the console rather than leaving it as an unhandled rejection, and never
// back into sink, which would recurse if the sink itself is what's failing.
sink.append({ time: Date.now(), level, message }).catch((err) => {
console.error(`geode: log sink append failed: ${err}`);
});
};
function isLogLevel(value: string): value is LogLevel {
return value === "debug" || value === "info" || value === "warn" || value === "error";
}

return {
debug: (message) => log("debug", message),
info: (message) => log("info", message),
warn: (message) => log("warn", message),
error: (message) => log("error", message),
};
// linesOf splits a log's text into its lines. Every persisted line ends in "\n", so a naive split
// leaves one trailing empty element; drop only that one rather than filtering every blank line
// generically, so a line that was genuinely empty for some other reason is never silently eaten.
function linesOf(text: string): string[] {
if (text === "") {
return [];
}
const parts = text.split("\n");
if (parts[parts.length - 1] === "") {
return parts.slice(0, -1);
}
return parts;
}
16 changes: 8 additions & 8 deletions src/log/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,6 @@ export const LOG_VIEW_TYPE = "geode-log-view";
// pane is sitting open still show up without a manual refresh.
const POLL_INTERVAL_MS = 2000;

// renderRow draws one entry into list, colour coded by level via a geode-log-row.is-<level> class.
function renderRow(list: HTMLElement, entry: LogEntry): void {
const row = list.createDiv({ cls: `geode-log-row is-${entry.level}` });
row.createSpan({ cls: "geode-log-time", text: new Date(entry.time).toLocaleString() });
row.createSpan({ cls: "geode-log-level", text: entry.level.toUpperCase() });
row.createSpan({ cls: "geode-log-message", text: entry.message });
}

// renderLogView draws entries into containerEl, most recent first.
function renderLogView(containerEl: HTMLElement, entries: LogEntry[]): void {
containerEl.empty();
Expand All @@ -32,6 +24,14 @@ function renderLogView(containerEl: HTMLElement, entries: LogEntry[]): void {
}
}

// renderRow draws one entry into list, colour coded by level via a geode-log-row.is-<level> class.
function renderRow(list: HTMLElement, entry: LogEntry): void {
const row = list.createDiv({ cls: `geode-log-row is-${entry.level}` });
row.createSpan({ cls: "geode-log-time", text: new Date(entry.time).toLocaleString() });
row.createSpan({ cls: "geode-log-level", text: entry.level.toUpperCase() });
row.createSpan({ cls: "geode-log-message", text: entry.message });
}

// GeodeLogView renders geode's persisted log as a plain, most recent first list. Read only: it
// has no way to write log entries, only display what the sink already recorded.
export class GeodeLogView extends ItemView {
Expand Down
12 changes: 6 additions & 6 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,17 @@ import {
} from "./vault/obsidian";
import { diffSnapshots, takeSnapshot } from "./vault/vault";

// VAULT_STATE_DEBOUNCE_MS delays a vault state refresh after the last file event, so a burst of
// edits (autosave, bulk rename, etc.) collapses into one snapshot instead of one per file.
const VAULT_STATE_DEBOUNCE_MS = 2000;
// LOG_MIN_LEVEL is fixed rather than user configurable: there's no meaningful "quiet" mode to
// offer today, so a verbosity setting would be a toggle with no observable effect.
const LOG_MIN_LEVEL = "debug";

// MAX_LOG_LINES caps how many lines geode.log keeps on disk, so a long running session can't
// grow it unbounded.
const MAX_LOG_LINES = 500;

// LOG_MIN_LEVEL is fixed rather than user configurable: there's no meaningful "quiet" mode to
// offer today, so a verbosity setting would be a toggle with no observable effect.
const LOG_MIN_LEVEL = "debug";
// VAULT_STATE_DEBOUNCE_MS delays a vault state refresh after the last file event, so a burst of
// edits (autosave, bulk rename, etc.) collapses into one snapshot instead of one per file.
const VAULT_STATE_DEBOUNCE_MS = 2000;

// AppWithSetting adds Obsidian's internal, undocumented settings-window API (there is no public
// equivalent) so the Settings command can jump straight to Geode's tab, and opening the log view
Expand Down
30 changes: 15 additions & 15 deletions src/settings/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ import {
} from "./settings.ts";

const normalizeCases: { name: string; input: unknown; want: GeodeSettings }[] = [
{
name: "partial legacy object",
input: { bucket: "my-bucket", accessKeyId: "AKIA123" },
want: { ...DEFAULT_SETTINGS, bucket: "my-bucket", accessKeyId: "AKIA123" },
},
{
name: "null",
input: null,
Expand All @@ -27,11 +32,6 @@ const normalizeCases: { name: string; input: unknown; want: GeodeSettings }[] =
input: {},
want: DEFAULT_SETTINGS,
},
{
name: "partial legacy object",
input: { bucket: "my-bucket", accessKeyId: "AKIA123" },
want: { ...DEFAULT_SETTINGS, bucket: "my-bucket", accessKeyId: "AKIA123" },
},
{
name: "junk types in string fields",
input: { accountId: 42, endpoint: true, region: [1, 2], bucket: null, accessKeyId: {} },
Expand Down Expand Up @@ -167,16 +167,6 @@ for (const { name, a, b, want } of settingsEqualCases) {
}

const hasConnectionConfigCases: { name: string; input: GeodeSettings; want: boolean }[] = [
{
name: "empty settings are incomplete",
input: DEFAULT_SETTINGS,
want: false,
},
{
name: "r2 missing account ID is incomplete",
input: { ...DEFAULT_SETTINGS, bucket: "b", accessKeyId: "a", secretId: "s" },
want: false,
},
{
name: "r2 with all fields is complete",
input: {
Expand All @@ -188,6 +178,16 @@ const hasConnectionConfigCases: { name: string; input: GeodeSettings; want: bool
},
want: true,
},
{
name: "empty settings are incomplete",
input: DEFAULT_SETTINGS,
want: false,
},
{
name: "r2 missing account ID is incomplete",
input: { ...DEFAULT_SETTINGS, bucket: "b", accessKeyId: "a", secretId: "s" },
want: false,
},
{
name: "custom missing region is incomplete",
input: {
Expand Down
Loading