import { existsSync, readFileSync } from "node:fs"; import { basename, dirname, join, relative, resolve, sep } from "node:path"; import { authoritativeProjectDescription, errorMessage, readProjectDescriptionAuthority, visibleMarkdownLines, } from "./aidlc-lib.ts"; interface Flags { stage?: string; outputPath?: string; deliverables?: string; } interface Result { pass: boolean; findings: string[]; scanned_files: string[]; questions_file: string; findings_count: number; reason?: string; } interface ClaimBlock { section: string; text: string; inAssumptions: boolean; } interface SourceUniverse { registered: Set; answeredQuestions: Set; assumptionsAccepted: boolean; acceptedAssumptions: Set; pastedDocumentPresent: boolean; findings: string[]; } interface RecordAuthority { projectDescription: string; pastedDocumentPresent: boolean; scope: string; projectRoot: string; activeSpace: string; findings: string[]; } const ASSUMPTIONS_HEADING = "Assumptions & Open Questions"; const REVIEW_HEADING = "Review"; const ACCEPT_ASSUMPTIONS_ANSWER = "A. Accept assumptions"; const ACTIVE_MEMORY_FILES = new Set(["org.md", "team.md", "project.md"]); const NON_VISIBLE_HTML_ELEMENTS = new Set([ "code", "pre", "script", "style", "template", ]); const SOURCE_TAG_RE = /\[(desc|scope|assumption|Q\d+|memory:[A-Za-z0-9][A-Za-z0-9._-]*)\]/g; const SOURCE_ENTRY_RE = /^ {0,3}[-*+]\s+\[(desc|scope|memory:[A-Za-z0-9][A-Za-z0-9._-]*)\]\s+(.+?)\s*$/; function parseFlags(argv: string[]): Flags { const flags: Flags = {}; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; if (arg === "--stage") { flags.stage = argv[++i]; } else if (arg === "--output-path") { flags.outputPath = argv[++i]; } else if (arg === "--deliverables") { flags.deliverables = argv[++i] ?? ""; } } return flags; } function fail(message: string): never { process.stderr.write(`aidlc-sensor-claim-sources: ${message}\n`); process.exit(1); } function h2Heading(line: string): string | null { const match = /^ {0,3}##(?:[ \t]+|$)(.*)$/.exec(line); if (!match) return null; return match[1].replace(/[ \t]+#+[ \t]*$/, "").trim(); } function sectionsNamed(lines: string[], heading: string): string[][] { const sections: string[][] = []; let current: string[] | null = null; for (const line of lines) { const h2 = h2Heading(line); if (h2 !== null) { if (current !== null) sections.push(current); current = h2 === heading ? [] : null; continue; } if (current !== null) current.push(line); } if (current !== null) sections.push(current); return sections; } function stateField(body: string, label: string): string { const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return ( new RegExp(`^- \\*\\*${escaped}\\*\\*:\\s*(.*)$`, "m").exec(body)?.[1]?.trim() ?? "" ); } function findRecordRoot(stageDir: string): string | null { let cursor = resolve(stageDir); for (;;) { if (existsSync(join(cursor, "aidlc-state.md"))) return cursor; const parent = dirname(cursor); if (parent === cursor) return null; cursor = parent; } } function projectRootFor(recordRoot: string, stateBody: string): string { if (basename(recordRoot) === "aidlc-docs") return dirname(recordRoot); let cursor = recordRoot; for (;;) { if (basename(cursor) === "aidlc") return dirname(cursor); const parent = dirname(cursor); if (parent === cursor) break; cursor = parent; } const configured = stateField(stateBody, "Project Root"); return configured ? resolve(configured) : ""; } function activeSpaceFor(projectRoot: string, recordRoot: string): string { const cursorPath = join(projectRoot, "aidlc", "active-space"); if (existsSync(cursorPath)) { try { return readFileSync(cursorPath, "utf-8").trim(); } catch { return ""; } } const spacesRoot = join(projectRoot, "aidlc", "spaces"); const rel = relative(spacesRoot, recordRoot); if (!rel.startsWith("..") && !rel.startsWith(sep)) { const first = rel.split(sep)[0]; if (first && first !== ".") return first; } return ""; } function loadRecordAuthority(stageDir: string): RecordAuthority { const findings: string[] = []; const recordRoot = findRecordRoot(stageDir); if (!recordRoot) { return { projectDescription: "", pastedDocumentPresent: false, scope: "", projectRoot: "", activeSpace: "", findings: ["cannot verify source register: aidlc-state.md was not found"], }; } let stateBody = ""; try { stateBody = readFileSync(join(recordRoot, "aidlc-state.md"), "utf-8"); } catch (error) { findings.push( `cannot verify source register: failed to read aidlc-state.md: ${errorMessage(error)}`, ); } let rawProjectDescription = ""; try { rawProjectDescription = readProjectDescriptionAuthority( recordRoot, stateBody, ).description; } catch (error) { findings.push( `cannot verify source register: ${errorMessage(error)}`, ); } const description = authoritativeProjectDescription(rawProjectDescription); if (description.error) { findings.push(`cannot verify source register: ${description.error}`); } const projectDescription = description.error ? "" : description.description; const scope = stateField(stateBody, "Scope"); const projectRoot = projectRootFor(recordRoot, stateBody); const activeSpace = projectRoot ? activeSpaceFor(projectRoot, recordRoot) : ""; if (!projectDescription) { findings.push("the record is missing authoritative project directions for [desc]"); } if (!scope) findings.push("aidlc-state.md is missing Scope authority for [scope]"); if (!projectRoot) { findings.push("cannot resolve the project root for memory source validation"); } if (!activeSpace) { findings.push("cannot resolve the active space for memory source validation"); } return { projectDescription, pastedDocumentPresent: description.pastedDocumentPresent, scope, projectRoot, activeSpace, findings, }; } function parseQuotedValue(value: string): string | null { if (!/^"(?:\\.|[^"\\])*"$/.test(value)) return null; try { const parsed = JSON.parse(value); return typeof parsed === "string" ? parsed : null; } catch { return null; } } function memoryRuleMatches( id: string, value: string, authority: RecordAuthority, findings: string[], ): boolean { const match = /^`([^`#]+)#([^`#]+)`:\s*("(?:\\.|[^"\\])*")$/.exec(value); if (!match) { findings.push( `[${id}] must use \`aidlc/spaces//memory/.md#\`: ""`, ); return false; } const [, sourcePath, heading, quoted] = match; const rule = parseQuotedValue(quoted); if (rule === null) { findings.push(`[${id}] has an invalid quoted rule`); return false; } if (!authority.projectRoot || !authority.activeSpace) return false; const expectedPrefix = `aidlc/spaces/${authority.activeSpace}/memory/`; if ( !sourcePath.startsWith(expectedPrefix) || sourcePath.includes("\\") || sourcePath.split("/").includes("..") ) { findings.push( `[${id}] path must name a file under the active memory root ${expectedPrefix}`, ); return false; } const memoryFile = sourcePath.slice(expectedPrefix.length); if (!ACTIVE_MEMORY_FILES.has(memoryFile)) { findings.push( `[${id}] must name an active memory file under ${expectedPrefix}: org.md, team.md, or project.md`, ); return false; } const memoryRoot = resolve(authority.projectRoot, expectedPrefix); const sourceFile = resolve(authority.projectRoot, sourcePath); if ( sourceFile !== memoryRoot && !sourceFile.startsWith(`${memoryRoot}${sep}`) ) { findings.push(`[${id}] path escapes the active memory root`); return false; } if (!existsSync(sourceFile)) { findings.push(`[${id}] memory source does not exist: ${sourcePath}`); return false; } let memoryBody = ""; try { memoryBody = readFileSync(sourceFile, "utf-8"); } catch (error) { findings.push( `[${id}] failed to read memory source ${sourcePath}: ${errorMessage(error)}`, ); return false; } const sections = sectionsNamed( visibleMarkdownLines(memoryBody, { preserveIndentedCode: true }), heading, ); if (sections.length !== 1) { findings.push( `[${id}] memory source must contain exactly one ## ${heading} heading`, ); return false; } const entries = sections[0] .map((line) => line.replace(/^ {0,3}(?:[-*+]|\d+\.)\s+/, "").trim(), ) .filter((line) => line.length > 0 && !/^>/.test(line)); if (!entries.includes(rule)) { findings.push( `[${id}] quoted rule does not exactly match an entry under ## ${heading}`, ); return false; } return true; } function answerIsFilled(answer: string): boolean { const normalized = answer.trim(); return normalized.length > 0 && !/^_+$/.test(normalized); } function parseSourceUniverse( questionsPath: string, stageDir: string, ): SourceUniverse { const findings: string[] = []; if (!existsSync(questionsPath)) { return { registered: new Set(), answeredQuestions: new Set(), assumptionsAccepted: false, acceptedAssumptions: new Set(), pastedDocumentPresent: false, findings: [`questions file missing: ${questionsPath}`], }; } let body: string; try { body = readFileSync(questionsPath, "utf-8"); } catch (error) { return { registered: new Set(), answeredQuestions: new Set(), assumptionsAccepted: false, acceptedAssumptions: new Set(), pastedDocumentPresent: false, findings: [ `failed to read questions file ${questionsPath}: ${errorMessage(error)}`, ], }; } const lines = visibleMarkdownLines(body, { preserveIndentedCode: true }); const labels = referenceLabels(body); const authority = loadRecordAuthority(stageDir); findings.push(...authority.findings); const registered = new Set(); const seenSources = new Set(); const sourceSections = sectionsNamed(lines, "Sources"); if (sourceSections.length === 0) { findings.push("questions file is missing ## Sources"); } else { if (sourceSections.length > 1) { findings.push("questions file has duplicate ## Sources sections"); } for (const line of sourceSections[0]) { const match = SOURCE_ENTRY_RE.exec(line); if (!match) continue; const [, id, value] = match; if (seenSources.has(id)) { findings.push(`duplicate source id [${id}] in ## Sources`); } seenSources.add(id); let valid = false; if (id === "desc") { const desc = /^Initial description:\s*("(?:\\.|[^"\\])*")$/.exec( value, ); const parsed = desc ? parseQuotedValue(desc[1]) : null; if (parsed === null) { findings.push( '[desc] must use Initial description: ""', ); } else if (parsed !== authority.projectDescription) { findings.push( "[desc] does not exactly match the authoritative project description", ); } else { valid = true; } } else if (id === "scope") { const scope = /^Workflow-selected scope:\s*`([^`]+)`\.?$/.exec(value)?.[1] ?? ""; if (!scope) { findings.push( "[scope] must use Workflow-selected scope: ``.", ); } else if (scope !== authority.scope) { findings.push( "[scope] does not exactly match Scope in aidlc-state.md", ); } else { valid = true; } } else { valid = memoryRuleMatches(id, value, authority, findings); } if (valid) registered.add(id); } for (const required of ["desc", "scope"]) { if (!seenSources.has(required)) { findings.push(`## Sources is missing [${required}]`); } } } const answeredQuestions = new Set(); const seenQuestions = new Set(); for (let index = 0; index < lines.length; index++) { const heading = h2Heading(lines[index]); const question = heading ? /^Q(\d+)\b/.exec(heading) : null; if (!question) continue; const id = `Q${question[1]}`; if (seenQuestions.has(id)) { findings.push(`duplicate question id ${id}`); } seenQuestions.add(id); let end = index + 1; while (end < lines.length && h2Heading(lines[end]) === null) end++; const answers = lines .slice(index + 1, end) .map((line) => /^\[Answer\]:\s*(.*)$/.exec(line)?.[1]) .filter((answer): answer is string => answer !== undefined); if (answers.length > 1) { findings.push(`duplicate [Answer]: entries for ${id}`); } const answer = answers[0] ?? ""; if (answerIsFilled(answer)) answeredQuestions.add(id); index = end - 1; } const confirmationSections = sectionsNamed(lines, "Assumption Confirmation"); if (confirmationSections.length > 1) { findings.push("questions file has duplicate ## Assumption Confirmation sections"); } const confirmation = confirmationSections[0] ?? []; const assumptionAnswers = confirmation .map((line) => /^\[Answer\]:\s*(.*)$/.exec(line)?.[1]) .filter((answer): answer is string => answer !== undefined); if (assumptionAnswers.length > 1) { findings.push("duplicate [Answer]: entries for Assumption Confirmation"); } const assumptionAnswer = assumptionAnswers[0] ?? ""; const acceptedAssumptions = new Set( confirmation .filter((line) => isListItem(line)) .filter((line) => sourceTags(line, labels).includes("assumption")) .map(normalizedAssumption) .filter((entry) => entry.length > 0), ); return { registered, answeredQuestions, assumptionsAccepted: assumptionAnswer.trim() === ACCEPT_ASSUMPTIONS_ANSWER, acceptedAssumptions, pastedDocumentPresent: authority.pastedDocumentPresent, findings, }; } function isTableSeparator(line: string): boolean { return /^\s*\|?(?:\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$/.test(line); } function isTableLine(line: string): boolean { const trimmed = line.trim(); return trimmed.startsWith("|") && trimmed.endsWith("|"); } function isListItem(line: string): boolean { return /^\s*(?:[-*+]|\d{1,9}[.)])\s+/.test(line); } function isNoneBlock(text: string): boolean { return /^None\.?$/i.test(text.trim()); } function claimBlocks( body: string, definitionLines: ReadonlySet = new Set(), ): { blocks: ClaimBlock[]; hasAssumptionsSection: boolean; } { const lines = visibleMarkdownLines(body, { preserveIndentedCode: true }).map((line, index) => definitionLines.has(index) ? "" : line, ); const tableHeaders = new Set(); for (let index = 1; index < lines.length; index++) { if (isTableSeparator(lines[index]) && isTableLine(lines[index - 1])) { tableHeaders.add(index - 1); } } const blocks: ClaimBlock[] = []; let section = ""; let skipReview = false; let hasAssumptionsSection = false; let pending: string[] = []; const flush = (): void => { const text = pending.join("\n").trimEnd(); if (text.length > 0) { blocks.push({ section, text, inAssumptions: section === ASSUMPTIONS_HEADING, }); } pending = []; }; for (let index = 0; index < lines.length; index++) { const line = lines[index]; const h2 = h2Heading(line); if (h2 !== null) { flush(); section = h2; skipReview = section === REVIEW_HEADING; if (section === ASSUMPTIONS_HEADING) hasAssumptionsSection = true; continue; } if (/^ {0,3}#{1,6}(?:[ \t]+|$)/.test(line)) { flush(); continue; } if (skipReview) continue; if (line.trim().length === 0 || isThematicBreak(line)) { flush(); continue; } if (isHtmlBlockStart(line)) { flush(); pending.push(line); continue; } if (isTableLine(line)) { flush(); if (!tableHeaders.has(index) && !isTableSeparator(line)) { blocks.push({ section, text: line.trim(), inAssumptions: section === ASSUMPTIONS_HEADING, }); } continue; } if (isListItem(line)) { flush(); pending.push(line); continue; } pending.push(line); } flush(); return { blocks, hasAssumptionsSection }; } function isEscaped(text: string, index: number): boolean { let slashes = 0; for (let cursor = index - 1; cursor >= 0 && text[cursor] === "\\"; cursor--) { slashes++; } return slashes % 2 === 1; } function matchingDelimiter( text: string, start: number, opening: "[" | "(", closing: "]" | ")", ): number { let depth = 0; let quote: "'" | '"' | null = null; for (let index = start; index < text.length; index++) { const char = text[index]; if (isEscaped(text, index)) continue; if (quote) { if (char === quote) quote = null; continue; } if ( opening === "(" && depth === 1 && (char === '"' || char === "'") && /\s/.test(text[index - 1] ?? "") ) { quote = char; continue; } if (char === opening) { depth++; } else if (char === closing) { depth--; if (depth === 0) return index; } } return -1; } interface HtmlTag { end: number; name: string; closing: boolean; selfClosing: boolean; hidesContent: boolean; } function htmlTagAt(text: string, start: number): HtmlTag | null { if (text[start] !== "<" || isEscaped(text, start)) return null; const tail = text.slice(start); const named = /^<(\/?)([A-Za-z][A-Za-z0-9-]*)\b/.exec(tail); const autolink = /^<(?:https?:\/\/|mailto:|[^<>\s]+@)/i.test(tail); if (!named && !autolink) return null; let quote: "'" | '"' | null = null; let end = -1; for (let index = start + 1; index < text.length; index++) { const char = text[index]; if (quote) { if (char === quote && !isEscaped(text, index)) quote = null; continue; } if (char === '"' || char === "'") { quote = char; } else if (char === ">") { end = index; break; } } if (end < 0) return null; if (!named) { return { end, name: "", closing: false, selfClosing: true, hidesContent: false, }; } const raw = text.slice(start, end + 1); const closing = named[1] === "/"; const name = named[2].toLowerCase(); const hiddenAttribute = /(?:^|\s)hidden(?:\s|=|\/?>)/i.test(raw) || /\saria-hidden\s*=\s*(?:"true"|'true'|true)(?:\s|\/?>)/i.test(raw) || /\sstyle\s*=\s*(?:"[^"]*(?:display\s*:\s*none|visibility\s*:\s*hidden)[^"]*"|'[^']*(?:display\s*:\s*none|visibility\s*:\s*hidden)[^']*')/i.test( raw, ); return { end, name, closing, selfClosing: /\/\s*>$/.test(raw), hidesContent: !closing && (NON_VISIBLE_HTML_ELEMENTS.has(name) || hiddenAttribute), }; } function visibleHtmlText(text: string): string { let visible = ""; let hiddenElement = ""; let hiddenDepth = 0; for (let index = 0; index < text.length; index++) { const tag = htmlTagAt(text, index); if (tag) { if (hiddenElement && tag.name === hiddenElement) { if (tag.closing) { hiddenDepth--; if (hiddenDepth === 0) hiddenElement = ""; } else if (!tag.selfClosing) { hiddenDepth++; } } else if (!hiddenElement && tag.hidesContent && !tag.selfClosing) { hiddenElement = tag.name; hiddenDepth = 1; } index = tag.end; continue; } if (!hiddenElement) visible += text[index]; } return visible; } // A definition keeps its meaning inside a block quote or a list item, and the // two nest in either order and to any depth. Taking one of each off would read // `> - [Q1]: url` and miss the equally valid `- > [Q1]: url`, so the markers // come off until the line stops changing. Five or more spaces after a list // marker start an indented code block inside the item rather than content, so // the marker only comes off for a run of one to four — and four spaces of // remaining indentation is an indented code block too, which the caller's // column test rejects. interface ContainerLine { text: string; context: string; } function containerLine(line: string): ContainerLine { let stripped = line; const context: string[] = []; for (;;) { const quote = /^ {0,3}> ?/.exec(stripped); if (quote) { stripped = stripped.slice(quote[0].length); context.push("quote"); continue; } const list = /^ {0,3}(?:[-*+]|\d{1,9}[.)])(?:\t| {1,4}(?! ))/.exec( stripped, ); if (list) { stripped = stripped.slice(list[0].length); context.push("list"); continue; } return { text: stripped, context: context.join("/") }; } } // CommonMark's link-destination grammar, which is what separates a definition // from a line that merely looks like one. A destination is either an // angle-bracket run that has to close on the same line and hold no unescaped // `<`, or a bare run that ends at the first space or control character and // keeps its parentheses balanced. Returns the index just past the destination, // or -1 when the text does not carry one. function referenceDestinationEnd(text: string, start: number): number { if (text[start] === "<") { for (let index = start + 1; index < text.length; index++) { if (isEscaped(text, index)) continue; if (text[index] === ">") return index + 1; if (text[index] === "<") return -1; } return -1; } let depth = 0; let index = start; for (; index < text.length; index++) { // Space and every ASCII control character end a bare destination. const codePoint = text.codePointAt(index) ?? 0; if (codePoint <= 0x20 || codePoint === 0x7f) break; if (isEscaped(text, index)) continue; if (text[index] === "(") { depth++; if (depth > 32) return -1; } else if (text[index] === ")") { depth--; if (depth < 0) return -1; } } return depth === 0 && index > start ? index : -1; } interface ReferenceDefinition { label: string; endLine: number; } interface ReferenceAnalysis { labels: Set; definitionLines: Set; } interface ActiveListContainer { beforeQuotes: number; contentIndent: number; listContext: string; } function contextParts(context: string): string[] { return context.length > 0 ? context.split("/") : []; } function textColumns(text: string): number { let column = 0; for (const char of text) { if (char === "\t") { column += 4 - (column % 4); } else { column++; } } return column; } function firstContent(text: string): { index: number; column: number } | null { let column = 0; for (let index = 0; index < text.length; index++) { if (text[index] === " ") { column++; continue; } if (text[index] === "\t") { column += 4 - (column % 4); continue; } return { index, column }; } return null; } function stripIndentColumns(text: string, required: number): string | null { let column = 0; let index = 0; while (column < required && index < text.length) { if (text[index] === " ") { column++; index++; continue; } if (text[index] === "\t") { column += 4 - (column % 4); index++; continue; } return null; } if (column < required) return null; return `${" ".repeat(column - required)}${text.slice(index)}`; } function stripLeadingQuotes( line: string, count: number, ): { text: string; contexts: string[] } | null { let text = line; const contexts: string[] = []; for (let index = 0; index < count; index++) { const quote = /^ {0,3}> ?/.exec(text); if (!quote) return null; text = text.slice(quote[0].length); contexts.push("quote"); } return { text, contexts }; } function explicitListContainer( line: string, listItem: number, ): { line: ContainerLine; active: ActiveListContainer } | null { let text = line; const before: string[] = []; for (;;) { const quote = /^ {0,3}> ?/.exec(text); if (!quote) break; text = text.slice(quote[0].length); before.push("quote"); } const marker = /^ {0,3}(?:[-*+]|\d{1,9}[.)])(?:\t| {1,4}(?! ))/.exec(text); if (!marker) return null; const after = containerLine(text.slice(marker[0].length)); const listContext = `list#${listItem}`; return { line: { text: after.text, context: [...before, listContext, ...contextParts(after.context)].join( "/", ), }, active: { beforeQuotes: before.length, contentIndent: textColumns(marker[0]), listContext, }, }; } // Container markers on the definition's first line are not repeated on its // continuation lines. Preserve the active list item's exact quote/list nesting, // indentation columns, and identity so continuations work through block quotes // and tabs but never cross into a sibling item. function documentContainerLines(lines: string[]): ContainerLine[] { const result: ContainerLine[] = []; let activeList: ActiveListContainer | null = null; let listItem = 0; for (const line of lines) { const explicitList = explicitListContainer(line, listItem + 1); if (explicitList) { listItem++; activeList = explicitList.active; result.push(explicitList.line); continue; } if (line.trim().length === 0) { result.push({ text: line, context: activeList?.listContext ?? "" }); continue; } if (activeList) { const quoted = stripLeadingQuotes(line, activeList.beforeQuotes); if (quoted && /^[ \t]*$/.test(quoted.text)) { result.push({ text: "", context: [...quoted.contexts, activeList.listContext].join("/"), }); continue; } const continuation = quoted ? stripIndentColumns(quoted.text, activeList.contentIndent) : null; if (quoted && continuation !== null) { const after = containerLine(continuation); result.push({ text: after.text, context: [ ...quoted.contexts, activeList.listContext, ...contextParts(after.context), ].join("/"), }); continue; } } activeList = null; result.push(containerLine(line)); } return result; } const HTML_BLOCK_TAGS = "address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h1|h2|h3|h4|h5|h6|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul"; const HTML_BLOCK_RE = new RegExp( `^(?:<(?:script|pre|style|textarea)(?:[ \\t>]|$)|