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.', + }, }, };