diff --git a/.agents/skills/ci-prep/SKILL.md b/.agents/skills/ci-prep/SKILL.md index f990d876..d7fb2032 100644 --- a/.agents/skills/ci-prep/SKILL.md +++ b/.agents/skills/ci-prep/SKILL.md @@ -3,7 +3,7 @@ name: ci-prep description: Prepares the current branch for CI by running the exact same steps locally and fixing issues. If CI is already failing, fetches the GH Actions logs first to diagnose. Use before pushing, when CI is red, or when the user says "fix ci". argument-hint: "[--failing] [optional job name to focus on]" --- - + # CI Prep diff --git a/.agents/skills/code-dedup/SKILL.md b/.agents/skills/code-dedup/SKILL.md index e0cd6363..b9389ea9 100644 --- a/.agents/skills/code-dedup/SKILL.md +++ b/.agents/skills/code-dedup/SKILL.md @@ -2,7 +2,7 @@ name: code-dedup description: Searches for duplicate code, duplicate tests, and dead code, then safely merges or removes them. Use when the user says "deduplicate", "find duplicates", "remove dead code", "DRY up", or "code dedup". Requires test coverage — refuses to touch untested code. --- - + # Code Dedup diff --git a/.agents/skills/fix-bug/SKILL.md b/.agents/skills/fix-bug/SKILL.md index 3eb8279c..fb30007b 100644 --- a/.agents/skills/fix-bug/SKILL.md +++ b/.agents/skills/fix-bug/SKILL.md @@ -4,7 +4,7 @@ description: Fix a bug using test-driven development. Use when the user reports argument-hint: "[bug description]" allowed-tools: Read, Grep, Glob, Edit, Write, Bash --- - + # Bug Fix Skill — Test-First Workflow diff --git a/.agents/skills/spec-check/SKILL.md b/.agents/skills/spec-check/SKILL.md index a077d8b3..540f101c 100644 --- a/.agents/skills/spec-check/SKILL.md +++ b/.agents/skills/spec-check/SKILL.md @@ -3,7 +3,7 @@ name: spec-check description: Audit spec/plan documents against the codebase. Ensures every spec section has implementing code, tests, and matching logic. Use when the user says "check specs", "spec audit", or "verify specs". argument-hint: "[optional spec ID or filename filter]" --- - + # spec-check diff --git a/.agents/skills/submit-pr/SKILL.md b/.agents/skills/submit-pr/SKILL.md index e4c85b15..6ab06153 100644 --- a/.agents/skills/submit-pr/SKILL.md +++ b/.agents/skills/submit-pr/SKILL.md @@ -3,7 +3,7 @@ name: submit-pr description: Creates a pull request with a well-structured description after verifying CI passes. Use when the user asks to submit, create, or open a pull request. disable-model-invocation: true --- - + # Submit PR diff --git a/.agents/skills/upgrade-packages/SKILL.md b/.agents/skills/upgrade-packages/SKILL.md index 82a654b4..6dbfde1c 100644 --- a/.agents/skills/upgrade-packages/SKILL.md +++ b/.agents/skills/upgrade-packages/SKILL.md @@ -3,7 +3,7 @@ name: upgrade-packages description: Upgrade all dependencies/packages to their latest versions for the detected language(s). Use when the user says "upgrade packages", "update dependencies", "bump versions", "update packages", or "upgrade deps". argument-hint: "[--check-only] [--major] [package-name]" --- - + # Upgrade Packages diff --git a/.agents/skills/website-audit/SKILL.md b/.agents/skills/website-audit/SKILL.md index 6f786a0e..07ccf612 100644 --- a/.agents/skills/website-audit/SKILL.md +++ b/.agents/skills/website-audit/SKILL.md @@ -2,7 +2,7 @@ name: website-audit description: Audits a website for SEO, AI search performance, structured data, mobile usability, broken links, and social media cards. Fixes issues found. Use when the user mentions "audit website", "SEO", "fix search ranking", "AI search", "structured data", "social media cards", or "website performance". --- - + # Website Audit diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..83d360b8 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(make fmt-check *)", + "Bash(gh issue *)" + ] + }, + "autoMemoryEnabled": false +} diff --git a/.clinerules/00-read-instructions.md b/.clinerules/00-read-instructions.md index 48aaceb3..7bf96649 100644 --- a/.clinerules/00-read-instructions.md +++ b/.clinerules/00-read-instructions.md @@ -1,2 +1,2 @@ - + @CLAUDE.md diff --git a/.deslop.toml b/.deslop.toml new file mode 100644 index 00000000..1dccf4a1 --- /dev/null +++ b/.deslop.toml @@ -0,0 +1,15 @@ +# agent-pmo:795a9c2 +# Deslop duplication gate — REPO-STANDARDS-SPEC [CI-DESLOP] +# Single source of truth for this repo's duplication budget. Committed, PR-reviewed, +# ratcheted DOWN only (never up without written justification). +# CI runs `deslop .`, which reads this file and exits 3 (tanking the build) when +# measured repo-wide duplication exceeds the value below. +# Docs: https://deslop.live/docs/for-ai/ +# +# NOTE: the local Deslop CLI on the authoring machine (0.5.1) is far behind the +# current release (0.14.0) and did not produce a clean measurement, so this keeps +# the conservative template default. After the first CI run reports the real +# `metrics.duplication_percent`, ratchet this DOWN to that measured value (round +# up ~0.3% for cross-runner jitter). Never raise it without written justification. +[threshold] +max_duplication_percent = 5.0 diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 00000000..c2ac60ab --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,7 @@ +# macos-13 is a real GitHub-hosted Intel runner (required for the darwin-x64 +# build leg per Shipwright manifest-and-platforms.md); actionlint's bundled +# label list lags, so declare it as a known self-hosted-style label to silence +# the false "unknown label" report. [SWR-VSIX-TARGETS] +self-hosted-runner: + labels: + - macos-13 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 48aaceb3..7bf96649 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,2 +1,2 @@ - + @CLAUDE.md diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..ec2e7f78 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,72 @@ +# agent-pmo:795a9c2 +# Dependabot — supply-chain defense without PR spam, and without burning CI on +# `main` for routine bumps. Portfolio standard ([GITHUB-DEPENDABOT]). +# +# Routine VERSION bumps target the long-lived `dependabot-upgrades` STAGING +# branch (never `main`). ci.yml / codeql.yml trigger only on +# `pull_request: [main]`, so staging PRs run NO build/test/CodeQL; they are +# auto-merged by .github/workflows/dependabot-automerge.yml. The batch reaches +# `main` through ONE consolidation PR where CI/CodeQL run exactly once. +# SECURITY updates ignore `target-branch` and still open against `main`. +# +# Ecosystems kept = the manifests THIS repo actually has: nuget (.csproj/.fsproj), +# cargo (Lql/lql-lsp-rust workspace), npm (root + Website + LqlExtension + +# LqlWebsite-Eleventy), and github-actions. No Python/Dart/Go manifests exist. +# +# REQUIREMENT: the `dependabot-upgrades` branch must exist and carry +# dependabot-automerge.yml (cut from main AFTER both land on main). +version: 2 + +updates: + # GitHub Actions — every repo with workflows. Pinned actions are a prime + # supply-chain target, so keep them current. + - package-ecosystem: github-actions + directory: / + target-branch: "dependabot-upgrades" + schedule: + interval: weekly + open-pull-requests-limit: 5 + labels: ["dependencies"] + groups: + github-actions: + patterns: ["*"] + + # NuGet / .NET — *.csproj, *.fsproj across the solution. + - package-ecosystem: nuget + directory: / + target-branch: "dependabot-upgrades" + schedule: + interval: weekly + open-pull-requests-limit: 5 + labels: ["dependencies"] + groups: + nuget: + patterns: ["*"] + + # Cargo / Rust — the LQL LSP workspace. + - package-ecosystem: cargo + directory: /Lql/lql-lsp-rust + target-branch: "dependabot-upgrades" + schedule: + interval: weekly + open-pull-requests-limit: 5 + labels: ["dependencies"] + groups: + cargo: + patterns: ["*"] + + # npm / Node — root tooling + both Eleventy sites + the VS Code extension. + - package-ecosystem: npm + directories: + - "/" + - "/Website" + - "/Lql/LqlExtension" + - "/Lql/LqlWebsite-Eleventy" + target-branch: "dependabot-upgrades" + schedule: + interval: weekly + open-pull-requests-limit: 5 + labels: ["dependencies"] + groups: + npm: + patterns: ["*"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 822f8f9b..4099e1cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,3 +1,4 @@ +# agent-pmo:795a9c2 name: CI on: @@ -10,7 +11,8 @@ concurrency: cancel-in-progress: true env: - DOTNET_VERSION: '9.0.x' + # Projects target a mix of net9.0, net10.0 and netstandard2.1, so both SDKs + # are installed everywhere .NET builds (see the setup-dotnet steps below). DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true DOTNET_CLI_TELEMETRY_OPTOUT: true @@ -23,10 +25,12 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Setup .NET + - name: Setup .NET (9 + 10) uses: actions/setup-dotnet@v4 with: - dotnet-version: ${{ env.DOTNET_VERSION }} + dotnet-version: | + 9.0.x + 10.0.x - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -115,10 +119,12 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Setup .NET + - name: Setup .NET (9 + 10) uses: actions/setup-dotnet@v4 with: - dotnet-version: ${{ env.DOTNET_VERSION }} + dotnet-version: | + 9.0.x + 10.0.x - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -280,3 +286,77 @@ jobs: - name: Package VSIX (dry run) run: cd Lql/LqlExtension && npm run compile && npx vsce package --no-git-tag-version --no-update-package-json + + # Duplication gate — Deslop ([CI-DESLOP]). Reads the threshold from .deslop.toml + # and exits 3 (failing the build) when measured duplication exceeds it. Owns the + # "duplicated code" concern; never hardcode the threshold here. + deslop: + name: Duplication (Deslop) + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + DESLOP_VERSION: '0.14.0' + steps: + - uses: actions/checkout@v4 + - name: Install Deslop + run: | + set -euo pipefail + curl -fsSL -o /usr/local/bin/deslop \ + "https://github.com/Nimblesite/Deslop/releases/download/v${DESLOP_VERSION}/deslop-x86_64-unknown-linux-gnu" + chmod +x /usr/local/bin/deslop + deslop --version + - name: Check duplication (reads .deslop.toml) + run: deslop . + - name: Upload Deslop report + uses: actions/upload-artifact@v4 + if: always() + with: + name: deslop-report + path: deslop-report.* + + # Vulnerable-dependency gate ([GITHUB-DEP-REVIEW]). Fails a PR that introduces a + # dependency with a known High+ advisory. Owns the "vulnerable deps" concern + # (CodeQL owns vulnerable code; make lint owns style). No overlap. + security: + name: Dependency review + runs-on: ubuntu-latest + timeout-minutes: 10 + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@v4 + - name: Dependency review + uses: actions/dependency-review-action@v4 + with: + fail-on-severity: high + + # Aggregating gate. This is the SINGLE required status check for branch + # protection ([GITHUB-PROTECTION]): it passes only when every real job above + # passed, giving a stable check name regardless of how the matrix evolves. + ci: + name: CI + runs-on: ubuntu-latest + timeout-minutes: 5 + if: always() + needs: + - lint-and-dataprovider + - build-and-test + - extension-tests + - deslop + - security + steps: + - name: Verify all required jobs succeeded + run: | + set -euo pipefail + # `security` is skipped on non-PR events; treat skipped as acceptable, + # any failure/cancellation as fatal. + results='${{ toJSON(needs) }}' + echo "$results" + fail=0 + for job in lint-and-dataprovider build-and-test extension-tests deslop security; do + result=$(echo "$results" | jq -r --arg j "$job" '.[$j].result') + if [ "$result" != "success" ] && [ "$result" != "skipped" ]; then + echo "::error::Required job '$job' did not succeed (result: $result)" + fail=1 + fi + done + exit $fail diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..1802b2c0 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,136 @@ +# agent-pmo:795a9c2 +name: CodeQL + +# CodeQL static security analysis ([GITHUB-CODE-SCANNING]). +# +# SEPARATE from ci.yml on purpose: CodeQL feeds GitHub code-scanning alerts and +# needs `security-events: write` + a weekly schedule, while ci.yml owns +# lint/test/build. It does NOT overlap with `make lint` (style/correctness) or +# dependency-review (vulnerable packages) — CodeQL finds vulnerable CODE. +# +# Matrix = (languages in this repo) ∩ (CodeQL-supported, checked at skill-run +# time): csharp, javascript-typescript (the LQL VS Code extension + Eleventy +# sites), rust (the LQL LSP), plus `actions` (always). F# is NOT CodeQL-supported +# and is therefore absent. Action SHAs are kept current by the github-actions +# Dependabot group ([GITHUB-DEPENDABOT]). +on: + pull_request: + branches: [main] + schedule: + # Weekly, so newly-published CodeQL queries re-scan even without a push. + - cron: "27 4 * * 1" + # release.yml calls this with gate=true on every v* tag to scan the exact + # released SHA with the current query set and BLOCK publishing on any + # High/Critical finding — a HARD gate, not advice. [GITHUB-CODE-SCANNING] + workflow_call: + inputs: + gate: + description: >- + When true (release calls), fail the job on any High/Critical finding so + the calling release workflow cannot publish. PR/weekly runs leave this + false and stay advisory (the PR check-failure threshold governs merges). + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: codeql-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + # Code scanning (SARIF upload) requires GitHub Advanced Security on PRIVATE + # repos. Gating on public visibility lets a private repo skip cleanly and + # self-enable the moment it is made public. + if: github.event.repository.visibility == 'public' + permissions: + security-events: write + actions: read + contents: read + strategy: + fail-fast: false + matrix: + # build-mode: none suits interpreted langs + rust + csharp (avoids + # recompiling what ci already builds). + include: + - language: actions # scans the workflow files themselves + build-mode: none + - language: csharp + build-mode: none + - language: javascript-typescript + build-mode: none + - language: rust + build-mode: none + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - name: Initialize CodeQL + uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + queries: security-extended + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + with: + category: "/language:${{ matrix.language }}" + # Drop SARIF on disk so the gate step can read it. `upload` stays on + # (default) so alerts still post to code scanning on every run. + output: sarif-results + # Release gate. `security-severity` is the 0-10 CVSS-style score CodeQL + # attaches to each security rule; >= 7.0 == High or Critical. Enforced ONLY + # on gated (release) calls. FAILS CLOSED: missing/malformed SARIF errors, + # never passes. [GITHUB-CODE-SCANNING] + - name: Enforce no high/critical findings (release gate) + if: inputs.gate + shell: bash + env: + SARIF_DIR: sarif-results + SEVERITY_THRESHOLD: '7.0' + run: |- + set -euo pipefail + shopt -s nullglob + # Fail closed: no SARIF means we cannot prove the code is clean. + sarifs=( "${SARIF_DIR}"/*.sarif ) + if [ "${#sarifs[@]}" -eq 0 ]; then + echo "::error::CodeQL gate: no SARIF in ${SARIF_DIR}; cannot verify findings — failing closed." + exit 1 + fi + offenders=0 + for sarif in "${sarifs[@]}"; do + if ! jq -e '.runs' "${sarif}" >/dev/null 2>&1; then + echo "::error::CodeQL gate: ${sarif} is not valid SARIF (no .runs) — failing closed." + exit 1 + fi + jq -r --arg f "${sarif##*/}" ' + ([ (.runs[].tool.driver.rules // [])[], + (.runs[].tool.extensions[]?.rules // [])[] ]) as $rules + | "CodeQL gate: \($f): results=\([.runs[].results[]?]|length) severity_rules=\([$rules[]|select(.properties["security-severity"])]|length)" + ' "${sarif}" + hits="$(jq -r --argjson t "${SEVERITY_THRESHOLD}" ' + .runs[] + | ( [ (.tool.driver.rules // [])[], + (.tool.extensions[]?.rules // [])[] ] + | map({ key: .id, + value: ((.properties["security-severity"] // "0") | tonumber) }) + | from_entries + ) as $severity + | .results[] + | select( ($severity[.ruleId] // 0) >= $t ) + | .ruleId + ' "${sarif}" | sort | uniq -c | sort -rn)" + if [ -n "${hits}" ]; then + echo "::error::High/critical CodeQL findings in ${sarif}:" + echo "${hits}" + offenders=$((offenders + 1)) + fi + done + if [ "${offenders}" -gt 0 ]; then + echo "::error::CodeQL gate failed — release blocked. Fix or dismiss-and-exclude the findings, then re-tag." + exit 1 + fi + echo "CodeQL gate passed: nothing at or above severity ${SEVERITY_THRESHOLD}." diff --git a/.github/workflows/dependabot-automerge.yml b/.github/workflows/dependabot-automerge.yml new file mode 100644 index 00000000..9c4f9832 --- /dev/null +++ b/.github/workflows/dependabot-automerge.yml @@ -0,0 +1,37 @@ +# agent-pmo:795a9c2 +name: Dependabot auto-merge + +# Auto-merges Dependabot version-bump PRs INTO the `dependabot-upgrades` staging +# branch so they never pile up for a human to clear ([GITHUB-DEPENDABOT]). +# +# Safe to merge unattended: nothing reaches `main` this way. The full +# build/test (ci.yml) + CodeQL (codeql.yml) gate the single +# `dependabot-upgrades -> main` consolidation PR, which is where review and the +# expensive matrix actually run. This is a ~10-second merge bot, NOT the CI +# pipeline. +# +# Lives at the repo root so it is present on `dependabot-upgrades` (cut from +# main): `pull_request` runs the workflow from the PR base ref. +on: + pull_request: + branches: + - dependabot-upgrades + +permissions: + contents: write + pull-requests: write + +jobs: + auto-merge: + name: Auto-merge into dependabot-upgrades + if: github.actor == 'dependabot[bot]' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Squash-merge the bump into the staging branch + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh pr merge --auto --squash --delete-branch "$PR_URL" \ + || gh pr merge --squash --delete-branch "$PR_URL" diff --git a/.github/workflows/publish-brew-tap.yml b/.github/workflows/publish-brew-tap.yml new file mode 100644 index 00000000..d7292102 --- /dev/null +++ b/.github/workflows/publish-brew-tap.yml @@ -0,0 +1,97 @@ +# Vendored from Shipwright templates/gh-actions/publish-brew-tap.yml +# Writes Formula/.rb (url/version/sha256 + --version test) to the tap repo. +# sha256 MUST come from the build's signed SHA256SUMS, never recomputed. [SWR-SEC-CHECKSUM] +name: publish-brew-tap + +on: + workflow_call: + inputs: + tap_repo: + required: true + type: string + formula_name: + required: true + type: string + version: + required: true + type: string + asset_url: + required: true + type: string + sha256: + required: true + type: string + binary_name: + required: true + type: string + homepage: + required: true + type: string + description: + required: true + type: string + secrets: + tap_token: + required: true + +# SWR-SEC-TOKEN-PRIVILEGE: this job checks out an EXTERNAL tap repo via tap_token +# and pushes to it; its own GITHUB_TOKEN needs no write. +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + repository: ${{ inputs.tap_repo }} + token: ${{ secrets.tap_token }} + path: tap + - name: Write formula + shell: bash + working-directory: tap + env: + FORMULA_NAME: ${{ inputs.formula_name }} + DESCRIPTION: ${{ inputs.description }} + HOMEPAGE: ${{ inputs.homepage }} + ASSET_URL: ${{ inputs.asset_url }} + VERSION: ${{ inputs.version }} + SHA256: ${{ inputs.sha256 }} + BINARY_NAME: ${{ inputs.binary_name }} + run: | + set -euo pipefail + class_name="$(ruby -e 'puts ARGV[0].split(/[-_]/).map(&:capitalize).join' "$FORMULA_NAME")" + mkdir -p Formula + cat > "Formula/${FORMULA_NAME}.rb" </, package, verify contents, attest provenance, then publish each VSIX +# to the Marketplace via Entra OIDC (no stored PAT). +# [SWR-VSIX-PACKAGE] [SWR-VSIX-LAYOUT] [SWR-VSIX-VERIFY] [SWR-VSIX-PUBLISH-OIDC] [SWR-VSIX-PROVENANCE] +name: publish-vsix-per-platform + +on: + workflow_call: + inputs: + version: + required: true + type: string + pre_release: + required: false + type: boolean + default: false + marketplace_environment: + required: false + type: string + default: release + secrets: + azure_client_id: + required: false + azure_tenant_id: + required: false + +permissions: + contents: read + +env: + EXT_DIR: Lql/LqlExtension + RUST_DIR: Lql/lql-lsp-rust + BINARY_NAME: lql-lsp + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - target: darwin-arm64 + os: macos-15 + npm_arch: arm64 + - target: darwin-x64 + os: macos-13 + npm_arch: x64 + - target: linux-x64 + os: ubuntu-latest + npm_arch: x64 + - target: win32-x64 + os: windows-latest + npm_arch: x64 + runs-on: ${{ matrix.os }} + permissions: + contents: read + id-token: write + attestations: write + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version: 22 + - name: Stamp version from tag + shell: bash + run: python3 tools/shipwright-version-stamp.py --tag "v${{ inputs.version }}" --root . + - name: Build lql-lsp (release, locked) + shell: bash + working-directory: ${{ env.RUST_DIR }} + run: cargo build --release --locked -p lql-lsp + - name: Sign + notarize darwin binary (before staging) + if: runner.os == 'macOS' && env.APPLE_TEAM_ID != '' + shell: bash + working-directory: ${{ env.RUST_DIR }} + env: + APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }} + run: | + set -euo pipefail + codesign --force --options runtime --timestamp \ + --sign "Developer ID Application: ${APPLE_TEAM_ID}" \ + "target/release/${BINARY_NAME}" + # [SWR-SIGN-APPLE-INTEGRATION] notarization runs in the release env when + # the App Store Connect API-key secrets are configured (manual follow-up). + - name: Stage binary under bin// and pin .vscodeignore + shell: bash + run: | + set -euo pipefail + exe="" + if [ "${{ runner.os }}" = "Windows" ]; then exe=".exe"; fi + dest="${EXT_DIR}/bin/${{ matrix.target }}" + mkdir -p "$dest" + cp "${RUST_DIR}/target/release/${BINARY_NAME}${exe}" "${dest}/${BINARY_NAME}${exe}" + # Verify the staged binary's version matches the tag before packaging. + "${dest}/${BINARY_NAME}${exe}" --version | grep -Fx "${BINARY_NAME} ${{ inputs.version }}" + # Pattern B manual .vscodeignore: ship ONLY this target's bin dir, exclude + # all other platform dirs and dev artifacts. [SWR-VSIX-LAYOUT] + cat > "${EXT_DIR}/.vscodeignore" </dev/null + echo "archive=dist/${stage}.zip" >> "$GITHUB_OUTPUT" + else + tar -czf "${GITHUB_WORKSPACE}/dist/${stage}.tar.gz" -C target/release "${{ inputs.binary_name }}" + echo "archive=dist/${stage}.tar.gz" >> "$GITHUB_OUTPUT" + fi + - name: Generate CycloneDX SBOM + uses: anchore/sbom-action@f08f31f53bbe4451c46f9c5d8a8ff4cbb2c5ed27 # v0.21.0 + with: + file: ${{ steps.pkg.outputs.archive }} + format: cyclonedx-json + output-file: ${{ steps.pkg.outputs.archive }}.cdx.json + - name: Attest build provenance + uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc20 # v3.0.0 + with: + subject-path: ${{ github.workspace }}/${{ steps.pkg.outputs.archive }} + - name: Attest SBOM + uses: actions/attest-sbom@bd218ad7dbf483c81a22dde9694703a90f723a0d # v3.0.0 + with: + subject-path: ${{ github.workspace }}/${{ steps.pkg.outputs.archive }} + sbom-path: ${{ steps.pkg.outputs.archive }}.cdx.json + - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: ${{ inputs.binary_name }}-${{ matrix.platform }} + path: dist/* + if-no-files-found: error + + publish: + if: ${{ inputs.upload_release }} + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + steps: + - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + with: + path: dist + merge-multiple: true + - name: Generate SHA256SUMS + shell: bash + working-directory: dist + run: | + set -euo pipefail + shopt -s nullglob + mapfile -t files < <(find . -maxdepth 1 -type f ! -name 'SHA256SUMS' -printf '%P\n') + test "${#files[@]}" -gt 0 + shasum -a 256 "${files[@]}" > SHA256SUMS + - uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 # v3.9.2 + - name: Sign SHA256SUMS (cosign keyless) + shell: bash + run: cosign sign-blob --yes --bundle dist/SHA256SUMS.sigstore.json dist/SHA256SUMS + - uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2 + with: + tag_name: v${{ inputs.version }} + files: dist/* diff --git a/.github/workflows/release-dotnet-aot-multiplatform.yml b/.github/workflows/release-dotnet-aot-multiplatform.yml new file mode 100644 index 00000000..2c1861e8 --- /dev/null +++ b/.github/workflows/release-dotnet-aot-multiplatform.yml @@ -0,0 +1,163 @@ +# Sibling of release-binary-multiplatform.yml for the .NET Native AOT migration +# CLI. Builds the DataProviderMigrate binary per platform with PublishAot, runs a +# native smoke test, archives with SBOM + provenance, and uploads to the +# v{version} release alongside the Rust binary. Implements [MIG-AOT-CI]. +# [SWR-REL-GITHUB] [SWR-SEC-CHECKSUM] [SWR-SEC-PROVENANCE] [SWR-SEC-SBOM] +name: release-dotnet-aot-multiplatform + +on: + workflow_call: + inputs: + binary_name: + required: false + type: string + default: DataProviderMigrate + project: + required: false + type: string + default: Migration/DataProviderMigrate/DataProviderMigrate.csproj + version: + required: true + type: string + upload_release: + required: false + type: boolean + default: true + +permissions: + contents: read + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - platform: darwin-arm64 + os: macos-15 + rid: osx-arm64 + - platform: darwin-x64 + os: macos-13 + rid: osx-x64 + - platform: linux-x64 + os: ubuntu-latest + rid: linux-x64 + - platform: win32-x64 + os: windows-latest + rid: win-x64 + runs-on: ${{ matrix.os }} + permissions: + contents: read + id-token: write + attestations: write + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 + with: + dotnet-version: "10.0.x" + # Native AOT needs the platform C toolchain; ubuntu/macos runners ship it, + # Windows runners ship MSVC. Linux additionally needs clang + zlib headers. + - name: Install Linux AOT toolchain + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y clang zlib1g-dev + - name: Publish Native AOT (${{ matrix.rid }}) + shell: bash + run: | + set -euo pipefail + dotnet publish "${{ inputs.project }}" \ + -c Release -r "${{ matrix.rid }}" \ + -p:PublishAot=true -p:InvariantGlobalization=true \ + --self-contained \ + -o "${GITHUB_WORKSPACE}/aot-out" + - name: Locate binary + id: bin + shell: bash + run: | + set -euo pipefail + exe="" + if [ "${{ runner.os }}" = "Windows" ]; then exe=".exe"; fi + path="${GITHUB_WORKSPACE}/aot-out/${{ inputs.binary_name }}${exe}" + test -f "$path" + echo "path=$path" >> "$GITHUB_OUTPUT" + echo "exe=$exe" >> "$GITHUB_OUTPUT" + - name: Native smoke test (migrate example schema → SQLite) + shell: bash + env: + DATAPROVIDERMIGRATE_AOT_BIN: ${{ steps.bin.outputs.path }} + run: | + set -euo pipefail + db="${RUNNER_TEMP}/aot_smoke.db" + "${DATAPROVIDERMIGRATE_AOT_BIN}" migrate \ + --schema Migration/DataProviderMigrate/example-schema.yaml \ + --output "$db" --provider sqlite + test -f "$db" + - name: Package archive + id: pkg + shell: bash + run: | + set -euo pipefail + stage="${{ inputs.binary_name }}-${{ inputs.version }}-${{ matrix.platform }}" + mkdir -p "${GITHUB_WORKSPACE}/dist" + if [ "${{ runner.os }}" = "Windows" ]; then + 7z a "${GITHUB_WORKSPACE}/dist/${stage}.zip" "${{ steps.bin.outputs.path }}" >/dev/null + echo "archive=dist/${stage}.zip" >> "$GITHUB_OUTPUT" + else + tar -czf "${GITHUB_WORKSPACE}/dist/${stage}.tar.gz" \ + -C "$(dirname "${{ steps.bin.outputs.path }}")" \ + "$(basename "${{ steps.bin.outputs.path }}")" + echo "archive=dist/${stage}.tar.gz" >> "$GITHUB_OUTPUT" + fi + - name: Generate CycloneDX SBOM + uses: anchore/sbom-action@f08f31f53bbe4451c46f9c5d8a8ff4cbb2c5ed27 # v0.21.0 + with: + file: ${{ steps.pkg.outputs.archive }} + format: cyclonedx-json + output-file: ${{ steps.pkg.outputs.archive }}.cdx.json + - name: Attest build provenance + uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc20 # v3.0.0 + with: + subject-path: ${{ github.workspace }}/${{ steps.pkg.outputs.archive }} + - name: Attest SBOM + uses: actions/attest-sbom@bd218ad7dbf483c81a22dde9694703a90f723a0d # v3.0.0 + with: + subject-path: ${{ github.workspace }}/${{ steps.pkg.outputs.archive }} + sbom-path: ${{ steps.pkg.outputs.archive }}.cdx.json + - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: ${{ inputs.binary_name }}-${{ matrix.platform }} + path: dist/* + if-no-files-found: error + + publish: + if: ${{ inputs.upload_release }} + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + steps: + - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + with: + path: dist + merge-multiple: true + # Own signed checksum file (separate from the Rust binary's SHA256SUMS) so + # this workflow is self-contained. [SWR-SEC-CHECKSUM] + - name: Generate SHA256SUMS + shell: bash + working-directory: dist + run: | + set -euo pipefail + shopt -s nullglob + mapfile -t files < <(find . -maxdepth 1 -type f ! -name '*SHA256SUMS*' -printf '%P\n') + test "${#files[@]}" -gt 0 + shasum -a 256 "${files[@]}" > "${{ inputs.binary_name }}-SHA256SUMS" + - uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 # v3.9.2 + - name: Sign SHA256SUMS (cosign keyless) + shell: bash + run: cosign sign-blob --yes --bundle "dist/${{ inputs.binary_name }}-SHA256SUMS.sigstore.json" "dist/${{ inputs.binary_name }}-SHA256SUMS" + - uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2 + with: + tag_name: v${{ inputs.version }} + files: dist/* diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cd082e25..06873458 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,121 +1,294 @@ -name: Release NuGet Packages +# agent-pmo:795a9c2 +# Tag-triggered release. Stamps the tag version into the runner working tree only +# (NO commit/push/tag bump after the tag exists — [SWR-VERSION-BUILD-STAMPING]), +# runs the CI + CodeQL hard gate, then publishes NuGet packages, per-platform binaries +# (GitHub Release), per-platform VSIX (Marketplace), Homebrew + Scoop, and deploys +# both documentation sites. [SWR-REL-WORKFLOW] [SWR-REL-GITHUB] +name: Release on: push: tags: - - 'v*' + - "v[0-9]+.[0-9]+.[0-9]+" + - "v[0-9]+.[0-9]+.[0-9]+-*" + +# Least privilege by default; write/id-token are granted per job. [SWR-SEC-TOKEN-PRIVILEGE] +permissions: + contents: read env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true DOTNET_CLI_TELEMETRY_OPTOUT: true + OWNER_REPO: Nimblesite/DataProvider jobs: - publish: + # ── Stamp + gate ────────────────────────────────────────────────────────── + prepare: runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 25 + environment: release permissions: - contents: write + contents: read outputs: - version: ${{ steps.version.outputs.VERSION }} + version: ${{ steps.v.outputs.version }} + prerelease: ${{ steps.v.outputs.prerelease }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: '9.0.x' - - - name: Extract version from tag - id: version - run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT - - - name: Pack solution - env: - VERSION: ${{ steps.version.outputs.VERSION }} + persist-credentials: false + - name: Derive version from tag + id: v + shell: bash + run: | + set -euo pipefail + version="${GITHUB_REF_NAME#v}" + echo "version=${version}" >> "$GITHUB_OUTPUT" + if [[ "$version" == *-* ]]; then + echo "prerelease=true" >> "$GITHUB_OUTPUT" + else + echo "prerelease=false" >> "$GITHUB_OUTPUT" + fi + - name: Stamp version into runner working tree (never committed) + shell: bash + run: python3 tools/shipwright-version-stamp.py --tag "${GITHUB_REF_NAME}" --root . + - name: Validate Shipwright manifests + shell: bash run: | set -euo pipefail - dotnet pack DataProvider.sln -c Release \ - -p:Version="$VERSION" \ - -p:PackageVersion="$VERSION" \ - -o ./nupkgs + python3 tools/validate-manifest.py shipwright.json + python3 tools/validate-manifest.py Lql/LqlExtension/shipwright.json + + # ── CodeQL hard release gate (blocks every publish job) ─────────────────── + codeql-gate: + needs: prepare + uses: ./.github/workflows/codeql.yml + with: + gate: true + permissions: + security-events: write + actions: read + contents: read + # ── NuGet packages ──────────────────────────────────────────────────────── + publish-nuget: + needs: [prepare, codeql-gate] + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: release + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v5.0.0 + with: + dotnet-version: "9.0.x" + - name: Stamp version from tag + shell: bash + run: python3 tools/shipwright-version-stamp.py --tag "v${{ needs.prepare.outputs.version }}" --root . + - name: Pack solution + shell: bash + run: dotnet pack DataProvider.sln -c Release -o ./nupkgs - name: Push packages to NuGet + shell: bash env: NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} run: | set -euo pipefail if [ -z "${NUGET_API_KEY:-}" ]; then - echo "NUGET_API_KEY secret is not set." >&2 + echo "::error::NUGET_API_KEY secret is not set." >&2 exit 1 fi shopt -s nullglob packages=(./nupkgs/*.nupkg) if [ ${#packages[@]} -eq 0 ]; then - echo "No packages produced — failing." >&2 + echo "::error::No packages produced — failing." >&2 exit 1 fi for pkg in "${packages[@]}"; do - echo "::group::Push $pkg" dotnet nuget push "$pkg" \ --api-key "$NUGET_API_KEY" \ --source https://api.nuget.org/v3/index.json \ --skip-duplicate - echo "::endgroup::" done - - name: Update version in Directory.Build.props + # ── Per-platform binaries → GitHub Release ──────────────────────────────── + release-binaries: + needs: [prepare, codeql-gate] + uses: ./.github/workflows/release-binary-multiplatform.yml + with: + binary_name: lql-lsp + version: ${{ needs.prepare.outputs.version }} + permissions: + contents: write + id-token: write + attestations: write + + # ── Per-platform .NET Native AOT migration CLI → GitHub Release [MIG-AOT-CI] ─ + release-dotnet-binaries: + needs: [prepare, codeql-gate] + uses: ./.github/workflows/release-dotnet-aot-multiplatform.yml + with: + binary_name: DataProviderMigrate + version: ${{ needs.prepare.outputs.version }} + permissions: + contents: write + id-token: write + attestations: write + + # ── Extract per-asset checksums from the signed SHA256SUMS ───────────────── + checksums: + needs: [prepare, release-binaries] + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + outputs: + darwin_arm64: ${{ steps.x.outputs.darwin_arm64 }} + win32_x64: ${{ steps.x.outputs.win32_x64 }} + steps: + - name: Fetch and parse SHA256SUMS from the release + id: x + shell: bash env: - VERSION: ${{ steps.version.outputs.VERSION }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ needs.prepare.outputs.version }} run: | set -euo pipefail - # Tag-triggered runs check out a detached HEAD. Find the unique remote - # branch that contains the tagged commit so we know where to push the - # version-bump back to. If 0 or >1 branches contain it, fail loudly — - # we will not guess. - git fetch --no-tags --prune origin '+refs/heads/*:refs/remotes/origin/*' - mapfile -t BRANCHES < <(git branch -r --contains HEAD --format='%(refname:short)' \ - | sed 's|^origin/||' \ - | grep -vx 'HEAD' \ - | sort -u) - if [ ${#BRANCHES[@]} -eq 0 ]; then - echo "No remote branch contains the tagged commit. Refusing to push." >&2 - exit 1 - fi - if [ ${#BRANCHES[@]} -gt 1 ]; then - echo "Tagged commit belongs to multiple branches: ${BRANCHES[*]}. Refusing to guess." >&2 - exit 1 - fi - TARGET_BRANCH="${BRANCHES[0]}" - echo "Pushing version bump to origin/$TARGET_BRANCH" - sed -i 's|.*|'"$VERSION"'|' Directory.Build.props - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add Directory.Build.props - git diff --cached --quiet || git commit -m "chore: bump version to $VERSION [skip ci]" - git push origin "HEAD:refs/heads/$TARGET_BRANCH" - - - name: Create GitHub Release + gh release download "v${VERSION}" --repo "${OWNER_REPO}" --pattern SHA256SUMS --dir . + darwin="$(grep -E "lql-lsp-${VERSION}-darwin-arm64\.tar\.gz$" SHA256SUMS | awk '{print $1}')" + win="$(grep -E "lql-lsp-${VERSION}-win32-x64\.zip$" SHA256SUMS | awk '{print $1}')" + test -n "$darwin" && test -n "$win" + echo "darwin_arm64=${darwin}" >> "$GITHUB_OUTPUT" + echo "win32_x64=${win}" >> "$GITHUB_OUTPUT" + + # ── Homebrew tap ────────────────────────────────────────────────────────── + publish-brew: + needs: [prepare, checksums] + if: needs.prepare.outputs.prerelease == 'false' + uses: ./.github/workflows/publish-brew-tap.yml + with: + tap_repo: Nimblesite/homebrew-lql + formula_name: lql-lsp + version: ${{ needs.prepare.outputs.version }} + asset_url: https://github.com/Nimblesite/DataProvider/releases/download/v${{ needs.prepare.outputs.version }}/lql-lsp-${{ needs.prepare.outputs.version }}-darwin-arm64.tar.gz + sha256: ${{ needs.checksums.outputs.darwin_arm64 }} + binary_name: lql-lsp + homepage: https://github.com/Nimblesite/DataProvider + description: Lambda Query Language LSP server + secrets: + tap_token: ${{ secrets.HOMEBREW_TAP_TOKEN }} + + # ── Scoop bucket ────────────────────────────────────────────────────────── + publish-scoop: + needs: [prepare, checksums] + if: needs.prepare.outputs.prerelease == 'false' + uses: ./.github/workflows/publish-scoop-bucket.yml + with: + bucket_repo: Nimblesite/scoop-lql + manifest_name: lql-lsp + version: ${{ needs.prepare.outputs.version }} + url: https://github.com/Nimblesite/DataProvider/releases/download/v${{ needs.prepare.outputs.version }}/lql-lsp-${{ needs.prepare.outputs.version }}-win32-x64.zip + sha256: ${{ needs.checksums.outputs.win32_x64 }} + bin: lql-lsp.exe + homepage: https://github.com/Nimblesite/DataProvider + description: Lambda Query Language LSP server + secrets: + bucket_token: ${{ secrets.SCOOP_BUCKET_TOKEN }} + + # ── Migration CLI checksums (from its own signed SHA256SUMS) [MIG-AOT-CI] ── + dotnet-checksums: + needs: [prepare, release-dotnet-binaries] + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + outputs: + darwin_arm64: ${{ steps.x.outputs.darwin_arm64 }} + win32_x64: ${{ steps.x.outputs.win32_x64 }} + steps: + - name: Fetch and parse DataProviderMigrate-SHA256SUMS from the release + id: x + shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh release create "${{ github.ref_name }}" --generate-notes + VERSION: ${{ needs.prepare.outputs.version }} + run: | + set -euo pipefail + gh release download "v${VERSION}" --repo "${OWNER_REPO}" --pattern DataProviderMigrate-SHA256SUMS --dir . + darwin="$(grep -E "DataProviderMigrate-${VERSION}-darwin-arm64\.tar\.gz$" DataProviderMigrate-SHA256SUMS | awk '{print $1}')" + win="$(grep -E "DataProviderMigrate-${VERSION}-win32-x64\.zip$" DataProviderMigrate-SHA256SUMS | awk '{print $1}')" + test -n "$darwin" && test -n "$win" + echo "darwin_arm64=${darwin}" >> "$GITHUB_OUTPUT" + echo "win32_x64=${win}" >> "$GITHUB_OUTPUT" + + # ── Homebrew tap (migration CLI) ────────────────────────────────────────── + publish-brew-migrate: + needs: [prepare, dotnet-checksums] + if: needs.prepare.outputs.prerelease == 'false' + uses: ./.github/workflows/publish-brew-tap.yml + with: + tap_repo: Nimblesite/homebrew-lql + formula_name: dataprovider-migrate + version: ${{ needs.prepare.outputs.version }} + asset_url: https://github.com/Nimblesite/DataProvider/releases/download/v${{ needs.prepare.outputs.version }}/DataProviderMigrate-${{ needs.prepare.outputs.version }}-darwin-arm64.tar.gz + sha256: ${{ needs.dotnet-checksums.outputs.darwin_arm64 }} + binary_name: DataProviderMigrate + homepage: https://github.com/Nimblesite/DataProvider + description: DataProvider database schema migration CLI (YAML → SQLite/PostgreSQL) + secrets: + tap_token: ${{ secrets.HOMEBREW_TAP_TOKEN }} + + # ── Scoop bucket (migration CLI) ────────────────────────────────────────── + publish-scoop-migrate: + needs: [prepare, dotnet-checksums] + if: needs.prepare.outputs.prerelease == 'false' + uses: ./.github/workflows/publish-scoop-bucket.yml + with: + bucket_repo: Nimblesite/scoop-lql + manifest_name: dataprovider-migrate + version: ${{ needs.prepare.outputs.version }} + url: https://github.com/Nimblesite/DataProvider/releases/download/v${{ needs.prepare.outputs.version }}/DataProviderMigrate-${{ needs.prepare.outputs.version }}-win32-x64.zip + sha256: ${{ needs.dotnet-checksums.outputs.win32_x64 }} + bin: DataProviderMigrate.exe + homepage: https://github.com/Nimblesite/DataProvider + description: DataProvider database schema migration CLI (YAML → SQLite/PostgreSQL) + secrets: + bucket_token: ${{ secrets.SCOOP_BUCKET_TOKEN }} + + # ── Per-platform VSIX → VS Code Marketplace (Entra OIDC, no PAT) ─────────── + publish-vsix: + needs: [prepare, codeql-gate, release-binaries] + uses: ./.github/workflows/publish-vsix-per-platform.yml + with: + version: ${{ needs.prepare.outputs.version }} + pre_release: ${{ needs.prepare.outputs.prerelease == 'true' }} + marketplace_environment: release + secrets: + azure_client_id: ${{ secrets.AZURE_CLIENT_ID }} + azure_tenant_id: ${{ secrets.AZURE_TENANT_ID }} + permissions: + contents: read + id-token: write + attestations: write + # ── Documentation sites (both deploy on release) ────────────────────────── deploy-lql-website: - needs: publish + needs: [prepare, publish-nuget] uses: ./.github/workflows/deploy-lql-website.yml with: - version: ${{ needs.publish.outputs.version }} + version: ${{ needs.prepare.outputs.version }} permissions: contents: read pages: write id-token: write deploy-website: - needs: [publish, deploy-lql-website] + needs: [prepare, publish-nuget, deploy-lql-website] uses: ./.github/workflows/deploy-pages.yml with: - version: ${{ needs.publish.outputs.version }} + version: ${{ needs.prepare.outputs.version }} secrets: inherit permissions: contents: read diff --git a/.gitignore b/.gitignore index 3f440c93..6611b67c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,12 @@ +# agent-pmo:795a9c2 # ═══════════════════════════════════════════════════════════════════════════════ # Universal (portfolio-wide) # ═══════════════════════════════════════════════════════════════════════════════ +# AI agent + IDE config dirs are COMMITTED, never ignored ([GITIGNORE-RULES]): +# .vscode/ .idea/ .claude/ .codex/ .agents/ .cline/ .clinerules/ .opencode/ +# .cursorrules .windsurfrules .github/copilot-instructions.md AGENTS.md CLAUDE.md +# Force-commit Claude's local settings (auto-ignored by Claude Code otherwise): +!.claude/settings.local.json .DS_Store? ._* .Spotlight-V100 @@ -150,7 +156,8 @@ _Pvt_Extensions # ═══════════════════════════════════════════════════════════════════════════════ # JetBrains Rider # ═══════════════════════════════════════════════════════════════════════════════ -.idea/ +# NOTE: .idea/ is intentionally NOT ignored — shared run configs + code-style +# settings are committed per [GITIGNORE-RULES]. *.sln.iml # ═══════════════════════════════════════════════════════════════════════════════ @@ -498,3 +505,6 @@ Reporting/Nimblesite.Reporting.React/wwwroot/js/h5.meta.js Reporting/Nimblesite.Reporting.React/wwwroot/js/h5.min.js Reporting/Nimblesite.Reporting.React/wwwroot/js/h5.meta.min.js Reporting/Nimblesite.Reporting.React/wwwroot/js/index.html + + +.deslop-cache/ \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 5ededd52..b63716e8 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,8 +1,9 @@ { - "_agent_pmo": "74cf183", + "_agent_pmo": "795a9c2", "recommendations": [ "nimblesite.commandtree", "nimblesite.too-many-cooks", - "nimblesite.typeDiagram" + "nimblesite.typeDiagram", + "nimblesite.napper" ] } diff --git a/AGENTS.md b/AGENTS.md index a8dd7ab9..ddbebb24 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ - + # Agent Instructions @CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md index 73dad95e..b1f11532 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,13 +7,13 @@ ⚠️ NEVER KILL ANY VSCODE PROCESS ⚠️ - + ## Project Overview DataProvider is a comprehensive .NET database access toolkit: source generation for SQL extension methods, the Lambda Query Language (LQL) transpiler, bidirectional offline-first sync, WebAuthn + RBAC auth, and an embeddable reporting platform. The LQL LSP is implemented in Rust with a VS Code extension in TypeScript. Healthcare sample applications live in a separate repo: [Nimblesite/ClinicalCoding](https://github.com/Nimblesite/ClinicalCoding). -**Primary language(s):** C# (.NET 10.0), Rust, TypeScript, F# +**Primary language(s):** C#, Rust, TypeScript, F# **Build command:** `make ci` **Test command:** `make test` **Lint command:** `make lint` @@ -30,11 +30,41 @@ If the TMC server is available: 5. Release locks immediately when done 6. Never edit a locked file — wait or find another approach +## Autonomy + +- **Act autonomously. Do NOT stop to ask the user questions.** When something is ambiguous, choose the most reasonable default, record the assumption, and continue to completion. +- **No mid-task pauses for confirmation, clarification, or approval.** Deliver finished work plus a short summary of any assumptions made. +- **Auto-memory is OFF.** All persistent rules go through a reviewed PR to this file — never auto-captured memory. (Claude Code: `"autoMemoryEnabled": false` in committed `.claude/settings.local.json`.) + +## Git discipline — only when you've been given the green light to use git + +- **Default to NOT touching git at all.** Use git only when the user has explicitly green-lit it for the task. Absent that, leave commits, branches, pushes, and merges to the user and CI. +- **NEVER push to the default branch (`main`) directly.** Always PR → CI green → merge. No exceptions. +- **Once you open a PR, OWN it until it's green.** Enable auto-merge where allowed (`gh pr merge --auto --squash`) so it lands when checks pass — then keep monitoring: on a failure, pull the logs, fix it, push, and loop until every required check passes. Never hand back a red or still-running PR. +- **NEVER list yourself (the agent) as a commit co-author.** No `Co-Authored-By` trailer, no agent attribution. This is never overridable. +- **Work on exactly ONE branch at a time, always** — even with multiple agents working concurrently (coordinate via TMC). Reuse the open feature branch. +- **NEVER start a new branch when a feature branch already exists.** Check first; work on the open one. If multiple feature branches exist, merge them into one IMMEDIATELY before doing any other work. +- **Worktrees are forbidden** unless the user explicitly directs you to use one. + +## Duplication — Deslop (MANDATORY) + +Spec: `[CI-DESLOP]`. Read the [docs](https://deslop.live/docs/for-ai/). False positives → log an issue with [Deslop](https://github.com/Nimblesite/Deslop/issues). Deslop earns its keep through **prevention, not cleanup.** Use its MCP tools on every code change: + +- **BEFORE you author** any function, method, class, helper, fixture, or test setup → call the **`find-similar`** MCP tool. + - `signals.fused ≥ 0.85`, or an `identical` / `nearly_identical` bucket → **REUSE the existing code. Do NOT write a duplicate.** + - `0.6 ≤ fused < 0.85` → open the canonical occurrence and bias hard toward reusing/extending it. + - `fused < 0.6` or empty → proceed and write the new code. +- **AFTER you change code** → call **`rescan`**, then **`top-offenders`** (worst clusters by severity) and **`cluster-by-id`** (full members + signals for a cluster you intend to merge). Use **`report-for-file`** / **`report-for-range`** to inspect a specific file or selection. Call **`schema-doc`** once per session to learn the report shape. +- **NEVER game the gate.** Do not silence findings by widening `max_duplication_percent`, marking code `hidden`, or splitting it into trivially different shapes. + +The duplication budget lives in committed `.deslop.toml` (`max_duplication_percent`). CI runs `deslop .` and the build **TANKS** (exit 3) if duplication exceeds it. The threshold ratchets **DOWN only** — lower it in the same PR when you reduce duplication; never raise it without written justification. + ## Hard Rules — Universal (no exceptions) - **Parsing SQL with anything other than the ⭐️ OFFICIAL 👨🏼‍⚖️ ⭐️ platform specific parser = ⛔️ILLEGAL** - Use the actual .NET parser specified by the DB maintainer - **DO NOT use git or Docker commands.** No `git add`, `git commit`, `git push`, or any git/Docker command. CI and GitHub Actions handle these. -- **ZERO DUPLICATION.** Before writing any code, search the codebase for existing implementations. Move code, don't copy it. +- **ZERO DUPLICATION.** Before writing any code, search the codebase for existing implementations. Move code, don't copy it. Run the Deslop MCP tools before AND after every change — see [Duplication — Deslop](#duplication--deslop-mandatory). +- **DATA MODELS — generate with typeDiagram, NEVER by hand.** Define every data model (domain types, DTOs, entities, enums, ADTs) in [typeDiagram](https://typediagram.dev/docs/) and generate the language code from it. The committed model is the source of truth; `make build` regenerates the types — never edit generated files, never hand-craft a model. If typeDiagram can't express a case, file an issue on its repo and reference it at a minimal temporary shim. Implements `[MODEL-TYPEDIAGRAM]`. - **NEVER THROW** — Return `Result`. Wrap failures in try/catch - **No casting/!** — Pattern match on type only - **No suppressing warnings** — Illegal. Fix the code, not the linter. @@ -131,9 +161,6 @@ Always include these in `Directory.Build.props`: - LQL is database platform INDEPENDENT. It MUST work exactly the same on whatever platform it is transpiled to. Failure for this to happen must be logged as a GitHub issue -## LQL -- LQL is database platform INDEPENDENT. It MUST work exactly the same on whatever platform it is transpiled to. Failure for this to happen must be logged as a GitHub issue - ## CSS - **MINIMAL CSS** — Do not duplicate CSS classes @@ -228,7 +255,7 @@ DataProvider/ ## Config -- .NET 10.0, C# latest, nullable, warnings as errors +- .NET, C# latest, nullable, warnings as errors - Central config in `Directory.Build.props` - Format: `dotnet csharpier .` diff --git a/Directory.Build.props b/Directory.Build.props index a9f5ce20..1c4fbc12 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -4,7 +4,9 @@ false disabled false - 0.9.12-beta + + 0.0.0-dev ChristianFindlay Nimblesite MIT diff --git a/Lql/LqlExtension/eslint.config.mjs b/Lql/LqlExtension/eslint.config.mjs index e802d768..916594b8 100644 --- a/Lql/LqlExtension/eslint.config.mjs +++ b/Lql/LqlExtension/eslint.config.mjs @@ -1,4 +1,4 @@ -// agent-pmo:d75d5c8 +// agent-pmo:795a9c2 import tseslint from "typescript-eslint"; export default tseslint.config( diff --git a/Lql/LqlExtension/package.json b/Lql/LqlExtension/package.json index 3ff5cbfe..181a4987 100644 --- a/Lql/LqlExtension/package.json +++ b/Lql/LqlExtension/package.json @@ -2,7 +2,7 @@ "name": "lql-language-support", "displayName": "Lambda Query Language (LQL)", "description": "Language support for Lambda Query Language with syntax highlighting, IntelliSense, and error checking", - "version": "0.1.0", + "version": "0.0.0-dev", "publisher": "lql-team", "engines": { "vscode": "^1.74.0" diff --git a/Lql/LqlExtension/shipwright.json b/Lql/LqlExtension/shipwright.json new file mode 100644 index 00000000..d82b7f03 --- /dev/null +++ b/Lql/LqlExtension/shipwright.json @@ -0,0 +1,20 @@ +{ + "manifestVersion": 1, + "product": { + "id": "lql-language-support", + "displayName": "Lambda Query Language (LQL)", + "version": "0.0.0-dev" + }, + "components": [ + { + "id": "lql-lsp", + "kind": "lsp", + "language": "rust", + "binaryName": "lql-lsp", + "expectedVersion": "0.0.0-dev", + "platforms": ["darwin-arm64", "darwin-x64", "linux-x64", "win32-x64"], + "sources": ["user-setting", "bundled"], + "required": true + } + ] +} diff --git a/Lql/LqlWebsite-Eleventy/_site/index.html b/Lql/LqlWebsite-Eleventy/_site/index.html index 6d67b022..f89530b5 100644 --- a/Lql/LqlWebsite-Eleventy/_site/index.html +++ b/Lql/LqlWebsite-Eleventy/_site/index.html @@ -69,6 +69,12 @@ +
  • + + Releases + +
  • +
  • GitHub ↗ @@ -451,6 +457,10 @@

    Run The Transpiler

    Playground
    + + Releases + + DataProvider ↗ diff --git a/Lql/LqlWebsite-Eleventy/_site/playground/index.html b/Lql/LqlWebsite-Eleventy/_site/playground/index.html deleted file mode 100644 index 90b51b4b..00000000 --- a/Lql/LqlWebsite-Eleventy/_site/playground/index.html +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - Codestin Search App - - - - - - - -
    Loading LQL transpiler...
    - -
    - The LQL transpiler failed to load. - Reload - 🗙 -
    - - - - - diff --git a/Lql/LqlWebsite-Eleventy/eleventy.config.js b/Lql/LqlWebsite-Eleventy/eleventy.config.js index 4ef89eb1..76933214 100644 --- a/Lql/LqlWebsite-Eleventy/eleventy.config.js +++ b/Lql/LqlWebsite-Eleventy/eleventy.config.js @@ -20,6 +20,11 @@ export default function(eleventyConfig) { const md = markdownIt(mdOptions).use(markdownItAnchor, mdAnchorOptions); eleventyConfig.setLibrary("md", md); + // Release notes carry untrusted PR titles/handles — render with raw HTML OFF. + // Implements [WEB-RELEASES]. + const releaseMd = markdownIt({ html: false, linkify: true, breaks: false }); + eleventyConfig.addFilter("releaseNotes", (body) => (body ? releaseMd.render(body) : "")); + eleventyConfig.addPlugin(syntaxHighlight); eleventyConfig.addPlugin(pluginRss); eleventyConfig.addPlugin(eleventyNavigationPlugin); diff --git a/Lql/LqlWebsite-Eleventy/package-lock.json b/Lql/LqlWebsite-Eleventy/package-lock.json index 49890f32..ee95a252 100644 --- a/Lql/LqlWebsite-Eleventy/package-lock.json +++ b/Lql/LqlWebsite-Eleventy/package-lock.json @@ -8,10 +8,11 @@ "name": "lql-website", "version": "1.0.0", "devDependencies": { - "@11ty/eleventy": "^3.1.2", - "@11ty/eleventy-navigation": "^0.3.5", - "@11ty/eleventy-plugin-rss": "^2.0.2", - "@11ty/eleventy-plugin-syntaxhighlight": "^5.0.0", + "@11ty/eleventy": "^3.1.6", + "@11ty/eleventy-navigation": "^1.0.5", + "@11ty/eleventy-plugin-rss": "^3.0.0", + "@11ty/eleventy-plugin-syntaxhighlight": "^5.0.2", + "markdown-it": "^14.2.0", "markdown-it-anchor": "^9.2.0" } }, @@ -39,45 +40,45 @@ } }, "node_modules/@11ty/eleventy": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@11ty/eleventy/-/eleventy-3.1.2.tgz", - "integrity": "sha512-IcsDlbXnBf8cHzbM1YBv3JcTyLB35EK88QexmVyFdVJVgUU6bh9g687rpxryJirHzo06PuwnYaEEdVZQfIgRGg==", + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/@11ty/eleventy/-/eleventy-3.1.6.tgz", + "integrity": "sha512-ZlSiR1PLdS2lv7TelBgWAhcvMiLNZkPBlLEb+lh7kGYZ+Mk0bo9qcYgVsewvw9W7Em0RH3wd01h5fAstNDh0zA==", "dev": true, "license": "MIT", "dependencies": { - "@11ty/dependency-tree": "^4.0.0", - "@11ty/dependency-tree-esm": "^2.0.0", + "@11ty/dependency-tree": "^4.0.2", + "@11ty/dependency-tree-esm": "^2.0.4", "@11ty/eleventy-dev-server": "^2.0.8", - "@11ty/eleventy-plugin-bundle": "^3.0.6", + "@11ty/eleventy-plugin-bundle": "^3.0.7", "@11ty/eleventy-utils": "^2.0.7", "@11ty/lodash-custom": "^4.17.21", - "@11ty/posthtml-urls": "^1.0.1", - "@11ty/recursive-copy": "^4.0.2", + "@11ty/posthtml-urls": "^1.0.3", + "@11ty/recursive-copy": "^4.0.4", "@sindresorhus/slugify": "^2.2.1", "bcp-47-normalize": "^2.3.0", "chokidar": "^3.6.0", - "debug": "^4.4.1", + "debug": "^4.4.3", "dependency-graph": "^1.0.0", "entities": "^6.0.1", "filesize": "^10.1.6", "gray-matter": "^4.0.3", "iso-639-1": "^3.1.5", - "js-yaml": "^4.1.0", + "js-yaml": "^4.1.1", "kleur": "^4.1.5", - "liquidjs": "^10.21.1", - "luxon": "^3.6.1", - "markdown-it": "^14.1.0", + "liquidjs": "^10.27.0", + "luxon": "^3.7.2", + "markdown-it": "^14.2.0", "minimist": "^1.2.8", - "moo": "^0.5.2", + "moo": "0.5.2", "node-retrieve-globals": "^6.0.1", "nunjucks": "^3.2.4", - "picomatch": "^4.0.2", + "picomatch": "^4.0.4", "please-upgrade-node": "^3.2.0", - "posthtml": "^0.16.6", + "posthtml": "^0.16.7", "posthtml-match-helper": "^2.0.3", - "semver": "^7.7.2", - "slugify": "^1.6.6", - "tinyglobby": "^0.2.14" + "semver": "^7.8.1", + "slugify": "^1.6.9", + "tinyglobby": "^0.2.16" }, "bin": { "eleventy": "cmd.cjs" @@ -122,29 +123,19 @@ } }, "node_modules/@11ty/eleventy-navigation": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@11ty/eleventy-navigation/-/eleventy-navigation-0.3.5.tgz", - "integrity": "sha512-4aKW5aIQDFed8xs1G1pWcEiFPcDSwZtA4IH1eERtoJ+Xy+/fsoe0pzbDmw84bHZ9ACny5jblENhfZhcCxklqQw==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@11ty/eleventy-navigation/-/eleventy-navigation-1.0.5.tgz", + "integrity": "sha512-zb6xe29cM9viSdYtZywKIkJw2HIROyBINdBcFWC9uD0c/jYOTAex5nwy3HNEuh5t6/Ld/S9V4gEizfmeYuYpCQ==", "dev": true, "license": "MIT", "dependencies": { - "dependency-graph": "^0.11.0" + "dependency-graph": "^1.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/11ty" } }, - "node_modules/@11ty/eleventy-navigation/node_modules/dependency-graph": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-0.11.0.tgz", - "integrity": "sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6.0" - } - }, "node_modules/@11ty/eleventy-plugin-bundle": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/@11ty/eleventy-plugin-bundle/-/eleventy-plugin-bundle-3.0.7.tgz", @@ -165,16 +156,16 @@ } }, "node_modules/@11ty/eleventy-plugin-rss": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@11ty/eleventy-plugin-rss/-/eleventy-plugin-rss-2.0.4.tgz", - "integrity": "sha512-LF60sGVlxGTryQe3hTifuzrwF8R7XbrNsM2xfcDcNMSliLN4kmB+7zvoLRySRx0AQDjqhPTAeeeT0ra6/9zHUQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@11ty/eleventy-plugin-rss/-/eleventy-plugin-rss-3.0.0.tgz", + "integrity": "sha512-kKW4DcR57xAyRx0e8gNhKh56ahHVEaAj8/TuXQDnw+B46ig2bWADJAlyj/GdV37IG5ja9dZ4SgKZrs/CHz6YWQ==", "dev": true, "license": "MIT", "dependencies": { - "@11ty/eleventy-utils": "^2.0.0", - "@11ty/posthtml-urls": "^1.0.1", - "debug": "^4.4.0", - "posthtml": "^0.16.6" + "@11ty/eleventy-utils": "^2.0.7", + "@11ty/posthtml-urls": "^1.0.2", + "debug": "^4.4.3", + "posthtml": "^0.16.7" }, "funding": { "type": "opencollective", @@ -224,9 +215,9 @@ } }, "node_modules/@11ty/posthtml-urls": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@11ty/posthtml-urls/-/posthtml-urls-1.0.2.tgz", - "integrity": "sha512-0vaV3Wt0surZ+oS1VdKKe0axeeupuM+l7W/Z866WFQwF+dGg2Tc/nmhk/5l74/Y55P8KyImnLN9CdygNw2huHg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@11ty/posthtml-urls/-/posthtml-urls-1.0.3.tgz", + "integrity": "sha512-1YvhnkaNlFnnJic1rBMWmTC2adbuy+JQiBfl1Hecr1Wjjik1pQZmGyk/eC9zKX/FQv52s2Nht1Gi/UwhYqrBeg==", "dev": true, "license": "MIT", "dependencies": { @@ -240,15 +231,15 @@ } }, "node_modules/@11ty/recursive-copy": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@11ty/recursive-copy/-/recursive-copy-4.0.3.tgz", - "integrity": "sha512-SX48BTLEGX8T/OsKWORsHAAeiDsbFl79Oa/0Wg/mv/d27b7trCVZs7fMHvpSgDvZz/fZqx5rDk8+nx5oyT7xBw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@11ty/recursive-copy/-/recursive-copy-4.0.4.tgz", + "integrity": "sha512-oI7m8pa7/IAU/3lqRU9vjBbs20iKFo7x+1K9kT3aVira6scc1X9MjBdgLCHzLJeJ7iB6wydioA+kr9/qPnvmlQ==", "dev": true, "license": "ISC", "dependencies": { "errno": "^1.0.0", "junk": "^3.1.0", - "maximatch": "^0.1.0", + "minimatch": "^3.1.5", "slash": "^3.0.0" }, "engines": { @@ -383,49 +374,6 @@ "dev": true, "license": "Python-2.0" }, - "node_modules/array-differ": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/array-differ/-/array-differ-1.0.0.tgz", - "integrity": "sha512-LeZY+DZDRnvP7eMuQ6LHfCzUGxAAIViUBliK24P3hWXL6y4SortgR6Nim6xrkfSLlmH0+k+9NYNwVC2s53ZrYQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/array-union": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", - "integrity": "sha512-Dxr6QJj/RdU/hCaBjOfxW+q6lyuVE6JFWIrAUpuOOhoJJoQ99cUn3igRaHVB5P9WrgFVN0FfArM3x0cueOU8ng==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-uniq": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/array-uniq": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz", - "integrity": "sha512-MNha4BWQ6JbwhFhj03YK552f7cb3AzoE8SzeljgChvL1dl3IcvggXVz1DilzySZkCja+CXuZbdW7yATchWn8/Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/arrify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", - "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", @@ -496,9 +444,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, "license": "MIT", "dependencies": { @@ -1168,19 +1116,29 @@ } }, "node_modules/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz", + "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], "license": "MIT", "dependencies": { "uc.micro": "^2.0.0" } }, "node_modules/liquidjs": { - "version": "10.25.0", - "resolved": "https://registry.npmjs.org/liquidjs/-/liquidjs-10.25.0.tgz", - "integrity": "sha512-XpO7AiGULTG4xcTlwkcTI5JreFG7b6esLCLp+aUSh7YuQErJZEoUXre9u9rbdb0057pfWG4l0VursvLd5Q/eAw==", + "version": "10.27.0", + "resolved": "https://registry.npmjs.org/liquidjs/-/liquidjs-10.27.0.tgz", + "integrity": "sha512-tw/OA59K7aIBlMKIrKlumr37fiZUheShVHXY8cVctWisgY1p9mc5hreOvlreoS0wTiwlWk14Ya7305c2a/Cg5w==", "dev": true, "license": "MIT", "dependencies": { @@ -1216,15 +1174,25 @@ } }, "node_modules/markdown-it": { - "version": "14.1.1", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", - "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz", + "integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", - "linkify-it": "^5.0.0", + "linkify-it": "^5.0.1", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" @@ -1257,22 +1225,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/maximatch": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/maximatch/-/maximatch-0.1.0.tgz", - "integrity": "sha512-9ORVtDUFk4u/NFfo0vG/ND/z7UQCVZBL539YW0+U1I7H1BkZwizcPx5foFv7LCPcBnm2U6RjFnQOsIvN4/Vm2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-differ": "^1.0.0", - "array-union": "^1.0.1", - "arrify": "^1.0.0", - "minimatch": "^3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/mdurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", @@ -1354,9 +1306,9 @@ } }, "node_modules/moo": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.3.tgz", - "integrity": "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==", + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.2.tgz", + "integrity": "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==", "dev": true, "license": "BSD-3-Clause" }, @@ -1463,9 +1415,9 @@ } }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -1616,9 +1568,9 @@ } }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", "dev": true, "license": "ISC", "bin": { @@ -1680,9 +1632,9 @@ } }, "node_modules/slugify": { - "version": "1.6.6", - "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.6.tgz", - "integrity": "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==", + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz", + "integrity": "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==", "dev": true, "license": "MIT", "engines": { @@ -1730,14 +1682,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" diff --git a/Lql/LqlWebsite-Eleventy/package.json b/Lql/LqlWebsite-Eleventy/package.json index 2f7b55d0..99c3d9fe 100644 --- a/Lql/LqlWebsite-Eleventy/package.json +++ b/Lql/LqlWebsite-Eleventy/package.json @@ -8,10 +8,11 @@ "build": "rm -rf _site && eleventy" }, "devDependencies": { - "@11ty/eleventy": "^3.1.2", - "@11ty/eleventy-navigation": "^0.3.5", - "@11ty/eleventy-plugin-rss": "^2.0.2", - "@11ty/eleventy-plugin-syntaxhighlight": "^5.0.0", + "@11ty/eleventy": "^3.1.6", + "@11ty/eleventy-navigation": "^1.0.5", + "@11ty/eleventy-plugin-rss": "^3.0.0", + "@11ty/eleventy-plugin-syntaxhighlight": "^5.0.2", + "markdown-it": "^14.2.0", "markdown-it-anchor": "^9.2.0" } } diff --git a/Lql/LqlWebsite-Eleventy/src/_data/navigation.json b/Lql/LqlWebsite-Eleventy/src/_data/navigation.json index a860d3d1..a8ace9f7 100644 --- a/Lql/LqlWebsite-Eleventy/src/_data/navigation.json +++ b/Lql/LqlWebsite-Eleventy/src/_data/navigation.json @@ -5,6 +5,7 @@ { "text": "Migrations", "url": "/#migrations" }, { "text": "Tooling", "url": "/#tooling" }, { "text": "Playground", "url": "/playground/" }, + { "text": "Releases", "url": "/releases/" }, { "text": "GitHub", "url": "https://github.com/Nimblesite/DataProvider", "external": true } ], "footer": [ @@ -12,6 +13,7 @@ { "text": "Examples", "url": "/#examples" }, { "text": "Migrations", "url": "/#migrations" }, { "text": "Playground", "url": "/playground/" }, + { "text": "Releases", "url": "/releases/" }, { "text": "DataProvider", "url": "https://dataprovider.dev", "external": true }, { "text": "GitHub", "url": "https://github.com/Nimblesite/DataProvider", "external": true } ] diff --git a/Lql/LqlWebsite-Eleventy/src/_data/release.js b/Lql/LqlWebsite-Eleventy/src/_data/release.js new file mode 100644 index 00000000..d4eb4bc1 --- /dev/null +++ b/Lql/LqlWebsite-Eleventy/src/_data/release.js @@ -0,0 +1,102 @@ +// agent-pmo:795a9c2 +// Build-time GitHub Releases data file. Implements [WEB-RELEASES]. +// +// Eleventy runs this during the static build and bakes the result into the +// /releases/ page — the browser makes ZERO network calls. Notes are rendered +// by the `releaseNotes` filter in eleventy.config.js (markdown-it, html:false), +// because release bodies carry untrusted PR titles/handles. + +const REPO = "Nimblesite/DataProvider"; +const MAX_RELEASES = 30; +const RECENT_COUNT = 4; +const API_URL = `https://api.github.com/repos/${REPO}/releases?per_page=${MAX_RELEASES}`; +const RELEASES_URL = `https://github.com/${REPO}/releases`; + +function fallback(reason) { + if (reason) console.warn(`[_data/release] using fallback — ${reason}`); + return { + available: false, + tag: null, + version: null, + url: RELEASES_URL, + releasesUrl: RELEASES_URL, + publishedAt: null, + publishedDate: null, + items: [], + recent: [], + }; +} + +function versionFromTag(tag) { + if (!tag) return null; + return tag.startsWith("v") ? tag.slice(1) : tag; +} + +function formatDate(value) { + if (!value) return null; + return new Intl.DateTimeFormat("en", { dateStyle: "medium", timeZone: "UTC" }).format( + new Date(value), + ); +} + +function mapRelease(data) { + const tag = data.tag_name; + const publishedAt = data.published_at || null; + return { + tag, + version: versionFromTag(tag), + title: data.name || tag, + url: data.html_url || RELEASES_URL, + publishedAt, + publishedDate: formatDate(publishedAt), + prerelease: Boolean(data.prerelease), + body: data.body || "", + }; +} + +export default async function () { + if (process.env.SKIP_RELEASE_FETCH === "1") { + return fallback("SKIP_RELEASE_FETCH=1"); + } + + const headers = { + "User-Agent": "LQL-website-build", + Accept: "application/vnd.github+json", + }; + const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 8000); + const response = await fetch(API_URL, { headers, signal: controller.signal }); + clearTimeout(timeoutId); + + if (!response.ok) { + return fallback(`GitHub API responded ${response.status}`); + } + + const data = await response.json(); + const items = data.filter((item) => !item.draft).map(mapRelease); + const latest = items.find((item) => !item.prerelease) || items[0]; + if (!latest) { + return fallback("response missing releases"); + } + + return { + available: true, + tag: latest.tag, + version: latest.version, + url: latest.url, + releasesUrl: RELEASES_URL, + publishedAt: latest.publishedAt, + publishedDate: latest.publishedDate, + items, + recent: items.slice(0, RECENT_COUNT), + }; + } catch (err) { + return fallback(err.message); + } +} diff --git a/Lql/LqlWebsite-Eleventy/src/releases.njk b/Lql/LqlWebsite-Eleventy/src/releases.njk new file mode 100644 index 00000000..b4e2972b --- /dev/null +++ b/Lql/LqlWebsite-Eleventy/src/releases.njk @@ -0,0 +1,46 @@ +--- +layout: layouts/base.njk +title: "Releases — LQL" +description: "Every release of the Lambda Query Language toolkit with full notes, generated at build time from the GitHub Releases." +permalink: /releases/ +--- + +{# + agent-pmo:795a9c2 + /releases/ page. Implements [WEB-RELEASES]. + Renders the build-time `release` data (src/_data/release.js). + Notes are rendered with the `releaseNotes` filter (markdown-it, html:false). +#} + +
    +
    +
    +

    Releases

    +

    Every release of the LQL toolkit with full notes — generated at build time from the GitHub Releases.

    +

    View all releases on GitHub ↗

    +
    +
    +
    + +
    +
    + {% if release.available and release.items.length %} + {% for item in release.items %} +
    +

    {{ item.title }} {{ item.tag }}

    +

    + {% if item.publishedDate %}{% endif %} + {% if item.prerelease %}· Prerelease{% endif %} +

    + {% if item.body %} + {{ item.body | releaseNotes | safe }} + {% else %} +

    Read the notes on GitHub

    + {% endif %} +
    + {% endfor %} + {% else %} +

    Releases will appear here once the first version ships. View all releases on GitHub

    + {% endif %} +
    +
    diff --git a/Lql/lql-lsp-rust/Cargo.toml b/Lql/lql-lsp-rust/Cargo.toml index 02274740..9cb47aa5 100644 --- a/Lql/lql-lsp-rust/Cargo.toml +++ b/Lql/lql-lsp-rust/Cargo.toml @@ -7,7 +7,8 @@ members = [ ] [workspace.package] -version = "0.1.0" +# Placeholder; release stamps the tag version in the runner tree. [SWR-VERSION-BUILD-STAMPING] +version = "0.0.0-dev" edition = "2021" license = "MIT" diff --git a/Lql/lql-lsp-rust/Claude.md b/Lql/lql-lsp-rust/Claude.md new file mode 100644 index 00000000..1871289a --- /dev/null +++ b/Lql/lql-lsp-rust/Claude.md @@ -0,0 +1,20 @@ +# lql-lsp-rust — AI guidance + +## Migration to `lspkit` + +The cross-cutting LSP scaffolding in this repo (tower-lsp boilerplate, document store, diagnostics pipeline, init-options config) is being distilled into the generic `lspkit-*` workspace at `/Users/christianfindlay/Documents/Code/lsp_toolkit`. + +**For new LSP infrastructure work:** prefer `lspkit-*` crates over reinventing it here. +**For changes to existing scaffolding in this repo:** flag in the PR description if the patch duplicates `lspkit` functionality, and reference the upstream crate. + +Mapping (current → toolkit crate): + +| Current file | Toolkit crate | +|---|---| +| `crates/lql-lsp/src/main.rs` document `HashMap` (lines 19–38, 310–337) | `lspkit-vfs` (`Vfs`, `DocumentUri`, incremental edits) | +| `crates/lql-lsp/src/main.rs` `tower-lsp` setup (lines 633–649, 206–245) | `lspkit-server` (hand-rolled JSON-RPC + `Dispatcher` + `Capabilities`) — **note:** toolkit does not depend on `tower-lsp` (unmaintained) | +| `crates/lql-lsp/src/main.rs` diagnostics collection (lines 45–92) | `lspkit-server::diagnostics::DiagnosticsBus` | +| `crates/lql-lsp/src/main.rs` `initializationOptions` parsing (lines 207–220) | `lspkit-config::load_from_ancestor` (file-backed) or consumer code reading init options | +| `crates/lql-lsp/tests/lsp_protocol.rs` LSP harness | (not yet in toolkit; harness crate is a v0.1 follow-up) | + +Code in this repo is **not** being removed — it stays canonical until the toolkit matures. This note exists so future agents reuse `lspkit` for new servers and avoid widening this repo's scaffolding. diff --git a/Makefile b/Makefile index 48ff8623..0e5951ab 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,14 @@ -# agent-pmo:74cf183 +# agent-pmo:795a9c2 # ============================================================================= # Standard Makefile — Nimblesite.DataProvider.Core # Cross-platform: Linux, macOS, Windows (via GNU Make) # All targets are language-agnostic. Add language-specific helpers below. # ============================================================================= -.PHONY: build test lint fmt clean ci setup check coverage vsix help +.PHONY: build test lint fmt clean ci setup check coverage vsix rebuild-install-vsix help + +# Installed VS Code extension id (publisher.name from Lql/LqlExtension/package.json) +VSIX_EXT_ID = lql-team.lql-language-support # ----------------------------------------------------------------------------- # OS Detection — portable commands for Linux, macOS, and Windows @@ -102,11 +105,30 @@ coverage: @echo "==> Coverage report..." $(MAKE) _coverage +## aot: Native AOT publish of the DataProviderMigrate CLI + native smoke test ([MIG-AOT-CI]) +aot: + @echo "==> Native AOT publish + smoke test (DataProviderMigrate)..." + $(MAKE) _aot_dotnet + ## vsix: Build Rust LSP (release), compile & package the VS Code extension (.vsix), and install it vsix: @echo "==> Building and packaging VSIX..." bash Lql/lql-lsp-rust/build-vsix.sh +## rebuild-install-vsix: Full clean cycle — uninstall, clean, rebuild, package, install ([MAKE-IDE-EXT]) +rebuild-install-vsix: _vsix_uninstall _vsix_clean vsix + +# Uninstall the currently-installed LQL extension (ignore if absent). +_vsix_uninstall: + @echo "==> Uninstalling $(VSIX_EXT_ID) (if present)..." + -code --uninstall-extension $(VSIX_EXT_ID) + +# Remove the previously packaged .vsix and the compiled extension output. +_vsix_clean: + @echo "==> Cleaning packaged VSIX + extension build output..." + $(RM) Lql/LqlExtension/out + -$(RM) Lql/LqlExtension/*.vsix + # ============================================================================= # LANGUAGE-SPECIFIC IMPLEMENTATIONS # ============================================================================= @@ -253,6 +275,40 @@ _setup_dotnet: dotnet restore dotnet tool restore +# Native AOT publish of the migration CLI, then run the published native binary +# as a black-box smoke test against a throwaway SQLite database. Implements +# [MIG-AOT-CI]. RID auto-detects per platform; override with `make aot RID=...`. +AOT_PROJ := Migration/DataProviderMigrate/DataProviderMigrate.csproj +ifeq ($(OS),Windows_NT) +RID ?= win-x64 +AOT_EXE := Migration/DataProviderMigrate/bin/Release/net9.0/$(RID)/publish/DataProviderMigrate.exe +else ifeq ($(shell uname -s),Darwin) +ifeq ($(shell uname -m),arm64) +RID ?= osx-arm64 +else +RID ?= osx-x64 +endif +AOT_EXE := Migration/DataProviderMigrate/bin/Release/net9.0/$(RID)/publish/DataProviderMigrate +else +RID ?= linux-x64 +AOT_EXE := Migration/DataProviderMigrate/bin/Release/net9.0/$(RID)/publish/DataProviderMigrate +endif + +_aot_dotnet: + @echo "==> Publishing Native AOT ($(RID))..." + dotnet publish $(AOT_PROJ) -c Release -r $(RID) -p:PublishAot=true --self-contained + @echo "==> Native smoke test: migrate example schema to SQLite..." + $(AOT_EXE) migrate --schema Migration/DataProviderMigrate/example-schema.yaml \ + --output $(AOT_SMOKE_DB) --provider sqlite + @echo "==> Native AOT smoke test passed." + +# Throwaway SQLite path for the smoke test (TMPDIR-aware, cleaned each run). +ifeq ($(OS),Windows_NT) +AOT_SMOKE_DB := $(TEMP)\dataprovider_aot_smoke.db +else +AOT_SMOKE_DB := $(shell printf '%s' "$${TMPDIR:-/tmp}")dataprovider_aot_smoke.db +endif + # --- RUST (LQL LSP) --- _build_rust: cd Lql/lql-lsp-rust && cargo build --release diff --git a/Migration/DataProviderMigrate/DataProviderMigrate.csproj b/Migration/DataProviderMigrate/DataProviderMigrate.csproj index c82e0888..62df8a90 100644 --- a/Migration/DataProviderMigrate/DataProviderMigrate.csproj +++ b/Migration/DataProviderMigrate/DataProviderMigrate.csproj @@ -16,6 +16,27 @@ dataprovider;migration;schema;yaml;sqlite;postgres;dotnet-tool + + + $(DefineConstants);AOT + true + true + true + true + + false + false + + diff --git a/Migration/DataProviderMigrate/Program.Export.cs b/Migration/DataProviderMigrate/Program.Export.cs new file mode 100644 index 00000000..753607af --- /dev/null +++ b/Migration/DataProviderMigrate/Program.Export.cs @@ -0,0 +1,230 @@ +#if !AOT +using System.Reflection; +using Nimblesite.DataProvider.Migration.Core; + +namespace DataProviderMigrate; + +// Implements [MIG-AOT-EXPORT]. The export command loads an arbitrary external +// assembly via reflection (Assembly.LoadFrom + GetType + reflected member +// invoke). That is fundamentally incompatible with a self-contained Native AOT +// binary, so this whole file is excluded from the AOT build. The native binary +// ships migrate only; export remains in the managed `dotnet tool`. +public static partial class Program +{ + private static int RunExport(string[] args) + { + var parseResult = ParseExportArguments(args); + + return parseResult switch + { + ExportParseResult.Success success => ExecuteExport(success), + ExportParseResult.Failure failure => ShowExportError(failure), + ExportParseResult.HelpRequested => ShowExportUsage(), + }; + } + + private static int ExecuteExport(ExportParseResult.Success args) + { + Console.WriteLine( + $""" + DataProviderMigrate - Export C# Schema to YAML + Assembly: {args.AssemblyPath} + Type: {args.TypeName} + Output: {args.OutputPath} + """ + ); + + if (!File.Exists(args.AssemblyPath)) + { + Console.WriteLine($"Error: Assembly not found: {args.AssemblyPath}"); + return 1; + } + + try + { + var assembly = Assembly.LoadFrom(args.AssemblyPath); + var schemaType = assembly.GetType(args.TypeName); + + if (schemaType is null) + { + Console.WriteLine($"Error: Type '{args.TypeName}' not found in assembly"); + return 1; + } + + var schema = GetSchemaDefinition(schemaType); + + if (schema is null) + { + Console.WriteLine( + $"Error: Could not get SchemaDefinition from type '{args.TypeName}'\n Expected: static property 'Definition' or static method 'Build()' returning SchemaDefinition" + ); + return 1; + } + + var directory = Path.GetDirectoryName(args.OutputPath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + SchemaYamlSerializer.ToYamlFile(schema, args.OutputPath); + Console.WriteLine( + $"Successfully exported schema '{schema.Name}' with {schema.Tables.Count} tables\n Output: {args.OutputPath}" + ); + return 0; + } + catch (Exception ex) + { + Console.WriteLine($"Error: {ex}"); + return 1; + } + } + + private static SchemaDefinition? GetSchemaDefinition(Type schemaType) + { + var definitionProp = schemaType.GetProperty( + "Definition", + BindingFlags.Public | BindingFlags.Static + ); + + if (definitionProp?.GetValue(null) is SchemaDefinition defFromProp) + { + return defFromProp; + } + + var buildMethod = schemaType.GetMethod( + "Build", + BindingFlags.Public | BindingFlags.Static, + Type.EmptyTypes + ); + + if (buildMethod?.Invoke(null, null) is SchemaDefinition defFromMethod) + { + return defFromMethod; + } + + return null; + } + + private static int ShowExportError(ExportParseResult.Failure failure) + { + Console.WriteLine($"Error: {failure.Message}\n"); + return ShowExportUsage(); + } + + private static int ShowExportUsage() + { + Console.WriteLine( + """ + Usage: DataProviderMigrate export [options] + + Options: + --assembly, -a Path to compiled assembly containing schema class (required) + --type, -t Fully qualified type name of schema class (required) + --output, -o Path to output YAML file (required) + + Examples: + DataProviderMigrate export -a bin/Debug/net10.0/MyProject.dll -t MyNamespace.MySchema -o schema.yaml + + Schema Class Requirements: + - Static property 'Definition' returning SchemaDefinition, OR + - Static method 'Build()' returning SchemaDefinition + """ + ); + return 1; + } + + private static ExportParseResult ParseExportArguments(string[] args) + { + string? assemblyPath = null; + string? typeName = null; + string? outputPath = null; + + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + + switch (arg) + { + case "--assembly" or "-a": + if (i + 1 >= args.Length) + { + return new ExportParseResult.Failure("--assembly requires a path argument"); + } + + assemblyPath = args[++i]; + break; + + case "--type" + or "-t": + if (i + 1 >= args.Length) + { + return new ExportParseResult.Failure( + "--type requires a type name argument" + ); + } + + typeName = args[++i]; + break; + + case "--output" + or "-o": + if (i + 1 >= args.Length) + { + return new ExportParseResult.Failure("--output requires a path argument"); + } + + outputPath = args[++i]; + break; + + case "--help" + or "-h": + return new ExportParseResult.HelpRequested(); + + default: + if (arg.StartsWith('-')) + { + return new ExportParseResult.Failure($"Unknown option: {arg}"); + } + + break; + } + } + + if (string.IsNullOrEmpty(assemblyPath)) + { + return new ExportParseResult.Failure("--assembly is required"); + } + + if (string.IsNullOrEmpty(typeName)) + { + return new ExportParseResult.Failure("--type is required"); + } + + if (string.IsNullOrEmpty(outputPath)) + { + return new ExportParseResult.Failure("--output is required"); + } + + return new ExportParseResult.Success(assemblyPath, typeName, outputPath); + } +} + +/// +/// Export subcommand argument parsing result. +/// +public abstract record ExportParseResult +{ + private ExportParseResult() { } + + /// Successfully parsed export arguments. + public sealed record Success(string AssemblyPath, string TypeName, string OutputPath) + : ExportParseResult; + + /// Parse error. + public sealed record Failure(string Message) : ExportParseResult; + + /// Help requested. + public sealed record HelpRequested : ExportParseResult; +} +#endif diff --git a/Migration/DataProviderMigrate/Program.cs b/Migration/DataProviderMigrate/Program.cs index 50597063..cd41e7fa 100644 --- a/Migration/DataProviderMigrate/Program.cs +++ b/Migration/DataProviderMigrate/Program.cs @@ -1,5 +1,4 @@ using System.Collections.Immutable; -using System.Reflection; using Microsoft.Data.Sqlite; using Nimblesite.DataProvider.Migration.Core; using Nimblesite.DataProvider.Migration.Postgres; @@ -26,7 +25,7 @@ namespace DataProviderMigrate; /// CLI tool for database schema operations: migrate from YAML and export C# schemas to YAML. /// This is the ONLY canonical tool for database creation - all projects MUST use this. /// -public static class Program +public static partial class Program { /// /// Entry point - dispatches to migrate or export subcommand. @@ -53,6 +52,22 @@ _ when command.StartsWith('-') => RunMigrate(args), // backwards compat: no subc }; } +#if AOT + // Implements [MIG-AOT-EXPORT]: the export command loads an arbitrary external + // assembly via reflection (Assembly.LoadFrom), which a self-contained Native + // AOT binary cannot do. The native build ships migrate only; export remains in + // the managed `dotnet tool`. + private static int RunExport(string[] args) + { + Console.WriteLine( + "Error: the 'export' command is not available in the native (AOT) build.\n" + + "It loads an external assembly via reflection, which Native AOT cannot do.\n" + + "Use the managed dotnet tool 'DataProviderMigrate export ...' instead." + ); + return 1; + } +#endif + private static int RunMigrate(string[] args) { var parseResult = ParseMigrateArguments(args); @@ -65,18 +80,6 @@ private static int RunMigrate(string[] args) }; } - private static int RunExport(string[] args) - { - var parseResult = ParseExportArguments(args); - - return parseResult switch - { - ExportParseResult.Success success => ExecuteExport(success), - ExportParseResult.Failure failure => ShowExportError(failure), - ExportParseResult.HelpRequested => ShowExportUsage(), - }; - } - // ── Migrate ────────────────────────────────────────────────────────── private static int ExecuteMigration(MigrateParseResult.Success args) @@ -413,91 +416,6 @@ private static int ShowProviderError(string provider) return 1; } - // ── Export ──────────────────────────────────────────────────────────── - - private static int ExecuteExport(ExportParseResult.Success args) - { - Console.WriteLine( - $""" - DataProviderMigrate - Export C# Schema to YAML - Assembly: {args.AssemblyPath} - Type: {args.TypeName} - Output: {args.OutputPath} - """ - ); - - if (!File.Exists(args.AssemblyPath)) - { - Console.WriteLine($"Error: Assembly not found: {args.AssemblyPath}"); - return 1; - } - - try - { - var assembly = Assembly.LoadFrom(args.AssemblyPath); - var schemaType = assembly.GetType(args.TypeName); - - if (schemaType is null) - { - Console.WriteLine($"Error: Type '{args.TypeName}' not found in assembly"); - return 1; - } - - var schema = GetSchemaDefinition(schemaType); - - if (schema is null) - { - Console.WriteLine( - $"Error: Could not get SchemaDefinition from type '{args.TypeName}'\n Expected: static property 'Definition' or static method 'Build()' returning SchemaDefinition" - ); - return 1; - } - - var directory = Path.GetDirectoryName(args.OutputPath); - if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) - { - Directory.CreateDirectory(directory); - } - - SchemaYamlSerializer.ToYamlFile(schema, args.OutputPath); - Console.WriteLine( - $"Successfully exported schema '{schema.Name}' with {schema.Tables.Count} tables\n Output: {args.OutputPath}" - ); - return 0; - } - catch (Exception ex) - { - Console.WriteLine($"Error: {ex}"); - return 1; - } - } - - private static SchemaDefinition? GetSchemaDefinition(Type schemaType) - { - var definitionProp = schemaType.GetProperty( - "Definition", - BindingFlags.Public | BindingFlags.Static - ); - - if (definitionProp?.GetValue(null) is SchemaDefinition defFromProp) - { - return defFromProp; - } - - var buildMethod = schemaType.GetMethod( - "Build", - BindingFlags.Public | BindingFlags.Static, - Type.EmptyTypes - ); - - if (buildMethod?.Invoke(null, null) is SchemaDefinition defFromMethod) - { - return defFromMethod; - } - - return null; - } - // ── Usage / Errors ─────────────────────────────────────────────────── private static int ShowTopLevelUsage() @@ -563,34 +481,6 @@ DataProviderMigrate migrate --schema schema.yaml --output "$PG_URL" --provider p return 1; } - private static int ShowExportError(ExportParseResult.Failure failure) - { - Console.WriteLine($"Error: {failure.Message}\n"); - return ShowExportUsage(); - } - - private static int ShowExportUsage() - { - Console.WriteLine( - """ - Usage: DataProviderMigrate export [options] - - Options: - --assembly, -a Path to compiled assembly containing schema class (required) - --type, -t Fully qualified type name of schema class (required) - --output, -o Path to output YAML file (required) - - Examples: - DataProviderMigrate export -a bin/Debug/net10.0/MyProject.dll -t MyNamespace.MySchema -o schema.yaml - - Schema Class Requirements: - - Static property 'Definition' returning SchemaDefinition, OR - - Static method 'Build()' returning SchemaDefinition - """ - ); - return 1; - } - // ── Argument Parsing ───────────────────────────────────────────────── private static MigrateParseResult ParseMigrateArguments(string[] args) @@ -697,81 +587,6 @@ private static MigrateParseResult ParseMigrateArguments(string[] args) phase ); } - - private static ExportParseResult ParseExportArguments(string[] args) - { - string? assemblyPath = null; - string? typeName = null; - string? outputPath = null; - - for (var i = 0; i < args.Length; i++) - { - var arg = args[i]; - - switch (arg) - { - case "--assembly" or "-a": - if (i + 1 >= args.Length) - { - return new ExportParseResult.Failure("--assembly requires a path argument"); - } - - assemblyPath = args[++i]; - break; - - case "--type" - or "-t": - if (i + 1 >= args.Length) - { - return new ExportParseResult.Failure( - "--type requires a type name argument" - ); - } - - typeName = args[++i]; - break; - - case "--output" - or "-o": - if (i + 1 >= args.Length) - { - return new ExportParseResult.Failure("--output requires a path argument"); - } - - outputPath = args[++i]; - break; - - case "--help" - or "-h": - return new ExportParseResult.HelpRequested(); - - default: - if (arg.StartsWith('-')) - { - return new ExportParseResult.Failure($"Unknown option: {arg}"); - } - - break; - } - } - - if (string.IsNullOrEmpty(assemblyPath)) - { - return new ExportParseResult.Failure("--assembly is required"); - } - - if (string.IsNullOrEmpty(typeName)) - { - return new ExportParseResult.Failure("--type is required"); - } - - if (string.IsNullOrEmpty(outputPath)) - { - return new ExportParseResult.Failure("--output is required"); - } - - return new ExportParseResult.Success(assemblyPath, typeName, outputPath); - } } /// @@ -797,24 +612,6 @@ public sealed record Failure(string Message) : MigrateParseResult; public sealed record HelpRequested : MigrateParseResult; } -/// -/// Export subcommand argument parsing result. -/// -public abstract record ExportParseResult -{ - private ExportParseResult() { } - - /// Successfully parsed export arguments. - public sealed record Success(string AssemblyPath, string TypeName, string OutputPath) - : ExportParseResult; - - /// Parse error. - public sealed record Failure(string Message) : ExportParseResult; - - /// Help requested. - public sealed record HelpRequested : ExportParseResult; -} - /// /// Two-phase migrate selector. Implements the NAP requirement that /// SECURITY DEFINER functions referenced by RLS policies be created out diff --git a/Migration/Nimblesite.DataProvider.Migration.Core/Nimblesite.DataProvider.Migration.Core.csproj b/Migration/Nimblesite.DataProvider.Migration.Core/Nimblesite.DataProvider.Migration.Core.csproj index e7bae225..d4a1dd91 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Core/Nimblesite.DataProvider.Migration.Core.csproj +++ b/Migration/Nimblesite.DataProvider.Migration.Core/Nimblesite.DataProvider.Migration.Core.csproj @@ -10,6 +10,10 @@ + diff --git a/Migration/Nimblesite.DataProvider.Migration.Core/SchemaJsonContext.cs b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaJsonContext.cs new file mode 100644 index 00000000..208980f7 --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaJsonContext.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +namespace Nimblesite.DataProvider.Migration.Core; + +/// +/// Source-generated +/// for and its graph. Implements [MIG-AOT-JSON]: +/// provides reflection-free JSON metadata so is +/// Native AOT and trim compatible. is intentionally +/// not listed — it is handled by registered +/// on the serializer options. +/// +[JsonSerializable(typeof(SchemaDefinition))] +internal sealed partial class SchemaJsonContext : JsonSerializerContext { } diff --git a/Migration/Nimblesite.DataProvider.Migration.Core/SchemaSerializer.cs b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaSerializer.cs index 5a1c44bb..5aceb9c4 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Core/SchemaSerializer.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaSerializer.cs @@ -9,13 +9,20 @@ namespace Nimblesite.DataProvider.Migration.Core; /// public static class SchemaSerializer { - private static readonly JsonSerializerOptions JsonOptions = new() - { - WriteIndented = true, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - Converters = { new PortableTypeJsonConverter() }, - }; + // Implements [MIG-AOT-JSON]: serialize through the source-generated + // JsonTypeInfo so the call carries no reflection (IL2026/IL3050). The + // PortableType converter is attached to the generated context's options; + // the typed JsonTypeInfo overloads — not the generic Serialize(options) + // overloads — are what keep the path AOT/trim clean. + private static readonly SchemaJsonContext Context = new( + new JsonSerializerOptions + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new PortableTypeJsonConverter() }, + } + ); /// /// Serialize a schema definition to JSON string. @@ -23,7 +30,7 @@ public static class SchemaSerializer /// Schema to serialize. /// JSON representation of the schema. public static string ToJson(SchemaDefinition schema) => - JsonSerializer.Serialize(schema, JsonOptions); + JsonSerializer.Serialize(schema, Context.SchemaDefinition); /// /// Deserialize a schema definition from JSON string. @@ -31,7 +38,7 @@ public static string ToJson(SchemaDefinition schema) => /// JSON string. /// Deserialized schema definition. public static SchemaDefinition FromJson(string json) => - JsonSerializer.Deserialize(json, JsonOptions) + JsonSerializer.Deserialize(json, Context.SchemaDefinition) ?? throw new JsonException("Failed to deserialize schema"); /// diff --git a/Migration/Nimblesite.DataProvider.Migration.Core/SchemaYamlReader.cs b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaYamlReader.cs new file mode 100644 index 00000000..770116d0 --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaYamlReader.cs @@ -0,0 +1,315 @@ +using System.Globalization; +using YamlDotNet.RepresentationModel; + +namespace Nimblesite.DataProvider.Migration.Core; + +/// +/// Reflection-free, Native-AOT-safe deserializer that maps a YAML string to a +/// using only the YamlDotNet representation model. +/// +internal static class SchemaYamlReader +{ + /// Parse a YAML document into a . + public static SchemaDefinition Read(string yaml) + { + var root = LoadRootMapping(yaml); + if (root is null) + { + return new SchemaDefinition { Name = string.Empty, Tables = [] }; + } + + return new SchemaDefinition + { + Name = ReadString(root, "name") ?? string.Empty, + Tables = ReadList(root, "tables", ReadTable), + Roles = ReadList(root, "roles", ReadRole), + Functions = ReadList(root, "functions", ReadFunction), + Grants = ReadList(root, "grants", ReadGrant), + }; + } + + private static YamlMappingNode? LoadRootMapping(string yaml) + { + if (string.IsNullOrWhiteSpace(yaml)) + { + return null; + } + + var stream = new YamlStream(); + using var reader = new StringReader(yaml); + stream.Load(reader); + + if (stream.Documents.Count == 0) + { + return null; + } + + return stream.Documents[0].RootNode as YamlMappingNode; + } + + private static TableDefinition ReadTable(YamlNode node) + { + var map = AsMapping(node); + return new TableDefinition + { + Schema = ReadString(map, "schema") ?? "public", + Name = ReadString(map, "name") ?? string.Empty, + Columns = ReadList(map, "columns", ReadColumn), + Indexes = ReadList(map, "indexes", ReadIndex), + ForeignKeys = ReadList(map, "foreignKeys", ReadForeignKey), + PrimaryKey = ReadOptional(map, "primaryKey", ReadPrimaryKey), + UniqueConstraints = ReadList(map, "uniqueConstraints", ReadUniqueConstraint), + CheckConstraints = ReadList(map, "checkConstraints", ReadCheckConstraint), + Comment = ReadString(map, "comment"), + RowLevelSecurity = ReadOptional(map, "rowLevelSecurity", ReadRlsPolicySet), + }; + } + + private static ColumnDefinition ReadColumn(YamlNode node) + { + var map = AsMapping(node); + return new ColumnDefinition + { + Name = ReadString(map, "name") ?? string.Empty, + Type = ReadPortableType(map, "type"), + IsNullable = ReadBool(map, "isNullable") ?? true, + DefaultValue = ReadString(map, "defaultValue"), + DefaultLqlExpression = ReadString(map, "defaultLqlExpression"), + IsIdentity = ReadBool(map, "isIdentity") ?? false, + IdentitySeed = ReadLong(map, "identitySeed") ?? 1L, + IdentityIncrement = ReadLong(map, "identityIncrement") ?? 1L, + ComputedExpression = ReadString(map, "computedExpression"), + IsComputedPersisted = ReadBool(map, "isComputedPersisted") ?? false, + Collation = ReadString(map, "collation"), + CheckConstraint = ReadString(map, "checkConstraint"), + CheckConstraintName = ReadString(map, "checkConstraintName"), + Comment = ReadString(map, "comment"), + }; + } + + private static IndexDefinition ReadIndex(YamlNode node) + { + var map = AsMapping(node); + return new IndexDefinition + { + Name = ReadString(map, "name") ?? string.Empty, + Columns = ReadStringList(map, "columns"), + Expressions = ReadStringList(map, "expressions"), + IsUnique = ReadBool(map, "isUnique") ?? false, + Filter = ReadString(map, "filter"), + }; + } + + private static ForeignKeyDefinition ReadForeignKey(YamlNode node) + { + var map = AsMapping(node); + return new ForeignKeyDefinition + { + Name = ReadString(map, "name"), + Columns = ReadStringList(map, "columns"), + ReferencedTable = ReadString(map, "referencedTable") ?? string.Empty, + ReferencedSchema = ReadString(map, "referencedSchema") ?? "public", + ReferencedColumns = ReadStringList(map, "referencedColumns"), + OnDelete = ReadForeignKeyAction(map, "onDelete"), + OnUpdate = ReadForeignKeyAction(map, "onUpdate"), + }; + } + + private static PrimaryKeyDefinition ReadPrimaryKey(YamlNode node) + { + var map = AsMapping(node); + return new PrimaryKeyDefinition + { + Name = ReadString(map, "name"), + Columns = ReadStringList(map, "columns"), + }; + } + + private static UniqueConstraintDefinition ReadUniqueConstraint(YamlNode node) + { + var map = AsMapping(node); + return new UniqueConstraintDefinition + { + Name = ReadString(map, "name"), + Columns = ReadStringList(map, "columns"), + }; + } + + private static CheckConstraintDefinition ReadCheckConstraint(YamlNode node) + { + var map = AsMapping(node); + return new CheckConstraintDefinition + { + Name = ReadString(map, "name") ?? string.Empty, + Expression = ReadString(map, "expression") ?? string.Empty, + }; + } + + private static RlsPolicySetDefinition ReadRlsPolicySet(YamlNode node) + { + var map = AsMapping(node); + return new RlsPolicySetDefinition + { + Enabled = ReadBool(map, "enabled") ?? true, + Policies = ReadList(map, "policies", ReadRlsPolicy), + Forced = ReadBool(map, "forced") ?? false, + }; + } + + private static RlsPolicyDefinition ReadRlsPolicy(YamlNode node) + { + var map = AsMapping(node); + var operationsNode = FindValue(map, "operations"); + return new RlsPolicyDefinition + { + Name = ReadString(map, "name") ?? string.Empty, + IsPermissive = ReadBool(map, "permissive") ?? true, + Operations = operationsNode is null + ? [RlsOperation.All] + : ReadSequenceItems(operationsNode, ReadRlsOperationNode), + Roles = ReadStringList(map, "roles"), + UsingLql = ReadString(map, "using"), + WithCheckLql = ReadString(map, "withCheck"), + UsingSql = ReadString(map, "usingSql"), + WithCheckSql = ReadString(map, "withCheckSql"), + }; + } + + private static PostgresRoleDefinition ReadRole(YamlNode node) + { + var map = AsMapping(node); + return new PostgresRoleDefinition + { + Name = ReadString(map, "name") ?? string.Empty, + Login = ReadBool(map, "login") ?? false, + BypassRls = ReadBool(map, "bypassRls") ?? false, + GrantTo = ReadStringList(map, "grantTo"), + }; + } + + private static PostgresFunctionDefinition ReadFunction(YamlNode node) + { + var map = AsMapping(node); + return new PostgresFunctionDefinition + { + Schema = ReadString(map, "schema") ?? "public", + Name = ReadString(map, "name") ?? string.Empty, + Arguments = ReadList(map, "arguments", ReadFunctionArgument), + Returns = ReadString(map, "returns") ?? "void", + Language = ReadString(map, "language") ?? "sql", + Volatility = ReadString(map, "volatility") ?? "stable", + SecurityDefiner = ReadBool(map, "securityDefiner") ?? false, + Body = ReadString(map, "body") ?? string.Empty, + BodyLql = ReadString(map, "bodyLql"), + ExecuteRoles = ReadStringList(map, "executeRoles"), + RevokePublicExecute = ReadBool(map, "revokePublicExecute") ?? true, + }; + } + + private static PostgresFunctionArgumentDefinition ReadFunctionArgument(YamlNode node) + { + var map = AsMapping(node); + return new PostgresFunctionArgumentDefinition + { + Name = ReadString(map, "name") ?? string.Empty, + Type = ReadString(map, "type") ?? string.Empty, + }; + } + + private static PostgresGrantDefinition ReadGrant(YamlNode node) + { + var map = AsMapping(node); + return new PostgresGrantDefinition + { + Schema = ReadString(map, "schema") ?? "public", + Target = ReadGrantTarget(map, "target"), + ObjectName = ReadString(map, "objectName"), + Privileges = ReadStringList(map, "privileges"), + Roles = ReadStringList(map, "roles"), + RunAs = ReadString(map, "runAs"), + }; + } + + private static PortableType ReadPortableType(YamlMappingNode map, string key) => + FindValue(map, key) is YamlScalarNode s + ? SchemaYamlScalars.ParseType(s.Value ?? string.Empty) + : new TextType(); + + private static ForeignKeyAction ReadForeignKeyAction(YamlMappingNode map, string key) => + FindValue(map, key) is YamlScalarNode { Value: { } v } + ? SchemaYamlScalars.ParseForeignKeyAction(v) + : ForeignKeyAction.NoAction; + + private static RlsOperation ReadRlsOperationNode(YamlNode node) => + node is YamlScalarNode { Value: { } v } + ? SchemaYamlScalars.ParseRlsOperation(v) + : RlsOperation.All; + + private static PostgresGrantTarget ReadGrantTarget(YamlMappingNode map, string key) => + FindValue(map, key) is YamlScalarNode { Value: { } v } + ? SchemaYamlScalars.ParseGrantTarget(v) + : PostgresGrantTarget.Table; + + private static YamlMappingNode AsMapping(YamlNode node) => + node as YamlMappingNode ?? new YamlMappingNode(); + + private static YamlNode? FindValue(YamlMappingNode map, string key) => + map.Children.TryGetValue(new YamlScalarNode(key), out var value) ? value : null; + + private static string? ReadString(YamlMappingNode map, string key) => + FindValue(map, key) is YamlScalarNode { Value: { } v } ? v : null; + + private static bool? ReadBool(YamlMappingNode map, string key) => + FindValue(map, key) is YamlScalarNode { Value: { } v } + ? v.Trim().ToUpperInvariant() switch + { + "TRUE" or "YES" or "ON" or "1" => true, + "FALSE" or "NO" or "OFF" or "0" => false, + _ => bool.TryParse(v, out var b) ? b : (bool?)null, + } + : null; + + private static long? ReadLong(YamlMappingNode map, string key) => + FindValue(map, key) is YamlScalarNode { Value: { } v } + && long.TryParse(v, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n) + ? n + : (long?)null; + + private static System.Collections.Immutable.ImmutableArray ReadList( + YamlMappingNode map, + string key, + Func readItem + ) => FindValue(map, key) is YamlSequenceNode seq ? ReadSequenceItems(seq, readItem) : []; + + private static System.Collections.Immutable.ImmutableArray ReadSequenceItems( + YamlNode node, + Func readItem + ) + { + if (node is not YamlSequenceNode seq) + { + return []; + } + + var builder = System.Collections.Immutable.ImmutableArray.CreateBuilder( + seq.Children.Count + ); + foreach (var child in seq.Children) + { + builder.Add(readItem(child)); + } + + return builder.ToImmutable(); + } + + private static System.Collections.Immutable.ImmutableArray ReadStringList( + YamlMappingNode map, + string key + ) => + FindValue(map, key) is YamlSequenceNode seq + ? ReadSequenceItems(seq, n => n is YamlScalarNode { Value: { } v } ? v : string.Empty) + : []; + + private static T? ReadOptional(YamlMappingNode map, string key, Func read) + where T : class => FindValue(map, key) is { } node ? read(node) : null; +} diff --git a/Migration/Nimblesite.DataProvider.Migration.Core/SchemaYamlScalars.cs b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaYamlScalars.cs new file mode 100644 index 00000000..433411e3 --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaYamlScalars.cs @@ -0,0 +1,214 @@ +using System.Globalization; + +namespace Nimblesite.DataProvider.Migration.Core; + +/// +/// Reflection-free scalar encode/parse helpers shared by the hand-written +/// YAML writer and reader. Implements [MIG-AOT-YAML]. The encodings here are +/// the single source of truth for how , foreign-key +/// actions, RLS operations and grant targets appear in schema YAML; they match +/// the format the previous YamlDotNet converters produced so existing schema +/// files and tests keep round-tripping. +/// +internal static class SchemaYamlScalars +{ + /// Encodes a as its scalar string form. + internal static string Encode(PortableType value) => + value switch + { + TinyIntType => "TinyInt", + SmallIntType => "SmallInt", + IntType => "Int", + BigIntType => "BigInt", + DecimalType d => $"Decimal({d.Precision},{d.Scale})", + FloatType => "Float", + DoubleType => "Double", + MoneyType => "Money", + SmallMoneyType => "SmallMoney", + BooleanType => "Boolean", + CharType c => $"Char({c.Length})", + VarCharType v => $"VarChar({v.MaxLength})", + NCharType nc => $"NChar({nc.Length})", + NVarCharType nv when nv.MaxLength == int.MaxValue => "NVarChar(max)", + NVarCharType nv => $"NVarChar({nv.MaxLength})", + TextType => "Text", + BinaryType b => $"Binary({b.Length})", + VarBinaryType vb when vb.MaxLength == int.MaxValue => "VarBinary(max)", + VarBinaryType vb => $"VarBinary({vb.MaxLength})", + BlobType => "Blob", + DateType => "Date", + TimeType t when t.Precision == 7 => "Time", + TimeType t => $"Time({t.Precision})", + DateTimeType dt when dt.Precision == 3 => "DateTime", + DateTimeType dt => $"DateTime({dt.Precision})", + DateTimeOffsetType => "DateTimeOffset", + UuidType => "Uuid", + JsonType => "Json", + XmlType => "Xml", + RowVersionType => "RowVersion", + GeometryType g when g.Srid.HasValue => $"Geometry({g.Srid})", + GeometryType => "Geometry", + GeographyType g when g.Srid == 4326 => "Geography", + GeographyType g => $"Geography({g.Srid})", + EnumType e => $"Enum({e.Name}:{string.Join("|", e.Values)})", + VectorType v => $"Vector({v.Dimensions})", + _ => "Text", + }; + + /// Parses a scalar string into a . + internal static PortableType ParseType(string typeStr) + { + var trimmed = typeStr.Trim(); + + var parenIndex = trimmed.IndexOf('(', StringComparison.Ordinal); + if (parenIndex > 0) + { + var typeName = trimmed[..parenIndex]; + var paramsStr = trimmed[(parenIndex + 1)..^1]; + return ParseParameterized(typeName.ToUpperInvariant(), paramsStr); + } + + return trimmed.ToUpperInvariant() switch + { + "TINYINT" => new TinyIntType(), + "SMALLINT" => new SmallIntType(), + "INT" or "INTEGER" => new IntType(), + "BIGINT" => new BigIntType(), + "FLOAT" or "REAL" => new FloatType(), + "DOUBLE" => new DoubleType(), + "MONEY" => new MoneyType(), + "SMALLMONEY" => new SmallMoneyType(), + "BOOLEAN" or "BOOL" => new BooleanType(), + "TEXT" => new TextType(), + "BLOB" => new BlobType(), + "DATE" => new DateType(), + "TIME" => new TimeType(), + "DATETIME" => new DateTimeType(), + "DATETIMEOFFSET" => new DateTimeOffsetType(), + "UUID" or "GUID" => new UuidType(), + "JSON" or "JSONB" => new JsonType(), + "XML" => new XmlType(), + "ROWVERSION" or "TIMESTAMP" => new RowVersionType(), + "GEOMETRY" => new GeometryType(null), + "GEOGRAPHY" => new GeographyType(), + _ => new TextType(), + }; + } + + private static PortableType ParseParameterized(string typeName, string paramsStr) => + typeName switch + { + "DECIMAL" => ParseDecimal(paramsStr), + "CHAR" => new CharType(ParseInt(paramsStr)), + "VARCHAR" => new VarCharType(ParseMaxLength(paramsStr)), + "NCHAR" => new NCharType(ParseInt(paramsStr)), + "NVARCHAR" => new NVarCharType(ParseMaxLength(paramsStr)), + "BINARY" => new BinaryType(ParseInt(paramsStr)), + "VARBINARY" => new VarBinaryType(ParseMaxLength(paramsStr)), + "TIME" => new TimeType(ParseInt(paramsStr)), + "DATETIME" => new DateTimeType(ParseInt(paramsStr)), + "GEOMETRY" => new GeometryType(ParseInt(paramsStr)), + "GEOGRAPHY" => new GeographyType(ParseInt(paramsStr)), + "ENUM" => ParseEnum(paramsStr), + "VECTOR" => new VectorType(ParseInt(paramsStr)), + _ => new TextType(), + }; + + private static int ParseInt(string s) => int.Parse(s, CultureInfo.InvariantCulture); + + private static int ParseMaxLength(string s) => + s.Equals("max", StringComparison.OrdinalIgnoreCase) ? int.MaxValue : ParseInt(s); + + private static DecimalType ParseDecimal(string paramsStr) + { + var parts = paramsStr.Split(','); + return parts.Length == 2 + ? new DecimalType( + int.Parse(parts[0].Trim(), CultureInfo.InvariantCulture), + int.Parse(parts[1].Trim(), CultureInfo.InvariantCulture) + ) + : new DecimalType(int.Parse(parts[0].Trim(), CultureInfo.InvariantCulture), 0); + } + + private static EnumType ParseEnum(string paramsStr) + { + var colonIndex = paramsStr.IndexOf(':', StringComparison.Ordinal); + if (colonIndex > 0) + { + var name = paramsStr[..colonIndex]; + var values = paramsStr[(colonIndex + 1)..].Split('|'); + return new EnumType(name, values); + } + + return new EnumType("enum", paramsStr.Split('|')); + } + + /// Encodes a foreign-key action enum value. + internal static string Encode(ForeignKeyAction action) => + action switch + { + ForeignKeyAction.NoAction => "NoAction", + ForeignKeyAction.Cascade => "Cascade", + ForeignKeyAction.SetNull => "SetNull", + ForeignKeyAction.SetDefault => "SetDefault", + ForeignKeyAction.Restrict => "Restrict", + _ => "NoAction", + }; + + /// Parses a foreign-key action scalar, tolerant of spelling variants. + internal static ForeignKeyAction ParseForeignKeyAction(string value) => + value.ToUpperInvariant() switch + { + "NOACTION" or "NO_ACTION" or "NO ACTION" => ForeignKeyAction.NoAction, + "CASCADE" => ForeignKeyAction.Cascade, + "SETNULL" or "SET_NULL" or "SET NULL" => ForeignKeyAction.SetNull, + "SETDEFAULT" or "SET_DEFAULT" or "SET DEFAULT" => ForeignKeyAction.SetDefault, + "RESTRICT" => ForeignKeyAction.Restrict, + _ => ForeignKeyAction.NoAction, + }; + + /// Encodes an RLS operation enum value to its scalar string form. + internal static string Encode(RlsOperation operation) => + operation switch + { + RlsOperation.All => "All", + RlsOperation.Select => "Select", + RlsOperation.Insert => "Insert", + RlsOperation.Update => "Update", + RlsOperation.Delete => "Delete", + _ => "All", + }; + + /// Parses an RLS operation scalar, tolerant of case. + internal static RlsOperation ParseRlsOperation(string value) => + value.ToUpperInvariant() switch + { + "ALL" => RlsOperation.All, + "SELECT" => RlsOperation.Select, + "INSERT" => RlsOperation.Insert, + "UPDATE" => RlsOperation.Update, + "DELETE" => RlsOperation.Delete, + _ => RlsOperation.All, + }; + + /// Encodes a grant-target enum value to its scalar string form. + internal static string Encode(PostgresGrantTarget target) => + target switch + { + PostgresGrantTarget.Schema => "Schema", + PostgresGrantTarget.Table => "Table", + PostgresGrantTarget.AllTablesInSchema => "AllTablesInSchema", + _ => "Table", + }; + + /// Parses a grant-target scalar, tolerant of spelling variants. + internal static PostgresGrantTarget ParseGrantTarget(string value) => + value.ToUpperInvariant() switch + { + "SCHEMA" => PostgresGrantTarget.Schema, + "TABLE" => PostgresGrantTarget.Table, + "ALLTABLESINSCHEMA" or "ALL_TABLES_IN_SCHEMA" or "ALL TABLES IN SCHEMA" => + PostgresGrantTarget.AllTablesInSchema, + _ => PostgresGrantTarget.Table, + }; +} diff --git a/Migration/Nimblesite.DataProvider.Migration.Core/SchemaYamlSerializer.cs b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaYamlSerializer.cs index ebbf0fc0..1127975b 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Core/SchemaYamlSerializer.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaYamlSerializer.cs @@ -1,68 +1,14 @@ -using System.Globalization; -using YamlDotNet.Core; -using YamlDotNet.Core.Events; -using YamlDotNet.Serialization; -using YamlDotNet.Serialization.NamingConventions; -using YamlDotNet.Serialization.ObjectGraphVisitors; - namespace Nimblesite.DataProvider.Migration.Core; /// /// Serializes and deserializes schema definitions to/from YAML. /// Used for storing schema definitions as portable configuration files. +/// Implements [MIG-AOT-YAML]: read/write are fully hand-written and +/// reflection-free (Native AOT safe) via and +/// . /// public static class SchemaYamlSerializer { - private static readonly ISerializer Serializer = new SerializerBuilder() - .WithNamingConvention(CamelCaseNamingConvention.Instance) - .WithTypeConverter(new PortableTypeYamlConverter()) - .WithTypeConverter(new ForeignKeyActionYamlConverter()) - .WithTypeConverter(new RlsOperationYamlConverter()) - .WithTypeConverter(new PostgresGrantTargetYamlConverter()) - .ConfigureDefaultValuesHandling( - DefaultValuesHandling.OmitDefaults - | DefaultValuesHandling.OmitNull - | DefaultValuesHandling.OmitEmptyCollections - ) - .WithEmissionPhaseObjectGraphVisitor(args => new PropertyDefaultValueFilter( - args.InnerVisitor - )) - .DisableAliases() - .Build(); - - private static readonly IDeserializer Deserializer = new DeserializerBuilder() - .WithNamingConvention(CamelCaseNamingConvention.Instance) - .WithTypeConverter(new PortableTypeYamlConverter()) - .WithTypeConverter(new ForeignKeyActionYamlConverter()) - .WithTypeConverter(new RlsOperationYamlConverter()) - .WithTypeConverter(new PostgresGrantTargetYamlConverter()) - .WithTypeMapping, List>() - .WithTypeMapping, List>() - .WithTypeMapping< - IReadOnlyList, - List - >() - .WithTypeMapping< - IReadOnlyList, - List - >() - .WithTypeMapping, List>() - .WithTypeMapping, List>() - .WithTypeMapping, List>() - .WithTypeMapping, List>() - .WithTypeMapping< - IReadOnlyList, - List - >() - .WithTypeMapping< - IReadOnlyList, - List - >() - .WithTypeMapping, List>() - .WithTypeMapping, List>() - .WithTypeMapping, List>() - .Build(); - /// /// Serialize a schema definition to YAML string. /// @@ -71,7 +17,7 @@ public static class SchemaYamlSerializer public static string ToYaml(SchemaDefinition schema) { ValidateSupportFunctionBodies(schema); - return Serializer.Serialize(schema); + return SchemaYamlWriter.Write(schema); } /// @@ -81,9 +27,7 @@ public static string ToYaml(SchemaDefinition schema) /// Deserialized schema definition. public static SchemaDefinition FromYaml(string yaml) { - var schema = - Deserializer.Deserialize(yaml) - ?? new SchemaDefinition { Name = string.Empty, Tables = [] }; + var schema = SchemaYamlReader.Read(yaml); ValidateSupportFunctionBodies(schema); return schema; } @@ -127,315 +71,3 @@ private static void ValidateSupportFunctionBodies(SchemaDefinition schema) } } } - -/// -/// YAML type converter for PortableType discriminated union. -/// Serializes types as simple strings like "Text", "Int", "VarChar(255)". -/// -internal sealed class PortableTypeYamlConverter : IYamlTypeConverter -{ - /// - public bool Accepts(Type type) => typeof(PortableType).IsAssignableFrom(type); - - /// - public object? ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) - { - var scalar = parser.Consume(); - return ParseType(scalar.Value); - } - - /// - public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) - { - var typeStr = value switch - { - TinyIntType => "TinyInt", - SmallIntType => "SmallInt", - IntType => "Int", - BigIntType => "BigInt", - DecimalType d => $"Decimal({d.Precision},{d.Scale})", - FloatType => "Float", - DoubleType => "Double", - MoneyType => "Money", - SmallMoneyType => "SmallMoney", - BooleanType => "Boolean", - CharType c => $"Char({c.Length})", - VarCharType v => $"VarChar({v.MaxLength})", - NCharType nc => $"NChar({nc.Length})", - NVarCharType nv when nv.MaxLength == int.MaxValue => "NVarChar(max)", - NVarCharType nv => $"NVarChar({nv.MaxLength})", - TextType => "Text", - BinaryType b => $"Binary({b.Length})", - VarBinaryType vb when vb.MaxLength == int.MaxValue => "VarBinary(max)", - VarBinaryType vb => $"VarBinary({vb.MaxLength})", - BlobType => "Blob", - DateType => "Date", - TimeType t when t.Precision == 7 => "Time", - TimeType t => $"Time({t.Precision})", - DateTimeType dt when dt.Precision == 3 => "DateTime", - DateTimeType dt => $"DateTime({dt.Precision})", - DateTimeOffsetType => "DateTimeOffset", - UuidType => "Uuid", - JsonType => "Json", - XmlType => "Xml", - RowVersionType => "RowVersion", - GeometryType g when g.Srid.HasValue => $"Geometry({g.Srid})", - GeometryType => "Geometry", - GeographyType g when g.Srid == 4326 => "Geography", - GeographyType g => $"Geography({g.Srid})", - EnumType e => $"Enum({e.Name}:{string.Join("|", e.Values)})", - VectorType v => $"Vector({v.Dimensions})", - _ => "Text", - }; - emitter.Emit(new Scalar(typeStr)); - } - - private static PortableType ParseType(string typeStr) - { - var trimmed = typeStr.Trim(); - - // Handle parameterized types - var parenIndex = trimmed.IndexOf('(', StringComparison.Ordinal); - if (parenIndex > 0) - { - var typeName = trimmed[..parenIndex]; - var paramsStr = trimmed[(parenIndex + 1)..^1]; - - return typeName.ToUpperInvariant() switch - { - "DECIMAL" => ParseDecimal(paramsStr), - "CHAR" => new CharType(int.Parse(paramsStr, CultureInfo.InvariantCulture)), - "VARCHAR" => new VarCharType(ParseMaxLength(paramsStr)), - "NCHAR" => new NCharType(int.Parse(paramsStr, CultureInfo.InvariantCulture)), - "NVARCHAR" => new NVarCharType(ParseMaxLength(paramsStr)), - "BINARY" => new BinaryType(int.Parse(paramsStr, CultureInfo.InvariantCulture)), - "VARBINARY" => new VarBinaryType(ParseMaxLength(paramsStr)), - "TIME" => new TimeType(int.Parse(paramsStr, CultureInfo.InvariantCulture)), - "DATETIME" => new DateTimeType(int.Parse(paramsStr, CultureInfo.InvariantCulture)), - "GEOMETRY" => new GeometryType(int.Parse(paramsStr, CultureInfo.InvariantCulture)), - "GEOGRAPHY" => new GeographyType( - int.Parse(paramsStr, CultureInfo.InvariantCulture) - ), - "ENUM" => ParseEnum(paramsStr), - "VECTOR" => new VectorType(int.Parse(paramsStr, CultureInfo.InvariantCulture)), - _ => new TextType(), - }; - } - - // Handle simple types - return trimmed.ToUpperInvariant() switch - { - "TINYINT" => new TinyIntType(), - "SMALLINT" => new SmallIntType(), - "INT" or "INTEGER" => new IntType(), - "BIGINT" => new BigIntType(), - "FLOAT" or "REAL" => new FloatType(), - "DOUBLE" => new DoubleType(), - "MONEY" => new MoneyType(), - "SMALLMONEY" => new SmallMoneyType(), - "BOOLEAN" or "BOOL" => new BooleanType(), - "TEXT" => new TextType(), - "BLOB" => new BlobType(), - "DATE" => new DateType(), - "TIME" => new TimeType(), - "DATETIME" => new DateTimeType(), - "DATETIMEOFFSET" => new DateTimeOffsetType(), - "UUID" or "GUID" => new UuidType(), - "JSON" or "JSONB" => new JsonType(), - "XML" => new XmlType(), - "ROWVERSION" or "TIMESTAMP" => new RowVersionType(), - "GEOMETRY" => new GeometryType(null), - "GEOGRAPHY" => new GeographyType(), - _ => new TextType(), - }; - } - - private static int ParseMaxLength(string s) => - s.Equals("max", StringComparison.OrdinalIgnoreCase) - ? int.MaxValue - : int.Parse(s, CultureInfo.InvariantCulture); - - private static DecimalType ParseDecimal(string paramsStr) - { - var parts = paramsStr.Split(','); - return parts.Length == 2 - ? new DecimalType( - int.Parse(parts[0].Trim(), CultureInfo.InvariantCulture), - int.Parse(parts[1].Trim(), CultureInfo.InvariantCulture) - ) - : new DecimalType(int.Parse(parts[0].Trim(), CultureInfo.InvariantCulture), 0); - } - - private static EnumType ParseEnum(string paramsStr) - { - var colonIndex = paramsStr.IndexOf(':', StringComparison.Ordinal); - if (colonIndex > 0) - { - var name = paramsStr[..colonIndex]; - var values = paramsStr[(colonIndex + 1)..].Split('|'); - return new EnumType(name, values); - } - - return new EnumType("enum", paramsStr.Split('|')); - } -} - -/// -/// YAML type converter for ForeignKeyAction enum. -/// -internal sealed class ForeignKeyActionYamlConverter : IYamlTypeConverter -{ - /// - public bool Accepts(Type type) => type == typeof(ForeignKeyAction); - - /// - public object? ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) - { - var scalar = parser.Consume(); - return scalar.Value.ToUpperInvariant() switch - { - "NOACTION" or "NO_ACTION" or "NO ACTION" => ForeignKeyAction.NoAction, - "CASCADE" => ForeignKeyAction.Cascade, - "SETNULL" or "SET_NULL" or "SET NULL" => ForeignKeyAction.SetNull, - "SETDEFAULT" or "SET_DEFAULT" or "SET DEFAULT" => ForeignKeyAction.SetDefault, - "RESTRICT" => ForeignKeyAction.Restrict, - _ => ForeignKeyAction.NoAction, - }; - } - - /// - public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) - { - var action = (ForeignKeyAction)(value ?? ForeignKeyAction.NoAction); - var str = action switch - { - ForeignKeyAction.NoAction => "NoAction", - ForeignKeyAction.Cascade => "Cascade", - ForeignKeyAction.SetNull => "SetNull", - ForeignKeyAction.SetDefault => "SetDefault", - ForeignKeyAction.Restrict => "Restrict", - _ => "NoAction", - }; - emitter.Emit(new Scalar(str)); - } -} - -/// -/// YAML type converter for the enum. Maps -/// to/from a single scalar like All, Select, etc. -/// -internal sealed class RlsOperationYamlConverter : IYamlTypeConverter -{ - /// - public bool Accepts(Type type) => type == typeof(RlsOperation); - - /// - public object? ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) - { - var scalar = parser.Consume(); - return scalar.Value.ToUpperInvariant() switch - { - "ALL" => RlsOperation.All, - "SELECT" => RlsOperation.Select, - "INSERT" => RlsOperation.Insert, - "UPDATE" => RlsOperation.Update, - "DELETE" => RlsOperation.Delete, - _ => RlsOperation.All, - }; - } - - /// - public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) - { - var op = (RlsOperation)(value ?? RlsOperation.All); - emitter.Emit(new Scalar(op.ToString())); - } -} - -/// -/// YAML type converter for the enum. -/// Implements [RLS-PG-SUPPORT-DDL]. -/// -internal sealed class PostgresGrantTargetYamlConverter : IYamlTypeConverter -{ - /// - public bool Accepts(Type type) => type == typeof(PostgresGrantTarget); - - /// - public object? ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) - { - var scalar = parser.Consume(); - return scalar.Value.ToUpperInvariant() switch - { - "SCHEMA" => PostgresGrantTarget.Schema, - "TABLE" => PostgresGrantTarget.Table, - "ALLTABLESINSCHEMA" or "ALL_TABLES_IN_SCHEMA" or "ALL TABLES IN SCHEMA" => - PostgresGrantTarget.AllTablesInSchema, - _ => PostgresGrantTarget.Table, - }; - } - - /// - public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) - { - var target = (PostgresGrantTarget)(value ?? PostgresGrantTarget.Table); - emitter.Emit(new Scalar(target.ToString())); - } -} - -/// -/// Filters out properties that have their semantic default values. -/// This handles cases where the property initializer differs from the type default. -/// -internal sealed class PropertyDefaultValueFilter(IObjectGraphVisitor next) - : ChainedObjectGraphVisitor(next) -{ - /// - /// Default values per property name -> (expected value type, default value). - /// Uses camelCase names (after naming convention applied). - /// - private static readonly Dictionary SemanticDefaults = - new() - { - // ColumnDefinition semantic defaults - { "isNullable", (typeof(bool), true) }, - { "identitySeed", (typeof(long), 1L) }, - { "identityIncrement", (typeof(long), 1L) }, - // TableDefinition and ForeignKeyDefinition semantic defaults - { "schema", (typeof(string), "public") }, - { "referencedSchema", (typeof(string), "public") }, - { "onDelete", (typeof(ForeignKeyAction), ForeignKeyAction.NoAction) }, - { "onUpdate", (typeof(ForeignKeyAction), ForeignKeyAction.NoAction) }, - // RlsPolicySetDefinition / RlsPolicyDefinition semantic defaults - { "enabled", (typeof(bool), true) }, - { "isPermissive", (typeof(bool), true) }, - // PostgreSQL support object semantic defaults - { "language", (typeof(string), "sql") }, - { "volatility", (typeof(string), "stable") }, - { "revokePublicExecute", (typeof(bool), true) }, - }; - - /// - public override bool EnterMapping( - IPropertyDescriptor key, - IObjectDescriptor value, - IEmitter context, - ObjectSerializer serializer - ) - { - if (SemanticDefaults.TryGetValue(key.Name, out var defaultInfo)) - { - // Match by property name and ensure value type matches expectation - if ( - value.Value != null - && defaultInfo.ValueType.IsAssignableFrom(value.Value.GetType()) - && Equals(value.Value, defaultInfo.Default) - ) - { - return false; - } - } - - return base.EnterMapping(key, value, context, serializer); - } -} diff --git a/Migration/Nimblesite.DataProvider.Migration.Core/SchemaYamlWriter.cs b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaYamlWriter.cs new file mode 100644 index 00000000..59dd22aa --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaYamlWriter.cs @@ -0,0 +1,410 @@ +using YamlDotNet.Core; +using YamlDotNet.Core.Events; + +namespace Nimblesite.DataProvider.Migration.Core; + +/// +/// Reflection-free YAML writer for . Implements +/// [MIG-AOT-YAML]: emits via the low-level YamlDotNet + +/// event API (no reflection, Native AOT safe). Key names, ordering, aliases and +/// the set of omitted semantic-default values match the prior reflection-based +/// serializer so existing schema files and tests round-trip unchanged. +/// +internal static class SchemaYamlWriter +{ + internal static string Write(SchemaDefinition schema) + { + using var sw = new StringWriter(System.Globalization.CultureInfo.InvariantCulture); + var emitter = new Emitter(sw); + emitter.Emit(new StreamStart()); + emitter.Emit(new DocumentStart(null, null, isImplicit: true)); + WriteSchema(emitter, schema); + emitter.Emit(new DocumentEnd(isImplicit: true)); + emitter.Emit(new StreamEnd()); + return sw.ToString(); + } + + private static void WriteSchema(Emitter e, SchemaDefinition schema) + { + e.Emit(new MappingStart()); + Key(e, "name"); + Str(e, schema.Name); + + WriteSeq(e, "tables", schema.Tables, WriteTable); + WriteSeq(e, "roles", schema.Roles, WriteRole); + WriteSeq(e, "functions", schema.Functions, WriteFunction); + WriteSeq(e, "grants", schema.Grants, WriteGrant); + e.Emit(new MappingEnd()); + } + + private static void WriteTable(Emitter e, TableDefinition t) + { + e.Emit(new MappingStart()); + Key(e, "name"); + Str(e, t.Name); + if (t.Schema != "public") + { + Key(e, "schema"); + Str(e, t.Schema); + } + WriteSeq(e, "columns", t.Columns, WriteColumn); + WriteSeq(e, "indexes", t.Indexes, WriteIndex); + if (t.PrimaryKey is { } pk) + { + Key(e, "primaryKey"); + WritePrimaryKey(e, pk); + } + WriteSeq(e, "foreignKeys", t.ForeignKeys, WriteForeignKey); + WriteSeq(e, "uniqueConstraints", t.UniqueConstraints, WriteUnique); + WriteSeq(e, "checkConstraints", t.CheckConstraints, WriteCheck); + StrOpt(e, "comment", t.Comment); + if (t.RowLevelSecurity is { } rls) + { + Key(e, "rowLevelSecurity"); + WriteRls(e, rls); + } + e.Emit(new MappingEnd()); + } + + private static void WriteColumn(Emitter e, ColumnDefinition c) + { + e.Emit(new MappingStart()); + Key(e, "name"); + Str(e, c.Name); + Key(e, "type"); + Str(e, SchemaYamlScalars.Encode(c.Type)); + if (!c.IsNullable) + { + Key(e, "isNullable"); + Bool(e, false); + } + StrOpt(e, "defaultValue", c.DefaultValue); + StrOpt(e, "defaultLqlExpression", c.DefaultLqlExpression); + if (c.IsIdentity) + { + Key(e, "isIdentity"); + Bool(e, true); + } + if (c.IdentitySeed != 1) + { + Key(e, "identitySeed"); + Long(e, c.IdentitySeed); + } + if (c.IdentityIncrement != 1) + { + Key(e, "identityIncrement"); + Long(e, c.IdentityIncrement); + } + StrOpt(e, "computedExpression", c.ComputedExpression); + if (c.IsComputedPersisted) + { + Key(e, "isComputedPersisted"); + Bool(e, true); + } + StrOpt(e, "collation", c.Collation); + StrOpt(e, "checkConstraint", c.CheckConstraint); + StrOpt(e, "checkConstraintName", c.CheckConstraintName); + StrOpt(e, "comment", c.Comment); + e.Emit(new MappingEnd()); + } + + private static void WriteIndex(Emitter e, IndexDefinition i) + { + e.Emit(new MappingStart()); + Key(e, "name"); + Str(e, i.Name); + WriteStrSeq(e, "columns", i.Columns); + WriteStrSeq(e, "expressions", i.Expressions); + if (i.IsUnique) + { + Key(e, "isUnique"); + Bool(e, true); + } + StrOpt(e, "filter", i.Filter); + e.Emit(new MappingEnd()); + } + + private static void WritePrimaryKey(Emitter e, PrimaryKeyDefinition pk) + { + e.Emit(new MappingStart()); + StrOpt(e, "name", pk.Name); + WriteStrSeq(e, "columns", pk.Columns); + e.Emit(new MappingEnd()); + } + + private static void WriteForeignKey(Emitter e, ForeignKeyDefinition fk) + { + e.Emit(new MappingStart()); + StrOpt(e, "name", fk.Name); + WriteStrSeq(e, "columns", fk.Columns); + Key(e, "referencedTable"); + Str(e, fk.ReferencedTable); + if (fk.ReferencedSchema != "public") + { + Key(e, "referencedSchema"); + Str(e, fk.ReferencedSchema); + } + WriteStrSeq(e, "referencedColumns", fk.ReferencedColumns); + if (fk.OnDelete != ForeignKeyAction.NoAction) + { + Key(e, "onDelete"); + Str(e, SchemaYamlScalars.Encode(fk.OnDelete)); + } + if (fk.OnUpdate != ForeignKeyAction.NoAction) + { + Key(e, "onUpdate"); + Str(e, SchemaYamlScalars.Encode(fk.OnUpdate)); + } + e.Emit(new MappingEnd()); + } + + private static void WriteUnique(Emitter e, UniqueConstraintDefinition u) + { + e.Emit(new MappingStart()); + StrOpt(e, "name", u.Name); + WriteStrSeq(e, "columns", u.Columns); + e.Emit(new MappingEnd()); + } + + private static void WriteCheck(Emitter e, CheckConstraintDefinition c) + { + e.Emit(new MappingStart()); + Key(e, "name"); + Str(e, c.Name); + Key(e, "expression"); + Str(e, c.Expression); + e.Emit(new MappingEnd()); + } + + private static void WriteRole(Emitter e, PostgresRoleDefinition r) + { + e.Emit(new MappingStart()); + Key(e, "name"); + Str(e, r.Name); + if (r.Login) + { + Key(e, "login"); + Bool(e, true); + } + if (r.BypassRls) + { + Key(e, "bypassRls"); + Bool(e, true); + } + WriteStrSeq(e, "grantTo", r.GrantTo); + e.Emit(new MappingEnd()); + } + + private static void WriteFunction(Emitter e, PostgresFunctionDefinition f) + { + e.Emit(new MappingStart()); + if (f.Schema != "public") + { + Key(e, "schema"); + Str(e, f.Schema); + } + Key(e, "name"); + Str(e, f.Name); + WriteSeq(e, "arguments", f.Arguments, WriteFunctionArg); + if (f.Returns != "void") + { + Key(e, "returns"); + Str(e, f.Returns); + } + if (f.Language != "sql") + { + Key(e, "language"); + Str(e, f.Language); + } + if (f.Volatility != "stable") + { + Key(e, "volatility"); + Str(e, f.Volatility); + } + if (f.SecurityDefiner) + { + Key(e, "securityDefiner"); + Bool(e, true); + } + if (!string.IsNullOrEmpty(f.Body)) + { + Key(e, "body"); + Str(e, f.Body); + } + StrOpt(e, "bodyLql", f.BodyLql); + WriteStrSeq(e, "executeRoles", f.ExecuteRoles); + if (!f.RevokePublicExecute) + { + Key(e, "revokePublicExecute"); + Bool(e, false); + } + e.Emit(new MappingEnd()); + } + + private static void WriteFunctionArg(Emitter e, PostgresFunctionArgumentDefinition a) + { + e.Emit(new MappingStart()); + if (!string.IsNullOrEmpty(a.Name)) + { + Key(e, "name"); + Str(e, a.Name); + } + Key(e, "type"); + Str(e, a.Type); + e.Emit(new MappingEnd()); + } + + private static void WriteGrant(Emitter e, PostgresGrantDefinition g) + { + e.Emit(new MappingStart()); + if (g.Schema != "public") + { + Key(e, "schema"); + Str(e, g.Schema); + } + if (g.Target != PostgresGrantTarget.Table) + { + Key(e, "target"); + Str(e, SchemaYamlScalars.Encode(g.Target)); + } + StrOpt(e, "objectName", g.ObjectName); + WriteStrSeq(e, "privileges", g.Privileges); + WriteStrSeq(e, "roles", g.Roles); + StrOpt(e, "runAs", g.RunAs); + e.Emit(new MappingEnd()); + } + + private static void WriteRls(Emitter e, RlsPolicySetDefinition rls) + { + e.Emit(new MappingStart()); + // enabled defaults true and is omitted at that default (only false is + // written); forced defaults false and is written only when true. + if (!rls.Enabled) + { + Key(e, "enabled"); + Bool(e, false); + } + WriteSeq(e, "policies", rls.Policies, WritePolicy); + if (rls.Forced) + { + Key(e, "forced"); + Bool(e, true); + } + e.Emit(new MappingEnd()); + } + + private static void WritePolicy(Emitter e, RlsPolicyDefinition p) + { + e.Emit(new MappingStart()); + Key(e, "name"); + Str(e, p.Name); + // permissive defaults true and is omitted at that default; only a + // restrictive (false) policy writes the key. + if (!p.IsPermissive) + { + Key(e, "permissive"); + Bool(e, false); + } + if (!(p.Operations.Count == 1 && p.Operations[0] == RlsOperation.All)) + { + Key(e, "operations"); + e.Emit(new SequenceStart(null, null, isImplicit: true, SequenceStyle.Block)); + foreach (var op in p.Operations) + { + Str(e, SchemaYamlScalars.Encode(op)); + } + e.Emit(new SequenceEnd()); + } + WriteStrSeq(e, "roles", p.Roles); + StrOpt(e, "using", p.UsingLql); + StrOpt(e, "withCheck", p.WithCheckLql); + StrOpt(e, "usingSql", p.UsingSql); + StrOpt(e, "withCheckSql", p.WithCheckSql); + e.Emit(new MappingEnd()); + } + + // ── Primitive emit helpers ─────────────────────────────────────────── + + private static void Key(Emitter e, string name) => Str(e, name); + + private static void Str(Emitter e, string value) + { + // Force literal block style for multi-line values so newlines round-trip + // verbatim and stay readable (LQL/SQL bodies); let the emitter choose for + // single-line values. + var style = value.Contains('\n', StringComparison.Ordinal) + ? ScalarStyle.Literal + : ScalarStyle.Any; + e.Emit( + new Scalar( + AnchorName.Empty, + TagName.Empty, + value, + style, + isPlainImplicit: true, + isQuotedImplicit: true + ) + ); + } + + private static void Bool(Emitter e, bool value) => Plain(e, value ? "true" : "false"); + + private static void Long(Emitter e, long value) => + Plain(e, value.ToString(System.Globalization.CultureInfo.InvariantCulture)); + + private static void Plain(Emitter e, string value) => + e.Emit( + new Scalar( + AnchorName.Empty, + TagName.Empty, + value, + ScalarStyle.Plain, + isPlainImplicit: true, + isQuotedImplicit: false + ) + ); + + private static void StrOpt(Emitter e, string key, string? value) + { + if (!string.IsNullOrEmpty(value)) + { + Key(e, key); + Str(e, value); + } + } + + private static void WriteSeq( + Emitter e, + string key, + IReadOnlyList items, + Action writeItem + ) + { + if (items.Count == 0) + { + return; + } + Key(e, key); + e.Emit(new SequenceStart(null, null, isImplicit: true, SequenceStyle.Block)); + foreach (var item in items) + { + writeItem(e, item); + } + e.Emit(new SequenceEnd()); + } + + private static void WriteStrSeq(Emitter e, string key, IReadOnlyList items) + { + if (items.Count == 0) + { + return; + } + Key(e, key); + e.Emit(new SequenceStart(null, null, isImplicit: true, SequenceStyle.Block)); + foreach (var item in items) + { + Str(e, item); + } + e.Emit(new SequenceEnd()); + } +} diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/NativeAotMigrateSmokeTests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/NativeAotMigrateSmokeTests.cs new file mode 100644 index 00000000..d058e768 --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/NativeAotMigrateSmokeTests.cs @@ -0,0 +1,179 @@ +using System.Diagnostics; + +namespace Nimblesite.DataProvider.Migration.Tests; + +/// +/// Implements [MIG-AOT-TEST]. Drives the PUBLISHED Native AOT binary as a +/// subprocess — the only thing that proves the native executable actually runs +/// (in-process Program.Main tests exercise the managed assembly, not the +/// AOT image). The binary path comes from the DATAPROVIDERMIGRATE_AOT_BIN +/// environment variable, which the AOT publish + CI set. When it is absent the +/// facts skip, so make test stays fast and self-contained while the CI +/// native job runs the real thing. +/// +public sealed class NativeAotMigrateSmokeTests +{ + private const string BinEnvVar = "DATAPROVIDERMIGRATE_AOT_BIN"; + + private static string? NativeBinaryPath() + { + var path = Environment.GetEnvironmentVariable(BinEnvVar); + return string.IsNullOrWhiteSpace(path) || !File.Exists(path) ? null : path; + } + + // Uses only SQLite-affinity-stable types (Text). VARCHAR/INT collapse to + // SQLite's TEXT/INTEGER affinity on inspection, which the integrity verifier + // would (correctly) flag as drift — that is a SQLite behaviour, not an AOT + // concern, so the smoke schema avoids it to keep the test focused on the + // native binary running the full pipeline. + private const string SmokeSchema = """ + name: aot_smoke + tables: + - name: Widget + columns: + - name: Id + type: Text + isNullable: false + - name: Label + type: Text + isNullable: false + - name: Quantity + type: Text + isNullable: false + primaryKey: + columns: + - Id + indexes: + - name: idx_widget_label + columns: + - Label + isUnique: true + """; + + [SkippableFact] + public void NativeBinary_MigratesSqliteSchema_ExitZero() + { + var bin = NativeBinaryPath(); + Skip.If(bin is null, $"{BinEnvVar} not set — native AOT binary not published."); + + var (schemaPath, dbPath) = WriteFixtures(); + try + { + var result = Run(bin!, schemaPath, dbPath); + + Assert.True(result.ExitCode == 0, userMessage: result.Output); + Assert.True(File.Exists(dbPath), userMessage: "SQLite database file was not created."); + Assert.Contains( + "Schema integrity check passed", + result.Output, + StringComparison.Ordinal + ); + } + finally + { + Cleanup(schemaPath, dbPath); + } + } + + [SkippableFact] + public void NativeBinary_RerunIsIdempotent_NoOperations() + { + var bin = NativeBinaryPath(); + Skip.If(bin is null, $"{BinEnvVar} not set — native AOT binary not published."); + + var (schemaPath, dbPath) = WriteFixtures(); + try + { + var first = Run(bin!, schemaPath, dbPath); + Assert.True(first.ExitCode == 0, userMessage: first.Output); + + var second = Run(bin!, schemaPath, dbPath); + Assert.True(second.ExitCode == 0, userMessage: second.Output); + Assert.Contains("Schema is up to date", second.Output, StringComparison.Ordinal); + } + finally + { + Cleanup(schemaPath, dbPath); + } + } + + [SkippableFact] + public void NativeBinary_ExportCommand_ReportsUnsupported() + { + var bin = NativeBinaryPath(); + Skip.If(bin is null, $"{BinEnvVar} not set — native AOT binary not published."); + + // Implements [MIG-AOT-EXPORT]: export is compiled out of the native build. + var result = RunRaw( + bin!, + ["export", "--assembly", "x.dll", "--type", "T", "--output", "o.yaml"] + ); + + Assert.True(result.ExitCode != 0, userMessage: result.Output); + Assert.Contains("not available in the native", result.Output, StringComparison.Ordinal); + } + + private static (string SchemaPath, string DbPath) WriteFixtures() + { + var id = Guid.NewGuid().ToString("N"); + var schemaPath = Path.Combine(Path.GetTempPath(), $"aot-smoke-{id}.yaml"); + var dbPath = Path.Combine(Path.GetTempPath(), $"aot-smoke-{id}.db"); + File.WriteAllText(schemaPath, SmokeSchema); + return (schemaPath, dbPath); + } + + private static (int ExitCode, string Output) Run( + string bin, + string schemaPath, + string dbPath + ) => + RunRaw( + bin, + ["migrate", "--schema", schemaPath, "--output", dbPath, "--provider", "sqlite"] + ); + + private static (int ExitCode, string Output) RunRaw(string bin, string[] args) + { + var startInfo = new ProcessStartInfo + { + FileName = bin, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + foreach (var arg in args) + { + startInfo.ArgumentList.Add(arg); + } + + using var process = + Process.Start(startInfo) + ?? throw new InvalidOperationException($"Failed to start {bin}"); + + var stdout = process.StandardOutput.ReadToEnd(); + var stderr = process.StandardError.ReadToEnd(); + + // 60s ceiling keeps a hung native process from stalling the suite; a + // timeout is a failure per the project testing rules. + Assert.True( + process.WaitForExit(milliseconds: 60_000), + userMessage: "Native binary did not exit within 60s." + ); + + return (process.ExitCode, string.Concat(stdout, stderr)); + } + + private static void Cleanup(string schemaPath, string dbPath) + { + TryDelete(schemaPath); + TryDelete(dbPath); + } + + private static void TryDelete(string path) + { + if (File.Exists(path)) + { + File.Delete(path); + } + } +} diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/Nimblesite.DataProvider.Migration.Tests.csproj b/Migration/Nimblesite.DataProvider.Migration.Tests/Nimblesite.DataProvider.Migration.Tests.csproj index 05d5d035..a14dbf34 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Tests/Nimblesite.DataProvider.Migration.Tests.csproj +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/Nimblesite.DataProvider.Migration.Tests.csproj @@ -10,6 +10,9 @@ + + all runtime; build; native; contentfiles; analyzers diff --git a/Other/Nimblesite.Sql.Model/ConstantExpressionEvaluator.cs b/Other/Nimblesite.Sql.Model/ConstantExpressionEvaluator.cs new file mode 100644 index 00000000..31732e48 --- /dev/null +++ b/Other/Nimblesite.Sql.Model/ConstantExpressionEvaluator.cs @@ -0,0 +1,55 @@ +using System.Linq.Expressions; +using System.Reflection; + +namespace Nimblesite.Sql.Model; + +/// +/// Evaluates the constant value of a LINQ expression subtree without using +/// . Implements [MIG-AOT-DYNCODE]: +/// Expression.Lambda(expr).Compile().DynamicInvoke() carries +/// +/// (IL3050) and breaks Native AOT. The cases that occur when translating a LINQ +/// predicate to SQL — literals, captured locals/fields, static fields/properties, +/// and member chains over those — are all walkable without emitting code. +/// +internal static class ConstantExpressionEvaluator +{ + /// + /// Returns the evaluated value of , or + /// null when the subtree cannot be reduced to a value without dynamic + /// code (matching the prior catch fallback behaviour). + /// + internal static object? TryEvaluate(Expression expression) => + expression switch + { + ConstantExpression constant => constant.Value, + MemberExpression member => EvaluateMember(member), + UnaryExpression { NodeType: ExpressionType.Convert } convert => TryEvaluate( + convert.Operand + ), + UnaryExpression { NodeType: ExpressionType.ConvertChecked } convert => TryEvaluate( + convert.Operand + ), + _ => null, + }; + + private static object? EvaluateMember(MemberExpression member) + { + // A static member (member.Expression is null) or a member over an already + // reducible subtree (a captured closure constant, a nested field, etc.). + var instance = member.Expression is null ? null : TryEvaluate(member.Expression); + + if (member.Expression is not null && instance is null) + { + return null; + } + + return member.Member switch + { + FieldInfo field => field.GetValue(instance), + PropertyInfo property when property.GetIndexParameters().Length == 0 => + property.GetValue(instance), + _ => null, + }; + } +} diff --git a/Other/Nimblesite.Sql.Model/SelectStatementLinqExtensions.cs b/Other/Nimblesite.Sql.Model/SelectStatementLinqExtensions.cs index 9662ed54..122ef21a 100644 --- a/Other/Nimblesite.Sql.Model/SelectStatementLinqExtensions.cs +++ b/Other/Nimblesite.Sql.Model/SelectStatementLinqExtensions.cs @@ -419,25 +419,10 @@ List conditions _ => null, }; - private static object? ExtractValue(Expression expr) - { - try - { - var lambda = Expression.Lambda(expr); - var compiled = lambda.Compile(); - return compiled.DynamicInvoke(); - } - catch (InvalidOperationException) - { - // Expected - expression cannot be evaluated at compile time - return default; - } - catch (ArgumentException) - { - // Expected - invalid expression structure - return default; - } - } + // Implements [MIG-AOT-DYNCODE]: walk the subtree to a constant instead of + // Expression.Compile().DynamicInvoke() (IL3050, breaks Native AOT). + private static object? ExtractValue(Expression expr) => + ConstantExpressionEvaluator.TryEvaluate(expr); private static string FormatValue(object? value) => value switch diff --git a/Other/Nimblesite.Sql.Model/SelectStatementVisitor.cs b/Other/Nimblesite.Sql.Model/SelectStatementVisitor.cs index 32b0164c..08be1080 100644 --- a/Other/Nimblesite.Sql.Model/SelectStatementVisitor.cs +++ b/Other/Nimblesite.Sql.Model/SelectStatementVisitor.cs @@ -460,27 +460,10 @@ private static IEnumerable ExtractColumns(Expression expression) => _ => null, }; - private static object? ExtractValue(Expression expression) - { - try - { - return expression switch - { - ConstantExpression constant => constant.Value, - _ => Expression.Lambda(expression).Compile().DynamicInvoke(), - }; - } - catch (InvalidOperationException) - { - // Expected - expression cannot be evaluated at compile time - return default; - } - catch (ArgumentException) - { - // Expected - invalid expression structure - return default; - } - } + // Implements [MIG-AOT-DYNCODE]: walk the subtree to a constant instead of + // Expression.Compile().DynamicInvoke() (IL3050, breaks Native AOT). + private static object? ExtractValue(Expression expression) => + ConstantExpressionEvaluator.TryEvaluate(expression); private static string FormatValue(object? value) => value switch diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..fe42c553 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,43 @@ + +# Security Policy + +GitHub surfaces this policy on the repository's **Security** tab and on the +"Report a vulnerability" page. References: +- Add a security policy: https://docs.github.com/en/code-security/how-tos/report-and-fix-vulnerabilities/configure-vulnerability-reporting/add-security-policy +- Configure private vulnerability reporting: https://docs.github.com/en/code-security/how-tos/report-and-fix-vulnerabilities/configure-vulnerability-reporting/configure-for-a-repository + +## Reporting a Vulnerability + +**Please do not report security vulnerabilities through public GitHub issues, +discussions, or pull requests.** + +Report privately through GitHub's **private vulnerability reporting**: go to the +repository's **Security** tab → **Report a vulnerability** (or +). This opens +a private, structured advisory only the maintainers can see. + +If you cannot use that channel, email **cftools@nimblesite.co**. + +When reporting, please include: + +- The type of issue (e.g. SQL injection, path traversal, auth bypass, secret exposure). +- The affected version(s), component (DataProvider, LQL, Migration, Sync, Gatekeeper, Reporting), file(s), and any relevant configuration. +- Steps to reproduce, ideally a minimal proof of concept. +- The impact: what an attacker can achieve. + +## What to Expect + +- **Acknowledgement** within **3 business days**. +- An assessment and a remediation plan (or a reasoned decline) within **10 business days**. +- Coordinated disclosure: we will agree a disclosure timeline with you and credit + you in the advisory unless you prefer to remain anonymous. + +## Supported Versions + +DataProvider is currently in **beta**. Security fixes land on the latest released +`0.9.x` beta line; earlier prereleases are not supported. + +| Version | Supported | +| ------------- | --------- | +| 0.9.x (beta) | ✅ | +| < 0.9 (beta) | ❌ | diff --git a/Website/eleventy.config.js b/Website/eleventy.config.js index 0b34c349..00873d68 100644 --- a/Website/eleventy.config.js +++ b/Website/eleventy.config.js @@ -20,6 +20,11 @@ export default function(eleventyConfig) { const md = markdownIt(mdOptions).use(markdownItAnchor, mdAnchorOptions); eleventyConfig.setLibrary("md", md); + // Release notes carry untrusted PR titles/handles — render with raw HTML OFF. + // Implements [WEB-RELEASES]. + const releaseMd = markdownIt({ html: false, linkify: true, breaks: false }); + eleventyConfig.addFilter("releaseNotes", (body) => (body ? releaseMd.render(body) : "")); + eleventyConfig.addPlugin(syntaxHighlight); eleventyConfig.addPlugin(pluginRss); eleventyConfig.addPlugin(eleventyNavigationPlugin); diff --git a/Website/package-lock.json b/Website/package-lock.json index b0759c52..c1488afd 100644 --- a/Website/package-lock.json +++ b/Website/package-lock.json @@ -11,20 +11,21 @@ "js-yaml": "^4.1.0" }, "devDependencies": { - "@11ty/eleventy": "^3.1.2", - "@11ty/eleventy-navigation": "^0.3.5", - "@11ty/eleventy-plugin-rss": "^2.0.2", - "@11ty/eleventy-plugin-syntaxhighlight": "^5.0.0", + "@11ty/eleventy": "^3.1.6", + "@11ty/eleventy-navigation": "^1.0.5", + "@11ty/eleventy-plugin-rss": "^3.0.0", + "@11ty/eleventy-plugin-syntaxhighlight": "^5.0.2", "@playwright/test": "^1.47.0", "@types/node": "^25.6.0", "http-server": "^14.1.1", + "markdown-it": "^14.2.0", "markdown-it-anchor": "^9.2.0" } }, "node_modules/@11ty/dependency-tree": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@11ty/dependency-tree/-/dependency-tree-4.0.1.tgz", - "integrity": "sha512-6EPI9ZkGU4BX2KNZpWlf4WdV3vrmIWQpn//nAXicTzdPubI3jZlmFdqEv0Yj5M7oavRUGNzw9GbV9cBxhulZWw==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@11ty/dependency-tree/-/dependency-tree-4.0.2.tgz", + "integrity": "sha512-RTF6VTZHatYf7fSZBUN3RKwiUeJh5dhWV61gDPrHhQF2/gzruAkYz8yXuvGLx3w3ZBKreGrR+MfYpSVkdbdbLA==", "dev": true, "license": "MIT", "dependencies": { @@ -45,45 +46,45 @@ } }, "node_modules/@11ty/eleventy": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@11ty/eleventy/-/eleventy-3.1.2.tgz", - "integrity": "sha512-IcsDlbXnBf8cHzbM1YBv3JcTyLB35EK88QexmVyFdVJVgUU6bh9g687rpxryJirHzo06PuwnYaEEdVZQfIgRGg==", + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/@11ty/eleventy/-/eleventy-3.1.6.tgz", + "integrity": "sha512-ZlSiR1PLdS2lv7TelBgWAhcvMiLNZkPBlLEb+lh7kGYZ+Mk0bo9qcYgVsewvw9W7Em0RH3wd01h5fAstNDh0zA==", "dev": true, "license": "MIT", "dependencies": { - "@11ty/dependency-tree": "^4.0.0", - "@11ty/dependency-tree-esm": "^2.0.0", + "@11ty/dependency-tree": "^4.0.2", + "@11ty/dependency-tree-esm": "^2.0.4", "@11ty/eleventy-dev-server": "^2.0.8", - "@11ty/eleventy-plugin-bundle": "^3.0.6", + "@11ty/eleventy-plugin-bundle": "^3.0.7", "@11ty/eleventy-utils": "^2.0.7", "@11ty/lodash-custom": "^4.17.21", - "@11ty/posthtml-urls": "^1.0.1", - "@11ty/recursive-copy": "^4.0.2", + "@11ty/posthtml-urls": "^1.0.3", + "@11ty/recursive-copy": "^4.0.4", "@sindresorhus/slugify": "^2.2.1", "bcp-47-normalize": "^2.3.0", "chokidar": "^3.6.0", - "debug": "^4.4.1", + "debug": "^4.4.3", "dependency-graph": "^1.0.0", "entities": "^6.0.1", "filesize": "^10.1.6", "gray-matter": "^4.0.3", "iso-639-1": "^3.1.5", - "js-yaml": "^4.1.0", + "js-yaml": "^4.1.1", "kleur": "^4.1.5", - "liquidjs": "^10.21.1", - "luxon": "^3.6.1", - "markdown-it": "^14.1.0", + "liquidjs": "^10.27.0", + "luxon": "^3.7.2", + "markdown-it": "^14.2.0", "minimist": "^1.2.8", - "moo": "^0.5.2", + "moo": "0.5.2", "node-retrieve-globals": "^6.0.1", "nunjucks": "^3.2.4", - "picomatch": "^4.0.2", + "picomatch": "^4.0.4", "please-upgrade-node": "^3.2.0", - "posthtml": "^0.16.6", + "posthtml": "^0.16.7", "posthtml-match-helper": "^2.0.3", - "semver": "^7.7.2", - "slugify": "^1.6.6", - "tinyglobby": "^0.2.14" + "semver": "^7.8.1", + "slugify": "^1.6.9", + "tinyglobby": "^0.2.16" }, "bin": { "eleventy": "cmd.cjs" @@ -128,29 +129,19 @@ } }, "node_modules/@11ty/eleventy-navigation": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@11ty/eleventy-navigation/-/eleventy-navigation-0.3.5.tgz", - "integrity": "sha512-4aKW5aIQDFed8xs1G1pWcEiFPcDSwZtA4IH1eERtoJ+Xy+/fsoe0pzbDmw84bHZ9ACny5jblENhfZhcCxklqQw==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@11ty/eleventy-navigation/-/eleventy-navigation-1.0.5.tgz", + "integrity": "sha512-zb6xe29cM9viSdYtZywKIkJw2HIROyBINdBcFWC9uD0c/jYOTAex5nwy3HNEuh5t6/Ld/S9V4gEizfmeYuYpCQ==", "dev": true, "license": "MIT", "dependencies": { - "dependency-graph": "^0.11.0" + "dependency-graph": "^1.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/11ty" } }, - "node_modules/@11ty/eleventy-navigation/node_modules/dependency-graph": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-0.11.0.tgz", - "integrity": "sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6.0" - } - }, "node_modules/@11ty/eleventy-plugin-bundle": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/@11ty/eleventy-plugin-bundle/-/eleventy-plugin-bundle-3.0.7.tgz", @@ -171,16 +162,16 @@ } }, "node_modules/@11ty/eleventy-plugin-rss": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@11ty/eleventy-plugin-rss/-/eleventy-plugin-rss-2.0.4.tgz", - "integrity": "sha512-LF60sGVlxGTryQe3hTifuzrwF8R7XbrNsM2xfcDcNMSliLN4kmB+7zvoLRySRx0AQDjqhPTAeeeT0ra6/9zHUQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@11ty/eleventy-plugin-rss/-/eleventy-plugin-rss-3.0.0.tgz", + "integrity": "sha512-kKW4DcR57xAyRx0e8gNhKh56ahHVEaAj8/TuXQDnw+B46ig2bWADJAlyj/GdV37IG5ja9dZ4SgKZrs/CHz6YWQ==", "dev": true, "license": "MIT", "dependencies": { - "@11ty/eleventy-utils": "^2.0.0", - "@11ty/posthtml-urls": "^1.0.1", - "debug": "^4.4.0", - "posthtml": "^0.16.6" + "@11ty/eleventy-utils": "^2.0.7", + "@11ty/posthtml-urls": "^1.0.2", + "debug": "^4.4.3", + "posthtml": "^0.16.7" }, "funding": { "type": "opencollective", @@ -230,9 +221,9 @@ } }, "node_modules/@11ty/posthtml-urls": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@11ty/posthtml-urls/-/posthtml-urls-1.0.2.tgz", - "integrity": "sha512-0vaV3Wt0surZ+oS1VdKKe0axeeupuM+l7W/Z866WFQwF+dGg2Tc/nmhk/5l74/Y55P8KyImnLN9CdygNw2huHg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@11ty/posthtml-urls/-/posthtml-urls-1.0.3.tgz", + "integrity": "sha512-1YvhnkaNlFnnJic1rBMWmTC2adbuy+JQiBfl1Hecr1Wjjik1pQZmGyk/eC9zKX/FQv52s2Nht1Gi/UwhYqrBeg==", "dev": true, "license": "MIT", "dependencies": { @@ -246,15 +237,15 @@ } }, "node_modules/@11ty/recursive-copy": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@11ty/recursive-copy/-/recursive-copy-4.0.3.tgz", - "integrity": "sha512-SX48BTLEGX8T/OsKWORsHAAeiDsbFl79Oa/0Wg/mv/d27b7trCVZs7fMHvpSgDvZz/fZqx5rDk8+nx5oyT7xBw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@11ty/recursive-copy/-/recursive-copy-4.0.4.tgz", + "integrity": "sha512-oI7m8pa7/IAU/3lqRU9vjBbs20iKFo7x+1K9kT3aVira6scc1X9MjBdgLCHzLJeJ7iB6wydioA+kr9/qPnvmlQ==", "dev": true, "license": "ISC", "dependencies": { "errno": "^1.0.0", "junk": "^3.1.0", - "maximatch": "^0.1.0", + "minimatch": "^3.1.5", "slash": "^3.0.0" }, "engines": { @@ -430,49 +421,6 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, - "node_modules/array-differ": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/array-differ/-/array-differ-1.0.0.tgz", - "integrity": "sha512-LeZY+DZDRnvP7eMuQ6LHfCzUGxAAIViUBliK24P3hWXL6y4SortgR6Nim6xrkfSLlmH0+k+9NYNwVC2s53ZrYQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/array-union": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", - "integrity": "sha512-Dxr6QJj/RdU/hCaBjOfxW+q6lyuVE6JFWIrAUpuOOhoJJoQ99cUn3igRaHVB5P9WrgFVN0FfArM3x0cueOU8ng==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-uniq": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/array-uniq": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz", - "integrity": "sha512-MNha4BWQ6JbwhFhj03YK552f7cb3AzoE8SzeljgChvL1dl3IcvggXVz1DilzySZkCja+CXuZbdW7yATchWn8/Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/arrify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", - "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", @@ -563,9 +511,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, "license": "MIT", "dependencies": { @@ -1578,19 +1526,29 @@ } }, "node_modules/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz", + "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], "license": "MIT", "dependencies": { "uc.micro": "^2.0.0" } }, "node_modules/liquidjs": { - "version": "10.24.0", - "resolved": "https://registry.npmjs.org/liquidjs/-/liquidjs-10.24.0.tgz", - "integrity": "sha512-TAUNAdgwaAXjjcUFuYVJm9kOVH7zc0mTKxsG9t9Lu4qdWjB2BEblyVIYpjWcmJLMGgiYqnGNJjpNMHx0gp/46A==", + "version": "10.27.0", + "resolved": "https://registry.npmjs.org/liquidjs/-/liquidjs-10.27.0.tgz", + "integrity": "sha512-tw/OA59K7aIBlMKIrKlumr37fiZUheShVHXY8cVctWisgY1p9mc5hreOvlreoS0wTiwlWk14Ya7305c2a/Cg5w==", "dev": true, "license": "MIT", "dependencies": { @@ -1626,15 +1584,25 @@ } }, "node_modules/markdown-it": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", - "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz", + "integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", - "linkify-it": "^5.0.0", + "linkify-it": "^5.0.1", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" @@ -1677,22 +1645,6 @@ "node": ">= 0.4" } }, - "node_modules/maximatch": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/maximatch/-/maximatch-0.1.0.tgz", - "integrity": "sha512-9ORVtDUFk4u/NFfo0vG/ND/z7UQCVZBL539YW0+U1I7H1BkZwizcPx5foFv7LCPcBnm2U6RjFnQOsIvN4/Vm2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-differ": "^1.0.0", - "array-union": "^1.0.1", - "arrify": "^1.0.0", - "minimatch": "^3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/mdurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", @@ -1741,9 +1693,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -1906,9 +1858,9 @@ } }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -2164,9 +2116,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", "dev": true, "license": "ISC", "bin": { @@ -2304,9 +2256,9 @@ } }, "node_modules/slugify": { - "version": "1.6.6", - "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.6.tgz", - "integrity": "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==", + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz", + "integrity": "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==", "dev": true, "license": "MIT", "engines": { @@ -2367,14 +2319,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" diff --git a/Website/package.json b/Website/package.json index daa32030..ff6e753a 100644 --- a/Website/package.json +++ b/Website/package.json @@ -12,13 +12,14 @@ "test:e2e:install": "playwright install chromium --with-deps" }, "devDependencies": { - "@11ty/eleventy": "^3.1.2", - "@11ty/eleventy-navigation": "^0.3.5", - "@11ty/eleventy-plugin-rss": "^2.0.2", - "@11ty/eleventy-plugin-syntaxhighlight": "^5.0.0", + "@11ty/eleventy": "^3.1.6", + "@11ty/eleventy-navigation": "^1.0.5", + "@11ty/eleventy-plugin-rss": "^3.0.0", + "@11ty/eleventy-plugin-syntaxhighlight": "^5.0.2", "@playwright/test": "^1.47.0", "@types/node": "^25.6.0", "http-server": "^14.1.1", + "markdown-it": "^14.2.0", "markdown-it-anchor": "^9.2.0" }, "dependencies": { diff --git a/Website/src/_data/navigation.json b/Website/src/_data/navigation.json index 39f5c36e..bf39b17e 100644 --- a/Website/src/_data/navigation.json +++ b/Website/src/_data/navigation.json @@ -3,6 +3,7 @@ { "text": "Docs", "url": "/docs/" }, { "text": "API", "url": "/apidocs/" }, { "text": "Blog", "url": "/blog/" }, + { "text": "Releases", "url": "/releases/" }, { "text": "GitHub", "url": "https://github.com/Nimblesite/DataProvider", "external": true } ], "docs": [ @@ -58,6 +59,7 @@ "title": "More", "items": [ { "text": "Blog", "url": "/blog/" }, + { "text": "Releases", "url": "/releases/" }, { "text": "About", "url": "/about/" } ] } diff --git a/Website/src/_data/release.js b/Website/src/_data/release.js new file mode 100644 index 00000000..024e6caa --- /dev/null +++ b/Website/src/_data/release.js @@ -0,0 +1,102 @@ +// agent-pmo:795a9c2 +// Build-time GitHub Releases data file. Implements [WEB-RELEASES]. +// +// Eleventy runs this during the static build and bakes the result into the +// /releases/ page — the browser makes ZERO network calls. Notes are rendered +// by the `releaseNotes` filter in eleventy.config.js (markdown-it, html:false), +// because release bodies carry untrusted PR titles/handles. + +const REPO = "Nimblesite/DataProvider"; +const MAX_RELEASES = 30; +const RECENT_COUNT = 4; +const API_URL = `https://api.github.com/repos/${REPO}/releases?per_page=${MAX_RELEASES}`; +const RELEASES_URL = `https://github.com/${REPO}/releases`; + +function fallback(reason) { + if (reason) console.warn(`[_data/release] using fallback — ${reason}`); + return { + available: false, + tag: null, + version: null, + url: RELEASES_URL, + releasesUrl: RELEASES_URL, + publishedAt: null, + publishedDate: null, + items: [], + recent: [], + }; +} + +function versionFromTag(tag) { + if (!tag) return null; + return tag.startsWith("v") ? tag.slice(1) : tag; +} + +function formatDate(value) { + if (!value) return null; + return new Intl.DateTimeFormat("en", { dateStyle: "medium", timeZone: "UTC" }).format( + new Date(value), + ); +} + +function mapRelease(data) { + const tag = data.tag_name; + const publishedAt = data.published_at || null; + return { + tag, + version: versionFromTag(tag), + title: data.name || tag, + url: data.html_url || RELEASES_URL, + publishedAt, + publishedDate: formatDate(publishedAt), + prerelease: Boolean(data.prerelease), + body: data.body || "", + }; +} + +export default async function () { + if (process.env.SKIP_RELEASE_FETCH === "1") { + return fallback("SKIP_RELEASE_FETCH=1"); + } + + const headers = { + "User-Agent": "DataProvider-website-build", + Accept: "application/vnd.github+json", + }; + const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 8000); + const response = await fetch(API_URL, { headers, signal: controller.signal }); + clearTimeout(timeoutId); + + if (!response.ok) { + return fallback(`GitHub API responded ${response.status}`); + } + + const data = await response.json(); + const items = data.filter((item) => !item.draft).map(mapRelease); + const latest = items.find((item) => !item.prerelease) || items[0]; + if (!latest) { + return fallback("response missing releases"); + } + + return { + available: true, + tag: latest.tag, + version: latest.version, + url: latest.url, + releasesUrl: RELEASES_URL, + publishedAt: latest.publishedAt, + publishedDate: latest.publishedDate, + items, + recent: items.slice(0, RECENT_COUNT), + }; + } catch (err) { + return fallback(err.message); + } +} diff --git a/Website/src/_includes/layouts/base.njk b/Website/src/_includes/layouts/base.njk index 709e4992..e25f1738 100644 --- a/Website/src/_includes/layouts/base.njk +++ b/Website/src/_includes/layouts/base.njk @@ -113,7 +113,7 @@ diff --git a/Website/src/releases.njk b/Website/src/releases.njk new file mode 100644 index 00000000..096b02d0 --- /dev/null +++ b/Website/src/releases.njk @@ -0,0 +1,40 @@ +--- +layout: layouts/base.njk +title: "Releases — DataProvider" +description: "Every DataProvider release with full release notes, generated at build time from the GitHub Releases for DataProvider." +permalink: /releases/ +--- + +{# + agent-pmo:795a9c2 + /releases/ page. Implements [WEB-RELEASES]. + Renders the build-time `release` data (src/_data/release.js). + Notes are rendered with the `releaseNotes` filter (markdown-it, html:false). +#} + +
    +
    +

    Releases

    +

    Every DataProvider release with full notes — generated at build time from the GitHub Releases.

    +

    View all releases on GitHub ↗

    + + {% if release.available and release.items.length %} + {% for item in release.items %} +
    +

    {{ item.title }} {{ item.tag }}

    +

    + {% if item.publishedDate %}{% endif %} + {% if item.prerelease %}· Prerelease{% endif %} +

    + {% if item.body %} + {{ item.body | releaseNotes | safe }} + {% else %} +

    Read the notes on GitHub

    + {% endif %} +
    + {% endfor %} + {% else %} +

    Releases will appear here once the first version ships. View all releases on GitHub

    + {% endif %} +
    +
    diff --git a/coverlet.runsettings b/coverlet.runsettings index e432e2ab..f60223ac 100644 --- a/coverlet.runsettings +++ b/coverlet.runsettings @@ -1,5 +1,10 @@ + + + + true + diff --git a/docs/plans/dataprovider-migrate-aot.md b/docs/plans/dataprovider-migrate-aot.md new file mode 100644 index 00000000..eb4af1ea --- /dev/null +++ b/docs/plans/dataprovider-migrate-aot.md @@ -0,0 +1,122 @@ +# Plan: AOT-compile and AOT-test DataProviderMigrate + +Spec group: `[MIG-AOT]`. Make the `DataProviderMigrate` CLI publish as a native +AOT executable and prove it works via tests that run the **native binary** (not +in-process `Program.Main`). + +## Why this is non-trivial + +The CLI's csproj lists only three project references, but the real AOT surface is +the whole transitive graph: + +``` +DataProviderMigrate +└─ Migration.Core ─┬─ YamlDotNet (reflection serialization) ← BLOCKER 1 + ├─ Nimblesite.Lql.Core ─ Nimblesite.Sql.Model ← BLOCKER 2 + ├─ Nimblesite.Lql.Postgres / SQLite / SqlServer + └─ (SqlParserCS, Outcome) + Migration.SQLite ─ Microsoft.Data.Sqlite (native SQLite, AOT-friendly) + Migration.Postgres ─ Npgsql (verify trim/AOT cleanliness) +``` + +## Blockers (empirically confirmed via `dotnet publish -p:PublishAot=true -r osx-arm64`) + +### [MIG-AOT-YAML] YamlDotNet runtime reflection — BLOCKER 1 +`SchemaYamlSerializer` uses `SerializerBuilder`/`DeserializerBuilder`, which reflect +over `SchemaDefinition` at runtime. AOT-incompatible. +- Fix: migrate to YamlDotNet 16.x **static** API — `[YamlStaticContext]` + source + generator + `StaticSerializerBuilder`/`StaticDeserializerBuilder`. +- **OUTCOME: static generator REJECTED.** `Vecc.YamlDotNet.Analyzers.StaticGenerator` + 16.2.1 emits a deserializer that does property *assignment* (`obj.X = v`), which + fails `CS8852` against our immutable `init`-only records — every schema property. + Immutable records are a hard project rule, so the generated path is unusable. +- **DECISION: hand-rolled, reflection-free YAML.** Verified AOT-clean via a probe: + YamlDotNet's `RepresentationModel` DOM (read) and low-level `Emitter` + `Events` + (write) publish with **zero IL warnings** and run natively. The schema format is + already fully hand-encoded by the four converters, so a purpose-built + writer/reader preserves it exactly with no new dependency. New files: + `SchemaYamlWriter.cs` (Emitter), `SchemaYamlReader.cs` (DOM), shared scalar + encode/parse helpers; `SchemaYamlSerializer` keeps its public API. + +### [MIG-AOT-DYNCODE] Expression.Lambda().Compile() — BLOCKER 2 +`IL3050` from `Nimblesite.Sql.Model`: +- `SelectStatementVisitor.cs:470` — `Expression.Lambda(expr).Compile().DynamicInvoke()` +- `SelectStatementLinqExtensions.cs:426` — same pattern +These are constant-folding fallbacks on a narrow LINQ-translation path the migrate +CLI does not exercise. `PredicateBuilder`'s `Expression.Lambda>` only +*builds* trees (no `.Compile()`) — AOT-safe, leave alone. +- **DONE.** Replaced both `.Compile().DynamicInvoke()` sites with a new + `ConstantExpressionEvaluator.TryEvaluate` that walks the expression tree + (constants, captured fields, static members) with no dynamic code. The 63 + `SqlModelCoverageTests` still pass — behavior preserved. + +### [MIG-AOT-JSON] System.Text.Json reflection — BLOCKER (found during publish) +`SchemaSerializer.ToJson/FromJson` used `JsonSerializer.Serialize(T, options)` +(`IL2026` + `IL3050`). The CLI's migrate path never calls it, but it is public API. +- **DONE.** Added a source-generated `SchemaJsonContext` (`[JsonSerializable]`) and + routed through the typed `JsonTypeInfo` overload; the `PortableType` converter is + attached to the context options. Round-trip test passes. + +### [MIG-AOT-EXPORT] export command uses Assembly.LoadFrom — BLOCKER 3 +`Program.ExecuteExport` does `Assembly.LoadFrom` + `GetType` + reflected +property/method invoke. **Fundamentally incompatible** with a self-contained native +binary (cannot load arbitrary external managed DLLs). Decision required: +- Option A (recommended): keep `export` only in the non-AOT (dotnet-tool) build; + compile it out under an `AOT` MSBuild constant so the native binary ships + `migrate` only. The `export` tool remains available as the managed `dotnet tool`. +- Option B: drop `export` from the CLI entirely. +Default to **A** unless the user says otherwise. + +### [MIG-AOT-NPGSQL] Npgsql trim/AOT cleanliness +Npgsql 9 is largely AOT-friendly but may emit trim warnings for type mapping. +Capture and resolve any `IL2xxx`/`IL3xxx` from Npgsql; add a trim feature switch or +runtime directives only if needed. `Microsoft.Data.Sqlite` (native SQLitePCLRaw) is +AOT-friendly. + +## Distribution goal (from the user) + +Ship the migration tool BOTH ways from one source tree: +1. **Managed `dotnet tool`** via NuGet — the existing pack path (`PackAsTool`). + Keeps `export` (reflection is fine in the managed tool). +2. **Native binaries** via **Homebrew tap + Scoop bucket** — per-platform AOT + builds (`migrate` only). This is the `Shipwright` release model already used in + this repo (`shipwright.json`, brew/scoop publish workflows). The `[MIG-AOT-CI]` + native publish feeds those artifacts. + +## Build & test strategy + +- `DataProviderMigrate.csproj`: AOT analyzers always on (`IsAotCompatible`, + `EnableTrimAnalyzer`, `EnableAotAnalyzer`) so `make lint` catches reflection + regressions; `PublishAot`/`InvariantGlobalization`/`AOT` constant turn on only + under a Native AOT publish, leaving the managed `dotnet tool` pack untouched. +- Tests: existing CLI tests call `Program.Main` in-process — they do NOT test AOT. + `NativeAotMigrateSmokeTests` drives the PUBLISHED binary as a **subprocess** + (`ProcessStartInfo`), gated on `DATAPROVIDERMIGRATE_AOT_BIN` (set by the AOT + publish/CI), asserting exit codes + schema integrity + idempotency + the + export-unsupported message. `SkippableFact` skips visibly when the binary is + absent, so `make test` stays fast and CI runs the real native verification. +- `make` target + CI matrix job publishes native per-platform, sets the env var, + runs the smoke tests, then hands the binaries to brew/scoop publishing. + +## TODO + +- [x] **[MIG-AOT-DYNCODE]** `ConstantExpressionEvaluator` replaces both + `.Compile().DynamicInvoke()` sites. 63 SqlModel tests pass; IL3050 gone. +- [x] **[MIG-AOT-JSON]** Source-gen `SchemaJsonContext` + typed `JsonTypeInfo` + overloads. Round-trip test passes. +- [x] **[MIG-AOT-EXPORT]** `export` gated under `#if !AOT` in `Program.Export.cs`; + native build prints unsupported + exits non-zero. Managed tool keeps export. +- [ ] **[MIG-AOT-YAML]** Hand-rolled reflection-free writer (Emitter) + reader (DOM) + replacing the YamlDotNet reflection serializer (static gen rejected — CS8852 on + init-only records). Keep `SchemaYamlSerializer` public API; preserve format so + all YAML tests pass. (workflow `aot-yaml-mapper` in flight) +- [ ] **[MIG-AOT-NPGSQL]** Publish again; resolve any Npgsql/SqlParserCS/Outcome/LQL + (ANTLR) trim or AOT warnings. Clean `dotnet publish -p:PublishAot=true`. +- [x] **[MIG-AOT-CSPROJ]** AOT props + analyzers added; `AOT` constant + Invariant + + pack-off only under PublishAot. (verify managed pack still works post-YAML) +- [x] **[MIG-AOT-TEST]** `NativeAotMigrateSmokeTests` subprocess fixture + + `Xunit.SkippableFact`. (runs once Core compiles + binary published) +- [ ] **[MIG-AOT-CI]** `make` target + CI matrix publish native + run smoke test per + platform; feed binaries to brew tap + scoop bucket (Shipwright). Timeout = fail. +- [ ] **[MIG-AOT-VERIFY]** Final: clean AOT publish (osx-arm64) zero IL warnings, + native `migrate` smoke test passes end to end; managed `dotnet pack` still works. diff --git a/opencode.json b/opencode.json index 0c8379c8..a2113bd0 100644 --- a/opencode.json +++ b/opencode.json @@ -1,5 +1,5 @@ { - "_agent_pmo": "74cf183", + "_agent_pmo": "795a9c2", "$schema": "https://opencode.ai/config.json", "instructions": ["CLAUDE.md"] } diff --git a/shipwright.json b/shipwright.json new file mode 100644 index 00000000..1d12df70 --- /dev/null +++ b/shipwright.json @@ -0,0 +1,48 @@ +{ + "manifestVersion": 1, + "product": { + "id": "dataprovider-lql", + "displayName": "DataProvider LQL Language Support", + "version": "0.0.0-dev" + }, + "supplyChain": { + "provenance": true, + "sbom": true, + "signedChecksums": true, + "signerWorkflow": "Nimblesite/DataProvider/.github/workflows/release.yml@refs/tags/v*" + }, + "components": [ + { + "id": "lql-lsp", + "kind": "lsp", + "language": "rust", + "binaryName": "lql-lsp", + "expectedVersion": "0.0.0-dev", + "platforms": ["darwin-arm64", "darwin-x64", "linux-x64", "win32-x64"], + "sources": ["user-setting", "bundled"], + "required": true, + "githubRelease": { + "signature": true, + "provenance": true, + "sbom": true, + "signerWorkflow": "Nimblesite/DataProvider/.github/workflows/release.yml@refs/tags/v*" + } + }, + { + "id": "dataprovider-migrate", + "kind": "cli", + "language": "dotnet", + "binaryName": "DataProviderMigrate", + "expectedVersion": "0.0.0-dev", + "platforms": ["darwin-arm64", "darwin-x64", "linux-x64", "win32-x64"], + "sources": ["bundled"], + "required": false, + "githubRelease": { + "signature": true, + "provenance": true, + "sbom": true, + "signerWorkflow": "Nimblesite/DataProvider/.github/workflows/release.yml@refs/tags/v*" + } + } + ] +} diff --git a/tools/shipwright-version-stamp.py b/tools/shipwright-version-stamp.py new file mode 100755 index 00000000..a4151cf1 --- /dev/null +++ b/tools/shipwright-version-stamp.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Shipwright build-time version stamper. [SWR-VERSION-BUILD-STAMPING] + +Rewrites every version carrier in the working tree from a release tag, using +structured parsers only (no regex on structured formats). Source stays at the +0.0.0-dev placeholder; the tag-triggered release stamps the runner working tree +and NEVER commits the result. + +Carriers stamped: + - Directory.Build.props (XML, ElementTree) + - Lql/lql-lsp-rust/Cargo.toml [workspace.package].version (TOML, table-aware walk) + - Lql/LqlExtension/package.json version (JSON) + - package.json version (JSON, only if present) + - shipwright.json product.version + every expectedVersion (JSON) + - Lql/LqlExtension/shipwright.json product.version + every expectedVersion (JSON) + +Usage: + shipwright-version-stamp.py --tag v1.2.3 --root . --dry-run # list carriers, change nothing + shipwright-version-stamp.py --tag v1.2.3 --root . # stamp the runner working tree +""" + +from __future__ import annotations + +import argparse +import json +import sys +import tomllib +import xml.etree.ElementTree as ET +from pathlib import Path + + +def parse_version(tag: str) -> str: + """Strip a single leading 'v' from a release tag. [SWR-VERSION-MATCHING]""" + return tag[1:] if tag.startswith("v") else tag + + +def stamp_json(path: Path, version: str, dry_run: bool) -> list[str]: + data = json.loads(path.read_text(encoding="utf-8")) + changed: list[str] = [] + if isinstance(data.get("version"), str): + data["version"] = version + changed.append(f"{path}: version -> {version}") + product = data.get("product") + if isinstance(product, dict) and isinstance(product.get("version"), str): + product["version"] = version + changed.append(f"{path}: product.version -> {version}") + for component in data.get("components", []) or []: + if isinstance(component, dict) and isinstance(component.get("expectedVersion"), str): + component["expectedVersion"] = version + changed.append(f"{path}: components[{component.get('id')}].expectedVersion -> {version}") + if changed and not dry_run: + path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + return changed + + +def stamp_msbuild(path: Path, version: str, dry_run: bool) -> list[str]: + tree = ET.parse(path) + changed: list[str] = [] + for version_el in tree.getroot().iter("Version"): + version_el.text = version + changed.append(f"{path}: -> {version}") + if changed and not dry_run: + tree.write(path, encoding="utf-8", xml_declaration=False) + return changed + + +def stamp_cargo_workspace(path: Path, version: str, dry_run: bool) -> list[str]: + """Rewrite [workspace.package].version with a table-aware line walk. + + tomllib (structured read) validates the file and confirms the key exists; + the write is a single targeted line replacement guarded by the active table, + never a regex over the document. + """ + parsed = tomllib.loads(path.read_text(encoding="utf-8")) + if "version" not in parsed.get("workspace", {}).get("package", {}): + return [] + lines = path.read_text(encoding="utf-8").splitlines(keepends=True) + out: list[str] = [] + active_table: str | None = None + changed: list[str] = [] + for line in lines: + stripped = line.strip() + if stripped.startswith("[") and stripped.endswith("]"): + active_table = stripped[1:-1].strip() + out.append(line) + continue + key = stripped.split("=", 1)[0].strip() if "=" in stripped else "" + if active_table == "workspace.package" and key == "version" and not changed: + eol = "\r\n" if line.endswith("\r\n") else "\n" + out.append(f'version = "{version}"{eol}') + changed.append(f"{path}: [workspace.package].version -> {version}") + continue + out.append(line) + if changed and not dry_run: + path.write_text("".join(out), encoding="utf-8") + return changed + + +def main() -> int: + ap = argparse.ArgumentParser(description="Shipwright version stamper") + ap.add_argument("--tag", required=True, help="Release tag, e.g. v1.2.3") + ap.add_argument("--root", default=".", help="Repo root to stamp") + ap.add_argument("--dry-run", action="store_true", help="List carriers; change nothing") + args = ap.parse_args() + + version = parse_version(args.tag) + root = Path(args.root).resolve() + + carriers: list[tuple[Path, str]] = [ + (root / "Directory.Build.props", "msbuild"), + (root / "Lql" / "lql-lsp-rust" / "Cargo.toml", "cargo"), + (root / "Lql" / "LqlExtension" / "package.json", "json"), + (root / "package.json", "json"), + (root / "shipwright.json", "json"), + (root / "Lql" / "LqlExtension" / "shipwright.json", "json"), + ] + + handlers = {"msbuild": stamp_msbuild, "cargo": stamp_cargo_workspace, "json": stamp_json} + all_changes: list[str] = [] + for path, kind in carriers: + if not path.exists(): + continue + all_changes.extend(handlers[kind](path, version, args.dry_run)) + + mode = "DRY-RUN (no files changed)" if args.dry_run else "STAMPED" + print(f"shipwright-version-stamp {mode}: tag={args.tag} version={version} root={root}") + for change in all_changes: + print(f" {change}") + if not all_changes: + print(" ERROR: no version carriers found — refusing to release silently.", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/validate-manifest.py b/tools/validate-manifest.py new file mode 100755 index 00000000..3d5e080e --- /dev/null +++ b/tools/validate-manifest.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Vendored Shipwright manifest validator. [SWR-GATE-CI] [SWR-VERSION-MANIFEST] + +Structural check of shipwright.json: required keys, canonical platform ids, valid +component kinds and sources. Exits non-zero with a precise message on any violation. +Stands in for `@nimblesite/shipwright-validate-manifest` where npm is not wired. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +CANONICAL_PLATFORMS = frozenset( + {"darwin-arm64", "darwin-x64", "linux-x64", "linux-arm64", "win32-x64", "win32-arm64", "all"} +) +VALID_KINDS = frozenset({"lsp", "mcp", "cli", "sidecar", "helper", "config"}) +VALID_SOURCES = frozenset({"user-setting", "env", "bundled", "github-release"}) +COMPONENT_KEYS = ("id", "kind", "language", "binaryName", "expectedVersion", "platforms", "sources", "required") + + +def validate(path: Path) -> list[str]: + errors: list[str] = [] + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + return [f"{path}: not readable/parseable JSON: {exc}"] + + if data.get("manifestVersion") != 1: + errors.append(f"{path}: manifestVersion must be 1") + product = data.get("product") + if not isinstance(product, dict) or not all(k in product for k in ("id", "displayName", "version")): + errors.append(f"{path}: product must have id, displayName, version") + + components = data.get("components") + if not isinstance(components, list) or not components: + return errors + [f"{path}: components must be a non-empty array"] + + for index, component in enumerate(components): + where = f"{path}: components[{index}]" + if not isinstance(component, dict): + errors.append(f"{where}: must be an object") + continue + for key in COMPONENT_KEYS: + if key not in component: + errors.append(f"{where}: missing required key '{key}'") + for platform in component.get("platforms", []) or []: + if platform not in CANONICAL_PLATFORMS: + errors.append(f"{where}: non-canonical platform '{platform}'") + if component.get("kind") not in VALID_KINDS: + errors.append(f"{where}: invalid kind '{component.get('kind')}'") + for source in component.get("sources", []) or []: + if source not in VALID_SOURCES: + errors.append(f"{where}: invalid source '{source}'") + return errors + + +def main() -> int: + if len(sys.argv) != 2: + print("usage: validate-manifest.py ", file=sys.stderr) + return 2 + errors = validate(Path(sys.argv[1])) + if errors: + for error in errors: + print(f"::error::{error}", file=sys.stderr) + return 1 + print(f"{sys.argv[1]}: valid Shipwright manifest") + return 0 + + +if __name__ == "__main__": + sys.exit(main())