57c99012f4b4

match Koh whitespace and line endings when reading declared versions (v3.0.203)

time
2026-10-03 14:02 UTC · 1 week ago
machine
Junes-MacBook-Pro.local
agent
gpt-6.1-sol
session
review declared repo version
parent
65b4734faa48
4 files package.json+1 −1src/describe/version.ts+5 −4src/mirror/store.ts+4 −2tests/version.test.ts+39 −0
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.202",3+	"version": "3.0.203",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

src/describe/version.ts+5 −4
@@ -10,11 +10,12 @@1010 	"build.gradle",1111 ];1212 const VERSION = /^[A-Za-z0-9.-]{1,32}$/;13+const ASCII_SPACE = /^[ \n\t\v\f\r]+|[ \n\t\v\f\r]+$/g;1314 1415 /** The declared version, reading files at one save through `read` (null for a missing file). */1516 export function declaredVersion(read: (path: string) => string | null): string | null {1617 	const sources: (() => string | null)[] = [17-		() => read("VERSION")?.trim() || null,18+		() => read("VERSION")?.replace(ASCII_SPACE, "") || null,1819 		() => lineVersion(read("build.zig.zon"), ".version"),1920 		() => lineVersion(read("v.mod"), "version"),2021 		() => lineVersion(read("package.json"), '"version"'),@@ -31,7 +32,7 @@3132 3233 // The value of the first line the key starts that assigns a quoted literal.3334 function lineVersion(text: string | null, key: string): string | null {34-	for (const line of text?.split(/\r?\n/) ?? []) {35+	for (const line of text?.split(/\r\n|[\r\n]/) ?? []) {3536 		const trimmed = line.replace(/^[ \t]+/, "");3637 		if (trimmed === "" || trimmed.startsWith("//") || trimmed.startsWith("#")) continue;3738 		if (!trimmed.startsWith(key)) continue;@@ -59,13 +60,13 @@5960 	const open = rest.indexOf("<string>");6061 	const close = rest.indexOf("</string>");6162 	if (open < 0 || close <= open + "<string>".length) return null;62-	return rest.slice(open + "<string>".length, close).trim() || null;63+	return rest.slice(open + "<string>".length, close).replace(ASCII_SPACE, "") || null;6364 }6465 6566 // `versionName` assigned a quoted literal, Kotlin (`= "1.2"`) or Groovy (`"1.2"`); comments and6667 // `versionNameSuffix` are not it.6768 function gradleVersion(text: string | null): string | null {68-	for (const line of text?.split(/\r?\n/) ?? []) {69+	for (const line of text?.split(/\r\n|[\r\n]/) ?? []) {6970 		const trimmed = line.replace(/^[ \t]+/, "");7071 		if (trimmed === "" || /^(\/\/|\/\*|\*)/.test(trimmed)) continue;7172 		if (!/^versionName[ =]/.test(trimmed)) continue;

view file

src/mirror/store.ts+4 −2
@@ -143,7 +143,7 @@143143 // Vendored code is someone else's: counting Koh 3's bundled SQLite made it read as 290k lines of C.144144 const VENDORED = /(^|\/)(vendor|third_party|node_modules)\//;145145 // Bumped when what the recount records changes, so every mirror recounts once when it opens.146-const STATS_RULE = "source-only, known objects, declared version";146+const STATS_RULE = "source-only, known objects, declared version (ASCII whitespace, BOM)";147147 148148 const open = new Map<string, Database>();149149 @@ -529,7 +529,9 @@529529 			AND f.path = ?`,530530 		)531531 		.get(save, path);532-	return row && row.size <= MAX_MANIFEST ? new TextDecoder().decode(inflateSync(row.data)) : null;532+	return row && row.size <= MAX_MANIFEST533+		? new TextDecoder("utf-8", { ignoreBOM: true }).decode(inflateSync(row.data))534+		: null;533535 }534536 535537 // Lines and languages never change between syncs, so they are counted once here, not per view. They

view file

tests/version.test.ts+39 −0
@@ -13,6 +13,30 @@1313 const from = (files: Record<string, string>) => declaredVersion((path) => files[path] ?? null);1414 1515 describe("the declared version", () => {16+	test("reads CR, LF and CRLF lines as Koh does", () => {17+		for (const ending of ["\r", "\n", "\r\n"]) {18+			expect(from({ "v.mod": `Module {${ending}version: '1.2.3'${ending}}` })).toBe("1.2.3");19+			expect(20+				from({ "app/build.gradle.kts": `android {${ending}versionName = "1.2.3"${ending}}` }),21+			).toBe("1.2.3");22+		}23+	});24+25+	test("trims only Koh's ASCII whitespace and refuses an invalid first answer", () => {26+		expect(from({ VERSION: " \n\t\v\f\r1.2.3 \n\t\v\f\r" })).toBe("1.2.3");27+		for (const space of ["\u00a0", "\ufeff"]) {28+			expect(29+				from({ VERSION: `${space}1.2.3${space}`, "Cargo.toml": 'version = "9.0.0"' }),30+			).toBeNull();31+			expect(32+				from({33+					"Info.plist": `<key>CFBundleShortVersionString</key><string>${space}1.2.3${space}</string>`,34+					"app/build.gradle.kts": 'versionName = "9.0.0"',35+				}),36+			).toBeNull();37+		}38+	});39+1640 	test("is the first source that answers, VERSION first", () => {1741 		expect(from({ VERSION: " 2.0.1\n", "package.json": '{ "version": "1.0.0" }' })).toBe("2.0.1");1842 		expect(from({ "package.json": '{\n  "name": "x",\n  "version": "3.0.199",\n}' })).toBe(@@ -71,4 +95,19 @@7195 		mirrorDir: join(inst.dataDir, "mirror"),7296 	});7397 	expect(await meta()).toStartWith("v2.0.0 · ");98+});99+100+test("a BOM in a saved VERSION is preserved and refuses a lower-priority version", async () => {101+	const repos = await makeRepos(["app"]);102+	const work = join(repos.bucketDir, "..", "work", "app");103+	await writeFile(join(work, "VERSION"), "\ufeff1.2.3");104+	await writeFile(join(work, "Cargo.toml"), 'version = "9.0.0"');105+	repos.koh("app", "save", "declare a version with a BOM");106+	repos.koh("app", "offer");107+	const inst = await instance(repos.bucketDir);108+	inst.kepr.run("UPDATE repos SET public = 1");109+	const page = await (await inst.app.request("/app")).text();110+	const meta = /<p class="repo-meta">([^<]*)<\/p>/.exec(page)?.[1];111+	expect(meta).toBeDefined();112+	expect(meta).not.toStartWith("v");74113 });

view file