From 867337deed972b6f5b13691f4adddbbf7579ee23 Mon Sep 17 00:00:00 2001 From: DragunovX16 <75143900+DragunovX16@users.noreply.github.com> Date: Fri, 3 Jul 2026 03:08:28 +0200 Subject: [PATCH 1/3] feat(miner-discovery): add tolerant MinerGoalSpec parser --- package-lock.json | 3 + packages/gittensory-engine/README.md | 6 +- packages/gittensory-engine/package.json | 3 + packages/gittensory-engine/src/index.ts | 3 + .../gittensory-engine/src/miner-goal-spec.ts | 185 +++++++++++++++- .../test/miner-goal-spec-parser.test.ts | 148 +++++++++++++ test/unit/mcp-cli-packets.test.ts | 3 +- test/unit/miner-goal-spec-parser.test.ts | 206 ++++++++++++++++++ 8 files changed, 546 insertions(+), 11 deletions(-) create mode 100644 packages/gittensory-engine/test/miner-goal-spec-parser.test.ts create mode 100644 test/unit/miner-goal-spec-parser.test.ts diff --git a/package-lock.json b/package-lock.json index 5296f75ee2..685221df87 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15486,6 +15486,9 @@ "name": "@jsonbored/gittensory-engine", "version": "0.1.0", "license": "AGPL-3.0-only", + "dependencies": { + "yaml": "^2.9.0" + }, "devDependencies": { "@types/node": "^22.10.0", "typescript": "^5.6.3" diff --git a/packages/gittensory-engine/README.md b/packages/gittensory-engine/README.md index 43582fc2a2..3d4ae77e2c 100644 --- a/packages/gittensory-engine/README.md +++ b/packages/gittensory-engine/README.md @@ -68,4 +68,8 @@ missing, or empty policy text stays allowed so discovery does not invent a ban. `MinerGoalSpec` is the type surface for a repo's `.gittensory-miner.yml` (miner-side analogue of `.gittensory.yml`). `DEFAULT_MINER_GOAL_SPEC` is the safe default a repo with no file behaves as — minable (`minerEnabled: true`, an -explicit opt-out), no path/label preferences, one concurrent claim, `neutral` discovery. Parsing is a separate module. +explicit opt-out), no path/label preferences, one concurrent claim, `neutral` discovery. + +`parseMinerGoalSpec(raw)` and `parseMinerGoalSpecContent(content)` are the tolerant parser pair for that file. They +never throw on malformed JSON/YAML; instead they return `{ present, spec, warnings }`, where `spec` is normalized to +safe defaults and `warnings` explains any dropped or invalid fields. diff --git a/packages/gittensory-engine/package.json b/packages/gittensory-engine/package.json index ea44365380..e89669b237 100644 --- a/packages/gittensory-engine/package.json +++ b/packages/gittensory-engine/package.json @@ -40,6 +40,9 @@ "build": "tsc -p tsconfig.json", "test": "npm run build && tsc -p tsconfig.test.json && node --test \"dist-test/**/*.test.js\"" }, + "dependencies": { + "yaml": "^2.9.0" + }, "devDependencies": { "@types/node": "^22.10.0", "typescript": "^5.6.3" diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index 77040ec70c..bfd54a096f 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -20,6 +20,9 @@ export { } from "./ai-policy-map.js"; export { DEFAULT_MINER_GOAL_SPEC, + parseMinerGoalSpec, + parseMinerGoalSpecContent, type MinerGoalSpec, type MinerIssueDiscoveryPolicy, + type ParsedMinerGoalSpec, } from "./miner-goal-spec.js"; diff --git a/packages/gittensory-engine/src/miner-goal-spec.ts b/packages/gittensory-engine/src/miner-goal-spec.ts index 973bdae566..0f72683935 100644 --- a/packages/gittensory-engine/src/miner-goal-spec.ts +++ b/packages/gittensory-engine/src/miner-goal-spec.ts @@ -1,12 +1,11 @@ -// MinerGoalSpec (#2293). The type surface for `.gittensory-miner.yml` — the per-repo config a maintainer/repo-owner -// drops in to tell an autonomous miner what to look for and how to behave when targeting their repo. This is the -// MINER-side analogue of the review-side `.gittensory.yml` focus manifest (see `src/signals/focus-manifest.ts`'s -// `FocusManifest`): a small typed config object paired with an explicit safe-defaults constant. -// -// This module is TYPES ONLY — no parsing, no IO. The parser (validation + safe-default coercion of raw YAML) is a -// separate follow-up issue; keeping the shape small here is deliberate, because it is easy to add a field later and -// painful to remove one contributors already rely on. Field names/semantics that overlap the review side are -// carried over verbatim from `.gittensory.yml` so the two manifests stay obviously paired. +import { parse as parseYaml } from "yaml"; + +// MinerGoalSpec (#2293 / #2301). The type surface for `.gittensory-miner.yml` — the per-repo config a +// maintainer/repo-owner drops in to tell an autonomous miner what to look for and how to behave when targeting +// their repo. This is the MINER-side analogue of the review-side `.gittensory.yml` focus manifest (see +// `src/signals/focus-manifest.ts`'s `FocusManifest`): a small typed config object paired with explicit +// safe-defaults and a tolerant parser that degrades malformed input to those defaults with warnings rather than +// throwing. /** How strongly opening discovery issues is encouraged for this repo. Mirrors the review-side policy vocabulary. */ export type MinerIssueDiscoveryPolicy = "encouraged" | "neutral" | "discouraged"; @@ -47,6 +46,15 @@ export type MinerGoalSpec = { issueDiscoveryPolicy: MinerIssueDiscoveryPolicy; }; +/** The tolerant parser result for `.gittensory-miner.yml`: the normalized spec plus parse warnings and whether the + * file actually expressed any non-default goal fields. Mirrors `parseFocusManifest`'s present/warnings pattern + * without forcing metadata onto downstream consumers that only need the config itself. */ +export type ParsedMinerGoalSpec = { + present: boolean; + spec: MinerGoalSpec; + warnings: string[]; +}; + /** * The safe defaults applied when a field is absent from `.gittensory-miner.yml` (or the file itself is missing). * Every value here matches the "Default: X" documented on its field above. Analogous to the defaults constant that @@ -64,3 +72,162 @@ export const DEFAULT_MINER_GOAL_SPEC: Readonly = Object.freeze({ maxConcurrentClaims: 1, issueDiscoveryPolicy: "neutral", }); + +const MAX_MINER_GOAL_SPEC_BYTES = 32_768; +const MAX_LIST_ITEMS = 100; +const MAX_ITEM_LENGTH = 256; + +function cloneDefaultMinerGoalSpec(): MinerGoalSpec { + return { + ...DEFAULT_MINER_GOAL_SPEC, + wantedPaths: [...DEFAULT_MINER_GOAL_SPEC.wantedPaths], + blockedPaths: [...DEFAULT_MINER_GOAL_SPEC.blockedPaths], + preferredLabels: [...DEFAULT_MINER_GOAL_SPEC.preferredLabels], + }; +} + +function emptyMinerGoalSpec(warnings: string[] = []): ParsedMinerGoalSpec { + return { present: false, spec: cloneDefaultMinerGoalSpec(), warnings }; +} + +function normalizeStringList(value: unknown, field: string, warnings: string[]): string[] { + if (value === undefined || value === null) return []; + if (!Array.isArray(value)) { + warnings.push(`MinerGoalSpec field "${field}" must be a list; ignoring a ${typeof value} value.`); + return []; + } + const result: string[] = []; + for (const entry of value) { + if (typeof entry !== "string") { + warnings.push(`MinerGoalSpec field "${field}" skipped a non-string entry.`); + continue; + } + const trimmed = entry.trim(); + if (!trimmed) continue; + let normalized = trimmed; + if (normalized.length > MAX_ITEM_LENGTH) { + warnings.push(`MinerGoalSpec field "${field}" truncated an over-long entry.`); + normalized = normalized.slice(0, MAX_ITEM_LENGTH); + } + if (!result.includes(normalized)) result.push(normalized); + if (result.length >= MAX_LIST_ITEMS) { + warnings.push(`MinerGoalSpec field "${field}" exceeded ${MAX_LIST_ITEMS} entries; extra entries ignored.`); + break; + } + } + return result; +} + +function normalizeBoolean(value: unknown, field: string, fallback: boolean, warnings: string[]): boolean { + if (value === undefined || value === null) return fallback; + if (typeof value === "boolean") return value; + warnings.push(`MinerGoalSpec field "${field}" must be a boolean; falling back to ${String(fallback)}.`); + return fallback; +} + +function normalizeIssueDiscoveryPolicy( + value: unknown, + field: string, + fallback: MinerIssueDiscoveryPolicy, + warnings: string[], +): MinerIssueDiscoveryPolicy { + if (value === undefined || value === null) return fallback; + if (value === "encouraged" || value === "neutral" || value === "discouraged") return value; + warnings.push( + `MinerGoalSpec field "${field}" must be one of encouraged, neutral, discouraged; falling back to "${fallback}".`, + ); + return fallback; +} + +function normalizePositiveInteger(value: unknown, field: string, fallback: number, warnings: string[]): number { + if (value === undefined || value === null) return fallback; + if (typeof value !== "number" || !Number.isFinite(value)) { + warnings.push(`MinerGoalSpec field "${field}" must be a positive whole number; falling back to ${fallback}.`); + return fallback; + } + const normalized = Math.floor(value); + if (normalized >= 1) return normalized; + warnings.push(`MinerGoalSpec field "${field}" must be >= 1 after flooring; falling back to ${fallback}.`); + return fallback; +} + +function hasConfiguredGoalFields(spec: MinerGoalSpec): boolean { + return ( + spec.minerEnabled !== DEFAULT_MINER_GOAL_SPEC.minerEnabled || + spec.wantedPaths.length > 0 || + spec.blockedPaths.length > 0 || + spec.preferredLabels.length > 0 || + spec.maxConcurrentClaims !== DEFAULT_MINER_GOAL_SPEC.maxConcurrentClaims || + spec.issueDiscoveryPolicy !== DEFAULT_MINER_GOAL_SPEC.issueDiscoveryPolicy + ); +} + +/** + * Tolerantly normalize an already-parsed `.gittensory-miner.yml` object into a {@link ParsedMinerGoalSpec}. + * Never throws: malformed shapes degrade to safe defaults and accumulate warnings so callers can surface + * "your miner goal spec had problems" without hard-failing a run. + */ +export function parseMinerGoalSpec(raw: unknown): ParsedMinerGoalSpec { + if (raw === undefined || raw === null) return emptyMinerGoalSpec(); + if (typeof raw !== "object" || Array.isArray(raw)) { + return emptyMinerGoalSpec([ + "MinerGoalSpec must be a mapping of fields; ignoring malformed config and falling back to safe defaults.", + ]); + } + const record = raw as Record; + const warnings: string[] = []; + const spec: MinerGoalSpec = { + minerEnabled: normalizeBoolean( + record.minerEnabled, + "minerEnabled", + DEFAULT_MINER_GOAL_SPEC.minerEnabled, + warnings, + ), + wantedPaths: normalizeStringList(record.wantedPaths, "wantedPaths", warnings), + blockedPaths: normalizeStringList(record.blockedPaths, "blockedPaths", warnings), + preferredLabels: normalizeStringList(record.preferredLabels, "preferredLabels", warnings), + maxConcurrentClaims: normalizePositiveInteger( + record.maxConcurrentClaims, + "maxConcurrentClaims", + DEFAULT_MINER_GOAL_SPEC.maxConcurrentClaims, + warnings, + ), + issueDiscoveryPolicy: normalizeIssueDiscoveryPolicy( + record.issueDiscoveryPolicy, + "issueDiscoveryPolicy", + DEFAULT_MINER_GOAL_SPEC.issueDiscoveryPolicy, + warnings, + ), + }; + if (!hasConfiguredGoalFields(spec)) { + warnings.push("MinerGoalSpec contained no recognized non-default goal fields; falling back to safe defaults."); + return { present: false, spec: cloneDefaultMinerGoalSpec(), warnings }; + } + return { present: true, spec, warnings }; +} + +/** + * Parse raw `.gittensory-miner.yml` file content (JSON or YAML). Malformed content degrades to an absent + * goal spec with a warning rather than throwing, mirroring `parseFocusManifestContent`. + */ +export function parseMinerGoalSpecContent(content: string | null | undefined): ParsedMinerGoalSpec { + if (content === undefined || content === null || content.trim() === "") return emptyMinerGoalSpec(); + if (content.length > MAX_MINER_GOAL_SPEC_BYTES) { + return emptyMinerGoalSpec([ + `MinerGoalSpec content exceeded ${MAX_MINER_GOAL_SPEC_BYTES} bytes; ignoring it and falling back to safe defaults.`, + ]); + } + const trimmed = content.trim(); + const looksLikeJson = trimmed.startsWith("{") || trimmed.startsWith("["); + let parsed: unknown; + try { + parsed = looksLikeJson ? JSON.parse(trimmed) : parseYaml(trimmed); + } catch { + return emptyMinerGoalSpec([ + looksLikeJson + ? "MinerGoalSpec content was not valid JSON; ignoring it and falling back to safe defaults." + : "MinerGoalSpec content was not valid YAML; ignoring it and falling back to safe defaults.", + ]); + } + return parseMinerGoalSpec(parsed); +} diff --git a/packages/gittensory-engine/test/miner-goal-spec-parser.test.ts b/packages/gittensory-engine/test/miner-goal-spec-parser.test.ts new file mode 100644 index 0000000000..bc57135d11 --- /dev/null +++ b/packages/gittensory-engine/test/miner-goal-spec-parser.test.ts @@ -0,0 +1,148 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + DEFAULT_MINER_GOAL_SPEC, + parseMinerGoalSpec, + parseMinerGoalSpecContent, +} from "../dist/index.js"; + +test("barrel: the public entrypoint re-exports the MinerGoalSpec parser API", () => { + assert.equal(typeof parseMinerGoalSpec, "function"); + assert.equal(typeof parseMinerGoalSpecContent, "function"); +}); + +test("parseMinerGoalSpec: missing raw input returns an absent safe-default spec with no warnings", () => { + const parsed = parseMinerGoalSpec(undefined); + assert.equal(parsed.present, false); + assert.deepEqual(parsed.spec, DEFAULT_MINER_GOAL_SPEC); + assert.deepEqual(parsed.warnings, []); +}); + +test("parseMinerGoalSpec: a non-mapping raw value degrades to safe defaults with a warning", () => { + const parsed = parseMinerGoalSpec(["not", "a", "mapping"]); + assert.equal(parsed.present, false); + assert.deepEqual(parsed.spec, DEFAULT_MINER_GOAL_SPEC); + assert.match(parsed.warnings.join(" "), /must be a mapping/i); +}); + +test("parseMinerGoalSpec: valid raw config normalizes every field and keeps non-default input present", () => { + const parsed = parseMinerGoalSpec({ + minerEnabled: false, + wantedPaths: ["src/**", " src/** ", "", "docs/**"], + blockedPaths: ["dist/**"], + preferredLabels: ["help wanted", "help wanted", "gittensor:feature"], + maxConcurrentClaims: 2.9, + issueDiscoveryPolicy: "encouraged", + }); + + assert.equal(parsed.present, true); + assert.deepEqual(parsed.spec, { + minerEnabled: false, + wantedPaths: ["src/**", "docs/**"], + blockedPaths: ["dist/**"], + preferredLabels: ["help wanted", "gittensor:feature"], + maxConcurrentClaims: 2, + issueDiscoveryPolicy: "encouraged", + }); + assert.deepEqual(parsed.warnings, []); +}); + +test("parseMinerGoalSpec: malformed fields fall back independently with targeted warnings", () => { + const longEntry = "x".repeat(300); + const parsed = parseMinerGoalSpec({ + minerEnabled: "yes", + wantedPaths: "src/**", + blockedPaths: [123, " dist/** ", "", longEntry], + preferredLabels: [false, "bugfix"], + maxConcurrentClaims: 0.9, + issueDiscoveryPolicy: "always", + }); + + assert.equal(parsed.present, true); + assert.deepEqual(parsed.spec, { + minerEnabled: true, + wantedPaths: [], + blockedPaths: ["dist/**", longEntry.slice(0, 256)], + preferredLabels: ["bugfix"], + maxConcurrentClaims: 1, + issueDiscoveryPolicy: "neutral", + }); + const warningText = parsed.warnings.join(" "); + assert.match(warningText, /minerEnabled/i); + assert.match(warningText, /wantedPaths/i); + assert.match(warningText, /blockedPaths/i); + assert.match(warningText, /preferredLabels/i); + assert.match(warningText, /maxConcurrentClaims/i); + assert.match(warningText, /issueDiscoveryPolicy/i); + assert.match(warningText, /truncated an over-long entry/i); +}); + +test("parseMinerGoalSpec: unknown-only or default-only content stays absent with a fallback warning", () => { + const unknownOnly = parseMinerGoalSpec({ mystery: true }); + assert.equal(unknownOnly.present, false); + assert.deepEqual(unknownOnly.spec, DEFAULT_MINER_GOAL_SPEC); + assert.match(unknownOnly.warnings.join(" "), /no recognized non-default goal fields/i); + + const explicitDefaults = parseMinerGoalSpec({ + minerEnabled: true, + wantedPaths: [], + blockedPaths: [], + preferredLabels: [], + maxConcurrentClaims: 1, + issueDiscoveryPolicy: "neutral", + }); + assert.equal(explicitDefaults.present, false); + assert.deepEqual(explicitDefaults.spec, DEFAULT_MINER_GOAL_SPEC); + assert.match(explicitDefaults.warnings.join(" "), /no recognized non-default goal fields/i); +}); + +test("parseMinerGoalSpecContent: empty content returns an absent default spec", () => { + for (const value of ["", " ", null, undefined]) { + const parsed = parseMinerGoalSpecContent(value); + assert.equal(parsed.present, false); + assert.deepEqual(parsed.spec, DEFAULT_MINER_GOAL_SPEC); + assert.deepEqual(parsed.warnings, []); + } +}); + +test("parseMinerGoalSpecContent: parses valid JSON and YAML content", () => { + const json = parseMinerGoalSpecContent( + JSON.stringify({ + wantedPaths: ["src/**"], + preferredLabels: ["gittensor:feature"], + maxConcurrentClaims: 3, + }), + ); + assert.equal(json.present, true); + assert.deepEqual(json.spec.wantedPaths, ["src/**"]); + assert.deepEqual(json.spec.preferredLabels, ["gittensor:feature"]); + assert.equal(json.spec.maxConcurrentClaims, 3); + + const yaml = parseMinerGoalSpecContent( + "minerEnabled: false\nblockedPaths:\n - dist/**\nissueDiscoveryPolicy: discouraged\n", + ); + assert.equal(yaml.present, true); + assert.equal(yaml.spec.minerEnabled, false); + assert.deepEqual(yaml.spec.blockedPaths, ["dist/**"]); + assert.equal(yaml.spec.issueDiscoveryPolicy, "discouraged"); +}); + +test("parseMinerGoalSpecContent: malformed JSON and YAML warn instead of throwing", () => { + const badJson = parseMinerGoalSpecContent("{ invalid json"); + assert.equal(badJson.present, false); + assert.match(badJson.warnings.join(" "), /not valid JSON/i); + + const badYaml = parseMinerGoalSpecContent("wantedPaths: [unterminated"); + assert.equal(badYaml.present, false); + assert.match(badYaml.warnings.join(" "), /not valid YAML/i); +}); + +test("parseMinerGoalSpecContent: non-mapping parsed content and oversized content degrade safely", () => { + const notMapping = parseMinerGoalSpecContent('["src/**"]'); + assert.equal(notMapping.present, false); + assert.match(notMapping.warnings.join(" "), /must be a mapping/i); + + const oversized = parseMinerGoalSpecContent(`wantedPaths:\n - ${"x".repeat(40_000)}\n`); + assert.equal(oversized.present, false); + assert.match(oversized.warnings.join(" "), /exceeded 32768 bytes/i); +}); diff --git a/test/unit/mcp-cli-packets.test.ts b/test/unit/mcp-cli-packets.test.ts index b252ba881a..97edf12c03 100644 --- a/test/unit/mcp-cli-packets.test.ts +++ b/test/unit/mcp-cli-packets.test.ts @@ -311,11 +311,12 @@ describe("gittensory-mcp CLI — packets", () => { GITTENSORY_API_URL: url, GITTENSORY_TOKEN: "session-token", GITTENSORY_CONFIG_DIR: tempDir, + GITTENSORY_API_TIMEOUT_MS: "3000", }, ), ).rejects.toThrow("Refusing to print unsafe public packet markdown from the server."); } - }, 30000); + }, 45000); it("sends bounded structured validation summaries without local logs", async () => { tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-")); diff --git a/test/unit/miner-goal-spec-parser.test.ts b/test/unit/miner-goal-spec-parser.test.ts new file mode 100644 index 0000000000..2bf956ad68 --- /dev/null +++ b/test/unit/miner-goal-spec-parser.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_MINER_GOAL_SPEC, + parseMinerGoalSpec, + parseMinerGoalSpecContent, +} from "../../packages/gittensory-engine/src/index"; + +describe("MinerGoalSpec parser (#2301)", () => { + it("re-exports the parser API from the engine barrel", () => { + expect(typeof parseMinerGoalSpec).toBe("function"); + expect(typeof parseMinerGoalSpecContent).toBe("function"); + }); + + it("treats missing raw input as an absent safe-default spec", () => { + for (const raw of [undefined, null]) { + expect(parseMinerGoalSpec(raw)).toEqual({ + present: false, + spec: DEFAULT_MINER_GOAL_SPEC, + warnings: [], + }); + } + }); + + it.each([ + "not a mapping", + ["still", "not", "a", "mapping"], + ])("degrades malformed top-level raw values to safe defaults: %j", (raw) => { + const parsed = parseMinerGoalSpec(raw); + expect(parsed.present).toBe(false); + expect(parsed.spec).toEqual(DEFAULT_MINER_GOAL_SPEC); + expect(parsed.warnings.join(" ")).toMatch(/must be a mapping/i); + }); + + it("normalizes valid goal fields, dedupes strings, truncates long entries, and floors claims", () => { + const longEntry = "x".repeat(300); + const parsed = parseMinerGoalSpec({ + minerEnabled: false, + wantedPaths: ["src/**", " src/** ", "", "docs/**"], + blockedPaths: ["dist/**", longEntry], + preferredLabels: ["help wanted", "help wanted", "gittensor:feature"], + maxConcurrentClaims: 2.9, + issueDiscoveryPolicy: "encouraged", + }); + + expect(parsed).toEqual({ + present: true, + spec: { + minerEnabled: false, + wantedPaths: ["src/**", "docs/**"], + blockedPaths: ["dist/**", longEntry.slice(0, 256)], + preferredLabels: ["help wanted", "gittensor:feature"], + maxConcurrentClaims: 2, + issueDiscoveryPolicy: "encouraged", + }, + warnings: ['MinerGoalSpec field "blockedPaths" truncated an over-long entry.'], + }); + }); + + it("caps oversized string lists and ignores extra entries", () => { + const wantedPaths = Array.from({ length: 101 }, (_, index) => `src/${index}.ts`); + const parsed = parseMinerGoalSpec({ wantedPaths }); + + expect(parsed.present).toBe(true); + expect(parsed.spec.wantedPaths).toHaveLength(100); + expect(parsed.spec.wantedPaths.at(0)).toBe("src/0.ts"); + expect(parsed.spec.wantedPaths.at(-1)).toBe("src/99.ts"); + expect(parsed.warnings.join(" ")).toMatch(/exceeded 100 entries/i); + }); + + it("falls back per field for invalid values without throwing", () => { + const parsed = parseMinerGoalSpec({ + minerEnabled: "yes", + wantedPaths: "src/**", + blockedPaths: [123, " dist/** "], + preferredLabels: [false, "bugfix"], + maxConcurrentClaims: "3", + issueDiscoveryPolicy: "always", + }); + + expect(parsed).toEqual({ + present: true, + spec: { + minerEnabled: true, + wantedPaths: [], + blockedPaths: ["dist/**"], + preferredLabels: ["bugfix"], + maxConcurrentClaims: 1, + issueDiscoveryPolicy: "neutral", + }, + warnings: expect.arrayContaining([ + expect.stringMatching(/minerEnabled/i), + expect.stringMatching(/wantedPaths/i), + expect.stringMatching(/blockedPaths/i), + expect.stringMatching(/preferredLabels/i), + expect.stringMatching(/maxConcurrentClaims/i), + expect.stringMatching(/issueDiscoveryPolicy/i), + ]), + }); + }); + + it("rejects claim counts below one after flooring", () => { + const parsed = parseMinerGoalSpec({ + wantedPaths: ["src/**"], + maxConcurrentClaims: 0.9, + }); + + expect(parsed.present).toBe(true); + expect(parsed.spec.maxConcurrentClaims).toBe(1); + expect(parsed.warnings.join(" ")).toMatch(/must be >= 1 after flooring/i); + }); + + it("marks unknown-only and explicit-default configs as absent", () => { + expect(parseMinerGoalSpec({ mystery: true })).toEqual({ + present: false, + spec: DEFAULT_MINER_GOAL_SPEC, + warnings: ['MinerGoalSpec contained no recognized non-default goal fields; falling back to safe defaults.'], + }); + + expect( + parseMinerGoalSpec({ + minerEnabled: true, + wantedPaths: [], + blockedPaths: [], + preferredLabels: [], + maxConcurrentClaims: 1, + issueDiscoveryPolicy: "neutral", + }), + ).toEqual({ + present: false, + spec: DEFAULT_MINER_GOAL_SPEC, + warnings: ['MinerGoalSpec contained no recognized non-default goal fields; falling back to safe defaults.'], + }); + }); + + it("parses valid JSON and YAML content", () => { + expect( + parseMinerGoalSpecContent( + JSON.stringify({ + wantedPaths: ["src/**"], + preferredLabels: ["gittensor:feature"], + maxConcurrentClaims: 3, + }), + ), + ).toEqual({ + present: true, + spec: { + ...DEFAULT_MINER_GOAL_SPEC, + wantedPaths: ["src/**"], + preferredLabels: ["gittensor:feature"], + maxConcurrentClaims: 3, + }, + warnings: [], + }); + + expect( + parseMinerGoalSpecContent( + "minerEnabled: false\nblockedPaths:\n - dist/**\nissueDiscoveryPolicy: discouraged\n", + ), + ).toEqual({ + present: true, + spec: { + ...DEFAULT_MINER_GOAL_SPEC, + minerEnabled: false, + blockedPaths: ["dist/**"], + issueDiscoveryPolicy: "discouraged", + }, + warnings: [], + }); + }); + + it("treats empty, malformed, non-mapping, and oversized content as absent", () => { + for (const value of ["", " ", null, undefined]) { + expect(parseMinerGoalSpecContent(value)).toEqual({ + present: false, + spec: DEFAULT_MINER_GOAL_SPEC, + warnings: [], + }); + } + + expect(parseMinerGoalSpecContent("{ invalid json")).toEqual({ + present: false, + spec: DEFAULT_MINER_GOAL_SPEC, + warnings: ["MinerGoalSpec content was not valid JSON; ignoring it and falling back to safe defaults."], + }); + + expect(parseMinerGoalSpecContent("wantedPaths: [unterminated")).toEqual({ + present: false, + spec: DEFAULT_MINER_GOAL_SPEC, + warnings: ["MinerGoalSpec content was not valid YAML; ignoring it and falling back to safe defaults."], + }); + + expect(parseMinerGoalSpecContent('["src/**"]')).toEqual({ + present: false, + spec: DEFAULT_MINER_GOAL_SPEC, + warnings: [ + "MinerGoalSpec must be a mapping of fields; ignoring malformed config and falling back to safe defaults.", + ], + }); + + expect(parseMinerGoalSpecContent(`wantedPaths:\n - ${"x".repeat(40_000)}\n`)).toEqual({ + present: false, + spec: DEFAULT_MINER_GOAL_SPEC, + warnings: ["MinerGoalSpec content exceeded 32768 bytes; ignoring it and falling back to safe defaults."], + }); + }); +}); From ce7c7acd8c3e88bd3f38b34555eeca198afc4e7f Mon Sep 17 00:00:00 2001 From: DragunovX16 <75143900+DragunovX16@users.noreply.github.com> Date: Fri, 3 Jul 2026 03:13:53 +0200 Subject: [PATCH 2/3] fix(miner-discovery): enforce MinerGoalSpec byte cap --- .../gittensory-engine/src/miner-goal-spec.ts | 21 +++++++++++++++++-- .../test/miner-goal-spec-parser.test.ts | 4 ++++ test/unit/miner-goal-spec-parser.test.ts | 6 ++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/gittensory-engine/src/miner-goal-spec.ts b/packages/gittensory-engine/src/miner-goal-spec.ts index 0f72683935..8130671ba7 100644 --- a/packages/gittensory-engine/src/miner-goal-spec.ts +++ b/packages/gittensory-engine/src/miner-goal-spec.ts @@ -97,6 +97,7 @@ function normalizeStringList(value: unknown, field: string, warnings: string[]): return []; } const result: string[] = []; + const seen = new Set(); for (const entry of value) { if (typeof entry !== "string") { warnings.push(`MinerGoalSpec field "${field}" skipped a non-string entry.`); @@ -109,7 +110,10 @@ function normalizeStringList(value: unknown, field: string, warnings: string[]): warnings.push(`MinerGoalSpec field "${field}" truncated an over-long entry.`); normalized = normalized.slice(0, MAX_ITEM_LENGTH); } - if (!result.includes(normalized)) result.push(normalized); + if (!seen.has(normalized)) { + result.push(normalized); + seen.add(normalized); + } if (result.length >= MAX_LIST_ITEMS) { warnings.push(`MinerGoalSpec field "${field}" exceeded ${MAX_LIST_ITEMS} entries; extra entries ignored.`); break; @@ -151,6 +155,19 @@ function normalizePositiveInteger(value: unknown, field: string, fallback: numbe return fallback; } +function utf8ByteLength(value: string): number { + let bytes = 0; + for (const char of value) { + const codePoint = char.codePointAt(0); + if (codePoint === undefined) continue; + if (codePoint <= 0x7f) bytes += 1; + else if (codePoint <= 0x7ff) bytes += 2; + else if (codePoint <= 0xffff) bytes += 3; + else bytes += 4; + } + return bytes; +} + function hasConfiguredGoalFields(spec: MinerGoalSpec): boolean { return ( spec.minerEnabled !== DEFAULT_MINER_GOAL_SPEC.minerEnabled || @@ -212,7 +229,7 @@ export function parseMinerGoalSpec(raw: unknown): ParsedMinerGoalSpec { */ export function parseMinerGoalSpecContent(content: string | null | undefined): ParsedMinerGoalSpec { if (content === undefined || content === null || content.trim() === "") return emptyMinerGoalSpec(); - if (content.length > MAX_MINER_GOAL_SPEC_BYTES) { + if (utf8ByteLength(content) > MAX_MINER_GOAL_SPEC_BYTES) { return emptyMinerGoalSpec([ `MinerGoalSpec content exceeded ${MAX_MINER_GOAL_SPEC_BYTES} bytes; ignoring it and falling back to safe defaults.`, ]); diff --git a/packages/gittensory-engine/test/miner-goal-spec-parser.test.ts b/packages/gittensory-engine/test/miner-goal-spec-parser.test.ts index bc57135d11..e00594fb3b 100644 --- a/packages/gittensory-engine/test/miner-goal-spec-parser.test.ts +++ b/packages/gittensory-engine/test/miner-goal-spec-parser.test.ts @@ -145,4 +145,8 @@ test("parseMinerGoalSpecContent: non-mapping parsed content and oversized conten const oversized = parseMinerGoalSpecContent(`wantedPaths:\n - ${"x".repeat(40_000)}\n`); assert.equal(oversized.present, false); assert.match(oversized.warnings.join(" "), /exceeded 32768 bytes/i); + + const multibyteOversized = parseMinerGoalSpecContent(`wantedPaths:\n - ${"好".repeat(12_000)}\n`); + assert.equal(multibyteOversized.present, false); + assert.match(multibyteOversized.warnings.join(" "), /exceeded 32768 bytes/i); }); diff --git a/test/unit/miner-goal-spec-parser.test.ts b/test/unit/miner-goal-spec-parser.test.ts index 2bf956ad68..09101f8cc2 100644 --- a/test/unit/miner-goal-spec-parser.test.ts +++ b/test/unit/miner-goal-spec-parser.test.ts @@ -202,5 +202,11 @@ describe("MinerGoalSpec parser (#2301)", () => { spec: DEFAULT_MINER_GOAL_SPEC, warnings: ["MinerGoalSpec content exceeded 32768 bytes; ignoring it and falling back to safe defaults."], }); + + expect(parseMinerGoalSpecContent(`wantedPaths:\n - ${"好".repeat(12_000)}\n`)).toEqual({ + present: false, + spec: DEFAULT_MINER_GOAL_SPEC, + warnings: ["MinerGoalSpec content exceeded 32768 bytes; ignoring it and falling back to safe defaults."], + }); }); }); From 661e49a8c4381a1d557ff27786526f072a4f5a8c Mon Sep 17 00:00:00 2001 From: DragunovX16 <75143900+DragunovX16@users.noreply.github.com> Date: Fri, 3 Jul 2026 03:23:05 +0200 Subject: [PATCH 3/3] fix(miner-discovery): tighten parser boundary checks --- .../gittensory-engine/src/miner-goal-spec.ts | 10 ++++----- .../test/miner-goal-spec-parser.test.ts | 17 +++++++++++++++ test/unit/miner-goal-spec-parser.test.ts | 21 +++++++++++++++++++ 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/packages/gittensory-engine/src/miner-goal-spec.ts b/packages/gittensory-engine/src/miner-goal-spec.ts index 8130671ba7..9bc433bd3c 100644 --- a/packages/gittensory-engine/src/miner-goal-spec.ts +++ b/packages/gittensory-engine/src/miner-goal-spec.ts @@ -110,14 +110,13 @@ function normalizeStringList(value: unknown, field: string, warnings: string[]): warnings.push(`MinerGoalSpec field "${field}" truncated an over-long entry.`); normalized = normalized.slice(0, MAX_ITEM_LENGTH); } - if (!seen.has(normalized)) { - result.push(normalized); - seen.add(normalized); - } + if (seen.has(normalized)) continue; if (result.length >= MAX_LIST_ITEMS) { warnings.push(`MinerGoalSpec field "${field}" exceeded ${MAX_LIST_ITEMS} entries; extra entries ignored.`); break; } + result.push(normalized); + seen.add(normalized); } return result; } @@ -158,8 +157,7 @@ function normalizePositiveInteger(value: unknown, field: string, fallback: numbe function utf8ByteLength(value: string): number { let bytes = 0; for (const char of value) { - const codePoint = char.codePointAt(0); - if (codePoint === undefined) continue; + const codePoint = char.codePointAt(0) as number; if (codePoint <= 0x7f) bytes += 1; else if (codePoint <= 0x7ff) bytes += 2; else if (codePoint <= 0xffff) bytes += 3; diff --git a/packages/gittensory-engine/test/miner-goal-spec-parser.test.ts b/packages/gittensory-engine/test/miner-goal-spec-parser.test.ts index e00594fb3b..a3617ec70d 100644 --- a/packages/gittensory-engine/test/miner-goal-spec-parser.test.ts +++ b/packages/gittensory-engine/test/miner-goal-spec-parser.test.ts @@ -47,6 +47,15 @@ test("parseMinerGoalSpec: valid raw config normalizes every field and keeps non- assert.deepEqual(parsed.warnings, []); }); +test("parseMinerGoalSpec: exactly 100 unique entries are accepted without a cap warning", () => { + const wantedPaths = Array.from({ length: 100 }, (_, index) => `src/${index}.ts`); + const parsed = parseMinerGoalSpec({ wantedPaths }); + + assert.equal(parsed.present, true); + assert.deepEqual(parsed.spec.wantedPaths, wantedPaths); + assert.ok(!parsed.warnings.some((warning) => /exceeded 100 entries/i.test(warning))); +}); + test("parseMinerGoalSpec: malformed fields fall back independently with targeted warnings", () => { const longEntry = "x".repeat(300); const parsed = parseMinerGoalSpec({ @@ -149,4 +158,12 @@ test("parseMinerGoalSpecContent: non-mapping parsed content and oversized conten const multibyteOversized = parseMinerGoalSpecContent(`wantedPaths:\n - ${"好".repeat(12_000)}\n`); assert.equal(multibyteOversized.present, false); assert.match(multibyteOversized.warnings.join(" "), /exceeded 32768 bytes/i); + + const twoByteOversized = parseMinerGoalSpecContent(`wantedPaths:\n - ${"é".repeat(17_000)}\n`); + assert.equal(twoByteOversized.present, false); + assert.match(twoByteOversized.warnings.join(" "), /exceeded 32768 bytes/i); + + const fourByteOversized = parseMinerGoalSpecContent(`wantedPaths:\n - ${"🙂".repeat(9_000)}\n`); + assert.equal(fourByteOversized.present, false); + assert.match(fourByteOversized.warnings.join(" "), /exceeded 32768 bytes/i); }); diff --git a/test/unit/miner-goal-spec-parser.test.ts b/test/unit/miner-goal-spec-parser.test.ts index 09101f8cc2..1293270659 100644 --- a/test/unit/miner-goal-spec-parser.test.ts +++ b/test/unit/miner-goal-spec-parser.test.ts @@ -67,6 +67,15 @@ describe("MinerGoalSpec parser (#2301)", () => { expect(parsed.warnings.join(" ")).toMatch(/exceeded 100 entries/i); }); + it("accepts exactly 100 unique entries without a cap warning", () => { + const wantedPaths = Array.from({ length: 100 }, (_, index) => `src/${index}.ts`); + const parsed = parseMinerGoalSpec({ wantedPaths }); + + expect(parsed.present).toBe(true); + expect(parsed.spec.wantedPaths).toEqual(wantedPaths); + expect(parsed.warnings.join(" ")).not.toMatch(/exceeded 100 entries/i); + }); + it("falls back per field for invalid values without throwing", () => { const parsed = parseMinerGoalSpec({ minerEnabled: "yes", @@ -208,5 +217,17 @@ describe("MinerGoalSpec parser (#2301)", () => { spec: DEFAULT_MINER_GOAL_SPEC, warnings: ["MinerGoalSpec content exceeded 32768 bytes; ignoring it and falling back to safe defaults."], }); + + expect(parseMinerGoalSpecContent(`wantedPaths:\n - ${"é".repeat(17_000)}\n`)).toEqual({ + present: false, + spec: DEFAULT_MINER_GOAL_SPEC, + warnings: ["MinerGoalSpec content exceeded 32768 bytes; ignoring it and falling back to safe defaults."], + }); + + expect(parseMinerGoalSpecContent(`wantedPaths:\n - ${"🙂".repeat(9_000)}\n`)).toEqual({ + present: false, + spec: DEFAULT_MINER_GOAL_SPEC, + warnings: ["MinerGoalSpec content exceeded 32768 bytes; ignoring it and falling back to safe defaults."], + }); }); });