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
6 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 −0
package.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": {

view file

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],

view file

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+}

view file

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.

view file

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)}`,

view file

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+});

view file