88502325cbf4
bring Kepr 2's repo map to the Atlas panel: each file's exports, project imports, users and tests, a folder's users, found by syntax per language and linked to jump through the tree (v3.0.106)
- time
- 2026-09-29 18:18 UTC · 2 weeks ago
- machine
- Junes-MacBook-Pro.local
- agent
- claude-opus-5-5
- session
- match kepr 2 repo page
- parent
- d5eaa7c0b5e5
↓ kepr-88502325.tar.gzatlas at this save
files changed7 files
package.json+1 −1public/js/kepr.js+30 −2src/describe/links.ts+205 −0src/mirror/store.ts+3 −3src/pages/atlas.ts+91 −6tests/links.test.ts+85 −0tests/pages.test.ts+7 −4package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "kepr",3- "version": "3.0.105",3+ "version": "3.0.106",44 "private": true,55 "type": "module",66 "scripts": {public/js/kepr.js+30 −2
@@ -398,7 +398,22 @@398398 });399399 row.addEventListener("focus", () => show(row));400400 }401- const viewer = fileViewer(root);401+ // A map link in a panel jumps to its row, opening the folders above it.402+ const jump = (path) => {403+ const row = path.endsWith("/")404+ ? folders.find((folder) => `${folder.dataset.path}/` === path)?.firstElementChild405+ : files.find((file) => file.dataset.path === path);406+ if (!row) return false;407+ for (const folder of folders) folder.open ||= path.startsWith(`${folder.dataset.path}/`);408+ show(row);409+ row.scrollIntoView({ block: "nearest" });410+ return true;411+ };412+ panel.addEventListener("click", (event) => {413+ const link = event.target.closest("a.atlas-jump");414+ if (link && !modified(event) && jump(link.dataset.path)) event.preventDefault();415+ });416+ const viewer = fileViewer(root, jump);402417 for (const file of files) {403418 file.addEventListener("click", (event) => {404419 if (modified(event)) return;@@ -460,7 +475,7 @@460475 // Files open in a viewer over the page, as in Kepr 2's Atlas: the file page's content with the461476 // row's detail panel beside it, and controls for text size, wrapping, raw, download and the462477 // neighbouring files. Escape closes it; the arrow keys step through the files.463-function fileViewer(root) {478+function fileViewer(root, jump) {464479 let [row, size, wrap] = [null, 12, false];465480 const overlay = element("div", "fv-overlay");466481 overlay.tabIndex = -1;@@ -554,6 +569,19 @@554569 else if (event.key === "ArrowLeft" || event.key === "ArrowRight") {555570 event.preventDefault();556571 step(event.key === "ArrowLeft" ? -1 : 1);572+ }573+ });574+ // A map link beside the code opens that file here; a folder closes the viewer on its row.575+ overlay.addEventListener("click", (event) => {576+ const link = event.target.closest(".fv-detail a.atlas-jump");577+ if (!link || modified(event)) return;578+ const path = link.dataset.path;579+ const file = [...root.querySelectorAll(".atlas-file")].find((f) => f.dataset.path === path);580+ event.preventDefault();581+ if (file) open(file);582+ else {583+ close();584+ jump(path);557585 }558586 });559587 // The Markdown view's rendered/source toggle points at the file page: switch in place.src/describe/links.ts+205 −0
@@ -0,0 +1,205 @@1+// What a source file says about its place in the repo, found by syntax alone like its description:2+// the names it exports and the imports that may name project files or folders. Anything that3+// resolves to nothing in the repo (a standard library, a package) is left out.4+5+/** `relative` specs are paths from the file's own folder; the rest are module paths. */6+export type Import = { spec: string; relative: boolean; tries: string[] };7+export type Outline = { exports: string[]; imports: Import[] };8+9+type Language = {10+ exports: RegExp[];11+ imports: (line: string, path: string) => Import[];12+};13+14+const JS_TRIES = ["", ".ts", ".tsx", ".js", ".mjs", ".jsx", "/index.ts", "/index.js"];15+const PY_TRIES = [".py", "/__init__.py"];16+17+const js: Language = {18+ exports: [19+ /^export\s+(?:default\s+)?(?:async\s+)?(?:function\*?|class|const|let|var|type|interface|enum)\s+([A-Za-z_$][\w$]*)/,20+ ],21+ imports: (line) =>22+ [23+ /^\s*(?:import|export)\s[^'"]*?\bfrom\s*['"]([^'"]+)['"]/,24+ /^\s*import\s*['"]([^'"]+)['"]/,25+ /\brequire\(\s*['"]([^'"]+)['"]\s*\)/,26+ ].flatMap((pattern) => {27+ const spec = pattern.exec(line)?.[1];28+ return spec?.startsWith(".") ? [{ spec, relative: true, tries: JS_TRIES }] : [];29+ }),30+};31+32+const LANGUAGES: Record<string, Language> = {33+ zig: {34+ exports: [/^pub\s+(?:inline\s+)?(?:fn|const|var)\s+(\w+)/],35+ imports: (line) =>36+ [...line.matchAll(/@import\("([^"]+\.zig)"\)/g)].map((m) => ({37+ spec: m[1] as string,38+ relative: true,39+ tries: [""],40+ })),41+ },42+ v: {43+ exports: [44+ /^pub\s+fn\s+(?:\([^)]*\)\s*)?(\w+)/,45+ /^pub\s+(?:struct|enum|interface|type|const)\s+(\w+)/,46+ ],47+ imports: (line) => dotted(/^import\s+([\w.]+)/.exec(line)?.[1]),48+ },49+ rs: {50+ exports: [/^pub\s+(?:async\s+)?(?:fn|struct|enum|trait|type|const|static|mod)\s+(\w+)/],51+ imports: (line, path) => {52+ const mod = /^\s*(?:pub\s+)?mod\s+(\w+)\s*;/.exec(line)?.[1];53+ if (mod) {54+ // `mod x;` in foo.rs names foo/x.rs; in main.rs, lib.rs or mod.rs, a sibling.55+ const name = path.split("/").pop() as string;56+ const base = /^(main|lib|mod)\.rs$/.test(name) ? "." : `./${name.slice(0, -3)}`;57+ return [{ spec: `${base}/${mod}`, relative: true, tries: [".rs", "/mod.rs"] }];58+ }59+ return dotted(/^\s*(?:pub\s+)?use\s+(?:crate|super|self)::([\w:]+)/.exec(line)?.[1]);60+ },61+ },62+ go: {63+ exports: [/^func\s+(?:\([^)]*\)\s*)?([A-Z]\w*)/, /^type\s+([A-Z]\w*)/],64+ // Both `import "x"` and the lines of an `import ( … )` block.65+ imports: (line) => dotted(/^\s*(?:import\s+)?(?:[\w.]+\s+)?"([\w./-]+)"\s*$/.exec(line)?.[1]),66+ },67+ py: {68+ exports: [/^def\s+([A-Za-z]\w*)/, /^class\s+([A-Za-z]\w*)/],69+ imports: (line) => {70+ const from = /^from\s+(\.+)([\w.]*)\s+import\s+([\w\s,]+)/.exec(line);71+ if (from) {72+ const up = `./${"../".repeat((from[1] as string).length - 1)}`;73+ const modules = from[2]74+ ? [from[2]]75+ : (from[3] as string).split(",").map((name) => name.trim());76+ return modules77+ .filter(Boolean)78+ .map((mod) => ({ spec: up + mod.replaceAll(".", "/"), relative: true, tries: PY_TRIES }));79+ }80+ return dotted(/^(?:from|import)\s+([\w.]+)/.exec(line)?.[1]);81+ },82+ },83+ kt: {84+ exports: [85+ /^(?:(?:public|internal|open|abstract|sealed|data|enum|annotation|inline|value|suspend|final|static)\s+)*(?:class|object|interface|fun|typealias|record)\s+(?:<[^>]*>\s*)?([A-Za-z]\w*)/,86+ ],87+ imports: (line) => dotted(/^import\s+(?:static\s+)?([\w.]+)/.exec(line)?.[1]),88+ },89+ c: {90+ exports: [],91+ imports: (line) => {92+ const spec = /^\s*#\s*include\s+"([^"]+)"/.exec(line)?.[1];93+ return spec ? [{ spec, relative: true, tries: [""] }] : [];94+ },95+ },96+};97+for (const ext of "ts tsx mts cts js mjs cjs jsx".split(" ")) LANGUAGES[ext] = js;98+LANGUAGES.java = LANGUAGES.kt as Language;99+for (const ext of "h cc cpp cxx hpp hh".split(" ")) LANGUAGES[ext] = LANGUAGES.c as Language;100+101+function dotted(spec: string | undefined): Import[] {102+ return spec ? [{ spec, relative: false, tries: [] }] : [];103+}104+105+export function outline(path: string, text: string): Outline | null {106+ const language = LANGUAGES[path.split(".").pop() ?? ""];107+ if (!language) return null;108+ const exports = new Set<string>();109+ const imports: Import[] = [];110+ for (const line of text.split("\n")) {111+ for (const pattern of language.exports) {112+ const name = pattern.exec(line)?.[1];113+ if (name) exports.add(name);114+ }115+ imports.push(...language.imports(line, path));116+ }117+ return { exports: [...exports], imports };118+}119+120+/** Test files, as every language here names them. */121+export function isTestPath(path: string): boolean {122+ return /(^|\/)(tests?|__tests__)\/|_test\.|\.(test|spec)\.|(^|\/)test_[^/]*\.py$/.test(path);123+}124+125+/**126+ * The repo's files and folders, indexed so a module path finds them: a folder by its path, a127+ * file by its path without the extension, each under every trailing run of its segments.128+ */129+export type RepoIndex = {130+ files: Set<string>;131+ folders: Set<string>;132+ bySuffix: Map<string, string[]>;133+};134+135+export function repoIndex(paths: string[]): RepoIndex {136+ const files = new Set(paths);137+ const folders = new Set(138+ paths.flatMap((path) =>139+ path140+ .split("/")141+ .slice(0, -1)142+ .map((_, i, parts) => parts.slice(0, i + 1).join("/")),143+ ),144+ );145+ const bySuffix = new Map<string, string[]>();146+ const add = (key: string, target: string) => {147+ const segments = key.split("/");148+ for (let i = 0; i < segments.length; i++) {149+ const suffix = segments.slice(i).join("/");150+ bySuffix.set(suffix, [...(bySuffix.get(suffix) ?? []), target]);151+ }152+ };153+ for (const path of files) add(path.replace(/\.[^./]+$/, ""), path);154+ for (const folder of folders) add(folder, `${folder}/`);155+ return { files, folders, bySuffix };156+}157+158+/** The project files (and `folder/`s, for module imports) that `path`'s imports name. */159+export function resolveImports(path: string, imports: Import[], index: RepoIndex): string[] {160+ const dir = path.split("/").slice(0, -1);161+ const found = new Set<string>();162+ for (const imp of imports) {163+ const target = imp.relative ? relativeTarget(dir, imp, index) : moduleTarget(imp.spec, index);164+ if (target && target !== path) found.add(target);165+ }166+ return [...found];167+}168+169+function relativeTarget(dir: string[], imp: Import, index: RepoIndex): string | null {170+ const parts = [...dir];171+ for (const segment of imp.spec.split("/")) {172+ if (segment === "..") {173+ if (parts.pop() === undefined) return null;174+ } else if (segment !== "." && segment !== "") parts.push(segment);175+ }176+ const base = parts.join("/");177+ for (const tail of imp.tries) if (index.files.has(base + tail)) return base + tail;178+ if (index.folders.has(base)) return `${base}/`;179+ // C's `#include "x.h"` also searches include folders (and Zig's build can add paths): a file of180+ // that name found once in the repo is it.181+ if (imp.tries.length !== 1) return null;182+ const named = (index.bySuffix.get(imp.spec.replace(/\.[^./]+$/, "")) ?? []).filter((hit) =>183+ hit.endsWith(imp.spec),184+ );185+ return named.length === 1 ? (named[0] as string) : null;186+}187+188+// Longest run of the module path first, then shorter ones, so `use crate::db::Pool` finds db.rs189+// and a Go import finds its folder under the module's own name. A single segment only counts from190+// the start, so `android.content.Context` never lands on a project file named Context. More than191+// one match at the longest length is ambiguous, and names nothing.192+function moduleTarget(spec: string, index: RepoIndex): string | null {193+ const segments = spec.split(/[./:]+/).filter(Boolean);194+ for (let length = segments.length; length >= 1; length--) {195+ const hits = new Set<string>();196+ for (let start = 0; start + length <= segments.length; start++) {197+ if (length === 1 && start > 0) break;198+ const key = segments.slice(start, start + length).join("/");199+ for (const hit of index.bySuffix.get(key) ?? []) hits.add(hit);200+ }201+ if (hits.size === 1) return [...hits][0] as string;202+ if (hits.size > 1) return null;203+ }204+ return null;205+}src/mirror/store.ts+3 −3
@@ -3,6 +3,7 @@33 import { join } from "node:path";44 import { inflateSync } from "node:zlib";55 import type { EventRecord, ReleaseRecord, SaveRecord } from "../bucket/repo.ts";6+import { isTestPath } from "../describe/links.ts";67 78 // Koh 3's schema (§5.1, `src/storage/storage.v`) minus `file_cache`, which only a working tree needs,89 // plus `releases`: each version's release.json (§11.2) as read from the bucket, since that is bucket@@ -102,8 +103,6 @@102103 103104 // Vendored code is someone else's: counting Koh 3's bundled SQLite made it read as 290k lines of C.104105 const VENDORED = /(^|\/)(vendor|third_party|node_modules)\//;105-// Tests are not the program: counted, Koh 3's read as 18k lines of V where it has 11k.106-const TEST = /(^|\/)(tests?|__tests__)\/|_test\.|\.(test|spec)\.|(^|\/)test_[^/]*\.py$/;107106 // Bumped when what counts as a line changes, so every mirror recounts once when it opens.108107 const STATS_RULE = "source-only";109108 @@ -262,7 +261,8 @@262261 const byLanguage = new Map<string, number>();263262 for (const file of files) {264263 const language = LANGUAGES[file.path.split(".").pop() ?? ""];265- if (!language || VENDORED.test(file.path) || TEST.test(file.path)) continue;264+ // Tests are not the program: counted, Koh 3's read as 18k lines of V where it has 11k.265+ if (!language || VENDORED.test(file.path) || isTestPath(file.path)) continue;266266 if (file.size > MAX_COUNTED) continue;267267 const bytes = inflateSync(file.data);268268 if (bytes.includes(0)) continue;src/pages/atlas.ts+91 −6
@@ -1,4 +1,5 @@11 import { describeFile, describeFolder, firstSentence } from "../describe/describe.ts";2+import { isTestPath, type Outline, outline, repoIndex, resolveImports } from "../describe/links.ts";23 import { filesAt, fileTouches, objectBytes, type Save, type Touches } from "../mirror/queries.ts";34 import { isText } from "./files.ts";45 import { type Html, html } from "./html.ts";@@ -18,6 +19,7 @@1819 size: number;1920 lines: number | null;2021 description: string | null;22+ outline: Outline | null;2123 /** The saves that touched it, oldest first. */2224 saves: Save[];2325 };@@ -31,10 +33,16 @@3133 saves: Save[];3234 };3335 34-type View = { repo: string; at: string | null };36+/** Each file's resolved imports, and who imports each file or `folder/`. */37+type Links = { imports: Map<string, string[]>; usedBy: Map<string, string[]> };3538 36-type Facts = { lines: number | null; description: string | null };39+type View = { repo: string; at: string | null; links: Links };3740 41+type Facts = { lines: number | null; description: string | null; outline: Outline | null };42+43+// A panel lists this many names or links, then says how many more.44+const LISTED = 12;45+3846 // Measured: Koh 3's vendored 9 MB sqlite3.c cost every Atlas view ~25 ms to inflate and scan. An3947 // object never changes, so what it says about itself is kept for the life of the process, keyed by4048 // hash and file name (the name picks the comment syntax).@@ -49,8 +57,12 @@4957 const text = bytes && isText(bytes) ? new TextDecoder().decode(bytes) : null;5058 const facts =5159 text === null52- ? { lines: null, description: null }53- : { lines: countLines(text), description: describeFile(name, text) };60+ ? { lines: null, description: null, outline: null }61+ : {62+ lines: countLines(text),63+ description: describeFile(name, text),64+ outline: outline(name, text),65+ };5466 known.set(key, facts);5567 return facts;5668 }@@ -93,7 +105,7 @@93105 saves: touches.byPath.get(entry.path) ?? [],94106 });95107 }96- const view = { repo: repo.settings.name, at };108+ const view = { repo: repo.settings.name, at, links: linksOf(allFiles(root)) };97109 return html`<section class="repo-section" id="atlas">98110 <details class="repo-section-toggle" open>99111 <summary class="section-head"><span class="section-label">atlas</span><span class="section-count">${plural(@@ -145,7 +157,7 @@145157 const href = `/${view.repo}/files/${pathUrl(file.path)}${view.at ? `?at=${view.at}` : ""}`;146158 return html`<a class="atlas-row atlas-file" href="${147159 href148- }" data-name="${file.name.toLowerCase()}">${row(file.name, description, base, file.size, file.saves)}</a>160+ }" data-name="${file.name.toLowerCase()}" data-path="${file.path}">${row(file.name, description, base, file.size, file.saves)}</a>149161 <template>${panel(150162 file.name,151163 file.path,@@ -156,9 +168,62 @@156168 ],157169 description,158170 base,171+ fileMap(file, view),159172 )}</template>`;160173 }161174 175+// Kepr 2's map of the repo, only where a file has something to say: what it offers, what it176+// leans on, and what leans on it, tests apart.177+function fileMap(file: AtlasFile, view: View): [string, Html[]][] {178+ const users = view.links.usedBy.get(file.path) ?? [];179+ return [180+ ["exports", listed((file.outline?.exports ?? []).map((name) => html`${name}`))],181+ ["imports", targets(view.links.imports.get(file.path) ?? [], view)],182+ [183+ "used by",184+ targets(185+ users.filter((path) => !isTestPath(path)),186+ view,187+ ),188+ ],189+ ["tested by", targets(users.filter(isTestPath), view)],190+ ];191+}192+193+function targets(paths: string[], view: View): Html[] {194+ return listed(paths.toSorted().map((path) => targetLink(path, view)));195+}196+197+// A file links to its page and a folder to the Atlas with it open; kepr.js jumps to the row.198+function targetLink(target: string, view: View): Html {199+ const folder = target.endsWith("/");200+ const path = folder ? target.slice(0, -1) : target;201+ const query = new URLSearchParams(view.at ? { at: view.at } : {});202+ if (folder) query.set("open", path);203+ const href = folder204+ ? `/${view.repo}?${query}#atlas`205+ : `/${view.repo}/files/${pathUrl(path)}${view.at ? `?${query}` : ""}`;206+ const name = `${path.split("/").pop()}${folder ? "/" : ""}`;207+ return html`<a class="atlas-jump" href="${href}" data-path="${target}" title="${path}">${name}</a>`;208+}209+210+function listed(items: Html[]): Html[] {211+ const more = items.length - LISTED;212+ return more > 0 ? [...items.slice(0, LISTED), html`+${more} more`] : items;213+}214+215+function linksOf(files: AtlasFile[]): Links {216+ const index = repoIndex(files.map((file) => file.path));217+ const imports = new Map<string, string[]>();218+ const usedBy = new Map<string, string[]>();219+ for (const file of files) {220+ const found = resolveImports(file.path, file.outline?.imports ?? [], index);221+ imports.set(file.path, found);222+ for (const target of found) usedBy.set(target, [...(usedBy.get(target) ?? []), file.path]);223+ }224+ return { imports, usedBy };225+}226+162227 function folderRow(folder: AtlasFolder, view: View): Html {163228 const description = describeFolder(folder.files);164229 const base = markdownBase(view, `${folder.path}/`);@@ -184,6 +249,7 @@184249 ],185250 description,186251 base,252+ [["used by", targets(folderUsers(folder, files, view), view)]],187253 )}</template>188254 <div class="atlas-children">${children(folder, view)}</div>189255 </details>`;@@ -205,13 +271,25 @@205271 }</span>`}${html`<span class="atlas-meta mono">${meta}</span>`}`;206272 }207273 274+// Files outside a folder that import it as a module, or anything inside it.275+function folderUsers(folder: AtlasFolder, files: AtlasFile[], view: View): string[] {276+ const inside = (path: string) => path.startsWith(`${folder.path}/`);277+ const users = new Set(view.links.usedBy.get(`${folder.path}/`) ?? []);278+ for (const file of files) {279+ for (const user of view.links.usedBy.get(file.path) ?? []) if (!inside(user)) users.add(user);280+ }281+ return [...users];282+}283+208284 function panel(209285 name: string,210286 path: string,211287 facts: [string, Html | string][],212288 description: string | null,213289 base: MarkdownBase,290+ map: [string, Html[]][],214291 ): Html {292+ const shown = map.filter(([, items]) => items.length > 0);215293 return html`<h3 class="atlas-info-name">${name}</h3>216294 <p class="atlas-info-path mono">${path}</p>217295 <dl class="atlas-facts mono">${facts.map(@@ -220,6 +298,13 @@220298 ${221299 description &&222300 html`<div class="markdown atlas-info-desc">${renderMarkdown(description, base)}</div>`301+}302+${303+ shown.length > 0 &&304+ html`<dl class="atlas-facts atlas-map mono">${shown.map(305+ ([term, items]) =>306+ html`<dt>${term}</dt><dd>${items.map((item, i) => html`${i > 0 && " · "}${item}`)}</dd>`,307+ )}</dl>`223308 }`;224309 }225310 tests/links.test.ts+85 −0
@@ -0,0 +1,85 @@1+import { expect, test } from "bun:test";2+import { isTestPath, outline, repoIndex, resolveImports } from "../src/describe/links.ts";3+4+const repo = repoIndex([5+ "src/cli/cli.v",6+ "src/cli/cmd_man.v",7+ "src/fmt/fmt.v",8+ "src/fmt/fmt_test.v",9+ "web/app.ts",10+ "web/lib/util.ts",11+ "web/lib/index.ts",12+ "zig/main.zig",13+ "zig/db.zig",14+ "rs/src/main.rs",15+ "rs/src/store.rs",16+ "rs/src/store/pool.rs",17+ "go/internal/view/view.go",18+ "go/cmd/main.go",19+ "py/app/models.py",20+ "py/app/views.py",21+ "kt/app/src/main/java/com/x/license/Store.kt",22+ "kt/app/src/main/java/com/x/Main.kt",23+ "c/include/koh.h",24+ "c/src/koh.c",25+]);26+27+const links = (path: string, text: string) =>28+ resolveImports(path, outline(path, text)?.imports ?? [], repo);29+30+test("finds a file's project imports in each language, and nothing outside the repo", () => {31+ expect(links("src/cli/cli.v", "module cli\n\nimport os\nimport src.fmt\n")).toEqual(["src/fmt/"]);32+ expect(33+ links(34+ "web/app.ts",35+ 'import { a } from "./lib/util";\nimport x from "hono";\nimport "./lib";\n',36+ ),37+ ).toEqual(["web/lib/util.ts", "web/lib/index.ts"]);38+ expect(39+ links("zig/main.zig", 'const std = @import("std");\nconst db = @import("db.zig");\n'),40+ ).toEqual(["zig/db.zig"]);41+ expect(links("rs/src/main.rs", "mod store;\nuse crate::store::Pool;\nuse std::io;\n")).toEqual([42+ "rs/src/store.rs",43+ ]);44+ expect(links("rs/src/store.rs", "mod pool;\n")).toEqual(["rs/src/store/pool.rs"]);45+ expect(46+ links("go/cmd/main.go", 'import (\n\t"fmt"\n\t"example.com/me/app/internal/view"\n)\n'),47+ ).toEqual(["go/internal/view/"]);48+ expect(links("py/app/views.py", "from .models import User\nimport os\n")).toEqual([49+ "py/app/models.py",50+ ]);51+ expect(52+ links(53+ "kt/app/src/main/java/com/x/Main.kt",54+ "package com.x\n\nimport com.x.license.Store\nimport android.content.Context\n",55+ ),56+ ).toEqual(["kt/app/src/main/java/com/x/license/Store.kt"]);57+ expect(links("c/src/koh.c", '#include <stdio.h>\n#include "koh.h"\n')).toEqual([58+ "c/include/koh.h",59+ ]);60+});61+62+test("lists what a file exports", () => {63+ expect(64+ outline(65+ "a.v",66+ "pub fn run() {}\nfn hidden() {}\npub fn (r Repo) save() {}\npub struct Repo {}\n",67+ )?.exports,68+ ).toEqual(["run", "save", "Repo"]);69+ expect(70+ outline("a.ts", "export function go() {}\nexport type T = 1;\nconst x = 1;\n")?.exports,71+ ).toEqual(["go", "T"]);72+ expect(outline("a.zig", "pub fn open() void {}\nfn close() void {}\n")?.exports).toEqual([73+ "open",74+ ]);75+ expect(outline("notes.md", "# notes\n")).toBeNull();76+});77+78+test("tells tests from the program", () => {79+ for (const path of ["src/fmt/fmt_test.v", "web/a.test.ts", "tests/x.ts", "py/test_views.py"]) {80+ expect([path, isTestPath(path)]).toEqual([path, true]);81+ }82+ for (const path of ["src/fmt/fmt.v", "web/testing.ts", "src/latest.v"]) {83+ expect([path, isTestPath(path)]).toEqual([path, false]);84+ }85+});tests/pages.test.ts+7 −4
@@ -224,10 +224,11 @@224224 describe("the Atlas", () => {225225 // The detail panel markup waiting in the <template> after a file's row.226226 const panelOf = (page: string, name: string) =>227- new RegExp(`data-name="${name}">.*?</a>\\n<template>(.*?)</template>`, "s").exec(page)?.[1] ??228- "";227+ new RegExp(`data-name="${name}"[^>]*>.*?</a>\\n<template>(.*?)</template>`, "s").exec(228+ page,229+ )?.[1] ?? "";229230 const rowOf = (page: string, name: string) =>230- new RegExp(`data-name="${name}">(.*?)</a>`, "s").exec(page)?.[1] ?? "";231+ new RegExp(`data-name="${name}"[^>]*>(.*?)</a>`, "s").exec(page)?.[1] ?? "";231232 232233 test("sits between the timeline and History", async () => {233234 const page = await body("/demo");@@ -265,9 +266,11 @@265266 expect(rowOf(page, "guide.md")).toContain('<span class="atlas-desc">Read me.</span>');266267 expect(rowOf(page, "secret.txt")).toContain('<span class="atlas-desc"></span>');267268 expect(page).toContain(268- '<a class="atlas-row atlas-file" href="/demo/files/src/main.ts" data-name="main.ts">',269+ '<a class="atlas-row atlas-file" href="/demo/files/src/main.ts" data-name="main.ts" data-path="src/main.ts">',269270 );270271 expect(panelOf(page, "util.ts")).toContain("<p>The <em>one</em> helper. Deep down.</p>");272+ expect(panelOf(page, "util.ts")).toContain("<dt>exports</dt><dd>one</dd>");273+ expect(panelOf(page, "main.ts")).not.toContain("atlas-map");271274 });272275 273276 test("the panel counts only real saves, crediting a checkpoint's change to the next save", async () => {