From fdd504c146e05e7cef58d77cbfd13c609d2e57cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Herna=CC=81n=20Pau=CC=81l=20Ossando=CC=81n?= Date: Sun, 17 May 2026 13:15:23 -0400 Subject: [PATCH 1/9] feat(@helm/shared): add ProductRegistry schema and parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProductRegistrySchema validates .helm/products.yaml — a list of knowledge repo paths (relative to a base dir, or absolute). loadProductRegistry() resolves each path, loads its .helm/product.yaml, and returns Product[]. parseProductRegistryYaml() validates raw YAML without I/O. Backward-compat: no changes to existing exports. The registry is an opt-in layer on top of the existing single-product flow. 12 tests covering: single/multi entry, dot-path resolution, missing registry file (ENOENT), missing product.yaml (ENOENT), malformed product.yaml, invalid registry YAML, empty products array. Co-Authored-By: Claude Sonnet 4.6 --- packages/shared/src/config/index.ts | 3 + .../config/product-registry-parser.test.ts | 164 ++++++++++++++++++ .../src/config/product-registry-parser.ts | 75 ++++++++ .../src/config/product-registry-schema.ts | 29 ++++ 4 files changed, 271 insertions(+) create mode 100644 packages/shared/src/config/product-registry-parser.test.ts create mode 100644 packages/shared/src/config/product-registry-parser.ts create mode 100644 packages/shared/src/config/product-registry-schema.ts diff --git a/packages/shared/src/config/index.ts b/packages/shared/src/config/index.ts index e8ec01c..17581d3 100644 --- a/packages/shared/src/config/index.ts +++ b/packages/shared/src/config/index.ts @@ -5,6 +5,9 @@ export { parseProductConfigFromFile, ProductConfigError, } from './product-parser.js'; +export { ProductRegistrySchema, ProductRegistryEntrySchema } from './product-registry-schema.js'; +export type { ProductRegistry, ProductRegistryEntry } from './product-registry-schema.js'; +export { parseProductRegistryYaml, loadProductRegistry } from './product-registry-parser.js'; // WorkflowStage and WORKFLOW_STAGES originate in @helm/workflow; re-exported // here so consumers of @helm/shared don't need to know the source package. export { WORKFLOW_STAGES, type WorkflowStage } from '@helm/workflow'; diff --git a/packages/shared/src/config/product-registry-parser.test.ts b/packages/shared/src/config/product-registry-parser.test.ts new file mode 100644 index 0000000..21c8096 --- /dev/null +++ b/packages/shared/src/config/product-registry-parser.test.ts @@ -0,0 +1,164 @@ +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { randomUUID } from 'node:crypto'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { parseProductRegistryYaml, loadProductRegistry } from './product-registry-parser.js'; +import { ProductConfigError } from './product-parser.js'; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const VALID_PRODUCT_YAML = ` +helm_version: "0" +product: + slug: test-product + name: Test Product +issue_tracker: + provider: github_projects + org: test-org + project_number: 1 +code_repos: + - url: https://github.com/test-org/test-repo + default_branch: main + role: app +knowledge_repo: + url: https://github.com/test-org/test-knowledge + default_branch: main +workflow: + stages_enabled: [discovery, spec-ready, released] +specialists: + spec_writer: { runtime: claude_code, model: claude-sonnet-4-6 } + plan_writer: { runtime: claude_code, model: claude-sonnet-4-6 } + implementer: { runtime: claude_code, model: claude-opus-4-7 } + code_reviewer: { runtime: claude_code, model: claude-sonnet-4-6 } + security_reviewer: { runtime: claude_code, model: claude-sonnet-4-6 } + test_reviewer: { runtime: claude_code, model: claude-sonnet-4-6 } + remediation: { runtime: claude_code, model: claude-sonnet-4-6 } +`.trim(); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +async function makeKnowledgeRepo( + baseDir: string, + slug: string, + productYaml = VALID_PRODUCT_YAML, +): Promise { + const repoDir = join(baseDir, `${slug}-knowledge`); + await mkdir(join(repoDir, '.helm'), { recursive: true }); + const yaml = productYaml.replace('test-product', slug).replace('Test Product', slug); + await writeFile(join(repoDir, '.helm', 'product.yaml'), yaml, 'utf-8'); + return repoDir; +} + +// ── parseProductRegistryYaml ───────────────────────────────────────────────── + +describe('parseProductRegistryYaml', () => { + it('parses a valid registry with one entry', () => { + const yaml = `products:\n - path: .`; + const entries = parseProductRegistryYaml(yaml); + expect(entries).toHaveLength(1); + expect(entries[0]!.path).toBe('.'); + }); + + it('parses a registry with multiple entries', () => { + const yaml = `products:\n - path: .\n - path: ../other-knowledge`; + const entries = parseProductRegistryYaml(yaml); + expect(entries).toHaveLength(2); + expect(entries[1]!.path).toBe('../other-knowledge'); + }); + + it('throws ProductConfigError on invalid YAML syntax', () => { + expect(() => parseProductRegistryYaml(': invalid: yaml: [')).toThrow(ProductConfigError); + }); + + it('throws ProductConfigError when products array is empty', () => { + expect(() => parseProductRegistryYaml('products: []')).toThrow(ProductConfigError); + }); + + it('throws ProductConfigError when products key is missing', () => { + expect(() => parseProductRegistryYaml('entries:\n - path: .')).toThrow(ProductConfigError); + }); + + it('throws ProductConfigError when a path is empty string', () => { + expect(() => parseProductRegistryYaml('products:\n - path: ""')).toThrow(ProductConfigError); + }); +}); + +// ── loadProductRegistry ─────────────────────────────────────────────────────── + +describe('loadProductRegistry', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = join(tmpdir(), `helm-test-${randomUUID()}`); + await mkdir(tmpDir, { recursive: true }); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + it('loads a single product from the registry', async () => { + await makeKnowledgeRepo(tmpDir, 'alpha'); + const registryYaml = `products:\n - path: alpha-knowledge`; + const registryPath = join(tmpDir, 'products.yaml'); + await writeFile(registryPath, registryYaml, 'utf-8'); + + const products = await loadProductRegistry(registryPath, tmpDir); + expect(products).toHaveLength(1); + expect(products[0]!.product.slug).toBe('alpha'); + }); + + it('loads multiple products from the registry', async () => { + await makeKnowledgeRepo(tmpDir, 'alpha'); + await makeKnowledgeRepo(tmpDir, 'beta'); + const registryYaml = `products:\n - path: alpha-knowledge\n - path: beta-knowledge`; + const registryPath = join(tmpDir, 'products.yaml'); + await writeFile(registryPath, registryYaml, 'utf-8'); + + const products = await loadProductRegistry(registryPath, tmpDir); + expect(products).toHaveLength(2); + const slugs = products.map((p) => p.product.slug).sort(); + expect(slugs).toEqual(['alpha', 'beta']); + }); + + it('resolves the "." path to the baseDir itself', async () => { + // Treat tmpDir as the primary knowledge repo (path = ".") + await mkdir(join(tmpDir, '.helm'), { recursive: true }); + await writeFile(join(tmpDir, '.helm', 'product.yaml'), VALID_PRODUCT_YAML, 'utf-8'); + const registryYaml = `products:\n - path: .`; + const registryPath = join(tmpDir, 'products.yaml'); + await writeFile(registryPath, registryYaml, 'utf-8'); + + const products = await loadProductRegistry(registryPath, tmpDir); + expect(products).toHaveLength(1); + expect(products[0]!.product.slug).toBe('test-product'); + }); + + it('throws ProductConfigError (ENOENT) when registry file is missing', async () => { + await expect( + loadProductRegistry(join(tmpDir, 'nonexistent.yaml'), tmpDir), + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('throws ProductConfigError when a referenced product.yaml is missing', async () => { + const registryYaml = `products:\n - path: ghost-knowledge`; + const registryPath = join(tmpDir, 'products.yaml'); + await writeFile(registryPath, registryYaml, 'utf-8'); + + await expect(loadProductRegistry(registryPath, tmpDir)).rejects.toMatchObject({ + code: 'ENOENT', + }); + }); + + it('throws ProductConfigError when a referenced product.yaml is malformed', async () => { + const repoDir = join(tmpDir, 'bad-knowledge'); + await mkdir(join(repoDir, '.helm'), { recursive: true }); + await writeFile(join(repoDir, '.helm', 'product.yaml'), 'not_valid: yaml: [', 'utf-8'); + const registryYaml = `products:\n - path: bad-knowledge`; + const registryPath = join(tmpDir, 'products.yaml'); + await writeFile(registryPath, registryYaml, 'utf-8'); + + await expect(loadProductRegistry(registryPath, tmpDir)).rejects.toThrow(ProductConfigError); + }); +}); diff --git a/packages/shared/src/config/product-registry-parser.ts b/packages/shared/src/config/product-registry-parser.ts new file mode 100644 index 0000000..502f98f --- /dev/null +++ b/packages/shared/src/config/product-registry-parser.ts @@ -0,0 +1,75 @@ +import { readFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { parse as parseYaml } from 'yaml'; +import { ProductRegistrySchema } from './product-registry-schema.js'; +import { parseProductConfigFromFile, ProductConfigError } from './product-parser.js'; +import type { Product } from './product-schema.js'; + +/** + * Parses a YAML string into a validated ProductRegistry structure. + * Does NOT load individual product configs — use loadProductRegistry for that. + * Throws ProductConfigError on invalid YAML or schema violations. + */ +export function parseProductRegistryYaml(yamlContent: string): Array<{ path: string }> { + let raw: unknown; + try { + raw = parseYaml(yamlContent); + } catch (err) { + throw new ProductConfigError('Failed to parse products.yaml', err as Error); + } + + const result = ProductRegistrySchema.safeParse(raw); + if (!result.success) { + const firstIssue = result.error.issues[0]; + const path = firstIssue?.path.join('.') ?? '(root)'; + const message = firstIssue?.message ?? 'Unknown validation error'; + throw new ProductConfigError(`Invalid products.yaml — "${path}": ${message}`, result.error); + } + + return result.data.products; +} + +/** + * Loads and resolves all products listed in a products.yaml registry file. + * + * @param registryFilePath - absolute path to the products.yaml file + * @param baseDir - directory used to resolve relative paths listed in the registry. + * Typically the parent directory of the primary knowledge repo + * (i.e., path.dirname(HELM_KNOWLEDGE_REPO_PATH)). + * + * Each entry's `path` is resolved via path.resolve(baseDir, entry.path). + * Absolute paths in entries are used as-is (path.resolve handles this). + * + * Throws ProductConfigError if the registry file is unreadable or invalid. + * Throws ProductConfigError for any individual product.yaml that fails to load. + */ +export async function loadProductRegistry( + registryFilePath: string, + baseDir: string, +): Promise { + let content: string; + try { + content = await readFile(registryFilePath, 'utf-8'); + } catch (err) { + const fsErr = err as NodeJS.ErrnoException; + throw new ProductConfigError( + `Cannot read products.yaml: ${registryFilePath}`, + fsErr, + fsErr.code, + ); + } + + const entries = parseProductRegistryYaml(content); + const products: Product[] = []; + + for (const entry of entries) { + const repoPath = resolve(baseDir, entry.path); + const productYamlPath = join(repoPath, '.helm', 'product.yaml'); + // Let individual product parse errors propagate — caller decides whether + // to skip malformed products or treat it as a fatal error. + const product = await parseProductConfigFromFile(productYamlPath); + products.push(product); + } + + return products; +} diff --git a/packages/shared/src/config/product-registry-schema.ts b/packages/shared/src/config/product-registry-schema.ts new file mode 100644 index 0000000..c84d729 --- /dev/null +++ b/packages/shared/src/config/product-registry-schema.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; + +/** + * Schema for .helm/products.yaml — the multi-product registry file. + * + * Each entry lists a `path` to a knowledge repository that contains its own + * .helm/product.yaml. Paths are resolved relative to the parent directory of + * HELM_KNOWLEDGE_REPO_PATH (the "sibling layout" assumption documented in + * ADR-004). Absolute paths are also accepted. + * + * Example: + * products: + * - path: . # this repo + * - path: ../helm-playground-knowledge # sibling repo + */ +export const ProductRegistryEntrySchema = z + .object({ + path: z.string().min(1, 'path must not be empty'), + }) + .strict(); + +export const ProductRegistrySchema = z + .object({ + products: z.array(ProductRegistryEntrySchema).min(1, 'registry must list at least one product'), + }) + .strict(); + +export type ProductRegistryEntry = z.infer; +export type ProductRegistry = z.infer; From 833b6430b4a6dfcdfade9773b41729e386a2ed28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Herna=CC=81n=20Pau=CC=81l=20Ossando=CC=81n?= Date: Sun, 17 May 2026 13:16:44 -0400 Subject: [PATCH 2/9] feat(@helm/api): add getProductRegistry singleton and /api/products routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getProductRegistry() reads $HELM_KNOWLEDGE_REPO_PATH/.helm/products.yaml and resolves each listed path (relative to dirname(HELM_KNOWLEDGE_REPO_PATH)) to a Product. ENOENT on products.yaml → falls back to [getProductConfig()] for single-product backward compat. Single-flight + cache. New endpoints: GET /api/products → all registered Products GET /api/products/:slug → one Product by slug (404 if unknown) GET /api/products/:slug/items → items filtered by productSlug (404 if unknown product) Existing /api/product and /api/items are unchanged — 15 existing tests pass without modification. _resetForTests() extended with registry state. 11 new route tests covering happy paths, cross-product isolation, empty results, 404, and 500 paths. Co-Authored-By: Claude Sonnet 4.6 --- apps/api/src/app.ts | 2 + apps/api/src/routes/products.test.ts | 161 +++++++++++++++++++++++++++ apps/api/src/routes/products.ts | 55 +++++++++ apps/api/src/services/index.ts | 56 +++++++++- 4 files changed, 272 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/routes/products.test.ts create mode 100644 apps/api/src/routes/products.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 93f4e67..01b3b8f 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -2,6 +2,7 @@ import { Hono } from 'hono'; import { HELM_VERSION } from '@helm/shared'; import { itemsRouter } from './routes/items.js'; import { productRouter } from './routes/product.js'; +import { productsRouter } from './routes/products.js'; import { webhooksRouter } from './routes/webhooks.js'; export const app = new Hono(); @@ -16,5 +17,6 @@ app.get('/health', (c) => ); app.route('/api', productRouter); +app.route('/api', productsRouter); app.route('/api', itemsRouter); app.route('/api', webhooksRouter); diff --git a/apps/api/src/routes/products.test.ts b/apps/api/src/routes/products.test.ts new file mode 100644 index 0000000..474c5f8 --- /dev/null +++ b/apps/api/src/routes/products.test.ts @@ -0,0 +1,161 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { app } from '../app.js'; +import { _resetForTests } from '../services/index.js'; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const HELM_PRODUCT = { + helm_version: '0', + product: { slug: 'helm', name: 'Helm' }, + workflow: { stages_enabled: ['discovery', 'released'], designer_gate: 'skip', qa_gate: 'skip' }, +}; + +const PLAYGROUND_PRODUCT = { + helm_version: '0', + product: { slug: 'helm-playground', name: 'Helm Playground' }, + workflow: { stages_enabled: ['discovery', 'released'], designer_gate: 'skip', qa_gate: 'skip' }, +}; + +const HELM_ITEMS = [ + { + externalId: 'issue_1', + productSlug: 'helm', + currentStage: 'discovery', + history: [], + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }, + { + externalId: 'issue_2', + productSlug: 'helm', + currentStage: 'released', + history: [], + createdAt: '2026-01-02T00:00:00Z', + updatedAt: '2026-01-02T00:00:00Z', + }, +]; + +const PLAYGROUND_ITEMS = [ + { + externalId: 'issue_10', + productSlug: 'helm-playground', + currentStage: 'discovery', + history: [], + createdAt: '2026-01-03T00:00:00Z', + updatedAt: '2026-01-03T00:00:00Z', + }, +]; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +const { mockGetProductRegistry, mockList } = vi.hoisted(() => ({ + mockGetProductRegistry: vi.fn(), + mockList: vi.fn(), +})); + +vi.mock('../services/index.js', async (importOriginal) => { + const real = await importOriginal(); + return { + ...real, + getProductRegistry: mockGetProductRegistry, + getItemStore: vi.fn().mockResolvedValue({ list: mockList }), + }; +}); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('GET /api/products', () => { + beforeEach(() => { + _resetForTests(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('GET /api/products', () => { + it('returns all registered products', async () => { + mockGetProductRegistry.mockResolvedValue([HELM_PRODUCT, PLAYGROUND_PRODUCT]); + const res = await app.request('/api/products'); + expect(res.status).toBe(200); + const body = (await res.json()) as (typeof HELM_PRODUCT)[]; + expect(body).toHaveLength(2); + expect(body.map((p) => p.product.slug).sort()).toEqual(['helm', 'helm-playground']); + }); + + it('returns 500 when registry loading fails', async () => { + mockGetProductRegistry.mockRejectedValue(new Error('disk error')); + const res = await app.request('/api/products'); + expect(res.status).toBe(500); + }); + }); + + describe('GET /api/products/:slug', () => { + it('returns the matching product', async () => { + mockGetProductRegistry.mockResolvedValue([HELM_PRODUCT, PLAYGROUND_PRODUCT]); + const res = await app.request('/api/products/helm-playground'); + expect(res.status).toBe(200); + const body = (await res.json()) as typeof PLAYGROUND_PRODUCT; + expect(body.product.slug).toBe('helm-playground'); + }); + + it('returns 404 for unknown slug', async () => { + mockGetProductRegistry.mockResolvedValue([HELM_PRODUCT]); + const res = await app.request('/api/products/nonexistent'); + expect(res.status).toBe(404); + }); + + it('returns 500 when registry loading fails', async () => { + mockGetProductRegistry.mockRejectedValue(new Error('disk error')); + const res = await app.request('/api/products/helm'); + expect(res.status).toBe(500); + }); + }); + + describe('GET /api/products/:slug/items', () => { + it('returns only items belonging to the requested product', async () => { + mockGetProductRegistry.mockResolvedValue([HELM_PRODUCT, PLAYGROUND_PRODUCT]); + mockList.mockResolvedValue([...HELM_ITEMS, ...PLAYGROUND_ITEMS]); + + const res = await app.request('/api/products/helm/items'); + expect(res.status).toBe(200); + const body = (await res.json()) as typeof HELM_ITEMS; + expect(body).toHaveLength(2); + expect(body.every((i) => i.productSlug === 'helm')).toBe(true); + }); + + it('returns only playground items when requesting helm-playground', async () => { + mockGetProductRegistry.mockResolvedValue([HELM_PRODUCT, PLAYGROUND_PRODUCT]); + mockList.mockResolvedValue([...HELM_ITEMS, ...PLAYGROUND_ITEMS]); + + const res = await app.request('/api/products/helm-playground/items'); + expect(res.status).toBe(200); + const body = (await res.json()) as typeof PLAYGROUND_ITEMS; + expect(body).toHaveLength(1); + expect(body[0]!.externalId).toBe('issue_10'); + }); + + it('returns empty array when the product has no items', async () => { + mockGetProductRegistry.mockResolvedValue([HELM_PRODUCT, PLAYGROUND_PRODUCT]); + mockList.mockResolvedValue(HELM_ITEMS); + + const res = await app.request('/api/products/helm-playground/items'); + expect(res.status).toBe(200); + expect(await res.json()).toEqual([]); + }); + + it('returns 404 when the product slug does not exist', async () => { + mockGetProductRegistry.mockResolvedValue([HELM_PRODUCT]); + const res = await app.request('/api/products/ghost/items'); + expect(res.status).toBe(404); + }); + + it('returns 500 when item store fails', async () => { + mockGetProductRegistry.mockResolvedValue([HELM_PRODUCT]); + mockList.mockRejectedValue(new Error('disk error')); + const res = await app.request('/api/products/helm/items'); + expect(res.status).toBe(500); + }); + }); +}); diff --git a/apps/api/src/routes/products.ts b/apps/api/src/routes/products.ts new file mode 100644 index 0000000..32551e3 --- /dev/null +++ b/apps/api/src/routes/products.ts @@ -0,0 +1,55 @@ +import { Hono } from 'hono'; +import { getProductRegistry, getItemStore } from '../services/index.js'; + +export const productsRouter = new Hono(); + +// ── GET /api/products ───────────────────────────────────────────────────────── + +productsRouter.get('/products', async (c) => { + try { + const products = await getProductRegistry(); + return c.json(products); + } catch (err) { + console.error('[products] Failed to load product registry:', err); + return c.json({ error: 'Failed to load product registry' }, 500); + } +}); + +// ── GET /api/products/:slug ─────────────────────────────────────────────────── + +productsRouter.get('/products/:slug', async (c) => { + const slug = c.req.param('slug'); + try { + const products = await getProductRegistry(); + const product = products.find((p) => p.product.slug === slug); + if (!product) { + return c.json({ error: `Product not found: ${slug}` }, 404); + } + return c.json(product); + } catch (err) { + console.error(`[products] Failed to load product ${slug}:`, err); + return c.json({ error: 'Failed to load product registry' }, 500); + } +}); + +// ── GET /api/products/:slug/items ───────────────────────────────────────────── + +productsRouter.get('/products/:slug/items', async (c) => { + const slug = c.req.param('slug'); + try { + // Verify the product exists before querying items + const products = await getProductRegistry(); + const product = products.find((p) => p.product.slug === slug); + if (!product) { + return c.json({ error: `Product not found: ${slug}` }, 404); + } + + const store = await getItemStore(); + const allItems = await store.list(); + const items = allItems.filter((item) => item.productSlug === slug); + return c.json(items); + } catch (err) { + console.error(`[products] Failed to load items for ${slug}:`, err); + return c.json({ error: 'Failed to load items' }, 500); + } +}); diff --git a/apps/api/src/services/index.ts b/apps/api/src/services/index.ts index ea3fb73..90ef8aa 100644 --- a/apps/api/src/services/index.ts +++ b/apps/api/src/services/index.ts @@ -1,6 +1,6 @@ -import { join } from 'node:path'; +import { join, dirname } from 'node:path'; import { ensureDataDir } from '@helm/storage'; -import { parseProductConfigFromFile } from '@helm/shared'; +import { parseProductConfigFromFile, loadProductRegistry } from '@helm/shared'; import type { Product } from '@helm/shared'; import { GitHubProjectsAdapter } from '@helm/adapters'; import { ItemStore } from './item-store.js'; @@ -101,6 +101,56 @@ export async function getGitHubAdapter(): Promise { } } +// ── Product registry (multi-product) ───────────────────────────────────────── + +let _productRegistry: Product[] | null = null; +let _productRegistryPromise: Promise | null = null; + +/** + * Returns all registered Products, initializing from the registry on first call. + * Single-flight: concurrent callers await the same promise. + * + * Discovery order: + * 1. If $HELM_KNOWLEDGE_REPO_PATH/.helm/products.yaml exists → load all products listed there. + * 2. Otherwise (ENOENT) → fall back to [getProductConfig()] for single-product backward compat. + * + * Paths in products.yaml are resolved relative to dirname(HELM_KNOWLEDGE_REPO_PATH) + * (the "sibling layout" — see ADR-004). + */ +export async function getProductRegistry(): Promise { + if (_productRegistry !== null) return _productRegistry; + if (_productRegistryPromise !== null) return _productRegistryPromise; + + _productRegistryPromise = (async () => { + const knowledgePath = process.env.HELM_KNOWLEDGE_REPO_PATH?.trim(); + if (!knowledgePath) throw new Error('HELM_KNOWLEDGE_REPO_PATH environment variable not set'); + + const registryFilePath = join(knowledgePath, '.helm', 'products.yaml'); + const baseDir = dirname(knowledgePath); + + let products: Product[]; + try { + products = await loadProductRegistry(registryFilePath, baseDir); + } catch (err) { + // ENOENT → products.yaml doesn't exist → backward-compat single-product fallback + if (err && typeof err === 'object' && 'code' in err && err.code === 'ENOENT') { + products = [await getProductConfig()]; + } else { + throw err; + } + } + + _productRegistry = products; + return products; + })(); + + try { + return await _productRegistryPromise; + } finally { + _productRegistryPromise = null; + } +} + // ── Test utilities ──────────────────────────────────────────────────────────── /** @@ -114,4 +164,6 @@ export function _resetForTests(): void { _productInitPromise = null; _githubAdapter = null; _githubAdapterPromise = null; + _productRegistry = null; + _productRegistryPromise = null; } From 85d9b33eb1b7932244daa58b12c2c0d5e4ee4258 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Herna=CC=81n=20Pau=CC=81l=20Ossando=CC=81n?= Date: Sun, 17 May 2026 17:22:22 -0400 Subject: [PATCH 3/9] fix(@helm/api): validate slug params with Zod and return defensive copies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. products.ts: validate slug route param against /^[a-z0-9-]+$/ via SlugParamsSchema before lookup — returns 400 on invalid format. Adds 2 new tests (400 for :slug and :slug/items). 2. services/index.ts: getProductRegistry() now returns spread copies {...p} instead of direct cache references to prevent callers from mutating singleton state. Addresses CodeRabbit review comments on PR #11. Co-Authored-By: Claude Sonnet 4.6 --- apps/api/src/routes/products.test.ts | 10 +++++++++ apps/api/src/routes/products.ts | 33 ++++++++++++++++++++-------- apps/api/src/services/index.ts | 9 ++++---- 3 files changed, 39 insertions(+), 13 deletions(-) diff --git a/apps/api/src/routes/products.test.ts b/apps/api/src/routes/products.test.ts index 474c5f8..6c55bdf 100644 --- a/apps/api/src/routes/products.test.ts +++ b/apps/api/src/routes/products.test.ts @@ -92,6 +92,11 @@ describe('GET /api/products', () => { }); describe('GET /api/products/:slug', () => { + it('returns 400 for invalid slug format', async () => { + const res = await app.request('/api/products/INVALID_SLUG!'); + expect(res.status).toBe(400); + }); + it('returns the matching product', async () => { mockGetProductRegistry.mockResolvedValue([HELM_PRODUCT, PLAYGROUND_PRODUCT]); const res = await app.request('/api/products/helm-playground'); @@ -145,6 +150,11 @@ describe('GET /api/products', () => { expect(await res.json()).toEqual([]); }); + it('returns 400 for invalid slug format', async () => { + const res = await app.request('/api/products/BAD_SLUG!/items'); + expect(res.status).toBe(400); + }); + it('returns 404 when the product slug does not exist', async () => { mockGetProductRegistry.mockResolvedValue([HELM_PRODUCT]); const res = await app.request('/api/products/ghost/items'); diff --git a/apps/api/src/routes/products.ts b/apps/api/src/routes/products.ts index 32551e3..c122d64 100644 --- a/apps/api/src/routes/products.ts +++ b/apps/api/src/routes/products.ts @@ -1,8 +1,24 @@ import { Hono } from 'hono'; +import { z } from 'zod'; import { getProductRegistry, getItemStore } from '../services/index.js'; export const productsRouter = new Hono(); +// Slug format mirrors product.schema.ts: lowercase alphanumeric + hyphens. +const SlugParamsSchema = z + .object({ + slug: z + .string() + .min(1) + .regex(/^[a-z0-9-]+$/, 'Invalid product slug format'), + }) + .strict(); + +function parseSlug(raw: string): string | null { + const result = SlugParamsSchema.safeParse({ slug: raw }); + return result.success ? result.data.slug : null; +} + // ── GET /api/products ───────────────────────────────────────────────────────── productsRouter.get('/products', async (c) => { @@ -18,13 +34,13 @@ productsRouter.get('/products', async (c) => { // ── GET /api/products/:slug ─────────────────────────────────────────────────── productsRouter.get('/products/:slug', async (c) => { - const slug = c.req.param('slug'); + const slug = parseSlug(c.req.param('slug')); + if (!slug) return c.json({ error: 'Invalid product slug' }, 400); + try { const products = await getProductRegistry(); const product = products.find((p) => p.product.slug === slug); - if (!product) { - return c.json({ error: `Product not found: ${slug}` }, 404); - } + if (!product) return c.json({ error: `Product not found: ${slug}` }, 404); return c.json(product); } catch (err) { console.error(`[products] Failed to load product ${slug}:`, err); @@ -35,14 +51,13 @@ productsRouter.get('/products/:slug', async (c) => { // ── GET /api/products/:slug/items ───────────────────────────────────────────── productsRouter.get('/products/:slug/items', async (c) => { - const slug = c.req.param('slug'); + const slug = parseSlug(c.req.param('slug')); + if (!slug) return c.json({ error: 'Invalid product slug' }, 400); + try { - // Verify the product exists before querying items const products = await getProductRegistry(); const product = products.find((p) => p.product.slug === slug); - if (!product) { - return c.json({ error: `Product not found: ${slug}` }, 404); - } + if (!product) return c.json({ error: `Product not found: ${slug}` }, 404); const store = await getItemStore(); const allItems = await store.list(); diff --git a/apps/api/src/services/index.ts b/apps/api/src/services/index.ts index 90ef8aa..1d5e528 100644 --- a/apps/api/src/services/index.ts +++ b/apps/api/src/services/index.ts @@ -118,7 +118,7 @@ let _productRegistryPromise: Promise | null = null; * (the "sibling layout" — see ADR-004). */ export async function getProductRegistry(): Promise { - if (_productRegistry !== null) return _productRegistry; + if (_productRegistry !== null) return _productRegistry.map((p) => ({ ...p })); if (_productRegistryPromise !== null) return _productRegistryPromise; _productRegistryPromise = (async () => { @@ -140,12 +140,13 @@ export async function getProductRegistry(): Promise { } } - _productRegistry = products; - return products; + _productRegistry = products.map((p) => ({ ...p })); + return _productRegistry.map((p) => ({ ...p })); })(); try { - return await _productRegistryPromise; + const products = await _productRegistryPromise; + return products.map((p) => ({ ...p })); } finally { _productRegistryPromise = null; } From 46e69d87d10c79f63e56180f6e9cc8210001b30d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Herna=CC=81n=20Pau=CC=81l=20Ossando=CC=81n?= Date: Sun, 17 May 2026 17:30:00 -0400 Subject: [PATCH 4/9] fix(@helm/api): narrow ENOENT fallback to registry file itself in getProductRegistry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously any ENOENT from loadProductRegistry triggered the single-product fallback, silently hiding broken registry entries (e.g., a referenced product.yaml that is missing). Now the fallback only fires when the error path matches registryFilePath — any other ENOENT propagates so the operator can diagnose the broken entry. Addresses CodeRabbit review comment on PR #11. Co-Authored-By: Claude Sonnet 4.6 --- apps/api/src/services/index.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/api/src/services/index.ts b/apps/api/src/services/index.ts index 1d5e528..b2ff0c7 100644 --- a/apps/api/src/services/index.ts +++ b/apps/api/src/services/index.ts @@ -132,8 +132,18 @@ export async function getProductRegistry(): Promise { try { products = await loadProductRegistry(registryFilePath, baseDir); } catch (err) { - // ENOENT → products.yaml doesn't exist → backward-compat single-product fallback - if (err && typeof err === 'object' && 'code' in err && err.code === 'ENOENT') { + // Fall back to single-product mode ONLY when the registry file itself is absent. + // Any other ENOENT (e.g., a referenced product.yaml is missing) must propagate so + // the operator can diagnose the broken registry entry instead of silently losing it. + const isMissingRegistryFile = + err !== null && + typeof err === 'object' && + 'code' in err && + err.code === 'ENOENT' && + 'path' in err && + (err as { path: string }).path === registryFilePath; + + if (isMissingRegistryFile) { products = [await getProductConfig()]; } else { throw err; From 665c0f29b0325622462740694178841e6845cca2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Herna=CC=81n=20Pau=CC=81l=20Ossando=CC=81n?= Date: Sun, 17 May 2026 17:31:53 -0400 Subject: [PATCH 5/9] fix(@helm/api): deep-clone Product objects in getProductRegistry with structuredClone { ...p } only copies top-level fields; nested objects like workflow, issue_tracker, and code_repos remained shared references to the singleton cache. Replaced with structuredClone(p) at all four return sites so callers cannot mutate cached state through nested properties. Addresses CodeRabbit review comment on PR #11. Co-Authored-By: Claude Sonnet 4.6 --- apps/api/src/services/index.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/api/src/services/index.ts b/apps/api/src/services/index.ts index b2ff0c7..9a90624 100644 --- a/apps/api/src/services/index.ts +++ b/apps/api/src/services/index.ts @@ -118,7 +118,7 @@ let _productRegistryPromise: Promise | null = null; * (the "sibling layout" — see ADR-004). */ export async function getProductRegistry(): Promise { - if (_productRegistry !== null) return _productRegistry.map((p) => ({ ...p })); + if (_productRegistry !== null) return _productRegistry.map((p) => structuredClone(p)); if (_productRegistryPromise !== null) return _productRegistryPromise; _productRegistryPromise = (async () => { @@ -150,13 +150,13 @@ export async function getProductRegistry(): Promise { } } - _productRegistry = products.map((p) => ({ ...p })); - return _productRegistry.map((p) => ({ ...p })); + _productRegistry = products.map((p) => structuredClone(p)); + return _productRegistry.map((p) => structuredClone(p)); })(); try { const products = await _productRegistryPromise; - return products.map((p) => ({ ...p })); + return products.map((p) => structuredClone(p)); } finally { _productRegistryPromise = null; } From f55fd78502d9c931d43e466bdb9007f779efc350 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Herna=CC=81n=20Pau=CC=81l=20Ossando=CC=81n?= Date: Sun, 17 May 2026 20:04:19 -0400 Subject: [PATCH 6/9] fix(@helm/api): deep-clone per-caller when returning in-flight registry promise Concurrent callers that arrived during initialization all awaited the same _productRegistryPromise, receiving the identical resolved array reference. Wrapping the in-flight promise with .then(products => products.map(structuredClone)) ensures every caller gets its own independent copy, consistent with the cache-hit and post-init paths. Addresses CodeRabbit review comment on PR #11. Co-Authored-By: Claude Sonnet 4.6 --- apps/api/src/services/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/api/src/services/index.ts b/apps/api/src/services/index.ts index 9a90624..aa9cf8d 100644 --- a/apps/api/src/services/index.ts +++ b/apps/api/src/services/index.ts @@ -119,7 +119,9 @@ let _productRegistryPromise: Promise | null = null; */ export async function getProductRegistry(): Promise { if (_productRegistry !== null) return _productRegistry.map((p) => structuredClone(p)); - if (_productRegistryPromise !== null) return _productRegistryPromise; + if (_productRegistryPromise !== null) { + return _productRegistryPromise.then((products) => products.map((p) => structuredClone(p))); + } _productRegistryPromise = (async () => { const knowledgePath = process.env.HELM_KNOWLEDGE_REPO_PATH?.trim(); From 8cc9fc640994aa7431990bd4722d55ee73924c5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Herna=CC=81n=20Pau=CC=81l=20Ossando=CC=81n?= Date: Mon, 18 May 2026 06:28:16 -0400 Subject: [PATCH 7/9] fix(@helm/api): validate HELM_KNOWLEDGE_REPO_PATH against filesystem path whitelist HELM_KNOWLEDGE_REPO_PATH is external input (env var) used directly to construct filesystem paths. Added SAFE_FS_PATH_REGEX whitelist check before path construction to prevent path traversal attacks. Addresses CodeRabbit review comment on PR #11. Co-Authored-By: Claude Sonnet 4.6 --- apps/api/src/services/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/api/src/services/index.ts b/apps/api/src/services/index.ts index aa9cf8d..621e40c 100644 --- a/apps/api/src/services/index.ts +++ b/apps/api/src/services/index.ts @@ -126,6 +126,10 @@ export async function getProductRegistry(): Promise { _productRegistryPromise = (async () => { const knowledgePath = process.env.HELM_KNOWLEDGE_REPO_PATH?.trim(); if (!knowledgePath) throw new Error('HELM_KNOWLEDGE_REPO_PATH environment variable not set'); + const SAFE_FS_PATH_REGEX = /^[A-Za-z0-9._/\-]+$/; + if (!SAFE_FS_PATH_REGEX.test(knowledgePath)) { + throw new Error('HELM_KNOWLEDGE_REPO_PATH contains invalid characters'); + } const registryFilePath = join(knowledgePath, '.helm', 'products.yaml'); const baseDir = dirname(knowledgePath); From bba633d0a339b68acd51ae4d5964d9deb9f9f8e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Herna=CC=81n=20Pau=CC=81l=20Ossando=CC=81n?= Date: Mon, 18 May 2026 08:57:09 -0400 Subject: [PATCH 8/9] =?UTF-8?q?fix(@helm/api):=20fix=20ENOENT=20fallback?= =?UTF-8?q?=20in=20getProductRegistry=20=E2=80=94=20check=20cause.path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProductConfigError exposes .code='ENOENT' but .path lives on .cause (the original NodeJS.ErrnoException), not directly on the error object. The previous check ('path' in err) never matched, so the fallback to single-product mode never fired when products.yaml was absent — getProductConfig() wasn't called and ENOENT propagated to the route. Fix: use instanceof ProductConfigError + err.cause?.path === registryFilePath. Adds 3 regression tests using a real tmpdir: - absent products.yaml → falls back to [getProductConfig()] - present products.yaml → loads correctly - present but referencing missing product.yaml → error propagates Co-Authored-By: Claude Sonnet 4.6 --- apps/api/src/services/index.ts | 15 ++-- .../api/src/services/product-registry.test.ts | 87 +++++++++++++++++++ 2 files changed, 94 insertions(+), 8 deletions(-) create mode 100644 apps/api/src/services/product-registry.test.ts diff --git a/apps/api/src/services/index.ts b/apps/api/src/services/index.ts index 621e40c..bad79f5 100644 --- a/apps/api/src/services/index.ts +++ b/apps/api/src/services/index.ts @@ -1,6 +1,6 @@ import { join, dirname } from 'node:path'; import { ensureDataDir } from '@helm/storage'; -import { parseProductConfigFromFile, loadProductRegistry } from '@helm/shared'; +import { parseProductConfigFromFile, loadProductRegistry, ProductConfigError } from '@helm/shared'; import type { Product } from '@helm/shared'; import { GitHubProjectsAdapter } from '@helm/adapters'; import { ItemStore } from './item-store.js'; @@ -139,15 +139,14 @@ export async function getProductRegistry(): Promise { products = await loadProductRegistry(registryFilePath, baseDir); } catch (err) { // Fall back to single-product mode ONLY when the registry file itself is absent. - // Any other ENOENT (e.g., a referenced product.yaml is missing) must propagate so - // the operator can diagnose the broken registry entry instead of silently losing it. + // ProductConfigError wraps the fs error: .code is preserved directly, but .path + // lives on .cause (the original NodeJS.ErrnoException). Checking .cause.path + // ensures we don't swallow ENOENT errors from a referenced product.yaml being + // missing — those must propagate so the operator can diagnose the broken entry. const isMissingRegistryFile = - err !== null && - typeof err === 'object' && - 'code' in err && + err instanceof ProductConfigError && err.code === 'ENOENT' && - 'path' in err && - (err as { path: string }).path === registryFilePath; + (err.cause as NodeJS.ErrnoException | undefined)?.path === registryFilePath; if (isMissingRegistryFile) { products = [await getProductConfig()]; diff --git a/apps/api/src/services/product-registry.test.ts b/apps/api/src/services/product-registry.test.ts new file mode 100644 index 0000000..90d89b4 --- /dev/null +++ b/apps/api/src/services/product-registry.test.ts @@ -0,0 +1,87 @@ +/** + * Focused regression tests for getProductRegistry() fallback logic. + * Uses a real tmpdir so the ENOENT originates from the actual filesystem, + * matching the production code path exactly. + */ +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { basename, join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { randomUUID } from 'node:crypto'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { _resetForTests, getProductRegistry } from './index.js'; + +// Minimal valid product.yaml +const PRODUCT_YAML = ` +helm_version: "0" +product: + slug: test-helm + name: Test Helm +issue_tracker: + provider: github_projects + org: test-org + project_number: 1 +code_repos: + - url: https://github.com/test-org/test-repo + default_branch: main + role: app +knowledge_repo: + url: https://github.com/test-org/test-knowledge + default_branch: main +workflow: + stages_enabled: [discovery, released] +specialists: + spec_writer: { runtime: claude_code, model: claude-sonnet-4-6 } + plan_writer: { runtime: claude_code, model: claude-sonnet-4-6 } + implementer: { runtime: claude_code, model: claude-opus-4-7 } + code_reviewer: { runtime: claude_code, model: claude-sonnet-4-6 } + security_reviewer: { runtime: claude_code, model: claude-sonnet-4-6 } + test_reviewer: { runtime: claude_code, model: claude-sonnet-4-6 } + remediation: { runtime: claude_code, model: claude-sonnet-4-6 } +`.trim(); + +describe('getProductRegistry() ENOENT fallback', () => { + let tmpDir: string; + const origEnv = process.env.HELM_KNOWLEDGE_REPO_PATH; + + beforeEach(async () => { + tmpDir = join(tmpdir(), `helm-registry-test-${randomUUID()}`); + await mkdir(join(tmpDir, '.helm'), { recursive: true }); + await writeFile(join(tmpDir, '.helm', 'product.yaml'), PRODUCT_YAML, 'utf-8'); + process.env.HELM_KNOWLEDGE_REPO_PATH = tmpDir; + _resetForTests(); + }); + + afterEach(async () => { + _resetForTests(); + if (origEnv === undefined) { + delete process.env.HELM_KNOWLEDGE_REPO_PATH; + } else { + process.env.HELM_KNOWLEDGE_REPO_PATH = origEnv; + } + await rm(tmpDir, { recursive: true, force: true }); + }); + + it('falls back to [getProductConfig()] when products.yaml is absent', async () => { + // No products.yaml in tmpDir/.helm/ — only product.yaml exists + const products = await getProductRegistry(); + expect(products).toHaveLength(1); + expect(products[0]!.product.slug).toBe('test-helm'); + }); + + it('returns all products when products.yaml is present', async () => { + // baseDir = dirname(tmpDir), so the relative path to tmpDir itself is basename(tmpDir) + const productsYaml = `products:\n - path: ${basename(tmpDir)}`; + await writeFile(join(tmpDir, '.helm', 'products.yaml'), productsYaml, 'utf-8'); + + const products = await getProductRegistry(); + expect(products).toHaveLength(1); + expect(products[0]!.product.slug).toBe('test-helm'); + }); + + it('propagates error when a referenced product.yaml is missing (not swallowed as fallback)', async () => { + const productsYaml = `products:\n - path: ./nonexistent-repo`; + await writeFile(join(tmpDir, '.helm', 'products.yaml'), productsYaml, 'utf-8'); + + await expect(getProductRegistry()).rejects.toThrow(); + }); +}); From a0310bcb8483d56765038d435d1bfdb9b013b486 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Herna=CC=81n=20Pau=CC=81l=20Ossando=CC=81n?= Date: Mon, 18 May 2026 09:03:19 -0400 Subject: [PATCH 9/9] fix(@helm/api): baseDir for products.yaml path resolution is knowledgePath itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dirname(knowledgePath) was one level too high: path "." resolved to the parent of the knowledge repo instead of the repo itself, causing ENOENT on {parent}/.helm/product.yaml. Fix: baseDir = knowledgePath so that - path "." → the knowledge repo itself ✓ - path "../X" → a sibling of the knowledge repo ✓ Remove now-unused dirname import. Adds 2 regression tests verifying path semantics: - path "." resolves to knowledgePath - path "../sibling" resolves to a real sibling tmpdir Updates existing products.yaml test to use path "." (was using basename workaround). ENOENT fallback unaffected: registryFilePath = join(knowledgePath, '.helm/products.yaml') unchanged, so err.cause?.path === registryFilePath still holds. Co-Authored-By: Claude Sonnet 4.6 --- apps/api/src/services/index.ts | 9 +-- .../api/src/services/product-registry.test.ts | 65 +++++++++++++++++-- 2 files changed, 66 insertions(+), 8 deletions(-) diff --git a/apps/api/src/services/index.ts b/apps/api/src/services/index.ts index bad79f5..ea6f258 100644 --- a/apps/api/src/services/index.ts +++ b/apps/api/src/services/index.ts @@ -1,4 +1,4 @@ -import { join, dirname } from 'node:path'; +import { join } from 'node:path'; import { ensureDataDir } from '@helm/storage'; import { parseProductConfigFromFile, loadProductRegistry, ProductConfigError } from '@helm/shared'; import type { Product } from '@helm/shared'; @@ -114,8 +114,9 @@ let _productRegistryPromise: Promise | null = null; * 1. If $HELM_KNOWLEDGE_REPO_PATH/.helm/products.yaml exists → load all products listed there. * 2. Otherwise (ENOENT) → fall back to [getProductConfig()] for single-product backward compat. * - * Paths in products.yaml are resolved relative to dirname(HELM_KNOWLEDGE_REPO_PATH) - * (the "sibling layout" — see ADR-004). + * Paths in products.yaml are resolved relative to HELM_KNOWLEDGE_REPO_PATH itself + * (the knowledge repo root). Example: path "." → the repo itself; path + * "../helm-playground-knowledge" → a sibling repo. See ADR-004. */ export async function getProductRegistry(): Promise { if (_productRegistry !== null) return _productRegistry.map((p) => structuredClone(p)); @@ -132,7 +133,7 @@ export async function getProductRegistry(): Promise { } const registryFilePath = join(knowledgePath, '.helm', 'products.yaml'); - const baseDir = dirname(knowledgePath); + const baseDir = knowledgePath; // paths in products.yaml are relative to the knowledge repo itself let products: Product[]; try { diff --git a/apps/api/src/services/product-registry.test.ts b/apps/api/src/services/product-registry.test.ts index 90d89b4..11ded8f 100644 --- a/apps/api/src/services/product-registry.test.ts +++ b/apps/api/src/services/product-registry.test.ts @@ -4,7 +4,7 @@ * matching the production code path exactly. */ import { mkdir, rm, writeFile } from 'node:fs/promises'; -import { basename, join } from 'node:path'; +import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { randomUUID } from 'node:crypto'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -68,9 +68,9 @@ describe('getProductRegistry() ENOENT fallback', () => { expect(products[0]!.product.slug).toBe('test-helm'); }); - it('returns all products when products.yaml is present', async () => { - // baseDir = dirname(tmpDir), so the relative path to tmpDir itself is basename(tmpDir) - const productsYaml = `products:\n - path: ${basename(tmpDir)}`; + it('returns all products when products.yaml is present with path "."', async () => { + // path: "." is relative to HELM_KNOWLEDGE_REPO_PATH itself (the knowledge repo root) + const productsYaml = `products:\n - path: .`; await writeFile(join(tmpDir, '.helm', 'products.yaml'), productsYaml, 'utf-8'); const products = await getProductRegistry(); @@ -85,3 +85,60 @@ describe('getProductRegistry() ENOENT fallback', () => { await expect(getProductRegistry()).rejects.toThrow(); }); }); + +describe('getProductRegistry() path resolution semantics', () => { + let tmpDir: string; + const origEnv = process.env.HELM_KNOWLEDGE_REPO_PATH; + + beforeEach(async () => { + tmpDir = join(tmpdir(), `helm-registry-path-test-${randomUUID()}`); + // Primary knowledge repo: tmpDir itself + await mkdir(join(tmpDir, '.helm'), { recursive: true }); + await writeFile(join(tmpDir, '.helm', 'product.yaml'), PRODUCT_YAML, 'utf-8'); + process.env.HELM_KNOWLEDGE_REPO_PATH = tmpDir; + _resetForTests(); + }); + + afterEach(async () => { + _resetForTests(); + if (origEnv === undefined) { + delete process.env.HELM_KNOWLEDGE_REPO_PATH; + } else { + process.env.HELM_KNOWLEDGE_REPO_PATH = origEnv; + } + await rm(tmpDir, { recursive: true, force: true }); + }); + + it('path "." resolves to the knowledge repo itself (not its parent)', async () => { + const productsYaml = `products:\n - path: .`; + await writeFile(join(tmpDir, '.helm', 'products.yaml'), productsYaml, 'utf-8'); + + const products = await getProductRegistry(); + expect(products).toHaveLength(1); + expect(products[0]!.product.slug).toBe('test-helm'); + }); + + it('path "../sibling" resolves to a sibling directory of the knowledge repo', async () => { + // Create a sibling repo next to tmpDir + const siblingDir = join(tmpdir(), `helm-sibling-${randomUUID()}`); + try { + await mkdir(join(siblingDir, '.helm'), { recursive: true }); + const siblingYaml = PRODUCT_YAML.replace('test-helm', 'sibling-product').replace( + 'Test Helm', + 'Sibling Product', + ); + await writeFile(join(siblingDir, '.helm', 'product.yaml'), siblingYaml, 'utf-8'); + + const siblingName = `../${siblingDir.split('/').at(-1)!}`; + const productsYaml = `products:\n - path: .\n - path: ${siblingName}`; + await writeFile(join(tmpDir, '.helm', 'products.yaml'), productsYaml, 'utf-8'); + + const products = await getProductRegistry(); + expect(products).toHaveLength(2); + const slugs = products.map((p) => p.product.slug).sort(); + expect(slugs).toEqual(['sibling-product', 'test-helm']); + } finally { + await rm(siblingDir, { recursive: true, force: true }); + } + }); +});