From d8608ba1ae0ea92e536f7a40ce646c7119ec0a50 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Mar 2026 10:37:38 +0000 Subject: [PATCH 1/8] Initial plan From ffa463806f949b8f1c704348e4f30f2ddc335a12 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Mar 2026 10:41:03 +0000 Subject: [PATCH 2/8] Initial plan for category config refactoring Co-authored-by: SorraTheOrc <250240+SorraTheOrc@users.noreply.github.com> --- package-lock.json | 51 ++++++++++++++++++++++++++++++++++++----------- package.json | 2 ++ 2 files changed, 41 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index e4e3fd5..90b8ccd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "buffer": "^6.0.3", "fft.js": "^4.0.4", "gray-matter": "^4.0.3", + "js-yaml": "^4.1.1", "marked": "^15.0.12", "marked-terminal": "^7.3.0", "node-web-audio-api": "^1.0.8", @@ -28,6 +29,7 @@ "toneforge": "bin/dev-cli.js" }, "devDependencies": { + "@types/js-yaml": "^4.0.9", "@types/marked-terminal": "^6.1.1", "@types/yargs": "^17.0.35", "concurrently": "^9.2.1", @@ -1242,6 +1244,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/marked-terminal": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/@types/marked-terminal/-/marked-terminal-6.1.1.tgz", @@ -1486,13 +1495,10 @@ "license": "MIT" }, "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" }, "node_modules/assertion-error": { "version": "2.0.1", @@ -2131,6 +2137,28 @@ "node": ">=6.0" } }, + "node_modules/gray-matter/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/gray-matter/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2216,13 +2244,12 @@ } }, "node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "license": "MIT", "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" + "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" diff --git a/package.json b/package.json index 76a0df7..d813902 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "buffer": "^6.0.3", "fft.js": "^4.0.4", "gray-matter": "^4.0.3", + "js-yaml": "^4.1.1", "marked": "^15.0.12", "marked-terminal": "^7.3.0", "node-web-audio-api": "^1.0.8", @@ -44,6 +45,7 @@ "yargs": "^17.7.2" }, "devDependencies": { + "@types/js-yaml": "^4.0.9", "@types/marked-terminal": "^6.1.1", "@types/yargs": "^17.0.35", "concurrently": "^9.2.1", From 8844eed2e7f1c39af500a0fdc7ea1dfad79e587b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Mar 2026 10:44:33 +0000 Subject: [PATCH 3/8] feat: lazy-load category prefix mappings from .toneforge/config.yaml with js-yaml Co-authored-by: SorraTheOrc <250240+SorraTheOrc@users.noreply.github.com> --- .toneforge/config.yaml | 20 ++++ src/classify/__tests__/category.test.ts | 129 +++++++++++++++++++++++- src/classify/dimensions/category.ts | 100 +++++++++++++++++- 3 files changed, 245 insertions(+), 4 deletions(-) create mode 100644 .toneforge/config.yaml diff --git a/.toneforge/config.yaml b/.toneforge/config.yaml new file mode 100644 index 0000000..120fe45 --- /dev/null +++ b/.toneforge/config.yaml @@ -0,0 +1,20 @@ +# ToneForge category classifier configuration. +# Maps recipe-name prefixes (before the first '-') to canonical category labels. +# Edit this file to add or rename categories without modifying TypeScript source. +# Schema: prefixToCategory is a flat map of string -> string. + +prefixToCategory: + weapon: weapon + footstep: footstep + ui: ui + ambient: ambient + character: character + creature: creature + vehicle: vehicle + impact: impact + slam: impact + rumble: impact + debris: impact + rattle: impact + resonance: impact + card: card-game diff --git a/src/classify/__tests__/category.test.ts b/src/classify/__tests__/category.test.ts index ed92c3b..9d49c5c 100644 --- a/src/classify/__tests__/category.test.ts +++ b/src/classify/__tests__/category.test.ts @@ -1,4 +1,7 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi, afterEach } from "vitest"; +import { writeFileSync, mkdirSync, rmSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; import { CategoryClassifier } from "../dimensions/category.js"; import type { AnalysisResult } from "../../analyze/types.js"; import type { RecipeContext } from "../types.js"; @@ -155,3 +158,127 @@ describe("CategoryClassifier", () => { } }); }); + +describe("CategoryClassifier — config loading", () => { + let tmpDir: string; + + afterEach(() => { + if (tmpDir) { + rmSync(tmpDir, { recursive: true, force: true }); + } + vi.restoreAllMocks(); + }); + + function makeTmpConfig(content: string): string { + tmpDir = join(tmpdir(), `toneforge-cat-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(tmpDir, { recursive: true }); + const configPath = join(tmpDir, "config.yaml"); + writeFileSync(configPath, content, "utf-8"); + return configPath; + } + + function makeAnalysis(): AnalysisResult { + return { + analysisVersion: "1.0", + sampleRate: 44100, + sampleCount: 44100, + metrics: { + time: { duration: 1.0, peak: 0.5, rms: 0.2, crestFactor: 2.5 }, + quality: { clipping: false, silence: false }, + envelope: { attackTime: 10 }, + spectral: { spectralCentroid: 2000 }, + }, + }; + } + + it("loads prefix-to-category mappings from a valid YAML config", () => { + const configPath = makeTmpConfig(` +prefixToCategory: + explosion: explosion + weapon: weapon +`); + const classifier = new CategoryClassifier(configPath); + const analysis = makeAnalysis(); + + expect(classifier.classify(analysis, { name: "explosion-large", category: "" }).category).toBe("explosion"); + expect(classifier.classify(analysis, { name: "weapon-shotgun", category: "" }).category).toBe("weapon"); + }); + + it("config mappings override built-in defaults when present", () => { + const configPath = makeTmpConfig(` +prefixToCategory: + weapon: custom-weapon +`); + const classifier = new CategoryClassifier(configPath); + const analysis = makeAnalysis(); + // The config only has "weapon"; "card" is not in config so falls through to metric heuristics + expect(classifier.classify(analysis, { name: "weapon-laser", category: "" }).category).toBe("custom-weapon"); + }); + + it("falls back to built-in defaults and warns when config file is missing", () => { + const missingPath = join(tmpdir(), `nonexistent-${Date.now()}`, "config.yaml"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + const classifier = new CategoryClassifier(missingPath); + const analysis = makeAnalysis(); + + // Should use built-in defaults (card -> card-game) + expect(classifier.classify(analysis, { name: "card-flip", category: "" }).category).toBe("card-game"); + expect(warnSpy).toHaveBeenCalledOnce(); + const warnMessage = warnSpy.mock.calls[0]![0] as string; + expect(warnMessage).toContain("[ToneForge]"); + expect(warnMessage).toContain("built-in category mappings"); + }); + + it("caches mappings — warning is only emitted once per classifier instance", () => { + const missingPath = join(tmpdir(), `nonexistent-${Date.now()}`, "config.yaml"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + const classifier = new CategoryClassifier(missingPath); + const analysis = makeAnalysis(); + + classifier.classify(analysis, { name: "card-flip", category: "" }); + classifier.classify(analysis, { name: "weapon-gun", category: "" }); + + expect(warnSpy).toHaveBeenCalledOnce(); + }); + + it("throws on malformed YAML (not a top-level object)", () => { + const configPath = makeTmpConfig(`- item1\n- item2\n`); + const classifier = new CategoryClassifier(configPath); + const analysis = makeAnalysis(); + expect(() => classifier.classify(analysis, { name: "weapon-gun", category: "" })).toThrow( + /Invalid config/, + ); + }); + + it("throws when prefixToCategory key is missing", () => { + const configPath = makeTmpConfig(`someOtherKey:\n weapon: weapon\n`); + const classifier = new CategoryClassifier(configPath); + const analysis = makeAnalysis(); + expect(() => classifier.classify(analysis, { name: "weapon-gun", category: "" })).toThrow( + /prefixToCategory/, + ); + }); + + it("throws when a prefixToCategory value is not a string", () => { + const configPath = makeTmpConfig(`prefixToCategory:\n weapon: 42\n`); + const classifier = new CategoryClassifier(configPath); + const analysis = makeAnalysis(); + expect(() => classifier.classify(analysis, { name: "weapon-gun", category: "" })).toThrow( + /must be a string/, + ); + }); + + it("config load does not trigger when context.category is set", () => { + const missingPath = join(tmpdir(), `nonexistent-${Date.now()}`, "config.yaml"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + const classifier = new CategoryClassifier(missingPath); + const analysis = makeAnalysis(); + + // context.category is set, so config should not be loaded + classifier.classify(analysis, { name: "weapon-gun", category: "weapon" }); + expect(warnSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/classify/dimensions/category.ts b/src/classify/dimensions/category.ts index cc5b2ae..c4cba73 100644 --- a/src/classify/dimensions/category.ts +++ b/src/classify/dimensions/category.ts @@ -7,14 +7,23 @@ * Categories are always lowercase strings matching the vocabulary: * weapon, footstep, ui, ambient, character, creature, vehicle, impact, card-game. * + * Prefix-to-category mappings are loaded lazily from `.toneforge/config.yaml` + * (relative to the current working directory) on the first name-based lookup. + * If the config file is absent, the built-in defaults below are used and a + * warning is emitted. If the file exists but is malformed, an error is thrown + * so CI surfaces the problem immediately. + * * Reference: docs/prd/CLASSIFY_PRD.md */ +import { readFileSync } from "fs"; +import { join } from "path"; +import { load as loadYaml } from "js-yaml"; import type { AnalysisResult } from "../../analyze/types.js"; import type { DimensionClassifier, DimensionResult, RecipeContext } from "../types.js"; /** - * Known category prefixes extracted from recipe names. + * Built-in prefix-to-category defaults, used when `.toneforge/config.yaml` is absent. * * When a recipe name starts with one of these prefixes (before the first `-` * or as a known multi-segment prefix), the corresponding category is assigned. @@ -36,6 +45,9 @@ const RECIPE_NAME_CATEGORY_MAP: Record = { card: "card-game", }; +/** Default config file path, relative to the working directory at classify time. */ +const DEFAULT_CONFIG_PATH = join(".toneforge", "config.yaml"); + /** * Infer category from analysis metrics when no recipe metadata is available. * @@ -92,10 +104,91 @@ function inferCategoryFromMetrics(analysis: AnalysisResult): string { * * Uses recipe metadata as the primary signal for category, with * analysis-metric-based fallback for unknown sources. + * + * Prefix-to-category mappings are lazy-loaded from the YAML config on the + * first name-based lookup. Pass a custom `configPath` in the constructor to + * override the default location (useful for testing). */ export class CategoryClassifier implements DimensionClassifier { readonly name = "category"; + private readonly configPath: string | undefined; + private mappings: Record | null = null; + private loaded = false; + + /** + * @param configPath - Optional override for the config file path. When omitted, + * the path is resolved from `process.cwd()` at the time of the first name-based + * classify call, so it reflects the working directory at usage time. + */ + constructor(configPath?: string) { + this.configPath = configPath; + } + + /** + * Lazily load prefix-to-category mappings from `.toneforge/config.yaml`. + * + * - If the file is absent: emits a console warning and returns built-in defaults. + * - If the file is present but malformed: throws an error to fail fast. + * - On subsequent calls: returns the cached result. + */ + private loadMappings(): Record { + if (this.loaded) { + return this.mappings ?? RECIPE_NAME_CATEGORY_MAP; + } + this.loaded = true; + + const resolvedPath = this.configPath ?? join(process.cwd(), DEFAULT_CONFIG_PATH); + let raw: string; + try { + raw = readFileSync(resolvedPath, "utf-8"); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + console.warn( + `[ToneForge] No ${resolvedPath} found; using built-in category mappings.`, + ); + return RECIPE_NAME_CATEGORY_MAP; + } + throw err; + } + + const parsed = loadYaml(raw) as unknown; + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error( + `[ToneForge] Invalid config at ${resolvedPath}: expected a YAML object at the top level.`, + ); + } + + const config = parsed as Record; + const prefixToCategory = config["prefixToCategory"]; + + if ( + !prefixToCategory || + typeof prefixToCategory !== "object" || + Array.isArray(prefixToCategory) + ) { + throw new Error( + `[ToneForge] Invalid config at ${resolvedPath}: missing or invalid 'prefixToCategory' key.`, + ); + } + + const mappings: Record = {}; + for (const [key, value] of Object.entries( + prefixToCategory as Record, + )) { + if (typeof value !== "string") { + throw new Error( + `[ToneForge] Invalid config at ${resolvedPath}: prefixToCategory['${key}'] must be a string, got ${typeof value}.`, + ); + } + mappings[key] = value; + } + + this.mappings = mappings; + return mappings; + } + classify(analysis: AnalysisResult, context?: RecipeContext): DimensionResult { // Primary signal: recipe metadata // Normalize to lowercase hyphenated form (e.g. "Card Game" -> "card-game") @@ -104,10 +197,11 @@ export class CategoryClassifier implements DimensionClassifier { return { category: context.category.toLowerCase().replace(/\s+/g, "-") }; } - // Secondary signal: recipe name parsing + // Secondary signal: recipe name parsing (triggers lazy config load) if (context?.name) { + const mappings = this.loadMappings(); const firstSegment = context.name.split("-")[0]!; - const mapped = RECIPE_NAME_CATEGORY_MAP[firstSegment]; + const mapped = mappings[firstSegment]; if (mapped) { return { category: mapped }; } From fb447cc2f104d03906b41404b3aa4a84bd207245 Mon Sep 17 00:00:00 2001 From: Sorra <250240+SorraTheOrc@users.noreply.github.com> Date: Tue, 24 Mar 2026 20:51:56 +0000 Subject: [PATCH 4/8] TF-0MN50KG9O0SEHHC8: Add sequence golden fixtures and restore missing preset (#250) * TF-0MN50KG9O0SEHHC8: Restore missing preset presets/sequences/tableau_coin_collect.json (from origin/main) * TF-0MN50KG9O0SEHHC8: Add tableau_coin_collect golden fixture --------- Co-authored-by: Sorra the Orc --- presets/sequences/tableau_coin_collect.json | 20 ++++++++++++++ .../tableau_coin_collect.golden.json | 27 +++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 presets/sequences/tableau_coin_collect.json create mode 100644 src/test-utils/fixtures/golden-sequences/tableau_coin_collect.golden.json diff --git a/presets/sequences/tableau_coin_collect.json b/presets/sequences/tableau_coin_collect.json new file mode 100644 index 0000000..b78ebba --- /dev/null +++ b/presets/sequences/tableau_coin_collect.json @@ -0,0 +1,20 @@ +{ + "version": "1.0", + "name": "tableau_coin_collect", + "description": "Collect coins from the tableau market or rent collection in a high-street economy card game.", + "events": [ + { + "time": 0, + "event": "card-coin-collect", + "seedOffset": 0, + "gain": 1.0 + }, + { + "time": 0.15, + "event": "card-chip-stack", + "seedOffset": 1, + "gain": 0.8, + "probability": 0.7 + } + ] +} diff --git a/src/test-utils/fixtures/golden-sequences/tableau_coin_collect.golden.json b/src/test-utils/fixtures/golden-sequences/tableau_coin_collect.golden.json new file mode 100644 index 0000000..8cd3605 --- /dev/null +++ b/src/test-utils/fixtures/golden-sequences/tableau_coin_collect.golden.json @@ -0,0 +1,27 @@ +{ + "name": "tableau_coin_collect", + "seed": 42, + "sampleRate": 44100, + "totalDuration": 0.15, + "totalDuration_ms": 150, + "events": [ + { + "time_ms": 0, + "sampleOffset": 0, + "event": "card-coin-collect", + "seedOffset": 0, + "eventSeed": 42, + "gain": 1, + "repetition": 0 + }, + { + "time_ms": 150, + "sampleOffset": 6615, + "event": "card-chip-stack", + "seedOffset": 1, + "eventSeed": 43, + "gain": 0.8, + "repetition": 0 + } + ] +} From f9dfa5b1b85e6594c849621eb48031aad67f727c Mon Sep 17 00:00:00 2001 From: Sorra the Orc Date: Tue, 24 Mar 2026 21:40:20 +0000 Subject: [PATCH 5/8] Resolve merge markers in package files and config; keep canonical category map and js-yaml @types@4.0.9 --- .toneforge/config.yaml | 13 +++++++++---- package-lock.json | 10 +--------- package.json | 4 ---- 3 files changed, 10 insertions(+), 17 deletions(-) diff --git a/.toneforge/config.yaml b/.toneforge/config.yaml index f283228..0411597 100644 --- a/.toneforge/config.yaml +++ b/.toneforge/config.yaml @@ -1,9 +1,14 @@ -<<<<<<< HEAD # ToneForge category classifier configuration. # Maps recipe-name prefixes (before the first '-') to canonical category labels. # Edit this file to add or rename categories without modifying TypeScript source. # Schema: prefixToCategory is a flat map of string -> string. +# Combined defaults: main branch provides a small sample of infra labels, +# while the feature branch included a comprehensive category map used by +# classification tests. Preserve both by keeping the classification map as +# the authoritative set (lowercase, hyphenated) and include the additional +# maintenance labels as examples under `examples` to avoid breaking parsing. + prefixToCategory: weapon: weapon footstep: footstep @@ -19,11 +24,11 @@ prefixToCategory: rattle: impact resonance: impact card: card-game -======= -prefixToCategory: + +# Examples from origin/main (human-friendly labels) — not used by parser. +examples: ui: "User Interface" perf: "Performance" build: "Build System" core: "Core" infra: "Infrastructure" ->>>>>>> origin/main diff --git a/package-lock.json b/package-lock.json index 31f8031..2a32865 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,11 +14,7 @@ "buffer": "^6.0.3", "fft.js": "^4.0.4", "gray-matter": "^4.0.3", -<<<<<<< HEAD "js-yaml": "^4.1.1", -======= - "js-yaml": "^4.1.0", ->>>>>>> origin/main "marked": "^15.0.12", "marked-terminal": "^7.3.0", "node-web-audio-api": "^1.0.8", @@ -32,12 +28,8 @@ "tf": "bin/dev-cli.js", "toneforge": "bin/dev-cli.js" }, - "devDependencies": { -<<<<<<< HEAD + "devDependencies": { "@types/js-yaml": "^4.0.9", -======= - "@types/js-yaml": "^4.0.5", ->>>>>>> origin/main "@types/marked-terminal": "^6.1.1", "@types/yargs": "^17.0.35", "concurrently": "^9.2.1", diff --git a/package.json b/package.json index f0d61dd..ca35576 100644 --- a/package.json +++ b/package.json @@ -46,11 +46,7 @@ "yargs": "^17.7.2" }, "devDependencies": { -<<<<<<< HEAD "@types/js-yaml": "^4.0.9", -======= - "@types/js-yaml": "^4.0.5", ->>>>>>> origin/main "@types/marked-terminal": "^6.1.1", "@types/yargs": "^17.0.35", "concurrently": "^9.2.1", From 28c83faafd46b89984d466eb6cf00c2530ee1f02 Mon Sep 17 00:00:00 2001 From: Sorra the Orc Date: Tue, 24 Mar 2026 21:47:11 +0000 Subject: [PATCH 6/8] Remove duplicate js-yaml entry from dependencies --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index ca35576..d813902 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,6 @@ "node-web-audio-api": "^1.0.8", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", - "js-yaml": "^4.1.0", "tone": "^15.1.22", "unified": "^11.0.5", "yargs": "^17.7.2" From c2574946d020b0215d0f3ad55e79a8dbb3a9b8bd Mon Sep 17 00:00:00 2001 From: Sorra the Orc Date: Tue, 24 Mar 2026 21:47:18 +0000 Subject: [PATCH 7/8] Sync package-lock after removing duplicate dependency --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 2a32865..90b8ccd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,7 @@ "tf": "bin/dev-cli.js", "toneforge": "bin/dev-cli.js" }, - "devDependencies": { + "devDependencies": { "@types/js-yaml": "^4.0.9", "@types/marked-terminal": "^6.1.1", "@types/yargs": "^17.0.35", From 0f78724c8ddfdf4b203f429068059f557533dcf2 Mon Sep 17 00:00:00 2001 From: Sorra the Orc Date: Tue, 24 Mar 2026 22:20:24 +0000 Subject: [PATCH 8/8] TF-0MN50KG9O0SEHHC8: Remove leftover merge markers and restore coherent category loader behavior --- .toneforge/config.yaml | 34 ---------- src/classify/dimensions/category.ts | 101 ++++++++++++++-------------- 2 files changed, 51 insertions(+), 84 deletions(-) delete mode 100644 .toneforge/config.yaml diff --git a/.toneforge/config.yaml b/.toneforge/config.yaml deleted file mode 100644 index 0411597..0000000 --- a/.toneforge/config.yaml +++ /dev/null @@ -1,34 +0,0 @@ -# ToneForge category classifier configuration. -# Maps recipe-name prefixes (before the first '-') to canonical category labels. -# Edit this file to add or rename categories without modifying TypeScript source. -# Schema: prefixToCategory is a flat map of string -> string. - -# Combined defaults: main branch provides a small sample of infra labels, -# while the feature branch included a comprehensive category map used by -# classification tests. Preserve both by keeping the classification map as -# the authoritative set (lowercase, hyphenated) and include the additional -# maintenance labels as examples under `examples` to avoid breaking parsing. - -prefixToCategory: - weapon: weapon - footstep: footstep - ui: ui - ambient: ambient - character: character - creature: creature - vehicle: vehicle - impact: impact - slam: impact - rumble: impact - debris: impact - rattle: impact - resonance: impact - card: card-game - -# Examples from origin/main (human-friendly labels) — not used by parser. -examples: - ui: "User Interface" - perf: "Performance" - build: "Build System" - core: "Core" - infra: "Infrastructure" diff --git a/src/classify/dimensions/category.ts b/src/classify/dimensions/category.ts index fcf362c..28bd3fc 100644 --- a/src/classify/dimensions/category.ts +++ b/src/classify/dimensions/category.ts @@ -205,59 +205,66 @@ export class CategoryClassifier implements DimensionClassifier { */ private loadMappings(): Record { if (this.loaded) { - return this.mappings ?? RECIPE_NAME_CATEGORY_MAP; + return this.mappings ?? DEFAULT_RECIPE_NAME_CATEGORY_MAP; } this.loaded = true; - const resolvedPath = this.configPath ?? join(process.cwd(), DEFAULT_CONFIG_PATH); - let raw: string; - try { - raw = readFileSync(resolvedPath, "utf-8"); - } catch (err: unknown) { - if ((err as NodeJS.ErrnoException).code === "ENOENT") { - console.warn( - `[ToneForge] No ${resolvedPath} found; using built-in category mappings.`, - ); - return RECIPE_NAME_CATEGORY_MAP; - } - throw err; - } + // If an instance-specific configPath was provided, prefer that and + // perform instance-scoped loading and warnings (so tests can spy on + // console.warn per-instance). Otherwise delegate to the module loader. + if (this.configPath) { + try { + if (fs.existsSync(this.configPath)) { + const raw = fs.readFileSync(this.configPath, "utf8"); + const parsed = yaml.load(raw); + + if (Array.isArray(parsed) || !parsed || typeof parsed !== "object") { + throw new Error(`Invalid config: expected a top-level mapping`); + } - const parsed = loadYaml(raw) as unknown; + const root = parsed as Record; + const candidate = (root.prefixToCategory && typeof root.prefixToCategory === "object") + ? root.prefixToCategory + : root; - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - throw new Error( - `[ToneForge] Invalid config at ${resolvedPath}: expected a YAML object at the top level.`, - ); - } + if (Array.isArray(candidate) || typeof candidate !== "object") { + throw new Error(`Invalid config: expected mapping of prefix->category`); + } - const config = parsed as Record; - const prefixToCategory = config["prefixToCategory"]; - - if ( - !prefixToCategory || - typeof prefixToCategory !== "object" || - Array.isArray(prefixToCategory) - ) { - throw new Error( - `[ToneForge] Invalid config at ${resolvedPath}: missing or invalid 'prefixToCategory' key.`, - ); - } + const map: Record = {}; + for (const [k, v] of Object.entries(candidate as Record)) { + if (typeof v !== "string") { + throw new Error(`prefixToCategory['${k}'] must be a string, got ${typeof v}`); + } + map[String(k).toLowerCase()] = String(v).toLowerCase().replace(/\s+/g, "-"); + } - const mappings: Record = {}; - for (const [key, value] of Object.entries( - prefixToCategory as Record, - )) { - if (typeof value !== "string") { - throw new Error( - `[ToneForge] Invalid config at ${resolvedPath}: prefixToCategory['${key}'] must be a string, got ${typeof value}.`, - ); + const normalizedDefaults: Record = {}; + for (const [k, v] of Object.entries(DEFAULT_RECIPE_NAME_CATEGORY_MAP)) { + normalizedDefaults[String(k).toLowerCase()] = String(v).toLowerCase().replace(/\s+/g, "-"); + } + + this.mappings = { ...normalizedDefaults, ...map }; + return this.mappings; + } + + // Missing file: emit a per-instance warning and fall back to defaults + // eslint-disable-next-line no-console + console.warn(`[ToneForge] No ${this.configPath} found; using built-in category mappings.`); + const normalizedDefaults: Record = {}; + for (const [k, v] of Object.entries(DEFAULT_RECIPE_NAME_CATEGORY_MAP)) { + normalizedDefaults[String(k).toLowerCase()] = String(v).toLowerCase().replace(/\s+/g, "-"); + } + this.mappings = { ...normalizedDefaults }; + return this.mappings; + } catch (err) { + throw new Error(`Error loading ${this.configPath}: ${(err as Error).message}`); } - mappings[key] = value; } - this.mappings = mappings; - return mappings; + // No instance path — use module-level loader which caches globally + this.mappings = loadConfigMap(); + return this.mappings; } classify(analysis: AnalysisResult, context?: RecipeContext): DimensionResult { @@ -271,14 +278,8 @@ export class CategoryClassifier implements DimensionClassifier { // Secondary signal: recipe name parsing (use config if available) if (context?.name) { const firstSegment = context.name.split("-")[0]!.toLowerCase(); - const map = loadConfigMap(); - const mapped = map[firstSegment]; - // Secondary signal: recipe name parsing (use config if available) - if (context?.name) { - const firstSegment = context.name.split("-")[0]!.toLowerCase(); - const map = loadConfigMap(); + const map = this.loadMappings(); const mapped = map[firstSegment]; ->>>>>>> origin/main if (mapped) { return { category: mapped }; }