From 701ac90cfdb88366a48c4f2ff0300dda02bc9913 Mon Sep 17 00:00:00 2001 From: Bernhard Jackiewicz Date: Sun, 4 Oct 2026 01:20:28 +0200 Subject: [PATCH] fix(ui): count a YAML list of mappings as a list in the file facts (K12) The keys of a list's items stand at the same indent as the keys of a mapping, so the facts of django's .pre-commit-config.yaml read "repos (10 keys: rev, hooks, rev, hooks, ...)" for a list of 5 items, under "Read from the file; not generated by the model". A key that holds any item is now a list: its items are counted and their keys are named as item keys, "repos (list of 5; item keys: repo, rev, hooks)". Signed-off-by: Bernhard Jackiewicz --- graph-ui/src/browser-ai/file-facts.test.ts | 18 ++++++++++++++++++ graph-ui/src/browser-ai/file-facts.ts | 3 ++- graph-ui/src/browser-ai/strings.ts | 2 ++ graph-ui/src/browser-ai/workflow-facts.ts | 13 +++++++++---- 4 files changed, 31 insertions(+), 5 deletions(-) diff --git a/graph-ui/src/browser-ai/file-facts.test.ts b/graph-ui/src/browser-ai/file-facts.test.ts index 482c6dc6f..816f4f3be 100644 --- a/graph-ui/src/browser-ai/file-facts.test.ts +++ b/graph-ui/src/browser-ai/file-facts.test.ts @@ -27,6 +27,24 @@ describe('facts read from a configuration or text file (K12)', () => { ]); }); + it('counts a list of mappings as a list, and the keys of its items as item keys, not as keys of the parent', () => { + // django's .pre-commit-config.yaml: one key holding 5 items, each with repo, rev and hooks. + const preCommit = ['repos:', ...['black', 'blacken-docs', 'isort', 'flake8', 'eslint'].flatMap(hook => [ + ` - repo: https://github.com/example/${hook}`, ' rev: 1.0.0', ' hooks:', ` - id: ${hook}`])].join('\n') + '\n'; + expect(fileFactLines('.pre-commit-config.yaml', preCommit)).toEqual([ + 'File kind: YAML configuration, not program code; 21 lines.', + 'Top-level keys (1): `repos` (list of 5; item keys: `repo`, `rev`, `hooks`).', + ]); + // The items' first key on the dash line and the rest below it; mappings that differ in their keys. + expect(fileFactLines('steps.yml', 'steps:\n - name: a\n run: x\n - name: b\n uses: y\n with:\n key: 1\n')).toContain( + 'Top-level keys (1): `steps` (list of 2; item keys: `name`, `run`, `uses`, `with`).'); + // A list written at its key's own indent, next to a mapping. + expect(fileFactLines('x.yaml', 'repos:\n- repo: a\n rev: 1\n- repo: b\n rev: 2\nci:\n autofix: true\n')).toContain( + 'Top-level keys (2): `repos` (list of 2; item keys: `repo`, `rev`), `ci` (1 key: `autofix`).'); + // A list of plain values has no item keys. + expect(fileFactLines('tags.yml', 'tags:\n - a\n - b\n - c\n')).toContain('Top-level keys (1): `tags` (list of 3).'); + }); + it('reads a JSON file with JSON.parse: keys and what their values are', () => { expect(fileFactLines('package.json', '{"name": "demo", "private": true, "scripts": {"build": "tsc", "test": "vitest"}, "files": ["dist", "src"]}')).toEqual([ 'File kind: JSON data, not program code; 1 line.', diff --git a/graph-ui/src/browser-ai/file-facts.ts b/graph-ui/src/browser-ai/file-facts.ts index 0ebaa12d6..0eec1a177 100644 --- a/graph-ui/src/browser-ai/file-facts.ts +++ b/graph-ui/src/browser-ai/file-facts.ts @@ -21,7 +21,8 @@ function yamlFacts(text: string): string[] { const outline = yamlOutline(text); if (!outline.length) return []; return [words.topKeys(outline.length, outline.slice(0, LISTED).map(item => `${quote(item.key)}${item.keys.length - ? ` (${words.children(item.keys.length, item.keys.slice(0, CHILDREN).map(quote))})` : item.items ? ` (${words.list(item.items)})` : ''}`))]; + ? ` (${words.children(item.keys.length, item.keys.slice(0, CHILDREN).map(quote))})` + : item.items ? ` (${words.list(item.items)}${item.itemKeys.length ? `; ${words.itemKeys(item.itemKeys.length, item.itemKeys.slice(0, CHILDREN).map(quote))}` : ''})` : ''}`))]; } function jsonValue(value: unknown): string { diff --git a/graph-ui/src/browser-ai/strings.ts b/graph-ui/src/browser-ai/strings.ts index 35c43b4a0..bd5337208 100644 --- a/graph-ui/src/browser-ai/strings.ts +++ b/graph-ui/src/browser-ai/strings.ts @@ -254,6 +254,8 @@ export const fileWords = { children: (total: number, items: readonly string[]) => total ? `${counted(total, 'key', 'keys')}: ${listed(items, total)}` : 'no keys', keyCount: (total: number) => counted(total, 'key', 'keys'), list: (total: number) => `list of ${total.toLocaleString('en-US')}`, + /** The keys the items of a list have: "item keys: `repo`, `rev`, `hooks`". */ + itemKeys: (total: number, items: readonly string[]) => `item keys: ${listed(items, total)}`, value: { text: 'text', number: 'number', boolean: 'true or false', empty: 'null' }, items: (total: number) => `A list of ${counted(total, 'item', 'items')}.`, invalidJson: 'The text is not valid JSON, so no keys are counted.', diff --git a/graph-ui/src/browser-ai/workflow-facts.ts b/graph-ui/src/browser-ai/workflow-facts.ts index 67bd35005..fc76517a5 100644 --- a/graph-ui/src/browser-ai/workflow-facts.ts +++ b/graph-ui/src/browser-ai/workflow-facts.ts @@ -66,16 +66,21 @@ function children(lines: readonly Line[], at: number): number[] { } /** The top-level keys of a YAML file in block layout, each with the keys right below it or - * the length of the list it holds (K12). Keys inside block values are text, not keys. */ -export function yamlOutline(text: string): { key: string; keys: string[]; items: number }[] { + * the length of the list it holds (K12). Keys inside block values are text, not keys. + * + * The keys of a list's items stand at the same indent as the keys of a mapping ("- repo:" + * and "rev:" below it), so a key holding any item is a list: its items are counted, and + * their keys are named as item keys, never as keys of the parent. */ +export function yamlOutline(text: string): { key: string; keys: string[]; items: number; itemKeys: string[] }[] { const lines = linesOf(text); if (!lines.length) return []; const level = Math.min(...lines.map(line => line.keyIndent)); return lines.flatMap((line, index) => { if (line.keyIndent !== level || line.dash || !line.key) return []; const below = line.value ? [] : children(lines, index); - return [{ key: line.key, keys: below.flatMap(child => !lines[child].dash && lines[child].key ? [lines[child].key!] : []), - items: below.filter(child => lines[child].dash).length }]; + const named = below.flatMap(child => lines[child].key ? [lines[child].key!] : []); + const items = below.filter(child => lines[child].dash).length; + return [items ? { key: line.key, keys: [], items, itemKeys: [...new Set(named)] } : { key: line.key, keys: named, items: 0, itemKeys: [] }]; }); }