/** * lib/story-bank.mjs — the one definition of what a story-bank.md story is. * * interview-prep/story-bank.md has three readers: match-star.mjs (`npm run * star`), negotiation-roi.mjs, and story-provenance-check.mjs. They used to * carry two separate parsers that agreed on how to split the file but not on * what counts as a story — match-star dropped a block with no Action line, * the provenance checker kept it. A contract the readers can disagree on is a * contract no writer can satisfy (#4514), so splitting, field lookup and * validity live here, and every reader goes through them. * * The entry shape, shown in full in templates/story-bank.template.md: * * ### [Theme] Title * **Source:** Report #NNN — Company — Role * **S (Situation):** … * **A (Action):** … ← required: a block without it is not a story * … * * Three ways the previous split went wrong, each fixed here: * * - A `### ` line inside an HTML comment or a code fence was read as a real * story. The template removed in #944 kept its example inside ``, * so every install from that period carries one phantom story titled * "Story Title". Comments and fences are blanked before splitting. * - A block ran to the next `### `, so Block F table rows appended after a * story became part of that story's body, and the provenance checker * attributed their figures to it. A block now ends at the first table row * or `#`/`##` heading. * - Table rows were dropped without a word. They are returned separately, * so readers can say how many stories they cannot see instead of * reporting an empty bank as clean. * * Imports nothing, like lib/placeholder-cell.mjs and lib/ascii-fold.mjs. */ /** * Field → accepted labels, first match wins. The long form is what the * template teaches; the short form is what earlier entries used. */ export const STORY_FIELDS = Object.freeze({ source: Object.freeze(['Source']), situation: Object.freeze(['S (Situation)', 'Situation']), task: Object.freeze(['T (Task)', 'Task']), action: Object.freeze(['A (Action)', 'Action']), result: Object.freeze(['R (Result)', 'Result']), reflection: Object.freeze(['Reflection']), tags: Object.freeze(['Best for questions about']), }); const HEADING_RE = /^### (.*)$/; const BLOCK_END_RE = /^#{1,2}\s/; const TABLE_ROW_RE = /^\s*\|.*\|\s*$/; const TABLE_SEPARATOR_RE = /^\s*\|[\s:|-]+\|\s*$/; // CommonMark 0.31.2 §4.5. Opening: ≤3 spaces of indentation, then 3+ // backticks or 3+ tildes (never mixed); after a backtick run the info string // may not contain a backtick, or the line is inline code, not a fence. // Closing: ≤3 spaces, the SAME character, a run AT LEAST as long as the // opening one, then only spaces or tabs. Anything looser lets a ``` line // inside a ```` fence close it early and hide the rest of the file. const OPEN_FENCE_RE = /^ {0,3}(`{3,}|~{3,})(.*)$/; const CLOSE_FENCE_RE = /^ {0,3}(`{3,}|~{3,})[ \t]*$/; /** * Blank out HTML comments and fenced code blocks, keeping every newline so * line numbers still point into the original file. * @param {string} content * @returns {string[]} lines */ function contentLines(content) { const noComments = String(content ?? '').replace(//g, (m) => m.replace(/[^\n]/g, '')); const lines = noComments.split(/\r?\n/); let fence = null; // the opening run, e.g. '````' — its character and length both matter for (let i = 0; i < lines.length; i++) { if (fence) { const close = lines[i].match(CLOSE_FENCE_RE); if (close && close[1][0] === fence[0] && close[1].length >= fence.length) fence = null; lines[i] = ''; continue; } const open = lines[i].match(OPEN_FENCE_RE); if (open && !(open[1][0] === '`' && open[2].includes('`'))) { fence = open[1]; lines[i] = ''; } } // An unclosed fence runs to the end of the document, as in CommonMark. return lines; } function tableCells(line) { return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim()); } /** * Turn a run of consecutive table lines into data rows. A line followed by a * separator row is a header, not a story. */ function tableDataRows(run) { const hasHeader = run.length > 1 && TABLE_SEPARATOR_RE.test(run[1].text); const header = hasHeader ? tableCells(run[0].text) : null; const storyCol = header ? header.findIndex((c) => /story/i.test(c)) : -1; const rows = []; for (const [i, { line, text }] of run.entries()) { if (hasHeader && i < 2) continue; if (TABLE_SEPARATOR_RE.test(text)) continue; const cells = tableCells(text); const label = (storyCol >= 0 && cells[storyCol]) || cells.find((c) => c && !/^#?\d*$/.test(c)) || ''; rows.push({ line, text: text.trim(), cells, label }); } return rows; } /** * Split story-bank.md into `### ` blocks, the table rows outside them, and the * text a story was cut off from. * * `raw` is the heading line plus the body, which is what the provenance * checker scans, so a figure in a title is still checked. * * A story ends at a table row or a `#`/`##` heading, so a table's figures are * never credited to the story above it. But the lines AFTER that point, up to * the next `### `, are still in the file: a Result line below a `## Notes` * heading, or an Action below a table. They come back as `trailing`, one * entry per run of text between headings or tables, tied to the story they * follow (`story` is its index in `blocks`), so the provenance checker can * still scan them. Text before the first story belongs to no story and is not * returned. Table rows carry the same `story` index, or -1 before any story. * * @param {string} content * @returns {{ * blocks: Array<{header: string, theme: string, title: string, body: string, raw: string, line: number}>, * tableRows: Array<{line: number, text: string, cells: string[], label: string, story: number}>, * trailing: Array<{story: number, line: number, after: 'heading'|'table', text: string}> * }} `line` is 1-based. */ export function splitStoryBlocks(content) { const lines = contentLines(content); const blocks = []; const tableRows = []; const trailing = []; let current = null; // the block whose body is still open let run = []; // consecutive table lines let segment = null; // the trailing run being collected let cutBy = null; // what ended the last story or segment: 'heading' | 'table' const flushRun = () => { const story = blocks.length - 1; if (run.length) tableRows.push(...tableDataRows(run).map((r) => ({ ...r, story }))); run = []; }; for (let i = 0; i < lines.length; i++) { const text = lines[i]; const heading = text.match(HEADING_RE); if (TABLE_ROW_RE.test(text)) { current = null; segment = null; cutBy = 'table'; run.push({ line: i + 1, text }); continue; } flushRun(); if (heading) { const header = heading[1].trim(); const themeMatch = header.match(/^\[([^\]]+)\]\s*(.+)/); current = { header, theme: themeMatch ? themeMatch[1].trim() : '', title: themeMatch ? themeMatch[2].trim() : header, bodyLines: [], line: i + 1, }; blocks.push(current); segment = null; continue; } if (BLOCK_END_RE.test(text)) { current = null; segment = null; cutBy = 'heading'; continue; } if (current) { current.bodyLines.push(text); } else if (blocks.length && text.trim()) { if (!segment) { segment = { story: blocks.length - 1, line: i + 1, after: cutBy, lines: [] }; trailing.push(segment); } segment.lines.push(text); } } flushRun(); return { blocks: blocks.map(({ bodyLines, ...b }) => { const body = bodyLines.join('\n').trim(); return { ...b, body, raw: `${b.header}\n${body}` }; }), tableRows, trailing: trailing.map(({ lines: segLines, ...t }) => ({ ...t, text: segLines.join('\n') })), }; } function escapeRegExp(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } /** * Value of the first non-empty `**Label:** value` line for any of `labels`. * * The label must START the line, optionally after indentation and one list or * quote marker (`-`, `*`, `+`, `>`). Unanchored, a label quoted inside another * field's value counted as that field: a Situation reading "…had a line * reading **A (Action):** TBD…" passed isValidStory() with no Action of its * own, and this function decides validity for every reader. * * `[ \t]*`, not `\s*`: an empty `**Action:**` must not borrow the next line as * its value. * @param {string} body * @param {readonly string[]} labels - plain text; escaped here * @returns {string} '' when absent */ export function getField(body, labels) { const text = String(body ?? ''); for (const label of labels) { const re = new RegExp(`^[ \\t]*(?:[-*+>][ \\t]+)?\\*\\*${escapeRegExp(label)}:\\*\\*[ \\t]*(.+)$`, 'gm'); for (const hit of text.matchAll(re)) { if (hit[1].trim()) return hit[1].trim(); } } return ''; } /** * THE validity rule every reader shares: a title and an Action. A block that * fails it is invisible to `npm run star` and negotiation-roi, and the * provenance checker reports it as malformed (while still scanning it). * @param {{title: string, body: string}} block */ export function isValidStory(block) { return Boolean(block && block.title && getField(block.body, STORY_FIELDS.action)); } /** * Parse story-bank.md into the STAR stories match-star scores and * negotiation-roi mines. Only blocks passing isValidStory(). * @param {string} content * `line` (1-based, the heading's line) lets callers and the contract test * tell same-titled stories apart. * @returns {Array<{title, theme, source, situation, task, action, result, reflection, tags, line}>} */ export function parseStories(content) { return splitStoryBlocks(content).blocks.filter(isValidStory).map((b) => { const tagsRaw = getField(b.body, STORY_FIELDS.tags); return { title: b.title, theme: b.theme, source: getField(b.body, STORY_FIELDS.source), situation: getField(b.body, STORY_FIELDS.situation), task: getField(b.body, STORY_FIELDS.task), action: getField(b.body, STORY_FIELDS.action), result: getField(b.body, STORY_FIELDS.result), reflection: getField(b.body, STORY_FIELDS.reflection), tags: tagsRaw ? tagsRaw.split(/[,;]/).map((t) => t.trim().toLowerCase()).filter(Boolean) : [], line: b.line, }; }); }