Ctrl K
ComunidadGuías…

lib/story-bank.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * 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(/<!--[\s\S]*?-->/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,
    };
  });
}