From 30d4d6ad005ce10fa2d64cc5eaeb11fb01725746 Mon Sep 17 00:00:00 2001 From: Bernhard Jackiewicz Date: Sun, 4 Oct 2026 14:42:49 +0200 Subject: [PATCH] fix(ui): outline INI files and say what well-known files are for "was macht diese datei?" on tox.ini, setup.cfg, .flake8, .editorconfig, pytest.ini or .coveragerc went to the model, which wrote "Testverwendungen" for tox.ini. These files now get an outline read from them like YAML, JSON and TOML: each section with its keys and short values, a value continued on indented lines as its list, bounded with "+N more". The automatic card counts sections with the same reader, and .flake8, .coveragerc and .pylintrc now count as INI files there too. The outline also says what a well-known file is for, in one line after its heading, when the name and the structure fit: pre-commit, GitHub workflows, package.json, pyproject.toml, tox.ini, setup.cfg, Docker Compose, Read the Docs, EditorConfig, tsconfig, flake8, pytest and coverage.py. Other files get no such line. The heading no longer says "Read from the file:"; the note at the end still does. Signed-off-by: Bernhard Jackiewicz --- .../BrowserChatDock.config-files.test.tsx | 23 ++++ .../src/browser-ai/BrowserChatDock.test.tsx | 4 +- graph-ui/src/browser-ai/file-facts.ts | 33 +++-- graph-ui/src/browser-ai/file-kind.ts | 2 + .../src/browser-ai/file-outline.ini.test.ts | 121 ++++++++++++++++++ graph-ui/src/browser-ai/file-outline.test.ts | 4 +- graph-ui/src/browser-ai/file-outline.ts | 53 ++++++-- graph-ui/src/browser-ai/strings.ts | 36 ++++++ 8 files changed, 253 insertions(+), 23 deletions(-) create mode 100644 graph-ui/src/browser-ai/BrowserChatDock.config-files.test.tsx create mode 100644 graph-ui/src/browser-ai/file-outline.ini.test.ts diff --git a/graph-ui/src/browser-ai/BrowserChatDock.config-files.test.tsx b/graph-ui/src/browser-ai/BrowserChatDock.config-files.test.tsx new file mode 100644 index 000000000..4241df034 --- /dev/null +++ b/graph-ui/src/browser-ai/BrowserChatDock.config-files.test.tsx @@ -0,0 +1,23 @@ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest'; +import { dockHarness, readerOf } from './chat-dock.fixture'; + +const dock = dockHarness(); +const TOX = '[tox]\nminversion = 4.0\nenvlist =\n py3\n flake8\n\n[testenv]\nchangedir = tests\ncommands =\n {envpython} runtests.py {posargs}\n'; + +/** "was macht diese datei?" on tox.ini went to the model, which wrote "Testverwendungen" (B6, B7). */ +describe('what an INI file does, answered from the file (B6, B7)', () => { + it('answers from tox.ini with its purpose and sections, without the model', async () => { + const { props, runtime } = dock.setup(); + await dock.render({ ...props, selectionScope: 'django-demo:explore', readerContext: readerOf(TOX, 'tox.ini') }); await dock.load(); + await dock.ask('was macht diese datei?'); + expect(runtime.chat).not.toHaveBeenCalled(); + const answer = dock.answerOf(dock.last()); + expect(answer).toContain('tox.ini: INI-Konfiguration, 10 Zeilen.'); + expect(answer).toContain('tox-Konfiguration: die Testumgebungen, die tox anlegt, und die Befehle, die es in jeder ausführt.'); + expect(answer).toContain('[tox]: minversion 4.0; envlist: py3, flake8'); + expect(answer).toContain('[testenv]: changedir tests; commands {envpython} runtests.py {posargs}'); + expect(answer).toContain('Aus der Datei gelesen, nicht vom Modell erzeugt.'); + expect(dock.buttonsOf(dock.last())).toEqual(['Modell fragen']); + }); +}); diff --git a/graph-ui/src/browser-ai/BrowserChatDock.test.tsx b/graph-ui/src/browser-ai/BrowserChatDock.test.tsx index 506686218..196973aff 100644 --- a/graph-ui/src/browser-ai/BrowserChatDock.test.tsx +++ b/graph-ui/src/browser-ai/BrowserChatDock.test.tsx @@ -1611,7 +1611,7 @@ describe('general questions, prompts without a question and configuration files await render({ ...props, selectionScope: 'django-demo:explore', readerContext: reader(text, 'file', '.pre-commit-config.yaml') }); await click('Download & load'); for (const question of ['was macht diese datei?', 'erklär mir die datei detailliert']) { await type(question); await click('Send ↑'); - expect(answer()).toContain('.pre-commit-config.yaml: YAML-Konfiguration, 9 Zeilen. Aus der Datei gelesen:'); + expect(answer()).toContain('.pre-commit-config.yaml: YAML-Konfiguration, 9 Zeilen.\npre-commit-Konfiguration: Hooks, die vor jedem Commit laufen.'); expect(answer()).toContain('repos (Liste mit 2 Einträgen):'); expect(answer()).toContain('repo https://github.com/PyCQA/flake8; rev 7.1.1; hooks (1): flake8'); expect(answer()).toContain('Aus der Datei gelesen, nicht vom Modell erzeugt.'); @@ -1619,7 +1619,7 @@ describe('general questions, prompts without a question and configuration files } expect(runtime.chat).not.toHaveBeenCalled(); await type('What does this file do?'); await click('Send ↑'); - expect(answer()).toContain('.pre-commit-config.yaml: YAML configuration, 9 lines. Read from the file:'); + expect(answer()).toContain('.pre-commit-config.yaml: YAML configuration, 9 lines.\npre-commit configuration: hooks that run before each commit.'); expect(runtime.chat).not.toHaveBeenCalled(); await act(async () => button('Ask the model').click()); expect(runtime.chat).toHaveBeenCalledOnce(); diff --git a/graph-ui/src/browser-ai/file-facts.ts b/graph-ui/src/browser-ai/file-facts.ts index 0eec1a177..d2876df6d 100644 --- a/graph-ui/src/browser-ai/file-facts.ts +++ b/graph-ui/src/browser-ai/file-facts.ts @@ -61,21 +61,32 @@ function tomlFacts(text: string): string[] { ...tables.length ? [words.tables(tables.length, tables.slice(0, LISTED).map(table => `${quote(table.name)} (${words.keyCount(table.keys)})`))] : []]; } -/** INI and its relatives: sections with their number of keys, or the keys of a file without sections. */ -function iniFacts(text: string): string[] { - const sections: { name: string; keys: number }[] = []; - const loose: string[] = []; +/** One key of an INI file with its value; an indented line below it continues the value. */ +export interface IniEntry { key: string; values: string[] } +/** INI and its relatives as they are written: the keys before the first section, then each + * section with its keys. The automatic card counts them, the outline lists them (B6). */ +export function iniSections(text: string): { loose: IniEntry[]; sections: { name: string; entries: IniEntry[] }[] } { + const sections: { name: string; entries: IniEntry[] }[] = []; + const loose: IniEntry[] = []; + let last: IniEntry | undefined; for (const raw of rows(text)) { const line = raw.trim(); if (!line || line.startsWith('#') || line.startsWith(';')) continue; const section = /^\[([^\]]+)\]$/.exec(line); - if (section) { sections.push({ name: `[${section[1].trim()}]`, keys: 0 }); continue; } - const key = /^(?:export\s+)?([^=:\s][^=:]*?)\s*[=:]/.exec(line); - if (!key || /^\s/.test(raw)) continue; - if (sections.length) sections[sections.length - 1].keys += 1; else loose.push(key[1]); + if (section) { sections.push({ name: `[${section[1].trim()}]`, entries: [] }); last = undefined; continue; } + if (/^\s/.test(raw)) { last?.values.push(line); continue; } + const key = /^(?:export\s+)?([^=:\s][^=:]*?)\s*[=:]\s*(.*)$/.exec(line); + last = key ? { key: key[1], values: key[2] ? [key[2]] : [] } : undefined; + if (last) (sections.length ? sections[sections.length - 1].entries : loose).push(last); } - if (sections.length) return [words.sections(sections.length, sections.slice(0, LISTED).map(section => `${quote(section.name)} (${words.keyCount(section.keys)})`))]; - return loose.length ? [words.keys(loose.length, loose.slice(0, LISTED).map(quote))] : []; + return { loose, sections }; +} + +/** INI and its relatives: sections with their number of keys, or the keys of a file without sections. */ +function iniFacts(text: string): string[] { + const { loose, sections } = iniSections(text); + if (sections.length) return [words.sections(sections.length, sections.slice(0, LISTED).map(section => `${quote(section.name)} (${words.keyCount(section.entries.length)})`))]; + return loose.length ? [words.keys(loose.length, loose.slice(0, LISTED).map(entry => quote(entry.key)))] : []; } /** Markdown: the title, the second-level sections and the code blocks; lines inside a code block are code. */ @@ -129,7 +140,7 @@ function structureFacts(path: string, text: string): string[] { case 'yml': case 'yaml': return yamlFacts(text); case 'json': return jsonFacts(text); case 'toml': return tomlFacts(text); - case 'ini': case 'cfg': case 'conf': case 'env': case 'properties': case 'editorconfig': return iniFacts(text); + case 'ini': case 'cfg': case 'conf': case 'env': case 'properties': case 'editorconfig': case 'flake8': case 'coveragerc': case 'pylintrc': return iniFacts(text); case 'md': return markdownFacts(text); case 'rst': return restructuredFacts(text); case 'gitignore': case 'dockerignore': case 'gitattributes': return patternFacts(text); diff --git a/graph-ui/src/browser-ai/file-kind.ts b/graph-ui/src/browser-ai/file-kind.ts index c02924312..4b6affd55 100644 --- a/graph-ui/src/browser-ai/file-kind.ts +++ b/graph-ui/src/browser-ai/file-kind.ts @@ -12,6 +12,8 @@ const KINDS: Readonly> = { conf: 'configuration file, not program code', env: 'environment settings, not program code', properties: 'Java properties, not program code', editorconfig: 'EditorConfig settings, not program code', gitignore: 'ignore patterns, not program code', dockerignore: 'ignore patterns, not program code', gitattributes: 'Git attribute patterns, not program code', + // INI files named after their tool, without an extension of their own (B6). + flake8: 'INI configuration, not program code', coveragerc: 'INI configuration, not program code', pylintrc: 'INI configuration, not program code', }; /** Configuration, data and text files: what they say is read from them, not guessed by a model (K12). */ diff --git a/graph-ui/src/browser-ai/file-outline.ini.test.ts b/graph-ui/src/browser-ai/file-outline.ini.test.ts new file mode 100644 index 000000000..040aaac45 --- /dev/null +++ b/graph-ui/src/browser-ai/file-outline.ini.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest'; +import { fileOutline } from './file-outline'; +import { fileFactLines, isDataFile } from './file-facts'; + +/** django's tox.ini, shortened to its first sections. */ +const TOX = `# Tox (https://tox.readthedocs.io/) is a tool for running tests in multiple +# virtualenvs. + +[tox] +minversion = 4.0 +skipsdist = true +envlist = + py3 + black + flake8 + +# Add environment to use the default python3 installation +[testenv:py3] +basepython = python3 + +[testenv] +usedevelop = true +setenv = + PYTHONDONTWRITEBYTECODE=1 +deps = + -e . + py{3,310,311,312,313,py3}: -rtests/requirements/py3.txt + postgres: -rtests/requirements/postgres.txt +changedir = tests +commands = + {envpython} runtests.py {posargs} + +[testenv:javascript] +usedevelop = false +deps = +changedir = {toxinidir} +`; +const PRE_COMMIT = 'repos:\n - repo: https://github.com/PyCQA/isort\n rev: 5.13.2\n hooks:\n - id: isort\n - repo: https://github.com/PyCQA/flake8\n rev: 7.1.1\n hooks:\n - id: flake8\n'; +const FLAKE8 = '[flake8]\nexclude = build,.git,.tox,./tests/.env\nextend-ignore = E203\nmax-line-length = 88\nper-file-ignores =\n django/core/cache/backends/filebased.py:W601\n tests/cache/tests.py:W601\n'; +const EDITORCONFIG = '# https://editorconfig.org/\n\nroot = true\n\n[*]\nindent_style = space\nindent_size = 4\n\n[*.py]\nmax_line_length = 88\n\n[Makefile]\nindent_style = tab\n'; + +/** "was macht diese datei?" on tox.ini went to the model, which wrote "Testverwendungen" (B6). */ +describe('the outline of an INI file, read from it (B6)', () => { + it('lists each section of tox.ini with its keys and short values', () => { + const outline = fileOutline('tox.ini', TOX, 'en')!; + expect(outline).toContain('- `[tox]`: minversion `4.0`; skipsdist `true`; envlist: `py3`, `black`, `flake8`'); + expect(outline).toContain('- `[testenv:py3]`: basepython `python3`'); + expect(outline).toContain('- `[testenv]`: usedevelop `true`; setenv `PYTHONDONTWRITEBYTECODE=1`; deps: `-e .`, `py{3,310,311,312,313,py3}: -rtests/requirements/py3.txt`, ' + + '`postgres: -rtests/requirements/postgres.txt`; changedir `tests`; commands `{envpython} runtests.py {posargs}`'); + expect(outline).toContain('- `[testenv:javascript]`: usedevelop `false`; deps empty; changedir `{toxinidir}`'); + expect(outline).not.toContain('virtualenvs'); + expect(outline).toContain('_Read from the file; not generated by the model._'); + expect(fileOutline('tox.ini', TOX, 'de')).toContain('- `[testenv:javascript]`: usedevelop `false`; deps leer; changedir `{toxinidir}`'); + }); + + it('reads .flake8, .editorconfig, setup.cfg, pytest.ini and .coveragerc the same way, keys before the first section included', () => { + expect(fileOutline('.flake8', FLAKE8, 'en')).toContain('- `[flake8]`: exclude `build,.git,.tox,./tests/.env`; extend-ignore `E203`; max-line-length `88`; ' + + 'per-file-ignores: `django/core/cache/backends/filebased.py:W601`, `tests/cache/tests.py:W601`'); + const editor = fileOutline('.editorconfig', EDITORCONFIG, 'en')!; + expect(editor).toContain('- `root`: `true`'); + expect(editor).toContain('- `[*]`: indent_style `space`; indent_size `4`'); + expect(editor).toContain('- `[Makefile]`: indent_style `tab`'); + expect(fileOutline('setup.cfg', '[metadata]\nname = demo\n\n[isort]\nprofile = black\n', 'en')).toContain('- `[isort]`: profile `black`'); + expect(fileOutline('pytest.ini', '[pytest]\naddopts = -ra\n', 'en')).toContain('- `[pytest]`: addopts `-ra`'); + expect(fileOutline('.coveragerc', '[run]\nbranch = True\nsource = django\n', 'en')).toContain('- `[run]`: branch `True`; source `django`'); + }); + + it('bounds a section with many keys and a file with many sections with "+N more"', () => { + const wide = `[many]\n${Array.from({ length: 11 }, (_, index) => `key${index} = ${index}`).join('\n')}\n`; + expect(fileOutline('wide.ini', wide, 'en')).toContain('key7 `7`; +3 more'); + const sections = Array.from({ length: 15 }, (_, index) => `[section${index}]\nkey = ${index}\n`).join('\n'); + expect(fileOutline('many.cfg', sections, 'de')).toContain('+3 weitere'); + }); + + it('counts the sections of .flake8 and .coveragerc for the automatic card like those of any INI file', () => { + expect(isDataFile('.flake8')).toBe(true); + expect(isDataFile('.coveragerc')).toBe(true); + expect(fileFactLines('.flake8', FLAKE8)).toContain('Sections (1): `[flake8]` (4 keys).'); + expect(fileFactLines('tox.ini', TOX)).toContain('Sections (4): `[tox]` (3 keys), `[testenv:py3]` (1 key), `[testenv]` (5 keys), `[testenv:javascript]` (3 keys).'); + }); +}); + +/** The outline named what the file holds but not what it is for, and said "Read from the file" twice (B7). */ +describe('the purpose of a well-known file before its outline (B7)', () => { + it('says what a pre-commit file is for, and "Read from the file" only in the note', () => { + const english = fileOutline('.pre-commit-config.yaml', PRE_COMMIT, 'en')!; + expect(english.split('\n\n').slice(0, 2)).toEqual(['`.pre-commit-config.yaml`: YAML configuration, 9 lines.', 'pre-commit configuration: hooks that run before each commit.']); + expect(english.match(/Read from the file/g)).toHaveLength(1); + const german = fileOutline('.pre-commit-config.yaml', PRE_COMMIT, 'de')!; + expect(german.split('\n\n').slice(0, 2)).toEqual(['`.pre-commit-config.yaml`: YAML-Konfiguration, 9 Zeilen.', 'pre-commit-Konfiguration: Hooks, die vor jedem Commit laufen.']); + expect(german.match(/Aus der Datei gelesen/g)).toHaveLength(1); + }); + + it.each([ + ['.github/workflows/new_contributor_pr.yml', 'name: New contributor message\non:\n pull_request_target:\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/first-interaction@v1\n', + 'GitHub runs the jobs of this workflow when one of its triggers occurs.', 'GitHub führt die Jobs dieses Workflows aus, wenn einer seiner Auslöser eintritt.'], + ['package.json', '{"name": "Django", "scripts": {"test": "grunt test"}}', 'npm package manifest: the name, scripts and dependencies of a JavaScript package.', + 'npm-Paketmanifest: Name, Skripte und Abhängigkeiten eines JavaScript-Pakets.'], + ['pyproject.toml', '[build-system]\nrequires = ["setuptools"]\n\n[project]\nname = "Django"\n', 'Python project configuration: how the package is built, its metadata and the settings of tools.', + 'Python-Projektkonfiguration: wie das Paket gebaut wird, seine Metadaten und die Einstellungen von Werkzeugen.'], + ['tox.ini', TOX, 'tox configuration: the test environments tox creates and the commands it runs in each.', 'tox-Konfiguration: die Testumgebungen, die tox anlegt, und die Befehle, die es in jeder ausführt.'], + ['setup.cfg', '[metadata]\nname = demo\n', 'setuptools configuration: package metadata and options, often also settings of other tools.', + 'setuptools-Konfiguration: Paketmetadaten und Optionen, oft auch Einstellungen anderer Werkzeuge.'], + ['docker-compose.yml', 'services:\n web:\n image: nginx\n', 'Docker Compose file: the services that Docker Compose starts together.', 'Docker-Compose-Datei: die Services, die Docker Compose zusammen startet.'], + ['.readthedocs.yml', 'version: 2\nbuild:\n os: ubuntu-24.04\n', 'Read the Docs configuration: how readthedocs.org builds the documentation.', 'Read-the-Docs-Konfiguration: wie readthedocs.org die Dokumentation baut.'], + ['.editorconfig', EDITORCONFIG, 'EditorConfig: indentation, line endings and similar editor settings per file pattern.', 'EditorConfig: Einrückung, Zeilenenden und ähnliche Editor-Einstellungen je Dateimuster.'], + ['graph-ui/tsconfig.json', '{"compilerOptions": {"strict": true}, "include": ["src"]}', 'TypeScript compiler configuration: which files are compiled and with which options.', + 'TypeScript-Compilerkonfiguration: welche Dateien mit welchen Optionen kompiliert werden.'], + ['.flake8', FLAKE8, 'flake8 configuration: the rules of the Python linter flake8.', 'flake8-Konfiguration: die Regeln des Python-Linters flake8.'], + ])('gives %s its purpose in both languages', (path, text, english, german) => { + expect(fileOutline(path, text, 'en')!.split('\n\n')[1]).toBe(english); + expect(fileOutline(path, text, 'de')!.split('\n\n')[1]).toBe(german); + }); + + it('gives an unknown file, or a known name with another structure, no purpose line', () => { + const unknown = fileOutline('config/settings.yaml', 'debug: true\n', 'en')!; + expect(unknown.split('\n\n')).toEqual(['`settings.yaml`: YAML configuration, 1 line.', '- `debug`: `true`', '_Read from the file; not generated by the model._']); + expect(fileOutline('docker-compose.yml', 'version: "3"\n', 'en')!.split('\n\n')[1]).toBe('- `version`: `3`'); + expect(fileOutline('.pre-commit-config.yaml', 'default_language_version:\n python: python3\n', 'en')).not.toContain('hooks that run before each commit'); + }); +}); diff --git a/graph-ui/src/browser-ai/file-outline.test.ts b/graph-ui/src/browser-ai/file-outline.test.ts index 8bd592f24..781c63676 100644 --- a/graph-ui/src/browser-ai/file-outline.test.ts +++ b/graph-ui/src/browser-ai/file-outline.test.ts @@ -34,7 +34,7 @@ const WORKFLOW = 'name: New contributor message\n\non:\n pull_request_target:\n describe('the outline of a configuration file, read from it (C7)', () => { it('lists each repo of a pre-commit file with its revision and hooks', () => { const outline = fileOutline('.pre-commit-config.yaml', PRE_COMMIT, 'en')!; - expect(outline).toContain('`.pre-commit-config.yaml`: YAML configuration, 26 lines. Read from the file:'); + expect(outline).toContain('`.pre-commit-config.yaml`: YAML configuration, 26 lines.\n\npre-commit configuration: hooks that run before each commit.'); expect(outline).toContain('- `repos` (list of 5):'); expect(outline).toContain(' 1. repo `https://github.com/psf/black-pre-commit-mirror`; rev `25.1.0`; hooks (1): `black` (exclude `\\.py-tpl$`)'); expect(outline).toContain(' 2. repo `https://github.com/adamchainz/blacken-docs`; rev `1.19.1`; hooks (1): `blacken-docs` ' @@ -45,7 +45,7 @@ describe('the outline of a configuration file, read from it (C7)', () => { it('writes the outline in German for a German question', () => { const outline = fileOutline('.pre-commit-config.yaml', PRE_COMMIT, 'de')!; - expect(outline).toContain('`.pre-commit-config.yaml`: YAML-Konfiguration, 26 Zeilen. Aus der Datei gelesen:'); + expect(outline).toContain('`.pre-commit-config.yaml`: YAML-Konfiguration, 26 Zeilen.\n\npre-commit-Konfiguration: Hooks, die vor jedem Commit laufen.'); expect(outline).toContain('- `repos` (Liste mit 5 Einträgen):'); expect(outline).toContain(' 4. repo `https://github.com/PyCQA/flake8`; rev `7.1.1`; hooks (1): `flake8`'); expect(outline).toContain('_Aus der Datei gelesen, nicht vom Modell erzeugt._'); diff --git a/graph-ui/src/browser-ai/file-outline.ts b/graph-ui/src/browser-ai/file-outline.ts index ec8e9d0a9..5ed60d04d 100644 --- a/graph-ui/src/browser-ai/file-outline.ts +++ b/graph-ui/src/browser-ai/file-outline.ts @@ -1,5 +1,6 @@ import { isGithubWorkflow } from './file-kind'; -import { fileOutlineWords, germanWorkflowWords, workflowWords } from './strings'; +import { iniSections, type IniEntry } from './file-facts'; +import { chatRound3Text, fileOutlineWords, germanWorkflowWords, workflowWords } from './strings'; import { workflowFactLines, yamlTree, type YamlValue } from './workflow-facts'; /** "What does this file do?" about a configuration file, answered from the file (C7). The @@ -61,8 +62,22 @@ function tomlTree(text: string): YamlValue | undefined { return root.length ? { kind: 'map', entries: root } : undefined; } +/** INI: the keys before the first section, then each `[section]` with its keys. A value + * continued on indented lines is a list of those lines (`deps =` with one per line, B6). */ +function iniTree(text: string): YamlValue | undefined { + const { loose, sections } = iniSections(text); + const value = (entry: IniEntry): YamlValue => entry.values.length > 1 ? { kind: 'list', items: entry.values.map(item => ({ kind: 'scalar', value: item })) } + : { kind: 'scalar', value: entry.values[0] ?? '' }; + const entries = (list: readonly IniEntry[]) => list.map((entry): [string, YamlValue] => [entry.key, value(entry)]); + const root = [...entries(loose), ...sections.map((section): [string, YamlValue] => [section.name, { kind: 'map', entries: entries(section.entries) }])]; + return root.length ? { kind: 'map', entries: root } : undefined; +} + +const INI = new Set(['ini', 'cfg', 'flake8', 'coveragerc', 'pylintrc', 'editorconfig']); function treeOf(path: string, text: string): YamlValue | undefined { - switch (extension(path)) { + const kind = extension(path); + if (INI.has(kind)) return iniTree(text); + switch (kind) { case 'yml': case 'yaml': return yamlTree(text); case 'json': try { return jsonValue(JSON.parse(text)); } catch { return undefined; } case 'toml': return tomlTree(text); @@ -70,6 +85,26 @@ function treeOf(path: string, text: string): YamlValue | undefined { } } +type Purpose = keyof typeof chatRound3Text.en.purposes; +const has = (tree: YamlValue | undefined, test: (key: string) => boolean) => tree?.kind === 'map' && tree.entries.some(([key]) => test(key)); +/** What a well-known file is for, by its name, when its structure is that of such a file (B7). */ +function purposeOf(path: string, tree: YamlValue | undefined): Purpose | undefined { + const name = (path.split('/').pop() ?? path).toLowerCase(); + if (/^\.pre-commit-config\.ya?ml$/.test(name)) return has(tree, key => key === 'repos') ? 'preCommit' : undefined; + if (name === 'package.json') return tree?.kind === 'map' ? 'npmPackage' : undefined; + if (name === 'pyproject.toml') return has(tree, key => /^\[(?:project|build-system|tool\..+)\]$/.test(key)) ? 'pyproject' : undefined; + if (name === 'tox.ini') return has(tree, key => /^\[(?:tox|testenv.*)\]$/.test(key)) ? 'tox' : undefined; + if (name === 'setup.cfg') return tree?.kind === 'map' ? 'setupCfg' : undefined; + if (/^(?:docker-)?compose(?:\.[\w-]+)?\.ya?ml$/.test(name)) return has(tree, key => key === 'services') ? 'compose' : undefined; + if (/^\.readthedocs\.ya?ml$/.test(name)) return has(tree, key => key === 'version' || key === 'build' || key === 'sphinx') ? 'readTheDocs' : undefined; + if (name === '.editorconfig') return has(tree, key => key.startsWith('[') || key === 'root') ? 'editorConfig' : undefined; + if (/^tsconfig(?:\.[\w-]+)?\.json$/.test(name)) return has(tree, key => ['compilerOptions', 'include', 'files', 'extends', 'references'].includes(key)) ? 'tsconfig' : undefined; + if (name === '.flake8') return has(tree, key => key === '[flake8]') ? 'flake8' : undefined; + if (name === 'pytest.ini') return has(tree, key => key === '[pytest]') ? 'pytest' : undefined; + if (name === '.coveragerc') return has(tree, key => key.startsWith('[')) ? 'coverage' : undefined; + return undefined; +} + const isMap = (value: YamlValue): value is Extract => value.kind === 'map'; const scalarText = (value: YamlValue, words: Words) => value.kind === 'scalar' ? value.value === '' ? words.empty : /^[|>][-+0-9]*$/.test(value.value) ? words.text : code(value.value) : ''; const more = (total: number, shown: number, words: Words, separator = '; ') => total > shown ? `${separator}${words.more(total - shown)}` : ''; @@ -108,18 +143,20 @@ function entryLines(key: string, value: YamlValue, words: Words): string[] { ...value.items.length > ITEMS ? [` ${words.more(value.items.length - ITEMS)}`] : []]; } -/** The outline of a YAML, JSON or TOML file in the language of the question, or undefined - * for any other file and for text that does not parse. */ +/** The outline of a YAML, JSON, TOML or INI file in the language of the question, or undefined + * for any other file and for text that does not parse. A well-known file says first what it + * is for; "Read from the file" stands once, in the note at the end (B7). */ export function fileOutline(path: string, text: string, language: 'en' | 'de'): string | undefined { - const words = fileOutlineWords[language]; + const words = fileOutlineWords[language], own = chatRound3Text[language]; const name = code(path.split('/').pop() ?? path); if (isGithubWorkflow(path)) { const facts = workflowFactLines(path, text, language === 'de' ? germanWorkflowWords : workflowWords); - if (facts.length) return [words.heading(name, words.kinds.workflow, lineCount(text)), facts.map(line => `- ${line}`).join('\n'), `_${words.note}_`].join('\n\n'); + if (facts.length) return [own.outlineHeading(name, words.kinds.workflow, lineCount(text)), own.purposes.workflow, facts.map(line => `- ${line}`).join('\n'), `_${words.note}_`].join('\n\n'); } const tree = treeOf(path, text); if (!tree) return undefined; - const kind = extension(path) === 'json' ? words.kinds.json : extension(path) === 'toml' ? words.kinds.toml : words.kinds.yaml; + const kind = extension(path) === 'json' ? words.kinds.json : extension(path) === 'toml' ? words.kinds.toml : INI.has(extension(path)) ? own.iniKind : words.kinds.yaml; + const purpose = purposeOf(path, tree); const entries: [string, YamlValue][] = tree.kind === 'map' ? tree.entries : [['', tree]]; const lines: string[] = []; let used = 0, shown = 0; @@ -130,5 +167,5 @@ export function fileOutline(path: string, text: string, language: 'en' | 'de'): lines.push(...next); used += size; shown++; } if (shown === Math.min(entries.length, ENTRIES) && entries.length > ENTRIES) lines.push(words.more(entries.length - ENTRIES)); - return [words.heading(name, kind, lineCount(text)), lines.join('\n'), `_${words.note}_`].join('\n\n'); + return [own.outlineHeading(name, kind, lineCount(text)), ...purpose ? [own.purposes[purpose]] : [], lines.join('\n'), `_${words.note}_`].join('\n\n'); } diff --git a/graph-ui/src/browser-ai/strings.ts b/graph-ui/src/browser-ai/strings.ts index 5d5a0dfe9..145f83c82 100644 --- a/graph-ui/src/browser-ai/strings.ts +++ b/graph-ui/src/browser-ai/strings.ts @@ -395,6 +395,25 @@ const englishRound3Text = { backTo: (label: string) => `Back to: ${label}. Earlier messages about it are sent again.`, /** A follow-up ("und was noch?") right after a change of topic (B4). */ followUp: (typed: string, label: string) => `"${typed}" refers to earlier messages. Those were about another topic and are not sent with questions about ${label}. Please ask the full question, for example:`, + /** The first line of a file outline; the note under it says where it was read (B7). */ + outlineHeading: (name: string, kind: string, lines: number) => `${name}: ${kind}, ${counted(lines, 'line', 'lines')}.`, + iniKind: 'INI configuration', + /** What a well-known file is for, by its name and structure, before its outline (B7). */ + purposes: { + preCommit: 'pre-commit configuration: hooks that run before each commit.', + workflow: 'GitHub runs the jobs of this workflow when one of its triggers occurs.', + npmPackage: 'npm package manifest: the name, scripts and dependencies of a JavaScript package.', + pyproject: 'Python project configuration: how the package is built, its metadata and the settings of tools.', + tox: 'tox configuration: the test environments tox creates and the commands it runs in each.', + setupCfg: 'setuptools configuration: package metadata and options, often also settings of other tools.', + compose: 'Docker Compose file: the services that Docker Compose starts together.', + readTheDocs: 'Read the Docs configuration: how readthedocs.org builds the documentation.', + editorConfig: 'EditorConfig: indentation, line endings and similar editor settings per file pattern.', + tsconfig: 'TypeScript compiler configuration: which files are compiled and with which options.', + flake8: 'flake8 configuration: the rules of the Python linter flake8.', + pytest: 'pytest configuration: the options pytest runs the tests with.', + coverage: 'coverage.py configuration: which code test coverage measures and how it is reported.', + }, }; export const chatRound3Text: { en: typeof englishRound3Text; de: typeof englishRound3Text } = { en: englishRound3Text, @@ -404,5 +423,22 @@ export const chatRound3Text: { en: typeof englishRound3Text; de: typeof englishR askAgain: 'Erneut fragen', backTo: (label: string) => `Zurück zu: ${label}. Die früheren Nachrichten dazu werden wieder mitgeschickt.`, followUp: (typed: string, label: string) => `"${typed}" bezieht sich auf frühere Nachrichten. Die betrafen ein anderes Thema und werden mit Fragen zu ${label} nicht mitgeschickt. Stell die Frage bitte vollständig, zum Beispiel:`, + outlineHeading: (name: string, kind: string, lines: number) => `${name}: ${kind}, ${gezaehlt(lines, 'Zeile', 'Zeilen')}.`, + iniKind: 'INI-Konfiguration', + purposes: { + preCommit: 'pre-commit-Konfiguration: Hooks, die vor jedem Commit laufen.', + workflow: 'GitHub führt die Jobs dieses Workflows aus, wenn einer seiner Auslöser eintritt.', + npmPackage: 'npm-Paketmanifest: Name, Skripte und Abhängigkeiten eines JavaScript-Pakets.', + pyproject: 'Python-Projektkonfiguration: wie das Paket gebaut wird, seine Metadaten und die Einstellungen von Werkzeugen.', + tox: 'tox-Konfiguration: die Testumgebungen, die tox anlegt, und die Befehle, die es in jeder ausführt.', + setupCfg: 'setuptools-Konfiguration: Paketmetadaten und Optionen, oft auch Einstellungen anderer Werkzeuge.', + compose: 'Docker-Compose-Datei: die Services, die Docker Compose zusammen startet.', + readTheDocs: 'Read-the-Docs-Konfiguration: wie readthedocs.org die Dokumentation baut.', + editorConfig: 'EditorConfig: Einrückung, Zeilenenden und ähnliche Editor-Einstellungen je Dateimuster.', + tsconfig: 'TypeScript-Compilerkonfiguration: welche Dateien mit welchen Optionen kompiliert werden.', + flake8: 'flake8-Konfiguration: die Regeln des Python-Linters flake8.', + pytest: 'pytest-Konfiguration: die Optionen, mit denen pytest die Tests ausführt.', + coverage: 'coverage.py-Konfiguration: welcher Code bei der Testabdeckung gemessen und wie darüber berichtet wird.', + }, }, };