Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion packages/gittensory-engine/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 3 additions & 0 deletions packages/gittensory-engine/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions packages/gittensory-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
200 changes: 191 additions & 9 deletions packages/gittensory-engine/src/miner-goal-spec.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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
Expand All @@ -64,3 +72,177 @@ export const DEFAULT_MINER_GOAL_SPEC: Readonly<MinerGoalSpec> = 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[] = [];
const seen = new Set<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 (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;
}

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 utf8ByteLength(value: string): number {
let bytes = 0;
for (const char of value) {
const codePoint = char.codePointAt(0) as number;
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 ||
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<string, unknown>;
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 (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.`,
]);
}
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);
}
Loading
Loading