diff --git a/.github/actions/build-framework-docs/action.yml b/.github/actions/build-framework-docs/action.yml new file mode 100644 index 000000000..f69fab1cf --- /dev/null +++ b/.github/actions/build-framework-docs/action.yml @@ -0,0 +1,148 @@ +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 + with: + submodules: recursive + + - uses: actions/setup-node@v6 + with: + node-version: 22.x + cache: yarn + + # The cross-platform gulp build restores the docfx dotnet tool. + - uses: actions/setup-dotnet@v4 + 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 + 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 + fi + npm run "update-baseline:$FW" + fi + + - name: Report compression stats + 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 + + - name: Upload compressed docs + uses: actions/upload-artifact@v4 + 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@v4 + 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 + 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..dc9a1b87b --- /dev/null +++ b/.github/workflows/build-docs-db.yml @@ -0,0 +1,249 @@ +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(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(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(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') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 22.x + cache: yarn + - name: Install packages + run: yarn --frozen-lockfile + + - uses: actions/download-artifact@v4 + with: + pattern: docs-final-* + path: packages/igniteui-mcp/igniteui-doc-mcp/dist/docs_final + - uses: actions/download-artifact@v4 + with: + pattern: docs-prepeared-* + path: packages/igniteui-mcp/igniteui-doc-mcp/dist/docs_prepeared + - uses: actions/download-artifact@v4 + 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 + 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 + fi + 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@v4 + 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 + if: success() + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v6 + - uses: actions/download-artifact@v4 + 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..910bf53b0 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..910bf53b0 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/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..a99d15b12 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, 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..c8c4a2af9 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, 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..efe7944eb 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, 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..9115ea456 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, 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..c2a93b25b --- /dev/null +++ b/packages/igniteui-mcp/igniteui-doc-mcp/switch-submodules.sh @@ -0,0 +1,26 @@ +#!/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 ---" + 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..fac96be8f --- /dev/null +++ b/spec/unit/docs-db-counts-spec.ts @@ -0,0 +1,116 @@ +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 component is null or trim(component) = '' + or summary is null or trim(summary) = '' + or keywords is null or trim(keywords) = '' + `); + 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. + 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); + }); +});