@cyclonedx/cdxgen
Version:
Creates CycloneDX Software Bill of Materials (SBOM) from source or container image
234 lines (217 loc) • 7.27 kB
JavaScript
/**
* Tolerant parser for git-ai notes.
*
* Supports three shapes, all degrading gracefully (never throws):
* 1. The real git-ai `authorship/*` schema: per-file attribution blocks
* followed by a `---` separator and a JSON metadata block carrying
* `sessions` (each with an `agent_id` describing the AI tool/model).
* 2. A flat JSON object `{ agent, model, session, prompt, lines, ranges }`.
* 3. Line-oriented `key: value` / `key = value` text.
*
* @param {string} raw Raw git note content
* @returns {Object} Extracted fields { agent, model, session, prompt, lines, ranges, sessions, prompts, agents, models, aiAttributionCount }
*/
export function parseGitAiNote(raw) {
const result = {
agent: "",
model: "",
session: "",
prompt: "",
lines: [],
ranges: [],
sessions: {},
prompts: {},
agents: [],
models: [],
aiAttributionCount: 0,
};
if (!raw || typeof raw !== "string") {
return result;
}
const trimmed = raw.trim();
if (!trimmed) {
return result;
}
// git-ai `authorship/*` notes: optional per-file attribution section, then a
// line that is exactly `---`, then a JSON metadata block. Detect and parse
// this first because the note as a whole is not valid JSON.
const authorship = tryParseAuthorshipNote(trimmed);
if (authorship) {
return authorship;
}
// Flat JSON note.
try {
const parsed = JSON.parse(trimmed);
if (parsed && typeof parsed === "object") {
if (parsed.agent) result.agent = String(parsed.agent);
if (parsed.model) result.model = String(parsed.model);
if (parsed.session) result.session = String(parsed.session);
if (parsed.prompt) result.prompt = String(parsed.prompt);
if (Array.isArray(parsed.lines)) {
result.lines = parsed.lines;
}
if (Array.isArray(parsed.ranges)) {
result.ranges = parsed.ranges;
}
result.aiAttributionCount = result.ranges.length + result.lines.length;
return result;
}
} catch {
// Ignore JSON error, fall back to line parsing
}
// Fallback: line-oriented text parsing
for (const line of trimmed.split("\n")) {
const lineTrim = line.trim();
if (!lineTrim || lineTrim.startsWith("#")) {
continue;
}
const match = lineTrim.match(/^([a-zA-Z0-9_-]+)\s*[:=]\s*(.*)$/);
if (match) {
const key = match[1].toLowerCase();
const val = match[2].trim();
if (key === "agent") result.agent = val;
else if (key === "model") result.model = val;
else if (key === "session") result.session = val;
else if (key === "prompt") result.prompt = val;
else if (key === "lines" || key === "ranges") {
try {
if (val.startsWith("[") && val.endsWith("]")) {
result[key] = JSON.parse(val);
} else {
result[key] = val
.split(",")
.map((v) => v.trim())
.filter(Boolean);
}
} catch {
// ignore
}
}
}
}
result.aiAttributionCount = result.ranges.length + result.lines.length;
return result;
}
/**
* Parse a git-ai `authorship/*` note per the Git AI Standard v3.0.0. Returns
* null if the note is not in that format so callers can fall back to other
* parsers.
*
* The note is `<attestation-section>` + a line that is exactly `---` +
* `<metadata JSON>`. Attestation entries are ` <key> <ranges>` where the key
* routes by prefix (spec section 1.2.3.1):
* - `s_<hex>::t_<hex>` -> AI session; session id (before `::`) in `sessions`.
* - `h_<hex>` -> KNOWN HUMAN (`humans`); NOT AI, ignored here.
* - `<16hex>` (no prefix) -> legacy AI session in `prompts`.
*
* @param {string} trimmed Trimmed raw note content
* @returns {Object|null} Parsed fields, or null when not an authorship note
*/
function tryParseAuthorshipNote(trimmed) {
// The divider is a line containing exactly `---`. Metadata-only notes (purely
// human commits) start with `---`; bare JSON is accepted defensively.
let attributionText = "";
let jsonText = "";
const sepMatch = trimmed.match(/(^|\n)---[ \t]*\r?\n/);
if (sepMatch) {
const idx = sepMatch.index + sepMatch[0].length;
attributionText = trimmed.slice(0, sepMatch.index);
jsonText = trimmed.slice(idx).trim();
} else if (trimmed.startsWith("{")) {
jsonText = trimmed;
} else {
return null;
}
let meta;
try {
meta = JSON.parse(jsonText);
} catch {
return null;
}
if (
!meta ||
typeof meta !== "object" ||
typeof meta.schema_version !== "string" ||
!meta.schema_version.startsWith("authorship")
) {
return null;
}
const result = {
agent: "",
model: "",
session: "",
prompt: "",
lines: [],
ranges: [],
sessions: {},
prompts: {},
agents: [],
models: [],
aiAttributionCount: 0,
};
const asObject = (value) => (value && typeof value === "object" ? value : {});
const metaSessions = asObject(meta.sessions); // s_<hex> -> { agent_id, ... }
const metaPrompts = asObject(meta.prompts); // legacy <16hex> -> { agent_id, ... }
result.sessions = metaSessions;
result.prompts = metaPrompts;
// Collect all unique agent tool names and model names from sessions and
// prompts. Also record the first AI agent as the primary attribution key.
const agentSet = new Set();
const modelSet = new Set();
const collectAgent = (key, record) => {
const agentObj = record?.agent_id;
if (agentObj) {
const tool = agentObj.tool ? String(agentObj.tool) : "";
const model = agentObj.model ? String(agentObj.model) : "";
if (tool || model) {
if (tool) agentSet.add(tool);
if (model) modelSet.add(model);
if (!result.agent) {
result.agent = tool;
result.model = model;
result.session = key;
}
}
}
};
for (const [sid, sess] of Object.entries(metaSessions)) {
collectAgent(sid, sess);
}
for (const [pid, prompt] of Object.entries(metaPrompts)) {
collectAgent(pid, prompt);
}
result.agents = [...agentSet].sort();
result.models = [...modelSet].sort();
/**
* Resolve an attestation key to whether it denotes AI authorship.
* @param {string} key attestation key
* @returns {boolean} true when the key maps to an AI session/prompt
*/
const isAiKey = (key) => {
if (key.startsWith("h_")) {
return false; // known human
}
if (key.startsWith("s_")) {
const sessionId = key.split("::")[0];
return Object.hasOwn(metaSessions, sessionId);
}
// Legacy bare-hex key routes to prompts.
return Object.hasOwn(metaPrompts, key);
};
// Attestation entries are indented ` <key> <ranges>`; ranges never contain
// spaces. File-path lines (unindented) and anything else are ignored here.
for (const rawLine of attributionText.split("\n")) {
const attr = rawLine.match(/^\s+(\S+)[ \t]+(\S+)\s*$/);
if (!attr || !isAiKey(attr[1])) {
continue;
}
for (const part of attr[2].split(",")) {
const token = part.trim();
if (token) {
result.ranges.push(token);
}
}
}
result.aiAttributionCount = result.ranges.length;
return result;
}