diff --git a/.github/actions/build-framework-docs/action.yml b/.github/actions/build-framework-docs/action.yml
new file mode 100644
index 000000000..9c5f60bfe
--- /dev/null
+++ b/.github/actions/build-framework-docs/action.yml
@@ -0,0 +1,205 @@
+name: Build framework documentation
+description: >
+ Runs the documentation pipeline for a single framework (export → inject → rewrite →
+ compress) and uploads the compressed docs and updated baseline as artifacts.
+ Deliberately stops before build:db — the database must be assembled once, from all
+ frameworks at the same time, or it ends up containing only this one.
+
+inputs:
+ framework:
+ description: angular | react | blazor | webcomponents
+ required: true
+ mode:
+ description: incremental | full
+ required: true
+ model:
+ description: Compression model override. Empty uses the compress scripts' default.
+ required: false
+ default: ""
+ submodule-branch:
+ description: Branch to move the documentation submodules to.
+ required: true
+ default: master
+ openai-api-key:
+ description: OpenAI API key used by the compression step.
+ required: true
+
+runs:
+ using: composite
+ steps:
+ - uses: actions/checkout@v6
+
+ # 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: 24.x
+ cache: yarn
+
+ # The cross-platform gulp build restores the docfx dotnet tool.
+ - uses: actions/setup-dotnet@v6
+ if: inputs.framework != 'angular'
+ with:
+ dotnet-version: 8.x
+
+ - name: Install packages
+ shell: bash
+ run: yarn --frozen-lockfile
+
+ - name: Move submodules to ${{ inputs.submodule-branch }}
+ shell: bash
+ working-directory: packages/igniteui-mcp/igniteui-doc-mcp
+ run: ./switch-submodules.sh "${{ inputs.submodule-branch }}"
+
+ - name: Configure OpenAI credentials
+ shell: bash
+ working-directory: packages/igniteui-mcp/igniteui-doc-mcp
+ run: echo "OPENAI_API_KEY=${{ inputs.openai-api-key }}" > .env
+
+ # dist/ is gitignored, so an incremental run starts with no compressed docs at all.
+ # Incremental compression only writes the files that changed, so without this the
+ # artifact would contain a handful of docs instead of the full set.
+ - name: Restore compressed docs from the committed DB
+ if: inputs.mode == 'incremental'
+ shell: bash
+ working-directory: packages/igniteui-mcp/igniteui-doc-mcp
+ run: npx tsx scripts/restore-docs-final.ts --framework "${{ inputs.framework }}"
+
+ - name: Build documentation
+ shell: bash
+ working-directory: packages/igniteui-mcp/igniteui-doc-mcp
+ env:
+ FW: ${{ inputs.framework }}
+ MODE: ${{ inputs.mode }}
+ COMPRESS_MODEL: ${{ inputs.model }}
+ run: |
+ set -euo pipefail
+
+ # Only the xplat gulp target uses an abbreviated name.
+ case "$FW" in
+ webcomponents) XPLAT="wc" ;;
+ angular) XPLAT="" ;;
+ *) XPLAT="$FW" ;;
+ esac
+
+ if [ "$MODE" = "full" ]; then
+ npm run "clear:$FW"
+ else
+ npm run clear:build
+ fi
+
+ if [ -n "$XPLAT" ]; then
+ npm run "build:xplat-$XPLAT"
+ fi
+
+ npm run "export:$FW"
+ npm run "inject:$FW"
+ npm run "rewrite-api-urls:$FW"
+
+ 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"
+ # An empty manifest means nothing changed upstream. batchSubmit exits without
+ # writing _batch_state.json, which would make the subsequent poll fail, so
+ # skip compression entirely — the restored docs are already current.
+ CHANGED=$(node -e "const m=require('./dist/diff-manifest.json');console.log((m.changed||[]).length+(m.added||[]).length)")
+ echo "Manifest reports $CHANGED changed/added document(s)"
+ 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
+
+ # 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: |
+ npx tsx scripts/report-build-summary.ts \
+ --framework "${{ inputs.framework }}" \
+ --mode "${{ inputs.mode }}" >> "$GITHUB_STEP_SUMMARY"
+
+ - name: Upload compressed docs
+ uses: actions/upload-artifact@v7
+ with:
+ name: docs-final-${{ inputs.framework }}
+ path: packages/igniteui-mcp/igniteui-doc-mcp/dist/docs_final/${{ inputs.framework }}
+ retention-days: 5
+
+ # build-db reads _tocName from here. Without it every row's toc_name would be NULL.
+ - name: Upload prepared docs
+ 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@v7
+ with:
+ name: docs-baseline-${{ inputs.framework }}
+ path: packages/igniteui-mcp/igniteui-doc-mcp/docs_baseline/${{ inputs.framework }}
+ retention-days: 5
diff --git a/.github/workflows/build-docs-db.yml b/.github/workflows/build-docs-db.yml
new file mode 100644
index 000000000..3c0802b32
--- /dev/null
+++ b/.github/workflows/build-docs-db.yml
@@ -0,0 +1,263 @@
+name: Build documentation DB
+
+# Manual trigger only. A rebuild costs real money (a full run compresses ~1230
+# documents, roughly 3.5M output tokens), so it is always a deliberate decision.
+on:
+ workflow_dispatch:
+ inputs:
+ mode:
+ description: Recompress everything, or only what changed upstream
+ type: choice
+ options: [incremental, full]
+ default: incremental
+ frameworks:
+ description: Comma-separated subset to rebuild
+ type: string
+ default: angular,react,blazor,webcomponents
+ submodule_branch:
+ description: Branch to move the documentation submodules to
+ type: string
+ default: master
+ model:
+ description: Compression model override (empty uses the script default)
+ type: string
+ default: ""
+
+permissions:
+ contents: read
+
+jobs:
+ # The four compress jobs run strictly one after another. Their state is per-framework
+ # so they *could* run in parallel, but concurrent batch submissions contend for the
+ # same account-level OpenAI limits — in particular enqueued tokens per model.
+ angular:
+ if: contains(inputs.frameworks, 'angular')
+ runs-on: ubuntu-latest
+ timeout-minutes: 330
+ steps:
+ - uses: actions/checkout@v6
+ - uses: ./.github/actions/build-framework-docs
+ with:
+ framework: angular
+ mode: ${{ inputs.mode }}
+ model: ${{ inputs.model }}
+ submodule-branch: ${{ inputs.submodule_branch }}
+ openai-api-key: ${{ secrets.OPENAI_API_KEY }}
+
+ react:
+ needs: angular
+ if: always() && !cancelled() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') && contains(inputs.frameworks, 'react')
+ runs-on: ubuntu-latest
+ timeout-minutes: 330
+ steps:
+ - uses: actions/checkout@v6
+ - uses: ./.github/actions/build-framework-docs
+ with:
+ framework: react
+ mode: ${{ inputs.mode }}
+ model: ${{ inputs.model }}
+ submodule-branch: ${{ inputs.submodule_branch }}
+ openai-api-key: ${{ secrets.OPENAI_API_KEY }}
+
+ blazor:
+ needs: react
+ if: always() && !cancelled() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') && contains(inputs.frameworks, 'blazor')
+ runs-on: ubuntu-latest
+ timeout-minutes: 330
+ steps:
+ - uses: actions/checkout@v6
+ - uses: ./.github/actions/build-framework-docs
+ with:
+ framework: blazor
+ mode: ${{ inputs.mode }}
+ model: ${{ inputs.model }}
+ submodule-branch: ${{ inputs.submodule_branch }}
+ openai-api-key: ${{ secrets.OPENAI_API_KEY }}
+
+ webcomponents:
+ needs: blazor
+ if: always() && !cancelled() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') && contains(inputs.frameworks, 'webcomponents')
+ runs-on: ubuntu-latest
+ timeout-minutes: 330
+ steps:
+ - uses: actions/checkout@v6
+ - uses: ./.github/actions/build-framework-docs
+ with:
+ framework: webcomponents
+ mode: ${{ inputs.mode }}
+ model: ${{ inputs.model }}
+ submodule-branch: ${{ inputs.submodule_branch }}
+ openai-api-key: ${{ secrets.OPENAI_API_KEY }}
+
+ # The database is assembled exactly once, here, with every framework's docs present.
+ # A per-framework build:db on a fresh runner finds no existing DB and rebuilds from
+ # scratch with only that framework — the bug that shipped a 112-doc and later an
+ # angular-only database.
+ assemble:
+ needs: [angular, react, blazor, webcomponents]
+ 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: 24.x
+ cache: yarn
+ - name: Install packages
+ run: yarn --frozen-lockfile
+
+ - uses: actions/download-artifact@v8
+ with:
+ pattern: docs-final-*
+ path: packages/igniteui-mcp/igniteui-doc-mcp/dist/docs_final
+ - uses: actions/download-artifact@v8
+ with:
+ pattern: docs-prepeared-*
+ path: packages/igniteui-mcp/igniteui-doc-mcp/dist/docs_prepeared
+ - uses: actions/download-artifact@v8
+ with:
+ pattern: docs-baseline-*
+ path: packages/igniteui-mcp/igniteui-doc-mcp/docs_baseline
+
+ # download-artifact nests each artifact under its own name; flatten to the
+ # framework directories that build-db expects.
+ - name: Flatten artifact layout
+ working-directory: packages/igniteui-mcp/igniteui-doc-mcp
+ run: |
+ set -euo pipefail
+ for kind in docs_final:dist/docs_final docs_prepeared:dist/docs_prepeared docs_baseline:docs_baseline; do
+ prefix="${kind%%:*}"; dir="${kind##*:}"
+ for fw in angular react blazor webcomponents; do
+ src="$dir/${prefix//_/-}-$fw"
+ [ -d "$src" ] && rm -rf "$dir/$fw" && mv "$src" "$dir/$fw" || true
+ done
+ done
+ ls -la dist/docs_final
+
+ # Any framework missing from this run keeps the copy already committed, so the
+ # database is always assembled from a complete set. --toc-stubs also emits the
+ # 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" ] && [ -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
+ working-directory: packages/igniteui-mcp/igniteui-doc-mcp
+ run: npm run build:db
+
+ - name: Verify document counts
+ run: |
+ 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@v7
+ with:
+ name: igniteui-docs-db
+ path: |
+ packages/igniteui-mcp/igniteui-doc-mcp/db/igniteui-docs.db
+ packages/igniteui-mcp/igniteui-doc-mcp/docs_baseline
+ retention-days: 5
+
+ # The only job that writes to the repository. It opens a PR for review — nothing is
+ # pushed to a protected branch and nothing auto-merges.
+ publish:
+ needs: assemble
+ # 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@v8
+ with:
+ name: igniteui-docs-db
+ path: artifact
+
+ - name: Apply rebuilt database and baselines
+ run: |
+ set -euo pipefail
+
+ # upload-artifact roots an artifact at the least common ancestor of its paths,
+ # so the layout under artifact/ depends on which paths were uploaded together.
+ # Locate the contents instead of assuming a depth — this step runs after hours
+ # of compression, so it must not fail on a path guess.
+ DB=$(find artifact -type f -name igniteui-docs.db | head -1)
+ BASELINE=$(find artifact -type d -name docs_baseline | head -1)
+
+ if [ -z "$DB" ] || [ -z "$BASELINE" ]; then
+ echo "::error::Could not locate the database or baselines in the artifact."
+ find artifact
+ exit 1
+ fi
+ echo "Using DB: $DB"
+ echo "Using baselines: $BASELINE"
+
+ cp "$DB" packages/igniteui-mcp/igniteui-doc-mcp/db/igniteui-docs.db
+ # Kept in sync with the doc-mcp copy, as every prior doc-update commit has done.
+ cp "$DB" packages/igniteui-mcp/docs-backend/docs-backend/igniteui-docs.db
+ rm -rf packages/igniteui-mcp/igniteui-doc-mcp/docs_baseline
+ cp -r "$BASELINE" packages/igniteui-mcp/igniteui-doc-mcp/docs_baseline
+ rm -rf artifact
+
+ - name: Commit and open pull request
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ set -euo pipefail
+ BRANCH="chore/docs-db-${{ github.run_id }}"
+ git config user.name github-actions
+ git config user.email github-actions@github.com
+ git checkout -b "$BRANCH"
+
+ # Submodule pointers are deliberately excluded — the release pipeline checks
+ # submodules out fresh, so recording them here would only add noise.
+ git add packages/igniteui-mcp/igniteui-doc-mcp/db/igniteui-docs.db \
+ packages/igniteui-mcp/docs-backend/docs-backend/igniteui-docs.db \
+ packages/igniteui-mcp/igniteui-doc-mcp/docs_baseline
+
+ if git diff --cached --quiet; then
+ echo "No changes to publish — the documentation is already up to date."
+ exit 0
+ fi
+
+ git commit -m "chore(mcp): rebuild documentation database (${{ inputs.mode }})"
+ git push origin "$BRANCH"
+ gh pr create \
+ --base "${{ github.ref_name }}" \
+ --head "$BRANCH" \
+ --title "chore(mcp): rebuild documentation database" \
+ --body "Automated rebuild of the Ignite UI documentation database.
+
+ | | |
+ |---|---|
+ | mode | \`${{ inputs.mode }}\` |
+ | frameworks | \`${{ inputs.frameworks }}\` |
+ | submodule branch | \`${{ inputs.submodule_branch }}\` |
+ | model | \`${{ inputs.model || 'script default' }}\` |
+ | run | [#${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) |
+
+ Document counts were verified by \`spec/unit/docs-db-counts-spec.ts\` before this PR was opened.
+
+ Requires manual review and merge."
diff --git a/packages/igniteui-mcp/docs-backend/docs-backend/igniteui-docs.db b/packages/igniteui-mcp/docs-backend/docs-backend/igniteui-docs.db
index 05f833b3c..846340ceb 100644
Binary files a/packages/igniteui-mcp/docs-backend/docs-backend/igniteui-docs.db and b/packages/igniteui-mcp/docs-backend/docs-backend/igniteui-docs.db differ
diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/db/igniteui-docs.db b/packages/igniteui-mcp/igniteui-doc-mcp/db/igniteui-docs.db
index 05f833b3c..846340ceb 100644
Binary files a/packages/igniteui-mcp/igniteui-doc-mcp/db/igniteui-docs.db and b/packages/igniteui-mcp/igniteui-doc-mcp/db/igniteui-docs.db differ
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/docs_baseline/blazor/badge.md b/packages/igniteui-mcp/igniteui-doc-mcp/docs_baseline/blazor/badge.md
index 39bf84b43..16eb2559d 100644
--- a/packages/igniteui-mcp/igniteui-doc-mcp/docs_baseline/blazor/badge.md
+++ b/packages/igniteui-mcp/igniteui-doc-mcp/docs_baseline/blazor/badge.md
@@ -16,18 +16,61 @@ The Ignite UI for Blazor Badge is a component used in conjunction with avatars,
```razor
@using IgniteUI.Blazor.Controls
-
`;
```
diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/docs_baseline/webcomponents/grid-selection.md b/packages/igniteui-mcp/igniteui-doc-mcp/docs_baseline/webcomponents/grid-selection.md
index 2fa90b1f6..28f85f3cf 100644
--- a/packages/igniteui-mcp/igniteui-doc-mcp/docs_baseline/webcomponents/grid-selection.md
+++ b/packages/igniteui-mcp/igniteui-doc-mcp/docs_baseline/webcomponents/grid-selection.md
@@ -143,7 +143,6 @@ The context menu will have the following functions:
this.toggleContextMenu();
}
-
public copySelectedData() {
const selectedData = this.grid.getSelectedData();
this.copyData(selectedData);
diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/docs_baseline/webcomponents/pivot-grid-overview.md b/packages/igniteui-mcp/igniteui-doc-mcp/docs_baseline/webcomponents/pivot-grid-overview.md
index 95d4997d3..f65f7c5b6 100644
--- a/packages/igniteui-mcp/igniteui-doc-mcp/docs_baseline/webcomponents/pivot-grid-overview.md
+++ b/packages/igniteui-mcp/igniteui-doc-mcp/docs_baseline/webcomponents/pivot-grid-overview.md
@@ -256,7 +256,6 @@ Let's take a look at a basic pivot configuration:
enabled: true
}
-
],
rows: [
{
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/build-db.ts b/packages/igniteui-mcp/igniteui-doc-mcp/scripts/build-db.ts
index 74288c483..853af4113 100644
--- a/packages/igniteui-mcp/igniteui-doc-mcp/scripts/build-db.ts
+++ b/packages/igniteui-mcp/igniteui-doc-mcp/scripts/build-db.ts
@@ -120,9 +120,11 @@ function main() {
}
const isFullRebuild = !targetFramework;
+ // Captured before opening — opening the database creates the file.
+ const dbExisted = fs.existsSync(DB_PATH);
let db: Database.Database;
- if (isFullRebuild || !fs.existsSync(DB_PATH)) {
+ if (isFullRebuild || !dbExisted) {
db = new Database(DB_PATH);
db.exec("DROP TABLE IF EXISTS docs_fts");
db.exec("DROP TABLE IF EXISTS docs");
@@ -172,6 +174,13 @@ function main() {
}
db.exec("INSERT INTO docs_fts(docs_fts) VALUES('rebuild')");
+
+ // DROP/DELETE frees pages but never shrinks the file, and this DB is committed to git.
+ // A file created by this run has no free pages, so only vacuum an inherited one.
+ if (dbExisted) {
+ db.exec("VACUUM");
+ }
+
db.pragma("optimize");
const totalRows = (db.prepare("SELECT COUNT(*) AS cnt FROM docs").get() as any).cnt;
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 75a8afe93..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
@@ -56,7 +56,7 @@ interface BatchState {
function parseArgs(): CliArgs {
const args = process.argv.slice(2);
const opts: CliArgs = {
- model: "gpt-5.6-luna",
+ model: process.env.COMPRESS_MODEL || "gpt-5.6-luna",
minSize: 0,
dryRun: false,
delay: 0.5,
@@ -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 7e1465f33..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
@@ -56,7 +56,7 @@ interface BatchState {
function parseArgs(): CliArgs {
const args = process.argv.slice(2);
const opts: CliArgs = {
- model: "gpt-5.6-luna",
+ model: process.env.COMPRESS_MODEL || "gpt-5.6-luna",
minSize: 0,
dryRun: false,
delay: 0.5,
@@ -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 ae6a5cf01..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
@@ -56,7 +56,7 @@ interface BatchState {
function parseArgs(): CliArgs {
const args = process.argv.slice(2);
const opts: CliArgs = {
- model: "gpt-5.6-luna",
+ model: process.env.COMPRESS_MODEL || "gpt-5.6-luna",
minSize: 0,
dryRun: false,
delay: 0.5,
@@ -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 e4caf9870..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
@@ -56,7 +56,7 @@ interface BatchState {
function parseArgs(): CliArgs {
const args = process.argv.slice(2);
const opts: CliArgs = {
- model: "gpt-5.6-luna",
+ model: process.env.COMPRESS_MODEL || "gpt-5.6-luna",
minSize: 0,
dryRun: false,
delay: 0.5,
@@ -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/scripts/restore-docs-final.ts b/packages/igniteui-mcp/igniteui-doc-mcp/scripts/restore-docs-final.ts
new file mode 100644
index 000000000..a43ea81af
--- /dev/null
+++ b/packages/igniteui-mcp/igniteui-doc-mcp/scripts/restore-docs-final.ts
@@ -0,0 +1,113 @@
+/**
+ * Restores dist/docs_final//*.md from the committed SQLite DB.
+ *
+ * Incremental compression only writes changed and added docs into docs_final — it
+ * assumes the unchanged ones are already there from an earlier run. dist/ is
+ * gitignored, so a fresh checkout (CI) has nothing. Without this step an incremental
+ * run would leave docs_final holding only the handful of changed files, and build-db
+ * would produce a near-empty database.
+ *
+ * The DB is committed and always matches what was last published, so it is the
+ * natural source. The frontmatter written here round-trips exactly through
+ * build-db.ts's parseFrontmatter().
+ *
+ * Usage:
+ * npx tsx scripts/restore-docs-final.ts # all frameworks
+ * npx tsx scripts/restore-docs-final.ts --framework angular # one framework
+ * npx tsx scripts/restore-docs-final.ts --db path/to.db # non-default source
+ */
+import Database from "better-sqlite3";
+import * as fs from "fs";
+import * as path from "path";
+
+const FRAMEWORKS = ["angular", "react", "blazor", "webcomponents"];
+const DOCS_FINAL_DIR = path.resolve("dist", "docs_final");
+const DOCS_PREPARED_DIR = path.resolve("dist", "docs_prepeared");
+const DEFAULT_DB = path.resolve("db", "igniteui-docs.db");
+
+interface DocRow {
+ filename: string;
+ component: string;
+ premium: number;
+ keywords: string;
+ summary: string;
+ content: string;
+ toc_name: string | null;
+}
+
+function buildDoc(row: DocRow): string {
+ const lines = ["---", `component: ${row.component}`];
+ if (row.keywords) {
+ lines.push(`keywords: ${row.keywords}`);
+ }
+ if (row.summary) {
+ lines.push(`summary: ${row.summary}`);
+ }
+ if (row.premium) {
+ lines.push("premium: true");
+ }
+ lines.push("---");
+ // parseFrontmatter() strips exactly one newline after the closing ---, and the
+ // stored content keeps its own leading newline, so a single \n round-trips.
+ return `${lines.join("\n")}\n${row.content}`;
+}
+
+function main(): void {
+ const args = process.argv.slice(2);
+
+ const fwIdx = args.indexOf("--framework");
+ const targetFramework = fwIdx !== -1 ? args[fwIdx + 1] : null;
+ if (targetFramework && !FRAMEWORKS.includes(targetFramework)) {
+ console.error(`Unknown framework: ${targetFramework}. Valid: ${FRAMEWORKS.join(", ")}`);
+ process.exit(1);
+ }
+
+ // build-db reads _tocName out of docs_prepeared. When that directory is unavailable
+ // (the assemble job only has compressed docs), stubs carrying just _tocName keep
+ // toc_name populated instead of silently writing NULL for every row.
+ const tocStubs = args.includes("--toc-stubs");
+
+ const dbIdx = args.indexOf("--db");
+ const dbPath = dbIdx !== -1 ? path.resolve(args[dbIdx + 1]) : DEFAULT_DB;
+ if (!fs.existsSync(dbPath)) {
+ console.error(`Database not found: ${dbPath}`);
+ process.exit(1);
+ }
+
+ const db = new Database(dbPath, { readonly: true });
+ const select = db.prepare(
+ "SELECT filename, component, premium, keywords, summary, content, toc_name FROM docs WHERE framework = ?"
+ );
+
+ let grandTotal = 0;
+ for (const fw of targetFramework ? [targetFramework] : FRAMEWORKS) {
+ const rows = select.all(fw) as DocRow[];
+ if (rows.length === 0) {
+ console.warn(` [warn] ${fw}: no rows in ${path.basename(dbPath)} — nothing restored`);
+ continue;
+ }
+
+ const outDir = path.join(DOCS_FINAL_DIR, fw);
+ fs.mkdirSync(outDir, { recursive: true });
+
+ const stubDir = path.join(DOCS_PREPARED_DIR, fw);
+ if (tocStubs) {
+ fs.mkdirSync(stubDir, { recursive: true });
+ }
+
+ for (const row of rows) {
+ fs.writeFileSync(path.join(outDir, row.filename), buildDoc(row), "utf-8");
+ if (tocStubs && row.toc_name) {
+ fs.writeFileSync(path.join(stubDir, row.filename), `---\n_tocName: ${row.toc_name}\n---\n`, "utf-8");
+ }
+ }
+
+ grandTotal += rows.length;
+ console.log(` ${fw}: ${rows.length} docs restored to dist/docs_final/${fw}/${tocStubs ? " (+ toc stubs)" : ""}`);
+ }
+
+ db.close();
+ console.log(`\nRestored ${grandTotal} documents from ${dbPath}`);
+}
+
+main();
diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/switch-submodules.sh b/packages/igniteui-mcp/igniteui-doc-mcp/switch-submodules.sh
new file mode 100755
index 000000000..19e9f84f5
--- /dev/null
+++ b/packages/igniteui-mcp/igniteui-doc-mcp/switch-submodules.sh
@@ -0,0 +1,32 @@
+#!/bin/bash
+set -euo pipefail
+
+BRANCH="${1:-master}"
+BASE="$(cd "$(dirname "$0")" && pwd)"
+SUBMODULES=(
+ angular/igniteui-angular
+ angular/igniteui-angular-examples
+ blazor/igniteui-blazor-examples
+ common/igniteui-xplat-docs
+ react/igniteui-react-examples
+ webcomponents/igniteui-wc-examples
+)
+
+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
+ git -C "$dir" checkout "$BRANCH" && git -C "$dir" pull
+ else
+ echo "Branch '$BRANCH' not found, using master"
+ git -C "$dir" checkout master && git -C "$dir" pull
+ fi
+done
diff --git a/spec/unit/docs-db-counts-spec.ts b/spec/unit/docs-db-counts-spec.ts
new file mode 100644
index 000000000..2e5f9f0d9
--- /dev/null
+++ b/spec/unit/docs-db-counts-spec.ts
@@ -0,0 +1,139 @@
+import * as fs from "fs";
+import * as path from "path";
+
+const sqljs = require("sql.js");
+const initSqlJs: any = sqljs.default ?? sqljs;
+const DB_PATH = process.env.DOCS_DB_PATH ||
+ path.join(__dirname, "..", "..", "packages", "igniteui-mcp", "igniteui-doc-mcp", "db", "igniteui-docs.db");
+
+const FRAMEWORKS = ["angular", "react", "blazor", "webcomponents"];
+
+// Floors sit ~20% below the counts at the time of writing (angular 376, react 287,
+// blazor 270, webcomponents 299). They tolerate ordinary doc churn but fail on a
+// partial build — the failure mode that shipped a 112-doc and later an angular-only DB.
+const MIN_DOCS: { [fw: string]: number } = {
+ angular: 300,
+ react: 230,
+ blazor: 215,
+ webcomponents: 240
+};
+const MIN_TOTAL = 1000;
+
+describe("Unit - documentation database", () => {
+ let db: any;
+ const counts: { [fw: string]: number } = {};
+ let total = 0;
+
+ function rows(sql: string): any[] {
+ const res = db.exec(sql);
+ if (!res.length) {
+ return [];
+ }
+ return res[0].values.map((v: any[]) =>
+ res[0].columns.reduce((acc: any, col: string, i: number) => {
+ acc[col] = v[i];
+ return acc;
+ }, {}));
+ }
+
+ beforeAll(async () => {
+ expect(fs.existsSync(DB_PATH)).toBe(true, `Database not found at ${DB_PATH}. Run 'npm run build:db'.`);
+
+ const wasm = fs.readFileSync(require.resolve("sql.js/dist/sql-wasm.wasm"));
+ const SQL = await initSqlJs({
+ wasmBinary: wasm.buffer.slice(wasm.byteOffset, wasm.byteOffset + wasm.byteLength)
+ });
+ db = new SQL.Database(fs.readFileSync(DB_PATH));
+
+ for (const r of rows("select framework, count(*) c from docs group by framework")) {
+ counts[r.framework] = r.c;
+ }
+ total = rows("select count(*) c from docs")[0].c;
+ });
+
+ afterAll(() => {
+ if (db) {
+ db.close();
+ }
+ });
+
+ it("should contain every framework", () => {
+ expect(Object.keys(counts).sort()).toEqual(FRAMEWORKS.slice().sort());
+ });
+
+ 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]}`);
+ }
+ });
+
+ it("should have a total matching the sum of all frameworks", () => {
+ expect(total).toBeGreaterThanOrEqual(MIN_TOTAL);
+ expect(total).toEqual(FRAMEWORKS.reduce((sum, fw) => sum + (counts[fw] || 0), 0));
+ });
+
+ it("should not have any framework starved relative to the others", () => {
+ // A partial build leaves one framework whole and the rest tiny.
+ const values = FRAMEWORKS.map(fw => counts[fw] || 0);
+ expect(Math.min(...values) / Math.max(...values)).toBeGreaterThan(0.4);
+ });
+
+ it("should not contain empty or truncated documents", () => {
+ const bad = rows("select framework, filename from docs where content is null or length(trim(content)) < 200");
+ expect(bad.map(r => `${r.framework}/${r.filename}`)).toEqual([]);
+ });
+
+ it("should have required frontmatter on every document", () => {
+ const bad = rows(`
+ select framework, filename from docs
+ 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 not index documents under sample-application class names", () => {
+ // Every listed name should carry an Ignite UI prefix, be a documented API class,
+ // or at minimum not look like demo code. Sample classes such as MyComponent or
+ // ReactiveFormsSampleComponent make a document unreachable via list_components.
+ const suspect = /(^My[A-Z]|Sample(Component|Page)?$|^App(Component|Module)?$|Validator(Directive)?$)/;
+ const bad = rows("select framework, filename, component from docs where component is not null")
+ .filter(r => (r.component || "").split(",")
+ .map((s: string) => s.trim())
+ .some((s: string) => s && !/^Ig[xrbc][A-Z]/.test(s) && suspect.test(s)));
+ expect(bad.map(r => `${r.framework}/${r.filename}: ${r.component}`)).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.
+ const bad = rows("select framework, filename from docs where toc_name is null or trim(toc_name) = ''");
+ expect(bad.map(r => `${r.framework}/${r.filename}`)).toEqual([]);
+ });
+
+ it("should not contain duplicate documents", () => {
+ const dupes = rows("select framework, filename from docs group by framework, filename having count(*) > 1");
+ expect(dupes.map(r => `${r.framework}/${r.filename}`)).toEqual([]);
+ });
+
+ it("should keep the FTS index in sync with the docs table", () => {
+ expect(rows("select count(*) c from docs_fts")[0].c).toEqual(total);
+ });
+
+ it("should return results from a full-text search", () => {
+ expect(rows("select rowid from docs_fts where docs_fts match 'grid' limit 5").length).toBeGreaterThan(0);
+ });
+});