766144e8cfd8
show a repo's declared version in its header when it has no build, read at land time by Koh's own rule; describe budget 530, ceiling 12,900; Codex review pending, its service unreachable (v3.0.200)
- time
- 2026-10-03 04:47 UTC · 1 week ago
- machine
- Junes-MacBook-Pro.local
- agent
- claude-opus-5-5
- session
- repo version in header
- parent
- 9148c9819b36
↓ kepr-766144e8.tar.gzatlas at this save
files changed6 files
package.json+1 −1scripts/budget.ts+3 −3src/describe/version.ts+76 −0src/mirror/store.ts+20 −1src/pages/repo.ts+7 −3tests/version.test.ts+74 −0package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "kepr",3- "version": "3.0.199",3+ "version": "3.0.200",44 "private": true,55 "type": "module",66 "scripts": {scripts/budget.ts+3 −3
@@ -1,12 +1,12 @@11 // §26: fails when any area passes its budget or the TypeScript total passes the2-// 12,870-line ceiling.3-const CEILING = 12_870;2+// 12,900-line ceiling.3+const CEILING = 12_900;44 55 // Prefixes are matched in order, so the specific files come before the directories.66 const TS_AREAS: [string, string[], number][] = [77 ["bucket", ["src/bucket/"], 1170],88 ["mirror", ["src/mirror/"], 1100],9- ["describe", ["src/describe/"], 500],9+ ["describe", ["src/describe/"], 530],1010 ["pages", ["src/pages/"], 2600],1111 ["releases", ["src/releases/"], 900],1212 ["tarball", ["src/tarball/"], 300],src/describe/version.ts+76 −0
@@ -0,0 +1,76 @@1+// The version a project declares, by Koh's own rule (`src/build/version.v` in Koh 3): the first2+// source that answers wins, so a `VERSION` file overrides everything, and nothing is evaluated: a3+// value built from a variable or a call is skipped and the next source tried. A source that answers4+// with something that is not a version means no version, as Koh then refuses to publish.5+6+const GRADLE_FILES = [7+ "app/build.gradle.kts",8+ "app/build.gradle",9+ "build.gradle.kts",10+ "build.gradle",11+];12+const VERSION = /^[A-Za-z0-9.-]{1,32}$/;13+14+/** The declared version, reading files at one save through `read` (null for a missing file). */15+export function declaredVersion(read: (path: string) => string | null): string | null {16+ const sources: (() => string | null)[] = [17+ () => read("VERSION")?.trim() || null,18+ () => lineVersion(read("build.zig.zon"), ".version"),19+ () => lineVersion(read("v.mod"), "version"),20+ () => lineVersion(read("package.json"), '"version"'),21+ () => lineVersion(read("Cargo.toml"), "version"),22+ () => plistVersion(read("Info.plist")),23+ ...GRADLE_FILES.map((path) => () => gradleVersion(read(path))),24+ ];25+ for (const source of sources) {26+ const value = source();27+ if (value !== null) return VERSION.test(value) ? value : null;28+ }29+ return null;30+}31+32+// The value of the first line the key starts that assigns a quoted literal.33+function lineVersion(text: string | null, key: string): string | null {34+ for (const line of text?.split(/\r?\n/) ?? []) {35+ const trimmed = line.replace(/^[ \t]+/, "");36+ if (trimmed === "" || trimmed.startsWith("//") || trimmed.startsWith("#")) continue;37+ if (!trimmed.startsWith(key)) continue;38+ const value = literalAfter(trimmed, key.length);39+ if (value !== null) return value;40+ }41+ return null;42+}43+44+// The quoted literal right after the key, an optional `=` or `:` and any spaces.45+function literalAfter(line: string, from: number): string | null {46+ let at = from;47+ while (at < line.length && " \t=:".includes(line[at] as string)) at++;48+ const quote = line[at];49+ if (quote !== '"' && quote !== "'") return null;50+ const end = line.indexOf(quote, at + 1);51+ return end < 0 ? null : line.slice(at + 1, end);52+}53+54+// CFBundleShortVersionString: its value is in the <string> after the key, not on the key's line.55+function plistVersion(text: string | null): string | null {56+ const at = text?.indexOf("<key>CFBundleShortVersionString</key>") ?? -1;57+ if (!text || at < 0) return null;58+ const rest = text.slice(at);59+ const open = rest.indexOf("<string>");60+ const close = rest.indexOf("</string>");61+ if (open < 0 || close <= open + "<string>".length) return null;62+ return rest.slice(open + "<string>".length, close).trim() || null;63+}64+65+// `versionName` assigned a quoted literal, Kotlin (`= "1.2"`) or Groovy (`"1.2"`); comments and66+// `versionNameSuffix` are not it.67+function gradleVersion(text: string | null): string | null {68+ for (const line of text?.split(/\r?\n/) ?? []) {69+ const trimmed = line.replace(/^[ \t]+/, "");70+ if (trimmed === "" || /^(\/\/|\/\*|\*)/.test(trimmed)) continue;71+ if (!/^versionName[ =]/.test(trimmed)) continue;72+ const value = literalAfter(trimmed, "versionName".length);73+ if (value !== null) return value;74+ }75+ return null;76+}src/mirror/store.ts+20 −1
@@ -5,6 +5,7 @@55 import type { EventRecord, ReleaseRecord, SaveRecord } from "../bucket/repo.ts";66 import { fileFacts } from "../describe/describe.ts";77 import { isTestPath } from "../describe/links.ts";8+import { declaredVersion } from "../describe/version.ts";89 910 // Koh 3's schema (§5.1, `src/storage/storage.v`) minus `file_cache`, which only a working tree needs,1011 // plus `releases`: each version's release.json (§11.2) as read from the bucket, since that is bucket@@ -106,6 +107,9 @@106107 /** Marks a hidden entry as a name matched at any depth rather than a path from the root. */107108 export const ANY_DEPTH = "**/";108109 110+// No project declares its version in a file larger than this.111+const MAX_MANIFEST = 1024 * 1024;112+109113 // A recount inflates every file at the newest save; a file this large is data, not lines of code.110114 const MAX_COUNTED = 8 * 1024 * 1024;111115 @@ -139,7 +143,7 @@139143 // Vendored code is someone else's: counting Koh 3's bundled SQLite made it read as 290k lines of C.140144 const VENDORED = /(^|\/)(vendor|third_party|node_modules)\//;141145 // Bumped when what the recount records changes, so every mirror recounts once when it opens.142-const STATS_RULE = "source-only, known objects";146+const STATS_RULE = "source-only, known objects, declared version";143147 144148 const open = new Map<string, Database>();145149 @@ -514,6 +518,20 @@514518 })();515519 }516520 521+// A file at a save as text, if it is there and small enough to be a manifest.522+function smallFileText(db: Database, save: string, path: string): string | null {523+ const row = db524+ .query<{ size: number; data: Uint8Array }, [string, string]>(525+ `SELECT o.size, o.data526+ FROM save_files f527+ JOIN objects o ON o.hash = f.hash528+ WHERE f.save_id = ?529+ AND f.path = ?`,530+ )531+ .get(save, path);532+ return row && row.size <= MAX_MANIFEST ? new TextDecoder().decode(inflateSync(row.data)) : null;533+}534+517535 // Lines and languages never change between syncs, so they are counted once here, not per view. They518536 // come from the newest real save, not `head`: a checkpoint's files are not public history (§21.4).519537 // Lines are source lines: files in a known language, tests left out; docs and data never count.@@ -556,6 +574,7 @@556574 setState.run("language", languages[0]?.[0] ?? "");557575 setState.run("languages", JSON.stringify(languages));558576 setState.run("stats_save", save);577+ setState.run("declared_version", declaredVersion((path) => smallFileText(db, save, path)) ?? "");559578 // §21.1's "the library knows N things": the file contents any real save refers to, which the560579 // front page unites across public repos. From `objects`, probing save_files_hash: a DISTINCT561580 // over save_files reads saves × files rows.src/pages/repo.ts+7 −3
@@ -12,6 +12,7 @@1212 saveByPrefix,1313 timeline,1414 } from "../mirror/queries.ts";15+import { mirrorState } from "../mirror/store.ts";1516 import { visibleReleases } from "../releases/releases.ts";1617 import { setting } from "../server/db.ts";1718 import { atlasPanel, atlasPickerRows, atlasSection } from "./atlas.ts";@@ -132,10 +133,13 @@132133 const steal = `koh steal ${ctx.domain}/${name}`;133134 const stats = repoStats(repo.mirror);134135 const handle = setting(ctx.kepr, "handle");135- // §21.4: the version is the newest release; with none, the header shows no version.136- const newest = visibleReleases(ctx.kepr, repo.mirror, name)[0];136+ // §21.4: the version is the newest release; with none, the version the project declares, by137+ // Koh's rule, at its newest save.138+ const version =139+ visibleReleases(ctx.kepr, repo.mirror, name)[0]?.version ||140+ mirrorState(repo.mirror, "declared_version");137141 const meta = [138- newest && `v${newest.version}`,142+ version && `v${version}`,139143 plural(stats.saves, "save"),140144 stats.lines > 0 && plural(stats.lines, "line"),141145 stats.updated !== null && `updated ${relativeTime(stats.updated)}`,tests/version.test.ts+74 −0
@@ -0,0 +1,74 @@1+// Covers the version a repo's header shows without a build: what the project declares, by Koh's2+// own rule (Koh 3's src/build/version.v), and a build's version over it.3+4+import { describe, expect, test } from "bun:test";5+import { writeFile } from "node:fs/promises";6+import { join } from "node:path";7+import { openBucket } from "../src/bucket/reader.ts";8+import { syncAll } from "../src/bucket/sync.ts";9+import { declaredVersion } from "../src/describe/version.ts";10+import { instance } from "./browser.ts";11+import { makeRepos, writeRelease } from "./fixture.ts";12+13+const from = (files: Record<string, string>) => declaredVersion((path) => files[path] ?? null);14+15+describe("the declared version", () => {16+ test("is the first source that answers, VERSION first", () => {17+ expect(from({ VERSION: " 2.0.1\n", "package.json": '{ "version": "1.0.0" }' })).toBe("2.0.1");18+ expect(from({ "package.json": '{\n "name": "x",\n "version": "3.0.199",\n}' })).toBe(19+ "3.0.199",20+ );21+ expect(22+ from({ "build.zig.zon": '.{\n .version = "0.14.1",\n}', "Cargo.toml": 'version = "9"' }),23+ ).toBe("0.14.1");24+ expect(from({ "v.mod": "Module {\n\tname: 'koh'\n\tversion: '3.2.27'\n}" })).toBe("3.2.27");25+ expect(from({ "Cargo.toml": '[package]\nversion = "0.5.0"' })).toBe("0.5.0");26+ expect(27+ from({28+ "Info.plist": "<key>CFBundleShortVersionString</key>\n\t<string> 1.4 </string>",29+ }),30+ ).toBe("1.4");31+ });32+33+ test("reads Gradle's versionName literally, never a suffix, comment or variable", () => {34+ const gradle = (text: string) => from({ "app/build.gradle.kts": text });35+ expect(gradle('versionNameSuffix = "-dev"\nversionName = "2.17.43"')).toBe("2.17.43");36+ expect(gradle('// versionName = "1.0"\nversionName "5.2.62"')).toBe("5.2.62");37+ expect(gradle("versionName = libs.versions.app.get()")).toBeNull();38+ });39+40+ test("skips a source with no literal, and is none when the answer is not a version", () => {41+ expect(42+ from({ "package.json": '"version": pkg.version', "Cargo.toml": 'version = "1.1"' }),43+ ).toBe("1.1");44+ expect(from({ VERSION: "not a version!", "package.json": '"version": "1.0.0"' })).toBeNull();45+ expect(from({})).toBeNull();46+ });47+});48+49+test("a repo's header shows its declared version, and a build's over it", async () => {50+ const repos = await makeRepos(["app"]);51+ await writeFile(52+ join(repos.bucketDir, "..", "work", "app", "package.json"),53+ '{\n "name": "app",\n "version": "1.2.3"\n}\n',54+ );55+ repos.koh("app", "save", "declare a version");56+ repos.koh("app", "offer");57+ const inst = await instance(repos.bucketDir);58+ inst.kepr.run("UPDATE repos SET public = 1");59+ const meta = async () =>60+ /<p class="repo-meta">([^<]*)<\/p>/.exec(await (await inst.app.request("/app")).text())?.[1];61+ expect(await meta()).toStartWith("v1.2.3 · ");62+ const head = JSON.parse(repos.koh("app", "view", "--json")).head_id as string;63+ await writeRelease(repos.bucketDir, "app", {64+ version: "2.0.0",65+ save: head,66+ files: [{ platform: "linux-x86_64", name: "app", bytes: new Uint8Array([1]) }],67+ });68+ await syncAll({69+ bucket: openBucket({ kind: "folder", path: repos.bucketDir }),70+ kepr: inst.kepr,71+ mirrorDir: join(inst.dataDir, "mirror"),72+ });73+ expect(await meta()).toStartWith("v2.0.0 · ");74+});