diff --git a/.github/actions/build-framework-docs/action.yml b/.github/actions/build-framework-docs/action.yml index f69fab1cf..9c5f60bfe 100644 --- a/.github/actions/build-framework-docs/action.yml +++ b/.github/actions/build-framework-docs/action.yml @@ -28,16 +28,44 @@ runs: using: composite steps: - uses: actions/checkout@v6 - with: - submodules: recursive + # Deliberately not `submodules: recursive` — that also clones blazor/api-docs, + # blazor/igniteui-blazor and react/igniteui-react. api-docs is a private repository + # the default token cannot read, and none of the three are used by the documentation + # pipeline. Only the sources this framework reads are checked out. + - name: Check out documentation submodules + shell: bash + env: + FW: ${{ inputs.framework }} + run: | + set -euo pipefail + BASE=packages/igniteui-mcp/igniteui-doc-mcp + case "$FW" in + angular) + SUBS="angular/igniteui-docfx angular/igniteui-angular-samples angular/igniteui-angular-examples" ;; + react) + SUBS="common/igniteui-xplat-docs react/igniteui-react-examples" ;; + blazor) + SUBS="common/igniteui-xplat-docs blazor/igniteui-blazor-examples" ;; + webcomponents) + SUBS="common/igniteui-xplat-docs webcomponents/igniteui-wc-examples" ;; + *) + echo "::error::Unknown framework '$FW'"; exit 1 ;; + esac + for sub in $SUBS; do + echo "--- $sub ---" + git submodule update --init "$BASE/$sub" + done + + # Node 24, not 22: rewrite-api-links.ts uses URLPattern, which only became a + # global in Node 24. On 22 it fails with "URLPattern is not defined". - uses: actions/setup-node@v6 with: - node-version: 22.x + node-version: 24.x cache: yarn # The cross-platform gulp build restores the docfx dotnet tool. - - uses: actions/setup-dotnet@v4 + - uses: actions/setup-dotnet@v6 if: inputs.framework != 'angular' with: dotnet-version: 8.x @@ -99,6 +127,7 @@ runs: if [ "$MODE" = "full" ]; then npm run "compress:$FW" -- --batch submit npm run "compress:$FW" -- --batch poll + npm run "derive-components:$FW" npx tsx scripts/update-baseline.ts --framework "$FW" --full else npm run "diff:$FW" @@ -110,23 +139,51 @@ runs: if [ "$CHANGED" -gt 0 ]; then npm run "compress:$FW" -- --batch submit --manifest dist/diff-manifest.json npm run "compress:$FW" -- --batch poll + # Only the freshly compressed docs need it; restored ones already carry + # derived values from the committed DB. + npm run "derive-components:$FW" fi npm run "update-baseline:$FW" fi - - name: Report compression stats + # Compression must yield one document per input. A batch entry that fails is + # simply absent from docs_final — the pipeline continues, the DB is published + # short, and document-count floors are far too loose to notice one missing file. + # This is not hypothetical: a full run reported "375 succeeded, 1 failed" and + # silently dropped hierarchicalgrid-editing.md. + IN=$(find "dist/docs_prepeared/$FW" -name '*.md' -not -name '_*' | wc -l) + OUT=$(find "dist/docs_final/$FW" -name '*.md' -not -name '_*' | wc -l) + if [ "$OUT" -lt "$IN" ]; then + echo "::warning::$FW: $OUT of $IN documents present after compression — retrying failed batch entries" + # `--batch retry` only submits a new batch; polling downloads its results. + # batchPoll reads state.retry_batch_id, so it follows the retry batch. + if npm run "compress:$FW" -- --batch retry; then + npm run "compress:$FW" -- --batch poll || true + npm run "derive-components:$FW" || true + fi + OUT=$(find "dist/docs_final/$FW" -name '*.md' -not -name '_*' | wc -l) + fi + if [ "$OUT" -lt "$IN" ]; then + echo "::error::$FW: compression produced only $OUT of $IN documents. Refusing to publish an incomplete set." + comm -23 \ + <(find "dist/docs_prepeared/$FW" -name '*.md' -not -name '_*' -printf '%f\n' | sort) \ + <(find "dist/docs_final/$FW" -name '*.md' -not -name '_*' -printf '%f\n' | sort) + exit 1 + fi + echo "$FW: $OUT of $IN documents compressed" + + # Always runs, so a framework that skipped compression still reports why. + - name: Report build summary + if: always() shell: bash working-directory: packages/igniteui-mcp/igniteui-doc-mcp run: | - STATS="dist/docs_final/${{ inputs.framework }}/_compression_stats.json" - COUNT=$(find "dist/docs_final/${{ inputs.framework }}" -name '*.md' -not -name '_*' | wc -l) - echo "### ${{ inputs.framework }}: $COUNT documents" >> "$GITHUB_STEP_SUMMARY" - if [ -f "$STATS" ]; then - node -e "const s=require('./$STATS');console.log('- model: '+s.model+'\n- tokens: '+(s.total_tokens||0))" >> "$GITHUB_STEP_SUMMARY" - fi + npx tsx scripts/report-build-summary.ts \ + --framework "${{ inputs.framework }}" \ + --mode "${{ inputs.mode }}" >> "$GITHUB_STEP_SUMMARY" - name: Upload compressed docs - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: docs-final-${{ inputs.framework }} path: packages/igniteui-mcp/igniteui-doc-mcp/dist/docs_final/${{ inputs.framework }} @@ -134,14 +191,14 @@ runs: # build-db reads _tocName from here. Without it every row's toc_name would be NULL. - name: Upload prepared docs - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: docs-prepeared-${{ inputs.framework }} path: packages/igniteui-mcp/igniteui-doc-mcp/dist/docs_prepeared/${{ inputs.framework }} retention-days: 5 - name: Upload updated baseline - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: docs-baseline-${{ inputs.framework }} path: packages/igniteui-mcp/igniteui-doc-mcp/docs_baseline/${{ inputs.framework }} diff --git a/.github/workflows/build-docs-db.yml b/.github/workflows/build-docs-db.yml index dc9a1b87b..3c0802b32 100644 --- a/.github/workflows/build-docs-db.yml +++ b/.github/workflows/build-docs-db.yml @@ -46,7 +46,7 @@ jobs: react: needs: angular - if: always() && !cancelled() && !contains(needs.*.result, 'failure') && contains(inputs.frameworks, 'react') + if: always() && !cancelled() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') && contains(inputs.frameworks, 'react') runs-on: ubuntu-latest timeout-minutes: 330 steps: @@ -61,7 +61,7 @@ jobs: blazor: needs: react - if: always() && !cancelled() && !contains(needs.*.result, 'failure') && contains(inputs.frameworks, 'blazor') + if: always() && !cancelled() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') && contains(inputs.frameworks, 'blazor') runs-on: ubuntu-latest timeout-minutes: 330 steps: @@ -76,7 +76,7 @@ jobs: webcomponents: needs: blazor - if: always() && !cancelled() && !contains(needs.*.result, 'failure') && contains(inputs.frameworks, 'webcomponents') + if: always() && !cancelled() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') && contains(inputs.frameworks, 'webcomponents') runs-on: ubuntu-latest timeout-minutes: 330 steps: @@ -95,26 +95,26 @@ jobs: # angular-only database. assemble: needs: [angular, react, blazor, webcomponents] - if: always() && !cancelled() && !contains(needs.*.result, 'failure') + if: always() && !cancelled() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: - node-version: 22.x + node-version: 24.x cache: yarn - name: Install packages run: yarn --frozen-lockfile - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: pattern: docs-final-* path: packages/igniteui-mcp/igniteui-doc-mcp/dist/docs_final - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: pattern: docs-prepeared-* path: packages/igniteui-mcp/igniteui-doc-mcp/dist/docs_prepeared - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: pattern: docs-baseline-* path: packages/igniteui-mcp/igniteui-doc-mcp/docs_baseline @@ -139,13 +139,24 @@ jobs: # minimal docs_prepeared entries build-db needs to populate toc_name. - name: Restore frameworks not rebuilt in this run working-directory: packages/igniteui-mcp/igniteui-doc-mcp + env: + REQUESTED: ${{ inputs.frameworks }} run: | set -euo pipefail for fw in angular react blazor webcomponents; do - if [ ! -d "dist/docs_final/$fw" ] || [ -z "$(ls -A dist/docs_final/$fw 2>/dev/null)" ]; then - echo "$fw was not rebuilt — restoring from the committed DB" - npx tsx scripts/restore-docs-final.ts --framework "$fw" --toc-stubs + if [ -d "dist/docs_final/$fw" ] && [ -n "$(ls -A "dist/docs_final/$fw" 2>/dev/null)" ]; then + continue + fi + # A framework that was rebuilt but has no docs here means its artifact did + # not arrive. Restoring from the DB would silently publish stale docs for it + # with counts that look perfectly healthy, so fail instead. + if echo "$REQUESTED" | grep -qw "$fw"; then + echo "::error::$fw was part of this run but its artifact is missing — refusing to build a database from stale $fw documents." + ls -R dist/docs_final || true + exit 1 fi + echo "$fw was not part of this run — restoring from the committed DB" + npx tsx scripts/restore-docs-final.ts --framework "$fw" --toc-stubs done - name: Build database @@ -157,7 +168,7 @@ jobs: npx tsc spec/unit/docs-db-counts-spec.ts --target es6 --module commonjs --esModuleInterop --skipLibCheck npx jasmine spec/unit/docs-db-counts-spec.js - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: igniteui-docs-db path: | @@ -169,14 +180,17 @@ jobs: # pushed to a protected branch and nothing auto-merges. publish: needs: assemble - if: success() + # Not `success()`: at job level that evaluates the whole ancestor chain, so a run + # scoped to a subset of frameworks (leaving the others skipped) would make it false + # and silently skip publishing. Check the direct dependency's result instead. + if: always() && !cancelled() && needs.assemble.result == 'success' runs-on: ubuntu-latest permissions: contents: write pull-requests: write steps: - uses: actions/checkout@v6 - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: name: igniteui-docs-db path: artifact diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/docs/knowledgebase.md b/packages/igniteui-mcp/igniteui-doc-mcp/docs/knowledgebase.md index 0c8d189b9..428f20687 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/docs/knowledgebase.md +++ b/packages/igniteui-mcp/igniteui-doc-mcp/docs/knowledgebase.md @@ -1,6 +1,6 @@ # Documentation Processing Knowledgebase -Lessons learned and issues encountered while building the documentation processing pipelines. Entries 1-16 are from the Angular pipeline; entries 17-22 from React and MCP server; entries 23-28 from WebComponents and cross-platform improvements; entries 29-33 from Blazor, cross-platform architecture, and prompt improvements. +Lessons learned and issues encountered while building the documentation processing pipelines. Entries 1-16 are from the Angular pipeline; entries 17-22 from React and MCP server; entries 23-28 from WebComponents and cross-platform improvements; entries 29-34 from Blazor, cross-platform architecture, and prompt improvements. ## 1. LLM Compression: Wrong Component Prefix (Hallucination) @@ -371,6 +371,32 @@ This is the opposite logic from what you might expect — `exclude` means "hide **Rule:** This three-pronged approach is needed because the LLM's merge behavior is a chain: it first decides sections are "redundant" → merges headers → then drops examples from the merged section. Blocking any single step isn't enough — all three rules must reinforce each other. +## 34. LLM Compression: `component` Frontmatter Drifts and Names Sample-App Classes + +**Problem:** The `component` field was decided entirely by the compression model, and it is not stable across runs. Two full rebuilds with the same model (`gpt-5.6-luna`) over effectively unchanged sources produced **1231 of 1232 documents with changed content and 374 with a changed `component` field** — 144 listing fewer components, 129 more, 64 genuinely different names, 37 merely reordered. + +The worst case was `angular/angular-reactive-form-validation.md`: + +``` +before: IgxSelectComponent, IgxInputDirective, IgxComboComponent, IgxDatePickerComponent, … +after: DateValueValidatorDirective, DateValueAsyncValidatorDirective, ReactiveFormsSampleComponent, MyComponent +``` + +The model listed the **sample application's own classes** while the document body still documented `IgxSelectComponent`, `IgxInputDirective` and a dozen more. The prompt invited this by asking for "the exact class name(s) **as found in the document's source code**" — which those demo classes literally are. + +**Impact:** `component` drives `list_components` and component-filtered `search_docs`. A document indexed under `MyComponent` is effectively unreachable. Dropped entries (`cli-component-templates.md` went 28 → 6) shrink discoverability, and pure reordering churns the committed DB binary for no benefit. + +**Fix:** Two layers, because neither is sufficient alone. + +1. **Prompt** (all four compress scripts): every name must carry the platform prefix; never list classes the sample application defines for itself; list every component the doc covers rather than a subset; order by first appearance with the primary subject first. +2. **Deterministic post-pass** — `scripts/derive-components.ts`, wired into every `pipeline:*` after compression. It keeps a supplied name when it carries an Ignite UI prefix, or the API index knows it, or a heading names it; otherwise it drops it. It then puts the filename-derived primary first and adds indexed components named in headings. + +**Rule:** Prefer the **prefix** over API-index membership when deciding whether a name is real. The index built from `llms-full.txt` is incomplete — it lacks the data-visualisation components (`IgxCategoryChartComponent`), so filtering on index membership alone silently deletes valid entries. Equally, do not require the platform's own prefix exclusively: Angular docs legitimately reference `Igc*` Web Components wrappers (`IgcDockManagerComponent`, `IgcRatingComponent`, `IgcTileManagerComponent`), and the Excel library documents unprefixed classes (`Workbook`, `WorksheetChart`) that only the heading check preserves. + +**Rule:** Never let the derivation *substitute* when it has no positive evidence — falling back to "every component mentioned in the body" buries the subject under components used incidentally by demo code (`badge.md` became `IgxAvatarComponent, IgxBadgeComponent, IgxIconService, IgxListComponent…`). The one exception is when *every* supplied name was rejected, which means the model returned nothing usable. + +**Rule:** A `full` rebuild rewrites essentially the whole corpus even when nothing upstream changed. Prefer `incremental`, which only recompresses genuinely changed documents and therefore cannot churn metadata wholesale. + ## Related Documentation | Document | Description | Status | diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/package.json b/packages/igniteui-mcp/igniteui-doc-mcp/package.json index f76f584af..8d623986f 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/package.json +++ b/packages/igniteui-mcp/igniteui-doc-mcp/package.json @@ -66,9 +66,13 @@ "inject:react": "npx tsx scripts/inject-react-docs.ts", "inject:webcomponents": "npx tsx scripts/inject-wc-docs.ts", "rewrite-api-urls:angular": "npx tsx scripts/rewrite-api-links.ts --platform angular", + "derive-components:angular": "npx tsx scripts/derive-components.ts --framework angular", "rewrite-api-urls:blazor": "npx tsx scripts/rewrite-api-links.ts --platform blazor", + "derive-components:blazor": "npx tsx scripts/derive-components.ts --framework blazor", "rewrite-api-urls:react": "npx tsx scripts/rewrite-api-links.ts --platform react", + "derive-components:react": "npx tsx scripts/derive-components.ts --framework react", "rewrite-api-urls:webcomponents": "npx tsx scripts/rewrite-api-links.ts --platform webcomponents", + "derive-components:webcomponents": "npx tsx scripts/derive-components.ts --framework webcomponents", "compress:angular": "npx tsx --env-file=.env scripts/compress-angular-docs.ts", "compress:blazor": "npx tsx --env-file=.env scripts/compress-blazor-docs.ts", "compress:react": "npx tsx --env-file=.env scripts/compress-react-docs.ts", @@ -94,14 +98,14 @@ "update-baseline:react": "npx tsx scripts/update-baseline.ts --framework react --manifest dist/diff-manifest.json", "update-baseline:webcomponents": "npx tsx scripts/update-baseline.ts --framework webcomponents --manifest dist/diff-manifest.json", "clear:build": "npx tsx -e \"import{rmSync}from'fs';['docs_processing','docs_prepeared'].forEach(d=>{rmSync('dist/'+d,{recursive:true,force:true})})\"", - "pipeline:angular": "npm run clear:build && npm run export:angular && npm run inject:angular && npm run rewrite-api-urls:angular && npm run diff:angular && npm run compress:angular -- --batch submit --manifest dist/diff-manifest.json && npm run compress:angular -- --batch poll && npm run update-baseline:angular && npm run build:db -- --framework angular", - "pipeline:blazor": "npm run clear:build && npm run build:xplat-blazor && npm run export:blazor && npm run inject:blazor && npm run rewrite-api-urls:blazor && npm run diff:blazor && npm run compress:blazor -- --batch submit --manifest dist/diff-manifest.json && npm run compress:blazor -- --batch poll && npm run update-baseline:blazor && npm run build:db -- --framework blazor", - "pipeline:react": "npm run clear:build && npm run build:xplat-react && npm run export:react && npm run inject:react && npm run rewrite-api-urls:react && npm run diff:react && npm run compress:react -- --batch submit --manifest dist/diff-manifest.json && npm run compress:react -- --batch poll && npm run update-baseline:react && npm run build:db -- --framework react", - "pipeline:webcomponents": "npm run clear:build && npm run build:xplat-wc && npm run export:webcomponents && npm run inject:webcomponents && npm run rewrite-api-urls:webcomponents && npm run diff:webcomponents && npm run compress:webcomponents -- --batch submit --manifest dist/diff-manifest.json && npm run compress:webcomponents -- --batch poll && npm run update-baseline:webcomponents && npm run build:db -- --framework webcomponents", - "pipeline:angular:full": "npm run clear:angular && npm run export:angular && npm run inject:angular && npm run rewrite-api-urls:angular && npm run compress:angular -- --batch submit && npm run compress:angular -- --batch poll && npx tsx scripts/update-baseline.ts --framework angular --full && npm run build:db -- --framework angular", - "pipeline:blazor:full": "npm run clear:blazor && npm run build:xplat-blazor && npm run export:blazor && npm run inject:blazor && npm run rewrite-api-urls:blazor && npm run compress:blazor -- --batch submit && npm run compress:blazor -- --batch poll && npx tsx scripts/update-baseline.ts --framework blazor --full && npm run build:db -- --framework blazor", - "pipeline:react:full": "npm run clear:react && npm run build:xplat-react && npm run export:react && npm run inject:react && npm run rewrite-api-urls:react && npm run compress:react -- --batch submit && npm run compress:react -- --batch poll && npx tsx scripts/update-baseline.ts --framework react --full && npm run build:db -- --framework react", - "pipeline:webcomponents:full": "npm run clear:webcomponents && npm run build:xplat-wc && npm run export:webcomponents && npm run inject:webcomponents && npm run rewrite-api-urls:webcomponents && npm run compress:webcomponents -- --batch submit && npm run compress:webcomponents -- --batch poll && npx tsx scripts/update-baseline.ts --framework webcomponents --full && npm run build:db -- --framework webcomponents" + "pipeline:angular": "npm run clear:build && npm run export:angular && npm run inject:angular && npm run rewrite-api-urls:angular && npm run diff:angular && npm run compress:angular -- --batch submit --manifest dist/diff-manifest.json && npm run compress:angular -- --batch poll && npm run derive-components:angular && npm run update-baseline:angular && npm run build:db -- --framework angular", + "pipeline:blazor": "npm run clear:build && npm run build:xplat-blazor && npm run export:blazor && npm run inject:blazor && npm run rewrite-api-urls:blazor && npm run diff:blazor && npm run compress:blazor -- --batch submit --manifest dist/diff-manifest.json && npm run compress:blazor -- --batch poll && npm run derive-components:blazor && npm run update-baseline:blazor && npm run build:db -- --framework blazor", + "pipeline:react": "npm run clear:build && npm run build:xplat-react && npm run export:react && npm run inject:react && npm run rewrite-api-urls:react && npm run diff:react && npm run compress:react -- --batch submit --manifest dist/diff-manifest.json && npm run compress:react -- --batch poll && npm run derive-components:react && npm run update-baseline:react && npm run build:db -- --framework react", + "pipeline:webcomponents": "npm run clear:build && npm run build:xplat-wc && npm run export:webcomponents && npm run inject:webcomponents && npm run rewrite-api-urls:webcomponents && npm run diff:webcomponents && npm run compress:webcomponents -- --batch submit --manifest dist/diff-manifest.json && npm run compress:webcomponents -- --batch poll && npm run derive-components:webcomponents && npm run update-baseline:webcomponents && npm run build:db -- --framework webcomponents", + "pipeline:angular:full": "npm run clear:angular && npm run export:angular && npm run inject:angular && npm run rewrite-api-urls:angular && npm run compress:angular -- --batch submit && npm run compress:angular -- --batch poll && npm run derive-components:angular && npx tsx scripts/update-baseline.ts --framework angular --full && npm run build:db -- --framework angular", + "pipeline:blazor:full": "npm run clear:blazor && npm run build:xplat-blazor && npm run export:blazor && npm run inject:blazor && npm run rewrite-api-urls:blazor && npm run compress:blazor -- --batch submit && npm run compress:blazor -- --batch poll && npm run derive-components:blazor && npx tsx scripts/update-baseline.ts --framework blazor --full && npm run build:db -- --framework blazor", + "pipeline:react:full": "npm run clear:react && npm run build:xplat-react && npm run export:react && npm run inject:react && npm run rewrite-api-urls:react && npm run compress:react -- --batch submit && npm run compress:react -- --batch poll && npm run derive-components:react && npx tsx scripts/update-baseline.ts --framework react --full && npm run build:db -- --framework react", + "pipeline:webcomponents:full": "npm run clear:webcomponents && npm run build:xplat-wc && npm run export:webcomponents && npm run inject:webcomponents && npm run rewrite-api-urls:webcomponents && npm run compress:webcomponents -- --batch submit && npm run compress:webcomponents -- --batch poll && npm run derive-components:webcomponents && npx tsx scripts/update-baseline.ts --framework webcomponents --full && npm run build:db -- --framework webcomponents" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/scripts/compress-angular-docs.ts b/packages/igniteui-mcp/igniteui-doc-mcp/scripts/compress-angular-docs.ts index a99d15b12..eed84c2a8 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/scripts/compress-angular-docs.ts +++ b/packages/igniteui-mcp/igniteui-doc-mcp/scripts/compress-angular-docs.ts @@ -123,7 +123,11 @@ premium: true --- Rules for frontmatter fields: -- **component**: The exact Ignite UI for Angular component/directive class name(s) as found in the document's source code (e.g. IgxGridComponent, IgxButtonDirective, IgxComboComponent, IgxDatePickerComponent). Use the PascalCase Igx-prefixed name including the Component/Directive suffix as used in Angular. If the doc covers multiple components, comma-separate them. +- **component**: The Ignite UI for Angular component/directive class name(s) documented here (e.g. IgxGridComponent, IgxButtonDirective, IgxComboComponent, IgxDatePickerComponent). Use the PascalCase Igx-prefixed name including the Component/Directive suffix as used in Angular. Comma-separate multiple names. These rules are strict: + - Every name MUST begin with \`Igx\`. A class in the sample code that does not start with \`Igx\` is application code, not a library component. + - NEVER list classes the sample application defines for itself — custom validators, \`MyComponent\`, \`AppComponent\`, \`*SampleComponent\`, demo services, demo pipes, or demo directives. List only components from the Ignite UI library, even when the sample's own classes are more prominent in the code. + - List the components the document actually explains or demonstrates in its own sections — not every class that happens to appear somewhere in the sample code. About 15 is plenty; a long tail of incidental references is noise. + - Order them by first appearance in the document, with the document's primary subject first. - **keywords**: Do NOT repeat the component name from the \`component\` field. Include the short common name (e.g. grid, combo-box, date-picker), related UI concepts (e.g. filtering, sorting, paging, selection), and common synonyms developers might search for (e.g. card, avatar, badge, dropdown, dialog, modal, table). Use lowercase, comma-separated. Aim for 5-15 keywords. - **summary**: A concise 1-2 sentence description of what the document covers and what a developer can learn from it. Focus on the component's purpose and key capabilities. - **premium**: If the input frontmatter contains \`_premium: true\`, include \`premium: true\` in your output frontmatter. Otherwise omit the premium field entirely. @@ -496,10 +500,44 @@ async function processBatchResults( if (result.response?.status_code === 200) { let compressed = result.response.body.choices?.[0]?.message?.content ?? ""; compressed = stripResponseWrapper(compressed); - const { valid, issues } = validateStructure(compressed); + let { valid, issues } = validateStructure(compressed); + + if (!valid) { + // finish_reason and usage explain WHY a response was unusable. "length" means + // the model exhausted max_completion_tokens — reasoning tokens count against + // the same ceiling — and returned a truncated or empty body with no frontmatter. + const choice = result.response.body.choices?.[0]; + const usage = result.response.body.usage; + console.log( + ` INVALID ${name}: ${issues.join(", ")} ` + + `[finish_reason=${choice?.finish_reason ?? "?"}, ` + + `completion=${usage?.completion_tokens ?? "?"}, ` + + `reasoning=${usage?.completion_tokens_details?.reasoning_tokens ?? "?"}, ` + + `chars=${compressed.length}]` + ); + + // The sync path retries once on a validation failure; the batch path used to + // discard the document instead, which is how a full run published 375 of 376 + // Angular documents with nothing failing. Retry inline before giving up. + if (originalContent) { + console.log(` RETRY ${name}...`); + try { + const retried = await compressWithLLM(client, originalContent, state.model); + const recheck = validateStructure(retried); + if (recheck.valid) { + compressed = retried; + valid = true; + console.log(` RECOVERED ${name}`); + } else { + console.log(` still invalid after retry: ${recheck.issues.join(", ")}`); + } + } catch (err) { + console.log(` retry errored: ${(err as Error).message}`); + } + } + } if (!valid) { - console.log(` INVALID ${name}: ${issues.join(", ")}`); invalidCount++; invalidFiles.push(name); continue; diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/scripts/compress-blazor-docs.ts b/packages/igniteui-mcp/igniteui-doc-mcp/scripts/compress-blazor-docs.ts index c8c4a2af9..0b016cdba 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/scripts/compress-blazor-docs.ts +++ b/packages/igniteui-mcp/igniteui-doc-mcp/scripts/compress-blazor-docs.ts @@ -123,7 +123,11 @@ premium: true --- Rules for frontmatter fields: -- **component**: The exact Ignite UI for Blazor component class name(s) as found in the document's source code (e.g. IgbGrid, IgbButton, IgbCombo, IgbDatePicker). Use the PascalCase Igb-prefixed name as used in Blazor. Blazor components do NOT use suffixes like Component or Directive — just use the base name (e.g. IgbGrid, not IgbGridComponent). If the doc covers multiple components, comma-separate them. +- **component**: The Ignite UI for Blazor component class name(s) documented here (e.g. IgbGrid, IgbButton, IgbCombo, IgbDatePicker). Use the PascalCase Igb-prefixed name as used in Blazor. Blazor components do NOT use suffixes like Component or Directive — just use the base name (e.g. IgbGrid, not IgbGridComponent). Comma-separate multiple names. These rules are strict: + - Every name MUST begin with \`Igb\`. A class in the sample code that does not start with \`Igb\` is application code, not a library component. + - NEVER list classes the sample application defines for itself — page models, \`*Sample\`, demo services, or local record/DTO types. List only components from the Ignite UI library, even when the sample's own classes are more prominent. + - List the components the document actually explains or demonstrates in its own sections — not every class that happens to appear somewhere in the sample code. About 15 is plenty; a long tail of incidental references is noise. + - Order them by first appearance in the document, with the document's primary subject first. - **keywords**: Do NOT repeat the component name from the \`component\` field. Include the short common name (e.g. grid, combo-box, date-picker), related UI concepts (e.g. filtering, sorting, paging, selection), and common synonyms developers might search for (e.g. card, avatar, badge, dropdown, dialog, modal, table). Use lowercase, comma-separated. Aim for 5-15 keywords. - **summary**: A concise 1-2 sentence description of what the document covers and what a developer can learn from it. Focus on the component's purpose and key capabilities. - **premium**: If the input frontmatter contains \`_premium: true\`, include \`premium: true\` in your output frontmatter. Otherwise omit the premium field entirely. @@ -497,10 +501,44 @@ async function processBatchResults( if (result.response?.status_code === 200) { let compressed = result.response.body.choices?.[0]?.message?.content ?? ""; compressed = stripResponseWrapper(compressed); - const { valid, issues } = validateStructure(compressed); + let { valid, issues } = validateStructure(compressed); + + if (!valid) { + // finish_reason and usage explain WHY a response was unusable. "length" means + // the model exhausted max_completion_tokens — reasoning tokens count against + // the same ceiling — and returned a truncated or empty body with no frontmatter. + const choice = result.response.body.choices?.[0]; + const usage = result.response.body.usage; + console.log( + ` INVALID ${name}: ${issues.join(", ")} ` + + `[finish_reason=${choice?.finish_reason ?? "?"}, ` + + `completion=${usage?.completion_tokens ?? "?"}, ` + + `reasoning=${usage?.completion_tokens_details?.reasoning_tokens ?? "?"}, ` + + `chars=${compressed.length}]` + ); + + // The sync path retries once on a validation failure; the batch path used to + // discard the document instead, which is how a full run published 375 of 376 + // Angular documents with nothing failing. Retry inline before giving up. + if (originalContent) { + console.log(` RETRY ${name}...`); + try { + const retried = await compressWithLLM(client, originalContent, state.model); + const recheck = validateStructure(retried); + if (recheck.valid) { + compressed = retried; + valid = true; + console.log(` RECOVERED ${name}`); + } else { + console.log(` still invalid after retry: ${recheck.issues.join(", ")}`); + } + } catch (err) { + console.log(` retry errored: ${(err as Error).message}`); + } + } + } if (!valid) { - console.log(` INVALID ${name}: ${issues.join(", ")}`); invalidCount++; invalidFiles.push(name); continue; diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/scripts/compress-react-docs.ts b/packages/igniteui-mcp/igniteui-doc-mcp/scripts/compress-react-docs.ts index efe7944eb..bc71990cf 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/scripts/compress-react-docs.ts +++ b/packages/igniteui-mcp/igniteui-doc-mcp/scripts/compress-react-docs.ts @@ -123,7 +123,11 @@ premium: true --- Rules for frontmatter fields: -- **component**: The exact Ignite UI for React component class name(s) as found in the document's source code (e.g. IgrGrid, IgrButton, IgrCombo, IgrDatePicker). Use the PascalCase Igr-prefixed name as used in React. React components do NOT use Angular-style suffixes like Component or Directive — just use the base name (e.g. IgrGrid, not IgrGridComponent). If the doc covers multiple components, comma-separate them. +- **component**: The Ignite UI for React component class name(s) documented here (e.g. IgrGrid, IgrButton, IgrCombo, IgrDatePicker). Use the PascalCase Igr-prefixed name as used in React. React components do NOT use Angular-style suffixes like Component or Directive — just use the base name (e.g. IgrGrid, not IgrGridComponent). Comma-separate multiple names. These rules are strict: + - Every name MUST begin with \`Igr\`. A class or function in the sample code that does not start with \`Igr\` is application code, not a library component. + - NEVER list things the sample application defines for itself — \`App\`, \`MyComponent\`, \`*Sample\`, demo hooks, demo helpers, or local state types. List only components from the Ignite UI library, even when the sample's own code is more prominent. + - List the components the document actually explains or demonstrates in its own sections — not every class that happens to appear somewhere in the sample code. About 15 is plenty; a long tail of incidental references is noise. + - Order them by first appearance in the document, with the document's primary subject first. - **keywords**: Do NOT repeat the component name from the \`component\` field. Include the short common name (e.g. grid, combo-box, date-picker), related UI concepts (e.g. filtering, sorting, paging, selection), and common synonyms developers might search for (e.g. card, avatar, badge, dropdown, dialog, modal, table). Use lowercase, comma-separated. Aim for 5-15 keywords. - **summary**: A concise 1-2 sentence description of what the document covers and what a developer can learn from it. Focus on the component's purpose and key capabilities. - **premium**: If the input frontmatter contains \`_premium: true\`, include \`premium: true\` in your output frontmatter. Otherwise omit the premium field entirely. @@ -497,10 +501,44 @@ async function processBatchResults( if (result.response?.status_code === 200) { let compressed = result.response.body.choices?.[0]?.message?.content ?? ""; compressed = stripResponseWrapper(compressed); - const { valid, issues } = validateStructure(compressed); + let { valid, issues } = validateStructure(compressed); + + if (!valid) { + // finish_reason and usage explain WHY a response was unusable. "length" means + // the model exhausted max_completion_tokens — reasoning tokens count against + // the same ceiling — and returned a truncated or empty body with no frontmatter. + const choice = result.response.body.choices?.[0]; + const usage = result.response.body.usage; + console.log( + ` INVALID ${name}: ${issues.join(", ")} ` + + `[finish_reason=${choice?.finish_reason ?? "?"}, ` + + `completion=${usage?.completion_tokens ?? "?"}, ` + + `reasoning=${usage?.completion_tokens_details?.reasoning_tokens ?? "?"}, ` + + `chars=${compressed.length}]` + ); + + // The sync path retries once on a validation failure; the batch path used to + // discard the document instead, which is how a full run published 375 of 376 + // Angular documents with nothing failing. Retry inline before giving up. + if (originalContent) { + console.log(` RETRY ${name}...`); + try { + const retried = await compressWithLLM(client, originalContent, state.model); + const recheck = validateStructure(retried); + if (recheck.valid) { + compressed = retried; + valid = true; + console.log(` RECOVERED ${name}`); + } else { + console.log(` still invalid after retry: ${recheck.issues.join(", ")}`); + } + } catch (err) { + console.log(` retry errored: ${(err as Error).message}`); + } + } + } if (!valid) { - console.log(` INVALID ${name}: ${issues.join(", ")}`); invalidCount++; invalidFiles.push(name); continue; diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/scripts/compress-wc-docs.ts b/packages/igniteui-mcp/igniteui-doc-mcp/scripts/compress-wc-docs.ts index 9115ea456..0fe62d7e6 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/scripts/compress-wc-docs.ts +++ b/packages/igniteui-mcp/igniteui-doc-mcp/scripts/compress-wc-docs.ts @@ -123,7 +123,11 @@ premium: true --- Rules for frontmatter fields: -- **component**: The exact Ignite UI for Web Components component class name(s) as found in the document's source code (e.g. IgcGridComponent, IgcButtonComponent, IgcComboComponent, IgcDatePickerComponent). Use the PascalCase Igc-prefixed name with the Component suffix as used in Web Components. If the doc covers multiple components, comma-separate them. +- **component**: The Ignite UI for Web Components component class name(s) documented here (e.g. IgcGridComponent, IgcButtonComponent, IgcComboComponent, IgcDatePickerComponent). Use the PascalCase Igc-prefixed name with the Component suffix as used in Web Components. Comma-separate multiple names. These rules are strict: + - Every name MUST begin with \`Igc\`. A class in the sample code that does not start with \`Igc\` is application code, not a library component. + - NEVER list classes the sample application defines for itself — \`App\`, \`MyComponent\`, \`*Sample\`, demo services, or local data types. List only components from the Ignite UI library, even when the sample's own classes are more prominent. + - List the components the document actually explains or demonstrates in its own sections — not every class that happens to appear somewhere in the sample code. About 15 is plenty; a long tail of incidental references is noise. + - Order them by first appearance in the document, with the document's primary subject first. - **keywords**: Do NOT repeat the component name from the \`component\` field. Include the short common name (e.g. grid, combo-box, date-picker), related UI concepts (e.g. filtering, sorting, paging, selection), and common synonyms developers might search for (e.g. card, avatar, badge, dropdown, dialog, modal, table). Use lowercase, comma-separated. Aim for 5-15 keywords. - **summary**: A concise 1-2 sentence description of what the document covers and what a developer can learn from it. Focus on the component's purpose and key capabilities. - **premium**: If the input frontmatter contains \`_premium: true\`, include \`premium: true\` in your output frontmatter. Otherwise omit the premium field entirely. @@ -497,10 +501,44 @@ async function processBatchResults( if (result.response?.status_code === 200) { let compressed = result.response.body.choices?.[0]?.message?.content ?? ""; compressed = stripResponseWrapper(compressed); - const { valid, issues } = validateStructure(compressed); + let { valid, issues } = validateStructure(compressed); + + if (!valid) { + // finish_reason and usage explain WHY a response was unusable. "length" means + // the model exhausted max_completion_tokens — reasoning tokens count against + // the same ceiling — and returned a truncated or empty body with no frontmatter. + const choice = result.response.body.choices?.[0]; + const usage = result.response.body.usage; + console.log( + ` INVALID ${name}: ${issues.join(", ")} ` + + `[finish_reason=${choice?.finish_reason ?? "?"}, ` + + `completion=${usage?.completion_tokens ?? "?"}, ` + + `reasoning=${usage?.completion_tokens_details?.reasoning_tokens ?? "?"}, ` + + `chars=${compressed.length}]` + ); + + // The sync path retries once on a validation failure; the batch path used to + // discard the document instead, which is how a full run published 375 of 376 + // Angular documents with nothing failing. Retry inline before giving up. + if (originalContent) { + console.log(` RETRY ${name}...`); + try { + const retried = await compressWithLLM(client, originalContent, state.model); + const recheck = validateStructure(retried); + if (recheck.valid) { + compressed = retried; + valid = true; + console.log(` RECOVERED ${name}`); + } else { + console.log(` still invalid after retry: ${recheck.issues.join(", ")}`); + } + } catch (err) { + console.log(` retry errored: ${(err as Error).message}`); + } + } + } if (!valid) { - console.log(` INVALID ${name}: ${issues.join(", ")}`); invalidCount++; invalidFiles.push(name); continue; diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/scripts/derive-components.ts b/packages/igniteui-mcp/igniteui-doc-mcp/scripts/derive-components.ts new file mode 100644 index 000000000..6d2754677 --- /dev/null +++ b/packages/igniteui-mcp/igniteui-doc-mcp/scripts/derive-components.ts @@ -0,0 +1,221 @@ +/** + * Rewrites the `component` frontmatter field in dist/docs_final// from the + * document body, validated against the platform API index. + * + * The compression model decides this field today, and it drifts badly: a full rebuild + * over unchanged sources changed `component` on 374 of 1232 documents, in one case + * replacing the Ignite UI components with sample-app class names (MyComponent, + * ReactiveFormsSampleComponent), which makes the doc unreachable through + * list_components and component-filtered search. + * + * Deriving it mechanically removes that entire class of drift: names come from the + * document text, every one is checked against the real API index, ordering is stable, + * and repeated runs produce identical output. + * + * Usage: + * npx tsx scripts/derive-components.ts --framework angular + * npx tsx scripts/derive-components.ts --framework angular --dry-run + */ +import { readFileSync, writeFileSync, existsSync, readdirSync } from "fs"; +import { join, resolve } from "path"; +import { buildCanonicalIndex } from "./rewrite-api-links.js"; +import { PLATFORMS, type Platform } from "../src/config/platforms.js"; + +const ROOT = resolve(import.meta.dirname, ".."); + +// Component prefix per platform. Only names carrying the platform's own prefix are +// considered — anything else in a code sample is application code. +const PREFIX: Record = { + angular: "Igx", + react: "Igr", + blazor: "Igb", + webcomponents: "Igc", +}; + +function arg(name: string): string | undefined { + const i = process.argv.indexOf(`--${name}`); + return i !== -1 ? process.argv[i + 1] : undefined; +} + +interface Frontmatter { + block: string; + body: string; + component: string; +} + +function splitFrontmatter(raw: string): Frontmatter | null { + const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!m) return null; + const componentLine = m[1].match(/^component:[ \t]*(.*)$/m); + return { + block: m[0], + body: raw.slice(m[0].length), + component: componentLine ? componentLine[1].trim() : "", + }; +} + +/** Prefixed names from `text`, in first-appearance order, that exist in the API index. */ +function extract(text: string, prefix: string, index: Map): string[] { + const seen = new Set(); + const out: string[] = []; + const re = new RegExp(`\\b${prefix}[A-Za-z0-9]+\\b`, "g"); + for (const match of text.matchAll(re)) { + const canonical = index.get(match[0].toLowerCase()); + if (!canonical || seen.has(canonical)) continue; + seen.add(canonical); + out.push(canonical); + } + return out; +} + +/** + * The component the document is primarily about, guessed from its filename. + * "action-strip.md" -> IgxActionStripComponent, "grid-paging.md" -> IgxGridComponent. + */ +function primaryFromFilename(file: string, prefix: string, index: Map): string | null { + const tokens = file.replace(/\.md$/, "").split(/[-_.]/).filter(Boolean); + for (let take = tokens.length; take > 0; take--) { + const stem = (prefix + tokens.slice(0, take).join("")).toLowerCase(); + const exact = index.get(stem) ?? index.get(stem + "component") ?? index.get(stem + "directive"); + if (exact) return exact; + } + return null; +} + +/** + * Start from the model's list and remove anything not in the API index — that alone + * drops sample-app classes and hallucinated names. Then make sure the document's + * primary component leads, and add any indexed component named in a heading, which + * catches subjects the model omitted. Body-wide extraction is only a fallback: every + * component mentioned anywhere includes those merely used by demo code, which buries + * the actual subject. + */ +function derive( + modelValue: string, + body: string, + file: string, + prefix: string, + index: Map +): string[] { + // The model sometimes emits `component: ""` for documents with no library component + // (CLI guides, migration walkthroughs). Unquote so those become genuinely empty + // rather than a component literally named `""`. + const supplied = modelValue + .split(",") + .map(s => s.trim().replace(/^["']+|["']+$/g, "").trim()) + .filter(Boolean); + const headings = body.split("\n").filter(l => /^#{1,4}\s/.test(l)).join("\n"); + const fromHeadings = extract(headings, prefix, index); + + // Keep a supplied name when any of these hold, and drop it otherwise: + // * it carries an Ignite UI prefix — the API index is incomplete (it lacks the + // data-visualisation components, and Angular docs legitimately reference the + // Igc* Web Components wrappers), so the prefix is the more reliable signal; + // * the index knows it; + // * a heading names it — covers documented API outside the index, such as the + // Excel library's Workbook and WorksheetChart. + // Sample-application classes — MyComponent, ReactiveFormsSampleComponent, custom + // validators — satisfy none of these and are what this removes. + const kept = supplied + .filter(s => + /^Ig[xrbc][A-Z]/.test(s) || + index.has(s.toLowerCase()) || + new RegExp(`\\b${s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(headings)) + .map(s => index.get(s.toLowerCase()) ?? s); + + // Every supplied name was rejected, so the model listed nothing usable — the + // sample-app-classes case. Here the body is the better source even though it also + // picks up components used incidentally by demo code. + const allRejected = supplied.length > 0 && kept.length === 0; + const fallback = allRejected ? extract(body, prefix, index).slice(0, 12) : []; + + const primary = primaryFromFilename(file, prefix, index); + const ordered = [...(primary ? [primary] : []), ...kept, ...fromHeadings, ...fallback]; + + const seen = new Set(); + const out: string[] = []; + for (const name of ordered) { + if (seen.has(name)) continue; + seen.add(name); + out.push(name); + } + // No positive evidence at all — leave the model's value alone rather than replace it + // with components that merely appear in demo code. + return out; +} + +function main(): void { + const framework = arg("framework") as Platform | undefined; + const dryRun = process.argv.includes("--dry-run"); + + if (!framework || !PLATFORMS.includes(framework)) { + console.error(`--framework is required. Valid: ${PLATFORMS.join(", ")}`); + process.exit(1); + } + + const dir = join(ROOT, "dist", "docs_final", framework); + if (!existsSync(dir)) { + console.error(`Not found: ${dir}. Run the pipeline first.`); + process.exit(1); + } + + console.log(`Loading ${framework} API index…`); + const index = buildCanonicalIndex(framework); + console.log(` ${index.size} components indexed`); + + const only = arg("only"); + const files = readdirSync(dir) + .filter(f => f.endsWith(".md") && !f.startsWith("_")) + .filter(f => !only || f === only); + let rewritten = 0; + let unchanged = 0; + let kept = 0; + const samples: string[] = []; + + for (const file of files) { + const path = join(dir, file); + const raw = readFileSync(path, "utf-8"); + const fm = splitFrontmatter(raw); + if (!fm) { + console.warn(` [warn] ${file}: no frontmatter, skipped`); + continue; + } + + const derived = derive(fm.component, fm.body, file, PREFIX[framework], index); + if (derived.length === 0) { + // Nothing verifiable in the body — the model's value is better than an empty + // field. Covers docs whose components carry another platform's prefix, such as + // the IgcDockManagerComponent wrappers used from Angular. + kept++; + continue; + } + + const next = derived.join(", "); + if (next === fm.component) { + unchanged++; + continue; + } + + if (samples.length < 5) { + samples.push(` ${file}\n was: ${fm.component}\n now: ${next}`); + } + rewritten++; + + if (!dryRun) { + const block = fm.block.match(/^component:/m) + ? fm.block.replace(/^component:[ \t]*.*$/m, `component: ${next}`) + : fm.block.replace(/^---\r?\n/, `---\ncomponent: ${next}\n`); + writeFileSync(path, block + fm.body, "utf-8"); + } + } + + console.log(`\n${framework}: ${files.length} documents`); + console.log(` rewritten : ${rewritten}${dryRun ? " (dry run — nothing written)" : ""}`); + console.log(` already correct: ${unchanged}`); + console.log(` kept model value (no indexed component found): ${kept}`); + if (samples.length) { + console.log(`\nsample changes:\n${samples.join("\n")}`); + } +} + +main(); diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/scripts/report-build-summary.ts b/packages/igniteui-mcp/igniteui-doc-mcp/scripts/report-build-summary.ts new file mode 100644 index 000000000..a1a05b086 --- /dev/null +++ b/packages/igniteui-mcp/igniteui-doc-mcp/scripts/report-build-summary.ts @@ -0,0 +1,105 @@ +/** + * Prints a Markdown summary of one framework's documentation build. + * + * The workflow appends the output to $GITHUB_STEP_SUMMARY. It reports what changed + * upstream, how much was actually compressed and at what cost — the numbers you need + * to judge a run without opening the logs. + * + * Usage: npx tsx scripts/report-build-summary.ts --framework react --mode incremental + */ +import * as fs from "fs"; +import * as path from "path"; + +function arg(name: string): string { + const i = process.argv.indexOf(`--${name}`); + return i !== -1 ? process.argv[i + 1] : ""; +} + +function readJson(file: string): any | null { + try { + return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf-8")) : null; + } catch { + return null; + } +} + +const n = (v: number): string => v.toLocaleString("en-US"); +const kb = (v: number): string => (v >= 1024 ? `${(v / 1024).toFixed(1)} MB` : `${v.toFixed(0)} KB`); + +const framework = arg("framework"); +const mode = arg("mode") || "unknown"; +const finalDir = path.resolve("dist", "docs_final", framework); + +const rows: [string, string][] = []; + +const docCount = fs.existsSync(finalDir) + ? fs.readdirSync(finalDir).filter(f => f.endsWith(".md") && !f.startsWith("_")).length + : 0; +rows.push(["Documents in framework", n(docCount)]); +rows.push(["Mode", `\`${mode}\``]); + +const manifest = readJson(path.resolve("dist", "diff-manifest.json")); +const manifestApplies = manifest && manifest.framework === framework; +const changed = manifestApplies ? (manifest.changed ?? []).length : 0; +const added = manifestApplies ? (manifest.added ?? []).length : 0; +const deleted = manifestApplies ? (manifest.deleted ?? []).length : 0; +const unchanged = manifestApplies ? (manifest.unchanged ?? []).length : 0; + +if (manifestApplies) { + rows.push([ + "Changed upstream", + changed + added + deleted === 0 + ? `none — all ${n(unchanged)} documents unchanged` + : `${n(changed)} changed, ${n(added)} added, ${n(deleted)} deleted (${n(unchanged)} unchanged)` + ]); +} + +const stats = readJson(path.join(finalDir, "_compression_stats.json")); +const batch = readJson(path.join(finalDir, "_batch_state.json")); + +if (!stats) { + rows.push(["Compression", manifestApplies && changed + added === 0 + ? "**skipped** — nothing to recompress" + : "**did not run**"]); +} else { + const errors = Array.isArray(stats.errors) ? stats.errors.length : Number(stats.errors ?? 0); + rows.push(["Documents compressed", `${n(stats.files_processed ?? 0)} of ${n(docCount)}`]); + if (stats.files_skipped) { + rows.push(["Skipped", n(stats.files_skipped)]); + } + rows.push(["Model", `\`${stats.model ?? "unknown"}\``]); + if (stats.original_size_kb && stats.compressed_size_kb) { + rows.push([ + "Size of compressed set", + `${kb(stats.original_size_kb)} → ${kb(stats.compressed_size_kb)} (${(stats.compression_ratio ?? 0).toFixed(1)}% smaller)` + ]); + } + rows.push([ + "Generated output", + `${n(stats.total_tokens ?? 0)} tokens — size of the produced documents, not API usage` + ]); + if (errors > 0) { + rows.push(["Errors", `**${n(errors)}**`]); + } +} + +if (batch) { + const failed = Number(batch.failed ?? 0) + Number(batch.invalid ?? 0); + rows.push([ + "Batch", + `\`${batch.batch_id}\` — ${batch.status}, ${n(Number(batch.succeeded ?? 0))} succeeded` + + (failed > 0 ? `, **${n(failed)} failed/invalid**` : "") + ]); +} + +const out: string[] = []; +out.push(`### ${framework}`); +out.push(""); +out.push("| | |"); +out.push("|---|---|"); +for (const [label, value] of rows) { + out.push(`| ${label} | ${value} |`); +} +out.push(""); + +console.log(out.join("\n")); diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/switch-submodules.sh b/packages/igniteui-mcp/igniteui-doc-mcp/switch-submodules.sh index c2a93b25b..19e9f84f5 100755 --- a/packages/igniteui-mcp/igniteui-doc-mcp/switch-submodules.sh +++ b/packages/igniteui-mcp/igniteui-doc-mcp/switch-submodules.sh @@ -15,6 +15,12 @@ SUBMODULES=( for sub in "${SUBMODULES[@]}"; do dir="$BASE/$sub" echo "--- $sub ---" + # CI checks out only the submodules the framework being built actually needs, so + # anything uninitialized here is skipped rather than aborting the run. + if [ ! -e "$dir/.git" ]; then + echo "not initialized — skipping" + continue + fi git -C "$dir" fetch origin if git -C "$dir" rev-parse --verify "origin/$BRANCH" >/dev/null 2>&1 \ || git -C "$dir" fetch origin "$BRANCH:refs/remotes/origin/$BRANCH" 2>/dev/null; then diff --git a/spec/unit/docs-db-counts-spec.ts b/spec/unit/docs-db-counts-spec.ts index fac96be8f..b7ff25493 100644 --- a/spec/unit/docs-db-counts-spec.ts +++ b/spec/unit/docs-db-counts-spec.ts @@ -37,7 +37,9 @@ describe("Unit - documentation database", () => { } beforeAll(async () => { - expect(fs.existsSync(DB_PATH)).toBe(true, `Database not found at ${DB_PATH}. Run 'npm run build:db'.`); + expect(fs.existsSync(DB_PATH)) + .withContext(`Database not found at ${DB_PATH}. Run 'npm run build:db'.`) + .toBeTrue(); const wasm = fs.readFileSync(require.resolve("sql.js/dist/sql-wasm.wasm")); const SQL = await initSqlJs({ @@ -64,7 +66,8 @@ describe("Unit - documentation database", () => { it("should meet the minimum document count per framework", () => { for (const fw of FRAMEWORKS) { expect(counts[fw] || 0) - .toBeGreaterThanOrEqual(MIN_DOCS[fw], `${fw} has ${counts[fw] || 0} docs, expected >= ${MIN_DOCS[fw]}`); + .withContext(`${fw} has ${counts[fw] || 0} docs, expected >= ${MIN_DOCS[fw]}`) + .toBeGreaterThanOrEqual(MIN_DOCS[fw]); } }); @@ -87,13 +90,24 @@ describe("Unit - documentation database", () => { it("should have required frontmatter on every document", () => { const bad = rows(` select framework, filename from docs - where component is null or trim(component) = '' - or summary is null or trim(summary) = '' + where summary is null or trim(summary) = '' or keywords is null or trim(keywords) = '' `); expect(bad.map(r => `${r.framework}/${r.filename}`)).toEqual([]); }); + it("should only leave component empty when the document has no library component", () => { + // A few docs legitimately have none — CLI guides, migration walkthroughs. Any + // other empty value means the field was lost. Quote characters count as empty: + // the model has emitted `component: ""`, which reaches the DB as a component + // literally named `""` and shows up as one in list_components. + const bad = rows(` + select framework, filename, component, content from docs + where trim(replace(replace(coalesce(component, ''), '"', ''), '''', '')) = '' + `).filter(r => /\bIg[xrbc][A-Z]\w+/.test(r.content)); + expect(bad.map(r => `${r.framework}/${r.filename}`)).toEqual([]); + }); + it("should have a toc name on every document", () => { // build-db reads _tocName from docs_prepeared; if that directory is missing it // silently writes NULL for every row instead of failing.