Files
Marius CiepluchandClaude Opus 5 c2d9c4ad01 feat(learning-harness): absence rows travel with their registration
Eight of the ten vendored access-control rows had no named handler at all,
so every one of them would have scored `missing_context` forever rather
than as a detector miss. After the re-harvest nine are named and the tenth
states its reason.

The handler-context mode now carries the class its framework registers,
multi-line decorators, and commented-out guards -- `#@token_required`
above a ThreatByte handler is the entire bug. Where the registration lives
in another document (connexion's `operationId`, an express router), a
recipe names `context = [...]`, the harvester writes that stanza per side,
and the pair materializes both files at their repository-relative paths so
the entry-point mapper sees the layout the project has. `harvest_recipe`
fetches every blob before the first write and the driver takes `--report`,
so a failed fetch leaves a reviewed excerpt exactly as it was.

Two defects the re-harvest exposed: `.mjs` was not a known suffix, so the
one pair vendored as an ES module had no file target at all; and
`_python_route_access` matched auth words inside decorator prose, reading
`@ns.doc(description='... without proper authorization checks')` as a
guard. Recorded in the hygiene artifact: nine of the ten duplicate groups
sit on both sides of the split, which the repository-level split cannot
see.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-06 12:39:55 +02:00

163 lines
4.3 KiB
TypeScript

// Provenance: G4brym/workers-research (fixed).
// repo: G4brym/workers-research
// commit: 209444eb61cc564c2d6b43267256f85fe3e5e30e
// parent: eab923ad3099c01b002fbd6be7cf994614bbdee4
// commit_url: https://github.com/G4brym/workers-research/commit/209444eb61cc564c2d6b43267256f85fe3e5e30e
// cve:
// license: MIT
// function: getGoogleProvider
// relpath: src/utils.ts
// provenance: agent
// mechanism: source_reaches_sink
import {
createGoogleGenerativeAI,
type GoogleGenerativeAIProvider,
type GoogleGenerativeAIProviderSettings,
} from "@ai-sdk/google";
import type { Env } from "./bindings";
function getGoogleProvider(env: Env): GoogleGenerativeAIProvider {
const args: GoogleGenerativeAIProviderSettings = {
apiKey: env.GOOGLE_API_KEY,
};
if (env.AI_GATEWAY_ACCOUNT_ID && env.AI_GATEWAY_NAME) {
args.baseURL = `https://gateway.ai.cloudflare.com/v1/${env.AI_GATEWAY_ACCOUNT_ID}/${env.AI_GATEWAY_NAME}/google-ai-studio/v1beta`;
if (env.AI_GATEWAY_API_KEY) {
args.headers = {
"cf-aig-authorization": `Bearer ${env.AI_GATEWAY_API_KEY}`,
};
}
}
return createGoogleGenerativeAI(args);
}
export function getModel(env: Env) {
const google = getGoogleProvider(env);
return google("gemini-2.0-flash-exp");
}
export function getFallbackModel(env: Env) {
const google = getGoogleProvider(env);
return google("gemini-2.0-flash");
}
export function getModelThinking(env: Env) {
const google = getGoogleProvider(env);
return google("gemini-2.0-flash-exp");
}
export function timeAgo(date: Date): string {
const now = new Date();
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
const intervals: [number, string][] = [
[60, "minute"],
[60, "hour"],
[24, "day"],
[7, "week"],
[4.35, "month"],
[12, "year"],
];
let count = seconds;
let unit = "second";
for (const [interval, name] of intervals) {
if (count < interval) break;
count /= interval;
unit = name;
}
count = Math.floor(count);
return `${count} ${unit}${count !== 1 ? "s" : ""} ago`;
}
export async function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Normalize a domain input for excluded domain matching.
* Handles common user input formats:
* - Full URLs: "https://reddit.com/foo" → "reddit.com"
* - www prefix: "www.reddit.com" → "reddit.com"
* - Mixed: "https://www.reddit.com" → "reddit.com"
* - Plain domains are returned as-is (lowercased, trimmed)
*/
export function normalizeDomain(input: string): string {
let domain = input.trim().toLowerCase();
if (domain.length === 0) return "";
// If it looks like a URL (has ://), extract the hostname
if (domain.includes("://")) {
try {
domain = new URL(domain).hostname;
} catch {
// If URL parsing fails, strip the protocol manually
domain = domain.replace(/^[a-z]+:\/\//, "").split("/")[0];
}
} else {
// Strip any trailing path from plain domain input
domain = domain.split("/")[0];
}
// Strip www. prefix for consistent matching
domain = domain.replace(/^www\./, "");
return domain;
}
/**
* Build parameterized search filter conditions for research list queries.
* Uses parameterized queries to prevent SQL injection.
*/
export function buildSearchFilters(
q?: string,
status?: string,
): { conditions: string[]; params: (string | number)[] } {
const conditions: string[] = [];
const params: (string | number)[] = [];
if (q?.trim()) {
const searchTerm = `%${q.trim()}%`;
conditions.push("(title LIKE ? OR query LIKE ?)");
params.push(searchTerm, searchTerm);
}
if (status && ["1", "2", "3"].includes(status)) {
conditions.push("status = ?");
params.push(Number.parseInt(status, 10));
}
return { conditions, params };
}
export function formatDuration(ms: number): string {
// Handle negative or zero values
if (ms <= 0) {
return "0.0 seconds";
}
const seconds = ms / 1000;
const minutes = seconds / 60;
const hours = minutes / 60;
const days = hours / 24;
// Determine the appropriate unit and format with one decimal place
if (days >= 1) {
return `${days.toFixed(1)} day${days !== 1 ? "s" : ""}`;
} else if (hours >= 1) {
return `${hours.toFixed(1)} hour${hours !== 1 ? "s" : ""}`;
} else if (minutes >= 1) {
return `${minutes.toFixed(1)} minute${minutes !== 1 ? "s" : ""}`;
} else {
return `${seconds.toFixed(1)} second${seconds !== 1 ? "s" : ""}`;
}
}