From 8ff849c86b5d8d6aae2a43a2761b3315707c8487 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 24 Aug 2026 16:25:03 -0400 Subject: [PATCH 01/19] freshness: give the LLM tutorial context + SAP-aware rules The freshness scan judged code blocks in isolation and applied generic best-practice dogma, producing false positives (intentional errors, demo base64 credentials, dev-container setup, npm version-pinning advice that contradicts CAP guidance). - extractCodeBlocks now captures contextBefore/contextAfter (the prose paragraph adjacent to each fence). - extractTutorialContext pulls frontmatter + Prerequisites, fed once at the top of the prompt so the model knows the reader's environment. - SYSTEM_PROMPT now instructs the model to judge blocks in context, respect intentional teaching artifacts (deliberate errors, illustrative demo credentials), and follow SAP/CAP conventions (never suggest pinning @sap/* package versions). - Export SYSTEM_PROMPT/buildUserMessage; add extractor + prompt tests. --- srv/lib/freshness-detector.js | 41 ++++++-- srv/lib/freshness-extract.js | 119 ++++++++++++++++++++++- test/unit/freshness-extract.test.js | 57 +++++++++-- test/unit/freshness-prompt-guard.test.js | 47 ++++++++- 4 files changed, 244 insertions(+), 20 deletions(-) diff --git a/srv/lib/freshness-detector.js b/srv/lib/freshness-detector.js index 102490429..dd9486017 100644 --- a/srv/lib/freshness-detector.js +++ b/srv/lib/freshness-detector.js @@ -20,7 +20,7 @@ import cds from '@sap/cds'; import { OrchestrationClient } from '@sap-ai-sdk/orchestration'; -import { extractCodeBlocks } from './freshness-extract.js'; +import { extractCodeBlocks, extractTutorialContext } from './freshness-extract.js'; import { groundCodeBlock } from './freshness-grounding.js'; import { resolveChatLlmSettings } from './chat-settings-resolver.js'; import { tokensToCents } from './_token-cost.js'; @@ -83,26 +83,47 @@ export const FRESHNESS_TOOL_SPEC = { // ─── Prompt ─────────────────────────────────────────────────────────────────── -const SYSTEM_PROMPT = [ +export const SYSTEM_PROMPT = [ 'You are a technical reviewer detecting STALE code and dependencies in SAP developer tutorials.', - 'You are given code blocks (each with a stepRef + codeBlockIndex) and, per block, grounding context retrieved from official SAP docs.', + 'You are given the tutorial\'s frontmatter and prerequisites, and, per code block, the prose immediately before and after it plus grounding context retrieved from official SAP docs.', 'Report obsolete dependencies, deprecated/superseded APIs, dated idioms, hardcoded secrets, and broken step flow.', + 'CONTEXT: Judge every block IN THE CONTEXT of its surrounding prose and the tutorial as a whole — never in isolation. The prerequisites define the reader\'s environment; for example a dev container in VS Code or GitHub Codespaces already provides a shell and the required toolchain, so do NOT flag setup the prerequisites already establish.', + 'RESPECT INTENT: Do NOT report something as a problem when the surrounding text shows it is intentional. Examples: an error or warning the tutorial deliberately triggers and then explains in the following paragraph; sample or illustrative credentials such as a base64-encoded demo user (e.g. "alice:") or obvious placeholder tokens. Only raise a hardcoded-secret finding when the value is a real, sensitive credential a reader would ship to production — never for demo values the tutorial is showing on purpose.', + 'SAP CONVENTIONS: Follow official SAP/CAP guidance and do NOT propose fixes that contradict it. In particular, do NOT suggest pinning versions of @sap/* packages (such as @sap/cds or @sap/cds-dk) in npm install commands — CAP guidance is to install the latest. Do not invent generic best-practice advice that conflicts with how SAP tutorials are meant to be followed.', 'RULES: Echo back the exact stepRef and codeBlockIndex you were given — never invent locations.', 'Every finding MUST carry a confidence tier. If an API-obsolescence claim is NOT supported by the provided grounding context, set confidence to "Low" and leave groundingSource empty.', - 'Prefer High confidence only for clear, verifiable staleness (e.g. a dependency with a native replacement, a hardcoded credential).', + 'Prefer High confidence only for clear, verifiable staleness (e.g. a dependency with a native replacement, a real hardcoded credential).', ].join(' '); -function buildUserMessage(blocks, groundingByBlock) { - return blocks.map((b, i) => { +export function buildUserMessage(blocks, groundingByBlock, tutContext = {}) { + const parts = []; + + const preamble = []; + if (tutContext.frontmatter) { + preamble.push(`Tutorial frontmatter:\n${tutContext.frontmatter}`); + } + if (tutContext.prerequisites) { + preamble.push(`Tutorial prerequisites (these define the reader's environment/context):\n${tutContext.prerequisites}`); + } + if (preamble.length) { + parts.push(`## Tutorial context\n${preamble.join('\n\n')}`); + } + + const blockText = blocks.map((b, i) => { const hits = groundingByBlock[i] || []; const g = hits.length ? hits.map(h => `- ${h.title} (${h.url || 'n/a'}) [score ${h.score.toFixed(2)}]`).join('\n') : '- (no grounding context found)'; + const before = b.contextBefore ? `Text before this block:\n${b.contextBefore}\n\n` : ''; + const after = b.contextAfter ? `\nText after this block:\n${b.contextAfter}` : ''; return ( `### Block stepRef=${b.stepRef} codeBlockIndex=${b.codeBlockIndex} lang=${b.lang}\n` + - `\`\`\`\n${b.code}\n\`\`\`\nGrounding:\n${g}` + `${before}\`\`\`\n${b.code}\n\`\`\`${after}\nGrounding:\n${g}` ); }).join('\n\n'); + parts.push(blockText); + + return parts.join('\n\n'); } // ─── LLM call (with test-hook bypass) ───────────────────────────────────────── @@ -207,10 +228,14 @@ export async function detectFreshness({ db, tutorialId }) { const blocks = extractCodeBlocks([{ number: 1, content: src.markdown }]); if (!blocks.length) return { ok: true, model: null, costCents: 0, findings: [] }; + // Document-wide orientation (frontmatter + prerequisites) fed once at the top + // so the model judges blocks in the tutorial's real context, not in isolation. + const tutContext = extractTutorialContext(src.markdown); + const groundingByBlock = await Promise.all( blocks.map(b => groundCodeBlock({ db, code: b.code }).catch(() => [])) ); - const userMessage = buildUserMessage(blocks, groundingByBlock); + const userMessage = buildUserMessage(blocks, groundingByBlock, tutContext); const r = await callLlm({ blocks, userMessage }); diff --git a/srv/lib/freshness-extract.js b/srv/lib/freshness-extract.js index 2a8a0b38c..e3651c88f 100644 --- a/srv/lib/freshness-extract.js +++ b/srv/lib/freshness-extract.js @@ -1,12 +1,56 @@ // Runtime-safe (plain JS) code-block extractor. Ports the CommonMark fence // tracking from scripts/parsers/fence-tracker.ts (build-time TS, not importable // under cds-serve), extended to capture the fence language + accumulate code. +// +// #freshness-context: each block now also carries `contextBefore` / `contextAfter` +// — the prose paragraph immediately adjacent to the fence. The freshness LLM +// judges a block IN CONTEXT (e.g. an error the surrounding text says is expected) +// instead of in isolation, which was the root cause of the false positives DJ hit. const FENCE_OPEN = /^(\s{0,3})(`{3,}|~{3,})(.*)$/; +const WINDOW_LINES = 6; // max prose lines captured on each side of a block +const CHAR_CAP = 700; // hard cap per side, keeps prompt bounded + +function cap(str, max) { + if (typeof str !== 'string') return ''; + return str.length > max ? str.slice(0, max) : str; +} + +// Nearest prose paragraph immediately preceding `openIdx` (walking up). Stops at +// a blank-line paragraph boundary or another fence marker so we never bleed into +// an adjacent code block. +function proseBefore(lines, openIdx) { + let j = openIdx - 1; + while (j >= 0 && lines[j].trim() === '') j--; // skip blank gap directly above + const acc = []; + while (j >= 0 && acc.length < WINDOW_LINES) { + const ln = lines[j]; + if (FENCE_OPEN.test(ln) || ln.trim() === '') break; + acc.push(ln); + j--; + } + return cap(acc.reverse().join('\n').trim(), CHAR_CAP); +} + +// Nearest prose paragraph immediately following `closeIdx` (walking down). +function proseAfter(lines, closeIdx) { + let j = closeIdx + 1; + while (j < lines.length && lines[j].trim() === '') j++; + const acc = []; + while (j < lines.length && acc.length < WINDOW_LINES) { + const ln = lines[j]; + if (FENCE_OPEN.test(ln) || ln.trim() === '') break; + acc.push(ln); + j++; + } + return cap(acc.join('\n').trim(), CHAR_CAP); +} + /** * @param {Array<{number:number, content:string}>} steps - * @returns {Array<{stepRef:number, codeBlockIndex:number, lang:string, code:string}>} + * @returns {Array<{stepRef:number, codeBlockIndex:number, lang:string, code:string, + * contextBefore:string, contextAfter:string}>} */ export function extractCodeBlocks(steps) { const out = []; @@ -17,23 +61,88 @@ export function extractCodeBlocks(steps) { const stepRef = Number(step.number); const lines = content.split(/\r?\n/); let idx = 0; // per-step code block index - let open = null; // { marker:string, len:number, lang:string, body:string[] } - for (const line of lines) { + let open = null; // { marker, len, lang, body:[], openIdx } + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; if (open) { // closing fence: same marker char, length >= opening length, nothing but marker const close = line.match(FENCE_OPEN); if (close && close[2][0] === open.marker && close[2].length >= open.len && close[3].trim() === '') { - out.push({ stepRef, codeBlockIndex: idx++, lang: open.lang, code: open.body.join('\n') }); + out.push({ + stepRef, + codeBlockIndex: idx++, + lang: open.lang, + code: open.body.join('\n'), + contextBefore: proseBefore(lines, open.openIdx), + contextAfter: proseAfter(lines, i), + }); open = null; } else { open.body.push(line); } } else { const m = line.match(FENCE_OPEN); - if (m) open = { marker: m[2][0], len: m[2].length, lang: (m[3] || '').trim(), body: [] }; + if (m) open = { marker: m[2][0], len: m[2].length, lang: (m[3] || '').trim(), body: [], openIdx: i }; } } // unclosed fence at EOF is discarded (matches CommonMark tolerance for our purposes) } return out; } + +// ─── Tutorial-level context ───────────────────────────────────────────────── +// +// Document-wide orientation for the freshness LLM: the YAML frontmatter (title/ +// tags/domain) and the Prerequisites section (which defines the reader's +// environment — e.g. "a dev container in VS Code / GitHub Codespaces provides a +// shell and the toolchain"). Fed ONCE at the top of the prompt so the model does +// not re-flag setup the prerequisites already establish. + +const FRONTMATTER_RE = /^?---\r?\n([\s\S]*?)\r?\n---\r?\n?/; +const PREREQ_HEADING_RE = /^(#{1,6})\s+.*prerequisit/i; +const HEADING_RE = /^(#{1,6})\s+/; + +const FRONTMATTER_CAP = 1200; +const PREREQ_CAP = 1500; + +// Extract the first section whose heading matches `headingRe`, up to the next +// heading of the same or higher level. Returns '' when absent. +function extractSection(body, headingRe) { + const lines = body.split(/\r?\n/); + let start = -1; + let level = 0; + for (let i = 0; i < lines.length; i++) { + const m = lines[i].match(headingRe); + if (m) { start = i; level = m[1].length; break; } + } + if (start === -1) return ''; + const acc = []; + for (let i = start + 1; i < lines.length; i++) { + const h = lines[i].match(HEADING_RE); + if (h && h[1].length <= level) break; // next section of same/higher level + acc.push(lines[i]); + } + return acc.join('\n').trim(); +} + +/** + * @param {string} markdown raw tutorial source (frontmatter + body) + * @returns {{ frontmatter: string, prerequisites: string }} + */ +export function extractTutorialContext(markdown) { + if (typeof markdown !== 'string' || !markdown) { + return { frontmatter: '', prerequisites: '' }; + } + let frontmatter = ''; + let body = markdown; + const fm = markdown.match(FRONTMATTER_RE); + if (fm) { + frontmatter = fm[1].trim(); + body = markdown.slice(fm[0].length); + } + const prerequisites = extractSection(body, PREREQ_HEADING_RE); + return { + frontmatter: cap(frontmatter, FRONTMATTER_CAP), + prerequisites: cap(prerequisites, PREREQ_CAP), + }; +} diff --git a/test/unit/freshness-extract.test.js b/test/unit/freshness-extract.test.js index df58d90a9..91243aae8 100644 --- a/test/unit/freshness-extract.test.js +++ b/test/unit/freshness-extract.test.js @@ -1,8 +1,8 @@ import { describe, it, expect } from 'vitest'; -import { extractCodeBlocks } from '../../srv/lib/freshness-extract.js'; +import { extractCodeBlocks, extractTutorialContext } from '../../srv/lib/freshness-extract.js'; describe('extractCodeBlocks', () => { - it('extracts fenced blocks with language, step ref, and per-step index', () => { + it('extracts fenced blocks with language, step ref, index, and adjacent prose', () => { const steps = [ { number: 1, text: 'intro\n\n```Shell\nnpm init -y\n```\n' }, { number: 2, text: 'code\n\n```JavaScript\nconst fetch = require("node-fetch");\n```\nand again\n\n```JavaScript\nconsole.log(1);\n```\n' }, @@ -10,16 +10,23 @@ describe('extractCodeBlocks', () => { // NOTE: production reads persisted Steps rows; the parser accepts { number, content }. const blocks = extractCodeBlocks(steps.map(s => ({ number: s.number, content: s.text }))); expect(blocks).toEqual([ - { stepRef: 1, codeBlockIndex: 0, lang: 'Shell', code: 'npm init -y' }, - { stepRef: 2, codeBlockIndex: 0, lang: 'JavaScript', code: 'const fetch = require("node-fetch");' }, - { stepRef: 2, codeBlockIndex: 1, lang: 'JavaScript', code: 'console.log(1);' }, + { stepRef: 1, codeBlockIndex: 0, lang: 'Shell', code: 'npm init -y', contextBefore: 'intro', contextAfter: '' }, + { stepRef: 2, codeBlockIndex: 0, lang: 'JavaScript', code: 'const fetch = require("node-fetch");', contextBefore: 'code', contextAfter: 'and again' }, + { stepRef: 2, codeBlockIndex: 1, lang: 'JavaScript', code: 'console.log(1);', contextBefore: 'and again', contextAfter: '' }, ]); }); + it('captures the paragraph that explains an intentional error as contextAfter', () => { + const md = 'Run the command:\n\n```bash\ncds watch\n```\n\nYou will see the error below on purpose — we fix it in the next step.\n'; + const [block] = extractCodeBlocks([{ number: 1, content: md }]); + expect(block.contextBefore).toBe('Run the command:'); + expect(block.contextAfter).toBe('You will see the error below on purpose — we fix it in the next step.'); + }); + it('handles tilde fences and ignores unclosed fences gracefully', () => { const steps = [{ number: 1, content: '~~~py\nx=1\n~~~\n```\nunclosed' }]; const blocks = extractCodeBlocks(steps); - expect(blocks).toEqual([{ stepRef: 1, codeBlockIndex: 0, lang: 'py', code: 'x=1' }]); + expect(blocks).toEqual([{ stepRef: 1, codeBlockIndex: 0, lang: 'py', code: 'x=1', contextBefore: '', contextAfter: '' }]); }); it('returns [] for steps with no fences or empty input', () => { @@ -27,3 +34,41 @@ describe('extractCodeBlocks', () => { expect(extractCodeBlocks([])).toEqual([]); }); }); + +describe('extractTutorialContext', () => { + it('pulls YAML frontmatter and the Prerequisites section', () => { + const md = [ + '---', + 'title: Create a CAP service', + 'tags: [ cap, nodejs ]', + '---', + '', + '# Create a CAP service', + '', + '## Prerequisites', + '- A dev container in VS Code or GitHub Codespaces (provides a shell + Node.js)', + '- An SAP BTP trial account', + '', + '## Step 1', + 'Do the thing.', + ].join('\n'); + const ctx = extractTutorialContext(md); + expect(ctx.frontmatter).toContain('title: Create a CAP service'); + expect(ctx.frontmatter).toContain('tags: [ cap, nodejs ]'); + expect(ctx.frontmatter).not.toContain('# Create a CAP service'); + expect(ctx.prerequisites).toContain('dev container in VS Code or GitHub Codespaces'); + expect(ctx.prerequisites).toContain('SAP BTP trial account'); + // Section extraction stops at the next same-level heading. + expect(ctx.prerequisites).not.toContain('Do the thing.'); + }); + + it('returns empty strings when frontmatter/prerequisites are absent', () => { + const ctx = extractTutorialContext('# Title\n\nJust prose and\n\n```js\nx=1\n```\n'); + expect(ctx).toEqual({ frontmatter: '', prerequisites: '' }); + }); + + it('is safe on empty / non-string input', () => { + expect(extractTutorialContext('')).toEqual({ frontmatter: '', prerequisites: '' }); + expect(extractTutorialContext(null)).toEqual({ frontmatter: '', prerequisites: '' }); + }); +}); diff --git a/test/unit/freshness-prompt-guard.test.js b/test/unit/freshness-prompt-guard.test.js index 8476b87f2..79ba9bfeb 100644 --- a/test/unit/freshness-prompt-guard.test.js +++ b/test/unit/freshness-prompt-guard.test.js @@ -3,7 +3,7 @@ // Confidence enum and groundingSource must be present on every finding item. import { describe, it, expect } from 'vitest'; -import { FRESHNESS_TOOL_SPEC } from '../../srv/lib/freshness-detector.js'; +import { FRESHNESS_TOOL_SPEC, SYSTEM_PROMPT, buildUserMessage } from '../../srv/lib/freshness-detector.js'; describe('FRESHNESS_TOOL_SPEC', () => { it('requires confidence and groundingSource on every finding', () => { @@ -12,3 +12,48 @@ describe('FRESHNESS_TOOL_SPEC', () => { expect(item.properties.confidence.enum).toEqual(['High', 'Medium', 'Low']); }); }); + +describe('SYSTEM_PROMPT context + SAP guidance', () => { + it('tells the model to judge blocks in context, not isolation', () => { + expect(SYSTEM_PROMPT).toMatch(/in the context|in isolation/i); + expect(SYSTEM_PROMPT).toMatch(/prerequisites/i); + expect(SYSTEM_PROMPT).toMatch(/GitHub Codespaces|dev container/i); + }); + + it('tells the model to respect intentional teaching artifacts', () => { + expect(SYSTEM_PROMPT).toMatch(/intentional|on purpose|deliberately/i); + expect(SYSTEM_PROMPT).toMatch(/base64|demo|illustrative|placeholder/i); + }); + + it('forbids anti-CAP advice such as pinning @sap package versions', () => { + expect(SYSTEM_PROMPT).toMatch(/@sap/); + expect(SYSTEM_PROMPT).toMatch(/pin/i); + }); +}); + +describe('buildUserMessage', () => { + const blocks = [ + { stepRef: 1, codeBlockIndex: 0, lang: 'bash', code: 'cds watch', + contextBefore: 'Run the command:', contextAfter: 'You will see the error on purpose.' }, + ]; + + it('prepends tutorial context and inlines adjacent prose', () => { + const msg = buildUserMessage(blocks, [[]], { + frontmatter: 'title: Demo', + prerequisites: '- A dev container in GitHub Codespaces', + }); + expect(msg).toContain('## Tutorial context'); + expect(msg).toContain('title: Demo'); + expect(msg).toContain('dev container in GitHub Codespaces'); + expect(msg).toContain('Text before this block:\nRun the command:'); + expect(msg).toContain('Text after this block:\nYou will see the error on purpose.'); + expect(msg).toContain('cds watch'); + }); + + it('omits the context preamble when no frontmatter/prerequisites exist', () => { + const msg = buildUserMessage(blocks, [[]], {}); + expect(msg).not.toContain('## Tutorial context'); + expect(msg).toContain('cds watch'); + }); +}); + From a3f7eb5cd55be039983ca4111deab2bfbfa7df8b Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 24 Aug 2026 16:35:23 -0400 Subject: [PATCH 02/19] freshness: tighten system prompt for author noise + calibration - PRECISION: prefer no finding over a speculative one; report each issue once. - OUTPUT vs CODE: never flag staleness/secrets inside illustrative output blocks. - SEVERITY defined by reader impact (fails today / deprecated path / cosmetic). - SCOPE: skip prose, screenshots, product-name currency, deliberate simplifications. - GROUNDING: quote the offending token; flag training-data inferences as Low. Reframes the reviewer as helping the tutorial AUTHOR. Adds prompt-guard tests. --- srv/lib/freshness-detector.js | 11 ++++++---- test/unit/freshness-prompt-guard.test.js | 28 ++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/srv/lib/freshness-detector.js b/srv/lib/freshness-detector.js index dd9486017..0c086c2da 100644 --- a/srv/lib/freshness-detector.js +++ b/srv/lib/freshness-detector.js @@ -84,15 +84,18 @@ export const FRESHNESS_TOOL_SPEC = { // ─── Prompt ─────────────────────────────────────────────────────────────────── export const SYSTEM_PROMPT = [ - 'You are a technical reviewer detecting STALE code and dependencies in SAP developer tutorials.', + 'You are a technical reviewer helping the AUTHOR of an SAP developer tutorial find code and dependency issues that would trip up a reader following the tutorial today.', 'You are given the tutorial\'s frontmatter and prerequisites, and, per code block, the prose immediately before and after it plus grounding context retrieved from official SAP docs.', 'Report obsolete dependencies, deprecated/superseded APIs, dated idioms, hardcoded secrets, and broken step flow.', + 'PRECISION: The author acts on every finding, so prefer reporting NOTHING over a speculative one. If you are not confident an issue would actually trip up a reader today, omit it. Report each distinct issue ONCE — do not repeat the same stale dependency across every block it appears in.', 'CONTEXT: Judge every block IN THE CONTEXT of its surrounding prose and the tutorial as a whole — never in isolation. The prerequisites define the reader\'s environment; for example a dev container in VS Code or GitHub Codespaces already provides a shell and the required toolchain, so do NOT flag setup the prerequisites already establish.', + 'OUTPUT vs CODE: Many fenced blocks are illustrative OUTPUT — terminal or log output, HTTP responses, JSON payloads, directory trees, error messages — not code the reader writes. Never report staleness or secrets inside an output block.', 'RESPECT INTENT: Do NOT report something as a problem when the surrounding text shows it is intentional. Examples: an error or warning the tutorial deliberately triggers and then explains in the following paragraph; sample or illustrative credentials such as a base64-encoded demo user (e.g. "alice:") or obvious placeholder tokens. Only raise a hardcoded-secret finding when the value is a real, sensitive credential a reader would ship to production — never for demo values the tutorial is showing on purpose.', 'SAP CONVENTIONS: Follow official SAP/CAP guidance and do NOT propose fixes that contradict it. In particular, do NOT suggest pinning versions of @sap/* packages (such as @sap/cds or @sap/cds-dk) in npm install commands — CAP guidance is to install the latest. Do not invent generic best-practice advice that conflicts with how SAP tutorials are meant to be followed.', - 'RULES: Echo back the exact stepRef and codeBlockIndex you were given — never invent locations.', - 'Every finding MUST carry a confidence tier. If an API-obsolescence claim is NOT supported by the provided grounding context, set confidence to "Low" and leave groundingSource empty.', - 'Prefer High confidence only for clear, verifiable staleness (e.g. a dependency with a native replacement, a real hardcoded credential).', + 'SCOPE: Review only code and dependencies. Do NOT review prose, screenshots, UI labels, product-name currency, external links, or deliberate simplifications (placeholder values like , or notes such as "we skip error handling for brevity").', + 'SEVERITY reflects impact on a reader following the tutorial today. High: the step fails outright (a removed API, a retired service, or a broken install). Medium: it works but uses a deprecated path that will break soon or teaches a bad habit. Low: cosmetic or stylistic.', + 'LOCATIONS: Echo back the exact stepRef and codeBlockIndex you were given — never invent locations.', + 'GROUNDING & CONFIDENCE: Every finding MUST carry a confidence tier and quote the exact offending token in `evidence`. If an API-obsolescence claim is NOT supported by the provided grounding context — i.e. you are inferring from training data — say so in `evidence`, set confidence to "Low", and leave groundingSource empty. Prefer High confidence only for clear, verifiable staleness backed by grounding or the code itself (e.g. a dependency with a native replacement, a real hardcoded credential).', ].join(' '); export function buildUserMessage(blocks, groundingByBlock, tutContext = {}) { diff --git a/test/unit/freshness-prompt-guard.test.js b/test/unit/freshness-prompt-guard.test.js index 79ba9bfeb..78cb2141a 100644 --- a/test/unit/freshness-prompt-guard.test.js +++ b/test/unit/freshness-prompt-guard.test.js @@ -29,6 +29,34 @@ describe('SYSTEM_PROMPT context + SAP guidance', () => { expect(SYSTEM_PROMPT).toMatch(/@sap/); expect(SYSTEM_PROMPT).toMatch(/pin/i); }); + + it('biases toward precision: omit speculative findings, report each issue once', () => { + expect(SYSTEM_PROMPT).toMatch(/prefer reporting nothing|omit it/i); + expect(SYSTEM_PROMPT).toMatch(/once/i); + expect(SYSTEM_PROMPT).toMatch(/author/i); + }); + + it('distinguishes illustrative output blocks from code', () => { + expect(SYSTEM_PROMPT).toMatch(/output/i); + expect(SYSTEM_PROMPT).toMatch(/terminal|log|directory tree|HTTP/i); + }); + + it('defines severity by reader impact', () => { + expect(SYSTEM_PROMPT).toMatch(/severity/i); + expect(SYSTEM_PROMPT).toMatch(/removed API|retired service|broken install/i); + expect(SYSTEM_PROMPT).toMatch(/deprecated path/i); + }); + + it('scopes out prose, screenshots, and deliberate simplifications', () => { + expect(SYSTEM_PROMPT).toMatch(/scope/i); + expect(SYSTEM_PROMPT).toMatch(/screenshots|prose|external links/i); + expect(SYSTEM_PROMPT).toMatch(/simplification|your-subaccount|brevity/i); + }); + + it('requires quoting the offending token and flagging training-data inferences', () => { + expect(SYSTEM_PROMPT).toMatch(/exact offending token/i); + expect(SYSTEM_PROMPT).toMatch(/training data/i); + }); }); describe('buildUserMessage', () => { From 3f69ec7c5ab21fa64b6c4d37c005936e27041232 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 24 Aug 2026 18:33:15 -0400 Subject: [PATCH 03/19] perf(rebuild): cache node_modules to skip ~48s npm ci on unchanged lockfiles setup-node's 'cache: npm' already warms ~/.npm, but 'npm ci' still spends ~48s extracting/linking 1680 packages + native builds into node_modules on every rebuild run (verified in run 32783495862: cache restored, yet 'added 1680 packages in 48s'). Cache the materialized node_modules (root + hugo-apps) on an exact lockfile key and skip the install on hit. Exact-key only (no restore-keys): skipping 'npm ci' means a mismatched tree would go unreconciled, so any lockfile change must miss and reinstall clean. Safe because neither package.json has install/prepare/postinstall lifecycle scripts -- nothing outside node_modules is produced at install time. --- .github/workflows/rebuild-content.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/.github/workflows/rebuild-content.yml b/.github/workflows/rebuild-content.yml index d8a42837c..fe4d1d116 100644 --- a/.github/workflows/rebuild-content.yml +++ b/.github/workflows/rebuild-content.yml @@ -273,7 +273,31 @@ jobs: prod) echo "srv_url=${{ secrets.CAP_SRV_URL_PROD }}" >> "$GITHUB_OUTPUT" ;; esac + # Cache the *materialized* node_modules (root + hugo-apps), not just the + # npm download cache. `setup-node` `cache: npm` already warms ~/.npm, but + # `npm ci` still spends ~48s extracting/linking 1680 packages + running + # native builds (better-sqlite3) into node_modules on every run. An + # exact-key cache (keyed on both lockfiles + OS + Node major) lets us skip + # the install entirely when the lockfiles are unchanged. + # + # EXACT match only — NO restore-keys. A node_modules restored for a + # different lockfile would be silently wrong because we skip `npm ci` + # (which would otherwise reconcile). On any lockfile change the cache + # misses, the install below runs clean, and the new tree is re-cached. + # Safe to skip install on hit: neither package.json has install/prepare/ + # postinstall lifecycle scripts, so nothing outside node_modules is + # produced at install time. + - name: Cache node_modules + id: node-modules-cache + uses: actions/cache@v4 + with: + path: | + node_modules + hugo-apps/node_modules + key: node-modules-${{ runner.os }}-node${{ env.NODE_VERSION }}-${{ hashFiles('package-lock.json', 'hugo-apps/package-lock.json') }} + - name: Install dependencies + if: steps.node-modules-cache.outputs.cache-hit != 'true' env: NODE_AUTH_TOKEN: ${{ secrets.PACKAGES_READ_TOKEN || secrets.GITHUB_TOKEN }} run: | From 18dc7dbd6272a587e491780f974b8642cf3657e2 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 24 Aug 2026 19:08:56 -0400 Subject: [PATCH 04/19] docs(openspec): propose slug-targeted delta rebuild (O(changed) end-to-end) Design + specs + tasks for making a single-tutorial rebuild scale with changed slugs instead of the full corpus. Four independently-shippable workstreams, grounded in code research (file:line): - content-delta-publish: mutable ContentCurrent + append-only ContentHistory replaces full-snapshot-per-version + carryForwardUnchanged (~95s server cost) - generated-content-cache: cache hugo/content/tutorials (~25MB) with a correctness-first key (hashes /build feeds + parser source + per-slug source) - single-slug-render: scoped Hugo render (R2 verified coherent) - tutorial-discovery: unmask GraphQL errors + fix App-token org-node auth + loud REST fallback Companion CI win (node_modules cache) shipped separately in #2016. --- .../.openspec.yaml | 2 + .../slug-targeted-delta-rebuild/design.md | 82 +++++++++++++++++++ .../slug-targeted-delta-rebuild/proposal.md | 31 +++++++ .../specs/content-delta-publish/spec.md | 54 ++++++++++++ .../specs/generated-content-cache/spec.md | 38 +++++++++ .../specs/single-slug-render/spec.md | 23 ++++++ .../specs/tutorial-discovery/spec.md | 27 ++++++ .../slug-targeted-delta-rebuild/tasks.md | 65 +++++++++++++++ 8 files changed, 322 insertions(+) create mode 100644 openspec/changes/slug-targeted-delta-rebuild/.openspec.yaml create mode 100644 openspec/changes/slug-targeted-delta-rebuild/design.md create mode 100644 openspec/changes/slug-targeted-delta-rebuild/proposal.md create mode 100644 openspec/changes/slug-targeted-delta-rebuild/specs/content-delta-publish/spec.md create mode 100644 openspec/changes/slug-targeted-delta-rebuild/specs/generated-content-cache/spec.md create mode 100644 openspec/changes/slug-targeted-delta-rebuild/specs/single-slug-render/spec.md create mode 100644 openspec/changes/slug-targeted-delta-rebuild/specs/tutorial-discovery/spec.md create mode 100644 openspec/changes/slug-targeted-delta-rebuild/tasks.md diff --git a/openspec/changes/slug-targeted-delta-rebuild/.openspec.yaml b/openspec/changes/slug-targeted-delta-rebuild/.openspec.yaml new file mode 100644 index 000000000..4102db8a4 --- /dev/null +++ b/openspec/changes/slug-targeted-delta-rebuild/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-24 diff --git a/openspec/changes/slug-targeted-delta-rebuild/design.md b/openspec/changes/slug-targeted-delta-rebuild/design.md new file mode 100644 index 000000000..4332a7c25 --- /dev/null +++ b/openspec/changes/slug-targeted-delta-rebuild/design.md @@ -0,0 +1,82 @@ +## Context + +A slug-targeted rebuild of one tutorial takes ~4m41s (PROD run `gh run 32783495862`). The slug filter already scopes the GitHub *download* and the *delta publish set*, but three stages still do O(full-corpus) work: + +- **Server publish (~95s).** Content is a full snapshot per version: `ContentFiles` is keyed `(slug, version)` (`db/_content-shape.cds:12-42`) and every reader filters `version = activeVersion`. `carryForwardUnchanged` (`srv/lib/content-publish-session.js:1192-1303`) re-reads and re-inserts all ~11,253 unchanged BLOBs into the new version on every publish. The reported `Files: 11254 / 758.5 MB / 94698 ms` is that full snapshot being written. +- **Fetch/regenerate (~56s).** `scripts/fetch-tutorials.ts` regenerates all ~1,430 generated `.md` from cache and runs full-catalog discovery, even for one changed slug. +- **GraphQL discovery.** The GitHub App installation token cannot resolve the org-level GraphQL node; an error-masking bug (`scripts/parsers/github.ts:229-234`) hides the real error and degrades to ~1,400 slower REST calls silently. + +Install (~48s) was already cut by node_modules caching (PR #2016). + +Constraints (from CLAUDE.md / memory): HANA LOB-locators expire when BLOBs are mixed with metadata in one CQL query — BLOB reads stay on raw `db.run()`. The QA channel shares `_content-shape.cds`; every `srv/lib/` reader touched must be re-audited against the `srv-qa` `cp` list in `.deploy/mta.yaml`. `.hdbmigrationtable` artifacts are generated by `cds build --production`, never hand-edited. PRs target DEV; `main` is protected. + +## Goals / Non-Goals + +**Goals:** +- Make a single-tutorial rebuild O(changed slugs) end-to-end (~1 min target). +- Preserve correctness: served content, nav, drift-detection, and rollback behavior are unchanged from the operator's perspective. +- Ship the four workstreams independently, each flag-gated, DEV-first, with a rollback. + +**Non-Goals:** +- Caching `hugo/public` (591 MB) — restore cost would rival the savings; we cache `hugo/content/tutorials/` (~25 MB) instead. +- Changing the public publish/rollback API surface or the "Rebuild this tutorial" UX. +- Re-architecting the Hugo build engine (still `hugo --minify` over the content tree). +- Touching pre-cutover progress/NGDS semantics. + +## Decisions + +### D1 — Publish scoping via mutable current table + append-only history (Option B) +Introduce `ContentCurrent` (one row per slug, no version column, read directly by the serve path) and `ContentHistory` (append-only, per `(version, slug)`, records `contentHash`/`sourceHash`/`sizeBytes`/`action` and — see D2 — the BLOB). `ContentManifest` stays as the version ledger/lock/pipeline anchor. Publish UPSERTs only changed slugs into `ContentCurrent` and appends history rows; `carryForwardUnchanged` is deleted. + +- **Why over the alternatives:** copy-on-write with `MAX(version) ≤ active` per-slug resolution (Option A) forces every reader into a correlated/window query on a growing table — higher risk and worse read latency. A per-slug version-pointer table (Option C) keeps O(corpus) pointer rows per version. A carry-forward marker/stub (Option D) removes BLOB copies but still inserts ~11K rows per publish and complicates the read path with a fallback. Option B makes reads *simpler and faster* (no version join) and publish truly O(changed), at the cost of a rollback rework (D2). +- **Reader migration:** ~20 readers switch from `version = activeVersion` to `slug`-only against `ContentCurrent`; the `getActiveVersion()` helper (duplicated in 6+ places) becomes reader-dead and survives only for history/rollback. Hot path `serveStoredSlug` (`content-store.js:896-930`) keeps its raw `db.run()` BLOB fetch (LOB-locator constraint). + +### D2 — History stores BLOBs; rollback replays +`ContentHistory` carries the content BLOB per `(version, slug)` so rollback is self-contained: replay the target version's rows into `ContentCurrent` (re-insert slugs deleted since, delete slugs added since). Aggressively GC old history (repurpose `cleanupContentVersions`, `srv/jobs/cleanup.js:72-97`). + +- **Why over hash-only history:** hash-only would need a separate bounded blob archive and a fallback read path — more moving parts. Blobs-in-history keeps rollback a single-table replay; the space cost is bounded by history GC. `detectReverts` (`content-publish-session.js:306-387`) reads only the thin hash columns, so it is unaffected by blob width and actually gets cheaper (dedicated history table vs. scanning versioned `ContentFiles`). + +### D3 — Re-key the three version-keyed caches +`chrome-shell.js` (`__shell__`, cache keyed by version at `:277-284`), `concept-list-page.js` (module cache keyed by version), and `tutorial-step-slicer.js` (`slice:::`) switch to a monotonic **generation token** bumped on every commit (or per-slug `sourceVersion`). Without this, a delta publish that no longer bumps a single global version for every slug would serve stale cached content. + +### D4 — Generated-content cache with a correctness-first key +Cache `hugo/content/tutorials/` (git-ignored, ~25 MB) via `actions/cache`. Key = hash of: parser/generator source (`scripts/parsers/*`, `fetch-tutorials.ts`, `expand-ai-authored.ts`) + `/build/catalog` + `/build/co-completions` + `/build/tag-labels` payloads + per-slug source (`.md` sha + `rules.vr` ETag). On a hit with unchanged globals, regenerate only the changed slug; any global change misses the key and forces full regen. + +- **Why the broad key:** the frontmatter patch (`fetch-tutorials.ts:1285-1318`) is a *global cross-tutorial recompute* — `prev/next/mission*/recommendations/displayTags` on a tutorial depend on catalog/nav/tag-labels, so a naive "regen only changed slug" would ship stale nav on siblings. Hashing the feed payloads makes the common case (edit one tutorial's markdown) fast while any structural change stays correct. +- **navEntries sidecar:** nav + `browse.json` need `navEntries` for *all* slugs, currently produced only by composing each tutorial. Persist a `navEntries` sidecar so cached slugs contribute without recomposition. + +### D5 — Scoped Hugo render +Render the changed tutorial + always-regenerated aggregate pages only. Verified safe (R2): per-tutorial pages bake prev/next/breadcrumb from their own frontmatter; mission-nav/related/recommendations are client-hydrated; the only render-time cross-reads (`site.GetPage` title enrichment, `author_index.json`) degrade gracefully. Aggregates (homepage/browse/topics/verb/navigator/sitemap) are already produced by always-run fetchers. + +### D6 — GraphQL discovery: unmask + fix auth + loud fallback +(a) In `graphqlRequest` (`github.ts:229-234`), throw on `json.errors?.length` or null `data`, including error type/message. (b) Route the classic PAT (`TUTORIALS_GITHUB_TOKEN`) to the *fetch* step (which needs org-level GraphQL) while the App token stays on the dispatch/Actions paths — OR make discovery repo-oriented (drop the `organization(login:)` root, enumerate via REST `GET /orgs/{org}/repos`, GraphQL only per-`repository()`). (c) Emit an error-level log when degrading to REST. + +- **Why unmask first, regardless:** it is a one-line safety fix that surfaces the true cause and confirms the Phase-2 failure; it ships independently ahead of the auth decision. + +## Risks / Trade-offs + +- **Rollback semantics change (D1/D2).** Today's free metadata-flip becomes a replay → *Mitigation:* blobs-in-history makes replay single-table; keep `ContentFiles` read-only for one release as a fallback; add a hybrid test that publishes → rolls back → asserts byte-identical content. +- **Stale cache after delta publish (D3).** A missed re-key ships stale shell/concept/step content → *Mitigation:* single generation token bumped in the commit tx; unit tests asserting cache invalidation on delta publish for all three caches. +- **Generated-content cache ships stale nav (D4).** Under-scoped key → stale sibling frontmatter → *Mitigation:* hash the three `/build` feed payloads + parser source into the key; fail-open to full regen; a guard that full-regens if the sidecar is missing/inconsistent. +- **QA-channel drift.** New aspects/readers not mirrored → QA boot crash at deploy → *Mitigation:* add `ContentCurrent`/`ContentHistory` to both namespaces and re-audit the `srv-qa` `cp` list per touched `srv/lib/` file. +- **HANA LOB-locator regressions.** A migrated reader that mixes BLOB + metadata in one CQL query → *Mitigation:* keep all BLOB reads on raw `db.run()`; hybrid tests against real HANA. +- **Migration correctness.** Seeding `ContentCurrent` wrong → mass 404 → *Mitigation:* seed from the current ACTIVE snapshot (already deduped, one row per slug); verify byte-identical serve before/after on DEV; dark-launch behind a read flag. +- **GraphQL auth choice (D6).** Reverting to PAT re-expands a surface the App migration narrowed → *Mitigation:* prefer the repo-oriented discovery (no org node) so neither a PAT nor an org permission is needed; decide in Open Questions. + +## Migration Plan + +Sequenced, each independently shippable to DEV first, then QA, then PROD, behind its own flag: + +1. **GraphQL unmask + loud fallback (D6a/c).** Lowest risk, no data change. Ship first; observe the real cause in DEV logs to finalize D6b. +2. **GraphQL auth/repo-oriented discovery (D6b).** After #1 confirms the cause. +3. **Generated-content cache + scoped render (D4/D5).** CI-only; fail-open. Verify slug rebuild produces identical `hugo/public` to a full build on DEV. +4. **Publish scoping (D1/D2/D3).** The largest change. Steps: add aspects (both namespaces) + `cds build --production` migration tables → deploy with dual-write (write `ContentCurrent`/`ContentHistory` alongside legacy) behind a **write flag** → migrate-seed `ContentCurrent` → flip readers to `ContentCurrent` behind a **read flag** → verify serve + rollback on DEV → retire `carryForwardUnchanged` and legacy readers next release. + +**Rollback strategy:** each flag flips off to restore prior behavior; legacy `ContentFiles`/`ContentManifest` retained read-only for one release so the read flag can revert without data loss. + +## Open Questions + +- **D6b:** repo-oriented discovery (drop org node) vs. route PAT vs. grant the App an Org:Read permission — decide after the unmask (step 1) reveals the exact Phase-2 error. +- **History GC horizon (D2):** how many versions of blob history to retain vs. rollback reach — pick an N that bounds storage while covering realistic rollback windows. +- **Generation token shape (D3):** a single global monotonic token vs. per-slug `sourceVersion` for cache keys — the former is simpler; confirm it doesn't over-invalidate hot caches. +- **Scoped-render trigger:** reuse the existing rebuild `mode`/slug inputs, or add an explicit `scoped-render` flag gated on the generated-content cache being present? diff --git a/openspec/changes/slug-targeted-delta-rebuild/proposal.md b/openspec/changes/slug-targeted-delta-rebuild/proposal.md new file mode 100644 index 000000000..2408083ab --- /dev/null +++ b/openspec/changes/slug-targeted-delta-rebuild/proposal.md @@ -0,0 +1,31 @@ +## Why + +A slug-targeted content rebuild (hotfix to one tutorial) takes ~4m41s even though only one tutorial changed. Profiling a real PROD run (`gh run 32783495862`, slug `hxe-database-server`) showed the pipeline does **full-corpus work in three stages regardless of the slug filter**: the server-side publish re-writes all ~11,253 unchanged content BLOBs into a new version (~95s), the fetch stage regenerates all ~1,430 tutorials from cache and runs full-catalog discovery (~56s), and a GitHub-App-token GraphQL failure silently degrades discovery to ~1,400 slower REST calls. The install step (~48s) was already addressed by node_modules caching (PR #2016). The goal is to make a single-tutorial rebuild **O(changed slugs) end-to-end** (~1 minute), which is what operators expect from the "Rebuild this tutorial" button. + +## What Changes + +- **Server-side publish scoping (Option B).** Replace the full-snapshot-per-version content model with a mutable current table read directly by the serve path, plus an append-only history table for drift-detection and rollback. A publish writes **only changed slugs** instead of carrying forward the whole corpus. + - **BREAKING** (internal): `carryForwardUnchanged` is deleted; ~20 readers stop filtering on `version = activeVersion`; rollback changes from a metadata-flip to a replay-from-history; three version-keyed caches re-key off a generation token. +- **Generated-content CI cache.** Cache the git-ignored generated Hugo content tree (`hugo/content/tutorials/` ≈ 25 MB) across runs and regenerate **only the changed slug** when the global inputs are unchanged. The cache key hashes the CAP `/build` feeds, parser source, and per-slug source so any nav/catalog/tag change forces a full regen (correctness over speed). +- **Single-slug Hugo render.** Scope the Hugo render to the changed tutorial plus the always-regenerated aggregate pages, relying on the verified fact that per-tutorial pages bake nav from their own frontmatter and client-hydrate the rest. +- **GraphQL discovery fix.** Stop the error-masking in the GraphQL client (throw on GraphQL errors / null data), fix the auth path so discovery uses a token that can resolve the org node (or make discovery repo-oriented), and make any REST fallback loud instead of silent. + +## Capabilities + +### New Capabilities +- `content-delta-publish`: Server-side content persistence and serving from a mutable current-version table plus append-only history, so publishing writes only changed slugs; includes drift-detection and history-replay rollback. +- `generated-content-cache`: Deterministic caching of the generated Hugo content tree with a correctness-preserving invalidation key, enabling per-slug regeneration. +- `single-slug-render`: Scoped Hugo rendering of one tutorial page plus the always-regenerated aggregate pages. +- `tutorial-discovery`: Resilient GitHub tutorial discovery/metadata fetch — surfaced errors, correct auth for org-level GraphQL, and loud (not silent) REST fallback. + +### Modified Capabilities + + +## Impact + +- **CAP backend (`srv/`)**: `content-store.js`, `content-publish-session.js`, `chrome-shell.js`, `publish-concepts.js`, `concept-list-page.js`, `tutorial-step-slicer.js`, `embedding-pipeline.js`, `embedding-stats.js`, `admin-service.js`, `developer-service.js`, `freshness-detector.js`; jobs `embedding-reconciliation.js`, `cleanup.js`. +- **Data model (`db/`)**: new `ContentCurrent` + `ContentHistory` aspects in `_content-shape.cds` (shared with QA namespace); `.hdbmigrationtable` artifacts via `cds build --production`; one-time migration seeding `ContentCurrent` from the current ACTIVE version. +- **Build pipeline (`scripts/`)**: `fetch-tutorials.ts`, `scripts/parsers/*` (esp. `github.ts`, `compose.ts`, `render-frontmatter.ts`), new generated-content cache-key + sidecar for `navEntries`. +- **CI (`.github/workflows/rebuild-content.yml`)**: generated-content cache step + key; token routing for the fetch step; `rebuild-content-qa.yml` parity. +- **Constraints**: HANA LOB-locator (BLOB reads stay on raw `db.run()`); QA-channel parity + `srv-qa` `cp`-list audit; no hand-authored `.hdbmigrationtable` ALTERs; PRs target DEV; deploy/rollback per workstream behind flags. +- **Rollback safety**: each workstream is independently shippable and flag-gated; `ContentFiles`/`ContentManifest` retained read-only for one release as a fallback. diff --git a/openspec/changes/slug-targeted-delta-rebuild/specs/content-delta-publish/spec.md b/openspec/changes/slug-targeted-delta-rebuild/specs/content-delta-publish/spec.md new file mode 100644 index 000000000..cea9a7546 --- /dev/null +++ b/openspec/changes/slug-targeted-delta-rebuild/specs/content-delta-publish/spec.md @@ -0,0 +1,54 @@ +## ADDED Requirements + +### Requirement: Publish writes only changed slugs +The content publish path SHALL write only the slugs present in the publish payload and SHALL NOT copy forward unchanged content BLOBs. Publish cost MUST scale with the number of changed slugs, not the size of the corpus. + +#### Scenario: Single-slug publish touches one row +- **WHEN** a publish session commits with exactly one changed slug +- **THEN** exactly one content row is inserted/updated and no other slug's BLOB is read or rewritten +- **AND** the reported server commit time is independent of the total corpus size + +#### Scenario: Multi-slug publish touches only its payload +- **WHEN** a publish commits N changed slugs +- **THEN** exactly N content rows are written and unchanged slugs are left untouched + +### Requirement: Serve reads from a mutable current table +The serve path SHALL resolve a slug's current content from a single mutable current-content store keyed by slug alone, without joining on an active version. Every slug that is live MUST be served without requiring a full-snapshot version to exist. + +#### Scenario: Every live slug serves after a delta publish +- **WHEN** one slug is published as a delta and another slug was published in a prior publish +- **THEN** both slugs serve their latest content with a 200 response +- **AND** no slug returns 404 for being absent from the latest publish + +#### Scenario: Special slugs serve from current +- **WHEN** the serve path requests `__nav__`, `__shell__`, `__404__`, a `page-*`, `author-*`, `advocate-*`, or `concept-*` key +- **THEN** it is resolved from the current-content store the same way as tutorial slugs + +### Requirement: Caches invalidate on content change without a global version bump +Any content cache keyed on the old global active version (shell, concept-list, step-slicer) SHALL be re-keyed on a monotonic generation token or per-slug source version so that a delta publish that does not bump a global version still invalidates stale cached content. + +#### Scenario: Shell/concept/step caches refresh after delta publish +- **WHEN** a slug is delta-published +- **THEN** subsequent reads through the shell, concept-list, and step-slicer caches return the new content, not a stale cached copy + +### Requirement: Rollback replays from history +Rollback SHALL restore the current-content store to the state of a target version by replaying that version's content from an append-only history, including re-inserting slugs deleted since and removing slugs added since. Rollback MUST NOT depend on the target version's BLOBs remaining physically present as a separate live snapshot. + +#### Scenario: Rollback restores prior content +- **WHEN** an operator rolls back to a prior version +- **THEN** every slug's current content matches what was live at that version +- **AND** slugs added after the target version are removed and slugs deleted after it are restored + +### Requirement: Drift detection uses history +The no-revert / drift-detection guard SHALL determine whether an incoming publish reverts previously-published content by consulting per-slug, per-version source-hash history, and MUST continue to reject unintended reverts. + +#### Scenario: Revert of stale content is rejected +- **WHEN** a publish payload for a slug matches a source hash older than the current one +- **THEN** the publish is flagged as a revert and rejected unless explicitly overridden per slug + +### Requirement: Migration preserves currently-served content +A one-time migration SHALL seed the current-content store from the existing ACTIVE version so that all currently-served content is served identically after cutover, with the legacy snapshot tables retained read-only for one release. + +#### Scenario: No content changes across cutover +- **WHEN** the migration runs against the existing ACTIVE snapshot +- **THEN** every slug serves byte-identical content before and after cutover diff --git a/openspec/changes/slug-targeted-delta-rebuild/specs/generated-content-cache/spec.md b/openspec/changes/slug-targeted-delta-rebuild/specs/generated-content-cache/spec.md new file mode 100644 index 000000000..83a69051e --- /dev/null +++ b/openspec/changes/slug-targeted-delta-rebuild/specs/generated-content-cache/spec.md @@ -0,0 +1,38 @@ +## ADDED Requirements + +### Requirement: Generated content tree is cached across runs +The rebuild pipeline SHALL cache the generated Hugo content tree (`hugo/content/tutorials/`) between CI runs so that unchanged tutorials are not regenerated on a slug-targeted run. + +#### Scenario: Unchanged tutorials are not regenerated +- **WHEN** a slug-targeted rebuild runs with a warm generated-content cache and unchanged global inputs +- **THEN** only the changed slug's generated `.md` is re-derived +- **AND** the other tutorials' generated files are restored from cache without re-running the parser on them + +### Requirement: Cache key captures every cross-tutorial input +The generated-content cache key SHALL incorporate a hash of all inputs that can change an unchanged tutorial's generated output: the parser/generator source, the CAP `/build/catalog`, `/build/co-completions`, and `/build/tag-labels` feed payloads, and per-slug source (`.tutorial-cache/.md` and its `rules.vr` ETag). A change to any of these MUST force a full regeneration. + +#### Scenario: Catalog/nav change forces full regen +- **WHEN** the `/build/catalog`, `/build/co-completions`, or `/build/tag-labels` payload changes between runs +- **THEN** the cache key misses and every tutorial's frontmatter (prev/next/mission/recommendations/displayTags) is regenerated + +#### Scenario: Parser change forces full regen +- **WHEN** any parser/generator source file changes +- **THEN** the cache key misses and all tutorials are regenerated + +#### Scenario: Rules-only edit is not missed +- **WHEN** a tutorial's `rules.vr` changes but its `.md` does not +- **THEN** the cache key for that slug misses and its generated output is regenerated + +### Requirement: Nav graph is reconstructable without recomposing every tutorial +When unchanged tutorials are served from cache, the pipeline SHALL still assemble the complete nav graph and `browse.json` by reconstructing `navEntries` for cached slugs from a sidecar rather than by recomposing each tutorial. + +#### Scenario: browse.json and nav are complete on a cache hit +- **WHEN** a slug-targeted rebuild uses the generated-content cache +- **THEN** `browse.json` and the nav graph contain correct entries for all tutorials, not only the changed one + +### Requirement: Caching is opt-in and fail-open +The generated-content cache SHALL be behind a flag and MUST fail open — a cache miss, corruption, or flag-off condition falls back to full regeneration and never ships partial content. + +#### Scenario: Cache miss falls back to full build +- **WHEN** the generated-content cache is absent or the flag is off +- **THEN** the pipeline performs a full regeneration with no correctness difference from today diff --git a/openspec/changes/slug-targeted-delta-rebuild/specs/single-slug-render/spec.md b/openspec/changes/slug-targeted-delta-rebuild/specs/single-slug-render/spec.md new file mode 100644 index 000000000..bff57e9f6 --- /dev/null +++ b/openspec/changes/slug-targeted-delta-rebuild/specs/single-slug-render/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### Requirement: Slug-targeted render is scoped to changed slug plus aggregates +On a slug-targeted rebuild, the Hugo render SHALL produce the changed tutorial's page and the always-regenerated aggregate pages, and SHALL NOT depend on re-rendering unchanged tutorial pages for correctness of the changed page. + +#### Scenario: Changed tutorial renders correctly in isolation +- **WHEN** a single tutorial is rendered without re-rendering the other tutorials +- **THEN** its breadcrumb, prerequisites, steps, and prev/next links are correct (baked from its own frontmatter) +- **AND** mission side-nav, related concepts, and recommendations render their hydration hooks for client-side population + +### Requirement: Required precomputed inputs are present for a scoped render +A scoped render SHALL ensure the precomputed inputs the tutorial page reads at render time are current — specifically `hugo/data/author_index.json` — so author links and the "More from this author" rail are correct. + +#### Scenario: Author rail correct on scoped render +- **WHEN** a tutorial is rendered with a current `author_index.json` +- **THEN** the author link resolves to the internal author page (or degrades to GitHub only when the author is not indexed) and the author rail lists the correct sibling tutorials + +### Requirement: Aggregate pages are regenerated on every content-producing run +The homepage, `/browse/`, `/topics/`, verb pages, `/tutorial-navigator/`, and sitemap SHALL be regenerated on a slug-targeted run from the always-run fetcher data files, independently of the per-tutorial render scoping. + +#### Scenario: Aggregates reflect the change +- **WHEN** a slug-targeted rebuild completes +- **THEN** the aggregate pages reflect the current catalog and are published, not left stale diff --git a/openspec/changes/slug-targeted-delta-rebuild/specs/tutorial-discovery/spec.md b/openspec/changes/slug-targeted-delta-rebuild/specs/tutorial-discovery/spec.md new file mode 100644 index 000000000..48f2b6fd5 --- /dev/null +++ b/openspec/changes/slug-targeted-delta-rebuild/specs/tutorial-discovery/spec.md @@ -0,0 +1,27 @@ +## ADDED Requirements + +### Requirement: GraphQL errors are surfaced, not masked +The GraphQL client SHALL treat a response containing GraphQL-level errors or a null `data` field as a failure and MUST raise an error that includes the GraphQL error type and message, rather than returning the null data for callers to dereference. + +#### Scenario: GraphQL error raises with cause +- **WHEN** the GitHub GraphQL API returns an errors array or null `data` +- **THEN** the client raises an error whose message contains the GraphQL error type/message +- **AND** callers do not throw an opaque "cannot read properties of undefined" TypeError + +### Requirement: Discovery uses a token that can resolve its query +Tutorial discovery SHALL run with credentials capable of resolving its GraphQL query (the organization node), or SHALL be restructured to enumerate repositories without an org-level GraphQL node. Discovery MUST NOT silently fall back to a slower path due to a token-scope gap. + +#### Scenario: Discovery succeeds on the primary path +- **WHEN** a rebuild runs with the configured token +- **THEN** repository discovery completes on its primary path without falling back per-batch to REST + +### Requirement: Fallback is loud +If discovery or metadata fetch degrades to a REST fallback because of an auth/permission GraphQL error, the pipeline SHALL emit a clear error-level log line identifying the degradation and its cause, so a token-scope regression is visible rather than hidden as latency. + +#### Scenario: Degradation is logged at error level +- **WHEN** the GraphQL path fails with a permission/auth error and the pipeline falls back to REST +- **THEN** a single error-level log line states that discovery/metadata degraded to REST and includes the underlying cause + +#### Scenario: Correctness preserved on fallback +- **WHEN** the REST fallback is used +- **THEN** the discovered tutorial set and per-slug metadata are equivalent to the GraphQL path (no silent divergence) diff --git a/openspec/changes/slug-targeted-delta-rebuild/tasks.md b/openspec/changes/slug-targeted-delta-rebuild/tasks.md new file mode 100644 index 000000000..3fa73da92 --- /dev/null +++ b/openspec/changes/slug-targeted-delta-rebuild/tasks.md @@ -0,0 +1,65 @@ +## 1. Workstream A — GraphQL discovery unmask + loud fallback (ship first) + +- [ ] 1.1 In `scripts/parsers/github.ts` `graphqlRequest` (:229-234), throw on `json.errors?.length` or null/undefined `data`, including GraphQL error `type`/`message` in the thrown error. +- [ ] 1.2 Update callers (discovery :513-549, batch :763-777 / :826-840) so the thrown error is caught by their existing handlers and logged with cause. +- [ ] 1.3 Emit a single ERROR-level log line in `discoverAllTutorials` / `fetchGitHubMetaBatch` when degrading to REST due to a GraphQL auth/permission error (not just `[graphql-warn]`). +- [ ] 1.4 Unit test: a mocked GraphQL error/null-data response raises with the cause and does NOT produce an opaque TypeError. +- [ ] 1.5 Ship to DEV; run a rebuild and capture the real Phase-1/Phase-2 GraphQL error from logs to decide 2.x. + +## 2. Workstream B — GraphQL discovery auth fix + +- [ ] 2.1 Decide (from 1.5 evidence) between: repo-oriented discovery (drop `organization(login:)`, enumerate via REST `GET /orgs/{org}/repos`, GraphQL only per-`repository()`) vs. route classic PAT to the fetch step vs. grant the App Org:Read. Record the decision in design.md Open Questions. +- [ ] 2.2 Implement the chosen fix in `github.ts` and/or `.github/workflows/rebuild-content.yml` (:303-310 token step, :348 token routing). +- [ ] 2.3 Verify a DEV rebuild completes discovery on the primary path (no per-batch REST fallback) via log assertion. +- [ ] 2.4 Confirm discovered tutorial set + per-slug metadata are equivalent to the REST fallback (no divergence in contributors/createdAt). + +## 3. Workstream C — Generated-content cache + scoped render (CI-only, fail-open) + +- [ ] 3.1 Add a `navEntries` sidecar: persist per-slug nav entries during compose so cached slugs can contribute to nav + `browse.json` without recomposition (`fetch-tutorials.ts` around :1087/:1285-1329). +- [ ] 3.2 Compute a generated-content cache key hashing: parser/generator source (`scripts/parsers/*`, `fetch-tutorials.ts`, `expand-ai-authored.ts`), `/build/catalog` + `/build/co-completions` + `/build/tag-labels` payloads, and per-slug source (`.md` sha + `rules.vr` ETag). +- [ ] 3.3 Add an `actions/cache` step for `hugo/content/tutorials/` in `rebuild-content.yml` keyed on 3.2; behind a flag; fail-open to full regen on miss. +- [ ] 3.4 When the cache hits with unchanged globals, regenerate only the changed slug's generated `.md`; reconstruct nav/`browse.json` from the sidecar for cached slugs. +- [ ] 3.5 Scope the Hugo render to the changed slug + always-regenerated aggregate pages; ensure `hugo/data/author_index.json` is current for the scoped render. +- [ ] 3.6 Guard: a slug-targeted rebuild with the cache produces byte-identical `hugo/public` output for the changed tutorial vs. a full build (diff harness). +- [ ] 3.7 Verify on DEV: catalog/nav/tag-label change forces full regen; markdown-only edit hits the fast path; aggregates always reflect the change. +- [ ] 3.8 Mirror to `rebuild-content-qa.yml`. + +## 4. Workstream D — Publish scoping (Option B): schema + migration + +- [ ] 4.1 Add `ContentCurrent` and `ContentHistory` aspects to `db/_content-shape.cds` (per design.md D1/D2); mirror into the QA namespace (`db-qa/`). +- [ ] 4.2 Run `cds build --production` to generate `.hdbmigrationtable` artifacts for the new tables (no hand-authored ALTERs). +- [ ] 4.3 Write the seed migration: `INSERT INTO ContentCurrent SELECT ... FROM ContentFiles WHERE version = ` (one row per slug); optional `ContentHistory` backfill from retained versions. +- [ ] 4.4 `npx cds deploy --to sqlite::memory:` sanity + hybrid deploy check before commit. + +## 5. Workstream D — Publish scoping: write path + +- [ ] 5.1 Rewrite `appendToSession`/`commitSession` (`content-publish-session.js`) to UPSERT changed slugs into `ContentCurrent` and append `ContentHistory` rows; delete `carryForwardUnchanged` (:1192-1303). +- [ ] 5.2 Migrate the legacy single-shot `publishHandler` (`content-store.js:286-795`) and concept render (`publish-concepts.js`) to the delta write path. +- [ ] 5.3 Bump a monotonic generation token inside the commit transaction (source for cache re-keying in 6.x). +- [ ] 5.4 Gate the new write path behind a **write flag** with dual-write to legacy tables for one release. +- [ ] 5.5 Hybrid test: single-slug publish writes exactly one `ContentCurrent` row and appends history; N-slug publish writes N. + +## 6. Workstream D — Publish scoping: readers + caches + +- [ ] 6.1 Migrate the hot serve path `serveStoredSlug` (`content-store.js:896-930`) to `WHERE slug=?` on `ContentCurrent`, keeping BLOB reads on raw `db.run()` (LOB-locator). +- [ ] 6.2 Migrate special-slug readers (`__404__`, `__nav__`, `__shell__`) and page/author/advocate/concept serve handlers. +- [ ] 6.3 Migrate catalog/listing/hash/source readers (`navHandlerFallback`, `hashesHandler`, `sourceHashesHandler`, `getTutorialSource`). +- [ ] 6.4 Migrate embeddings/jobs active-slug-set reads (`embedding-pipeline.js`, `embedding-stats.js`, `embedding-reconciliation.js`, `cleanup.js:pruneOrphanEmbeddings`, `admin-service.js:seedEmbeddings`) to `SELECT slug FROM ContentCurrent`. +- [ ] 6.5 Re-key the three version-keyed caches (`chrome-shell.js`, `concept-list-page.js`, `tutorial-step-slicer.js`) off the generation token / `sourceVersion`. +- [ ] 6.6 Gate reader cutover behind a **read flag**; keep legacy `ContentFiles` readers as the fallback path. +- [ ] 6.7 Unit tests: all three caches invalidate after a delta publish. + +## 7. Workstream D — Publish scoping: rollback + drift + GC + +- [ ] 7.1 Rewrite `rollbackHandler` (`content-store.js:1491-1539`) to replay the target version from `ContentHistory` into `ContentCurrent` (re-insert deleted, remove added). +- [ ] 7.2 Point `detectReverts` (`content-publish-session.js:306-387`) at `ContentHistory` for per-slug source-hash history; fast-path compares to `ContentCurrent.sourceHash`. +- [ ] 7.3 Repurpose `cleanupContentVersions` (`cleanup.js:72-97`) to GC `ContentHistory` (+ superseded manifests) instead of `ContentFiles`. +- [ ] 7.4 Hybrid test: publish → rollback → assert byte-identical served content for every slug; revert-of-stale still rejected. + +## 8. Cutover, verification, cleanup + +- [ ] 8.1 QA-channel parity: re-audit every touched `srv/lib/` file against the `srv-qa` `cp` list in `.deploy/mta.yaml`. +- [ ] 8.2 DEV verification: byte-identical serve before/after cutover; slug rebuild wall-clock measured (target ~1 min); full smoke suite. +- [ ] 8.3 Flip write flag, run migration-seed, flip read flag on DEV; soak; then QA; then PROD. +- [ ] 8.4 Next release: remove `carryForwardUnchanged`, legacy readers, and the read-only `ContentFiles`/`ContentManifest` fallback once soak is clean. +- [ ] 8.5 Update CLAUDE.md gotchas + memory (snapshot→current model, cache invalidation triggers, GraphQL token routing). From 874af87a23195633b6de6522deb4731c96d57814 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 24 Aug 2026 19:49:02 -0400 Subject: [PATCH 05/19] fix(fetch): unmask GraphQL discovery errors + loud REST fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit graphqlRequest warn-logged a GraphQL errors/null-data response and returned the null data, so callers dereferenced data.organization/data.repository and threw an opaque TypeError — which the outer catch mistook for an outage and SILENTLY degraded discovery to the slow per-slug REST path (~1400 extra calls, secondary-rate-limit risk) every rebuild. - graphqlRequest now throws a typed GraphqlError carrying the GraphQL error type+message; throws on errors[] or null data. - GraphqlError.isAuthError classifies FORBIDDEN/INSUFFICIENT_SCOPES/permission failures (the App-installation-token org-node gap). - discovery + metadata-batch fallbacks log at ERROR level (once) when the cause is an auth/permission error, so a token-scope regression is visible instead of hidden as latency. Workstream A of the slug-targeted-delta-rebuild design (#2017). The auth fix itself (repo-oriented discovery vs PAT vs App Org:Read) is Workstream B and gated on the real error this now surfaces on DEV. Test: test/unit/github-graphql-error-unmask.test.ts (6 cases). --- scripts/parsers/github.ts | 61 +++++++++++++++-- test/unit/github-graphql-error-unmask.test.ts | 66 +++++++++++++++++++ 2 files changed, 123 insertions(+), 4 deletions(-) create mode 100644 test/unit/github-graphql-error-unmask.test.ts diff --git a/scripts/parsers/github.ts b/scripts/parsers/github.ts index 3ea6edc29..32a53a1b3 100644 --- a/scripts/parsers/github.ts +++ b/scripts/parsers/github.ts @@ -207,7 +207,42 @@ interface GraphqlRequestOptions { failFastOn5xx?: boolean } -async function graphqlRequest(query: string, opts: GraphqlRequestOptions = {}): Promise { +interface GraphqlErrorEntry { + type?: string + message: string +} + +// Thrown when GitHub returns a 200 with a GraphQL-level `errors` array or a null +// `data` field. Previously graphqlRequest warn-logged and returned the null +// `data`, so callers dereferenced `data.organization`/`data.repository` and blew +// up with an opaque "Cannot read properties of undefined" TypeError — which the +// outer catch handlers then mistook for a generic outage and silently degraded +// to the slow REST fallback. Surfacing the real GraphQL error (with its `type`) +// lets the fallback log the true cause and lets us tell an auth/permission gap +// (App-token missing org-level GraphQL) apart from a transient blip. (#slug- +// targeted-delta-rebuild / tutorial-discovery) +export class GraphqlError extends Error { + readonly errors: GraphqlErrorEntry[] + constructor(errors: GraphqlErrorEntry[]) { + const summary = errors.map(e => (e.type ? `${e.type}: ${e.message}` : e.message)).join('; ') + super(`GraphQL error: ${summary || 'response contained no data'}`) + this.name = 'GraphqlError' + this.errors = errors + } + // Permission/scope failures are actionable (fix the token), not transient — so + // the fallback logs them at error level. A missing org-read on an App + // installation token surfaces as FORBIDDEN / INSUFFICIENT_SCOPES or a + // "not accessible by integration" message. + get isAuthError(): boolean { + return this.errors.some(e => { + const type = (e.type ?? '').toUpperCase() + if (type === 'FORBIDDEN' || type === 'INSUFFICIENT_SCOPES') return true + return /not accessible by integration|permission|forbidden|scope|must have/i.test(e.message ?? '') + }) + } +} + +export async function graphqlRequest(query: string, opts: GraphqlRequestOptions = {}): Promise { const token = process.env.GITHUB_TOKEN ?? process.env.TUTORIALS_GITHUB_TOKEN if (!token) throw new Error('GITHUB_TOKEN or TUTORIALS_GITHUB_TOKEN is required for GraphQL API') @@ -228,12 +263,19 @@ async function graphqlRequest(query: string, opts: GraphqlRequestOptions = {}): const json = await res.json() if (json.errors?.length) { - const msgs = json.errors.map((e: any) => e.message).join('; ') - console.warn(` [graphql-warn] ${msgs}`) + throw new GraphqlError(json.errors as GraphqlErrorEntry[]) + } + if (json.data == null) { + throw new GraphqlError([{ message: 'GraphQL response contained null data' }]) } return json.data } +// One loud error per run is enough — the batch loop fires the catch ~once per +// 20-slug batch (~71× for the Tutorials repo), so gate the error-level line so +// it isn't buried under repetition. +let graphqlAuthFallbackWarned = false + function restAuthHeaders(): Record { const token = process.env.GITHUB_TOKEN ?? process.env.TUTORIALS_GITHUB_TOKEN if (!token) throw new Error('GITHUB_TOKEN or TUTORIALS_GITHUB_TOKEN is required for REST API') @@ -459,7 +501,14 @@ export async function discoverAllTutorials(): Promise { return { tutorials, source: 'github' } } catch (err) { const message = err instanceof Error ? err.message : String(err) - console.warn(`\n [graphql] Discovery failed (${message})`) + // Loud, not silent: a permission/scope GraphQL failure means discovery is + // running on the slow REST path every rebuild until a human fixes the token + // scope — surface it at error level so it's not hidden as mere latency. + if (err instanceof GraphqlError && err.isAuthError) { + console.error(` [graphql] ERROR: primary discovery degraded to REST due to a GraphQL permission/auth error (${message}). The fetch token cannot resolve the org-level GraphQL node — fix the token scope (see tutorial-discovery spec).`) + } else { + console.warn(`\n [graphql] Discovery failed (${message})`) + } try { console.warn(` [rest] Attempting REST API discovery fallback...`) @@ -866,6 +915,10 @@ export async function fetchGitHubMetaBatch( cache[slug] = meta } } catch (err) { + if (err instanceof GraphqlError && err.isAuthError && !graphqlAuthFallbackWarned) { + graphqlAuthFallbackWarned = true + console.error(` [graphql] ERROR: metadata fetch degraded to per-slug REST due to a GraphQL permission/auth error (${err.message}). This adds ~1 REST call per slug and risks secondary rate limits — fix the token scope (see tutorial-discovery spec).`) + } console.warn(` [warn] GraphQL batch failed for ${repo} batch ${Math.floor(i / BATCH_SIZE) + 1}: ${err instanceof Error ? err.message : err}; trying REST per-slug...`) for (const slug of batch) { if (results.has(slug)) continue diff --git a/test/unit/github-graphql-error-unmask.test.ts b/test/unit/github-graphql-error-unmask.test.ts new file mode 100644 index 000000000..8e7a8b42f --- /dev/null +++ b/test/unit/github-graphql-error-unmask.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { graphqlRequest, GraphqlError } from '../../scripts/parsers/github' + +// Regression guard for the tutorial-discovery workstream (#slug-targeted-delta-rebuild). +// Before this fix, graphqlRequest warn-logged a GraphQL `errors` response and +// returned the null `data`, so callers dereferenced `data.organization` / +// `data.repository` and threw an opaque "Cannot read properties of undefined" +// TypeError — which the outer catch mistook for an outage and silently degraded +// to the slow REST fallback. graphqlRequest must now THROW a GraphqlError that +// carries the real cause, and classify auth/permission failures. + +function mockFetchOnce(body: unknown) { + const res = { + ok: true, + status: 200, + json: async () => body, + text: async () => JSON.stringify(body), + headers: { get: () => null }, + } + // @ts-expect-error minimal Response stub for the test + global.fetch = vi.fn().mockResolvedValue(res) +} + +describe('graphqlRequest error unmasking', () => { + const prevToken = process.env.GITHUB_TOKEN + beforeEach(() => { process.env.GITHUB_TOKEN = 'test-token' }) + afterEach(() => { + if (prevToken === undefined) delete process.env.GITHUB_TOKEN + else process.env.GITHUB_TOKEN = prevToken + vi.restoreAllMocks() + }) + + it('throws GraphqlError (not a TypeError) when the response carries an errors array', async () => { + mockFetchOnce({ errors: [{ type: 'FORBIDDEN', message: 'Resource not accessible by integration' }], data: null }) + await expect(graphqlRequest('{ organization { name } }')).rejects.toBeInstanceOf(GraphqlError) + }) + + it('includes the GraphQL error type/message in the thrown error', async () => { + mockFetchOnce({ errors: [{ type: 'FORBIDDEN', message: 'Resource not accessible by integration' }] }) + await expect(graphqlRequest('{ organization { name } }')).rejects.toThrow(/FORBIDDEN.*not accessible by integration/) + }) + + it('flags permission/scope failures as auth errors', async () => { + mockFetchOnce({ errors: [{ type: 'FORBIDDEN', message: 'Resource not accessible by integration' }] }) + const err = await graphqlRequest('{ organization { name } }').catch(e => e) + expect(err).toBeInstanceOf(GraphqlError) + expect((err as GraphqlError).isAuthError).toBe(true) + }) + + it('does NOT flag a non-auth error (e.g. rate/timeout) as an auth error', async () => { + mockFetchOnce({ errors: [{ type: 'SERVICE_UNAVAILABLE', message: 'temporarily unavailable' }] }) + const err = await graphqlRequest('{ organization { name } }').catch(e => e) + expect(err).toBeInstanceOf(GraphqlError) + expect((err as GraphqlError).isAuthError).toBe(false) + }) + + it('throws GraphqlError (not a TypeError) on a null data field with no errors', async () => { + mockFetchOnce({ data: null }) + await expect(graphqlRequest('{ organization { name } }')).rejects.toBeInstanceOf(GraphqlError) + }) + + it('returns data unchanged on a successful response (no regression)', async () => { + mockFetchOnce({ data: { organization: { name: 'sap-tutorials' } } }) + await expect(graphqlRequest('{ organization { name } }')).resolves.toEqual({ organization: { name: 'sap-tutorials' } }) + }) +}) From 121f10ad2f4819eec4fa47706962c2571e04686d Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 24 Aug 2026 21:07:27 -0400 Subject: [PATCH 06/19] feat(rebuild): generated-content cache helpers (Workstream C foundation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure, tested helpers for the slug-targeted fast path: feed fingerprint (catalog/co-completions/tag-labels), sidecar read/write, fast-path eligibility decision, and navEntries-by-slug reconstruction. Fail-open throughout. Nothing calls these yet — the fetch-tutorials.ts reuse wiring (flag-gated, requires a byte-identical DEV diff-verify) + the actions/cache workflow step land next. Test: test/unit/content-cache.test.ts (15 cases). --- scripts/lib/content-cache.ts | 120 ++++++++++++++++++++++++++++++++ test/unit/content-cache.test.ts | 94 +++++++++++++++++++++++++ 2 files changed, 214 insertions(+) create mode 100644 scripts/lib/content-cache.ts create mode 100644 test/unit/content-cache.test.ts diff --git a/scripts/lib/content-cache.ts b/scripts/lib/content-cache.ts new file mode 100644 index 000000000..4e834b2a9 --- /dev/null +++ b/scripts/lib/content-cache.ts @@ -0,0 +1,120 @@ +// Generated-content cache helpers (Workstream C of slug-targeted-delta-rebuild). +// +// On a slug-targeted rebuild, fetch-tutorials regenerates the generated Hugo +// content (`hugo/content/tutorials/.md`) for ALL ~1400 tutorials from the +// markdown cache, even though only the target slug changed — the bulk of the +// ~56s "Fetch tutorials" cost (see the slug-targeted-delta-rebuild design, R1). +// +// The fast path reuses the previously-generated `.md` for non-target slugs +// instead of recomposing them. Correctness hinges on the fact that a non-target +// tutorial's generated output depends ONLY on: (a) the parser/generator source, +// and (b) the global CAP `/build` feeds (catalog / co-completions / tag-labels), +// which drive the cross-tutorial frontmatter patch (prev/next/mission/ +// recommendations/displayTags). The target slug is ALWAYS rebuilt, so its own +// source is never part of the reuse decision. +// +// Two gates protect the reuse, and BOTH must hold or we full-regenerate: +// 1. Parser-source hash — enforced by the CI `actions/cache` KEY (hashFiles of +// scripts/parsers/** + fetch-tutorials.ts). A parser change misses the +// cache entirely, so nothing is restored to reuse. +// 2. Feed fingerprint — enforced HERE at runtime. actions/cache keys can't +// hash the `/build` feeds (they're fetched after cache restore), so the +// sidecar records a fingerprint of the feeds at write time; a mismatch on +// the next run means the catalog/nav/tags changed and every tutorial's +// frontmatter must be re-patched → full regen. +// +// Everything here is pure + fail-open: any parse/IO error → treat as a cache +// miss (eligible=false) and the caller full-regenerates. + +import { createHash } from 'node:crypto' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' + +export const SIDECAR_VERSION = 1 + +export interface ContentCacheSidecar { + version: number + // sha256 of the global feed payloads at the time the cache was written. + feedFingerprint: string + // Per-slug nav entries (the same objects written to _nav.json) so non-target + // slugs can contribute to nav / browse.json without being recomposed. + navEntries: Record[] +} + +export interface FeedPayloads { + catalog: unknown + coCompletions: unknown + tagLabels: unknown +} + +// Stable JSON stringify (sorted keys) so semantically-identical feeds always +// hash identically regardless of key order / whitespace from the source. +function stableStringify(value: unknown): string { + if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null' + if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]` + const obj = value as Record + const keys = Object.keys(obj).sort() + return `{${keys.map(k => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(',')}}` +} + +// Deterministic fingerprint of the three global feeds. Any change to catalog +// (mission/group membership, ordering), co-completions (recommendations), or +// tag-labels (displayTags) flips the fingerprint → forces full regen. +export function computeFeedFingerprint(feeds: FeedPayloads): string { + const h = createHash('sha256') + h.update('catalog\0'); h.update(stableStringify(feeds.catalog)) + h.update('\0coCompletions\0'); h.update(stableStringify(feeds.coCompletions)) + h.update('\0tagLabels\0'); h.update(stableStringify(feeds.tagLabels)) + return h.digest('hex') +} + +// Read the sidecar; returns null on any fault (missing, malformed, wrong +// version) so the caller fail-opens to full regeneration. +export function readSidecar(path: string): ContentCacheSidecar | null { + try { + if (!existsSync(path)) return null + const parsed = JSON.parse(readFileSync(path, 'utf-8')) as ContentCacheSidecar + if (!parsed || parsed.version !== SIDECAR_VERSION) return null + if (typeof parsed.feedFingerprint !== 'string' || !Array.isArray(parsed.navEntries)) return null + return parsed + } catch { + return null + } +} + +export function writeSidecar(path: string, sidecar: ContentCacheSidecar): void { + writeFileSync(path, JSON.stringify(sidecar), 'utf-8') +} + +export interface FastPathDecision { + eligible: boolean + reason: string +} + +// Decide whether the slug-targeted fast path may reuse cached generated content. +// Eligible only when: the flag is on, this is a slug-targeted run, a valid +// sidecar was restored, and its feed fingerprint matches the current feeds. +export function decideFastPath(args: { + flagEnabled: boolean + isSlugTargeted: boolean + sidecar: ContentCacheSidecar | null + currentFingerprint: string +}): FastPathDecision { + if (!args.flagEnabled) return { eligible: false, reason: 'flag off (CONTENT_CACHE_FAST_PATH)' } + if (!args.isSlugTargeted) return { eligible: false, reason: 'not a slug-targeted run' } + if (!args.sidecar) return { eligible: false, reason: 'no valid sidecar restored (cache miss / first run)' } + if (args.sidecar.feedFingerprint !== args.currentFingerprint) { + return { eligible: false, reason: 'feed fingerprint changed (catalog/co-completions/tag-labels differ) — full regen' } + } + return { eligible: true, reason: 'parser source (cache key) + feeds unchanged — reusing cached non-target content' } +} + +// Build a slug -> navEntry lookup from the sidecar for reconstructing non-target +// nav data without recomposing. Slugs are compared lowercase (canonical). +export function navEntriesBySlug(sidecar: ContentCacheSidecar): Map> { + const map = new Map>() + for (const entry of sidecar.navEntries) { + const slug = typeof entry?.slug === 'string' ? entry.slug.toLowerCase() : null + if (slug) map.set(slug, entry) + } + return map +} diff --git a/test/unit/content-cache.test.ts b/test/unit/content-cache.test.ts new file mode 100644 index 000000000..00c5d2a83 --- /dev/null +++ b/test/unit/content-cache.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { writeFileSync, rmSync, existsSync } from 'node:fs' +import { + computeFeedFingerprint, + readSidecar, + writeSidecar, + decideFastPath, + navEntriesBySlug, + SIDECAR_VERSION, + type ContentCacheSidecar, +} from '../../scripts/lib/content-cache' + +const FEEDS = { catalog: { missions: [{ id: 1, slugs: ['a', 'b'] }] }, coCompletions: { a: ['b'] }, tagLabels: { cap: 'CAP' } } + +describe('computeFeedFingerprint', () => { + it('is stable regardless of object key order', () => { + const a = computeFeedFingerprint({ catalog: { x: 1, y: 2 }, coCompletions: {}, tagLabels: {} }) + const b = computeFeedFingerprint({ catalog: { y: 2, x: 1 }, coCompletions: {}, tagLabels: {} }) + expect(a).toBe(b) + }) + it('changes when the catalog changes', () => { + const base = computeFeedFingerprint(FEEDS) + const changed = computeFeedFingerprint({ ...FEEDS, catalog: { missions: [{ id: 1, slugs: ['a', 'b', 'c'] }] } }) + expect(changed).not.toBe(base) + }) + it('changes when tag-labels change', () => { + const base = computeFeedFingerprint(FEEDS) + const changed = computeFeedFingerprint({ ...FEEDS, tagLabels: { cap: 'SAP CAP' } }) + expect(changed).not.toBe(base) + }) + it('changes when co-completions change', () => { + const base = computeFeedFingerprint(FEEDS) + const changed = computeFeedFingerprint({ ...FEEDS, coCompletions: { a: ['b', 'c'] } }) + expect(changed).not.toBe(base) + }) +}) + +describe('readSidecar / writeSidecar', () => { + const path = join(tmpdir(), `content-cache-sidecar-test-${process.pid}.json`) + afterEach(() => { if (existsSync(path)) rmSync(path) }) + + it('round-trips a valid sidecar', () => { + const sidecar: ContentCacheSidecar = { version: SIDECAR_VERSION, feedFingerprint: 'abc', navEntries: [{ slug: 'x' }] } + writeSidecar(path, sidecar) + expect(readSidecar(path)).toEqual(sidecar) + }) + it('returns null when the file is missing', () => { + expect(readSidecar(join(tmpdir(), 'does-not-exist-xyz.json'))).toBeNull() + }) + it('returns null on malformed JSON', () => { + writeFileSync(path, '{not json', 'utf-8') + expect(readSidecar(path)).toBeNull() + }) + it('returns null on a version mismatch', () => { + writeFileSync(path, JSON.stringify({ version: 999, feedFingerprint: 'a', navEntries: [] }), 'utf-8') + expect(readSidecar(path)).toBeNull() + }) +}) + +describe('decideFastPath', () => { + const sidecar: ContentCacheSidecar = { version: SIDECAR_VERSION, feedFingerprint: 'fp', navEntries: [] } + it('not eligible when the flag is off', () => { + expect(decideFastPath({ flagEnabled: false, isSlugTargeted: true, sidecar, currentFingerprint: 'fp' }).eligible).toBe(false) + }) + it('not eligible on a full (non-slug-targeted) run', () => { + expect(decideFastPath({ flagEnabled: true, isSlugTargeted: false, sidecar, currentFingerprint: 'fp' }).eligible).toBe(false) + }) + it('not eligible with no sidecar (cache miss)', () => { + expect(decideFastPath({ flagEnabled: true, isSlugTargeted: true, sidecar: null, currentFingerprint: 'fp' }).eligible).toBe(false) + }) + it('not eligible when the feed fingerprint changed', () => { + const d = decideFastPath({ flagEnabled: true, isSlugTargeted: true, sidecar, currentFingerprint: 'DIFFERENT' }) + expect(d.eligible).toBe(false) + expect(d.reason).toMatch(/fingerprint changed/) + }) + it('eligible when flag on, slug-targeted, sidecar valid, fingerprint matches', () => { + expect(decideFastPath({ flagEnabled: true, isSlugTargeted: true, sidecar, currentFingerprint: 'fp' }).eligible).toBe(true) + }) +}) + +describe('navEntriesBySlug', () => { + it('maps entries by lowercase slug', () => { + const map = navEntriesBySlug({ version: SIDECAR_VERSION, feedFingerprint: 'x', navEntries: [{ slug: 'Foo-Bar' }, { slug: 'baz' }] }) + expect(map.get('foo-bar')).toEqual({ slug: 'Foo-Bar' }) + expect(map.get('baz')).toEqual({ slug: 'baz' }) + expect(map.size).toBe(2) + }) + it('skips entries without a string slug', () => { + const map = navEntriesBySlug({ version: SIDECAR_VERSION, feedFingerprint: 'x', navEntries: [{ slug: 'ok' }, { notslug: 1 } as never] }) + expect(map.size).toBe(1) + }) +}) From 759010a7f0ddceb6b717894ca47afbb302a2c659 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 24 Aug 2026 21:29:21 -0400 Subject: [PATCH 07/19] feat(rebuild): wire generated-content fast path (Workstream C, flag-gated off) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a slug-targeted run with CONTENT_CACHE_FAST_PATH=true, fetch-tutorials reuses the previously-generated .md + sidecar nav/author rows for non-target slugs, skipping composeTutorial/fetchRulesVr/AI-quiz/writeHugoPage — the bulk of Phase 3's ~56s. Two gates, both required, else full regen: - actions/cache KEY = parser-source hash (a parser change → cache miss → the per-slug existsSync guard falls through to recompose) - runtime feed fingerprint over CAP catalog + tag-labels (nav/mission/tags); co-completions excluded (empty on warm-cache runs, recommendations are client-hydrated) Sidecar (navEntries + authorRows + fingerprint) written to .content-cache/, cached by the same parser-hashed key as hugo/content/tutorials + image_dimensions.json. Fail-open throughout; flag defaults OFF so default behavior is byte-identical to today. Enabling on DEV (input content-cache=true) + a byte-identical diff-verify (task 3.6) is the follow-up before flipping the default. Helper unchanged behavior; content-cache tests green (15). --- .github/workflows/rebuild-content.yml | 30 +++++++++ .gitignore | 1 + scripts/fetch-tutorials.ts | 95 +++++++++++++++++++++++++++ scripts/lib/content-cache.ts | 10 ++- 4 files changed, 134 insertions(+), 2 deletions(-) diff --git a/.github/workflows/rebuild-content.yml b/.github/workflows/rebuild-content.yml index fe4d1d116..d0ca6e55c 100644 --- a/.github/workflows/rebuild-content.yml +++ b/.github/workflows/rebuild-content.yml @@ -83,6 +83,11 @@ on: required: false type: boolean default: false + content-cache: + description: 'EXPERIMENTAL (Workstream C / slug-targeted-delta-rebuild): on a slug-targeted run, reuse the generated-content cache to skip regenerating unchanged tutorials (the bulk of the Fetch step). Fail-open; default OFF. Enable to A/B the fast path on DEV before flipping it on by default.' + required: false + type: boolean + default: false env: NODE_VERSION: '22' @@ -360,6 +365,27 @@ jobs: echo "::add-mask::${VCAP}" echo "VCAP_SERVICES=${VCAP}" >> "$GITHUB_ENV" + # EXPERIMENTAL (Workstream C): restore the previously-generated content + # tree so a slug-targeted run can reuse it and skip regenerating unchanged + # tutorials. Keyed on the parser/generator SOURCE hash — a parser change + # misses the key, nothing is restored, and fetch-tutorials full-regenerates + # (the per-slug existsSync guard falls through). The run_id suffix + prefix + # restore-keys let each run save its updated tree/sidecar while restoring + # the most recent matching one (same pattern as the tutorial cache). The + # runtime feed-fingerprint gate (in fetch-tutorials) covers catalog/tag + # changes. Only runs when the flag is on; fail-open otherwise. + - name: Restore generated-content cache + if: ${{ inputs.content-cache == true && steps.mode.outputs.effective_mode != 'catalog-only' }} + uses: actions/cache@v4 + with: + path: | + hugo/content/tutorials + hugo/data/image_dimensions.json + .content-cache + key: content-tree-v1-${{ hashFiles('scripts/parsers/**', 'scripts/fetch-tutorials.ts', 'scripts/lib/content-cache.ts', 'scripts/lib/expand-ai-authored.ts') }}-${{ github.run_id }} + restore-keys: | + content-tree-v1-${{ hashFiles('scripts/parsers/**', 'scripts/fetch-tutorials.ts', 'scripts/lib/content-cache.ts', 'scripts/lib/expand-ai-authored.ts') }}- + - name: Fetch tutorials if: ${{ steps.mode.outputs.effective_mode != 'catalog-only' }} # [#357 followup] When force-cap-refetch is true, pass --force-cap so @@ -387,6 +413,10 @@ jobs: # telemetry line so the CI invariant regex keeps matching. AI_AUTHOR_BUILD_CAP: ${{ inputs.ai-author-build-cap }} CHAT_DEPLOYMENT_ID: ${{ secrets.CHAT_DEPLOYMENT_ID }} + # EXPERIMENTAL (Workstream C): enable the generated-content fast path. + # fetch-tutorials reuses cached non-target content when this is 'true' + # AND the run is slug-targeted AND the feed fingerprint matches. + CONTENT_CACHE_FAST_PATH: ${{ inputs.content-cache == true }} # [#601] Generate per-advocate profile-page markdown into # hugo/content/developer-advocates/ from /api/advocates. Runs on ALL diff --git a/.gitignore b/.gitignore index 51a2a4cd8..435aba87d 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ approuter/static/* !approuter/static/.well-known/*.template .tutorial-cache/ .tutorial-cache-qa/ +.content-cache/ hugo/data/image_dimensions.json hugo/data/homepage_shelves.json hugo/data/verb_definitions.json diff --git a/scripts/fetch-tutorials.ts b/scripts/fetch-tutorials.ts index 82e01cdf8..322c47b3c 100644 --- a/scripts/fetch-tutorials.ts +++ b/scripts/fetch-tutorials.ts @@ -7,6 +7,14 @@ import { flushDimensionsCache, populateImageDimensions, exportDimensionsForHugo import { composeTutorial } from './parsers/compose.js' import { discoverAllTutorials, fetchGitHubMetaBatch, fetchGitHubMeta, fetchRulesVr, fetchWithRetry, uploadDiscoveryToHana, saveDiscoveryBaseline, EXCLUDED_REPOS, type DiscoveredTutorial } from './parsers/github.js' import { fetchBuildCatalog, fetchCoCompletions, loadCapCache, saveCapCache, type BrowseFeaturedEntry } from './parsers/cap.js' +import { + computeFeedFingerprint, + readSidecar, + writeSidecar, + decideFastPath, + navEntriesBySlug, + SIDECAR_VERSION, +} from './lib/content-cache.js' import { parseRulesVrEnriched, collectAiGradedSpecs } from './parsers/rules.js' import { expandAiAuthoredQuestions, populateAiAuthoredSiblingMaps, type ExpandStats } from './lib/expand-ai-authored.js' import { loadAiQuizCache, saveAiQuizCache } from './lib/ai-quiz-cache.js' @@ -834,6 +842,49 @@ async function main() { // An empty map is returned on failure; all tags fall back to the heuristic. const tagRegistry = await fetchTagLabelRegistry() + // ── Content-cache fast path (Workstream C, flag-gated: CONTENT_CACHE_FAST_PATH) ── + // On a slug-targeted run, reuse the previously-generated content for non-target + // slugs instead of recomposing all ~1400 (the bulk of Phase 3's cost). TWO gates, + // both must hold or we full-regenerate: + // 1. The CI actions/cache KEY (parser-source hash) governs whether the generated + // tree + sidecar were even restored — a parser change misses the cache. + // 2. A runtime feed fingerprint over the CAP catalog + tag-labels — the + // deterministic drivers of non-target frontmatter (prev/next/mission/ + // displayTags). Co-completions are excluded: they are empty on warm-CAP-cache + // runs (fetched only on a cold cache in Phase 4) and their recommendations are + // client-hydrated, so they can't make a cached page's static output wrong. + // Fail-open everywhere: no sidecar / fingerprint mismatch / missing file → full regen. + const CONTENT_CACHE_FAST_PATH = process.env.CONTENT_CACHE_FAST_PATH === 'true' + // Sidecar lives in a dedicated dir cached by the SAME parser-source-hashed + // actions/cache key as hugo/content/tutorials, so a parser change busts both + // together (the generated .md files vanish → the per-slug existsSync guard + // falls through to recompose). Kept OUT of .tutorial-cache (whose key has no + // parser hash) and OUT of hugo/data (Hugo would load it as site.Data). + const contentSidecarPath = join(__dirname, '..', '.content-cache', 'content-cache-sidecar.json') + const decisionFingerprint = computeFeedFingerprint({ catalog: loadCapCache(), tagLabels: tagRegistry }) + const restoredSidecar = CONTENT_CACHE_FAST_PATH ? readSidecar(contentSidecarPath) : null + const fastPath = decideFastPath({ + flagEnabled: CONTENT_CACHE_FAST_PATH, + isSlugTargeted: !!tutorialSlugFilter, + sidecar: restoredSidecar, + currentFingerprint: decisionFingerprint, + }) + const reuseNavBySlug = restoredSidecar ? navEntriesBySlug(restoredSidecar) : new Map>() + const reuseAuthorRowsBySlug = new Map() + if (restoredSidecar?.authorRows) { + for (const row of restoredSidecar.authorRows as unknown as AuthorTutorialRow[]) { + const s = (row?.slug ?? '').toLowerCase() + if (!s) continue + const arr = reuseAuthorRowsBySlug.get(s) ?? [] + arr.push(row) + reuseAuthorRowsBySlug.set(s, arr) + } + } + let reusedCount = 0 + if (CONTENT_CACHE_FAST_PATH) { + console.log(`[content-cache] fast path ${fastPath.eligible ? 'ENABLED' : 'disabled'} — ${fastPath.reason}`) + } + mkdirSync(OUTPUT_DIR, { recursive: true }) const navEntries: TutorialNavEntry[] = [] @@ -849,6 +900,24 @@ async function main() { const tutStart = performance.now() const label = `[${idx + 1}/${allTutorials.length}] ${t.repo}/${t.slug}` try { + // Fast-path reuse: for a non-target slug on an eligible run, reuse the + // cached generated page + sidecar nav/author rows and skip compose / + // fetchRulesVr / AI-quiz / writeHugoPage entirely. Fail-open: if the + // generated file or the sidecar entry is missing, fall through to a + // normal (re)generation for this slug. + if (fastPath.eligible && tutorialSlugFilter && !tutorialSlugFilter.has(t.slug)) { + const cachedNav = reuseNavBySlug.get(t.slug.toLowerCase()) + const generatedFile = join(OUTPUT_DIR, `${t.slug}.md`) + if (cachedNav && existsSync(generatedFile)) { + navEntries.push(cachedNav as unknown as TutorialNavEntry) + for (const row of reuseAuthorRowsBySlug.get(t.slug.toLowerCase()) ?? []) authorRows.push(row) + reusedCount++ + cacheHits++ + console.log(`${label} [reused]`) + timings.push({ slug: t.slug, repo: t.repo, durationMs: performance.now() - tutStart }) + return + } + } let rawMd: string let lastUpdated = '' let createdAt = '' @@ -1381,6 +1450,32 @@ async function main() { const navPath = join(navJsonDir, '_nav.json') writeFileSync(navPath, JSON.stringify(navData, null, 2), 'utf-8') + // ── Content-cache sidecar (Workstream C) ── + // Persist the full post-Phase-4 navEntries + author rows + the feed fingerprint + // so the NEXT slug-targeted run can reuse non-target content (see the fast-path + // block above). Written on every content-producing run (full or slug-targeted) + // when the flag is on, so the sidecar always reflects the latest complete set. + // Guarded on a resolvable catalog; fail-open (never blocks the build). + if (CONTENT_CACHE_FAST_PATH) { + try { + const writeCatalog = loadCapCache() + if (writeCatalog) { + mkdirSync(dirname(contentSidecarPath), { recursive: true }) + writeSidecar(contentSidecarPath, { + version: SIDECAR_VERSION, + feedFingerprint: computeFeedFingerprint({ catalog: writeCatalog, tagLabels: tagRegistry }), + navEntries: navEntries as unknown as Record[], + authorRows: authorRows as unknown as Record[], + }) + console.log(`[content-cache] wrote sidecar: ${navEntries.length} nav entries, ${authorRows.length} author rows (${reusedCount} slug(s) reused this run)`) + } else { + console.log('[content-cache] sidecar not written (no CAP catalog available to fingerprint)') + } + } catch (err) { + console.warn(`[content-cache] sidecar write failed: ${err instanceof Error ? err.message : err}`) + } + } + if (target === 'vitepress') { // Also write to public/ so VitePress copies it to dist as a static asset const publicNavDir = join(__dirname, '..', 'site', 'public', 'tutorials') diff --git a/scripts/lib/content-cache.ts b/scripts/lib/content-cache.ts index 4e834b2a9..b532f9f43 100644 --- a/scripts/lib/content-cache.ts +++ b/scripts/lib/content-cache.ts @@ -38,12 +38,18 @@ export interface ContentCacheSidecar { // Per-slug nav entries (the same objects written to _nav.json) so non-target // slugs can contribute to nav / browse.json without being recomposed. navEntries: Record[] + // Per-slug author rows (one per tutorial) so reused slugs still contribute to + // author pages / "more from this author" without recomposition. + authorRows?: Record[] } export interface FeedPayloads { catalog: unknown - coCompletions: unknown tagLabels: unknown + // Optional: co-completions drive recommendations, which are empty on warm-CAP- + // cache runs and client-hydrated at render time, so they are excluded from the + // fingerprint by callers on the fast path. Kept optional for completeness/tests. + coCompletions?: unknown } // Stable JSON stringify (sorted keys) so semantically-identical feeds always @@ -62,7 +68,7 @@ function stableStringify(value: unknown): string { export function computeFeedFingerprint(feeds: FeedPayloads): string { const h = createHash('sha256') h.update('catalog\0'); h.update(stableStringify(feeds.catalog)) - h.update('\0coCompletions\0'); h.update(stableStringify(feeds.coCompletions)) + h.update('\0coCompletions\0'); h.update(stableStringify(feeds.coCompletions ?? null)) h.update('\0tagLabels\0'); h.update(stableStringify(feeds.tagLabels)) return h.digest('hex') } From fff5ac9dde6f4b3d747935bd4b28c9d88ffa76d7 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 24 Aug 2026 21:47:26 -0400 Subject: [PATCH 08/19] feat(db): add ContentCurrent + ContentHistory aspects/entities (Option B schema, Workstream D) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mutable current-content table (one row per slug, no version column) + append-only per-(version,slug) history carrying the BLOB for self-contained rollback replay (design.md D1/D2). Mirrored into the QA namespace. Verified: full model compiles + deploys to sqlite::memory:. NEXT (gated, not in this commit): .hdbmigrationtable staging for the two new HANA tables (db/src/ + db/last-dev/ regen via the project's cds-build-staging procedure) + hybrid deploy verify — the version-counter-hazard step. Then write path (dual-write, flag), readers, rollback. --- db-qa/schema.cds | 5 +++++ db/_content-shape.cds | 35 +++++++++++++++++++++++++++++++++++ db/schema.cds | 7 +++++++ 3 files changed, 47 insertions(+) diff --git a/db-qa/schema.cds b/db-qa/schema.cds index d09056120..906b527d1 100644 --- a/db-qa/schema.cds +++ b/db-qa/schema.cds @@ -7,6 +7,11 @@ entity ContentFiles : shared.ContentFilesAspect {} entity ContentManifest : shared.ContentManifestAspect {} +// Option B (slug-targeted-delta-rebuild) — QA-channel parity with prod. +entity ContentCurrent : shared.ContentCurrentAspect {} + +entity ContentHistory : shared.ContentHistoryAspect {} + // Plain-text projection of published Hugo HTML, indexed for full-text search. // Replaced (not versioned) on every publish so search reflects current content. @cds.autoexpose: false diff --git a/db/_content-shape.cds b/db/_content-shape.cds index d0cf714b2..35c9bd076 100644 --- a/db/_content-shape.cds +++ b/db/_content-shape.cds @@ -64,6 +64,41 @@ aspect ContentManifestAspect : managed { firstAppendAt : Timestamp; } +// Option B (slug-targeted-delta-rebuild): the MUTABLE current-content table — +// one row per slug, NO version column. Readers hit this directly (WHERE slug=?) +// instead of joining on the active manifest version, so a publish writes ONLY +// the changed slugs (no O(corpus) carry-forward). `sourceVersion` records the +// manifest version that last wrote this slug (audit + cache-generation token). +aspect ContentCurrentAspect : managed { + key slug : String(255); + content : LargeBinary; + contentHash : Sha256; + sizeBytes : Integer; + compressedBytes : Integer; + mimeType : String(100) default 'text/html'; + sourceContent : LargeBinary; + sourceHash : Sha256; + sourceVersion : Integer; +} + +// Option B: append-only per-(version, slug) history for drift-detection +// (detectReverts) + rollback replay. Carries the BLOB (`content`/`sourceContent`) +// so rollback is a self-contained replay into ContentCurrent (design.md D2); +// GC'd by the repurposed cleanupContentVersions. `action=DELETED` tombstones a +// slug removed at that version so rollback can re-add/remove correctly. +aspect ContentHistoryAspect : managed { + key version : Integer; + key slug : String(255); + action : String(10) enum { WRITTEN; DELETED; }; + content : LargeBinary; + contentHash : Sha256; + sizeBytes : Integer; + compressedBytes : Integer; + mimeType : String(100) default 'text/html'; + sourceContent : LargeBinary; + sourceHash : Sha256; +} + aspect TutorialBodyTextAspect : managed { key slug : String(255); bodyText : LargeString; diff --git a/db/schema.cds b/db/schema.cds index c2be7e051..07ffce7df 100644 --- a/db/schema.cds +++ b/db/schema.cds @@ -598,6 +598,13 @@ entity ContentFiles : shared.ContentFilesAspect {} entity ContentManifest : shared.ContentManifestAspect {} +// Option B (slug-targeted-delta-rebuild): mutable current-content table + +// append-only history. Coexist with ContentFiles/ContentManifest during the +// flag-gated dual-write migration; ContentFiles is retired one release after cutover. +entity ContentCurrent : shared.ContentCurrentAspect {} + +entity ContentHistory : shared.ContentHistoryAspect {} + // Plain-text projection of published Hugo HTML, indexed for full-text search. // Replaced (not versioned) on every publish so search reflects current content. @cds.autoexpose: false From dd85ef0a5b5ab6101b678de7961b31d7ec613dc3 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Tue, 25 Aug 2026 06:17:42 -0400 Subject: [PATCH 09/19] feat(publish): Option B dual-write to ContentCurrent + ContentHistory (Workstream D, flag-gated) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit commitSession now mirrors freshly-published slugs into the mutable ContentCurrent (UPSERT, one row per slug) + append-only ContentHistory (WRITTEN rows) when CONTENT_DELTA_WRITE_ENABLED=true, alongside the legacy ContentFiles write. Chunked BLOB read mirrors carryForwardUnchanged (raw db.run on HANA for the LOB-locator path, CQL on SQLite). Fail-SAFE: never throws into the commit tx — legacy ContentFiles stays the source of truth until the reader cutover. Tests (SQLite): test/unit/content-delta-dualwrite.test.js (4) — one-row-per-slug, UPSERT on republish, history append per version, flag-off writes neither. Existing commit-path tests still green (carryforward-memory + guard, 12). HANA LOB path is covered by the hybrid publish→rollback test (task 7.4). --- srv/lib/content-publish-session.js | 103 ++++++++++++++++++- test/unit/content-delta-dualwrite.test.js | 118 ++++++++++++++++++++++ 2 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 test/unit/content-delta-dualwrite.test.js diff --git a/srv/lib/content-publish-session.js b/srv/lib/content-publish-session.js index 98d9d3794..638d7b7e2 100644 --- a/srv/lib/content-publish-session.js +++ b/srv/lib/content-publish-session.js @@ -461,6 +461,21 @@ export function createSessionHelpers({ namespace }) { // srv/lib/content-store.js:320-378 so prod/SQLite parity is preserved. const { carriedForward, carriedSize } = await carryForwardUnchanged(namespace, newVersion, hanaTableName, getActiveVersion); + // Option B dual-write (Workstream D, flag-gated, fail-safe). Mirror the + // freshly-published slugs into the mutable ContentCurrent + append-only + // ContentHistory alongside the legacy ContentFiles write, so readers can be + // cut over behind a separate read flag with a safe fallback. Never throws + // into the commit tx — the legacy write remains the source of truth until + // the read cutover. + if (process.env.CONTENT_DELTA_WRITE_ENABLED === 'true') { + try { + const { written } = await dualWriteCurrentAndHistory(namespace, newVersion, freshSlugs, hanaTableName); + LOG.info(`[content/publish/commit] Option B dual-write: ${written} slug(s) → ContentCurrent/ContentHistory`); + } catch (err) { + LOG.error('[content/publish/commit] Option B dual-write failed (non-fatal; legacy ContentFiles write is source of truth):', err.message); + } + } + // Compute aggregated size after carry-forward for the manifest stats. const freshAgg = await SELECT.one.from(ContentFiles) .columns('count(*) as c', 'sum(sizeBytes) as s') @@ -1303,7 +1318,93 @@ async function carryForwardUnchanged(namespace, newVersion, hanaTableName, getAc } // --------------------------------------------------------------------------- -// Recompute TUTORIAL TaskRecords progress for any tutorial whose body content +// Option B dual-write (Workstream D, slug-targeted-delta-rebuild). When the +// CONTENT_DELTA_WRITE_ENABLED flag is on, mirror the freshly-published slugs +// into the mutable ContentCurrent table (UPSERT — one row per slug, no version) +// and append a WRITTEN row per (version, slug) to the append-only ContentHistory. +// This runs ALONGSIDE the legacy ContentFiles write during the migration window +// (dual-write), so readers can be cut over behind a separate read flag with a +// safe rollback to ContentFiles. Fail-SAFE: any fault here is logged and +// swallowed — it must never break the legacy commit (which remains the source +// of truth until the read cutover). +// +// BLOB handling mirrors carryForwardUnchanged: chunked, raw db.run on HANA to +// materialize LOBs as buffers (LOB-locator gotcha), CQL on SQLite. +async function dualWriteCurrentAndHistory(namespace, newVersion, freshSlugs, hanaTableName) { + if (!freshSlugs || freshSlugs.length === 0) return { written: 0 }; + const ents = cds.entities(namespace); + const { ContentFiles, ContentCurrent, ContentHistory } = ents; + if (!ContentCurrent || !ContentHistory) { + LOG.warn('[content/publish/commit] dual-write skipped — ContentCurrent/ContentHistory not in model'); + return { written: 0 }; + } + + const db = await cds.connect.to('db'); + const isHana = db.options?.kind === 'hana' || db.constructor?.name === 'HANAService'; + const CHUNK = 50; + let written = 0; + + for (let i = 0; i < freshSlugs.length; i += CHUNK) { + const chunk = freshSlugs.slice(i, i + CHUNK); + + let rows; + if (isHana) { + const placeholders = chunk.map(() => '?').join(', '); + const raw = await db.run( + `SELECT "SLUG", "CONTENT", "CONTENTHASH", "SIZEBYTES", "COMPRESSEDBYTES", "MIMETYPE", "SOURCECONTENT", "SOURCEHASH" + FROM "${hanaTableName()}" + WHERE "VERSION" = ? AND "SLUG" IN (${placeholders})`, + [newVersion, ...chunk] + ); + rows = raw.map((r) => ({ + slug: r.SLUG, content: r.CONTENT, contentHash: r.CONTENTHASH, + sizeBytes: r.SIZEBYTES, compressedBytes: r.COMPRESSEDBYTES, + mimeType: r.MIMETYPE, sourceContent: r.SOURCECONTENT, sourceHash: r.SOURCEHASH, + })); + } else { + rows = await SELECT.from(ContentFiles) + .columns('slug', 'content', 'contentHash', 'sizeBytes', 'compressedBytes', 'mimeType', 'sourceContent', 'sourceHash') + .where({ version: newVersion, slug: { in: chunk } }); + } + + const currentEntries = []; + const historyEntries = []; + for (const row of rows) { + const buf = Buffer.isBuffer(row.content) ? row.content : await toBuffer(row.content); + let srcBuf = null; + if (row.sourceContent != null) { + srcBuf = Buffer.isBuffer(row.sourceContent) ? row.sourceContent : await toBuffer(row.sourceContent); + } + currentEntries.push({ + slug: row.slug, content: buf, contentHash: row.contentHash, + sizeBytes: row.sizeBytes, compressedBytes: row.compressedBytes, + mimeType: row.mimeType, sourceContent: srcBuf, sourceHash: row.sourceHash ?? null, + sourceVersion: newVersion, + }); + historyEntries.push({ + version: newVersion, slug: row.slug, action: 'WRITTEN', content: buf, + contentHash: row.contentHash, sizeBytes: row.sizeBytes, compressedBytes: row.compressedBytes, + mimeType: row.mimeType, sourceContent: srcBuf, sourceHash: row.sourceHash ?? null, + }); + } + + // UPSERT ContentCurrent by replace (DELETE-then-INSERT keyed on slug) — + // portable across SQLite + HANA and avoids relying on native UPSERT. + const chunkSlugs = currentEntries.map((e) => e.slug); + if (chunkSlugs.length) { + await DELETE.from(ContentCurrent).where({ slug: { in: chunkSlugs } }); + await INSERT.into(ContentCurrent).entries(currentEntries); + // History is append-only keyed on (version, slug); a re-commit of the same + // version (idempotent retry) would duplicate-key, so clear this version's + // rows for the chunk first. + await DELETE.from(ContentHistory).where({ version: newVersion, slug: { in: chunkSlugs } }); + await INSERT.into(ContentHistory).entries(historyEntries); + written += currentEntries.length; + } + } + + return { written }; +} // was published in this version. appendToSession already calls the bulk // recompute when metadata is provided, but if a chunk arrived with body text // only (no metadata payload), the recompute would be skipped. Re-running here diff --git a/test/unit/content-delta-dualwrite.test.js b/test/unit/content-delta-dualwrite.test.js new file mode 100644 index 000000000..b4a61cca1 --- /dev/null +++ b/test/unit/content-delta-dualwrite.test.js @@ -0,0 +1,118 @@ +// test/unit/content-delta-dualwrite.test.js +// +// Workstream D (slug-targeted-delta-rebuild) — Option B dual-write guard. +// +// When CONTENT_DELTA_WRITE_ENABLED=true, commitSession mirrors the freshly- +// published slugs into the mutable ContentCurrent table (one row per slug, no +// version) + appends WRITTEN rows to ContentHistory, ALONGSIDE the legacy +// ContentFiles write. This test drives publishes on in-memory SQLite and +// asserts: (a) ContentCurrent is one-row-per-slug and UPSERTs on republish, +// (b) ContentHistory accumulates per version, (c) the flag OFF writes neither. +// +// HANA LOB-locator behavior is NOT exercised here (SQLite CQL path); that is +// covered by the hybrid publish→rollback test in Workstream D task 7.4. + +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import cds from '@sap/cds'; +import { gzipSync } from 'node:zlib'; +import { createSessionHelpers } from '../../srv/lib/content-publish-session.js'; + +const NS = 'com.sap.developers.ims'; + +cds.test('serve', '--project', '.', '--in-memory'); + +function html(s) { + return gzipSync(Buffer.from(`
${s}
`, 'utf-8')).toString('base64'); +} +function source(s) { + return gzipSync(Buffer.from(s, 'utf-8')).toString('base64'); +} +async function appendAll(helpers, sessionId, slugs) { + const files = {}; + const sources = {}; + for (const slug of slugs) { files[slug] = html(`body-${slug}`); sources[slug] = source(`src-${slug}`); } + await helpers.appendToSession({ sessionId, files, sources }); +} + +describe('Option B dual-write (Workstream D)', () => { + let helpers; + let ContentFiles, ContentManifest, ContentCurrent, ContentHistory, PipelineLog, JobLocks; + const prevFlag = process.env.CONTENT_DELTA_WRITE_ENABLED; + + beforeAll(() => { + helpers = createSessionHelpers({ namespace: NS }); + ({ ContentFiles, ContentManifest, ContentCurrent, ContentHistory, PipelineLog, JobLocks } = cds.entities(NS)); + }); + afterAll(() => { + if (prevFlag === undefined) delete process.env.CONTENT_DELTA_WRITE_ENABLED; + else process.env.CONTENT_DELTA_WRITE_ENABLED = prevFlag; + }); + beforeEach(async () => { + await DELETE.from(ContentFiles); + await DELETE.from(ContentManifest); + await DELETE.from(ContentCurrent); + await DELETE.from(ContentHistory); + await DELETE.from(PipelineLog); + await DELETE.from(JobLocks); + }); + + it('exposes ContentCurrent + ContentHistory entities', () => { + expect(ContentCurrent).toBeTruthy(); + expect(ContentHistory).toBeTruthy(); + }); + + it('writes ContentCurrent (one row per slug) + ContentHistory when the flag is ON', async () => { + process.env.CONTENT_DELTA_WRITE_ENABLED = 'true'; + const slugs = ['a', 'b', 'c']; + const s = await helpers.beginPublishSession({ trigger: 'ci/test', expectedSlugCount: slugs.length, initiator: 'test' }); + await appendAll(helpers, s.sessionId, slugs); + const res = await helpers.commitSession({ sessionId: s.sessionId }); + + const current = await SELECT.from(ContentCurrent).columns('slug', 'contentHash', 'sourceVersion', 'content'); + expect(current.map(r => r.slug).sort()).toEqual(['a', 'b', 'c']); + for (const row of current) { + expect(row.content, `ContentCurrent.${row.slug} has null content`).toBeTruthy(); + expect(row.sourceVersion).toBe(res.version); + } + const history = await SELECT.from(ContentHistory).columns('slug', 'version', 'action'); + expect(history.length).toBe(3); + expect(history.every(h => h.action === 'WRITTEN' && h.version === res.version)).toBe(true); + }, 60_000); + + it('UPSERTs ContentCurrent on republish (stays one row per slug) + appends history per version', async () => { + process.env.CONTENT_DELTA_WRITE_ENABLED = 'true'; + const slugs = ['a', 'b', 'c']; + const s1 = await helpers.beginPublishSession({ trigger: 'ci/test', expectedSlugCount: 3, initiator: 'test' }); + await appendAll(helpers, s1.sessionId, slugs); + const r1 = await helpers.commitSession({ sessionId: s1.sessionId }); + + // Republish only 'a' with new content. + const s2 = await helpers.beginPublishSession({ trigger: 'ci/test', expectedSlugCount: 1, initiator: 'test' }); + await helpers.appendToSession({ sessionId: s2.sessionId, files: { a: html('body-a-v2') }, sources: { a: source('src-a-v2') } }); + const r2 = await helpers.commitSession({ sessionId: s2.sessionId }); + + // ContentCurrent still has exactly one row for 'a', now at the new version. + const aRows = await SELECT.from(ContentCurrent).where({ slug: 'a' }); + expect(aRows.length).toBe(1); + expect(aRows[0].sourceVersion).toBe(r2.version); + // b + c unchanged rows remain (from v1) — dual-write only touches fresh slugs. + const all = await SELECT.from(ContentCurrent).columns('slug'); + expect(all.map(r => r.slug).sort()).toEqual(['a', 'b', 'c']); + + // History has 'a' at both versions (append-only). + const aHistory = await SELECT.from(ContentHistory).where({ slug: 'a' }); + expect(aHistory.map(h => h.version).sort((x, y) => x - y)).toEqual([r1.version, r2.version]); + }, 60_000); + + it('writes NEITHER table when the flag is OFF', async () => { + process.env.CONTENT_DELTA_WRITE_ENABLED = 'false'; + const s = await helpers.beginPublishSession({ trigger: 'ci/test', expectedSlugCount: 2, initiator: 'test' }); + await appendAll(helpers, s.sessionId, ['x', 'y']); + await helpers.commitSession({ sessionId: s.sessionId }); + + expect((await SELECT.from(ContentCurrent)).length).toBe(0); + expect((await SELECT.from(ContentHistory)).length).toBe(0); + // Legacy ContentFiles still written (source of truth). + expect((await SELECT.from(ContentFiles).columns('slug')).map(r => r.slug).sort()).toEqual(['x', 'y']); + }, 60_000); +}); From d2159473855d85357c58d0a15db2f2cd4ea5d270 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Tue, 25 Aug 2026 06:44:52 -0400 Subject: [PATCH 10/19] fix(#2023): stop Hugo YAML 1.1 coercing yes/no quiz options to booleans rules.vr answer options like [ ] no / [x] yes rendered as false/true (and a yes/no correctAnswer flipped to a boolean) because the frontmatter serializer emits bare yes/no/on/off tokens under YAML 1.2, which Hugo's YAML 1.1 parser then reads as booleans. jsonify ships those booleans into