From 3b2dea568586ae19b15271682af13da19fdaf4fb Mon Sep 17 00:00:00 2001 From: ducoterra Date: Sat, 12 Sep 2026 15:56:43 -0400 Subject: [PATCH] phase: 102_extensionless_filenames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All verification complete — every gate green, no defects found in previously completed work. **Phase 102 final verification pass — report** Verified (all three task files present in `complete/`; working-tree implementation matches D1–D5 design): - `match_extension` choke point in `app/rag/importer.py` (walk + `formats` counter), `doc_format` name-token badge in `app/api/docs.py`, config/`.env.example` docs, fixture `tests/fixtures/extensionless_kb/`, integration + E2E suites — all present and correct - Completion criteria: end-to-end sync (✓ integration + E2E), case matrix incl. `mydockerfile`/`Dockerfile.dev`/`.dockerfile` exclusions (✓ unit), `formats=dockerfile:1` not `unknown` (✓ log-line assertion), badge `dockerfile`/`containerfile` + `text` fallback + suffixed unchanged (✓ unit/integration/E2E), prune-on-token-removal (✓ `pruned==2`), suffixed-path rule byte-identical (✓ single-line swap, existing cases untouched) Test / lint results (exact commands): - `uv run pytest --cov=app --cov-report=term-missing` → 2084 passed, **99%** coverage (>90% gate) - `uv run pytest tests/e2e/test_extensionless_import.py -v --no-cov` → 2 passed, isolated, DB up - Regressions isolated: `test_import_documents` 3✓, `test_import_extensions_env` 2✓, `test_quadlet_jinja_import` 4✓, `test_document_viewer` 7✓, `test_kb_tree` 8✓ - `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings Notable: commit intentionally not made (harness commits the phase); 102's task files already sit in `complete/`, overview stays in `todo/` for the harness. Next pending phases: 98, 99, 103, 104, 105 (numeric next after 102: `103_suggestions_session_openers`). --- .../102_extensionless_filenames/00_phase.md | 0 .../01_name_token_matching.md | 0 .../02_format_badge_and_docs.md | 0 .../03_integration_and_e2e.md | 0 ...2_extensionless_filenames__00_phase.a1.err | 0 ...02_extensionless_filenames__00_phase.a1.md | 16 + ...ensionless_filenames__00_phase.a1.validate | 96 ++++++ ...s_filenames__01_name_token_matching.a1.err | 0 ...ss_filenames__01_name_token_matching.a1.md | 14 + ...enames__01_name_token_matching.a1.validate | 96 ++++++ ...filenames__02_format_badge_and_docs.a1.err | 0 ..._filenames__02_format_badge_and_docs.a1.md | 16 + ...ames__02_format_badge_and_docs.a1.validate | 96 ++++++ ...s_filenames__03_integration_and_e2e.a1.err | 0 ...ss_filenames__03_integration_and_e2e.a1.md | 14 + ...enames__03_integration_and_e2e.a1.validate | 96 ++++++ .env.example | 5 +- app/api/docs.py | 25 +- app/config.py | 5 +- app/rag/importer.py | 32 +- tests/e2e/test_extensionless_import.py | 245 +++++++++++++++ tests/fixtures/extensionless_kb/Containerfile | 14 + tests/fixtures/extensionless_kb/Dockerfile | 16 + tests/fixtures/extensionless_kb/Makefile | 10 + tests/fixtures/extensionless_kb/notes.md | 6 + tests/integration/test_document_content.py | 69 +++++ .../integration/test_import_extensionless.py | 293 ++++++++++++++++++ tests/unit/test_document_viewer.py | 41 ++- tests/unit/test_importer.py | 82 +++++ 29 files changed, 1276 insertions(+), 11 deletions(-) rename .agents/phases/{todo => complete}/102_extensionless_filenames/00_phase.md (100%) rename .agents/phases/{todo => complete}/102_extensionless_filenames/01_name_token_matching.md (100%) rename .agents/phases/{todo => complete}/102_extensionless_filenames/02_format_badge_and_docs.md (100%) rename .agents/phases/{todo => complete}/102_extensionless_filenames/03_integration_and_e2e.md (100%) create mode 100644 .agents/reports/102_extensionless_filenames/102_extensionless_filenames__00_phase.a1.err create mode 100644 .agents/reports/102_extensionless_filenames/102_extensionless_filenames__00_phase.a1.md create mode 100644 .agents/reports/102_extensionless_filenames/102_extensionless_filenames__00_phase.a1.validate create mode 100644 .agents/reports/102_extensionless_filenames/102_extensionless_filenames__01_name_token_matching.a1.err create mode 100644 .agents/reports/102_extensionless_filenames/102_extensionless_filenames__01_name_token_matching.a1.md create mode 100644 .agents/reports/102_extensionless_filenames/102_extensionless_filenames__01_name_token_matching.a1.validate create mode 100644 .agents/reports/102_extensionless_filenames/102_extensionless_filenames__02_format_badge_and_docs.a1.err create mode 100644 .agents/reports/102_extensionless_filenames/102_extensionless_filenames__02_format_badge_and_docs.a1.md create mode 100644 .agents/reports/102_extensionless_filenames/102_extensionless_filenames__02_format_badge_and_docs.a1.validate create mode 100644 .agents/reports/102_extensionless_filenames/102_extensionless_filenames__03_integration_and_e2e.a1.err create mode 100644 .agents/reports/102_extensionless_filenames/102_extensionless_filenames__03_integration_and_e2e.a1.md create mode 100644 .agents/reports/102_extensionless_filenames/102_extensionless_filenames__03_integration_and_e2e.a1.validate create mode 100644 tests/e2e/test_extensionless_import.py create mode 100644 tests/fixtures/extensionless_kb/Containerfile create mode 100644 tests/fixtures/extensionless_kb/Dockerfile create mode 100644 tests/fixtures/extensionless_kb/Makefile create mode 100644 tests/fixtures/extensionless_kb/notes.md create mode 100644 tests/integration/test_import_extensionless.py diff --git a/.agents/phases/todo/102_extensionless_filenames/00_phase.md b/.agents/phases/complete/102_extensionless_filenames/00_phase.md similarity index 100% rename from .agents/phases/todo/102_extensionless_filenames/00_phase.md rename to .agents/phases/complete/102_extensionless_filenames/00_phase.md diff --git a/.agents/phases/todo/102_extensionless_filenames/01_name_token_matching.md b/.agents/phases/complete/102_extensionless_filenames/01_name_token_matching.md similarity index 100% rename from .agents/phases/todo/102_extensionless_filenames/01_name_token_matching.md rename to .agents/phases/complete/102_extensionless_filenames/01_name_token_matching.md diff --git a/.agents/phases/todo/102_extensionless_filenames/02_format_badge_and_docs.md b/.agents/phases/complete/102_extensionless_filenames/02_format_badge_and_docs.md similarity index 100% rename from .agents/phases/todo/102_extensionless_filenames/02_format_badge_and_docs.md rename to .agents/phases/complete/102_extensionless_filenames/02_format_badge_and_docs.md diff --git a/.agents/phases/todo/102_extensionless_filenames/03_integration_and_e2e.md b/.agents/phases/complete/102_extensionless_filenames/03_integration_and_e2e.md similarity index 100% rename from .agents/phases/todo/102_extensionless_filenames/03_integration_and_e2e.md rename to .agents/phases/complete/102_extensionless_filenames/03_integration_and_e2e.md diff --git a/.agents/reports/102_extensionless_filenames/102_extensionless_filenames__00_phase.a1.err b/.agents/reports/102_extensionless_filenames/102_extensionless_filenames__00_phase.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/102_extensionless_filenames/102_extensionless_filenames__00_phase.a1.md b/.agents/reports/102_extensionless_filenames/102_extensionless_filenames__00_phase.a1.md new file mode 100644 index 0000000..8f48325 --- /dev/null +++ b/.agents/reports/102_extensionless_filenames/102_extensionless_filenames__00_phase.a1.md @@ -0,0 +1,16 @@ +All verification complete — every gate green, no defects found in previously completed work. + +**Phase 102 final verification pass — report** + +Verified (all three task files present in `complete/`; working-tree implementation matches D1–D5 design): +- `match_extension` choke point in `app/rag/importer.py` (walk + `formats` counter), `doc_format` name-token badge in `app/api/docs.py`, config/`.env.example` docs, fixture `tests/fixtures/extensionless_kb/`, integration + E2E suites — all present and correct +- Completion criteria: end-to-end sync (✓ integration + E2E), case matrix incl. `mydockerfile`/`Dockerfile.dev`/`.dockerfile` exclusions (✓ unit), `formats=dockerfile:1` not `unknown` (✓ log-line assertion), badge `dockerfile`/`containerfile` + `text` fallback + suffixed unchanged (✓ unit/integration/E2E), prune-on-token-removal (✓ `pruned==2`), suffixed-path rule byte-identical (✓ single-line swap, existing cases untouched) + +Test / lint results (exact commands): +- `uv run pytest --cov=app --cov-report=term-missing` → 2084 passed, **99%** coverage (>90% gate) +- `uv run pytest tests/e2e/test_extensionless_import.py -v --no-cov` → 2 passed, isolated, DB up +- Regressions isolated: `test_import_documents` 3✓, `test_import_extensions_env` 2✓, `test_quadlet_jinja_import` 4✓, `test_document_viewer` 7✓, `test_kb_tree` 8✓ +- `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings + +Notable: commit intentionally not made (harness commits the phase); 102's task files already sit in `complete/`, overview stays in `todo/` for the harness. +Next pending phases: 98, 99, 103, 104, 105 (numeric next after 102: `103_suggestions_session_openers`). diff --git a/.agents/reports/102_extensionless_filenames/102_extensionless_filenames__00_phase.a1.validate b/.agents/reports/102_extensionless_filenames/102_extensionless_filenames__00_phase.a1.validate new file mode 100644 index 0000000..f295754 --- /dev/null +++ b/.agents/reports/102_extensionless_filenames/102_extensionless_filenames__00_phase.a1.validate @@ -0,0 +1,96 @@ +........................................................................ [ 3%] +........................................................................ [ 6%] +........................................................................ [ 10%] +........................................................................ [ 13%] +........................................................................ [ 17%] +........................................................................ [ 20%] +........................................................................ [ 24%] +........................................................................ [ 27%] +........................................................................ [ 31%] +........................................................................ [ 34%] +........................................................................ [ 38%] +........................................................................ [ 41%] +........................................................................ [ 44%] +........................................................................ [ 48%] +........................................................................ [ 51%] +........................................................................ [ 55%] +........................................................................ [ 58%] +........................................................................ [ 62%] +........................................................................ [ 65%] +........................................................................ [ 69%] +........................................................................ [ 72%] +........................................................................ [ 76%] +........................................................................ [ 79%] +........................................................................ [ 82%] +........................................................................ [ 86%] +........................................................................ [ 89%] +........................................................................ [ 93%] +........................................................................ [ 96%] +.................................................................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +-------------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 52 0 100% +app/api/chat.py 181 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 94 0 100% +app/api/docs.py 134 1 99% +app/api/git_sources.py 229 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 29 0 100% +app/api/sync.py 115 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 140 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 21 0 100% +app/main.py 66 0 100% +app/models.py 125 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 316 1 99% +app/rag/archive_upload.py 128 0 100% +app/rag/chunker.py 206 4 98% +app/rag/folder_summaries.py 121 0 100% +app/rag/git_sources.py 14 0 100% +app/rag/importer.py 197 3 98% +app/rag/llm.py 243 1 99% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 88 0 100% +app/rag/retriever.py 150 3 98% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 24 0 100% +app/schemas.py 307 0 100% +-------------------------------------------------- +TOTAL 3862 15 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/102_extensionless_filenames/102_extensionless_filenames__01_name_token_matching.a1.err b/.agents/reports/102_extensionless_filenames/102_extensionless_filenames__01_name_token_matching.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/102_extensionless_filenames/102_extensionless_filenames__01_name_token_matching.a1.md b/.agents/reports/102_extensionless_filenames/102_extensionless_filenames__01_name_token_matching.a1.md new file mode 100644 index 0000000..7b4f91d --- /dev/null +++ b/.agents/reports/102_extensionless_filenames/102_extensionless_filenames__01_name_token_matching.a1.md @@ -0,0 +1,14 @@ +Task 01 complete. All gates green. + +**Implemented (phase 102, task 01 — name-token matching):** +- `app/rag/importer.py::match_extension(path, extensions) -> str | None` — pure helper: rule 1 = dotted suffix (existing A9 rule, case-insensitive); rule 2 = suffix-less file whose lowercased full name equals a bare token (`Dockerfile` → `dockerfile`); dotted set kept as parameter form (no `config.py` change) +- `iter_importable_files` walk now filters via `match_extension(...) is None` (hidden-component + phase-89 ignore checks untouched, still first) — covers the phase-64 progress pre-walk too, so `total` stays honest +- `import_sources` `formats` counter uses the matched token (`dockerfile:1`, never `unknown`); module docstring documents the extensionless rule +- Unit tests in `tests/unit/test_importer.py`: the full D1 matching matrix, walk inclusion/exclusion (`Dockerfile`/`Containerfile` in; `mydockerfile`, `Dockerfile.dev`, `.dockerfile` out), in-process counter test asserting `summary.formats == {"dockerfile": 1, "md": 1}` and the PLAN §9 line `formats=dockerfile:1,md:1` + +**Results:** +- `uv run pytest --cov=app --cov-report=term-missing` → 2068 passed, `app/` coverage **99%** (>90% gate) +- `uv run ruff check . && uv run pyright` → clean (0 errors) +- No behavior change for any suffixed path — all pre-existing importer/phase-56/quadlet/ignore suites pass unmodified; no task files or PLAN touched, no commit made + +**Next pending task:** `.agents/phases/todo/102_extensionless_filenames/02_format_badge_and_docs.md` (viewer `doc_format` badge + config/`.env.example` docs) diff --git a/.agents/reports/102_extensionless_filenames/102_extensionless_filenames__01_name_token_matching.a1.validate b/.agents/reports/102_extensionless_filenames/102_extensionless_filenames__01_name_token_matching.a1.validate new file mode 100644 index 0000000..f167371 --- /dev/null +++ b/.agents/reports/102_extensionless_filenames/102_extensionless_filenames__01_name_token_matching.a1.validate @@ -0,0 +1,96 @@ +........................................................................ [ 3%] +........................................................................ [ 6%] +........................................................................ [ 10%] +........................................................................ [ 13%] +........................................................................ [ 17%] +........................................................................ [ 20%] +........................................................................ [ 24%] +........................................................................ [ 27%] +........................................................................ [ 31%] +........................................................................ [ 34%] +........................................................................ [ 38%] +........................................................................ [ 41%] +........................................................................ [ 45%] +........................................................................ [ 48%] +........................................................................ [ 52%] +........................................................................ [ 55%] +........................................................................ [ 59%] +........................................................................ [ 62%] +........................................................................ [ 66%] +........................................................................ [ 69%] +........................................................................ [ 73%] +........................................................................ [ 76%] +........................................................................ [ 80%] +........................................................................ [ 83%] +........................................................................ [ 87%] +........................................................................ [ 90%] +........................................................................ [ 94%] +........................................................................ [ 97%] +.................................................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +-------------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 52 0 100% +app/api/chat.py 181 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 94 0 100% +app/api/docs.py 127 1 99% +app/api/git_sources.py 229 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 29 0 100% +app/api/sync.py 115 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 140 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 21 0 100% +app/main.py 66 0 100% +app/models.py 125 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 316 1 99% +app/rag/archive_upload.py 128 0 100% +app/rag/chunker.py 206 4 98% +app/rag/folder_summaries.py 121 0 100% +app/rag/git_sources.py 14 0 100% +app/rag/importer.py 197 3 98% +app/rag/llm.py 243 1 99% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 88 0 100% +app/rag/retriever.py 150 3 98% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 24 0 100% +app/schemas.py 307 0 100% +-------------------------------------------------- +TOTAL 3855 15 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/102_extensionless_filenames/102_extensionless_filenames__02_format_badge_and_docs.a1.err b/.agents/reports/102_extensionless_filenames/102_extensionless_filenames__02_format_badge_and_docs.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/102_extensionless_filenames/102_extensionless_filenames__02_format_badge_and_docs.a1.md b/.agents/reports/102_extensionless_filenames/102_extensionless_filenames__02_format_badge_and_docs.a1.md new file mode 100644 index 0000000..df291de --- /dev/null +++ b/.agents/reports/102_extensionless_filenames/102_extensionless_filenames__02_format_badge_and_docs.a1.md @@ -0,0 +1,16 @@ +All green. Final report: + +**Implemented (task 02 — badge + docs):** +- `app/api/docs.py`: `doc_format(path, extensions=frozenset())` — non-empty suffix returned unconditionally; suffix-less paths reuse task-01's `match_extension` name-token rule (`Dockerfile` → `dockerfile`), else `text`; default-args call byte-identical. Content endpoint now passes `get_settings().import_extension_set` (lru-cached). +- `app/config.py`: `import_extensions` docstring documents the extensionless exact-name rule (phase 102). +- `.env.example`: "Import scope" comment gains the rule with the `dockerfile,containerfile` example; default `BOR_IMPORT_EXTENSIONS` line untouched. +- `frontend/assets/document.js` verified untouched (badge renders `doc.format`; only `md`/`markdown` get markdown rendering — raw `
` is correct).
+- Tests: unit `test_document_viewer.py` (token-set matrix, suffix precedence, no-arg `text` pin) + integration `test_document_content.py::test_content_format_name_token_extensionless` (Dockerfile→`dockerfile` via env-override pattern, README.dev→`dev`, Containerfile→`text` without token).
+
+**Results:**
+- `uv run pytest --cov=app --cov-report=term-missing` → 2080 passed, `app/` coverage **99%** (gate >90%)
+- `uv run ruff check .` → all checks passed; `uv run pyright` → 0 errors
+- Regression: `test_document_content.py` (13), `test_document_viewer.py` + `test_importer.py` (65) green
+
+**Deviations:** none. (Regression E2E suites + story E2E are task 03's gate.)
+**Next pending task:** `.agents/phases/todo/102_extensionless_filenames/03_integration_and_e2e.md`
diff --git a/.agents/reports/102_extensionless_filenames/102_extensionless_filenames__02_format_badge_and_docs.a1.validate b/.agents/reports/102_extensionless_filenames/102_extensionless_filenames__02_format_badge_and_docs.a1.validate
new file mode 100644
index 0000000..bd55527
--- /dev/null
+++ b/.agents/reports/102_extensionless_filenames/102_extensionless_filenames__02_format_badge_and_docs.a1.validate
@@ -0,0 +1,96 @@
+........................................................................ [  3%]
+........................................................................ [  6%]
+........................................................................ [ 10%]
+........................................................................ [ 13%]
+........................................................................ [ 17%]
+........................................................................ [ 20%]
+........................................................................ [ 24%]
+........................................................................ [ 27%]
+........................................................................ [ 31%]
+........................................................................ [ 34%]
+........................................................................ [ 38%]
+........................................................................ [ 41%]
+........................................................................ [ 45%]
+........................................................................ [ 48%]
+........................................................................ [ 51%]
+........................................................................ [ 55%]
+........................................................................ [ 58%]
+........................................................................ [ 62%]
+........................................................................ [ 65%]
+........................................................................ [ 69%]
+........................................................................ [ 72%]
+........................................................................ [ 76%]
+........................................................................ [ 79%]
+........................................................................ [ 83%]
+........................................................................ [ 86%]
+........................................................................ [ 90%]
+........................................................................ [ 93%]
+........................................................................ [ 96%]
+................................................................         [100%]
+=============================== warnings summary ===============================
+.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
+  /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
+    from starlette.testclient import TestClient as TestClient  # noqa
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+================================ tests coverage ================================
+_______________ coverage: platform linux, python 3.13.13-final-0 _______________
+
+Name                           Stmts   Miss  Cover
+--------------------------------------------------
+app/__init__.py                    1      0   100%
+app/api/__init__.py                0      0   100%
+app/api/auth.py                   52      0   100%
+app/api/chat.py                  181      0   100%
+app/api/chats.py                 110      0   100%
+app/api/config.py                 13      0   100%
+app/api/doc_drafts.py             94      0   100%
+app/api/docs.py                  134      1    99%
+app/api/git_sources.py           229      0   100%
+app/api/health.py                 10      0   100%
+app/api/steering.py               42      0   100%
+app/api/suggestions.py            29      0   100%
+app/api/sync.py                  115      0   100%
+app/api/tokens.py                 40      0   100%
+app/api/ui_settings.py            55      0   100%
+app/config.py                    140      0   100%
+app/core/__init__.py               0      0   100%
+app/core/auth.py                  45      0   100%
+app/core/caching.py              124      0   100%
+app/core/debugging.py             29      2    93%
+app/core/docs_push.py             39      0   100%
+app/core/errors.py                 5      0   100%
+app/core/logging.py               13      0   100%
+app/core/rate_limit.py            44      0   100%
+app/core/security_headers.py      20      0   100%
+app/core/theming.py               38      0   100%
+app/core/tokens.py                44      0   100%
+app/db.py                         21      0   100%
+app/main.py                       66      0   100%
+app/models.py                    125      0   100%
+app/rag/__init__.py                0      0   100%
+app/rag/agent.py                 316      1    99%
+app/rag/archive_upload.py        128      0   100%
+app/rag/chunker.py               206      4    98%
+app/rag/folder_summaries.py      121      0   100%
+app/rag/git_sources.py            14      0   100%
+app/rag/importer.py              197      3    98%
+app/rag/llm.py                   243      1    99%
+app/rag/overview.py               71      0   100%
+app/rag/prompts.py                88      0   100%
+app/rag/retriever.py             150      3    98%
+app/rag/scaffolding.py            55      0   100%
+app/rag/source_removal.py         41      0   100%
+app/rag/sources_meta.py           16      0   100%
+app/rag/suggestions.py            27      0   100%
+app/rag/summarizer.py             24      0   100%
+app/schemas.py                   307      0   100%
+--------------------------------------------------
+TOTAL                           3862     15    99%
+coverage gate: app/ 99% (>90%) OK
+All checks passed!
+0 errors, 0 warnings, 0 informations
+WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
+Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
+
+validation OK
diff --git a/.agents/reports/102_extensionless_filenames/102_extensionless_filenames__03_integration_and_e2e.a1.err b/.agents/reports/102_extensionless_filenames/102_extensionless_filenames__03_integration_and_e2e.a1.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/102_extensionless_filenames/102_extensionless_filenames__03_integration_and_e2e.a1.md b/.agents/reports/102_extensionless_filenames/102_extensionless_filenames__03_integration_and_e2e.a1.md
new file mode 100644
index 0000000..a144cab
--- /dev/null
+++ b/.agents/reports/102_extensionless_filenames/102_extensionless_filenames__03_integration_and_e2e.a1.md
@@ -0,0 +1,14 @@
+All gates green. Report:
+
+**Phase 102 task 03 — extensionless filenames: integration + E2E proof**
+
+- Fixture `tests/fixtures/extensionless_kb/`: `Dockerfile` (sentinel `…7a3e`), `Containerfile` (sentinel `…4b1c`), `Makefile` (negative control), `notes.md` (md control)
+- `tests/integration/test_import_extensionless.py` (4 tests, house phase-56 pattern): positive import (3 files, `formats == {dockerfile:1, containerfile:1, md:1}`, no `unknown`, 768-dim chunks, mock `SUMMARY_MODE` digests), `DOCKERFILE` case-insensitive row, `md`-only negative, prune-on-token-removal (`pruned=2`, md survives)
+- `tests/e2e/test_extensionless_import.py` (2 tests): admin — drill-down tree lists all 3 rows, `Makefile` absent, same-page modal shows badge `dockerfile` + source badge + title + raw sentinel + phase-30 summary panel, still on `/sources.html`; anonymous — gate + zero `/api/docs` page calls + direct GET 403
+- E2E in isolation: `uv run pytest tests/e2e/test_extensionless_import.py -v --no-cov` → 2 passed (run twice, stable; no stale servers on E2E ports)
+- Regressions, isolated: `test_import_documents` 3, `test_import_extensions_env` 2, `test_quadlet_jinja_import` 4, `test_document_viewer` 7, `test_kb_tree` 8 — all passed
+- `uv run pytest --cov=app --cov-report=term-missing` → 2084 passed, app/ coverage **99%** (>90% gate)
+- `uv run ruff check . && uv run pyright` → All checks passed / 0 errors
+- Decision: the viewer badge is rendered by the app from its own settings, so the E2E file pins `BOR_IMPORT_EXTENSIONS` for the app-under-test at module level (phase-61 leak-guard pattern; process env outranks the local `.env`, isolation makes it deterministic)
+- Per harness rules I did not commit or move phase files — working tree left ready for the harness's single atomic phase commit (`fix(import): sync extensionless files (Dockerfile, Containerfile) named in BOR_IMPORT_EXTENSIONS`)
+- Next pending task: none in this phase (03 was last) — harness to move phase 102 to `complete/`; next phase in `todo/` is `103_suggestions_session_openers`
diff --git a/.agents/reports/102_extensionless_filenames/102_extensionless_filenames__03_integration_and_e2e.a1.validate b/.agents/reports/102_extensionless_filenames/102_extensionless_filenames__03_integration_and_e2e.a1.validate
new file mode 100644
index 0000000..f295754
--- /dev/null
+++ b/.agents/reports/102_extensionless_filenames/102_extensionless_filenames__03_integration_and_e2e.a1.validate
@@ -0,0 +1,96 @@
+........................................................................ [  3%]
+........................................................................ [  6%]
+........................................................................ [ 10%]
+........................................................................ [ 13%]
+........................................................................ [ 17%]
+........................................................................ [ 20%]
+........................................................................ [ 24%]
+........................................................................ [ 27%]
+........................................................................ [ 31%]
+........................................................................ [ 34%]
+........................................................................ [ 38%]
+........................................................................ [ 41%]
+........................................................................ [ 44%]
+........................................................................ [ 48%]
+........................................................................ [ 51%]
+........................................................................ [ 55%]
+........................................................................ [ 58%]
+........................................................................ [ 62%]
+........................................................................ [ 65%]
+........................................................................ [ 69%]
+........................................................................ [ 72%]
+........................................................................ [ 76%]
+........................................................................ [ 79%]
+........................................................................ [ 82%]
+........................................................................ [ 86%]
+........................................................................ [ 89%]
+........................................................................ [ 93%]
+........................................................................ [ 96%]
+....................................................................     [100%]
+=============================== warnings summary ===============================
+.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
+  /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
+    from starlette.testclient import TestClient as TestClient  # noqa
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+================================ tests coverage ================================
+_______________ coverage: platform linux, python 3.13.13-final-0 _______________
+
+Name                           Stmts   Miss  Cover
+--------------------------------------------------
+app/__init__.py                    1      0   100%
+app/api/__init__.py                0      0   100%
+app/api/auth.py                   52      0   100%
+app/api/chat.py                  181      0   100%
+app/api/chats.py                 110      0   100%
+app/api/config.py                 13      0   100%
+app/api/doc_drafts.py             94      0   100%
+app/api/docs.py                  134      1    99%
+app/api/git_sources.py           229      0   100%
+app/api/health.py                 10      0   100%
+app/api/steering.py               42      0   100%
+app/api/suggestions.py            29      0   100%
+app/api/sync.py                  115      0   100%
+app/api/tokens.py                 40      0   100%
+app/api/ui_settings.py            55      0   100%
+app/config.py                    140      0   100%
+app/core/__init__.py               0      0   100%
+app/core/auth.py                  45      0   100%
+app/core/caching.py              124      0   100%
+app/core/debugging.py             29      2    93%
+app/core/docs_push.py             39      0   100%
+app/core/errors.py                 5      0   100%
+app/core/logging.py               13      0   100%
+app/core/rate_limit.py            44      0   100%
+app/core/security_headers.py      20      0   100%
+app/core/theming.py               38      0   100%
+app/core/tokens.py                44      0   100%
+app/db.py                         21      0   100%
+app/main.py                       66      0   100%
+app/models.py                    125      0   100%
+app/rag/__init__.py                0      0   100%
+app/rag/agent.py                 316      1    99%
+app/rag/archive_upload.py        128      0   100%
+app/rag/chunker.py               206      4    98%
+app/rag/folder_summaries.py      121      0   100%
+app/rag/git_sources.py            14      0   100%
+app/rag/importer.py              197      3    98%
+app/rag/llm.py                   243      1    99%
+app/rag/overview.py               71      0   100%
+app/rag/prompts.py                88      0   100%
+app/rag/retriever.py             150      3    98%
+app/rag/scaffolding.py            55      0   100%
+app/rag/source_removal.py         41      0   100%
+app/rag/sources_meta.py           16      0   100%
+app/rag/suggestions.py            27      0   100%
+app/rag/summarizer.py             24      0   100%
+app/schemas.py                   307      0   100%
+--------------------------------------------------
+TOTAL                           3862     15    99%
+coverage gate: app/ 99% (>90%) OK
+All checks passed!
+0 errors, 0 warnings, 0 informations
+WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
+Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
+
+validation OK
diff --git a/.env.example b/.env.example
index 9468ad0..276351f 100644
--- a/.env.example
+++ b/.env.example
@@ -54,7 +54,10 @@ BOR_RRF_K=60                      # Reciprocal Rank Fusion damping constant
 # Comma-separated file extensions (lowercase, no dot) the importer reads.
 # Any extension is allowed — the value below is the built-in default (the
 # A9 family: the original seven + the quadlet family + jinja ``j2``); add
-# your own (e.g. md,sh,toml) or narrow it (e.g. md). A blank list or a
+# your own (e.g. md,sh,toml,dockerfile,containerfile) or narrow it
+# (e.g. md). A token also matches extensionless files whose lowercased
+# full filename equals it exactly (dockerfile → Dockerfile — case-
+# insensitive, no partial names — phase 102). A blank list or a
 # malformed token (e.g. md,sh!) fails startup loudly, naming the value.
 BOR_IMPORT_EXTENSIONS=md,markdown,txt,yaml,yml,json,py,container,network,volume,image,pod,kube,swap,os,endpoint,j2
 # BOR_SUGGESTIONS=["How is my Kubernetes cluster set up?"]   # JSON seed chips — shown only before any question has been saved (phase 80)
diff --git a/app/api/docs.py b/app/api/docs.py
index c5859d9..b2e7c0a 100644
--- a/app/api/docs.py
+++ b/app/api/docs.py
@@ -35,11 +35,13 @@ from sqlalchemy import func, select
 from sqlalchemy.orm import Session
 
 from app.api.sync import _sanitize_error
+from app.config import get_settings
 from app.core.auth import require_admin, require_user
 from app.db import get_db
 from app.models import Chunk, Document, FolderSummary
 from app.rag.agent import list_source_names
 from app.rag.folder_summaries import folder_of
+from app.rag.importer import match_extension
 from app.rag.llm import EmbeddingError, LLMClient
 from app.schemas import (
     DocContent,
@@ -58,11 +60,24 @@ from app.schemas import (
 router = APIRouter(tags=["kb"])
 
 
-def doc_format(path: str) -> str:
+def doc_format(path: str, extensions: frozenset[str] = frozenset()) -> str:
     """Lowercased path suffix without its dot (``kubernetes.md`` → ``md``,
-    ``notes/deep.Markdown`` → ``markdown``); ``text`` when the path has no
-    suffix — the value shown in the viewer's format badge."""
-    return Path(path).suffix.lower().lstrip(".") or "text"
+    ``notes/deep.Markdown`` → ``markdown``) — returned **unconditionally**
+    for a non-empty suffix (display never depends on the import list — an
+    out-of-scope ``readme.rst`` still badges ``rst``); ``text`` when the
+    path has no suffix **and its name is not a configured token** (phase
+    102: a suffix-less ``Dockerfile`` badges ``dockerfile`` when its
+    lowercased full filename is one of *extensions* — the importer's
+    :func:`app.rag.importer.match_extension` rule). The value shown in
+    the viewer's format badge; the default empty *extensions* keeps the
+    pre-phase-102 suffix-only result for every path (byte-identical).
+    """
+    p = Path(path)
+    suffix = p.suffix.lower().lstrip(".")
+    if suffix:
+        return suffix
+    matched = match_extension(p, extensions)
+    return matched if matched is not None else "text"
 
 
 @router.get("/docs", response_model=DocList)
@@ -140,7 +155,7 @@ def get_document_content(
         source=doc.source,
         path=doc.path,
         title=doc.title,
-        format=doc_format(doc.path),
+        format=doc_format(doc.path, get_settings().import_extension_set),
         summary=doc.summary,
         content=doc.content,
         indexed_at=doc.indexed_at.isoformat(),
diff --git a/app/config.py b/app/config.py
index 02439fc..6ba30a1 100644
--- a/app/config.py
+++ b/app/config.py
@@ -207,7 +207,10 @@ class Settings(BaseSettings):
     # doubles as the typo guard); the value below is the built-in default
     # (the A9 family, incl. the quadlet family + ``j2``) and the documented
     # example in ``.env.example``. Hidden (dot) path components are always
-    # skipped, plus the importer's exclusion list.
+    # skipped, plus the importer's exclusion list. A token also matches
+    # extensionless files whose lowercased full filename equals it exactly
+    # (``dockerfile`` → ``Dockerfile``), case-insensitive, no partial
+    # names (phase 102).
     # Stored as a raw CSV string (env-native — no JSON) and parsed on demand
     # via :py:meth:`import_extension_set`. The validator rejects an empty
     # list and malformed tokens so a typo fails loudly at startup (it can
diff --git a/app/rag/importer.py b/app/rag/importer.py
index bb61529..4527201 100644
--- a/app/rag/importer.py
+++ b/app/rag/importer.py
@@ -19,7 +19,9 @@ the two-phase upsert:
 
 Scope (A9, revised 2026-08-21): any path containing a dot-prefixed
 component (hidden dirs — vendored caches like ``.esphome/.espressif/**`` —
-or hidden files) is skipped, plus the well-known exclusion list.
+or hidden files) is skipped, plus the well-known exclusion list. A token
+may also name extensionless files by their exact lowercased full filename
+(``Dockerfile`` under the ``dockerfile`` token — phase 102).
 
 ``prune=True`` deletes documents (of the imported sources only) whose files
 no longer exist **or no longer match the format filter** — this is how
@@ -158,6 +160,24 @@ def _ignore_for_root(
     return tuple(p for p in (normalize_ignore_path(e) for e in raw) if p)
 
 
+def match_extension(path: Path, extensions: frozenset[str]) -> str | None:
+    """The bare lowercased token *path* imports under, or ``None``.
+
+    1. Non-empty lowercased dotted suffix in *extensions* (the A9 rule —
+       ``kubernetes.md`` → ``md``).
+    2. No suffix: the lowercased FULL filename equals a bare token of
+       *extensions* (``Dockerfile`` → ``dockerfile``) — the phase-102
+       extensionless rule. Exact name only: ``mydockerfile`` never
+       matches the ``dockerfile`` token.
+    """
+    suffix = path.suffix.lower()
+    if suffix and suffix in extensions:
+        return suffix.lstrip(".")
+    if not suffix and path.name.lower() in {e.lstrip(".") for e in extensions}:
+        return path.name.lower()
+    return None
+
+
 def iter_importable_files(
     root: Path,
     extensions: frozenset[str],
@@ -187,7 +207,7 @@ def iter_importable_files(
             continue
         if ignore and is_ignored(rel.as_posix(), ignore):
             continue
-        if path.suffix.lower() not in extensions:
+        if match_extension(path, extensions) is None:
             continue
         files.append(path)
     return files
@@ -274,7 +294,13 @@ async def import_sources(
                 rel = path.relative_to(root).as_posix()
                 seen.add((source, rel))
                 summary.files += 1
-                ext = path.suffix.lower().lstrip(".") or "unknown"
+                # Phase 102: the matched bare token (``dockerfile`` for an
+                # extensionless ``Dockerfile``), never ``unknown`` — the
+                # file is in scope, so the walk matched it.
+                ext = (
+                    match_extension(path, llm.settings.import_extension_set)
+                    or "unknown"
+                )
                 summary.formats[ext] = summary.formats.get(ext, 0) + 1
                 if progress is not None:
                     # phase 64: report the file *before* indexing it — a
diff --git a/tests/e2e/test_extensionless_import.py b/tests/e2e/test_extensionless_import.py
new file mode 100644
index 0000000..4d2582a
--- /dev/null
+++ b/tests/e2e/test_extensionless_import.py
@@ -0,0 +1,245 @@
+"""Phase 102 E2E (Playwright): extensionless files (``Dockerfile``,
+``Containerfile``) sync when their name is a ``BOR_IMPORT_EXTENSIONS``
+token — import → chunks → mock ``lite`` summary → drill-down tree →
+viewer badge, plus the negative control and the anonymous gate.
+
+The owner's defect: files without extensions never got synced, so
+``Dockerfile`` / ``Containerfile`` were skipped even with
+``dockerfile,containerfile`` in the env. Phase 102's rule: a suffix-less
+file imports iff its lowercased FULL filename equals a token (exact
+name, case-insensitive), the import ``formats=`` counter keys it by the
+matched token, and the viewer badge is truthful (``dockerfile``, not
+the generic ``text``).
+
+The subject is the extensionless scope (``import_extensions=
+"md,dockerfile,containerfile"``); the story-dedicated fixture
+(``tests/fixtures/extensionless_kb/``) is seeded in-process against the
+deterministic mock LLM — the phase-02/56 seeding-thread pattern — with
+``Dockerfile`` / ``Containerfile`` carrying their own unique sentinels
+and ``Makefile`` as the negative control (no token names it).
+
+Run in isolation (DB must be up: ``podman compose up -d db``):
+
+    uv run pytest tests/e2e/test_extensionless_import.py -v --no-cov
+
+DB isolation: the fixture's source name (``extensionless_kb``) is
+distinctive — the suite never asserts on absolute row counts and
+deletes the rows it creates in a ``finally`` (other suites' documents
+stay untouched in the shared E2E database).
+
+App-under-test scope pin (the phase-61 leak-guard pattern): the viewer
+badge is rendered by the APP from its own ``get_settings().
+import_extension_set`` (``GET /api/documents/content``), so the app
+process must carry exactly the scope under test — a module-level
+``BOR_IMPORT_EXTENSIONS`` override (process env ranks above the
+operator's local gitignored ``.env`` when the ``app_server`` fixture
+snapshots ``os.environ``). In isolation (AGENTS.md rule 9) the session
+app is spawned by this file's first test, so the pin lands determinis-
+tically.
+
+Phase 97 adaptation: the catalog is the DRILL-DOWN TREE — the fixture's
+files live at the SOURCE level (no subfolders), so a single drill
+reaches every asserted row; the drill is the only change, the asserted
+rows/links/modal are unchanged.
+"""
+from __future__ import annotations
+
+import asyncio
+import os
+from collections.abc import Iterator
+from pathlib import Path
+from threading import Thread
+from typing import Any
+
+import pytest
+from playwright.sync_api import Page, expect
+from sqlalchemy import select
+
+from app.config import Settings
+from app.db import SessionLocal
+from app.models import Document
+from app.rag.importer import ImportSummary, import_sources
+from app.rag.llm import LLMClient
+from e2e.auth_helpers import login
+from tests.e2e.mock_llm import TOKEN_RE
+
+#: The app-under-test scope pin (see the module docstring) — exactly the
+#: scope this suite proves, no matter what the operator's local ``.env``
+#: carries. Must run before the session ``app_server`` fixture snapshots
+#: ``os.environ`` (module import precedes fixture setup).
+os.environ["BOR_IMPORT_EXTENSIONS"] = "md,dockerfile,containerfile"
+
+REPO = Path(__file__).resolve().parents[2]
+FIXTURES = REPO / "tests" / "fixtures" / "extensionless_kb"
+SOURCE = FIXTURES.name  # "extensionless_kb" — distinctive, never asserted by count
+DOCKER_REL = "Dockerfile"
+CONTAINER_REL = "Containerfile"
+NOTES_REL = "notes.md"
+MAKE_REL = "Makefile"  # negative control — no token in the scope under test
+DOCKER_SENTINEL = "DOCKERFILE-PROBE-SENTINEL-7a3e"
+
+
+async def _import_fixtures(mock_port: int, extensions: str) -> ImportSummary:
+    kwargs: dict[str, Any] = {
+        "_env_file": None,
+        "llm_base_url": f"http://127.0.0.1:{mock_port}/v1",
+        "import_extensions": extensions,
+    }
+    settings = Settings(**kwargs)  # pyright: ignore[reportCallIssue]
+    return await import_sources([FIXTURES], LLMClient(settings))
+
+
+def _run_in_thread(coro: Any) -> Any:
+    """Run a coroutine on a worker thread.
+
+    Playwright's sync API keeps an asyncio loop running on the test
+    thread, so ``asyncio.run`` cannot be called directly from a test
+    body.
+    """
+    box: dict[str, Any] = {}
+
+    def runner() -> None:
+        try:
+            box["value"] = asyncio.run(coro)
+        except BaseException as e:  # noqa: BLE001 — re-raised on the test thread
+            box["error"] = e
+
+    t = Thread(target=runner)
+    t.start()
+    t.join()
+    if "error" in box:
+        raise box["error"]
+    return box["value"]
+
+
+def _delete_source_rows() -> None:
+    """Delete every row of this suite's distinctive source (chunks
+    cascade with the document rows)."""
+    with SessionLocal() as db:
+        for doc in db.scalars(select(Document).where(Document.source == SOURCE)).all():
+            db.delete(doc)
+        db.commit()
+
+
+def _drill(page: Page, *names: str) -> None:
+    """Drill one level at a time (phase 97 — client-side, no fetch,
+    no URL change): each name is the EXACT text of the source/folder
+    link at the current level."""
+    for name in names:
+        page.click(f'#folders-tbody a.folder-link:text-is("{name}")')
+
+
+def _go_top(page: Page) -> None:
+    """Back to the top level: the breadcrumb's top-level link (call
+    between drills only — the breadcrumb is hidden at the top)."""
+    page.locator("#kb-crumb a.kb-crumb-link").first.click()
+
+
+def _summary_lines(path: str) -> tuple[str, str]:
+    """(digest line, pointer line) of the stored phase-30 summary for a
+    fixture file — mirrors the mock ``SUMMARY_MODE`` branch (first 24
+    tokens of the document content) plus the code pointer line (the
+    ``test_summary_in_viewer.py`` house helper)."""
+    content = (FIXTURES / path).read_text(encoding="utf-8")
+    digest = " ".join(TOKEN_RE.findall(content.lower())[:24])
+    expected = f"This document covers {digest}.\nSource: {SOURCE}/{path}"
+    digest_line, pointer_line = expected.split("\n", 1)
+    return digest_line, pointer_line
+
+
+@pytest.fixture(autouse=True)
+def extensionless_kb(mock_llm: int, db_ready: None) -> Iterator[ImportSummary]:
+    """Seed the fixture with the extensionless scope
+    (``md,dockerfile,containerfile``) for one test and delete every row
+    it creates afterwards (DB isolation — see the module docstring)."""
+    _delete_source_rows()  # idempotent: leftovers from a crashed run
+    summary = _run_in_thread(_import_fixtures(mock_llm, "md,dockerfile,containerfile"))
+    try:
+        yield summary
+    finally:
+        _delete_source_rows()
+
+
+def test_admin_tree_lists_name_token_files_and_viewer_badge(
+    page: Page, app_url: str, extensionless_kb: ImportSummary
+) -> None:
+    # The seed saw exactly the three in-scope files in their formats —
+    # the two extensionless build files walked by name token, chunked,
+    # and summarized; the Makefile negative control stayed out.
+    assert extensionless_kb.formats == {"dockerfile": 1, "containerfile": 1, "md": 1}
+    assert "unknown" not in extensionless_kb.formats
+    assert (extensionless_kb.added, extensionless_kb.errors) == (3, 0)
+
+    login(page, app_url)  # phase 16: the catalog is admin-only
+    # All three fixture files live at the SOURCE level (no subfolders —
+    # one drill reaches them, phase 97).
+    _drill(page, SOURCE)
+    for name in (DOCKER_REL, CONTAINER_REL, NOTES_REL):
+        expect(page.locator("#docs-tbody tr", has_text=name)).to_have_count(1)
+    # The negative control: no token names Makefile exactly — absent.
+    expect(page.locator("#docs-tbody tr", has_text=MAKE_REL)).to_have_count(0)
+
+    # The Dockerfile row's path link opens the SAME-PAGE modal and its
+    # meta row shows the D3 badge (``dockerfile`` — not the generic
+    # ``text`` fallback) + the source badge + the stem title.
+    row = page.locator("#docs-tbody tr", has_text=DOCKER_REL)
+    link = row.locator("td:nth-child(2) a.doc-link")
+    expect(link).to_have_count(1)
+    expect(link).to_have_attribute("title", DOCKER_REL)
+    before = len(page.context.pages)
+    link.click()
+    assert len(page.context.pages) == before, "clicking a row link must not open a new tab"
+    expect(page.locator("#doc-modal-meta .doc-source-badge")).to_have_text(SOURCE)
+    expect(page.locator("#doc-modal-meta .format-badge")).to_have_text("dockerfile")
+    expect(page.locator("#doc-modal-title")).to_have_text(DOCKER_REL)
+
+    # Non-markdown content renders as escaped monospace text in a pre —
+    # the sentinel proves it is THIS document's content.
+    pre = page.locator("#doc-modal-content pre.doc-raw")
+    expect(pre).to_have_count(1)
+    expect(pre).to_contain_text(DOCKER_SENTINEL)
+
+    # The phase-30 summary line renders above the content (house
+    # assertion style — test_summary_in_viewer.py): the deterministic
+    # mock digest + the code pointer line, in the labeled panel.
+    digest_line, pointer_line = _summary_lines(DOCKER_REL)
+    panel = page.locator("#doc-modal .doc-summary")
+    expect(panel).to_have_count(1)
+    expect(panel).to_be_visible()
+    expect(panel).to_have_attribute("aria-label", "Summary")
+    expect(panel).to_contain_text(digest_line)
+    expect(panel).to_contain_text(pointer_line)
+
+    # Still on the Sources page: no navigation happened.
+    assert page.url == app_url + "/sources.html", f"navigated away: {page.url}"
+
+
+def test_anonymous_sources_gate_and_no_api_docs(
+    page: Page, app_url: str, extensionless_kb: ImportSummary
+) -> None:
+    """A fresh anonymous context (function-scoped ``page`` = new
+    browser context, no cookies): the sign-in gate renders and the page
+    never calls ``/api/docs`` — the phase-16 pin, regression-checked
+    with the extensionless KB seeded; the API itself 403s anonymous
+    callers."""
+    api_docs_calls: list[str] = []
+    page.on(
+        "request",
+        lambda r: api_docs_calls.append(r.url) if "/api/docs" in r.url else None,
+    )
+
+    page.goto(f"{app_url}/sources.html")
+    # The gate, with its sign-in link — not a redirect.
+    gate = page.locator("#sources-gate")
+    expect(gate).to_be_visible()
+    expect(gate).to_contain_text("Sign in to view the full catalog")
+    expect(gate.locator("a[href='/login.html?next=/sources.html']")).to_have_count(1)
+    # Stat cards + table hidden…
+    expect(page.locator("#stat-cards")).to_be_hidden()
+    expect(page.locator("#docs-table")).to_be_hidden()
+    expect(page.locator("#sources-empty")).to_be_hidden()
+    # …and NO /api/docs call was ever made.
+    assert api_docs_calls == [], f"anonymous sources page called /api/docs: {api_docs_calls}"
+    # And the API gate itself: an anonymous GET /api/docs 403s (phase 16
+    # — the router sits behind require_admin).
+    assert page.request.get(f"{app_url}/api/docs").status == 403
diff --git a/tests/fixtures/extensionless_kb/Containerfile b/tests/fixtures/extensionless_kb/Containerfile
new file mode 100644
index 0000000..f749daa
--- /dev/null
+++ b/tests/fixtures/extensionless_kb/Containerfile
@@ -0,0 +1,14 @@
+# CONTAINERFILE-PROBE-SENTINEL-4b1c — phase 102 fixture marker: this
+# token exists nowhere else (the Dockerfile sibling carries its own).
+#
+# Containerfile — the local Postgres 17 + pgvector image the compose
+# stack builds for the Brain of Reese database service.
+
+FROM postgres:17
+
+RUN apt-get update \
+    && apt-get install -y --no-install-recommends build-essential git \
+    && git clone --branch v0.7.4 https://github.com/pgvector/pgvector /tmp/pgvector \
+    && make -C /tmp/pgvector install \
+    && rm -rf /tmp/pgvector \
+    && apt-get purge -y build-essential git
diff --git a/tests/fixtures/extensionless_kb/Dockerfile b/tests/fixtures/extensionless_kb/Dockerfile
new file mode 100644
index 0000000..3853a42
--- /dev/null
+++ b/tests/fixtures/extensionless_kb/Dockerfile
@@ -0,0 +1,16 @@
+# DOCKERFILE-PROBE-SENTINEL-7a3e — phase 102 fixture marker: this token
+# exists nowhere else, so the extensionless_kb rows are unambiguous in
+# the shared database.
+#
+# Dockerfile — the Brain of Reese app image: a slim Python runtime that
+# serves the FastAPI app behind the homelab reverse proxy.
+
+FROM python:3.12-slim
+
+WORKDIR /app
+COPY pyproject.toml uv.lock ./
+COPY app ./app
+RUN pip install --no-cache-dir uv && uv sync --frozen --no-dev
+
+EXPOSE 8000
+CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0"]
diff --git a/tests/fixtures/extensionless_kb/Makefile b/tests/fixtures/extensionless_kb/Makefile
new file mode 100644
index 0000000..2278e8b
--- /dev/null
+++ b/tests/fixtures/extensionless_kb/Makefile
@@ -0,0 +1,10 @@
+# Makefile — phase 102 negative control: extensionless, and no token in
+# the env under test names it exactly, so the importer must skip it
+# (the exact-name rule never reaches for a compound or unrelated name).
+.PHONY: up down ps
+up:
+	podman compose up -d
+down:
+	podman compose down
+ps:
+	podman compose ps
diff --git a/tests/fixtures/extensionless_kb/notes.md b/tests/fixtures/extensionless_kb/notes.md
new file mode 100644
index 0000000..84f633d
--- /dev/null
+++ b/tests/fixtures/extensionless_kb/notes.md
@@ -0,0 +1,6 @@
+# Extensionless fixture notes
+
+A short markdown control document for the phase 102 extensionless-kb
+fixture. With `import_extensions="md,dockerfile,containerfile"` the
+importer walks this file plus the name-token build files (`Dockerfile`,
+`Containerfile`); with `md` alone, this note is the only import.
diff --git a/tests/integration/test_document_content.py b/tests/integration/test_document_content.py
index e4acbcc..7037f6b 100644
--- a/tests/integration/test_document_content.py
+++ b/tests/integration/test_document_content.py
@@ -35,6 +35,7 @@ from sqlalchemy import select, text
 from sqlalchemy.orm import Session
 
 import app.api.docs as docs_api
+from app.config import get_settings
 from app.main import app as fastapi_app
 from app.models import Chunk, Document
 from app.rag.llm import EmbeddingError
@@ -555,3 +556,71 @@ def test_content_format_from_suffix(client, db) -> None:
     finally:
         db.execute(text("TRUNCATE chunks, documents"))
         db.commit()
+
+
+def test_content_format_name_token_extensionless(
+    client, db, monkeypatch
+) -> None:
+    """Phase 102: the endpoint's ``format`` carries the name token for an
+    extensionless document whose lowercased full filename is a
+    ``BOR_IMPORT_EXTENSIONS`` token (``Dockerfile`` → ``dockerfile``),
+    ``text`` for an extensionless name that is NOT configured, and every
+    suffixed value is unchanged (display never depends on the import
+    list — ``notes/README.dev`` badges ``dev`` even though ``dev`` is not
+    a configured token). The endpoint reads the real ``get_settings()``,
+    so the token lands via the house env-override pattern (the default
+    list does not contain ``dockerfile``)."""
+    get_settings.cache_clear()
+    try:
+        monkeypatch.setenv("BOR_IMPORT_EXTENSIONS", "md,dockerfile")
+
+        _seed_doc(
+            db,
+            path="services/api/Dockerfile",
+            title="Dockerfile",
+            content="FROM alpine\nCMD [\"/bin/sh\"]",
+            chunks=0,
+        )
+        r = client.get(
+            "/api/documents/content",
+            params={"source": "Homelab", "path": "services/api/Dockerfile"},
+        )
+        assert r.status_code == 200
+        assert r.json()["format"] == "dockerfile"
+
+        # Suffix precedence under the same override — the import list is
+        # ignored for suffixed paths.
+        _seed_doc(
+            db,
+            path="notes/README.dev",
+            title="README.dev",
+            content="dev note",
+            chunks=0,
+        )
+        r = client.get(
+            "/api/documents/content",
+            params={"source": "Homelab", "path": "notes/README.dev"},
+        )
+        assert r.status_code == 200
+        assert r.json()["format"] == "dev"
+
+        # Extensionless name NOT in the configured set → the ``text``
+        # fallback (the existing README pin above covers the same case
+        # under the default settings).
+        _seed_doc(
+            db,
+            path="Containerfile",
+            title="Containerfile",
+            content="FROM alpine",
+            chunks=0,
+        )
+        r = client.get(
+            "/api/documents/content",
+            params={"source": "Homelab", "path": "Containerfile"},
+        )
+        assert r.status_code == 200
+        assert r.json()["format"] == "text"
+    finally:
+        get_settings.cache_clear()
+        db.execute(text("TRUNCATE chunks, documents"))
+        db.commit()
diff --git a/tests/integration/test_import_extensionless.py b/tests/integration/test_import_extensionless.py
new file mode 100644
index 0000000..15dddc8
--- /dev/null
+++ b/tests/integration/test_import_extensionless.py
@@ -0,0 +1,293 @@
+"""Integration test: phase 102 — extensionless files named in
+``BOR_IMPORT_EXTENSIONS`` import end to end.
+
+The owner's defect: ``Dockerfile`` / ``Containerfile`` never synced even
+with ``dockerfile`` / ``containerfile`` in the env, because the importer
+matched on the dotted suffix only (``Path("Dockerfile").suffix`` is
+``""``). Phase 102's rule (D1/D2): a suffix-less file is in scope iff its
+lowercased FULL filename equals a token of the set — exact name,
+case-insensitive — and the ``formats`` counter keys it by the matched
+token, never ``unknown``.
+
+Proven here against the story-dedicated fixture directory
+``tests/fixtures/extensionless_kb/`` (the shared ``extension_kb``
+fixture of phase 56 stays pinned by its own suite):
+
+* positive — ``md,dockerfile,containerfile`` walks ``Dockerfile``,
+  ``Containerfile`` and ``notes.md`` (the ``Makefile`` negative control
+  stays out): rows + embedded chunks + the deterministic mock
+  ``SUMMARY_MODE`` digest for both build files (non-markdown → the
+  phase-30 ``lite`` path), ``formats == {"dockerfile": 1,
+  "containerfile": 1, "md": 1}`` with NO ``unknown`` key;
+* case — an on-disk ``DOCKERFILE`` imports under the ``dockerfile``
+  token, its row keeps the on-disk case;
+* negative — ``md`` alone: only the control note imports;
+* prune — a token removed from the env drops its extensionless rows on
+  the next ``prune=True`` run (D4 — the A9 junk precedent).
+
+The LLM is the deterministic mock server (``tests/e2e/mock_llm.py``) on
+a scratch port — the integration analogue of the e2e ``mock_llm``
+fixture (the phase-56 integration pattern). Runs against the local
+compose Postgres (the ``db`` fixture from ``tests/conftest.py``); only
+the distinctive ``extensionless_kb`` / ``casekb`` source rows are
+created and deleted, so the rest of the shared KB is untouched.
+
+Runs (DB must be up: ``podman compose up -d db``):
+
+    uv run pytest tests/integration/test_import_extensionless.py -v
+"""
+from __future__ import annotations
+
+import asyncio
+import os
+import socket
+import subprocess
+import sys
+import time
+from collections.abc import Iterator
+from pathlib import Path
+from typing import Any
+
+import httpx
+import pytest
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from app.config import Settings
+from app.models import Document
+from app.rag.importer import import_sources
+from app.rag.llm import LLMClient
+
+REPO = Path(__file__).resolve().parents[2]
+FIXTURES = REPO / "tests" / "fixtures" / "extensionless_kb"
+SOURCE = FIXTURES.name  # "extensionless_kb" — distinctive, never asserted by count
+DOCKER_REL = "Dockerfile"
+CONTAINER_REL = "Containerfile"
+NOTES_REL = "notes.md"
+DOCKER_SENTINEL = "DOCKERFILE-PROBE-SENTINEL-7a3e"
+CONTAINER_SENTINEL = "CONTAINERFILE-PROBE-SENTINEL-4b1c"
+#: The mock's SUMMARY_MODE tokenizes the document (``[a-z0-9]+``) — the
+#: hyphenated sentinels land in the digest in their tokenized forms.
+DOCKER_SENTINEL_TOKENS = "dockerfile probe sentinel 7a3e"
+CONTAINER_SENTINEL_TOKENS = "containerfile probe sentinel 4b1c"
+
+
+def _wait_http(url: str, timeout: float = 30.0) -> None:
+    deadline = time.monotonic() + timeout
+    last_err = "unknown"
+    while time.monotonic() < deadline:
+        try:
+            httpx.get(url, timeout=2.0)
+            return
+        except Exception as e:  # noqa: BLE001 — retry until deadline
+            last_err = str(e)
+            time.sleep(0.2)
+    raise RuntimeError(f"mock LLM at {url} did not come up: {last_err}")
+
+
+@pytest.fixture(scope="module")
+def mock_llm_port() -> Iterator[int]:
+    """The deterministic mock LLM (``tests/e2e/mock_llm.py``) on a free
+    scratch port — same server as the e2e ``mock_llm`` fixture, but
+    private to this file (integration tests otherwise run network-free)."""
+    sock = socket.socket()
+    sock.bind(("127.0.0.1", 0))
+    port = sock.getsockname()[1]
+    sock.close()
+    env = dict(os.environ)
+    env.pop("DEBUGPY", None)
+    proc = subprocess.Popen(
+        [sys.executable, "-m", "uvicorn", "tests.e2e.mock_llm:app",
+         "--host", "127.0.0.1", "--port", str(port), "--log-level", "warning"],
+        cwd=REPO,
+        env=env,
+    )
+    try:
+        _wait_http(f"http://127.0.0.1:{port}/v1/models")
+        yield port
+    finally:
+        proc.terminate()
+        try:
+            proc.wait(timeout=10)
+        except subprocess.TimeoutExpired:
+            proc.kill()
+
+
+def _settings(mock_port: int, extensions: str) -> Settings:
+    kwargs: dict[str, Any] = {
+        "_env_file": None,
+        "llm_base_url": f"http://127.0.0.1:{mock_port}/v1",
+        "import_extensions": extensions,
+    }
+    return Settings(**kwargs)  # pyright: ignore[reportCallIssue]
+
+
+def _cleanup_source(db: Session, source: str) -> None:
+    for doc in db.scalars(select(Document).where(Document.source == source)).all():
+        db.delete(doc)
+    db.commit()
+
+
+def test_name_token_files_import_end_to_end(mock_llm_port: int, db: Session) -> None:
+    """``md,dockerfile,containerfile`` over the fixture imports the two
+    name-token build files + the markdown control: rows, embedded
+    chunks, the mock ``SUMMARY_MODE`` digest for each build file, and
+    the ``formats`` counter keyed by the matched token — never
+    ``unknown``. The ``Makefile`` (no token) stays out."""
+    settings = _settings(mock_llm_port, "md,dockerfile,containerfile")
+    assert settings.import_extension_set == {".md", ".dockerfile", ".containerfile"}
+    summary = asyncio.run(import_sources([FIXTURES], LLMClient(settings), session=db))
+    try:
+        # Exactly the three in-scope files walk — the Makefile negative
+        # control and nothing else.
+        assert (
+            summary.files, summary.added, summary.unchanged, summary.updated, summary.errors
+        ) == (3, 3, 0, 0, 0)
+        # D2: extensionless files count under their matched token.
+        assert summary.formats == {"dockerfile": 1, "containerfile": 1, "md": 1}
+        assert "unknown" not in summary.formats
+        # Phase 30: both build files are non-markdown → lite summaries;
+        # the markdown control never is.
+        assert (summary.summaries, summary.summary_errors) == (2, 0)
+
+        for rel, sentinel, tokens in (
+            (DOCKER_REL, DOCKER_SENTINEL, DOCKER_SENTINEL_TOKENS),
+            (CONTAINER_REL, CONTAINER_SENTINEL, CONTAINER_SENTINEL_TOKENS),
+        ):
+            doc = db.scalar(
+                select(Document).where(Document.source == SOURCE, Document.path == rel)
+            )
+            assert doc is not None, f"{rel} was not imported by name token"
+            # Suffix-less: the title is the file stem — the whole name.
+            assert doc.title == rel
+            # Plain-text chunking: the content (incl. the sentinel) is
+            # chunked and every content chunk is embedded at the 768-dim
+            # contract.
+            content = [c for c in doc.chunks if not c.is_summary]
+            assert content, f"{rel} has no content chunks"
+            assert all(
+                c.embedding is not None and len(c.embedding) == 768 for c in content
+            )
+            assert any(sentinel in c.content for c in content)
+            # Mock SUMMARY_MODE digest: byte-stable, the tokenized
+            # sentinel inside it, plus the code-appended pointer line.
+            assert doc.summary is not None
+            assert doc.summary.startswith("This document covers")
+            assert tokens in doc.summary
+            assert f"Source: {SOURCE}/{rel}" in doc.summary
+            schunks = [c for c in doc.chunks if c.is_summary]
+            assert len(schunks) == 1 and schunks[0].position == -1
+            assert schunks[0].embedding is not None
+
+        # The markdown control doc imported too — but markdown never
+        # gets a summary (phase 30).
+        note = db.scalar(
+            select(Document).where(Document.source == SOURCE, Document.path == NOTES_REL)
+        )
+        assert note is not None
+        assert note.summary is None
+        assert [c for c in note.chunks if not c.is_summary]
+
+        # The negative control: no token names Makefile exactly — never
+        # walked.
+        assert (
+            db.scalar(
+                select(Document).where(Document.source == SOURCE, Document.path == "Makefile")
+            )
+            is None
+        )
+    finally:
+        _cleanup_source(db, SOURCE)
+
+
+def test_uppercase_extensionless_name_imports(
+    mock_llm_port: int, db: Session, tmp_path: Path
+) -> None:
+    """Case-insensitive exact name (D1): an on-disk ``DOCKERFILE``
+    imports under the ``dockerfile`` token, and its row keeps the
+    on-disk case in both ``path`` and ``title``."""
+    root = tmp_path / "casekb"
+    root.mkdir()
+    (root / "DOCKERFILE").write_text("FROM alpine\nRUN apk add --no-cache curl\n")
+    settings = _settings(mock_llm_port, "md,dockerfile")
+    summary = asyncio.run(import_sources([root], LLMClient(settings), session=db))
+    try:
+        assert (summary.files, summary.added, summary.errors) == (1, 1, 0)
+        assert summary.formats == {"dockerfile": 1}
+        doc = db.scalar(
+            select(Document).where(Document.source == "casekb", Document.path == "DOCKERFILE")
+        )
+        assert doc is not None, "DOCKERFILE did not import under the dockerfile token"
+        assert doc.title == "DOCKERFILE"
+        assert [c for c in doc.chunks if not c.is_summary]
+        # Non-markdown → the phase-30 lite path ran.
+        assert summary.summaries == 1
+        assert doc.summary is not None
+        assert doc.summary.startswith("This document covers")
+    finally:
+        _cleanup_source(db, "casekb")
+
+
+def test_without_tokens_the_extensionless_files_stay_out(
+    mock_llm_port: int, db: Session
+) -> None:
+    """``md`` alone: the control note imports; ``Dockerfile`` /
+    ``Containerfile`` (their names are not tokens in this scope) and the
+    ``Makefile`` are all out of scope — no value of the scope walks
+    them."""
+    settings = _settings(mock_llm_port, "md")
+    assert settings.import_extension_set == {".md"}
+    summary = asyncio.run(import_sources([FIXTURES], LLMClient(settings), session=db))
+    try:
+        assert (
+            summary.files, summary.added, summary.unchanged, summary.updated, summary.errors
+        ) == (1, 1, 0, 0, 0)
+        assert summary.formats == {"md": 1}
+        assert summary.summaries == 0
+        for rel in (DOCKER_REL, CONTAINER_REL, "Makefile"):
+            assert (
+                db.scalar(
+                    select(Document).where(Document.source == SOURCE, Document.path == rel)
+                )
+                is None
+            ), f"{rel} was imported although its name is not a token in `md`"
+        assert (
+            db.scalar(
+                select(Document).where(Document.source == SOURCE, Document.path == NOTES_REL)
+            )
+            is not None
+        )
+    finally:
+        _cleanup_source(db, SOURCE)
+
+
+def test_prune_removes_extensionless_rows_when_the_token_is_removed(
+    mock_llm_port: int, db: Session
+) -> None:
+    """D4: a file that stops matching — the token leaves the env —
+    leaves ``seen`` and is deleted by the next ``prune=True`` run (the
+    A9 junk precedent); the in-scope ``notes.md`` row survives."""
+    with_tokens = _settings(mock_llm_port, "md,dockerfile,containerfile")
+    asyncio.run(import_sources([FIXTURES], LLMClient(with_tokens), session=db))
+    try:
+        md_only = _settings(mock_llm_port, "md")
+        summary = asyncio.run(
+            import_sources([FIXTURES], LLMClient(md_only), session=db, prune=True)
+        )
+        assert summary.pruned == 2, "the two extensionless rows must be pruned"
+        assert summary.unchanged == 1, "notes.md must survive the prune"
+        for rel in (DOCKER_REL, CONTAINER_REL):
+            assert (
+                db.scalar(
+                    select(Document).where(Document.source == SOURCE, Document.path == rel)
+                )
+                is None
+            ), f"{rel} survived although its token left the env"
+        assert (
+            db.scalar(
+                select(Document).where(Document.source == SOURCE, Document.path == NOTES_REL)
+            )
+            is not None
+        )
+    finally:
+        _cleanup_source(db, SOURCE)
diff --git a/tests/unit/test_document_viewer.py b/tests/unit/test_document_viewer.py
index 6000ab0..f2e2d7e 100644
--- a/tests/unit/test_document_viewer.py
+++ b/tests/unit/test_document_viewer.py
@@ -2,7 +2,9 @@
 
 Python side:
 * ``doc_format`` — format from the path suffix (incl. ``.markdown`` and the
-  no-suffix fallback);
+  no-suffix fallback) + the phase-102 extensionless name-token rule
+  (``Dockerfile`` → ``dockerfile`` with a configured token, suffixes still
+  unconditional, no-arg calls byte-identical to the suffix-only rule);
 * the content endpoint's 200/404 mapping — tested WITHOUT a database by
   stubbing the session via FastAPI's dependency override (unknown pairs and
   traversal-style paths map to 404 ``{detail: "document not found"}``;
@@ -74,6 +76,43 @@ def test_doc_format_from_suffix(path: str, expected: str) -> None:
     assert doc_format(path) == expected
 
 
+def test_doc_format_no_args_extensionless_falls_back_to_text() -> None:
+    """The no-arg contract: default ``extensions=frozenset()`` keeps the
+    pre-phase-102 result for every path — an extensionless name that LOOKS
+    like a configured token still badges ``text`` without the token set."""
+    assert doc_format("Dockerfile") == "text"
+    assert doc_format("README") == "text"
+
+
+#: A token set in the dotted form ``import_extension_set`` passes it.
+_TOKEN_EXTS = frozenset({".md", ".dev", ".dockerfile", ".containerfile"})
+
+
+@pytest.mark.parametrize(
+    ("path", "extensions", "expected"),
+    [
+        # Name-token branch (phase 102): suffix-less name in the set.
+        ("services/api/Dockerfile", _TOKEN_EXTS, "dockerfile"),
+        ("DOCKERFILE", _TOKEN_EXTS, "dockerfile"),  # case-insensitive name
+        ("Containerfile", _TOKEN_EXTS, "containerfile"),
+        # Suffix precedence: display never depends on the import list.
+        ("Dockerfile.dev", _TOKEN_EXTS, "dev"),  # suffixed → its suffix rules
+        ("readme.rst", _TOKEN_EXTS, "rst"),  # out-of-scope suffix still badges
+        ("notes/README.dev", _TOKEN_EXTS, "dev"),
+        ("dockerfile.bak", _TOKEN_EXTS, "bak"),  # lookalike → suffix, not name
+        ("kubernetes.md", frozenset(), "md"),  # empty set: suffix unconditional
+        # Extensionless names NOT in the set fall back to text — exact
+        # name only, no partial names.
+        ("README", _TOKEN_EXTS, "text"),
+        ("mydockerfile", _TOKEN_EXTS, "text"),
+    ],
+)
+def test_doc_format_with_token_set(
+    path: str, extensions: frozenset[str], expected: str
+) -> None:
+    assert doc_format(path, extensions) == expected
+
+
 # ---------------------------------------------------------------------------
 # Content endpoint mapping — stubbed session, no database required
 # ---------------------------------------------------------------------------
diff --git a/tests/unit/test_importer.py b/tests/unit/test_importer.py
index 814a598..67f9328 100644
--- a/tests/unit/test_importer.py
+++ b/tests/unit/test_importer.py
@@ -27,6 +27,7 @@ from app.rag.importer import (
     _store_summary,
     import_sources,
     iter_importable_files,
+    match_extension,
 )
 from app.rag.llm import EmbeddingError
 from tests.fakes import FakeEmbedder
@@ -59,6 +60,15 @@ class _CapEmbedder(FakeEmbedder):
         return await super().embed(texts)
 
 
+def _embedder_with_extensions(extensions: str) -> FakeEmbedder:
+    """A :class:`FakeEmbedder` whose settings carry a custom
+    ``BOR_IMPORT_EXTENSIONS`` CSV (phase 102 — the import scope the walk
+    and the ``formats`` counter read from ``llm.settings``)."""
+    llm = FakeEmbedder()
+    llm.settings = Settings(_env_file=None, import_extensions=extensions)  # pyright: ignore[reportCallIssue]
+    return llm
+
+
 def _cleanup_source(db, source: str) -> None:
     for doc in db.scalars(select(Document).where(Document.source == source)).all():
         db.delete(doc)
@@ -199,6 +209,48 @@ def test_iter_importable_files_respects_custom_extension_filter(tmp_path: Path)
     assert found == {"a.md"}
 
 
+def test_match_extension_matrix() -> None:
+    """Phase 102, D1 — the matching matrix: the A9 dotted-suffix rule first,
+    then the extensionless exact-name rule (case-insensitive), exact name
+    only — no partial matching, suffixed files governed by their suffix."""
+    exts = frozenset({".md", ".dockerfile"})
+    # Rule 1 — the dotted suffix (case-insensitive, as today):
+    assert match_extension(Path("kubernetes.md"), frozenset({".md"})) == "md"
+    assert match_extension(Path("Kubernetes.MD"), frozenset({".md"})) == "md"
+    # Rule 2 — extensionless files by exact lowercased FULL filename:
+    assert match_extension(Path("Dockerfile"), exts) == "dockerfile"
+    assert match_extension(Path("DOCKERFILE"), exts) == "dockerfile"
+    # …only when the token is actually in the set:
+    assert match_extension(Path("Dockerfile"), frozenset({".md"})) is None
+    # Exact name only — compound lookalikes never match the token:
+    assert match_extension(Path("mydockerfile"), exts) is None
+    # A suffixed file is governed by its suffix, never its name:
+    assert match_extension(Path("Dockerfile.dev"), exts) is None
+    assert match_extension(Path("Dockerfile.dev"), frozenset({".md", ".dev"})) == "dev"
+    # An out-of-scope suffix is out of scope, name be damned:
+    assert match_extension(Path("readme.rst"), frozenset({".md"})) is None
+
+
+def test_iter_importable_files_walks_extensionless_name_tokens(tmp_path: Path) -> None:
+    """Phase 102, D1 — an extensionless file walks iff its lowercased full
+    name is a token (``md,dockerfile,containerfile`` here): lookalikes and
+    the dot-prefixed hidden file stay skipped by the pre-existing rules."""
+    root = tmp_path / "build"
+    root.mkdir()
+    for name in (
+        "Dockerfile",
+        "Containerfile",
+        "mydockerfile",  # compound name — never matches the `dockerfile` token
+        "Dockerfile.dev",  # governed by its .dev suffix (not a token here)
+        ".dockerfile",  # dot-prefixed FILE — the hidden-component rule
+        "notes.md",
+    ):
+        (root / name).write_text(f"# {name}\n\nbody {name}\n")
+    exts = frozenset({".md", ".dockerfile", ".containerfile"})
+    found = [p.name for p in iter_importable_files(root, exts)]
+    assert found == ["Containerfile", "Dockerfile", "notes.md"]
+
+
 def test_excluded_dirs_match_plan_anchor_a9() -> None:
     assert {
         ".venv", "node_modules", ".git", "__pycache__", ".pytest_cache", "dist", "build"
@@ -711,6 +763,36 @@ def test_prune_removes_files_now_excluded_by_format_filter(db, tmp_path: Path) -
         _cleanup_source(db, root.name)
 
 
+# ---------- phase 102: extensionless name-token import ----------
+
+
+def test_formats_counter_counts_extensionless_name_token(
+    db, tmp_path: Path, caplog: pytest.LogCaptureFixture
+) -> None:
+    """Phase 102, D2 — an imported ``Dockerfile`` counts under
+    ``dockerfile`` in ``summary.formats`` and the PLAN §9 line, never
+    ``unknown``."""
+    root = tmp_path / "extless"
+    root.mkdir()
+    (root / "Dockerfile").write_text("FROM alpine\n\nCMD [\"/bin/sh\"]\n")
+    (root / "notes.md").write_text("# Notes\n\nbody\n")
+    llm = _embedder_with_extensions("md,dockerfile")
+    try:
+        with caplog.at_level(logging.INFO, logger="app.importer"):
+            summary = asyncio.run(import_sources([root], llm, session=db))
+        assert summary.files == 2 and summary.added == 2
+        assert summary.formats == {"dockerfile": 1, "md": 1}
+        line = next(
+            r.getMessage()
+            for r in caplog.records
+            if "import: summary files=" in r.getMessage()
+        )
+        assert line.endswith("formats=dockerfile:1,md:1")
+        assert "unknown" not in line
+    finally:
+        _cleanup_source(db, root.name)
+
+
 # ---------- phase 64 (task 01): optional per-file progress hook ----------